diff --git a/extensions/msteams/src/mentions.test.ts b/extensions/msteams/src/mentions.test.ts new file mode 100644 index 000000000..bfd66873e --- /dev/null +++ b/extensions/msteams/src/mentions.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; +import { buildMentionEntities, formatMentionText, parseMentions } from "./mentions.js"; + +describe("parseMentions", () => { + it("parses single mention", () => { + const result = parseMentions("Hello @[John Doe](28:a1b2c3-d4e5f6)!"); + + expect(result.text).toBe("Hello John Doe!"); + expect(result.entities).toHaveLength(1); + expect(result.entities[0]).toEqual({ + type: "mention", + text: "John Doe", + mentioned: { + id: "28:a1b2c3-d4e5f6", + name: "John Doe", + }, + }); + }); + + it("parses multiple mentions", () => { + const result = parseMentions("Hey @[Alice](28:aaa) and @[Bob](28:bbb), can you review this?"); + + expect(result.text).toBe("Hey Alice and Bob, can you review this?"); + expect(result.entities).toHaveLength(2); + expect(result.entities[0]).toEqual({ + type: "mention", + text: "Alice", + mentioned: { + id: "28:aaa", + name: "Alice", + }, + }); + expect(result.entities[1]).toEqual({ + type: "mention", + text: "Bob", + mentioned: { + id: "28:bbb", + name: "Bob", + }, + }); + }); + + it("handles text without mentions", () => { + const result = parseMentions("Hello world!"); + + expect(result.text).toBe("Hello world!"); + expect(result.entities).toHaveLength(0); + }); + + it("handles empty text", () => { + const result = parseMentions(""); + + expect(result.text).toBe(""); + expect(result.entities).toHaveLength(0); + }); + + it("handles mention with spaces in name", () => { + const result = parseMentions("@[John Peter Smith](28:a1b2c3)"); + + expect(result.text).toBe("John Peter Smith"); + expect(result.entities[0]?.mentioned.name).toBe("John Peter Smith"); + }); + + it("trims whitespace from id and name", () => { + const result = parseMentions("@[ John Doe ]( 28:a1b2c3 )"); + + expect(result.entities[0]).toEqual({ + type: "mention", + text: "John Doe", + mentioned: { + id: "28:a1b2c3", + name: "John Doe", + }, + }); + }); + + it("handles Japanese characters in mention at start of message", () => { + const input = "@[タナカ タロウ](a1b2c3d4-e5f6-7890-abcd-ef1234567890) スキル化完了しました!"; + const result = parseMentions(input); + + expect(result.text).toBe("タナカ タロウ スキル化完了しました!"); + expect(result.entities).toHaveLength(1); + expect(result.entities[0]).toEqual({ + type: "mention", + text: "タナカ タロウ", + mentioned: { + id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + name: "タナカ タロウ", + }, + }); + + // Verify entity text exactly matches what's in the formatted text + const entityText = result.entities[0]?.text; + expect(result.text).toContain(entityText); + expect(result.text.indexOf(entityText)).toBe(0); + }); + + it("skips mention-like patterns with non-Teams IDs (e.g. in code blocks)", () => { + // This reproduces the actual failing payload: the message contains a real mention + // plus `@[表示名](ユーザーID)` as documentation text inside backticks. + const input = + "@[タナカ タロウ](a1b2c3d4-e5f6-7890-abcd-ef1234567890) スキル化完了しました!📋\n\n" + + "**作成したスキル:** `teams-mention`\n" + + "- 機能: Teamsでのメンション形式 `@[表示名](ユーザーID)`\n\n" + + "**追加対応:**\n" + + "- ユーザーのID `a1b2c3d4-e5f6-7890-abcd-ef1234567890` を登録済み"; + const result = parseMentions(input); + + // Only the real mention should be parsed; the documentation example should be left as-is + expect(result.entities).toHaveLength(1); + expect(result.entities[0]?.mentioned.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + expect(result.entities[0]?.mentioned.name).toBe("タナカ タロウ"); + + // The documentation pattern must remain untouched in the text + expect(result.text).toContain("`@[表示名](ユーザーID)`"); + }); + + it("accepts Bot Framework IDs (28:xxx)", () => { + const result = parseMentions("@[Bot](28:abc-123)"); + expect(result.entities).toHaveLength(1); + expect(result.entities[0]?.mentioned.id).toBe("28:abc-123"); + }); + + it("accepts AAD object IDs (UUIDs)", () => { + const result = parseMentions("@[User](a1b2c3d4-e5f6-7890-abcd-ef1234567890)"); + expect(result.entities).toHaveLength(1); + expect(result.entities[0]?.mentioned.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + }); + + it("rejects non-ID strings as mention targets", () => { + const result = parseMentions("See @[docs](https://example.com) for details"); + expect(result.entities).toHaveLength(0); + // Original text preserved + expect(result.text).toBe("See @[docs](https://example.com) for details"); + }); +}); + +describe("buildMentionEntities", () => { + it("builds entities from mention info", () => { + const mentions = [ + { id: "28:aaa", name: "Alice" }, + { id: "28:bbb", name: "Bob" }, + ]; + + const entities = buildMentionEntities(mentions); + + expect(entities).toHaveLength(2); + expect(entities[0]).toEqual({ + type: "mention", + text: "Alice", + mentioned: { + id: "28:aaa", + name: "Alice", + }, + }); + expect(entities[1]).toEqual({ + type: "mention", + text: "Bob", + mentioned: { + id: "28:bbb", + name: "Bob", + }, + }); + }); + + it("handles empty list", () => { + const entities = buildMentionEntities([]); + expect(entities).toHaveLength(0); + }); +}); + +describe("formatMentionText", () => { + it("formats text with single mention", () => { + const text = "Hello @John!"; + const mentions = [{ id: "28:xxx", name: "John" }]; + + const result = formatMentionText(text, mentions); + + expect(result).toBe("Hello John!"); + }); + + it("formats text with multiple mentions", () => { + const text = "Hey @Alice and @Bob"; + const mentions = [ + { id: "28:aaa", name: "Alice" }, + { id: "28:bbb", name: "Bob" }, + ]; + + const result = formatMentionText(text, mentions); + + expect(result).toBe("Hey Alice and Bob"); + }); + + it("handles case-insensitive matching", () => { + const text = "Hey @alice and @ALICE"; + const mentions = [{ id: "28:aaa", name: "Alice" }]; + + const result = formatMentionText(text, mentions); + + expect(result).toBe("Hey Alice and Alice"); + }); + + it("handles text without mentions", () => { + const text = "Hello world"; + const mentions = [{ id: "28:xxx", name: "John" }]; + + const result = formatMentionText(text, mentions); + + expect(result).toBe("Hello world"); + }); +}); diff --git a/extensions/msteams/src/mentions.ts b/extensions/msteams/src/mentions.ts new file mode 100644 index 000000000..7ff3a3578 --- /dev/null +++ b/extensions/msteams/src/mentions.ts @@ -0,0 +1,113 @@ +/** + * MS Teams mention handling utilities. + * + * Mentions in Teams require: + * 1. Text containing Name tags + * 2. entities array with mention metadata + */ + +export type MentionEntity = { + type: "mention"; + text: string; + mentioned: { + id: string; + name: string; + }; +}; + +export type MentionInfo = { + /** User/bot ID (e.g., "28:xxx" or AAD object ID) */ + id: string; + /** Display name */ + name: string; +}; + +/** + * Check whether an ID looks like a valid Teams user/bot identifier. + * Accepts: + * - Bot Framework IDs: "28:xxx..." or "29:xxx..." + * - AAD object IDs (UUIDs): "d5318c29-33ac-4e6b-bd42-57b8b793908f" + * + * This prevents false positives from text like `@[表示名](ユーザーID)` + * that appears in code snippets or documentation within messages. + */ +const TEAMS_ID_PATTERN = + /^(?:\d+:[a-f0-9-]+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i; + +function isValidTeamsId(id: string): boolean { + return TEAMS_ID_PATTERN.test(id); +} + +/** + * Parse mentions from text in the format @[Name](id). + * Example: "Hello @[John Doe](28:xxx-yyy-zzz)!" + * + * Only matches where the id looks like a real Teams user/bot ID are treated + * as mentions. This avoids false positives from documentation or code samples + * embedded in the message (e.g. `@[表示名](ユーザーID)` in backticks). + * + * Returns both the formatted text with tags and the entities array. + */ +export function parseMentions(text: string): { + text: string; + entities: MentionEntity[]; +} { + const mentionPattern = /@\[([^\]]+)\]\(([^)]+)\)/g; + const entities: MentionEntity[] = []; + + // Replace @[Name](id) with Name only for valid Teams IDs + const formattedText = text.replace(mentionPattern, (match, name, id) => { + const trimmedId = id.trim(); + + // Skip matches where the id doesn't look like a real Teams identifier + if (!isValidTeamsId(trimmedId)) { + return match; + } + + const trimmedName = name.trim(); + const mentionTag = `${trimmedName}`; + entities.push({ + type: "mention", + text: mentionTag, + mentioned: { + id: trimmedId, + name: trimmedName, + }, + }); + return mentionTag; + }); + + return { + text: formattedText, + entities, + }; +} + +/** + * Build mention entities array from a list of mentions. + * Use this when you already have the mention info and formatted text. + */ +export function buildMentionEntities(mentions: MentionInfo[]): MentionEntity[] { + return mentions.map((mention) => ({ + type: "mention", + text: `${mention.name}`, + mentioned: { + id: mention.id, + name: mention.name, + }, + })); +} + +/** + * Format text with mentions using tags. + * This is a convenience function when you want to manually format mentions. + */ +export function formatMentionText(text: string, mentions: MentionInfo[]): string { + let formatted = text; + for (const mention of mentions) { + // Replace @Name or @name with Name + const namePattern = new RegExp(`@${mention.name}`, "gi"); + formatted = formatted.replace(namePattern, `${mention.name}`); + } + return formatted; +} diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index 11b04db8e..e29c620b2 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -19,6 +19,7 @@ import { uploadAndShareSharePoint, } from "./graph-upload.js"; import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js"; +import { parseMentions } from "./mentions.js"; import { getMSTeamsRuntime } from "./runtime.js"; /** @@ -269,7 +270,14 @@ async function buildActivity( const activity: Record = { type: "message" }; if (msg.text) { - activity.text = msg.text; + // Parse mentions from text (format: @[Name](id)) + const { text: formattedText, entities } = parseMentions(msg.text); + activity.text = formattedText; + + // Add mention entities if any mentions were found + if (entities.length > 0) { + activity.entities = entities; + } } if (msg.mediaUrl) {