mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-04 22:01:15 +00:00
559 lines
19 KiB
TypeScript
559 lines
19 KiB
TypeScript
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createPluginRuntimeMock } from "../../../test/helpers/plugins/plugin-runtime-mock.js";
|
|
import { SILENT_REPLY_TOKEN, type PluginRuntime } from "../runtime-api.js";
|
|
import type { StoredConversationReference } from "./conversation-store.js";
|
|
const graphUploadMockState = vi.hoisted(() => ({
|
|
uploadAndShareOneDrive: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./graph-upload.js", async () => {
|
|
const actual = await vi.importActual<typeof import("./graph-upload.js")>("./graph-upload.js");
|
|
return {
|
|
...actual,
|
|
uploadAndShareOneDrive: graphUploadMockState.uploadAndShareOneDrive,
|
|
};
|
|
});
|
|
|
|
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
|
import {
|
|
type MSTeamsAdapter,
|
|
type MSTeamsRenderedMessage,
|
|
buildActivity,
|
|
renderReplyPayloadsToMessages,
|
|
sendMSTeamsMessages,
|
|
} from "./messenger.js";
|
|
import { setMSTeamsRuntime } from "./runtime.js";
|
|
|
|
const chunkMarkdownText = (text: string, limit: number) => {
|
|
if (!text) {
|
|
return [];
|
|
}
|
|
if (limit <= 0 || text.length <= limit) {
|
|
return [text];
|
|
}
|
|
const chunks: string[] = [];
|
|
for (let index = 0; index < text.length; index += limit) {
|
|
chunks.push(text.slice(index, index + limit));
|
|
}
|
|
return chunks;
|
|
};
|
|
|
|
const runtimeStub: PluginRuntime = createPluginRuntimeMock({
|
|
channel: {
|
|
text: {
|
|
chunkMarkdownText,
|
|
chunkMarkdownTextWithMode: chunkMarkdownText,
|
|
resolveMarkdownTableMode: () => "code",
|
|
convertMarkdownTables: (text: string) => text,
|
|
},
|
|
},
|
|
});
|
|
|
|
const noopUpdateActivity = async () => {};
|
|
const noopDeleteActivity = async () => {};
|
|
|
|
const createNoopAdapter = (): MSTeamsAdapter => ({
|
|
continueConversation: async () => {},
|
|
process: async () => {},
|
|
updateActivity: noopUpdateActivity,
|
|
deleteActivity: noopDeleteActivity,
|
|
});
|
|
|
|
const createRecordedSendActivity = (
|
|
sink: string[],
|
|
failFirstWithStatusCode?: number,
|
|
): ((activity: unknown) => Promise<{ id: string }>) => {
|
|
let attempts = 0;
|
|
return async (activity: unknown) => {
|
|
const { text } = activity as { text?: string };
|
|
const content = text ?? "";
|
|
sink.push(content);
|
|
attempts += 1;
|
|
if (failFirstWithStatusCode !== undefined && attempts === 1) {
|
|
throw Object.assign(new Error("send failed"), { statusCode: failFirstWithStatusCode });
|
|
}
|
|
return { id: `id:${content}` };
|
|
};
|
|
};
|
|
|
|
const REVOCATION_ERROR = "Cannot perform 'set' on a proxy that has been revoked";
|
|
|
|
function requireConversationId(ref: { conversation?: { id?: string } }) {
|
|
if (!ref.conversation?.id) {
|
|
throw new Error("expected Teams top-level send to preserve conversation id");
|
|
}
|
|
return ref.conversation.id;
|
|
}
|
|
|
|
function requireSentMessage(sent: Array<{ text?: string; entities?: unknown[] }>) {
|
|
const firstSent = sent[0];
|
|
if (!firstSent?.text) {
|
|
throw new Error("expected Teams message send to include rendered text");
|
|
}
|
|
return firstSent;
|
|
}
|
|
|
|
const createFallbackAdapter = (proactiveSent: string[]): MSTeamsAdapter => ({
|
|
continueConversation: async (_appId, _reference, logic) => {
|
|
await logic({
|
|
sendActivity: createRecordedSendActivity(proactiveSent),
|
|
updateActivity: noopUpdateActivity,
|
|
deleteActivity: noopDeleteActivity,
|
|
});
|
|
},
|
|
process: async () => {},
|
|
updateActivity: noopUpdateActivity,
|
|
deleteActivity: noopDeleteActivity,
|
|
});
|
|
|
|
describe("msteams messenger", () => {
|
|
beforeEach(() => {
|
|
setMSTeamsRuntime(runtimeStub);
|
|
graphUploadMockState.uploadAndShareOneDrive.mockReset();
|
|
graphUploadMockState.uploadAndShareOneDrive.mockResolvedValue({
|
|
itemId: "item123",
|
|
webUrl: "https://onedrive.example.com/item123",
|
|
shareUrl: "https://onedrive.example.com/share/item123",
|
|
name: "upload.txt",
|
|
});
|
|
});
|
|
|
|
describe("renderReplyPayloadsToMessages", () => {
|
|
it("filters silent replies", () => {
|
|
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
|
|
textChunkLimit: 4000,
|
|
tableMode: "code",
|
|
});
|
|
expect(messages).toEqual([]);
|
|
});
|
|
|
|
it("does not filter non-exact silent reply prefixes", () => {
|
|
const messages = renderReplyPayloadsToMessages(
|
|
[{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
|
|
{ textChunkLimit: 4000, tableMode: "code" },
|
|
);
|
|
expect(messages).toEqual([{ text: `${SILENT_REPLY_TOKEN} -- ignored` }]);
|
|
});
|
|
|
|
it("splits media into separate messages by default", () => {
|
|
const messages = renderReplyPayloadsToMessages(
|
|
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
|
{ textChunkLimit: 4000, tableMode: "code" },
|
|
);
|
|
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
|
|
});
|
|
|
|
it("supports inline media mode", () => {
|
|
const messages = renderReplyPayloadsToMessages(
|
|
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
|
{ textChunkLimit: 4000, mediaMode: "inline", tableMode: "code" },
|
|
);
|
|
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
|
|
});
|
|
|
|
it("chunks long text when enabled", () => {
|
|
const long = "hello ".repeat(200);
|
|
const messages = renderReplyPayloadsToMessages([{ text: long }], {
|
|
textChunkLimit: 50,
|
|
tableMode: "code",
|
|
});
|
|
expect(messages.length).toBeGreaterThan(1);
|
|
});
|
|
});
|
|
|
|
describe("sendMSTeamsMessages", () => {
|
|
function createRevokedThreadContext(params?: { failAfterAttempt?: number; sent?: string[] }) {
|
|
let attempt = 0;
|
|
return {
|
|
sendActivity: async (activity: unknown) => {
|
|
const { text } = activity as { text?: string };
|
|
const content = text ?? "";
|
|
attempt += 1;
|
|
if (params?.failAfterAttempt && attempt < params.failAfterAttempt) {
|
|
params.sent?.push(content);
|
|
return { id: `id:${content}` };
|
|
}
|
|
throw new TypeError(REVOCATION_ERROR);
|
|
},
|
|
updateActivity: noopUpdateActivity,
|
|
deleteActivity: noopDeleteActivity,
|
|
};
|
|
}
|
|
|
|
const baseRef: StoredConversationReference = {
|
|
activityId: "activity123",
|
|
user: { id: "user123", name: "User" },
|
|
agent: { id: "bot123", name: "Bot" },
|
|
conversation: { id: "19:abc@thread.tacv2;messageid=deadbeef" },
|
|
channelId: "msteams",
|
|
serviceUrl: "https://service.example.com",
|
|
};
|
|
|
|
it("sends thread messages via the provided context", async () => {
|
|
const sent: string[] = [];
|
|
const ctx = {
|
|
sendActivity: createRecordedSendActivity(sent),
|
|
updateActivity: noopUpdateActivity,
|
|
deleteActivity: noopDeleteActivity,
|
|
};
|
|
const adapter = createNoopAdapter();
|
|
|
|
const ids = await sendMSTeamsMessages({
|
|
replyStyle: "thread",
|
|
adapter,
|
|
appId: "app123",
|
|
conversationRef: baseRef,
|
|
context: ctx,
|
|
messages: [{ text: "one" }, { text: "two" }],
|
|
});
|
|
|
|
expect(sent).toEqual(["one", "two"]);
|
|
expect(ids).toEqual(["id:one", "id:two"]);
|
|
});
|
|
|
|
it("sends top-level messages via continueConversation and strips activityId", async () => {
|
|
const seen: { reference?: unknown; texts: string[] } = { texts: [] };
|
|
|
|
const adapter: MSTeamsAdapter = {
|
|
continueConversation: async (_appId, reference, logic) => {
|
|
seen.reference = reference;
|
|
await logic({
|
|
sendActivity: createRecordedSendActivity(seen.texts),
|
|
updateActivity: noopUpdateActivity,
|
|
deleteActivity: noopDeleteActivity,
|
|
});
|
|
},
|
|
process: async () => {},
|
|
updateActivity: noopUpdateActivity,
|
|
deleteActivity: noopDeleteActivity,
|
|
};
|
|
|
|
const ids = await sendMSTeamsMessages({
|
|
replyStyle: "top-level",
|
|
adapter,
|
|
appId: "app123",
|
|
conversationRef: baseRef,
|
|
messages: [{ text: "hello" }],
|
|
});
|
|
|
|
expect(seen.texts).toEqual(["hello"]);
|
|
expect(ids).toEqual(["id:hello"]);
|
|
|
|
const ref = seen.reference as {
|
|
activityId?: string;
|
|
conversation?: { id?: string };
|
|
};
|
|
expect(ref.activityId).toBeUndefined();
|
|
expect(requireConversationId(ref)).toBe("19:abc@thread.tacv2");
|
|
});
|
|
|
|
it("preserves parsed mentions when appending OneDrive fallback file links", async () => {
|
|
const tmpDir = await mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "msteams-mention-"));
|
|
const localFile = path.join(tmpDir, "note.txt");
|
|
await writeFile(localFile, "hello");
|
|
|
|
try {
|
|
const sent: Array<{ text?: string; entities?: unknown[] }> = [];
|
|
const ctx = {
|
|
sendActivity: async (activity: unknown) => {
|
|
sent.push(activity as { text?: string; entities?: unknown[] });
|
|
return { id: "id:one" };
|
|
},
|
|
updateActivity: noopUpdateActivity,
|
|
deleteActivity: noopDeleteActivity,
|
|
};
|
|
|
|
const adapter = createNoopAdapter();
|
|
|
|
const ids = await sendMSTeamsMessages({
|
|
replyStyle: "thread",
|
|
adapter,
|
|
appId: "app123",
|
|
conversationRef: {
|
|
...baseRef,
|
|
conversation: {
|
|
...baseRef.conversation,
|
|
conversationType: "channel",
|
|
},
|
|
},
|
|
context: ctx,
|
|
messages: [{ text: "Hello @[John](29:08q2j2o3jc09au90eucae)", mediaUrl: localFile }],
|
|
tokenProvider: {
|
|
getAccessToken: async () => "token",
|
|
},
|
|
});
|
|
|
|
expect(ids).toEqual(["id:one"]);
|
|
expect(graphUploadMockState.uploadAndShareOneDrive).toHaveBeenCalledOnce();
|
|
expect(sent).toHaveLength(1);
|
|
const firstSent = requireSentMessage(sent);
|
|
expect(firstSent.text).toContain("Hello <at>John</at>");
|
|
expect(firstSent.text).toContain(
|
|
"📎 [upload.txt](https://onedrive.example.com/share/item123)",
|
|
);
|
|
expect(sent[0]?.entities).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
type: "mention",
|
|
text: "<at>John</at>",
|
|
mentioned: {
|
|
id: "29:08q2j2o3jc09au90eucae",
|
|
name: "John",
|
|
},
|
|
},
|
|
expect.objectContaining({
|
|
additionalType: ["AIGeneratedContent"],
|
|
}),
|
|
]),
|
|
);
|
|
} finally {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("retries thread sends on throttling (429)", async () => {
|
|
const attempts: string[] = [];
|
|
const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = [];
|
|
|
|
const ctx = {
|
|
sendActivity: createRecordedSendActivity(attempts, 429),
|
|
updateActivity: noopUpdateActivity,
|
|
deleteActivity: noopDeleteActivity,
|
|
};
|
|
const adapter = createNoopAdapter();
|
|
|
|
const ids = await sendMSTeamsMessages({
|
|
replyStyle: "thread",
|
|
adapter,
|
|
appId: "app123",
|
|
conversationRef: baseRef,
|
|
context: ctx,
|
|
messages: [{ text: "one" }],
|
|
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
|
onRetry: (e) => retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }),
|
|
});
|
|
|
|
expect(attempts).toEqual(["one", "one"]);
|
|
expect(ids).toEqual(["id:one"]);
|
|
expect(retryEvents).toEqual([{ nextAttempt: 2, delayMs: 0 }]);
|
|
});
|
|
|
|
it("does not retry thread sends on client errors (4xx)", async () => {
|
|
const ctx = {
|
|
sendActivity: async () => {
|
|
throw Object.assign(new Error("bad request"), { statusCode: 400 });
|
|
},
|
|
updateActivity: noopUpdateActivity,
|
|
deleteActivity: noopDeleteActivity,
|
|
};
|
|
|
|
const adapter = createNoopAdapter();
|
|
|
|
await expect(
|
|
sendMSTeamsMessages({
|
|
replyStyle: "thread",
|
|
adapter,
|
|
appId: "app123",
|
|
conversationRef: baseRef,
|
|
context: ctx,
|
|
messages: [{ text: "one" }],
|
|
retry: { maxAttempts: 3, baseDelayMs: 0, maxDelayMs: 0 },
|
|
}),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it("falls back to proactive messaging when thread context is revoked", async () => {
|
|
const proactiveSent: string[] = [];
|
|
const ctx = createRevokedThreadContext();
|
|
const adapter = createFallbackAdapter(proactiveSent);
|
|
|
|
const ids = await sendMSTeamsMessages({
|
|
replyStyle: "thread",
|
|
adapter,
|
|
appId: "app123",
|
|
conversationRef: baseRef,
|
|
context: ctx,
|
|
messages: [{ text: "hello" }],
|
|
});
|
|
|
|
// Should have fallen back to proactive messaging
|
|
expect(proactiveSent).toEqual(["hello"]);
|
|
expect(ids).toEqual(["id:hello"]);
|
|
});
|
|
|
|
it("falls back only for remaining thread messages after context revocation", async () => {
|
|
const threadSent: string[] = [];
|
|
const proactiveSent: string[] = [];
|
|
const ctx = createRevokedThreadContext({ failAfterAttempt: 2, sent: threadSent });
|
|
const adapter = createFallbackAdapter(proactiveSent);
|
|
|
|
const ids = await sendMSTeamsMessages({
|
|
replyStyle: "thread",
|
|
adapter,
|
|
appId: "app123",
|
|
conversationRef: baseRef,
|
|
context: ctx,
|
|
messages: [{ text: "one" }, { text: "two" }, { text: "three" }],
|
|
});
|
|
|
|
expect(threadSent).toEqual(["one"]);
|
|
expect(proactiveSent).toEqual(["two", "three"]);
|
|
expect(ids).toEqual(["id:one", "id:two", "id:three"]);
|
|
});
|
|
|
|
it("retries top-level sends on transient (5xx)", async () => {
|
|
const attempts: string[] = [];
|
|
|
|
const adapter: MSTeamsAdapter = {
|
|
continueConversation: async (_appId, _reference, logic) => {
|
|
await logic({
|
|
sendActivity: createRecordedSendActivity(attempts, 503),
|
|
updateActivity: noopUpdateActivity,
|
|
deleteActivity: noopDeleteActivity,
|
|
});
|
|
},
|
|
process: async () => {},
|
|
updateActivity: noopUpdateActivity,
|
|
deleteActivity: noopDeleteActivity,
|
|
};
|
|
|
|
const ids = await sendMSTeamsMessages({
|
|
replyStyle: "top-level",
|
|
adapter,
|
|
appId: "app123",
|
|
conversationRef: baseRef,
|
|
messages: [{ text: "hello" }],
|
|
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
|
});
|
|
|
|
expect(attempts).toEqual(["hello", "hello"]);
|
|
expect(ids).toEqual(["id:hello"]);
|
|
});
|
|
|
|
it("delivers all blocks in a multi-block reply via a single continueConversation call (#29379)", async () => {
|
|
// Regression: multiple text blocks (e.g. text -> tool -> text) must all
|
|
// reach the user. Previously each deliver() call opened a separate
|
|
// continueConversation(); Teams silently drops blocks 2+ in that case.
|
|
// The fix batches all rendered messages into one sendMSTeamsMessages call
|
|
// so they share a single continueConversation().
|
|
const conversationCallTexts: string[][] = [];
|
|
const adapter: MSTeamsAdapter = {
|
|
continueConversation: async (_appId, _reference, logic) => {
|
|
const batchTexts: string[] = [];
|
|
await logic({
|
|
sendActivity: async (activity: unknown) => {
|
|
const { text } = activity as { text?: string };
|
|
batchTexts.push(text ?? "");
|
|
return { id: `id:${text ?? ""}` };
|
|
},
|
|
updateActivity: noopUpdateActivity,
|
|
deleteActivity: noopDeleteActivity,
|
|
});
|
|
conversationCallTexts.push(batchTexts);
|
|
},
|
|
process: async () => {},
|
|
updateActivity: noopUpdateActivity,
|
|
deleteActivity: noopDeleteActivity,
|
|
};
|
|
|
|
// Three blocks (text + code + text) sent together in one call.
|
|
const ids = await sendMSTeamsMessages({
|
|
replyStyle: "top-level",
|
|
adapter,
|
|
appId: "app123",
|
|
conversationRef: baseRef,
|
|
messages: [
|
|
{ text: "Let me look that up..." },
|
|
{ text: "```\nresult = 42\n```" },
|
|
{ text: "The answer is 42." },
|
|
],
|
|
});
|
|
|
|
// All three blocks delivered.
|
|
expect(ids).toHaveLength(3);
|
|
// All three arrive in a single continueConversation() call, not three.
|
|
expect(conversationCallTexts).toHaveLength(1);
|
|
expect(conversationCallTexts[0]).toEqual([
|
|
"Let me look that up...",
|
|
"```\nresult = 42\n```",
|
|
"The answer is 42.",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("buildActivity AI metadata", () => {
|
|
const baseRef: StoredConversationReference = {
|
|
activityId: "activity123",
|
|
user: { id: "user123", name: "User" },
|
|
agent: { id: "bot123", name: "Bot" },
|
|
conversation: { id: "conv123", conversationType: "personal" },
|
|
channelId: "msteams",
|
|
serviceUrl: "https://service.example.com",
|
|
};
|
|
|
|
it("adds AI-generated entity to text messages", async () => {
|
|
const activity = await buildActivity({ text: "hello" }, baseRef);
|
|
const entities = activity.entities as Array<Record<string, unknown>>;
|
|
expect(entities).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: "https://schema.org/Message",
|
|
"@type": "Message",
|
|
additionalType: ["AIGeneratedContent"],
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("adds AI-generated entity to media-only messages", async () => {
|
|
const activity = await buildActivity({ mediaUrl: "https://example.com/img.png" }, baseRef);
|
|
const entities = activity.entities as Array<Record<string, unknown>>;
|
|
expect(entities).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
additionalType: ["AIGeneratedContent"],
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("preserves mention entities alongside AI entity", async () => {
|
|
const activity = await buildActivity({ text: "hi <at>@User</at>" }, baseRef);
|
|
const entities = activity.entities as Array<Record<string, unknown>>;
|
|
// Should have at least the AI entity
|
|
expect(entities.length).toBeGreaterThanOrEqual(1);
|
|
expect(entities).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
additionalType: ["AIGeneratedContent"],
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("sets feedbackLoopEnabled in channelData when enabled", async () => {
|
|
const activity = await buildActivity(
|
|
{ text: "hello" },
|
|
baseRef,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{
|
|
feedbackLoopEnabled: true,
|
|
},
|
|
);
|
|
const channelData = activity.channelData as Record<string, unknown>;
|
|
expect(channelData.feedbackLoopEnabled).toBe(true);
|
|
});
|
|
|
|
it("defaults feedbackLoopEnabled to false", async () => {
|
|
const activity = await buildActivity({ text: "hello" }, baseRef);
|
|
const channelData = activity.channelData as Record<string, unknown>;
|
|
expect(channelData.feedbackLoopEnabled).toBe(false);
|
|
});
|
|
});
|
|
});
|