@openclaw/msteams 2026.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/index.ts +18 -0
  3. package/openclaw.plugin.json +11 -0
  4. package/package.json +36 -0
  5. package/src/attachments/download.ts +206 -0
  6. package/src/attachments/graph.ts +319 -0
  7. package/src/attachments/html.ts +76 -0
  8. package/src/attachments/payload.ts +22 -0
  9. package/src/attachments/shared.ts +235 -0
  10. package/src/attachments/types.ts +37 -0
  11. package/src/attachments.test.ts +424 -0
  12. package/src/attachments.ts +18 -0
  13. package/src/channel.directory.test.ts +46 -0
  14. package/src/channel.ts +436 -0
  15. package/src/conversation-store-fs.test.ts +89 -0
  16. package/src/conversation-store-fs.ts +155 -0
  17. package/src/conversation-store-memory.ts +45 -0
  18. package/src/conversation-store.ts +41 -0
  19. package/src/directory-live.ts +179 -0
  20. package/src/errors.test.ts +46 -0
  21. package/src/errors.ts +158 -0
  22. package/src/file-consent-helpers.test.ts +234 -0
  23. package/src/file-consent-helpers.ts +73 -0
  24. package/src/file-consent.ts +122 -0
  25. package/src/graph-chat.ts +52 -0
  26. package/src/graph-upload.ts +445 -0
  27. package/src/inbound.test.ts +67 -0
  28. package/src/inbound.ts +38 -0
  29. package/src/index.ts +4 -0
  30. package/src/media-helpers.test.ts +186 -0
  31. package/src/media-helpers.ts +77 -0
  32. package/src/messenger.test.ts +245 -0
  33. package/src/messenger.ts +460 -0
  34. package/src/monitor-handler/inbound-media.ts +123 -0
  35. package/src/monitor-handler/message-handler.ts +629 -0
  36. package/src/monitor-handler.ts +166 -0
  37. package/src/monitor-types.ts +5 -0
  38. package/src/monitor.ts +290 -0
  39. package/src/onboarding.ts +432 -0
  40. package/src/outbound.ts +47 -0
  41. package/src/pending-uploads.ts +87 -0
  42. package/src/policy.test.ts +210 -0
  43. package/src/policy.ts +247 -0
  44. package/src/polls-store-memory.ts +30 -0
  45. package/src/polls-store.test.ts +40 -0
  46. package/src/polls.test.ts +73 -0
  47. package/src/polls.ts +300 -0
  48. package/src/probe.test.ts +57 -0
  49. package/src/probe.ts +99 -0
  50. package/src/reply-dispatcher.ts +128 -0
  51. package/src/resolve-allowlist.ts +277 -0
  52. package/src/runtime.ts +14 -0
  53. package/src/sdk-types.ts +19 -0
  54. package/src/sdk.ts +33 -0
  55. package/src/send-context.ts +156 -0
  56. package/src/send.ts +489 -0
  57. package/src/sent-message-cache.test.ts +16 -0
  58. package/src/sent-message-cache.ts +41 -0
  59. package/src/storage.ts +22 -0
  60. package/src/store-fs.ts +80 -0
  61. package/src/token.ts +19 -0
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ normalizeMSTeamsConversationId,
5
+ parseMSTeamsActivityTimestamp,
6
+ stripMSTeamsMentionTags,
7
+ wasMSTeamsBotMentioned,
8
+ } from "./inbound.js";
9
+
10
+ describe("msteams inbound", () => {
11
+ describe("stripMSTeamsMentionTags", () => {
12
+ it("removes <at>...</at> tags and trims", () => {
13
+ expect(stripMSTeamsMentionTags("<at>Bot</at> hi")).toBe("hi");
14
+ expect(stripMSTeamsMentionTags("hi <at>Bot</at>")).toBe("hi");
15
+ });
16
+
17
+ it("removes <at ...> tags with attributes", () => {
18
+ expect(stripMSTeamsMentionTags('<at id="1">Bot</at> hi')).toBe("hi");
19
+ expect(stripMSTeamsMentionTags('hi <at itemid="2">Bot</at>')).toBe("hi");
20
+ });
21
+ });
22
+
23
+ describe("normalizeMSTeamsConversationId", () => {
24
+ it("strips the ;messageid suffix", () => {
25
+ expect(normalizeMSTeamsConversationId("19:abc@thread.tacv2;messageid=deadbeef")).toBe(
26
+ "19:abc@thread.tacv2",
27
+ );
28
+ });
29
+ });
30
+
31
+ describe("parseMSTeamsActivityTimestamp", () => {
32
+ it("returns undefined for empty/invalid values", () => {
33
+ expect(parseMSTeamsActivityTimestamp(undefined)).toBeUndefined();
34
+ expect(parseMSTeamsActivityTimestamp("not-a-date")).toBeUndefined();
35
+ });
36
+
37
+ it("parses string timestamps", () => {
38
+ const ts = parseMSTeamsActivityTimestamp("2024-01-01T00:00:00.000Z");
39
+ expect(ts?.toISOString()).toBe("2024-01-01T00:00:00.000Z");
40
+ });
41
+
42
+ it("passes through Date instances", () => {
43
+ const d = new Date("2024-01-01T00:00:00.000Z");
44
+ expect(parseMSTeamsActivityTimestamp(d)).toBe(d);
45
+ });
46
+ });
47
+
48
+ describe("wasMSTeamsBotMentioned", () => {
49
+ it("returns true when a mention entity matches recipient.id", () => {
50
+ expect(
51
+ wasMSTeamsBotMentioned({
52
+ recipient: { id: "bot" },
53
+ entities: [{ type: "mention", mentioned: { id: "bot" } }],
54
+ }),
55
+ ).toBe(true);
56
+ });
57
+
58
+ it("returns false when there is no matching mention", () => {
59
+ expect(
60
+ wasMSTeamsBotMentioned({
61
+ recipient: { id: "bot" },
62
+ entities: [{ type: "mention", mentioned: { id: "other" } }],
63
+ }),
64
+ ).toBe(false);
65
+ });
66
+ });
67
+ });
package/src/inbound.ts ADDED
@@ -0,0 +1,38 @@
1
+ export type MentionableActivity = {
2
+ recipient?: { id?: string } | null;
3
+ entities?: Array<{
4
+ type?: string;
5
+ mentioned?: { id?: string };
6
+ }> | null;
7
+ };
8
+
9
+ export function normalizeMSTeamsConversationId(raw: string): string {
10
+ return raw.split(";")[0] ?? raw;
11
+ }
12
+
13
+ export function extractMSTeamsConversationMessageId(raw: string): string | undefined {
14
+ if (!raw) return undefined;
15
+ const match = /(?:^|;)messageid=([^;]+)/i.exec(raw);
16
+ const value = match?.[1]?.trim() ?? "";
17
+ return value || undefined;
18
+ }
19
+
20
+ export function parseMSTeamsActivityTimestamp(value: unknown): Date | undefined {
21
+ if (!value) return undefined;
22
+ if (value instanceof Date) return value;
23
+ if (typeof value !== "string") return undefined;
24
+ const date = new Date(value);
25
+ return Number.isNaN(date.getTime()) ? undefined : date;
26
+ }
27
+
28
+ export function stripMSTeamsMentionTags(text: string): string {
29
+ // Teams wraps mentions in <at>...</at> tags
30
+ return text.replace(/<at[^>]*>.*?<\/at>/gi, "").trim();
31
+ }
32
+
33
+ export function wasMSTeamsBotMentioned(activity: MentionableActivity): boolean {
34
+ const botId = activity.recipient?.id;
35
+ if (!botId) return false;
36
+ const entities = activity.entities ?? [];
37
+ return entities.some((e) => e.type === "mention" && e.mentioned?.id === botId);
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { monitorMSTeamsProvider } from "./monitor.js";
2
+ export { probeMSTeams } from "./probe.js";
3
+ export { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
4
+ export { type MSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js";
@@ -0,0 +1,186 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
4
+
5
+ describe("msteams media-helpers", () => {
6
+ describe("getMimeType", () => {
7
+ it("detects png from URL", async () => {
8
+ expect(await getMimeType("https://example.com/image.png")).toBe("image/png");
9
+ });
10
+
11
+ it("detects jpeg from URL (both extensions)", async () => {
12
+ expect(await getMimeType("https://example.com/photo.jpg")).toBe("image/jpeg");
13
+ expect(await getMimeType("https://example.com/photo.jpeg")).toBe("image/jpeg");
14
+ });
15
+
16
+ it("detects gif from URL", async () => {
17
+ expect(await getMimeType("https://example.com/anim.gif")).toBe("image/gif");
18
+ });
19
+
20
+ it("detects webp from URL", async () => {
21
+ expect(await getMimeType("https://example.com/modern.webp")).toBe("image/webp");
22
+ });
23
+
24
+ it("handles URLs with query strings", async () => {
25
+ expect(await getMimeType("https://example.com/image.png?v=123")).toBe("image/png");
26
+ });
27
+
28
+ it("handles data URLs", async () => {
29
+ expect(await getMimeType("data:image/png;base64,iVBORw0KGgo=")).toBe("image/png");
30
+ expect(await getMimeType("data:image/jpeg;base64,/9j/4AAQ")).toBe("image/jpeg");
31
+ expect(await getMimeType("data:image/gif;base64,R0lGOD")).toBe("image/gif");
32
+ });
33
+
34
+ it("handles data URLs without base64", async () => {
35
+ expect(await getMimeType("data:image/svg+xml,%3Csvg")).toBe("image/svg+xml");
36
+ });
37
+
38
+ it("handles local paths", async () => {
39
+ expect(await getMimeType("/tmp/image.png")).toBe("image/png");
40
+ expect(await getMimeType("/Users/test/photo.jpg")).toBe("image/jpeg");
41
+ });
42
+
43
+ it("handles tilde paths", async () => {
44
+ expect(await getMimeType("~/Downloads/image.gif")).toBe("image/gif");
45
+ });
46
+
47
+ it("defaults to application/octet-stream for unknown extensions", async () => {
48
+ expect(await getMimeType("https://example.com/image")).toBe("application/octet-stream");
49
+ expect(await getMimeType("https://example.com/image.unknown")).toBe("application/octet-stream");
50
+ });
51
+
52
+ it("is case-insensitive", async () => {
53
+ expect(await getMimeType("https://example.com/IMAGE.PNG")).toBe("image/png");
54
+ expect(await getMimeType("https://example.com/Photo.JPEG")).toBe("image/jpeg");
55
+ });
56
+
57
+ it("detects document types", async () => {
58
+ expect(await getMimeType("https://example.com/doc.pdf")).toBe("application/pdf");
59
+ expect(await getMimeType("https://example.com/doc.docx")).toBe(
60
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
61
+ );
62
+ expect(await getMimeType("https://example.com/spreadsheet.xlsx")).toBe(
63
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
64
+ );
65
+ });
66
+ });
67
+
68
+ describe("extractFilename", () => {
69
+ it("extracts filename from URL with extension", async () => {
70
+ expect(await extractFilename("https://example.com/photo.jpg")).toBe("photo.jpg");
71
+ });
72
+
73
+ it("extracts filename from URL with path", async () => {
74
+ expect(await extractFilename("https://example.com/images/2024/photo.png")).toBe("photo.png");
75
+ });
76
+
77
+ it("handles URLs without extension by deriving from MIME", async () => {
78
+ // Now defaults to application/octet-stream → .bin fallback
79
+ expect(await extractFilename("https://example.com/images/photo")).toBe("photo.bin");
80
+ });
81
+
82
+ it("handles data URLs", async () => {
83
+ expect(await extractFilename("data:image/png;base64,iVBORw0KGgo=")).toBe("image.png");
84
+ expect(await extractFilename("data:image/jpeg;base64,/9j/4AAQ")).toBe("image.jpg");
85
+ });
86
+
87
+ it("handles document data URLs", async () => {
88
+ expect(await extractFilename("data:application/pdf;base64,JVBERi0")).toBe("file.pdf");
89
+ });
90
+
91
+ it("handles local paths", async () => {
92
+ expect(await extractFilename("/tmp/screenshot.png")).toBe("screenshot.png");
93
+ expect(await extractFilename("/Users/test/photo.jpg")).toBe("photo.jpg");
94
+ });
95
+
96
+ it("handles tilde paths", async () => {
97
+ expect(await extractFilename("~/Downloads/image.gif")).toBe("image.gif");
98
+ });
99
+
100
+ it("returns fallback for empty URL", async () => {
101
+ expect(await extractFilename("")).toBe("file.bin");
102
+ });
103
+
104
+ it("extracts original filename from embedded pattern", async () => {
105
+ // Pattern: {original}---{uuid}.{ext}
106
+ expect(
107
+ await extractFilename("/media/inbound/report---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"),
108
+ ).toBe("report.pdf");
109
+ });
110
+
111
+ it("extracts original filename with uppercase UUID", async () => {
112
+ expect(
113
+ await extractFilename("/media/inbound/Document---A1B2C3D4-E5F6-7890-ABCD-EF1234567890.docx"),
114
+ ).toBe("Document.docx");
115
+ });
116
+
117
+ it("falls back to UUID filename for legacy paths", async () => {
118
+ // UUID-only filename (legacy format, no embedded name)
119
+ expect(
120
+ await extractFilename("/media/inbound/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"),
121
+ ).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf");
122
+ });
123
+
124
+ it("handles --- in filename without valid UUID pattern", async () => {
125
+ // foo---bar.txt (bar is not a valid UUID)
126
+ expect(await extractFilename("/media/inbound/foo---bar.txt")).toBe("foo---bar.txt");
127
+ });
128
+ });
129
+
130
+ describe("isLocalPath", () => {
131
+ it("returns true for file:// URLs", () => {
132
+ expect(isLocalPath("file:///tmp/image.png")).toBe(true);
133
+ expect(isLocalPath("file://localhost/tmp/image.png")).toBe(true);
134
+ });
135
+
136
+ it("returns true for absolute paths", () => {
137
+ expect(isLocalPath("/tmp/image.png")).toBe(true);
138
+ expect(isLocalPath("/Users/test/photo.jpg")).toBe(true);
139
+ });
140
+
141
+ it("returns true for tilde paths", () => {
142
+ expect(isLocalPath("~/Downloads/image.png")).toBe(true);
143
+ });
144
+
145
+ it("returns false for http URLs", () => {
146
+ expect(isLocalPath("http://example.com/image.png")).toBe(false);
147
+ expect(isLocalPath("https://example.com/image.png")).toBe(false);
148
+ });
149
+
150
+ it("returns false for data URLs", () => {
151
+ expect(isLocalPath("data:image/png;base64,iVBORw0KGgo=")).toBe(false);
152
+ });
153
+ });
154
+
155
+ describe("extractMessageId", () => {
156
+ it("extracts id from valid response", () => {
157
+ expect(extractMessageId({ id: "msg123" })).toBe("msg123");
158
+ });
159
+
160
+ it("returns null for missing id", () => {
161
+ expect(extractMessageId({ foo: "bar" })).toBeNull();
162
+ });
163
+
164
+ it("returns null for empty id", () => {
165
+ expect(extractMessageId({ id: "" })).toBeNull();
166
+ });
167
+
168
+ it("returns null for non-string id", () => {
169
+ expect(extractMessageId({ id: 123 })).toBeNull();
170
+ expect(extractMessageId({ id: null })).toBeNull();
171
+ });
172
+
173
+ it("returns null for null response", () => {
174
+ expect(extractMessageId(null)).toBeNull();
175
+ });
176
+
177
+ it("returns null for undefined response", () => {
178
+ expect(extractMessageId(undefined)).toBeNull();
179
+ });
180
+
181
+ it("returns null for non-object response", () => {
182
+ expect(extractMessageId("string")).toBeNull();
183
+ expect(extractMessageId(123)).toBeNull();
184
+ });
185
+ });
186
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * MIME type detection and filename extraction for MSTeams media attachments.
3
+ */
4
+
5
+ import path from "node:path";
6
+
7
+ import {
8
+ detectMime,
9
+ extensionForMime,
10
+ extractOriginalFilename,
11
+ getFileExtension,
12
+ } from "openclaw/plugin-sdk";
13
+
14
+ /**
15
+ * Detect MIME type from URL extension or data URL.
16
+ * Uses shared MIME detection for consistency with core handling.
17
+ */
18
+ export async function getMimeType(url: string): Promise<string> {
19
+ // Handle data URLs: data:image/png;base64,...
20
+ if (url.startsWith("data:")) {
21
+ const match = url.match(/^data:([^;,]+)/);
22
+ if (match?.[1]) return match[1];
23
+ }
24
+
25
+ // Use shared MIME detection (extension-based for URLs)
26
+ const detected = await detectMime({ filePath: url });
27
+ return detected ?? "application/octet-stream";
28
+ }
29
+
30
+ /**
31
+ * Extract filename from URL or local path.
32
+ * For local paths, extracts original filename if stored with embedded name pattern.
33
+ * Falls back to deriving the extension from MIME type when no extension present.
34
+ */
35
+ export async function extractFilename(url: string): Promise<string> {
36
+ // Handle data URLs: derive extension from MIME
37
+ if (url.startsWith("data:")) {
38
+ const mime = await getMimeType(url);
39
+ const ext = extensionForMime(mime) ?? ".bin";
40
+ const prefix = mime.startsWith("image/") ? "image" : "file";
41
+ return `${prefix}${ext}`;
42
+ }
43
+
44
+ // Try to extract from URL pathname
45
+ try {
46
+ const pathname = new URL(url).pathname;
47
+ const basename = path.basename(pathname);
48
+ const existingExt = getFileExtension(pathname);
49
+ if (basename && existingExt) return basename;
50
+ // No extension in URL, derive from MIME
51
+ const mime = await getMimeType(url);
52
+ const ext = extensionForMime(mime) ?? ".bin";
53
+ const prefix = mime.startsWith("image/") ? "image" : "file";
54
+ return basename ? `${basename}${ext}` : `${prefix}${ext}`;
55
+ } catch {
56
+ // Local paths - use extractOriginalFilename to extract embedded original name
57
+ return extractOriginalFilename(url);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Check if a URL refers to a local file path.
63
+ */
64
+ export function isLocalPath(url: string): boolean {
65
+ return url.startsWith("file://") || url.startsWith("/") || url.startsWith("~");
66
+ }
67
+
68
+ /**
69
+ * Extract the message ID from a Bot Framework response.
70
+ */
71
+ export function extractMessageId(response: unknown): string | null {
72
+ if (!response || typeof response !== "object") return null;
73
+ if (!("id" in response)) return null;
74
+ const { id } = response as { id?: unknown };
75
+ if (typeof id !== "string" || !id) return null;
76
+ return id;
77
+ }
@@ -0,0 +1,245 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+
3
+ import { SILENT_REPLY_TOKEN, type PluginRuntime } from "openclaw/plugin-sdk";
4
+ import type { StoredConversationReference } from "./conversation-store.js";
5
+ import {
6
+ type MSTeamsAdapter,
7
+ renderReplyPayloadsToMessages,
8
+ sendMSTeamsMessages,
9
+ } from "./messenger.js";
10
+ import { setMSTeamsRuntime } from "./runtime.js";
11
+
12
+ const chunkMarkdownText = (text: string, limit: number) => {
13
+ if (!text) return [];
14
+ if (limit <= 0 || text.length <= limit) return [text];
15
+ const chunks: string[] = [];
16
+ for (let index = 0; index < text.length; index += limit) {
17
+ chunks.push(text.slice(index, index + limit));
18
+ }
19
+ return chunks;
20
+ };
21
+
22
+ const runtimeStub = {
23
+ channel: {
24
+ text: {
25
+ chunkMarkdownText,
26
+ chunkMarkdownTextWithMode: chunkMarkdownText,
27
+ resolveMarkdownTableMode: () => "code",
28
+ convertMarkdownTables: (text: string) => text,
29
+ },
30
+ },
31
+ } as unknown as PluginRuntime;
32
+
33
+ describe("msteams messenger", () => {
34
+ beforeEach(() => {
35
+ setMSTeamsRuntime(runtimeStub);
36
+ });
37
+
38
+ describe("renderReplyPayloadsToMessages", () => {
39
+ it("filters silent replies", () => {
40
+ const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
41
+ textChunkLimit: 4000,
42
+ tableMode: "code",
43
+ });
44
+ expect(messages).toEqual([]);
45
+ });
46
+
47
+ it("filters silent reply prefixes", () => {
48
+ const messages = renderReplyPayloadsToMessages(
49
+ [{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
50
+ { textChunkLimit: 4000, tableMode: "code" },
51
+ );
52
+ expect(messages).toEqual([]);
53
+ });
54
+
55
+ it("splits media into separate messages by default", () => {
56
+ const messages = renderReplyPayloadsToMessages(
57
+ [{ text: "hi", mediaUrl: "https://example.com/a.png" }],
58
+ { textChunkLimit: 4000, tableMode: "code" },
59
+ );
60
+ expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
61
+ });
62
+
63
+ it("supports inline media mode", () => {
64
+ const messages = renderReplyPayloadsToMessages(
65
+ [{ text: "hi", mediaUrl: "https://example.com/a.png" }],
66
+ { textChunkLimit: 4000, mediaMode: "inline", tableMode: "code" },
67
+ );
68
+ expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
69
+ });
70
+
71
+ it("chunks long text when enabled", () => {
72
+ const long = "hello ".repeat(200);
73
+ const messages = renderReplyPayloadsToMessages([{ text: long }], {
74
+ textChunkLimit: 50,
75
+ tableMode: "code",
76
+ });
77
+ expect(messages.length).toBeGreaterThan(1);
78
+ });
79
+ });
80
+
81
+ describe("sendMSTeamsMessages", () => {
82
+ const baseRef: StoredConversationReference = {
83
+ activityId: "activity123",
84
+ user: { id: "user123", name: "User" },
85
+ agent: { id: "bot123", name: "Bot" },
86
+ conversation: { id: "19:abc@thread.tacv2;messageid=deadbeef" },
87
+ channelId: "msteams",
88
+ serviceUrl: "https://service.example.com",
89
+ };
90
+
91
+ it("sends thread messages via the provided context", async () => {
92
+ const sent: string[] = [];
93
+ const ctx = {
94
+ sendActivity: async (activity: unknown) => {
95
+ const { text } = activity as { text?: string };
96
+ sent.push(text ?? "");
97
+ return { id: `id:${text ?? ""}` };
98
+ },
99
+ };
100
+
101
+ const adapter: MSTeamsAdapter = {
102
+ continueConversation: async () => {},
103
+ };
104
+
105
+ const ids = await sendMSTeamsMessages({
106
+ replyStyle: "thread",
107
+ adapter,
108
+ appId: "app123",
109
+ conversationRef: baseRef,
110
+ context: ctx,
111
+ messages: [{ text: "one" }, { text: "two" }],
112
+ });
113
+
114
+ expect(sent).toEqual(["one", "two"]);
115
+ expect(ids).toEqual(["id:one", "id:two"]);
116
+ });
117
+
118
+ it("sends top-level messages via continueConversation and strips activityId", async () => {
119
+ const seen: { reference?: unknown; texts: string[] } = { texts: [] };
120
+
121
+ const adapter: MSTeamsAdapter = {
122
+ continueConversation: async (_appId, reference, logic) => {
123
+ seen.reference = reference;
124
+ await logic({
125
+ sendActivity: async (activity: unknown) => {
126
+ const { text } = activity as { text?: string };
127
+ seen.texts.push(text ?? "");
128
+ return { id: `id:${text ?? ""}` };
129
+ },
130
+ });
131
+ },
132
+ };
133
+
134
+ const ids = await sendMSTeamsMessages({
135
+ replyStyle: "top-level",
136
+ adapter,
137
+ appId: "app123",
138
+ conversationRef: baseRef,
139
+ messages: [{ text: "hello" }],
140
+ });
141
+
142
+ expect(seen.texts).toEqual(["hello"]);
143
+ expect(ids).toEqual(["id:hello"]);
144
+
145
+ const ref = seen.reference as {
146
+ activityId?: string;
147
+ conversation?: { id?: string };
148
+ };
149
+ expect(ref.activityId).toBeUndefined();
150
+ expect(ref.conversation?.id).toBe("19:abc@thread.tacv2");
151
+ });
152
+
153
+ it("retries thread sends on throttling (429)", async () => {
154
+ const attempts: string[] = [];
155
+ const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = [];
156
+
157
+ const ctx = {
158
+ sendActivity: async (activity: unknown) => {
159
+ const { text } = activity as { text?: string };
160
+ attempts.push(text ?? "");
161
+ if (attempts.length === 1) {
162
+ throw Object.assign(new Error("throttled"), { statusCode: 429 });
163
+ }
164
+ return { id: `id:${text ?? ""}` };
165
+ },
166
+ };
167
+
168
+ const adapter: MSTeamsAdapter = {
169
+ continueConversation: async () => {},
170
+ };
171
+
172
+ const ids = await sendMSTeamsMessages({
173
+ replyStyle: "thread",
174
+ adapter,
175
+ appId: "app123",
176
+ conversationRef: baseRef,
177
+ context: ctx,
178
+ messages: [{ text: "one" }],
179
+ retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
180
+ onRetry: (e) => retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }),
181
+ });
182
+
183
+ expect(attempts).toEqual(["one", "one"]);
184
+ expect(ids).toEqual(["id:one"]);
185
+ expect(retryEvents).toEqual([{ nextAttempt: 2, delayMs: 0 }]);
186
+ });
187
+
188
+ it("does not retry thread sends on client errors (4xx)", async () => {
189
+ const ctx = {
190
+ sendActivity: async () => {
191
+ throw Object.assign(new Error("bad request"), { statusCode: 400 });
192
+ },
193
+ };
194
+
195
+ const adapter: MSTeamsAdapter = {
196
+ continueConversation: async () => {},
197
+ };
198
+
199
+ await expect(
200
+ sendMSTeamsMessages({
201
+ replyStyle: "thread",
202
+ adapter,
203
+ appId: "app123",
204
+ conversationRef: baseRef,
205
+ context: ctx,
206
+ messages: [{ text: "one" }],
207
+ retry: { maxAttempts: 3, baseDelayMs: 0, maxDelayMs: 0 },
208
+ }),
209
+ ).rejects.toMatchObject({ statusCode: 400 });
210
+ });
211
+
212
+ it("retries top-level sends on transient (5xx)", async () => {
213
+ const attempts: string[] = [];
214
+
215
+ const adapter: MSTeamsAdapter = {
216
+ continueConversation: async (_appId, _reference, logic) => {
217
+ await logic({
218
+ sendActivity: async (activity: unknown) => {
219
+ const { text } = activity as { text?: string };
220
+ attempts.push(text ?? "");
221
+ if (attempts.length === 1) {
222
+ throw Object.assign(new Error("server error"), {
223
+ statusCode: 503,
224
+ });
225
+ }
226
+ return { id: `id:${text ?? ""}` };
227
+ },
228
+ });
229
+ },
230
+ };
231
+
232
+ const ids = await sendMSTeamsMessages({
233
+ replyStyle: "top-level",
234
+ adapter,
235
+ appId: "app123",
236
+ conversationRef: baseRef,
237
+ messages: [{ text: "hello" }],
238
+ retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
239
+ });
240
+
241
+ expect(attempts).toEqual(["hello", "hello"]);
242
+ expect(ids).toEqual(["id:hello"]);
243
+ });
244
+ });
245
+ });