mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:00:43 +00:00
perf: slim msteams hot test imports
This commit is contained in:
@@ -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) {
|
||||
|
||||
150
extensions/msteams/src/file-consent-invoke.ts
Normal file
150
extensions/msteams/src/file-consent-invoke.ts
Normal 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) });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return {
|
||||
...actual,
|
||||
stripHtmlFromTeamsMessage,
|
||||
resolveTeamGroupId: resolveTeamGroupIdMock,
|
||||
fetchChannelMessage: fetchChannelMessageMock,
|
||||
fetchThreadReplies: fetchThreadRepliesMock,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
17
extensions/msteams/src/monitor-handler/thread-session.ts
Normal file
17
extensions/msteams/src/monitor-handler/thread-session.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user