@openclaw/msteams 2026.3.13 → 2026.5.2-beta.1

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 (175) hide show
  1. package/api.ts +3 -0
  2. package/channel-config-api.ts +1 -0
  3. package/channel-plugin-api.ts +2 -0
  4. package/config-api.ts +4 -0
  5. package/contract-api.ts +4 -0
  6. package/index.ts +15 -12
  7. package/openclaw.plugin.json +553 -1
  8. package/package.json +46 -12
  9. package/runtime-api.ts +73 -0
  10. package/secret-contract-api.ts +5 -0
  11. package/setup-entry.ts +13 -0
  12. package/setup-plugin-api.ts +3 -0
  13. package/src/ai-entity.ts +7 -0
  14. package/src/approval-auth.ts +44 -0
  15. package/src/attachments/bot-framework.test.ts +461 -0
  16. package/src/attachments/bot-framework.ts +362 -0
  17. package/src/attachments/download.ts +63 -19
  18. package/src/attachments/graph.test.ts +416 -0
  19. package/src/attachments/graph.ts +163 -72
  20. package/src/attachments/html.ts +33 -1
  21. package/src/attachments/payload.ts +1 -1
  22. package/src/attachments/remote-media.test.ts +137 -0
  23. package/src/attachments/remote-media.ts +75 -8
  24. package/src/attachments/shared.test.ts +138 -1
  25. package/src/attachments/shared.ts +193 -26
  26. package/src/attachments/types.ts +10 -0
  27. package/src/attachments.graph.test.ts +342 -0
  28. package/src/attachments.helpers.test.ts +246 -0
  29. package/src/attachments.test-helpers.ts +17 -0
  30. package/src/attachments.test.ts +163 -418
  31. package/src/attachments.ts +5 -5
  32. package/src/block-streaming-config.test.ts +61 -0
  33. package/src/channel-api.ts +1 -0
  34. package/src/channel.actions.test.ts +742 -0
  35. package/src/channel.directory.test.ts +145 -4
  36. package/src/channel.runtime.ts +56 -0
  37. package/src/channel.setup.ts +77 -0
  38. package/src/channel.test.ts +128 -0
  39. package/src/channel.ts +1077 -395
  40. package/src/config-schema.ts +6 -0
  41. package/src/config-ui-hints.ts +12 -0
  42. package/src/conversation-store-fs.test.ts +4 -5
  43. package/src/conversation-store-fs.ts +35 -51
  44. package/src/conversation-store-helpers.test.ts +202 -0
  45. package/src/conversation-store-helpers.ts +105 -0
  46. package/src/conversation-store-memory.ts +27 -23
  47. package/src/conversation-store.shared.test.ts +225 -0
  48. package/src/conversation-store.ts +30 -0
  49. package/src/directory-live.test.ts +156 -0
  50. package/src/directory-live.ts +7 -4
  51. package/src/doctor.ts +27 -0
  52. package/src/errors.test.ts +64 -1
  53. package/src/errors.ts +50 -9
  54. package/src/feedback-reflection-prompt.ts +117 -0
  55. package/src/feedback-reflection-store.ts +114 -0
  56. package/src/feedback-reflection.test.ts +237 -0
  57. package/src/feedback-reflection.ts +283 -0
  58. package/src/file-consent-helpers.test.ts +83 -0
  59. package/src/file-consent-helpers.ts +64 -11
  60. package/src/file-consent-invoke.ts +150 -0
  61. package/src/file-consent.test.ts +363 -0
  62. package/src/file-consent.ts +165 -4
  63. package/src/graph-chat.ts +5 -3
  64. package/src/graph-group-management.test.ts +318 -0
  65. package/src/graph-group-management.ts +168 -0
  66. package/src/graph-members.test.ts +89 -0
  67. package/src/graph-members.ts +48 -0
  68. package/src/graph-messages.actions.test.ts +243 -0
  69. package/src/graph-messages.read.test.ts +391 -0
  70. package/src/graph-messages.search.test.ts +213 -0
  71. package/src/graph-messages.test-helpers.ts +50 -0
  72. package/src/graph-messages.ts +534 -0
  73. package/src/graph-teams.test.ts +215 -0
  74. package/src/graph-teams.ts +114 -0
  75. package/src/graph-thread.test.ts +246 -0
  76. package/src/graph-thread.ts +146 -0
  77. package/src/graph-upload.test.ts +161 -4
  78. package/src/graph-upload.ts +147 -56
  79. package/src/graph.test.ts +516 -0
  80. package/src/graph.ts +233 -21
  81. package/src/inbound.test.ts +156 -1
  82. package/src/inbound.ts +101 -1
  83. package/src/media-helpers.ts +1 -1
  84. package/src/mentions.test.ts +27 -18
  85. package/src/mentions.ts +2 -2
  86. package/src/messenger.test.ts +504 -23
  87. package/src/messenger.ts +133 -52
  88. package/src/monitor-handler/access.ts +125 -0
  89. package/src/monitor-handler/inbound-media.test.ts +289 -0
  90. package/src/monitor-handler/inbound-media.ts +57 -5
  91. package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
  92. package/src/monitor-handler/message-handler.authz.test.ts +588 -74
  93. package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
  94. package/src/monitor-handler/message-handler.test-support.ts +100 -0
  95. package/src/monitor-handler/message-handler.thread-parent.test.ts +223 -0
  96. package/src/monitor-handler/message-handler.thread-session.test.ts +77 -0
  97. package/src/monitor-handler/message-handler.ts +470 -164
  98. package/src/monitor-handler/reaction-handler.test.ts +267 -0
  99. package/src/monitor-handler/reaction-handler.ts +210 -0
  100. package/src/monitor-handler/thread-session.ts +17 -0
  101. package/src/monitor-handler.adaptive-card.test.ts +162 -0
  102. package/src/monitor-handler.feedback-authz.test.ts +314 -0
  103. package/src/monitor-handler.file-consent.test.ts +281 -79
  104. package/src/monitor-handler.sso.test.ts +563 -0
  105. package/src/monitor-handler.test-helpers.ts +180 -0
  106. package/src/monitor-handler.ts +459 -115
  107. package/src/monitor-handler.types.ts +27 -0
  108. package/src/monitor-types.ts +1 -0
  109. package/src/monitor.lifecycle.test.ts +74 -10
  110. package/src/monitor.test.ts +35 -1
  111. package/src/monitor.ts +143 -46
  112. package/src/oauth.flow.ts +77 -0
  113. package/src/oauth.shared.ts +37 -0
  114. package/src/oauth.test.ts +305 -0
  115. package/src/oauth.token.ts +158 -0
  116. package/src/oauth.ts +130 -0
  117. package/src/outbound.test.ts +10 -11
  118. package/src/outbound.ts +62 -44
  119. package/src/pending-uploads-fs.test.ts +246 -0
  120. package/src/pending-uploads-fs.ts +235 -0
  121. package/src/pending-uploads.test.ts +173 -0
  122. package/src/pending-uploads.ts +34 -2
  123. package/src/policy.test.ts +11 -5
  124. package/src/policy.ts +5 -5
  125. package/src/polls.test.ts +106 -5
  126. package/src/polls.ts +15 -7
  127. package/src/presentation.ts +68 -0
  128. package/src/probe.test.ts +27 -8
  129. package/src/probe.ts +43 -9
  130. package/src/reply-dispatcher.test.ts +437 -0
  131. package/src/reply-dispatcher.ts +259 -73
  132. package/src/reply-stream-controller.test.ts +235 -0
  133. package/src/reply-stream-controller.ts +147 -0
  134. package/src/resolve-allowlist.test.ts +105 -1
  135. package/src/resolve-allowlist.ts +112 -7
  136. package/src/runtime.ts +6 -3
  137. package/src/sdk-types.ts +43 -3
  138. package/src/sdk.test.ts +666 -0
  139. package/src/sdk.ts +867 -16
  140. package/src/secret-contract.ts +49 -0
  141. package/src/secret-input.ts +1 -1
  142. package/src/send-context.ts +76 -9
  143. package/src/send.test.ts +389 -5
  144. package/src/send.ts +140 -32
  145. package/src/sent-message-cache.ts +30 -18
  146. package/src/session-route.ts +40 -0
  147. package/src/setup-core.ts +160 -0
  148. package/src/setup-surface.test.ts +202 -0
  149. package/src/setup-surface.ts +320 -0
  150. package/src/sso-token-store.test.ts +72 -0
  151. package/src/sso-token-store.ts +166 -0
  152. package/src/sso.ts +300 -0
  153. package/src/storage.ts +1 -1
  154. package/src/store-fs.ts +2 -2
  155. package/src/streaming-message.test.ts +262 -0
  156. package/src/streaming-message.ts +297 -0
  157. package/src/test-runtime.ts +1 -1
  158. package/src/thread-parent-context.test.ts +224 -0
  159. package/src/thread-parent-context.ts +159 -0
  160. package/src/token.test.ts +237 -50
  161. package/src/token.ts +162 -7
  162. package/src/user-agent.test.ts +86 -0
  163. package/src/user-agent.ts +53 -0
  164. package/src/webhook-timeouts.ts +27 -0
  165. package/src/welcome-card.test.ts +81 -0
  166. package/src/welcome-card.ts +57 -0
  167. package/test-api.ts +1 -0
  168. package/tsconfig.json +16 -0
  169. package/CHANGELOG.md +0 -107
  170. package/src/file-lock.ts +0 -1
  171. package/src/graph-users.test.ts +0 -66
  172. package/src/onboarding.ts +0 -381
  173. package/src/polls-store.test.ts +0 -38
  174. package/src/revoked-context.test.ts +0 -39
  175. package/src/token-response.test.ts +0 -23
