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 { export function formatUnknownError(err: unknown): string {
if (err instanceof Error) { 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 { lookup } from "node:dns/promises";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { buildUserAgent } from "./user-agent.js"; 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. * Allowlist of domains that are valid targets for file consent uploads.
* These are the Microsoft/SharePoint domains that Teams legitimately provides * 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 os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import type { PluginRuntime } from "../runtime-api.js";
import { import { respondToMSTeamsFileConsentInvoke } from "./file-consent-invoke.js";
type MSTeamsActivityHandler,
type MSTeamsMessageHandlerDeps,
registerMSTeamsHandlers,
} from "./monitor-handler.js";
import {
createActivityHandler,
createMSTeamsMessageHandlerDeps,
} from "./monitor-handler.test-helpers.js";
import { getPendingUploadFs, storePendingUploadFs } from "./pending-uploads-fs.js"; import { getPendingUploadFs, storePendingUploadFs } from "./pending-uploads-fs.js";
import { clearPendingUploads, getPendingUpload, storePendingUpload } from "./pending-uploads.js"; import { clearPendingUploads, getPendingUpload, storePendingUpload } from "./pending-uploads.js";
import { setMSTeamsRuntime } from "./runtime.js"; import { setMSTeamsRuntime } from "./runtime.js";
@@ -64,14 +56,11 @@ function createRuntimeStub(stateDir?: string): PluginRuntime {
const runtimeStub: PluginRuntime = createRuntimeStub(); const runtimeStub: PluginRuntime = createRuntimeStub();
function createDeps(): MSTeamsMessageHandlerDeps { const log = {
return createMSTeamsMessageHandlerDeps({ debug: vi.fn(),
cfg: {} as OpenClawConfig, info: vi.fn(),
runtime: { error: vi.fn(),
error: vi.fn(), };
} as unknown as RuntimeEnv,
});
}
function createInvokeContext(params: { function createInvokeContext(params: {
conversationId: string; conversationId: string;
@@ -129,18 +118,12 @@ function createConsentInvokeHarness(params: {
conversationId: params.pendingConversationId ?? "19:victim@thread.v2", conversationId: params.pendingConversationId ?? "19:victim@thread.v2",
consentCardActivityId: params.consentCardActivityId, consentCardActivityId: params.consentCardActivityId,
}); });
const handler = registerMSTeamsHandlers(
createActivityHandler(),
createDeps(),
) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
const { context, sendActivity, updateActivity } = createInvokeContext({ const { context, sendActivity, updateActivity } = createInvokeContext({
conversationId: params.invokeConversationId, conversationId: params.invokeConversationId,
uploadId, uploadId,
action: params.action, action: params.action,
}); });
return { uploadId, handler, context, sendActivity, updateActivity }; return { uploadId, context, sendActivity, updateActivity };
} }
function requirePendingUpload(uploadId: string) { function requirePendingUpload(uploadId: string) {
@@ -155,17 +138,18 @@ describe("msteams file consent invoke authz", () => {
beforeEach(() => { beforeEach(() => {
setMSTeamsRuntime(runtimeStub); setMSTeamsRuntime(runtimeStub);
clearPendingUploads(); clearPendingUploads();
vi.clearAllMocks();
fileConsentMockState.uploadToConsentUrl.mockReset(); fileConsentMockState.uploadToConsentUrl.mockReset();
fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined); fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined);
}); });
it("uploads when invoke conversation matches pending upload conversation", async () => { 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", invokeConversationId: "19:victim@thread.v2;messageid=abc123",
action: "accept", action: "accept",
}); });
await handler.run(context); await respondToMSTeamsFileConsentInvoke(context, log);
// invokeResponse should be sent immediately // invokeResponse should be sent immediately
expect(sendActivity).toHaveBeenCalledWith( 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 () => { 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", invokeConversationId: "19:victim@thread.v2;messageid=abc123",
action: "accept", action: "accept",
consentCardActivityId: "consent-card-activity-id-123", consentCardActivityId: "consent-card-activity-id-123",
}); });
await handler.run?.(context); await respondToMSTeamsFileConsentInvoke(context, log);
expect(sendActivity).toHaveBeenCalledWith(expect.objectContaining({ type: "invokeResponse" })); expect(sendActivity).toHaveBeenCalledWith(expect.objectContaining({ type: "invokeResponse" }));
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1); 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 () => { 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", invokeConversationId: "19:victim@thread.v2;messageid=abc123",
action: "accept", action: "accept",
consentCardActivityId: "consent-card-activity-id-happy", consentCardActivityId: "consent-card-activity-id-happy",
}); });
await handler.run?.(context); await respondToMSTeamsFileConsentInvoke(context, log);
// updateActivity should replace the consent card in-place // updateActivity should replace the consent card in-place
expect(updateActivity).toHaveBeenCalledTimes(1); 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 () => { 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", invokeConversationId: "19:victim@thread.v2;messageid=abc123",
action: "accept", action: "accept",
// no consentCardActivityId // no consentCardActivityId
}); });
await handler.run?.(context); await respondToMSTeamsFileConsentInvoke(context, log);
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1); expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
expect(updateActivity).not.toHaveBeenCalled(); expect(updateActivity).not.toHaveBeenCalled();
}); });
it("still completes upload if updateActivity throws", async () => { 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", invokeConversationId: "19:victim@thread.v2;messageid=abc123",
action: "accept", action: "accept",
consentCardActivityId: "consent-card-activity-id-fail", consentCardActivityId: "consent-card-activity-id-fail",
}); });
updateActivity.mockRejectedValueOnce(new Error("Teams API error")); updateActivity.mockRejectedValueOnce(new Error("Teams API error"));
await handler.run?.(context); await respondToMSTeamsFileConsentInvoke(context, log);
// Upload should have completed despite updateActivity failure // Upload should have completed despite updateActivity failure
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1); 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 () => { 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", invokeConversationId: "19:attacker@thread.v2",
action: "accept", action: "accept",
}); });
await handler.run(context); await respondToMSTeamsFileConsentInvoke(context, log);
// invokeResponse should be sent immediately // invokeResponse should be sent immediately
expect(sendActivity).toHaveBeenCalledWith( expect(sendActivity).toHaveBeenCalledWith(
@@ -296,12 +280,12 @@ describe("msteams file consent invoke authz", () => {
}); });
it("ignores cross-conversation decline invoke and keeps pending upload", async () => { 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", invokeConversationId: "19:attacker@thread.v2",
action: "decline", action: "decline",
}); });
await handler.run(context); await respondToMSTeamsFileConsentInvoke(context, log);
// invokeResponse should be sent immediately // invokeResponse should be sent immediately
expect(sendActivity).toHaveBeenCalledWith( expect(sendActivity).toHaveBeenCalledWith(
@@ -330,6 +314,7 @@ describe("msteams file consent invoke FS fallback", () => {
process.env.OPENCLAW_STATE_DIR = tmpDir; process.env.OPENCLAW_STATE_DIR = tmpDir;
setMSTeamsRuntime(createRuntimeStub(tmpDir)); setMSTeamsRuntime(createRuntimeStub(tmpDir));
clearPendingUploads(); clearPendingUploads();
vi.clearAllMocks();
fileConsentMockState.uploadToConsentUrl.mockReset(); fileConsentMockState.uploadToConsentUrl.mockReset();
fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined); fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined);
}); });
@@ -387,14 +372,7 @@ describe("msteams file consent invoke FS fallback", () => {
updateActivity, updateActivity,
} as unknown as MSTeamsTurnContext; } as unknown as MSTeamsTurnContext;
const handler = registerMSTeamsHandlers( await respondToMSTeamsFileConsentInvoke(context, log);
createActivityHandler(),
createDeps(),
) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
await handler.run(context);
// The upload should have run using the FS-loaded buffer // The upload should have run using the FS-loaded buffer
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1); expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
@@ -437,14 +415,7 @@ describe("msteams file consent invoke FS fallback", () => {
updateActivity, updateActivity,
} as unknown as MSTeamsTurnContext; } as unknown as MSTeamsTurnContext;
const handler = registerMSTeamsHandlers( await respondToMSTeamsFileConsentInvoke(context, log);
createActivityHandler(),
createDeps(),
) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
await handler.run(context);
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled(); expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
expect(await getPendingUploadFs(uploadId)).toBeUndefined(); 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 { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import { formatUnknownError } from "./errors.js"; import { formatUnknownError } from "./errors.js";
import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.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 { extractMSTeamsConversationMessageId, normalizeMSTeamsConversationId } from "./inbound.js";
import { resolveMSTeamsSenderAccess } from "./monitor-handler/access.js"; import { resolveMSTeamsSenderAccess } from "./monitor-handler/access.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js"; import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
import { createMSTeamsReactionHandler } from "./monitor-handler/reaction-handler.js"; import { createMSTeamsReactionHandler } from "./monitor-handler/reaction-handler.js";
export type { MSTeamsAccessTokenProvider } from "./attachments/types.js"; export type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
import 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 { getMSTeamsRuntime } from "./runtime.js";
import type { MSTeamsTurnContext } from "./sdk-types.js"; import type { MSTeamsTurnContext } from "./sdk-types.js";
import { 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). * Parse and handle feedback invoke activities (thumbs up/down).
* Returns true if the activity was a feedback invoke, false otherwise. * 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; const ctx = context as MSTeamsTurnContext;
// Handle file consent invokes before passing to normal flow // Handle file consent invokes before passing to normal flow
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "fileConsent/invoke") { if (ctx.activity?.type === "invoke" && ctx.activity?.name === "fileConsent/invoke") {
// Send invoke response IMMEDIATELY to prevent Teams timeout await respondToMSTeamsFileConsentInvoke(ctx, deps.log);
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) });
}
return; return;
} }

