diff --git a/extensions/msteams/src/graph-thread.test.ts b/extensions/msteams/src/graph-thread.test.ts index 77164b1c4697..d936d01546fa 100644 --- a/extensions/msteams/src/graph-thread.test.ts +++ b/extensions/msteams/src/graph-thread.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { _teamGroupIdCacheForTest, fetchChannelMessage, + fetchChatMessageText, fetchThreadReplies, formatThreadContext, resolveTeamGroupId, @@ -161,6 +162,53 @@ describe("fetchChannelMessage", () => { }); }); +describe("fetchChatMessageText", () => { + beforeEach(() => { + vi.mocked(fetchGraphJson).mockReset(); + }); + + it("fetches the chat message and strips HTML body to plain text", async () => { + vi.mocked(fetchGraphJson).mockResolvedValueOnce({ + id: "1783379480258", + body: { + content: "
San Francisco right now:
' + + 'Frank' + + '", + }, + ]); + expect(result?.body).toBe("truncated snippet…"); + expect(result?.sender).toBe("Frank"); + }); + + it("prefers 'copy' over 'preview' when both are present", () => { + const result = extractMSTeamsQuoteInfo([ + { + contentType: "text/html", + content: + 'truncated snippet…
' + + "
' + + 'Grace' + + '", + }, + ]); + expect(result?.body).toBe("the full text"); + }); + + it("captures the blockquote itemid as the quoted message id", () => { + const result = extractMSTeamsQuoteInfo([ + { + contentType: "text/html", + content: + 'short…
' + + 'the full text
' + + "
' + + 'Heidi' + + '", + }, + ]); + expect(result).toEqual({ + sender: "Heidi", + body: "San Francisco right now…", + id: "1783379480258", + }); + }); + + it("parses a real Teams quote-reply payload (preview + itemid)", () => { + const result = extractMSTeamsQuoteInfo([ + { + contentType: "text/html", + content: + 'San Francisco right now…
' + + "
' + + 'Display Name' + + '' + + '\nSan Francisco right now ... Today\'s range: 54-64 °F (avg…
' + + "
what abt not?
", + }, + ]); + expect(result).toEqual({ + sender: "Display Name", + body: "San Francisco right now ... Today's range: 54-64 °F (avg…", + id: "1783379480258", + }); + }); }); }); diff --git a/extensions/msteams/src/inbound.ts b/extensions/msteams/src/inbound.ts index 4eb2312a6332..c7aed94133ad 100644 --- a/extensions/msteams/src/inbound.ts +++ b/extensions/msteams/src/inbound.ts @@ -2,6 +2,12 @@ type MSTeamsQuoteInfo = { sender: string; body: string; + /** + * The quoted message's Teams id (the blockquote `itemid`). Present when Teams + * includes it; used to fetch the complete message text via Graph because the + * inbound blockquote only carries a truncated `preview` snippet. + */ + id?: string; }; /** @@ -64,12 +70,22 @@ export function extractMSTeamsQuoteInfo( const senderMatch = /]*itemprop=["']mri["'][^>]*>(.*?)<\/strong>/i.exec(content); const sender = senderMatch?.[1] ? htmlToPlainText(senderMatch[1]) : undefined; - // Extract body from. - const bodyMatch = /
]*itemprop=["']copy["'][^>]*>(.*?)<\/p>/is.exec(content); + // Extract body from
(full quoted text) and fall back to + //
— the truncated snippet Teams actually sends for + // quote replies. Prefer `copy` when both are present. + const copyMatch = /
]*itemprop=["']copy["'][^>]*>(.*?)<\/p>/is.exec(content); + const bodyMatch = + copyMatch ?? /
]*itemprop=["']preview["'][^>]*>(.*?)<\/p>/is.exec(content); const body = bodyMatch?.[1] ? htmlToPlainText(bodyMatch[1]) : undefined; + // Capture the blockquote `itemid` (the quoted message's Teams id) so callers + // can fetch the complete message text via Graph when only a preview snippet + // is available. + const idMatch = /
]*\bitemid=["']([^"']+)["'][^>]*>/is.exec(content); + const id = idMatch?.[1]?.trim() || undefined; + if (body) { - return { sender: sender ?? "unknown", body }; + return { sender: sender ?? "unknown", body, ...(id ? { id } : {}) }; } } return undefined; diff --git a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts index 6bf510180a07..b5c0301a6902 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts @@ -39,6 +39,9 @@ const graphThreadMockState = vi.hoisted(() => ({ limit?: number, ) => Promise>(async () => []), + fetchChatMessageText: vi.fn< + (token: string, chatId: string, messageId: string) => Promise + >(async () => undefined), })); vi.mock("../graph-thread.js", () => { @@ -78,6 +81,7 @@ vi.mock("../graph-thread.js", () => { resolveTeamGroupId: graphThreadMockState.resolveTeamGroupId, fetchChannelMessage: graphThreadMockState.fetchChannelMessage, fetchThreadReplies: graphThreadMockState.fetchThreadReplies, + fetchChatMessageText: graphThreadMockState.fetchChatMessageText, }; }); @@ -120,6 +124,7 @@ describe("msteams monitor handler authz", () => { graphThreadMockState.resolveTeamGroupId.mockClear(); graphThreadMockState.fetchChannelMessage.mockReset(); graphThreadMockState.fetchThreadReplies.mockReset(); + graphThreadMockState.fetchChatMessageText.mockClear(); // Parent-context LRU + per-session dedupe are module-level; clear between // cases so stale parent fetches from earlier tests don't bleed in. resetThreadParentContextCachesForTest(); @@ -991,4 +996,76 @@ describe("msteams monitor handler authz", () => { expect(ctx.SupplementalContext).toEqual({}); expect(ctx.BodyForAgent).toBe("Current message"); }); + + it("does not fetch full quote text via Graph for group-chat quote replies", async () => { + resetThreadMocks(); + const { deps } = createDeps({ + channels: { msteams: { groupPolicy: "open", requireMention: false } }, + } as OpenClawConfig); + const handler = createMSTeamsMessageHandler(deps); + await handler( + createMessageActivity({ + id: "grp-quote-1", + text: "what about this?", + from: { id: "attacker-id", aadObjectId: "attacker-aad", name: "Attacker" }, + conversation: { id: "19:group@thread.tacv2", conversationType: "groupChat" }, + attachments: [ + { + contentType: "text/html", + content: + ' ' + + 'Victim' + + '', + }, + ], + }), + ); + + // The group quote IS surfaced from the inbound preview (fix 1), proving the + // quote path ran — but the app-only Graph full-text fetch must NOT fire in a + // group chat: the fetched body would bypass the supplemental-quote visibility + // allowlist. Only 1:1 DMs may fetch full text. + const ctx = recordFromMockCall(firstSettledDispatch().ctxPayload); + expect(ctx.SupplementalContext).toMatchObject({ quote: { body: "secret snippet…" } }); + expect(graphThreadMockState.fetchChatMessageText).not.toHaveBeenCalled(); + }); + + it("replaces a DM quote preview with the complete Graph message", async () => { + resetThreadMocks(); + graphThreadMockState.fetchChatMessageText.mockResolvedValueOnce("complete quoted message"); + const { deps } = createDeps({ + channels: { msteams: { dmPolicy: "open", allowFrom: ["*"] } }, + } as OpenClawConfig); + const handler = createMSTeamsMessageHandler(deps); + + await handler( + createMessageActivity({ + id: "dm-quote-1", + text: "what about this?", + from: { id: "user-id", aadObjectId: "user-aad", name: "User" }, + conversation: { id: "19:dm@thread.v2", conversationType: "personal" }, + attachments: [ + { + contentType: "text/html", + content: + 'secret snippet…
' + + 'Bot' + + '', + }, + ], + }), + ); + + expect(deps.tokenProvider.getAccessToken).toHaveBeenCalledWith("https://graph.microsoft.com"); + expect(graphThreadMockState.fetchChatMessageText).toHaveBeenCalledWith( + "token", + "19:dm@thread.v2", + "message-1", + ); + expect(recordFromMockCall(firstSettledDispatch().ctxPayload).SupplementalContext).toMatchObject( + { + quote: { id: "message-1", body: "complete quoted message", sender: "Bot" }, + }, + ); + }); }); diff --git a/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts b/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts index c0f5ecc063df..45f8852eec09 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts @@ -14,6 +14,7 @@ import { const runtimeApiMockState = getRuntimeApiMockState(); const fetchChannelMessageMock = vi.hoisted(() => vi.fn()); const fetchThreadRepliesMock = vi.hoisted(() => vi.fn(async () => [])); +const fetchChatMessageTextMock = vi.hoisted(() => vi.fn(async () => undefined)); const resolveTeamGroupIdMock = vi.hoisted(() => vi.fn(async () => "group-1")); vi.mock("../graph-thread.js", () => { @@ -34,6 +35,7 @@ vi.mock("../graph-thread.js", () => { resolveTeamGroupId: resolveTeamGroupIdMock, fetchChannelMessage: fetchChannelMessageMock, fetchThreadReplies: fetchThreadRepliesMock, + fetchChatMessageText: fetchChatMessageTextMock, }; }); diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index c015a0e2b8c9..6728731c9d35 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -33,6 +33,7 @@ import { tryNormalizeBotFrameworkServiceUrl } from "../bot-framework-service-url import type { StoredConversationReference } from "../conversation-store.js"; import { formatUnknownError } from "../errors.js"; import { + fetchChatMessageText, fetchThreadReplies, formatThreadContext, resolveTeamGroupId, @@ -589,6 +590,27 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { } } + // The inbound Teams blockquote only carries a truncated `preview` snippet for + // quote replies. When we have the quoted message id, fetch the complete text + // via the app-only `GET /chats/{chatId}/messages/{id}` endpoint (allowed with + // Chat.Read.All). Restricted to 1:1 DMs on purpose: in a group chat an + // allowlisted sender could quote a non-allowlisted member, and the fetched + // full body would bypass the supplemental-quote visibility allowlist applied + // below. DMs have only two participants, so there is no third-party exposure. + // Group/channel quotes keep the (now-surfaced) truncated preview from fix 1. + // Any failure degrades to that preview, so message handling never breaks. + let quoteBodyFull: string | undefined; + if (quoteInfo?.id && isDirectMessage && graphConversationId.startsWith("19:")) { + try { + const graphToken = await tokenProvider.getAccessToken("https://graph.microsoft.com"); + quoteBodyFull = await fetchChatMessageText(graphToken, graphConversationId, quoteInfo.id); + } catch (err) { + log.debug?.("failed to fetch full quoted message text", { + error: formatUnknownError(err), + }); + } + } + const mediaList = await resolveMSTeamsInboundMedia({ attachments, htmlSummary: htmlSummary ?? undefined, @@ -776,8 +798,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { supplemental: { quote: quoteInfo ? { - id: activity.replyToId ?? undefined, - body: quoteInfo.body, + id: quoteInfo.id ?? activity.replyToId ?? undefined, + body: quoteBodyFull ?? quoteInfo.body, sender: quoteInfo.sender, senderAllowed: quoteSenderAllowed, isQuote: true,truncated preview…