239 lines
7.3 KiB
TypeScript
239 lines
7.3 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { SILENT_REPLY_TOKEN, type PluginRuntime } from "clawdbot/plugin-sdk";
|
|
import type { StoredConversationReference } from "./conversation-store.js";
|
|
import {
|
|
type MSTeamsAdapter,
|
|
renderReplyPayloadsToMessages,
|
|
sendMSTeamsMessages,
|
|
} from "./messenger.js";
|
|
import { setMSTeamsRuntime } from "./runtime.js";
|
|
|
|
const runtimeStub = {
|
|
channel: {
|
|
text: {
|
|
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;
|
|
},
|
|
},
|
|
},
|
|
} as unknown as PluginRuntime;
|
|
|
|
describe("msteams messenger", () => {
|
|
beforeEach(() => {
|
|
setMSTeamsRuntime(runtimeStub);
|
|
});
|
|
|
|
describe("renderReplyPayloadsToMessages", () => {
|
|
it("filters silent replies", () => {
|
|
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
|
|
textChunkLimit: 4000,
|
|
});
|
|
expect(messages).toEqual([]);
|
|
});
|
|
|
|
it("filters silent reply prefixes", () => {
|
|
const messages = renderReplyPayloadsToMessages(
|
|
[{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
|
|
{ textChunkLimit: 4000 },
|
|
);
|
|
expect(messages).toEqual([]);
|
|
});
|
|
|
|
it("splits media into separate messages by default", () => {
|
|
const messages = renderReplyPayloadsToMessages(
|
|
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
|
{ textChunkLimit: 4000 },
|
|
);
|
|
expect(messages).toEqual(["hi", "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" },
|
|
);
|
|
expect(messages).toEqual(["hi\n\nhttps://example.com/a.png"]);
|
|
});
|
|
|
|
it("chunks long text when enabled", () => {
|
|
const long = "hello ".repeat(200);
|
|
const messages = renderReplyPayloadsToMessages([{ text: long }], {
|
|
textChunkLimit: 50,
|
|
});
|
|
expect(messages.length).toBeGreaterThan(1);
|
|
});
|
|
});
|
|
|
|
describe("sendMSTeamsMessages", () => {
|
|
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: async (activity: unknown) => {
|
|
const { text } = activity as { text?: string };
|
|
sent.push(text ?? "");
|
|
return { id: `id:${text ?? ""}` };
|
|
},
|
|
};
|
|
|
|
const adapter: MSTeamsAdapter = {
|
|
continueConversation: async () => {},
|
|
};
|
|
|
|
const ids = await sendMSTeamsMessages({
|
|
replyStyle: "thread",
|
|
adapter,
|
|
appId: "app123",
|
|
conversationRef: baseRef,
|
|
context: ctx,
|
|
messages: ["one", "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: async (activity: unknown) => {
|
|
const { text } = activity as { text?: string };
|
|
seen.texts.push(text ?? "");
|
|
return { id: `id:${text ?? ""}` };
|
|
},
|
|
});
|
|
},
|
|
};
|
|
|
|
const ids = await sendMSTeamsMessages({
|
|
replyStyle: "top-level",
|
|
adapter,
|
|
appId: "app123",
|
|
conversationRef: baseRef,
|
|
messages: ["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(ref.conversation?.id).toBe("19:abc@thread.tacv2");
|
|
});
|
|
|
|
it("retries thread sends on throttling (429)", async () => {
|
|
const attempts: string[] = [];
|
|
const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = [];
|
|
|
|
const ctx = {
|
|
sendActivity: async (activity: unknown) => {
|
|
const { text } = activity as { text?: string };
|
|
attempts.push(text ?? "");
|
|
if (attempts.length === 1) {
|
|
throw Object.assign(new Error("throttled"), { statusCode: 429 });
|
|
}
|
|
return { id: `id:${text ?? ""}` };
|
|
},
|
|
};
|
|
|
|
const adapter: MSTeamsAdapter = {
|
|
continueConversation: async () => {},
|
|
};
|
|
|
|
const ids = await sendMSTeamsMessages({
|
|
replyStyle: "thread",
|
|
adapter,
|
|
appId: "app123",
|
|
conversationRef: baseRef,
|
|
context: ctx,
|
|
messages: ["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 });
|
|
},
|
|
};
|
|
|
|
const adapter: MSTeamsAdapter = {
|
|
continueConversation: async () => {},
|
|
};
|
|
|
|
await expect(
|
|
sendMSTeamsMessages({
|
|
replyStyle: "thread",
|
|
adapter,
|
|
appId: "app123",
|
|
conversationRef: baseRef,
|
|
context: ctx,
|
|
messages: ["one"],
|
|
retry: { maxAttempts: 3, baseDelayMs: 0, maxDelayMs: 0 },
|
|
}),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it("retries top-level sends on transient (5xx)", async () => {
|
|
const attempts: string[] = [];
|
|
|
|
const adapter: MSTeamsAdapter = {
|
|
continueConversation: async (_appId, _reference, logic) => {
|
|
await logic({
|
|
sendActivity: async (activity: unknown) => {
|
|
const { text } = activity as { text?: string };
|
|
attempts.push(text ?? "");
|
|
if (attempts.length === 1) {
|
|
throw Object.assign(new Error("server error"), {
|
|
statusCode: 503,
|
|
});
|
|
}
|
|
return { id: `id:${text ?? ""}` };
|
|
},
|
|
});
|
|
},
|
|
};
|
|
|
|
const ids = await sendMSTeamsMessages({
|
|
replyStyle: "top-level",
|
|
adapter,
|
|
appId: "app123",
|
|
conversationRef: baseRef,
|
|
messages: ["hello"],
|
|
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
|
});
|
|
|
|
expect(attempts).toEqual(["hello", "hello"]);
|
|
expect(ids).toEqual(["id:hello"]);
|
|
});
|
|
});
|
|
});
|