View File

@@ -15,10 +15,21 @@ const fetchChannelMessageMock = vi.hoisted(() => vi.fn());
const fetchThreadRepliesMock = vi.hoisted(() => vi.fn(async () => [])); const fetchThreadRepliesMock = vi.hoisted(() => vi.fn(async () => []));
const resolveTeamGroupIdMock = vi.hoisted(() => vi.fn(async () => "group-1")); const resolveTeamGroupIdMock = vi.hoisted(() => vi.fn(async () => "group-1"));
vi.mock("../graph-thread.js", async () => { vi.mock("../graph-thread.js", () => {
const actual = await vi.importActual<typeof import("../graph-thread.js")>("../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 { return {
...actual, stripHtmlFromTeamsMessage,
resolveTeamGroupId: resolveTeamGroupIdMock, resolveTeamGroupId: resolveTeamGroupIdMock,
fetchChannelMessage: fetchChannelMessageMock, fetchChannelMessage: fetchChannelMessageMock,
fetchThreadReplies: fetchThreadRepliesMock, fetchThreadReplies: fetchThreadRepliesMock,

View File

@@ -1,136 +1,77 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../runtime-api.js"; import { resolveMSTeamsRouteSessionKey } from "./thread-session.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";
vi.mock("../graph-thread.js", async () => { const channelConversationSessionKey = "agent:main:msteams:channel:19:channel@thread.tacv2";
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 () => []),
};
});
describe("msteams thread session isolation", () => { describe("msteams thread session isolation", () => {
it("appends thread suffix to session key for channel thread replies", async () => { it("appends thread suffix to session key for channel thread replies", async () => {
const cfg: OpenClawConfig = { const sessionKey = resolveMSTeamsRouteSessionKey({
channels: { msteams: { groupPolicy: "open" } }, baseSessionKey: channelConversationSessionKey,
} as OpenClawConfig; isChannel: true,
const { deps, recordInboundSession } = createMessageHandlerDeps(cfg); replyToId: "thread-root-123",
const handler = createMSTeamsMessageHandler(deps); });
// 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:");
expect(sessionKey).toContain("thread-root-123"); expect(sessionKey).toContain("thread-root-123");
}); });
it("does not append thread suffix for top-level channel messages", async () => { it("does not append thread suffix for top-level channel messages", async () => {
const cfg: OpenClawConfig = { const sessionKey = resolveMSTeamsRouteSessionKey({
channels: { msteams: { groupPolicy: "open" } }, baseSessionKey: channelConversationSessionKey,
} as OpenClawConfig; isChannel: true,
const { deps, recordInboundSession } = createMessageHandlerDeps(cfg); replyToId: undefined,
const handler = createMSTeamsMessageHandler(deps); });
// 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).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 () => { it("produces different session keys for different threads in the same channel", async () => {
const cfg: OpenClawConfig = { const sessionKeyA = resolveMSTeamsRouteSessionKey({
channels: { msteams: { groupPolicy: "open" } }, baseSessionKey: channelConversationSessionKey,
} as OpenClawConfig; isChannel: true,
const { deps, recordInboundSession } = createMessageHandlerDeps(cfg); replyToId: "thread-A",
const handler = createMSTeamsMessageHandler(deps); });
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).not.toBe(sessionKeyB);
expect(sessionKeyA).toContain("thread-a"); // normalized lowercase expect(sessionKeyA).toContain("thread-a"); // normalized lowercase
expect(sessionKeyB).toContain("thread-b"); expect(sessionKeyB).toContain("thread-b");
}); });
it("does not affect DM session keys", async () => { it("does not affect DM session keys", async () => {
const cfg: OpenClawConfig = { const sessionKey = resolveMSTeamsRouteSessionKey({
channels: { msteams: { allowFrom: ["*"] } }, baseSessionKey: "agent:main:msteams:dm:user-1",
} as OpenClawConfig; isChannel: false,
const { deps, recordInboundSession } = createMessageHandlerDeps(cfg); replyToId: "some-reply-id",
const handler = createMSTeamsMessageHandler(deps); });
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:"); expect(sessionKey).not.toContain("thread:");
}); });
it("does not affect group chat session keys", async () => { it("does not affect group chat session keys", async () => {
const cfg: OpenClawConfig = { const sessionKey = resolveMSTeamsRouteSessionKey({
channels: { msteams: { groupPolicy: "open" } }, baseSessionKey: "agent:main:msteams:group:19:group-chat-id@unq.gbl.spaces",
} as OpenClawConfig; isChannel: false,
const { deps, recordInboundSession } = createMessageHandlerDeps(cfg); replyToId: "some-reply-id",
const handler = createMSTeamsMessageHandler(deps); });
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:"); 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 { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import { import {
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled, clearHistoryEntriesIfEnabled,
@@ -95,6 +94,7 @@ import type { MSTeamsTurnContext } from "../sdk-types.js";
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js"; import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
import { resolveMSTeamsSenderAccess } from "./access.js"; import { resolveMSTeamsSenderAccess } from "./access.js";
import { resolveMSTeamsInboundMedia } from "./inbound-media.js"; import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
import { resolveMSTeamsRouteSessionKey } from "./thread-session.js";
function buildStoredConversationReference(params: { function buildStoredConversationReference(params: {
activity: MSTeamsTurnContext["activity"]; activity: MSTeamsTurnContext["activity"];
@@ -476,15 +476,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
// ;messageid= portion of conversation.id, i.e. the thread root) over // ;messageid= portion of conversation.id, i.e. the thread root) over
// activity.replyToId (which may point to a non-root parent in deep threads). // activity.replyToId (which may point to a non-root parent in deep threads).
// DMs and group chats are unaffected — only channel thread replies fork. // DMs and group chats are unaffected — only channel thread replies fork.
const channelThreadId = isChannel route.sessionKey = resolveMSTeamsRouteSessionKey({
? (conversationMessageId ?? activity.replyToId ?? undefined)
: undefined;
const threadKeys = resolveThreadSessionKeys({
baseSessionKey: route.sessionKey, baseSessionKey: route.sessionKey,
threadId: channelThreadId, isChannel,
parentSessionKey: channelThreadId ? route.sessionKey : undefined, conversationMessageId,
replyToId: activity.replyToId,
}); });
route.sessionKey = threadKeys.sessionKey;
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160); const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isDirectMessage 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;
}