perf: slim msteams hot test imports

This commit is contained in:
Peter Steinberger
2026-04-24 00:04:21 +01:00
parent 07049c8eba
commit bd49117a50
9 changed files with 267 additions and 324 deletions

View File

@@ -1,4 +1,6 @@
import { isRecord } from "./attachments/shared.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function formatUnknownError(err: unknown): string {
if (err instanceof Error) {

View File

@@ -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<boolean> {
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<void> {
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) });
}
}

View File

@@ -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

View File

@@ -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<MSTeamsActivityHandler["run"]>;
};
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<MSTeamsActivityHandler["run"]>;
};
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<MSTeamsActivityHandler["run"]>;
};
await handler.run(context);
await respondToMSTeamsFileConsentInvoke(context, log);
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
expect(await getPendingUploadFs(uploadId)).toBeUndefined();

View File

@@ -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<boolean> {
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<T extends MSTeamsActivityHandler>(
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;
}

View File

@@ -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<typeof import("../graph-thread.js")>("../graph-thread.js");
vi.mock("../graph-thread.js", () => {
const stripHtmlFromTeamsMessage = (html: string) =>
html
.replace(/<at[^>]*>(.*?)<\/at>/gi, "@$1")
.replace(/<[^>]*>/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
return {
...actual,
stripHtmlFromTeamsMessage,
resolveTeamGroupId: resolveTeamGroupIdMock,
fetchChannelMessage: fetchChannelMessageMock,
fetchThreadReplies: fetchThreadRepliesMock,

View File

@@ -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<typeof import("../graph-thread.js")>("../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<typeof handler>[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<typeof handler>[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<typeof handler>[0]);
await handler({
activity: buildChannelActivity({ id: "msg-2", replyToId: "thread-B" }),
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[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<typeof handler>[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<typeof handler>[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");
});
});

View File

@@ -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

View File

@@ -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;
}