@openclaw/msteams 2026.3.13 → 2026.5.1-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
@@ -1,14 +1,24 @@
1
- import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/msteams";
1
+ import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
2
+ import {
3
+ normalizeLowercaseStringOrEmpty,
4
+ normalizeOptionalLowercaseString,
5
+ normalizeOptionalString,
6
+ } from "openclaw/plugin-sdk/text-runtime";
2
7
  import { getMSTeamsRuntime } from "../runtime.js";
8
+ import { ensureUserAgentHeader } from "../user-agent.js";
3
9
  import { downloadMSTeamsAttachments } from "./download.js";
4
10
  import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
5
11
  import {
6
12
  applyAuthorizationHeaderForUrl,
13
+ encodeGraphShareId,
7
14
  GRAPH_ROOT,
15
+ estimateBase64DecodedBytes,
8
16
  inferPlaceholder,
9
- isRecord,
17
+ readNestedString,
10
18
  isUrlAllowed,
19
+ type MSTeamsAttachmentDownloadLogger,
11
20
  type MSTeamsAttachmentFetchPolicy,
21
+ type MSTeamsAttachmentResolveFn,
12
22
  normalizeContentType,
13
23
  resolveMediaSsrfPolicy,
14
24
  resolveAttachmentFetchPolicy,
@@ -18,6 +28,7 @@ import {
18
28
  import type {
19
29
  MSTeamsAccessTokenProvider,
20
30
  MSTeamsAttachmentLike,
31
+ MSTeamsGraphMediaLogger,
21
32
  MSTeamsGraphMediaResult,
22
33
  MSTeamsInboundMedia,
23
34
  } from "./types.js";
@@ -37,17 +48,6 @@ type GraphAttachment = {
37
48
  content?: unknown;
38
49
  };
39
50
 
40
- function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
41
- let current: unknown = value;
42
- for (const key of keys) {
43
- if (!isRecord(current)) {
44
- return undefined;
45
- }
46
- current = current[key as keyof typeof current];
47
- }
48
- return typeof current === "string" && current.trim() ? current.trim() : undefined;
49
- }
50
-
51
51
  export function buildMSTeamsGraphMessageUrls(params: {
52
52
  conversationType?: string | null;
53
53
  conversationId?: string | null;
@@ -56,10 +56,10 @@ export function buildMSTeamsGraphMessageUrls(params: {
56
56
  conversationMessageId?: string | null;
57
57
  channelData?: unknown;
58
58
  }): string[] {
59
- const conversationType = params.conversationType?.trim().toLowerCase() ?? "";
59
+ const conversationType = normalizeLowercaseStringOrEmpty(params.conversationType ?? "");
60
60
  const messageIdCandidates = new Set<string>();
61
61
  const pushCandidate = (value: string | null | undefined) => {
62
- const trimmed = typeof value === "string" ? value.trim() : "";
62
+ const trimmed = normalizeOptionalString(value) ?? "";
63
63
  if (trimmed) {
64
64
  messageIdCandidates.add(trimmed);
65
65
  }
@@ -70,7 +70,7 @@ export function buildMSTeamsGraphMessageUrls(params: {
70
70
  pushCandidate(readNestedString(params.channelData, ["messageId"]));
71
71
  pushCandidate(readNestedString(params.channelData, ["teamsMessageId"]));
72
72
 
73
- const replyToId = typeof params.replyToId === "string" ? params.replyToId.trim() : "";
73
+ const replyToId = normalizeOptionalString(params.replyToId) ?? "";
74
74
 
75
75
  if (conversationType === "channel") {
76
76
  const teamId =
@@ -119,18 +119,18 @@ export function buildMSTeamsGraphMessageUrls(params: {
119
119
  return Array.from(new Set(urls));
120
120
  }
121
121
 
122
- async function fetchGraphCollection<T>(params: {
122
+ async function fetchGraphCollection(params: {
123
123
  url: string;
124
124
  accessToken: string;
125
125
  fetchFn?: typeof fetch;
126
126
  ssrfPolicy?: SsrFPolicy;
127
- }): Promise<{ status: number; items: T[] }> {
127
+ }): Promise<{ status: number; items: unknown[] }> {
128
128
  const fetchFn = params.fetchFn ?? fetch;
129
129
  const { response, release } = await fetchWithSsrFGuard({
130
130
  url: params.url,
131
131
  fetchImpl: fetchFn,
132
132
  init: {
133
- headers: { Authorization: `Bearer ${params.accessToken}` },
133
+ headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
134
134
  },
135
135
  policy: params.ssrfPolicy,
136
136
  auditContext: "msteams.graph.collection",
@@ -141,7 +141,7 @@ async function fetchGraphCollection<T>(params: {
141
141
  return { status, items: [] };
142
142
  }
143
143
  try {
144
- const data = (await response.json()) as { value?: T[] };
144
+ const data = (await response.json()) as { value?: unknown[] };
145
145
  return { status, items: Array.isArray(data.value) ? data.value : [] };
146
146
  } catch {
147
147
  return { status, items: [] };
@@ -180,13 +180,14 @@ async function downloadGraphHostedContent(params: {
180
180
  fetchFn?: typeof fetch;
181
181
  preserveFilenames?: boolean;
182
182
  ssrfPolicy?: SsrFPolicy;
183
+ logger?: MSTeamsAttachmentDownloadLogger;
183
184
  }): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
184
- const hosted = await fetchGraphCollection<GraphHostedContent>({
185
+ const hosted = (await fetchGraphCollection({
185
186
  url: `${params.messageUrl}/hostedContents`,
186
187
  accessToken: params.accessToken,
187
188
  fetchFn: params.fetchFn,
188
189
  ssrfPolicy: params.ssrfPolicy,
189
- });
190
+ })) as { status: number; items: GraphHostedContent[] };
190
191
  if (hosted.items.length === 0) {
191
192
  return { media: [], status: hosted.status, count: 0 };
192
193
  }
@@ -194,13 +195,53 @@ async function downloadGraphHostedContent(params: {
194
195
  const out: MSTeamsInboundMedia[] = [];
195
196
  for (const item of hosted.items) {
196
197
  const contentBytes = typeof item.contentBytes === "string" ? item.contentBytes : "";
197
- if (!contentBytes) {
198
- continue;
199
- }
200
198
  let buffer: Buffer;
201
- try {
202
- buffer = Buffer.from(contentBytes, "base64");
203
- } catch {
199
+ if (contentBytes) {
200
+ if (estimateBase64DecodedBytes(contentBytes) > params.maxBytes) {
201
+ continue;
202
+ }
203
+ try {
204
+ buffer = Buffer.from(contentBytes, "base64");
205
+ } catch (err) {
206
+ params.logger?.warn?.("msteams graph hostedContent base64 decode failed", {
207
+ error: err instanceof Error ? err.message : String(err),
208
+ });
209
+ continue;
210
+ }
211
+ } else if (item.id) {
212
+ // contentBytes not inline — fetch from the individual $value endpoint.
213
+ try {
214
+ const valueUrl = `${params.messageUrl}/hostedContents/${encodeURIComponent(item.id)}/$value`;
215
+ const { response: valRes, release } = await fetchWithSsrFGuard({
216
+ url: valueUrl,
217
+ fetchImpl: params.fetchFn ?? fetch,
218
+ init: {
219
+ headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
220
+ },
221
+ policy: params.ssrfPolicy,
222
+ auditContext: "msteams.graph.hostedContent.value",
223
+ });
224
+ try {
225
+ if (!valRes.ok) {
226
+ continue;
227
+ }
228
+ // Check Content-Length before buffering to avoid RSS spikes on large files.
229
+ const cl = valRes.headers.get("content-length");
230
+ if (cl && Number(cl) > params.maxBytes) {
231
+ continue;
232
+ }
233
+ const ab = await valRes.arrayBuffer();
234
+ buffer = Buffer.from(ab);
235
+ } finally {
236
+ await release();
237
+ }
238
+ } catch (err) {
239
+ params.logger?.warn?.("msteams graph hostedContent value fetch failed", {
240
+ error: err instanceof Error ? err.message : String(err),
241
+ });
242
+ continue;
243
+ }
244
+ } else {
204
245
  continue;
205
246
  }
206
247
  if (buffer.byteLength > params.maxBytes) {
@@ -223,8 +264,10 @@ async function downloadGraphHostedContent(params: {
223
264
  contentType: saved.contentType,
224
265
  placeholder: inferPlaceholder({ contentType: saved.contentType }),
225
266
  });
226
- } catch {
227
- // Ignore save failures.
267
+ } catch (err) {
268
+ params.logger?.warn?.("msteams graph hostedContent save failed", {
269
+ error: err instanceof Error ? err.message : String(err),
270
+ });
228
271
  }
229
272
  }
230
273
 
@@ -238,8 +281,13 @@ export async function downloadMSTeamsGraphMedia(params: {
238
281
  allowHosts?: string[];
239
282
  authAllowHosts?: string[];
240
283
  fetchFn?: typeof fetch;
284
+ resolveFn?: MSTeamsAttachmentResolveFn;
241
285
  /** When true, embeds original filename in stored path for later extraction. */
242
286
  preserveFilenames?: boolean;
287
+ /** Optional logger used to surface Graph/SharePoint fetch errors. */
288
+ logger?: MSTeamsAttachmentDownloadLogger;
289
+ /** Back-compat diagnostic logger used by older tests/callers. */
290
+ log?: MSTeamsGraphMediaLogger;
243
291
  }): Promise<MSTeamsGraphMediaResult> {
244
292
  if (!params.messageUrl || !params.tokenProvider) {
245
293
  return { media: [] };
@@ -250,55 +298,78 @@ export async function downloadMSTeamsGraphMedia(params: {
250
298
  });
251
299
  const ssrfPolicy = resolveMediaSsrfPolicy(policy.allowHosts);
252
300
  const messageUrl = params.messageUrl;
301
+ const debugLog =
302
+ params.log ?? (params.logger as MSTeamsGraphMediaLogger | undefined) ?? undefined;
253
303
  let accessToken: string;
254
304
  try {
255
305
  accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com");
256
- } catch {
306
+ } catch (err) {
307
+ debugLog?.debug?.("graph media token acquisition failed", {
308
+ messageUrl,
309
+ error: err instanceof Error ? err.message : String(err),
310
+ });
311
+ params.logger?.warn?.("msteams graph token acquisition failed", {
312
+ error: err instanceof Error ? err.message : String(err),
313
+ });
257
314
  return { media: [], messageUrl, tokenError: true };
258
315
  }
259
316
 
260
- // Fetch the full message to get SharePoint file attachments (for group chats)
261
317
  const fetchFn = params.fetchFn ?? fetch;
262
318
  const sharePointMedia: MSTeamsInboundMedia[] = [];
263
319
  const downloadedReferenceUrls = new Set<string>();
320
+ let messageAttachments: GraphAttachment[] = [];
321
+ let messageStatus: number | undefined;
264
322
  try {
265
323
  const { response: msgRes, release } = await fetchWithSsrFGuard({
266
324
  url: messageUrl,
267
325
  fetchImpl: fetchFn,
268
326
  init: {
269
- headers: { Authorization: `Bearer ${accessToken}` },
327
+ headers: ensureUserAgentHeader({ Authorization: `Bearer ${accessToken}` }),
270
328
  },
271
329
  policy: ssrfPolicy,
272
330
  auditContext: "msteams.graph.message",
273
331
  });
274
332
  try {
333
+ messageStatus = msgRes.status;
275
334
  if (msgRes.ok) {
276
- const msgData = (await msgRes.json()) as {
335
+ let msgData: {
277
336
  body?: { content?: string; contentType?: string };
278
- attachments?: Array<{
279
- id?: string;
280
- contentUrl?: string;
281
- contentType?: string;
282
- name?: string;
283
- }>;
337
+ attachments?: GraphAttachment[];
284
338
  };
339
+ try {
340
+ msgData = (await msgRes.json()) as typeof msgData;
341
+ } catch (err) {
342
+ debugLog?.debug?.("graph media message parse failed", {
343
+ messageUrl,
344
+ error: err instanceof Error ? err.message : String(err),
345
+ });
346
+ params.logger?.warn?.("msteams graph message parse failed", {
347
+ error: err instanceof Error ? err.message : String(err),
348
+ messageUrl,
349
+ });
350
+ msgData = {};
351
+ }
352
+ messageAttachments = Array.isArray(msgData.attachments) ? msgData.attachments : [];
285
353
 
286
- // Extract SharePoint file attachments (contentType: "reference")
287
- // Download any file type, not just images
288
- const spAttachments = (msgData.attachments ?? []).filter(
354
+ const spAttachments = messageAttachments.filter(
289
355
  (a) => a.contentType === "reference" && a.contentUrl && a.name,
290
356
  );
291
357
  for (const att of spAttachments) {
292
358
  const name = att.name ?? "file";
359
+ const shareUrl = att.contentUrl ?? "";
360
+ if (!shareUrl) {
361
+ continue;
362
+ }
293
363
 
294
364
  try {
295
- // SharePoint URLs need to be accessed via Graph shares API
296
- const shareUrl = att.contentUrl!;
297
- if (!isUrlAllowed(shareUrl, policy.allowHosts)) {
365
+ const sharesUrl = `${GRAPH_ROOT}/shares/${encodeGraphShareId(shareUrl)}/driveItem/content`;
366
+ if (!isUrlAllowed(sharesUrl, policy.allowHosts)) {
367
+ debugLog?.debug?.("graph media sharepoint url not in allowHosts", {
368
+ messageUrl,
369
+ sharesUrl,
370
+ });
298
371
  continue;
299
372
  }
300
- const encodedUrl = Buffer.from(shareUrl).toString("base64url");
301
- const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
302
373
 
303
374
  const media = await downloadAndStoreMSTeamsRemoteMedia({
304
375
  url: sharesUrl,
@@ -307,9 +378,10 @@ export async function downloadMSTeamsGraphMedia(params: {
307
378
  contentTypeHint: "application/octet-stream",
308
379
  preserveFilenames: params.preserveFilenames,
309
380
  ssrfPolicy,
381
+ useDirectFetch: true,
310
382
  fetchImpl: async (input, init) => {
311
383
  const requestUrl = resolveRequestUrl(input);
312
- const headers = new Headers(init?.headers);
384
+ const headers = ensureUserAgentHeader(init?.headers);
313
385
  applyAuthorizationHeaderForUrl({
314
386
  headers,
315
387
  url: requestUrl,
@@ -324,21 +396,36 @@ export async function downloadMSTeamsGraphMedia(params: {
324
396
  ...init,
325
397
  headers,
326
398
  },
399
+ resolveFn: params.resolveFn,
327
400
  });
328
401
  },
329
402
  });
330
403
  sharePointMedia.push(media);
331
404
  downloadedReferenceUrls.add(shareUrl);
332
- } catch {
333
- // Ignore SharePoint download failures.
405
+ } catch (err) {
406
+ params.logger?.warn?.("msteams SharePoint reference download failed", {
407
+ error: err instanceof Error ? err.message : String(err),
408
+ name,
409
+ });
334
410
  }
335
411
  }
412
+ } else {
413
+ debugLog?.debug?.("graph media message fetch not ok", {
414
+ messageUrl,
415
+ status: messageStatus,
416
+ });
336
417
  }
337
418
  } finally {
338
419
  await release();
339
420
  }
340
- } catch {
341
- // Ignore message fetch failures.
421
+ } catch (err) {
422
+ debugLog?.debug?.("graph media message fetch failed", {
423
+ messageUrl,
424
+ error: err instanceof Error ? err.message : String(err),
425
+ });
426
+ params.logger?.warn?.("msteams graph message fetch failed", {
427
+ error: err instanceof Error ? err.message : String(err),
428
+ });
342
429
  }
343
430
 
344
431
  const hosted = await downloadGraphHostedContent({
@@ -348,20 +435,14 @@ export async function downloadMSTeamsGraphMedia(params: {
348
435
  fetchFn: params.fetchFn,
349
436
  preserveFilenames: params.preserveFilenames,
350
437
  ssrfPolicy,
438
+ logger: params.logger,
351
439
  });
352
440
 
353
- const attachments = await fetchGraphCollection<GraphAttachment>({
354
- url: `${messageUrl}/attachments`,
355
- accessToken,
356
- fetchFn: params.fetchFn,
357
- ssrfPolicy,
358
- });
359
-
360
- const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);
441
+ const normalizedAttachments = messageAttachments.map(normalizeGraphAttachment);
361
442
  const filteredAttachments =
362
443
  sharePointMedia.length > 0
363
444
  ? normalizedAttachments.filter((att) => {
364
- const contentType = att.contentType?.toLowerCase();
445
+ const contentType = normalizeOptionalLowercaseString(att.contentType);
365
446
  if (contentType !== "reference") {
366
447
  return true;
367
448
  }
@@ -372,22 +453,32 @@ export async function downloadMSTeamsGraphMedia(params: {
372
453
  return !downloadedReferenceUrls.has(url);
373
454
  })
374
455
  : normalizedAttachments;
375
- const attachmentMedia = await downloadMSTeamsAttachments({
376
- attachments: filteredAttachments,
377
- maxBytes: params.maxBytes,
378
- tokenProvider: params.tokenProvider,
379
- allowHosts: policy.allowHosts,
380
- authAllowHosts: policy.authAllowHosts,
381
- fetchFn: params.fetchFn,
382
- preserveFilenames: params.preserveFilenames,
383
- });
456
+ let attachmentMedia: MSTeamsInboundMedia[] = [];
457
+ try {
458
+ attachmentMedia = await downloadMSTeamsAttachments({
459
+ attachments: filteredAttachments,
460
+ maxBytes: params.maxBytes,
461
+ tokenProvider: params.tokenProvider,
462
+ allowHosts: policy.allowHosts,
463
+ authAllowHosts: policy.authAllowHosts,
464
+ fetchFn: params.fetchFn,
465
+ resolveFn: params.resolveFn,
466
+ preserveFilenames: params.preserveFilenames,
467
+ logger: params.logger,
468
+ });
469
+ } catch (err) {
470
+ params.logger?.warn?.("msteams graph attachment download failed", {
471
+ error: err instanceof Error ? err.message : String(err),
472
+ messageUrl,
473
+ });
474
+ }
384
475
 
385
476
  return {
386
477
  media: [...sharePointMedia, ...hosted.media, ...attachmentMedia],
387
478
  hostedCount: hosted.count,
388
479
  attachmentCount: filteredAttachments.length + sharePointMedia.length,
389
480
  hostedStatus: hosted.status,
390
- attachmentStatus: attachments.status,
481
+ attachmentStatus: messageStatus,
391
482
  messageUrl,
392
483
  };
393
484
  }
@@ -8,6 +8,37 @@ import {
8
8
  } from "./shared.js";
9
9
  import type { MSTeamsAttachmentLike, MSTeamsHtmlAttachmentSummary } from "./types.js";
10
10
 
11
+ /**
12
+ * Extract every `<attachment id="...">` reference from the HTML attachments in
13
+ * the inbound activity. Returns the complete (non-sliced) list; callers that
14
+ * need a capped diagnostic summary can truncate after calling this helper.
15
+ */
16
+ export function extractMSTeamsHtmlAttachmentIds(
17
+ attachments: MSTeamsAttachmentLike[] | undefined,
18
+ ): string[] {
19
+ const list = Array.isArray(attachments) ? attachments : [];
20
+ if (list.length === 0) {
21
+ return [];
22
+ }
23
+ const ids = new Set<string>();
24
+ for (const att of list) {
25
+ const html = extractHtmlFromAttachment(att);
26
+ if (!html) {
27
+ continue;
28
+ }
29
+ ATTACHMENT_TAG_RE.lastIndex = 0;
30
+ let match: RegExpExecArray | null = ATTACHMENT_TAG_RE.exec(html);
31
+ while (match) {
32
+ const id = match[1]?.trim();
33
+ if (id) {
34
+ ids.add(id);
35
+ }
36
+ match = ATTACHMENT_TAG_RE.exec(html);
37
+ }
38
+ }
39
+ return Array.from(ids);
40
+ }
41
+
11
42
  export function summarizeMSTeamsHtmlAttachments(
12
43
  attachments: MSTeamsAttachmentLike[] | undefined,
13
44
  ): MSTeamsHtmlAttachmentSummary | undefined {
@@ -74,13 +105,14 @@ export function summarizeMSTeamsHtmlAttachments(
74
105
 
75
106
  export function buildMSTeamsAttachmentPlaceholder(
76
107
  attachments: MSTeamsAttachmentLike[] | undefined,
108
+ limits?: { maxInlineBytes?: number; maxInlineTotalBytes?: number },
77
109
  ): string {
78
110
  const list = Array.isArray(attachments) ? attachments : [];
79
111
  if (list.length === 0) {
80
112
  return "";
81
113
  }
82
114
  const imageCount = list.filter(isLikelyImageAttachment).length;
83
- const inlineCount = extractInlineImageCandidates(list).length;
115
+ const inlineCount = extractInlineImageCandidates(list, limits).length;
84
116
  const totalImages = imageCount + inlineCount;
85
117
  if (totalImages > 0) {
86
118
  return `<media:image>${totalImages > 1 ? ` (${totalImages} images)` : ""}`;
@@ -1,4 +1,4 @@
1
- import { buildMediaPayload } from "openclaw/plugin-sdk/msteams";
1
+ import { buildMediaPayload } from "../../runtime-api.js";
2
2
 
3
3
  export function buildMSTeamsMediaPayload(
4
4
  mediaList: Array<{ path: string; contentType?: string }>,
@@ -0,0 +1,137 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Mock the runtime so we can assert whether the strict-dispatcher path
4
+ // (`fetchRemoteMedia`) was invoked versus the new direct-fetch path added
5
+ // for issue #63396 (Node 24+ / undici v7 compat).
6
+ const runtimeFetchRemoteMediaMock = vi.fn();
7
+ const runtimeDetectMimeMock = vi.fn(async () => "image/png");
8
+ const runtimeSaveMediaBufferMock = vi.fn(async (_buf: Buffer, contentType?: string) => ({
9
+ id: "saved",
10
+ path: "/tmp/saved.png",
11
+ size: 42,
12
+ contentType: contentType ?? "image/png",
13
+ }));
14
+
15
+ vi.mock("../runtime.js", () => ({
16
+ getMSTeamsRuntime: () => ({
17
+ media: { detectMime: runtimeDetectMimeMock },
18
+ channel: {
19
+ media: {
20
+ fetchRemoteMedia: runtimeFetchRemoteMediaMock,
21
+ saveMediaBuffer: runtimeSaveMediaBufferMock,
22
+ },
23
+ },
24
+ }),
25
+ }));
26
+
27
+ import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
28
+
29
+ const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
30
+
31
+ function jsonResponse(body: BodyInit, init?: ResponseInit): Response {
32
+ return new Response(body, init);
33
+ }
34
+
35
+ describe("downloadAndStoreMSTeamsRemoteMedia", () => {
36
+ beforeEach(() => {
37
+ runtimeFetchRemoteMediaMock.mockReset();
38
+ runtimeDetectMimeMock.mockClear();
39
+ runtimeSaveMediaBufferMock.mockClear();
40
+ });
41
+
42
+ describe("useDirectFetch: true (Node 24+ / undici v7 path for issue #63396)", () => {
43
+ it("bypasses fetchRemoteMedia and calls the supplied fetchImpl directly", async () => {
44
+ // `fetchImpl` here simulates the "pre-validated hostname" contract from
45
+ // `safeFetchWithPolicy`: the caller has already enforced the allowlist,
46
+ // so the strict SSRF dispatcher is not needed.
47
+ const fetchImpl = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
48
+ jsonResponse(PNG_BYTES, { status: 200, headers: { "content-type": "image/png" } }),
49
+ );
50
+
51
+ const result = await downloadAndStoreMSTeamsRemoteMedia({
52
+ url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
53
+ filePathHint: "file.png",
54
+ maxBytes: 1024,
55
+ useDirectFetch: true,
56
+ fetchImpl,
57
+ });
58
+
59
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
60
+ const [calledUrl] = fetchImpl.mock.calls[0] ?? [];
61
+ expect(calledUrl).toBe("https://graph.microsoft.com/v1.0/shares/abc/driveItem/content");
62
+ expect(runtimeFetchRemoteMediaMock).not.toHaveBeenCalled();
63
+ expect(result.path).toBe("/tmp/saved.png");
64
+ });
65
+
66
+ it("surfaces HTTP errors as exceptions (no silent drop)", async () => {
67
+ const fetchImpl = vi.fn(async () => jsonResponse("nope", { status: 403 }));
68
+
69
+ await expect(
70
+ downloadAndStoreMSTeamsRemoteMedia({
71
+ url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
72
+ filePathHint: "file.png",
73
+ maxBytes: 1024,
74
+ useDirectFetch: true,
75
+ fetchImpl,
76
+ }),
77
+ ).rejects.toThrow(/HTTP 403/);
78
+ expect(runtimeFetchRemoteMediaMock).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it("rejects a response whose Content-Length exceeds maxBytes", async () => {
82
+ const fetchImpl = vi.fn(async () =>
83
+ jsonResponse(PNG_BYTES, {
84
+ status: 200,
85
+ headers: { "content-length": "999999" },
86
+ }),
87
+ );
88
+
89
+ await expect(
90
+ downloadAndStoreMSTeamsRemoteMedia({
91
+ url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
92
+ filePathHint: "file.png",
93
+ maxBytes: 1024,
94
+ useDirectFetch: true,
95
+ fetchImpl,
96
+ }),
97
+ ).rejects.toThrow(/exceeds maxBytes/);
98
+ expect(runtimeFetchRemoteMediaMock).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it("falls back to the runtime fetchRemoteMedia path when useDirectFetch is omitted", async () => {
102
+ // Non-SharePoint caller, no pre-validated fetchImpl: make sure the strict
103
+ // SSRF dispatcher path is still used.
104
+ runtimeFetchRemoteMediaMock.mockResolvedValueOnce({
105
+ buffer: PNG_BYTES,
106
+ contentType: "image/png",
107
+ fileName: "file.png",
108
+ });
109
+
110
+ await downloadAndStoreMSTeamsRemoteMedia({
111
+ url: "https://tenant.sharepoint.com/file.png",
112
+ filePathHint: "file.png",
113
+ maxBytes: 1024,
114
+ });
115
+
116
+ expect(runtimeFetchRemoteMediaMock).toHaveBeenCalledTimes(1);
117
+ });
118
+
119
+ it("does not use the direct path when useDirectFetch is true but fetchImpl is missing", async () => {
120
+ runtimeFetchRemoteMediaMock.mockResolvedValueOnce({
121
+ buffer: PNG_BYTES,
122
+ contentType: "image/png",
123
+ });
124
+
125
+ await downloadAndStoreMSTeamsRemoteMedia({
126
+ url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
127
+ filePathHint: "file.png",
128
+ maxBytes: 1024,
129
+ useDirectFetch: true,
130
+ });
131
+
132
+ // Without a fetchImpl to delegate to, we must fall back to the runtime
133
+ // path rather than crashing.
134
+ expect(runtimeFetchRemoteMediaMock).toHaveBeenCalledTimes(1);
135
+ });
136
+ });
137
+ });