@@ -0,0 +1,461 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { setMSTeamsRuntime } from "../runtime.js";
3
+ import {
4
+ downloadMSTeamsBotFrameworkAttachment,
5
+ downloadMSTeamsBotFrameworkAttachments,
6
+ isBotFrameworkPersonalChatId,
7
+ } from "./bot-framework.js";
8
+ import type { MSTeamsAccessTokenProvider } from "./types.js";
9
+
10
+ type SavedCall = {
11
+ buffer: Buffer;
12
+ contentType?: string;
13
+ direction: string;
14
+ maxBytes: number;
15
+ originalFilename?: string;
16
+ };
17
+
18
+ type MockRuntime = {
19
+ saveCalls: SavedCall[];
20
+ savePath: string;
21
+ savedContentType: string;
22
+ };
23
+
24
+ function installRuntime(): MockRuntime {
25
+ const state: MockRuntime = {
26
+ saveCalls: [],
27
+ savePath: "/tmp/bf-attachment.bin",
28
+ savedContentType: "application/pdf",
29
+ };
30
+ setMSTeamsRuntime({
31
+ media: {
32
+ detectMime: async ({ headerMime }: { headerMime?: string }) =>
33
+ headerMime ?? "application/pdf",
34
+ },
35
+ channel: {
36
+ media: {
37
+ saveMediaBuffer: async (
38
+ buffer: Buffer,
39
+ contentType: string | undefined,
40
+ direction: string,
41
+ maxBytes: number,
42
+ originalFilename?: string,
43
+ ) => {
44
+ state.saveCalls.push({
45
+ buffer,
46
+ contentType,
47
+ direction,
48
+ maxBytes,
49
+ originalFilename,
50
+ });
51
+ return { path: state.savePath, contentType: state.savedContentType };
52
+ },
53
+ fetchRemoteMedia: async () => ({ buffer: Buffer.alloc(0), contentType: undefined }),
54
+ },
55
+ },
56
+ } as unknown as Parameters<typeof setMSTeamsRuntime>[0]);
57
+ return state;
58
+ }
59
+
60
+ function createMockFetch(entries: Array<{ match: RegExp; response: Response }>): typeof fetch {
61
+ return (async (input: RequestInfo | URL) => {
62
+ const url =
63
+ typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
64
+ const entry = entries.find((e) => e.match.test(url));
65
+ if (!entry) {
66
+ return new Response("not found", { status: 404 });
67
+ }
68
+ return entry.response.clone();
69
+ }) as typeof fetch;
70
+ }
71
+
72
+ function buildTokenProvider(): MSTeamsAccessTokenProvider {
73
+ return {
74
+ getAccessToken: vi.fn(async (scope: string) => {
75
+ if (scope.includes("botframework.com")) {
76
+ return "bf-token";
77
+ }
78
+ return "graph-token";
79
+ }),
80
+ };
81
+ }
82
+
83
+ describe("isBotFrameworkPersonalChatId", () => {
84
+ it("detects a: prefix personal chat IDs", () => {
85
+ expect(isBotFrameworkPersonalChatId("a:1dRsHCobZ1AxURzY05Dc")).toBe(true);
86
+ });
87
+
88
+ it("detects 8:orgid: prefix chat IDs", () => {
89
+ expect(isBotFrameworkPersonalChatId("8:orgid:12345678-1234-1234-1234-123456789abc")).toBe(true);
90
+ });
91
+
92
+ it("returns false for Graph-compatible 19: thread IDs", () => {
93
+ expect(isBotFrameworkPersonalChatId("19:abc@thread.tacv2")).toBe(false);
94
+ });
95
+
96
+ it("returns false for synthetic DM Graph IDs", () => {
97
+ expect(isBotFrameworkPersonalChatId("19:aad-user-id_bot-app-id@unq.gbl.spaces")).toBe(false);
98
+ });
99
+
100
+ it("returns false for null/undefined/empty", () => {
101
+ expect(isBotFrameworkPersonalChatId(null)).toBe(false);
102
+ expect(isBotFrameworkPersonalChatId(undefined)).toBe(false);
103
+ expect(isBotFrameworkPersonalChatId("")).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe("downloadMSTeamsBotFrameworkAttachment", () => {
108
+ let runtime: MockRuntime;
109
+ beforeEach(() => {
110
+ runtime = installRuntime();
111
+ });
112
+
113
+ it("fetches attachment info then view and saves media", async () => {
114
+ const info = {
115
+ name: "report.pdf",
116
+ type: "application/pdf",
117
+ views: [{ viewId: "original", size: 1024 }],
118
+ };
119
+ const fileBytes = Buffer.from("PDFBYTES", "utf-8");
120
+ const fetchFn = createMockFetch([
121
+ {
122
+ match: /\/v3\/attachments\/att-1$/,
123
+ response: new Response(JSON.stringify(info), {
124
+ status: 200,
125
+ headers: { "content-type": "application/json" },
126
+ }),
127
+ },
128
+ {
129
+ match: /\/v3\/attachments\/att-1\/views\/original$/,
130
+ response: new Response(fileBytes, {
131
+ status: 200,
132
+ headers: { "content-length": String(fileBytes.byteLength) },
133
+ }),
134
+ },
135
+ ]);
136
+
137
+ const media = await downloadMSTeamsBotFrameworkAttachment({
138
+ serviceUrl: "https://smba.trafficmanager.net/amer/",
139
+ attachmentId: "att-1",
140
+ tokenProvider: buildTokenProvider(),
141
+ maxBytes: 10_000_000,
142
+ fetchFn,
143
+ });
144
+
145
+ expect(media).toBeDefined();
146
+ expect(media?.path).toBe(runtime.savePath);
147
+ expect(runtime.saveCalls).toHaveLength(1);
148
+ expect(runtime.saveCalls[0].buffer.toString("utf-8")).toBe("PDFBYTES");
149
+ });
150
+
151
+ it("returns undefined when attachment info fetch fails", async () => {
152
+ const fetchFn = createMockFetch([
153
+ {
154
+ match: /\/v3\/attachments\//,
155
+ response: new Response("unauthorized", { status: 401 }),
156
+ },
157
+ ]);
158
+
159
+ const media = await downloadMSTeamsBotFrameworkAttachment({
160
+ serviceUrl: "https://smba.trafficmanager.net/amer",
161
+ attachmentId: "att-1",
162
+ tokenProvider: buildTokenProvider(),
163
+ maxBytes: 10_000_000,
164
+ fetchFn,
165
+ });
166
+
167
+ expect(media).toBeUndefined();
168
+ expect(runtime.saveCalls).toHaveLength(0);
169
+ });
170
+
171
+ it("skips when attachment view size exceeds maxBytes", async () => {
172
+ const info = {
173
+ name: "huge.bin",
174
+ type: "application/octet-stream",
175
+ views: [{ viewId: "original", size: 50_000_000 }],
176
+ };
177
+ const fetchFn = createMockFetch([
178
+ {
179
+ match: /\/v3\/attachments\/big-1$/,
180
+ response: new Response(JSON.stringify(info), { status: 200 }),
181
+ },
182
+ ]);
183
+
184
+ const media = await downloadMSTeamsBotFrameworkAttachment({
185
+ serviceUrl: "https://smba.trafficmanager.net/amer",
186
+ attachmentId: "big-1",
187
+ tokenProvider: buildTokenProvider(),
188
+ maxBytes: 10_000_000,
189
+ fetchFn,
190
+ });
191
+
192
+ expect(media).toBeUndefined();
193
+ expect(runtime.saveCalls).toHaveLength(0);
194
+ });
195
+
196
+ it("returns undefined when no views are returned", async () => {
197
+ const info = { name: "nothing", type: "application/pdf", views: [] };
198
+ const fetchFn = createMockFetch([
199
+ {
200
+ match: /\/v3\/attachments\/empty-1$/,
201
+ response: new Response(JSON.stringify(info), { status: 200 }),
202
+ },
203
+ ]);
204
+
205
+ const media = await downloadMSTeamsBotFrameworkAttachment({
206
+ serviceUrl: "https://smba.trafficmanager.net/amer",
207
+ attachmentId: "empty-1",
208
+ tokenProvider: buildTokenProvider(),
209
+ maxBytes: 10_000_000,
210
+ fetchFn,
211
+ });
212
+
213
+ expect(media).toBeUndefined();
214
+ });
215
+
216
+ it("returns undefined without a tokenProvider", async () => {
217
+ const fetchFn = vi.fn();
218
+ const media = await downloadMSTeamsBotFrameworkAttachment({
219
+ serviceUrl: "https://smba.trafficmanager.net/amer",
220
+ attachmentId: "att-1",
221
+ tokenProvider: undefined,
222
+ maxBytes: 10_000_000,
223
+ fetchFn: fetchFn as unknown as typeof fetch,
224
+ });
225
+ expect(media).toBeUndefined();
226
+ expect(fetchFn).not.toHaveBeenCalled();
227
+ });
228
+
229
+ describe("Node 24+ dispatcher bypass (issue #63396)", () => {
230
+ it("drives the caller's fetchFn directly without the pinned undici dispatcher", async () => {
231
+ // Regression: before the fix, fetchBotFrameworkAttachment* routed
232
+ // through `fetchWithSsrFGuard`, which installs a `createPinnedDispatcher`
233
+ // incompatible with Node 24+'s built-in undici v7. Downloads failed with
234
+ // "invalid onRequestStart method". The fix switches to
235
+ // `safeFetchWithPolicy`, which calls the supplied `fetchFn` directly
236
+ // and never attaches a pinned dispatcher. Verify the caller's `fetchFn`
237
+ // is invoked (no dispatcher in init).
238
+ const fileBytes = Buffer.from("BFBYTES", "utf-8");
239
+ const fetchCalls: Array<{ url: string; init?: RequestInit }> = [];
240
+ const fetchFn: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
241
+ const url =
242
+ typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
243
+ fetchCalls.push({ url, init });
244
+ if (url.endsWith("/v3/attachments/att-1")) {
245
+ return new Response(
246
+ JSON.stringify({
247
+ name: "doc.pdf",
248
+ type: "application/pdf",
249
+ views: [{ viewId: "original", size: fileBytes.byteLength }],
250
+ }),
251
+ { status: 200, headers: { "content-type": "application/json" } },
252
+ );
253
+ }
254
+ if (url.endsWith("/v3/attachments/att-1/views/original")) {
255
+ return new Response(fileBytes, {
256
+ status: 200,
257
+ headers: { "content-length": String(fileBytes.byteLength) },
258
+ });
259
+ }
260
+ return new Response("not found", { status: 404 });
261
+ }) as typeof fetch;
262
+
263
+ const media = await downloadMSTeamsBotFrameworkAttachment({
264
+ serviceUrl: "https://smba.trafficmanager.net/amer",
265
+ attachmentId: "att-1",
266
+ tokenProvider: buildTokenProvider(),
267
+ maxBytes: 10_000_000,
268
+ fetchFn,
269
+ });
270
+
271
+ expect(media).toBeDefined();
272
+ // Both the attachment info call and the view call should be observed,
273
+ // confirming the direct fetch path was taken (no dispatcher interception).
274
+ expect(fetchCalls).toHaveLength(2);
275
+ expect(fetchCalls[0].url.endsWith("/v3/attachments/att-1")).toBe(true);
276
+ expect(fetchCalls[1].url.endsWith("/v3/attachments/att-1/views/original")).toBe(true);
277
+ // Verify no pinned undici dispatcher is attached on either request.
278
+ for (const call of fetchCalls) {
279
+ const init = call.init as RequestInit & { dispatcher?: unknown };
280
+ expect(init?.dispatcher).toBeUndefined();
281
+ }
282
+ });
283
+
284
+ it("logs a warning when the attachmentInfo fetch throws (no longer silently swallowed)", async () => {
285
+ const warn = vi.fn();
286
+ const logger = { warn };
287
+ const error = new TypeError("fetch failed | invalid onRequestStart method");
288
+ const fetchFn: typeof fetch = (async () => {
289
+ throw error;
290
+ }) as typeof fetch;
291
+
292
+ const media = await downloadMSTeamsBotFrameworkAttachment({
293
+ serviceUrl: "https://smba.trafficmanager.net/amer",
294
+ attachmentId: "att-1",
295
+ tokenProvider: buildTokenProvider(),
296
+ maxBytes: 10_000_000,
297
+ fetchFn,
298
+ logger,
299
+ });
300
+
301
+ expect(media).toBeUndefined();
302
+ expect(warn).toHaveBeenCalledWith(
303
+ "msteams botFramework attachmentInfo fetch failed",
304
+ expect.objectContaining({
305
+ error: expect.stringContaining("invalid onRequestStart method"),
306
+ }),
307
+ );
308
+ });
309
+
310
+ it("logs a warning when the attachmentView fetch throws", async () => {
311
+ const warn = vi.fn();
312
+ const logger = { warn };
313
+ const fetchFn: typeof fetch = (async (input: RequestInfo | URL) => {
314
+ const url =
315
+ typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
316
+ if (url.endsWith("/v3/attachments/att-1")) {
317
+ return new Response(
318
+ JSON.stringify({
319
+ name: "doc.pdf",
320
+ type: "application/pdf",
321
+ views: [{ viewId: "original", size: 10 }],
322
+ }),
323
+ { status: 200 },
324
+ );
325
+ }
326
+ throw new TypeError("fetch failed");
327
+ }) as typeof fetch;
328
+
329
+ const media = await downloadMSTeamsBotFrameworkAttachment({
330
+ serviceUrl: "https://smba.trafficmanager.net/amer",
331
+ attachmentId: "att-1",
332
+ tokenProvider: buildTokenProvider(),
333
+ maxBytes: 10_000_000,
334
+ fetchFn,
335
+ logger,
336
+ });
337
+
338
+ expect(media).toBeUndefined();
339
+ expect(warn).toHaveBeenCalledWith(
340
+ "msteams botFramework attachmentView fetch failed",
341
+ expect.objectContaining({
342
+ error: expect.stringContaining("fetch failed"),
343
+ }),
344
+ );
345
+ });
346
+
347
+ it("logs a warning on non-ok attachmentInfo response", async () => {
348
+ const warn = vi.fn();
349
+ const fetchFn = createMockFetch([
350
+ {
351
+ match: /\/v3\/attachments\/att-1$/,
352
+ response: new Response("server error", { status: 500 }),
353
+ },
354
+ ]);
355
+
356
+ const media = await downloadMSTeamsBotFrameworkAttachment({
357
+ serviceUrl: "https://smba.trafficmanager.net/amer",
358
+ attachmentId: "att-1",
359
+ tokenProvider: buildTokenProvider(),
360
+ maxBytes: 10_000_000,
361
+ fetchFn,
362
+ logger: { warn },
363
+ });
364
+
365
+ expect(media).toBeUndefined();
366
+ expect(warn).toHaveBeenCalledWith(
367
+ "msteams botFramework attachmentInfo non-ok",
368
+ expect.objectContaining({ status: 500 }),
369
+ );
370
+ });
371
+ });
372
+ });
373
+
374
+ describe("downloadMSTeamsBotFrameworkAttachments", () => {
375
+ beforeEach(() => {
376
+ installRuntime();
377
+ });
378
+
379
+ it("fetches every unique attachment id and returns combined media", async () => {
380
+ const mkInfo = (viewId: string) => ({
381
+ name: `file-${viewId}.pdf`,
382
+ type: "application/pdf",
383
+ views: [{ viewId, size: 10 }],
384
+ });
385
+ const fetchFn = createMockFetch([
386
+ {
387
+ match: /\/v3\/attachments\/att-1$/,
388
+ response: new Response(JSON.stringify(mkInfo("original")), { status: 200 }),
389
+ },
390
+ {
391
+ match: /\/v3\/attachments\/att-1\/views\/original$/,
392
+ response: new Response(Buffer.from("A"), { status: 200 }),
393
+ },
394
+ {
395
+ match: /\/v3\/attachments\/att-2$/,
396
+ response: new Response(JSON.stringify(mkInfo("original")), { status: 200 }),
397
+ },
398
+ {
399
+ match: /\/v3\/attachments\/att-2\/views\/original$/,
400
+ response: new Response(Buffer.from("B"), { status: 200 }),
401
+ },
402
+ ]);
403
+
404
+ const result = await downloadMSTeamsBotFrameworkAttachments({
405
+ serviceUrl: "https://smba.trafficmanager.net/amer",
406
+ attachmentIds: ["att-1", "att-2", "att-1"],
407
+ tokenProvider: buildTokenProvider(),
408
+ maxBytes: 10_000,
409
+ fetchFn,
410
+ });
411
+
412
+ expect(result.media).toHaveLength(2);
413
+ expect(result.attachmentCount).toBe(2);
414
+ });
415
+
416
+ it("returns empty when no valid attachment ids", async () => {
417
+ const result = await downloadMSTeamsBotFrameworkAttachments({
418
+ serviceUrl: "https://smba.trafficmanager.net/amer",
419
+ attachmentIds: [],
420
+ tokenProvider: buildTokenProvider(),
421
+ maxBytes: 10_000,
422
+ fetchFn: vi.fn() as unknown as typeof fetch,
423
+ });
424
+ expect(result.media).toEqual([]);
425
+ });
426
+
427
+ it("continues past a per-attachment failure", async () => {
428
+ const fetchFn = createMockFetch([
429
+ {
430
+ match: /\/v3\/attachments\/ok$/,
431
+ response: new Response(
432
+ JSON.stringify({
433
+ name: "ok.pdf",
434
+ type: "application/pdf",
435
+ views: [{ viewId: "original", size: 1 }],
436
+ }),
437
+ { status: 200 },
438
+ ),
439
+ },
440
+ {
441
+ match: /\/v3\/attachments\/ok\/views\/original$/,
442
+ response: new Response(Buffer.from("OK"), { status: 200 }),
443
+ },
444
+ {
445
+ match: /\/v3\/attachments\/bad$/,
446
+ response: new Response("nope", { status: 500 }),
447
+ },
448
+ ]);
449
+
450
+ const result = await downloadMSTeamsBotFrameworkAttachments({
451
+ serviceUrl: "https://smba.trafficmanager.net/amer",
452
+ attachmentIds: ["bad", "ok"],
453
+ tokenProvider: buildTokenProvider(),
454
+ maxBytes: 10_000,
455
+ fetchFn,
456
+ });
457
+
458
+ expect(result.media).toHaveLength(1);
459
+ expect(result.attachmentCount).toBe(2);
460
+ });
461
+ });