diff --git a/extensions/msteams/src/graph-thread.test.ts b/extensions/msteams/src/graph-thread.test.ts new file mode 100644 index 00000000000..8e1266f44ec --- /dev/null +++ b/extensions/msteams/src/graph-thread.test.ts @@ -0,0 +1,246 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + _teamGroupIdCacheForTest, + fetchChannelMessage, + fetchThreadReplies, + formatThreadContext, + resolveTeamGroupId, + stripHtmlFromTeamsMessage, +} from "./graph-thread.js"; +import { fetchGraphJson } from "./graph.js"; + +vi.mock("./graph.js", () => ({ + fetchGraphJson: vi.fn(), +})); + +describe("stripHtmlFromTeamsMessage", () => { + it("preserves @mention display names from tags", () => { + expect(stripHtmlFromTeamsMessage("Alice hello")).toBe("@Alice hello"); + }); + + it("strips other HTML tags", () => { + expect(stripHtmlFromTeamsMessage("

Hello world

")).toBe("Hello world"); + }); + + it("decodes common HTML entities", () => { + expect(stripHtmlFromTeamsMessage("& <b> "x" 'y'  z")).toBe( + "& \"x\" 'y' z", + ); + }); + + it("normalizes multiple whitespace to single space", () => { + expect(stripHtmlFromTeamsMessage("hello world")).toBe("hello world"); + }); + + it("handles tags with attributes", () => { + expect(stripHtmlFromTeamsMessage('Bob please review')).toBe( + "@Bob please review", + ); + }); + + it("returns empty string for empty input", () => { + expect(stripHtmlFromTeamsMessage("")).toBe(""); + }); +}); + +describe("resolveTeamGroupId", () => { + beforeEach(() => { + vi.mocked(fetchGraphJson).mockReset(); + _teamGroupIdCacheForTest.clear(); + }); + + it("fetches team id from Graph and caches it", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({ id: "group-guid-1" } as never); + + const result = await resolveTeamGroupId("tok", "team-123"); + expect(result).toBe("group-guid-1"); + expect(fetchGraphJson).toHaveBeenCalledWith({ + token: "tok", + path: "/teams/team-123?$select=id", + }); + }); + + it("returns cached value without calling Graph again", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({ id: "group-guid-2" } as never); + + await resolveTeamGroupId("tok", "team-456"); + await resolveTeamGroupId("tok", "team-456"); + + expect(fetchGraphJson).toHaveBeenCalledTimes(1); + }); + + it("falls back to conversationTeamId when Graph returns no id", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never); + + const result = await resolveTeamGroupId("tok", "team-fallback"); + expect(result).toBe("team-fallback"); + }); +}); + +describe("fetchChannelMessage", () => { + beforeEach(() => { + vi.mocked(fetchGraphJson).mockReset(); + }); + + it("fetches the parent message with correct path", async () => { + const mockMsg = { id: "msg-1", body: { content: "hello", contentType: "text" } }; + vi.mocked(fetchGraphJson).mockResolvedValueOnce(mockMsg as never); + + const result = await fetchChannelMessage("tok", "group-1", "channel-1", "msg-1"); + + expect(result).toEqual(mockMsg); + expect(fetchGraphJson).toHaveBeenCalledWith({ + token: "tok", + path: "/teams/group-1/channels/channel-1/messages/msg-1?$select=id,from,body,createdDateTime", + }); + }); + + it("returns undefined on fetch error", async () => { + vi.mocked(fetchGraphJson).mockRejectedValueOnce(new Error("forbidden") as never); + + const result = await fetchChannelMessage("tok", "group-1", "channel-1", "msg-1"); + expect(result).toBeUndefined(); + }); + + it("URL-encodes group, channel, and message IDs", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never); + + await fetchChannelMessage("tok", "g/1", "c/2", "m/3"); + + expect(fetchGraphJson).toHaveBeenCalledWith({ + token: "tok", + path: "/teams/g%2F1/channels/c%2F2/messages/m%2F3?$select=id,from,body,createdDateTime", + }); + }); +}); + +describe("fetchThreadReplies", () => { + beforeEach(() => { + vi.mocked(fetchGraphJson).mockReset(); + }); + + it("fetches replies with correct path and default limit", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({ + value: [{ id: "reply-1" }, { id: "reply-2" }], + } as never); + + const result = await fetchThreadReplies("tok", "group-1", "channel-1", "msg-1"); + + expect(result).toHaveLength(2); + expect(fetchGraphJson).toHaveBeenCalledWith({ + token: "tok", + path: "/teams/group-1/channels/channel-1/messages/msg-1/replies?$top=50&$select=id,from,body,createdDateTime", + }); + }); + + it("clamps limit to 50 maximum", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({ value: [] } as never); + + await fetchThreadReplies("tok", "g", "c", "m", 200); + + const path = vi.mocked(fetchGraphJson).mock.calls[0]?.[0]?.path ?? ""; + expect(path).toContain("$top=50"); + }); + + it("clamps limit to 1 minimum", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({ value: [] } as never); + + await fetchThreadReplies("tok", "g", "c", "m", 0); + + const path = vi.mocked(fetchGraphJson).mock.calls[0]?.[0]?.path ?? ""; + expect(path).toContain("$top=1"); + }); + + it("returns empty array when value is missing", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never); + + const result = await fetchThreadReplies("tok", "g", "c", "m"); + expect(result).toEqual([]); + }); +}); + +describe("formatThreadContext", () => { + it("formats messages as sender: content lines", () => { + const messages = [ + { + id: "m1", + from: { user: { displayName: "Alice" } }, + body: { content: "Hello!", contentType: "text" }, + }, + { + id: "m2", + from: { user: { displayName: "Bob" } }, + body: { content: "World!", contentType: "text" }, + }, + ]; + expect(formatThreadContext(messages)).toBe("Alice: Hello!\nBob: World!"); + }); + + it("skips the current message by id", () => { + const messages = [ + { + id: "m1", + from: { user: { displayName: "Alice" } }, + body: { content: "Hello!", contentType: "text" }, + }, + { + id: "m2", + from: { user: { displayName: "Bob" } }, + body: { content: "Current", contentType: "text" }, + }, + ]; + expect(formatThreadContext(messages, "m2")).toBe("Alice: Hello!"); + }); + + it("strips HTML from html contentType messages", () => { + const messages = [ + { + id: "m1", + from: { user: { displayName: "Carol" } }, + body: { content: "

Hello world

", contentType: "html" }, + }, + ]; + expect(formatThreadContext(messages)).toBe("Carol: Hello world"); + }); + + it("uses application displayName when user is absent", () => { + const messages = [ + { + id: "m1", + from: { application: { displayName: "BotApp" } }, + body: { content: "automated msg", contentType: "text" }, + }, + ]; + expect(formatThreadContext(messages)).toBe("BotApp: automated msg"); + }); + + it("skips messages with empty content", () => { + const messages = [ + { + id: "m1", + from: { user: { displayName: "Alice" } }, + body: { content: "", contentType: "text" }, + }, + { + id: "m2", + from: { user: { displayName: "Bob" } }, + body: { content: "actual content", contentType: "text" }, + }, + ]; + expect(formatThreadContext(messages)).toBe("Bob: actual content"); + }); + + it("falls back to 'unknown' sender when from is missing", () => { + const messages = [ + { + id: "m1", + body: { content: "orphan msg", contentType: "text" }, + }, + ]; + expect(formatThreadContext(messages)).toBe("unknown: orphan msg"); + }); + + it("returns empty string for empty messages array", () => { + expect(formatThreadContext([])).toBe(""); + }); +}); diff --git a/extensions/msteams/src/graph-thread.ts b/extensions/msteams/src/graph-thread.ts new file mode 100644 index 00000000000..258f37cebde --- /dev/null +++ b/extensions/msteams/src/graph-thread.ts @@ -0,0 +1,142 @@ +import { fetchGraphJson, type GraphResponse } from "./graph.js"; + +export type GraphThreadMessage = { + id?: string; + from?: { + user?: { displayName?: string; id?: string }; + application?: { displayName?: string; id?: string }; + }; + body?: { content?: string; contentType?: string }; + createdDateTime?: string; +}; + +// TTL cache for team ID -> group GUID mapping. +const teamGroupIdCache = new Map(); +const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +/** + * Strip HTML tags from Teams message content, preserving @mention display names. + * Teams wraps mentions in Name tags. + */ +export function stripHtmlFromTeamsMessage(html: string): string { + // Preserve mention display names by replacing Name with @Name. + let text = html.replace(/]*>(.*?)<\/at>/gi, "@$1"); + // Strip remaining HTML tags. + text = text.replace(/<[^>]*>/g, " "); + // Decode common HTML entities. + text = text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, " "); + // Normalize whitespace. + return text.replace(/\s+/g, " ").trim(); +} + +/** + * Resolve the Azure AD group GUID for a Teams conversation team ID. + * Results are cached with a TTL to avoid repeated Graph API calls. + */ +export async function resolveTeamGroupId( + token: string, + conversationTeamId: string, +): Promise { + const cached = teamGroupIdCache.get(conversationTeamId); + if (cached && cached.expiresAt > Date.now()) { + return cached.groupId; + } + + // The team ID in channelData is typically the group ID itself for standard teams. + // Validate by fetching /teams/{id} and returning the confirmed id. + // Requires Team.ReadBasic.All permission; fall back to raw ID if missing. + try { + const path = `/teams/${encodeURIComponent(conversationTeamId)}?$select=id`; + const team = await fetchGraphJson<{ id?: string }>({ token, path }); + const groupId = team.id ?? conversationTeamId; + + // Only cache when the Graph lookup succeeds — caching a fallback raw ID + // can cause silent failures for the entire TTL if the ID is not a valid + // Graph team GUID (e.g. Bot Framework conversation key). + teamGroupIdCache.set(conversationTeamId, { + groupId, + expiresAt: Date.now() + CACHE_TTL_MS, + }); + + return groupId; + } catch { + // Fallback to raw team ID without caching so subsequent calls retry the + // Graph lookup instead of using a potentially invalid cached value. + return conversationTeamId; + } +} + +/** + * Fetch a single channel message (the parent/root of a thread). + * Returns undefined on error so callers can degrade gracefully. + */ +export async function fetchChannelMessage( + token: string, + groupId: string, + channelId: string, + messageId: string, +): Promise { + const path = `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}?$select=id,from,body,createdDateTime`; + try { + return await fetchGraphJson({ token, path }); + } catch { + return undefined; + } +} + +/** + * Fetch thread replies for a channel message, ordered chronologically. + * + * **Limitation:** The Graph API replies endpoint (`/messages/{id}/replies`) does not + * support `$orderby`, so results are always returned in ascending (oldest-first) order. + * Combined with the `$top` cap of 50, this means only the **oldest 50 replies** are + * returned for long threads — newer replies are silently omitted. There is currently no + * Graph API workaround for this; pagination via `@odata.nextLink` can retrieve more + * replies but still in ascending order only. + */ +export async function fetchThreadReplies( + token: string, + groupId: string, + channelId: string, + messageId: string, + limit = 50, +): Promise { + const top = Math.min(Math.max(limit, 1), 50); + // NOTE: Graph replies endpoint returns oldest-first and does not support $orderby. + // For threads with >50 replies, only the oldest 50 are returned. The most recent + // replies (often the most relevant context) may be truncated. + const path = `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/replies?$top=${top}&$select=id,from,body,createdDateTime`; + const res = await fetchGraphJson>({ token, path }); + return res.value ?? []; +} + +/** + * Format thread messages into a context string for the agent. + * Skips the current message (by id) and blank messages. + */ +export function formatThreadContext( + messages: GraphThreadMessage[], + currentMessageId?: string, +): string { + const lines: string[] = []; + for (const msg of messages) { + if (msg.id && msg.id === currentMessageId) continue; // Skip the triggering message. + const sender = msg.from?.user?.displayName ?? msg.from?.application?.displayName ?? "unknown"; + const contentType = msg.body?.contentType ?? "text"; + const rawContent = msg.body?.content ?? ""; + const content = + contentType === "html" ? stripHtmlFromTeamsMessage(rawContent) : rawContent.trim(); + if (!content) continue; + lines.push(`${sender}: ${content}`); + } + return lines.join("\n"); +} + +// Exported for testing only. +export { teamGroupIdCache as _teamGroupIdCacheForTest }; diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 0ad5053f91e..badac50c118 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -28,6 +28,12 @@ import { } from "../attachments.js"; import type { StoredConversationReference } from "../conversation-store.js"; import { formatUnknownError } from "../errors.js"; +import { + fetchChannelMessage, + fetchThreadReplies, + formatThreadContext, + resolveTeamGroupId, +} from "../graph-thread.js"; import { extractMSTeamsConversationMessageId, extractMSTeamsQuoteInfo, @@ -474,6 +480,29 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }); const mediaPayload = buildMSTeamsMediaPayload(mediaList); + + // Fetch thread history when the message is a reply inside a Teams channel thread. + // This is a best-effort enhancement; errors are logged and do not block the reply. + let threadContext: string | undefined; + if (activity.replyToId && isChannel && teamId) { + try { + const graphToken = await tokenProvider.getAccessToken("https://graph.microsoft.com"); + const groupId = await resolveTeamGroupId(graphToken, teamId); + const [parentMsg, replies] = await Promise.all([ + fetchChannelMessage(graphToken, groupId, conversationId, activity.replyToId), + fetchThreadReplies(graphToken, groupId, conversationId, activity.replyToId), + ]); + const allMessages = parentMsg ? [parentMsg, ...replies] : replies; + const formatted = formatThreadContext(allMessages, activity.id); + if (formatted) { + threadContext = formatted; + } + } catch (err) { + log.debug?.("failed to fetch thread history", { error: String(err) }); + // Graceful degradation: thread history is an optional enhancement. + } + } + const envelopeFrom = isDirectMessage ? senderName : conversationType; const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({ cfg, @@ -518,9 +547,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { : undefined; const commandBody = text.trim(); + // Prepend thread history to the agent body so the agent has full thread context. + const bodyForAgent = threadContext + ? `[Thread history]\n${threadContext}\n[/Thread history]\n\n${rawBody}` + : rawBody; + const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: combinedBody, - BodyForAgent: rawBody, + BodyForAgent: bodyForAgent, InboundHistory: inboundHistory, RawBody: rawBody, CommandBody: commandBody,