From bd49117a503cfcfe246c3a45242afe7ebd76fca3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 00:04:21 +0100 Subject: [PATCH] perf: slim msteams hot test imports --- extensions/msteams/src/errors.ts | 4 +- extensions/msteams/src/file-consent-invoke.ts | 150 +++++++++++++++++ extensions/msteams/src/file-consent.ts | 5 +- .../src/monitor-handler.file-consent.test.ts | 81 +++------- extensions/msteams/src/monitor-handler.ts | 153 +----------------- .../message-handler.thread-parent.test.ts | 17 +- .../message-handler.thread-session.test.ts | 151 ++++++----------- .../src/monitor-handler/message-handler.ts | 13 +- .../src/monitor-handler/thread-session.ts | 17 ++ 9 files changed, 267 insertions(+), 324 deletions(-) create mode 100644 extensions/msteams/src/file-consent-invoke.ts create mode 100644 extensions/msteams/src/monitor-handler/thread-session.ts diff --git a/extensions/msteams/src/errors.ts b/extensions/msteams/src/errors.ts index c558690521d..47a223daf2d 100644 --- a/extensions/msteams/src/errors.ts +++ b/extensions/msteams/src/errors.ts @@ -1,4 +1,6 @@ -import { isRecord } from "./attachments/shared.js"; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} export function formatUnknownError(err: unknown): string { if (err instanceof Error) { diff --git a/extensions/msteams/src/file-consent-invoke.ts b/extensions/msteams/src/file-consent-invoke.ts new file mode 100644 index 00000000000..e00fa1411a2 --- /dev/null +++ b/extensions/msteams/src/file-consent-invoke.ts @@ -0,0 +1,150 @@ +import { formatUnknownError } from "./errors.js"; +import { buildFileInfoCard, parseFileConsentInvoke, uploadToConsentUrl } from "./file-consent.js"; +import { normalizeMSTeamsConversationId } from "./inbound.js"; +import type { MSTeamsMonitorLogger } from "./monitor-types.js"; +import { getPendingUploadFs, removePendingUploadFs } from "./pending-uploads-fs.js"; +import { getPendingUpload, removePendingUpload } from "./pending-uploads.js"; +import { withRevokedProxyFallback } from "./revoked-context.js"; +import type { MSTeamsTurnContext } from "./sdk-types.js"; + +/** + * Handle fileConsent/invoke activities for large file uploads. + */ +export async function handleMSTeamsFileConsentInvoke( + context: MSTeamsTurnContext, + log: MSTeamsMonitorLogger, +): Promise { + const expiredUploadMessage = + "The file upload request has expired. Please try sending the file again."; + const activity = context.activity; + if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") { + return false; + } + + const consentResponse = parseFileConsentInvoke(activity); + if (!consentResponse) { + log.debug?.("invalid file consent invoke", { value: activity.value }); + return false; + } + + const uploadId = + typeof consentResponse.context?.uploadId === "string" + ? consentResponse.context.uploadId + : undefined; + // Prefer the in-memory store (same-process reply path); fall back to the + // FS-backed store so CLI `message send --media` flows work even when the + // invoke callback is delivered to a different process. + const inMemoryFile = getPendingUpload(uploadId); + const fsFile = inMemoryFile ? undefined : await getPendingUploadFs(uploadId); + const pendingFile: + | { + buffer: Buffer; + filename: string; + contentType?: string; + conversationId: string; + consentCardActivityId?: string; + } + | undefined = inMemoryFile ?? fsFile; + if (pendingFile) { + const pendingConversationId = normalizeMSTeamsConversationId(pendingFile.conversationId); + const invokeConversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? ""); + if (!invokeConversationId || pendingConversationId !== invokeConversationId) { + log.info("file consent conversation mismatch", { + uploadId, + expectedConversationId: pendingConversationId, + receivedConversationId: invokeConversationId || undefined, + }); + if (consentResponse.action === "accept") { + await context.sendActivity(expiredUploadMessage); + } + return true; + } + } + + if (consentResponse.action === "accept" && consentResponse.uploadInfo) { + if (pendingFile) { + log.debug?.("user accepted file consent, uploading", { + uploadId, + filename: pendingFile.filename, + size: pendingFile.buffer.length, + }); + + try { + await uploadToConsentUrl({ + url: consentResponse.uploadInfo.uploadUrl, + buffer: pendingFile.buffer, + contentType: pendingFile.contentType, + }); + + const fileInfoCard = buildFileInfoCard({ + filename: consentResponse.uploadInfo.name, + contentUrl: consentResponse.uploadInfo.contentUrl, + uniqueId: consentResponse.uploadInfo.uniqueId, + fileType: consentResponse.uploadInfo.fileType, + }); + + if (!pendingFile.consentCardActivityId) { + await context.sendActivity({ + type: "message", + attachments: [fileInfoCard], + }); + } + + if (pendingFile.consentCardActivityId) { + try { + await context.updateActivity({ + id: pendingFile.consentCardActivityId, + type: "message", + attachments: [fileInfoCard], + }); + } catch { + await context.sendActivity({ + type: "message", + attachments: [fileInfoCard], + }); + } + } + + log.info("file upload complete", { + uploadId, + filename: consentResponse.uploadInfo.name, + uniqueId: consentResponse.uploadInfo.uniqueId, + }); + } catch (err) { + log.error("file upload failed", { uploadId, error: formatUnknownError(err) }); + await context.sendActivity("File upload failed. Please try again."); + } finally { + removePendingUpload(uploadId); + await removePendingUploadFs(uploadId); + } + } else { + log.debug?.("pending file not found for consent", { uploadId }); + await context.sendActivity(expiredUploadMessage); + } + } else { + log.debug?.("user declined file consent", { uploadId }); + removePendingUpload(uploadId); + await removePendingUploadFs(uploadId); + } + + return true; +} + +export async function respondToMSTeamsFileConsentInvoke( + context: MSTeamsTurnContext, + log: MSTeamsMonitorLogger, +): Promise { + await context.sendActivity({ type: "invokeResponse", value: { status: 200 } }); + + try { + await withRevokedProxyFallback({ + run: async () => await handleMSTeamsFileConsentInvoke(context, log), + onRevoked: async () => true, + onRevokedLog: () => { + log.debug?.("turn context revoked during file consent invoke; skipping delayed response"); + }, + }); + } catch (err) { + log.debug?.("file consent handler error", { error: formatUnknownError(err) }); + } +} diff --git a/extensions/msteams/src/file-consent.ts b/extensions/msteams/src/file-consent.ts index 5f8b71bb7a7..ad34bc0b237 100644 --- a/extensions/msteams/src/file-consent.ts +++ b/extensions/msteams/src/file-consent.ts @@ -9,9 +9,12 @@ */ import { lookup } from "node:dns/promises"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { buildUserAgent } from "./user-agent.js"; +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + /** * Allowlist of domains that are valid targets for file consent uploads. * These are the Microsoft/SharePoint domains that Teams legitimately provides diff --git a/extensions/msteams/src/monitor-handler.file-consent.test.ts b/extensions/msteams/src/monitor-handler.file-consent.test.ts index c19c3873b80..65b06200ed7 100644 --- a/extensions/msteams/src/monitor-handler.file-consent.test.ts +++ b/extensions/msteams/src/monitor-handler.file-consent.test.ts @@ -2,16 +2,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; -import { - type MSTeamsActivityHandler, - type MSTeamsMessageHandlerDeps, - registerMSTeamsHandlers, -} from "./monitor-handler.js"; -import { - createActivityHandler, - createMSTeamsMessageHandlerDeps, -} from "./monitor-handler.test-helpers.js"; +import type { PluginRuntime } from "../runtime-api.js"; +import { respondToMSTeamsFileConsentInvoke } from "./file-consent-invoke.js"; import { getPendingUploadFs, storePendingUploadFs } from "./pending-uploads-fs.js"; import { clearPendingUploads, getPendingUpload, storePendingUpload } from "./pending-uploads.js"; import { setMSTeamsRuntime } from "./runtime.js"; @@ -64,14 +56,11 @@ function createRuntimeStub(stateDir?: string): PluginRuntime { const runtimeStub: PluginRuntime = createRuntimeStub(); -function createDeps(): MSTeamsMessageHandlerDeps { - return createMSTeamsMessageHandlerDeps({ - cfg: {} as OpenClawConfig, - runtime: { - error: vi.fn(), - } as unknown as RuntimeEnv, - }); -} +const log = { + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), +}; function createInvokeContext(params: { conversationId: string; @@ -129,18 +118,12 @@ function createConsentInvokeHarness(params: { conversationId: params.pendingConversationId ?? "19:victim@thread.v2", consentCardActivityId: params.consentCardActivityId, }); - const handler = registerMSTeamsHandlers( - createActivityHandler(), - createDeps(), - ) as MSTeamsActivityHandler & { - run: NonNullable; - }; const { context, sendActivity, updateActivity } = createInvokeContext({ conversationId: params.invokeConversationId, uploadId, action: params.action, }); - return { uploadId, handler, context, sendActivity, updateActivity }; + return { uploadId, context, sendActivity, updateActivity }; } function requirePendingUpload(uploadId: string) { @@ -155,17 +138,18 @@ describe("msteams file consent invoke authz", () => { beforeEach(() => { setMSTeamsRuntime(runtimeStub); clearPendingUploads(); + vi.clearAllMocks(); fileConsentMockState.uploadToConsentUrl.mockReset(); fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined); }); it("uploads when invoke conversation matches pending upload conversation", async () => { - const { uploadId, handler, context, sendActivity } = createConsentInvokeHarness({ + const { uploadId, context, sendActivity } = createConsentInvokeHarness({ invokeConversationId: "19:victim@thread.v2;messageid=abc123", action: "accept", }); - await handler.run(context); + await respondToMSTeamsFileConsentInvoke(context, log); // invokeResponse should be sent immediately expect(sendActivity).toHaveBeenCalledWith( @@ -185,13 +169,13 @@ describe("msteams file consent invoke authz", () => { }); it("calls updateActivity to replace the consent card when consentCardActivityId is set", async () => { - const { handler, context, sendActivity, updateActivity } = createConsentInvokeHarness({ + const { context, sendActivity, updateActivity } = createConsentInvokeHarness({ invokeConversationId: "19:victim@thread.v2;messageid=abc123", action: "accept", consentCardActivityId: "consent-card-activity-id-123", }); - await handler.run?.(context); + await respondToMSTeamsFileConsentInvoke(context, log); expect(sendActivity).toHaveBeenCalledWith(expect.objectContaining({ type: "invokeResponse" })); expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1); @@ -212,13 +196,13 @@ describe("msteams file consent invoke authz", () => { }); it("does not send file info card via sendActivity when updateActivity succeeds", async () => { - const { handler, context, sendActivity, updateActivity } = createConsentInvokeHarness({ + const { context, sendActivity, updateActivity } = createConsentInvokeHarness({ invokeConversationId: "19:victim@thread.v2;messageid=abc123", action: "accept", consentCardActivityId: "consent-card-activity-id-happy", }); - await handler.run?.(context); + await respondToMSTeamsFileConsentInvoke(context, log); // updateActivity should replace the consent card in-place expect(updateActivity).toHaveBeenCalledTimes(1); @@ -240,27 +224,27 @@ describe("msteams file consent invoke authz", () => { }); it("does not call updateActivity when no consentCardActivityId is stored", async () => { - const { handler, context, updateActivity } = createConsentInvokeHarness({ + const { context, updateActivity } = createConsentInvokeHarness({ invokeConversationId: "19:victim@thread.v2;messageid=abc123", action: "accept", // no consentCardActivityId }); - await handler.run?.(context); + await respondToMSTeamsFileConsentInvoke(context, log); expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1); expect(updateActivity).not.toHaveBeenCalled(); }); it("still completes upload if updateActivity throws", async () => { - const { uploadId, handler, context, updateActivity } = createConsentInvokeHarness({ + const { uploadId, context, updateActivity } = createConsentInvokeHarness({ invokeConversationId: "19:victim@thread.v2;messageid=abc123", action: "accept", consentCardActivityId: "consent-card-activity-id-fail", }); updateActivity.mockRejectedValueOnce(new Error("Teams API error")); - await handler.run?.(context); + await respondToMSTeamsFileConsentInvoke(context, log); // Upload should have completed despite updateActivity failure expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1); @@ -269,12 +253,12 @@ describe("msteams file consent invoke authz", () => { }); it("rejects cross-conversation accept invoke and keeps pending upload", async () => { - const { uploadId, handler, context, sendActivity } = createConsentInvokeHarness({ + const { uploadId, context, sendActivity } = createConsentInvokeHarness({ invokeConversationId: "19:attacker@thread.v2", action: "accept", }); - await handler.run(context); + await respondToMSTeamsFileConsentInvoke(context, log); // invokeResponse should be sent immediately expect(sendActivity).toHaveBeenCalledWith( @@ -296,12 +280,12 @@ describe("msteams file consent invoke authz", () => { }); it("ignores cross-conversation decline invoke and keeps pending upload", async () => { - const { uploadId, handler, context, sendActivity } = createConsentInvokeHarness({ + const { uploadId, context, sendActivity } = createConsentInvokeHarness({ invokeConversationId: "19:attacker@thread.v2", action: "decline", }); - await handler.run(context); + await respondToMSTeamsFileConsentInvoke(context, log); // invokeResponse should be sent immediately expect(sendActivity).toHaveBeenCalledWith( @@ -330,6 +314,7 @@ describe("msteams file consent invoke FS fallback", () => { process.env.OPENCLAW_STATE_DIR = tmpDir; setMSTeamsRuntime(createRuntimeStub(tmpDir)); clearPendingUploads(); + vi.clearAllMocks(); fileConsentMockState.uploadToConsentUrl.mockReset(); fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined); }); @@ -387,14 +372,7 @@ describe("msteams file consent invoke FS fallback", () => { updateActivity, } as unknown as MSTeamsTurnContext; - const handler = registerMSTeamsHandlers( - createActivityHandler(), - createDeps(), - ) as MSTeamsActivityHandler & { - run: NonNullable; - }; - - await handler.run(context); + await respondToMSTeamsFileConsentInvoke(context, log); // The upload should have run using the FS-loaded buffer expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1); @@ -437,14 +415,7 @@ describe("msteams file consent invoke FS fallback", () => { updateActivity, } as unknown as MSTeamsTurnContext; - const handler = registerMSTeamsHandlers( - createActivityHandler(), - createDeps(), - ) as MSTeamsActivityHandler & { - run: NonNullable; - }; - - await handler.run(context); + await respondToMSTeamsFileConsentInvoke(context, log); expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled(); expect(await getPendingUploadFs(uploadId)).toBeUndefined(); diff --git a/extensions/msteams/src/monitor-handler.ts b/extensions/msteams/src/monitor-handler.ts index 1e69e38f87c..008db49680c 100644 --- a/extensions/msteams/src/monitor-handler.ts +++ b/extensions/msteams/src/monitor-handler.ts @@ -4,17 +4,13 @@ import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; import { formatUnknownError } from "./errors.js"; import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.js"; -import { buildFileInfoCard, parseFileConsentInvoke, uploadToConsentUrl } from "./file-consent.js"; +import { respondToMSTeamsFileConsentInvoke } from "./file-consent-invoke.js"; import { extractMSTeamsConversationMessageId, normalizeMSTeamsConversationId } from "./inbound.js"; import { resolveMSTeamsSenderAccess } from "./monitor-handler/access.js"; import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js"; import { createMSTeamsReactionHandler } from "./monitor-handler/reaction-handler.js"; export type { MSTeamsAccessTokenProvider } from "./attachments/types.js"; import type { MSTeamsAccessTokenProvider } from "./attachments/types.js"; -import type { MSTeamsMonitorLogger } from "./monitor-types.js"; -import { getPendingUploadFs, removePendingUploadFs } from "./pending-uploads-fs.js"; -import { getPendingUpload, removePendingUpload } from "./pending-uploads.js"; -import { withRevokedProxyFallback } from "./revoked-context.js"; import { getMSTeamsRuntime } from "./runtime.js"; import type { MSTeamsTurnContext } from "./sdk-types.js"; import { @@ -146,136 +142,6 @@ async function isSigninInvokeAuthorized( }); } -/** - * Handle fileConsent/invoke activities for large file uploads. - */ -async function handleFileConsentInvoke( - context: MSTeamsTurnContext, - log: MSTeamsMonitorLogger, -): Promise { - const expiredUploadMessage = - "The file upload request has expired. Please try sending the file again."; - const activity = context.activity; - if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") { - return false; - } - - const consentResponse = parseFileConsentInvoke(activity); - if (!consentResponse) { - log.debug?.("invalid file consent invoke", { value: activity.value }); - return false; - } - - const uploadId = - typeof consentResponse.context?.uploadId === "string" - ? consentResponse.context.uploadId - : undefined; - // Prefer the in-memory store (same-process reply path); fall back to the - // FS-backed store so CLI `message send --media` flows work even when the - // invoke callback is delivered to a different process. - const inMemoryFile = getPendingUpload(uploadId); - const fsFile = inMemoryFile ? undefined : await getPendingUploadFs(uploadId); - const pendingFile: - | { - buffer: Buffer; - filename: string; - contentType?: string; - conversationId: string; - consentCardActivityId?: string; - } - | undefined = inMemoryFile ?? fsFile; - if (pendingFile) { - const pendingConversationId = normalizeMSTeamsConversationId(pendingFile.conversationId); - const invokeConversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? ""); - if (!invokeConversationId || pendingConversationId !== invokeConversationId) { - log.info("file consent conversation mismatch", { - uploadId, - expectedConversationId: pendingConversationId, - receivedConversationId: invokeConversationId || undefined, - }); - if (consentResponse.action === "accept") { - await context.sendActivity(expiredUploadMessage); - } - return true; - } - } - - if (consentResponse.action === "accept" && consentResponse.uploadInfo) { - if (pendingFile) { - log.debug?.("user accepted file consent, uploading", { - uploadId, - filename: pendingFile.filename, - size: pendingFile.buffer.length, - }); - - try { - // Upload file to the provided URL - await uploadToConsentUrl({ - url: consentResponse.uploadInfo.uploadUrl, - buffer: pendingFile.buffer, - contentType: pendingFile.contentType, - }); - - // Send confirmation card - const fileInfoCard = buildFileInfoCard({ - filename: consentResponse.uploadInfo.name, - contentUrl: consentResponse.uploadInfo.contentUrl, - uniqueId: consentResponse.uploadInfo.uniqueId, - fileType: consentResponse.uploadInfo.fileType, - }); - - // Only send a new file info message if we can't replace the consent card in-place - if (!pendingFile.consentCardActivityId) { - await context.sendActivity({ - type: "message", - attachments: [fileInfoCard], - }); - } - - // Replace the original FileConsentCard with the file info card so the - // consent prompt no longer shows as pending in the chat - if (pendingFile.consentCardActivityId) { - try { - await context.updateActivity({ - id: pendingFile.consentCardActivityId, - type: "message", - attachments: [fileInfoCard], - }); - } catch { - // Non-fatal fallback: if update fails, send as new message - await context.sendActivity({ - type: "message", - attachments: [fileInfoCard], - }); - } - } - - log.info("file upload complete", { - uploadId, - filename: consentResponse.uploadInfo.name, - uniqueId: consentResponse.uploadInfo.uniqueId, - }); - } catch (err) { - log.error("file upload failed", { uploadId, error: formatUnknownError(err) }); - await context.sendActivity("File upload failed. Please try again."); - } finally { - removePendingUpload(uploadId); - await removePendingUploadFs(uploadId); - } - } else { - log.debug?.("pending file not found for consent", { uploadId }); - await context.sendActivity(expiredUploadMessage); - } - } else { - // User declined - log.debug?.("user declined file consent", { uploadId }); - removePendingUpload(uploadId); - await removePendingUploadFs(uploadId); - } - - return true; -} - /** * Parse and handle feedback invoke activities (thumbs up/down). * Returns true if the activity was a feedback invoke, false otherwise. @@ -464,22 +330,7 @@ export function registerMSTeamsHandlers( const ctx = context as MSTeamsTurnContext; // Handle file consent invokes before passing to normal flow if (ctx.activity?.type === "invoke" && ctx.activity?.name === "fileConsent/invoke") { - // Send invoke response IMMEDIATELY to prevent Teams timeout - await ctx.sendActivity({ type: "invokeResponse", value: { status: 200 } }); - - try { - await withRevokedProxyFallback({ - run: async () => await handleFileConsentInvoke(ctx, deps.log), - onRevoked: async () => true, - onRevokedLog: () => { - deps.log.debug?.( - "turn context revoked during file consent invoke; skipping delayed response", - ); - }, - }); - } catch (err) { - deps.log.debug?.("file consent handler error", { error: formatUnknownError(err) }); - } + await respondToMSTeamsFileConsentInvoke(ctx, deps.log); return; } 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 49036940ce9..eb3acafcede 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 @@ -15,10 +15,21 @@ const fetchChannelMessageMock = vi.hoisted(() => vi.fn()); const fetchThreadRepliesMock = vi.hoisted(() => vi.fn(async () => [])); const resolveTeamGroupIdMock = vi.hoisted(() => vi.fn(async () => "group-1")); -vi.mock("../graph-thread.js", async () => { - const actual = await vi.importActual("../graph-thread.js"); +vi.mock("../graph-thread.js", () => { + const stripHtmlFromTeamsMessage = (html: string) => + html + .replace(/]*>(.*?)<\/at>/gi, "@$1") + .replace(/<[^>]*>/g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); return { - ...actual, + stripHtmlFromTeamsMessage, resolveTeamGroupId: resolveTeamGroupIdMock, fetchChannelMessage: fetchChannelMessageMock, fetchThreadReplies: fetchThreadRepliesMock, diff --git a/extensions/msteams/src/monitor-handler/message-handler.thread-session.test.ts b/extensions/msteams/src/monitor-handler/message-handler.thread-session.test.ts index 80cd8bfb50f..45e06b90a5c 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.thread-session.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.thread-session.test.ts @@ -1,136 +1,77 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../runtime-api.js"; -import "./message-handler-mock-support.test-support.js"; -import { createMSTeamsMessageHandler } from "./message-handler.js"; -import { - buildChannelActivity, - channelConversationId, - createMessageHandlerDeps, -} from "./message-handler.test-support.js"; +import { describe, expect, it } from "vitest"; +import { resolveMSTeamsRouteSessionKey } from "./thread-session.js"; -vi.mock("../graph-thread.js", async () => { - const actual = await vi.importActual("../graph-thread.js"); - return { - ...actual, - resolveTeamGroupId: vi.fn(async () => "group-1"), - fetchChannelMessage: vi.fn(async () => undefined), - fetchThreadReplies: vi.fn(async () => []), - }; -}); +const channelConversationSessionKey = "agent:main:msteams:channel:19:channel@thread.tacv2"; describe("msteams thread session isolation", () => { it("appends thread suffix to session key for channel thread replies", async () => { - const cfg: OpenClawConfig = { - channels: { msteams: { groupPolicy: "open" } }, - } as OpenClawConfig; - const { deps, recordInboundSession } = createMessageHandlerDeps(cfg); - const handler = createMSTeamsMessageHandler(deps); + const sessionKey = resolveMSTeamsRouteSessionKey({ + baseSessionKey: channelConversationSessionKey, + isChannel: true, + replyToId: "thread-root-123", + }); - // Thread reply: has replyToId pointing to the thread root - await handler({ - activity: buildChannelActivity({ replyToId: "thread-root-123" }), - sendActivity: vi.fn(async () => undefined), - } as unknown as Parameters[0]); - - expect(recordInboundSession).toHaveBeenCalledTimes(1); - const sessionKey = recordInboundSession.mock.calls[0]?.[0]?.sessionKey; expect(sessionKey).toContain("thread:"); expect(sessionKey).toContain("thread-root-123"); }); it("does not append thread suffix for top-level channel messages", async () => { - const cfg: OpenClawConfig = { - channels: { msteams: { groupPolicy: "open" } }, - } as OpenClawConfig; - const { deps, recordInboundSession } = createMessageHandlerDeps(cfg); - const handler = createMSTeamsMessageHandler(deps); + const sessionKey = resolveMSTeamsRouteSessionKey({ + baseSessionKey: channelConversationSessionKey, + isChannel: true, + replyToId: undefined, + }); - // Top-level channel message: no replyToId - await handler({ - activity: buildChannelActivity({ replyToId: undefined }), - sendActivity: vi.fn(async () => undefined), - } as unknown as Parameters[0]); - - expect(recordInboundSession).toHaveBeenCalledTimes(1); - const sessionKey = recordInboundSession.mock.calls[0]?.[0]?.sessionKey; expect(sessionKey).not.toContain("thread:"); - expect(sessionKey).toBe(`agent:main:msteams:channel:${channelConversationId}`); + expect(sessionKey).toBe(channelConversationSessionKey); }); it("produces different session keys for different threads in the same channel", async () => { - const cfg: OpenClawConfig = { - channels: { msteams: { groupPolicy: "open" } }, - } as OpenClawConfig; - const { deps, recordInboundSession } = createMessageHandlerDeps(cfg); - const handler = createMSTeamsMessageHandler(deps); + const sessionKeyA = resolveMSTeamsRouteSessionKey({ + baseSessionKey: channelConversationSessionKey, + isChannel: true, + replyToId: "thread-A", + }); + const sessionKeyB = resolveMSTeamsRouteSessionKey({ + baseSessionKey: channelConversationSessionKey, + isChannel: true, + replyToId: "thread-B", + }); - await handler({ - activity: buildChannelActivity({ id: "msg-1", replyToId: "thread-A" }), - sendActivity: vi.fn(async () => undefined), - } as unknown as Parameters[0]); - - await handler({ - activity: buildChannelActivity({ id: "msg-2", replyToId: "thread-B" }), - sendActivity: vi.fn(async () => undefined), - } as unknown as Parameters[0]); - - expect(recordInboundSession).toHaveBeenCalledTimes(2); - const sessionKeyA = recordInboundSession.mock.calls[0]?.[0]?.sessionKey; - const sessionKeyB = recordInboundSession.mock.calls[1]?.[0]?.sessionKey; expect(sessionKeyA).not.toBe(sessionKeyB); expect(sessionKeyA).toContain("thread-a"); // normalized lowercase expect(sessionKeyB).toContain("thread-b"); }); it("does not affect DM session keys", async () => { - const cfg: OpenClawConfig = { - channels: { msteams: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const { deps, recordInboundSession } = createMessageHandlerDeps(cfg); - const handler = createMSTeamsMessageHandler(deps); + const sessionKey = resolveMSTeamsRouteSessionKey({ + baseSessionKey: "agent:main:msteams:dm:user-1", + isChannel: false, + replyToId: "some-reply-id", + }); - await handler({ - activity: { - ...buildChannelActivity(), - conversation: { - id: "a:dm-conversation", - conversationType: "personal", - }, - channelData: {}, - replyToId: "some-reply-id", - entities: [], - }, - sendActivity: vi.fn(async () => undefined), - } as unknown as Parameters[0]); - - expect(recordInboundSession).toHaveBeenCalledTimes(1); - const sessionKey = recordInboundSession.mock.calls[0]?.[0]?.sessionKey; expect(sessionKey).not.toContain("thread:"); }); it("does not affect group chat session keys", async () => { - const cfg: OpenClawConfig = { - channels: { msteams: { groupPolicy: "open" } }, - } as OpenClawConfig; - const { deps, recordInboundSession } = createMessageHandlerDeps(cfg); - const handler = createMSTeamsMessageHandler(deps); + const sessionKey = resolveMSTeamsRouteSessionKey({ + baseSessionKey: "agent:main:msteams:group:19:group-chat-id@unq.gbl.spaces", + isChannel: false, + replyToId: "some-reply-id", + }); - await handler({ - activity: { - ...buildChannelActivity(), - conversation: { - id: "19:group-chat-id@unq.gbl.spaces", - conversationType: "groupChat", - }, - channelData: {}, - replyToId: "some-reply-id", - entities: [{ type: "mention", mentioned: { id: "bot-id" } }], - }, - sendActivity: vi.fn(async () => undefined), - } as unknown as Parameters[0]); - - expect(recordInboundSession).toHaveBeenCalledTimes(1); - const sessionKey = recordInboundSession.mock.calls[0]?.[0]?.sessionKey; expect(sessionKey).not.toContain("thread:"); }); + + it("prefers conversation message id over replyToId for deep channel replies", async () => { + const sessionKey = resolveMSTeamsRouteSessionKey({ + baseSessionKey: channelConversationSessionKey, + isChannel: true, + conversationMessageId: "thread-root", + replyToId: "nested-reply", + }); + + expect(sessionKey).toContain("thread-root"); + expect(sessionKey).not.toContain("nested-reply"); + }); }); diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index e304059314e..587397abe91 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -1,5 +1,4 @@ import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound"; -import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, @@ -95,6 +94,7 @@ import type { MSTeamsTurnContext } from "../sdk-types.js"; import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js"; import { resolveMSTeamsSenderAccess } from "./access.js"; import { resolveMSTeamsInboundMedia } from "./inbound-media.js"; +import { resolveMSTeamsRouteSessionKey } from "./thread-session.js"; function buildStoredConversationReference(params: { activity: MSTeamsTurnContext["activity"]; @@ -476,15 +476,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { // ;messageid= portion of conversation.id, i.e. the thread root) over // activity.replyToId (which may point to a non-root parent in deep threads). // DMs and group chats are unaffected — only channel thread replies fork. - const channelThreadId = isChannel - ? (conversationMessageId ?? activity.replyToId ?? undefined) - : undefined; - const threadKeys = resolveThreadSessionKeys({ + route.sessionKey = resolveMSTeamsRouteSessionKey({ baseSessionKey: route.sessionKey, - threadId: channelThreadId, - parentSessionKey: channelThreadId ? route.sessionKey : undefined, + isChannel, + conversationMessageId, + replyToId: activity.replyToId, }); - route.sessionKey = threadKeys.sessionKey; const preview = rawBody.replace(/\s+/g, " ").slice(0, 160); const inboundLabel = isDirectMessage diff --git a/extensions/msteams/src/monitor-handler/thread-session.ts b/extensions/msteams/src/monitor-handler/thread-session.ts new file mode 100644 index 00000000000..13d54f88286 --- /dev/null +++ b/extensions/msteams/src/monitor-handler/thread-session.ts @@ -0,0 +1,17 @@ +import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; + +export function resolveMSTeamsRouteSessionKey(params: { + baseSessionKey: string; + isChannel: boolean; + conversationMessageId?: string; + replyToId?: string; +}): string { + const channelThreadId = params.isChannel + ? (params.conversationMessageId ?? params.replyToId ?? undefined) + : undefined; + return resolveThreadSessionKeys({ + baseSessionKey: params.baseSessionKey, + threadId: channelThreadId, + parentSessionKey: channelThreadId ? params.baseSessionKey : undefined, + }).sessionKey; +}