@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
@@ -1,10 +1,61 @@
1
- import type { SsrFPolicy } from "openclaw/plugin-sdk/msteams";
1
+ import { readResponseWithLimit } from "openclaw/plugin-sdk/media-runtime";
2
+ import type { SsrFPolicy } from "../../runtime-api.js";
2
3
  import { getMSTeamsRuntime } from "../runtime.js";
3
4
  import { inferPlaceholder } from "./shared.js";
4
5
  import type { MSTeamsInboundMedia } from "./types.js";
5
6
 
6
7
  type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
7
8
 
9
+ type FetchedRemoteMedia = {
10
+ buffer: Buffer;
11
+ contentType?: string;
12
+ };
13
+
14
+ /**
15
+ * Direct fetch path used when the caller's `fetchImpl` has already validated
16
+ * the URL against a hostname allowlist (for example `safeFetchWithPolicy`).
17
+ *
18
+ * Bypasses the strict SSRF dispatcher on `fetchRemoteMedia` because:
19
+ * 1. The pinned undici dispatcher used by `fetchRemoteMedia` is incompatible
20
+ * with Node 24+'s built-in undici v7 (fails with "invalid onRequestStart
21
+ * method"), which silently breaks SharePoint/OneDrive downloads. See
22
+ * issue #63396.
23
+ * 2. SSRF protection is already enforced by the caller's `fetchImpl`
24
+ * (`safeFetch` validates every redirect hop against the hostname
25
+ * allowlist before following).
26
+ */
27
+ async function fetchRemoteMediaDirect(params: {
28
+ url: string;
29
+ fetchImpl: FetchLike;
30
+ maxBytes: number;
31
+ }): Promise<FetchedRemoteMedia> {
32
+ const response = await params.fetchImpl(params.url, { redirect: "follow" });
33
+ if (!response.ok) {
34
+ const statusText = response.statusText ? ` ${response.statusText}` : "";
35
+ throw new Error(`HTTP ${response.status}${statusText}`);
36
+ }
37
+
38
+ // Enforce the max-bytes cap before buffering the full body so a rogue
39
+ // response cannot drive RSS usage past the configured limit.
40
+ const contentLength = response.headers.get("content-length");
41
+ if (contentLength) {
42
+ const length = Number(contentLength);
43
+ if (Number.isFinite(length) && length > params.maxBytes) {
44
+ throw new Error(`content length ${length} exceeds maxBytes ${params.maxBytes}`);
45
+ }
46
+ }
47
+
48
+ const buffer = await readResponseWithLimit(response, params.maxBytes, {
49
+ onOverflow: ({ size, maxBytes }) =>
50
+ new Error(`payload size ${size} exceeds maxBytes ${maxBytes}`),
51
+ });
52
+
53
+ return {
54
+ buffer,
55
+ contentType: response.headers.get("content-type") ?? undefined,
56
+ };
57
+ }
58
+
8
59
  export async function downloadAndStoreMSTeamsRemoteMedia(params: {
9
60
  url: string;
10
61
  filePathHint: string;
@@ -14,14 +65,30 @@ export async function downloadAndStoreMSTeamsRemoteMedia(params: {
14
65
  contentTypeHint?: string;
15
66
  placeholder?: string;
16
67
  preserveFilenames?: boolean;
68
+ /**
69
+ * Opt into a direct fetch path that bypasses `fetchRemoteMedia`'s strict
70
+ * SSRF dispatcher. Required for SharePoint/OneDrive downloads on Node 24+
71
+ * (see issue #63396). Only safe when the supplied `fetchImpl` has already
72
+ * validated the URL against a hostname allowlist.
73
+ */
74
+ useDirectFetch?: boolean;
17
75
  }): Promise<MSTeamsInboundMedia> {
18
- const fetched = await getMSTeamsRuntime().channel.media.fetchRemoteMedia({
19
- url: params.url,
20
- fetchImpl: params.fetchImpl,
21
- filePathHint: params.filePathHint,
22
- maxBytes: params.maxBytes,
23
- ssrfPolicy: params.ssrfPolicy,
24
- });
76
+ let fetched: FetchedRemoteMedia;
77
+ if (params.useDirectFetch && params.fetchImpl) {
78
+ fetched = await fetchRemoteMediaDirect({
79
+ url: params.url,
80
+ fetchImpl: params.fetchImpl,
81
+ maxBytes: params.maxBytes,
82
+ });
83
+ } else {
84
+ fetched = await getMSTeamsRuntime().channel.media.fetchRemoteMedia({
85
+ url: params.url,
86
+ fetchImpl: params.fetchImpl,
87
+ filePathHint: params.filePathHint,
88
+ maxBytes: params.maxBytes,
89
+ ssrfPolicy: params.ssrfPolicy,
90
+ });
91
+ }
25
92
  const mime = await getMSTeamsRuntime().media.detectMime({
26
93
  buffer: fetched.buffer,
27
94
  headerMime: fetched.contentType ?? params.contentTypeHint,
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  applyAuthorizationHeaderForUrl,
4
+ encodeGraphShareId,
5
+ extractInlineImageCandidates,
6
+ isGraphSharedLinkUrl,
4
7
  isPrivateOrReservedIP,
5
8
  isUrlAllowed,
6
9
  resolveAndValidateIP,
@@ -10,6 +13,7 @@ import {
10
13
  resolveMediaSsrfPolicy,
11
14
  safeFetch,
12
15
  safeFetchWithPolicy,
16
+ tryBuildGraphSharesUrlForSharedLink,
13
17
  } from "./shared.js";
14
18
 
15
19
  const publicResolve = async () => ({ address: "13.107.136.10" });
@@ -216,7 +220,9 @@ describe("safeFetch", () => {
216
220
  const rebindingResolve = async () => {
217
221
  callCount++;
218
222
  // First call (initial URL) resolves to public IP
219
- if (callCount === 1) return { address: "13.107.136.10" };
223
+ if (callCount === 1) {
224
+ return { address: "13.107.136.10" };
225
+ }
220
226
  // Second call (redirect target) resolves to private IP
221
227
  return { address: "169.254.169.254" };
222
228
  };
@@ -248,6 +254,18 @@ describe("safeFetch", () => {
248
254
  expect(fetchMock).not.toHaveBeenCalled();
249
255
  });
250
256
 
257
+ it("blocks private hosts with the default resolver", async () => {
258
+ const fetchMock = vi.fn();
259
+ await expect(
260
+ safeFetch({
261
+ url: "https://localhost/file.pdf",
262
+ allowHosts: ["localhost"],
263
+ fetchFn: fetchMock as unknown as typeof fetch,
264
+ }),
265
+ ).rejects.toThrow("Initial download URL blocked");
266
+ expect(fetchMock).not.toHaveBeenCalled();
267
+ });
268
+
251
269
  it("blocks when initial URL DNS resolution fails", async () => {
252
270
  const fetchMock = vi.fn();
253
271
  await expect(
@@ -391,3 +409,122 @@ describe("attachment fetch auth helpers", () => {
391
409
  expect(fetchMock).toHaveBeenCalledOnce();
392
410
  });
393
411
  });
412
+
413
+ describe("Graph shared-link helpers", () => {
414
+ it.each([
415
+ ["https://contoso.sharepoint.com/personal/user/Documents/report.pdf", true],
416
+ ["https://contoso.sharepoint.us/sites/team/file.docx", true],
417
+ ["https://contoso.sharepoint.cn/file", true],
418
+ ["https://tenant-my.sharepoint.com/:b:/g/personal/file", true],
419
+ ["https://1drv.ms/b/s!AkxYabc", true],
420
+ ["https://onedrive.live.com/view.aspx?resid=ABC", true],
421
+ ["https://onedrive.com/share/abc", true],
422
+ ["https://graph.microsoft.com/v1.0/me", false],
423
+ ["https://smba.trafficmanager.net/amer/v3", false],
424
+ ["https://example.com/file.pdf", false],
425
+ ["not-a-url", false],
426
+ ])("isGraphSharedLinkUrl(%s) === %s", (url, expected) => {
427
+ expect(isGraphSharedLinkUrl(url)).toBe(expected);
428
+ });
429
+
430
+ it("encodeGraphShareId uses u! + base64url without padding", () => {
431
+ // Graph docs example: encoding "https://onedrive.live.com/redir?resid=..."
432
+ // should yield u!aHR0cHM6... (base64url, no '+', '/', or trailing '=').
433
+ const url = "https://contoso.sharepoint.com/sites/a/Shared Documents/file.pdf";
434
+ const shareId = encodeGraphShareId(url);
435
+ expect(shareId.startsWith("u!")).toBe(true);
436
+ const encoded = shareId.slice(2);
437
+ // base64url alphabet is A-Z, a-z, 0-9, '-', '_' (no padding).
438
+ expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
439
+ // Round-trip check: decoding yields the original URL.
440
+ const decoded = Buffer.from(encoded, "base64url").toString("utf8");
441
+ expect(decoded).toBe(url);
442
+ });
443
+
444
+ it("encodeGraphShareId swaps '+' and '/' for '-' and '_'", () => {
445
+ // A URL whose standard base64 contains '+' and '/' chars.
446
+ // Choose an input that base64 encodes with those characters.
447
+ const url = "https://host.sharepoint.com/sites/path?x=???";
448
+ const shareId = encodeGraphShareId(url);
449
+ const encoded = shareId.slice(2);
450
+ expect(encoded).not.toContain("+");
451
+ expect(encoded).not.toContain("/");
452
+ expect(encoded).not.toContain("=");
453
+ });
454
+
455
+ it("tryBuildGraphSharesUrlForSharedLink rewrites SharePoint URLs", () => {
456
+ const url = "https://contoso.sharepoint.com/personal/user/Documents/report.pdf";
457
+ const result = tryBuildGraphSharesUrlForSharedLink(url);
458
+ expect(result).toBeDefined();
459
+ expect(result).toMatch(
460
+ /^https:\/\/graph\.microsoft\.com\/v1\.0\/shares\/u![A-Za-z0-9_-]+\/driveItem\/content$/,
461
+ );
462
+ });
463
+
464
+ it("tryBuildGraphSharesUrlForSharedLink rewrites OneDrive URLs", () => {
465
+ const url = "https://1drv.ms/b/s!AkxYabcdefg";
466
+ const result = tryBuildGraphSharesUrlForSharedLink(url);
467
+ expect(result).toBeDefined();
468
+ expect(result).toMatch(
469
+ /^https:\/\/graph\.microsoft\.com\/v1\.0\/shares\/u![A-Za-z0-9_-]+\/driveItem\/content$/,
470
+ );
471
+ });
472
+
473
+ it("tryBuildGraphSharesUrlForSharedLink returns undefined for non-shared URLs", () => {
474
+ expect(
475
+ tryBuildGraphSharesUrlForSharedLink("https://graph.microsoft.com/v1.0/me"),
476
+ ).toBeUndefined();
477
+ expect(tryBuildGraphSharesUrlForSharedLink("https://example.com/file.pdf")).toBeUndefined();
478
+ expect(tryBuildGraphSharesUrlForSharedLink("not-a-url")).toBeUndefined();
479
+ });
480
+ });
481
+
482
+ describe("msteams inline image limits", () => {
483
+ const smallPngDataUrl = "data:image/png;base64,aGVsbG8="; // "hello" (5 bytes)
484
+
485
+ it("rejects inline data images above per-image limit", () => {
486
+ const attachments = [
487
+ {
488
+ contentType: "text/html",
489
+ content: `<img src="${smallPngDataUrl}" />`,
490
+ },
491
+ ];
492
+ const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 4 });
493
+ expect(out).toEqual([]);
494
+ });
495
+
496
+ it("accepts inline data images within limit", () => {
497
+ const attachments = [
498
+ {
499
+ contentType: "text/html",
500
+ content: `<img src="${smallPngDataUrl}" />`,
501
+ },
502
+ ];
503
+ const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 10 });
504
+ expect(out.length).toBe(1);
505
+ expect(out[0]?.kind).toBe("data");
506
+ if (out[0]?.kind === "data") {
507
+ expect(out[0].data.byteLength).toBeGreaterThan(0);
508
+ expect(out[0].contentType).toBe("image/png");
509
+ }
510
+ });
511
+
512
+ it("enforces cumulative inline size limit across attachments", () => {
513
+ const attachments = [
514
+ {
515
+ contentType: "text/html",
516
+ content: `<img src="${smallPngDataUrl}" />`,
517
+ },
518
+ {
519
+ contentType: "text/html",
520
+ content: `<img src="${smallPngDataUrl}" />`,
521
+ },
522
+ ];
523
+ const out = extractInlineImageCandidates(attachments, {
524
+ maxInlineBytes: 10,
525
+ maxInlineTotalBytes: 6,
526
+ });
527
+ expect(out.length).toBe(1);
528
+ expect(out[0]?.kind).toBe("data");
529
+ });
530
+ });
@@ -1,11 +1,17 @@
1
+ import { Buffer } from "node:buffer";
1
2
  import { lookup } from "node:dns/promises";
2
3
  import {
3
4
  buildHostnameAllowlistPolicyFromSuffixAllowlist,
4
5
  isHttpsUrlAllowedByHostnameSuffixAllowlist,
5
6
  isPrivateIpAddress,
6
7
  normalizeHostnameSuffixAllowlist,
7
- } from "openclaw/plugin-sdk/msteams";
8
- import type { SsrFPolicy } from "openclaw/plugin-sdk/msteams";
8
+ type SsrFPolicy,
9
+ } from "openclaw/plugin-sdk/ssrf-policy";
10
+ import {
11
+ isRecord,
12
+ normalizeLowercaseStringOrEmpty,
13
+ normalizeOptionalString,
14
+ } from "openclaw/plugin-sdk/text-runtime";
9
15
  import type { MSTeamsAttachmentLike } from "./types.js";
10
16
 
11
17
  type InlineImageCandidate =
@@ -23,12 +29,17 @@ type InlineImageCandidate =
23
29
  placeholder: string;
24
30
  };
25
31
 
26
- export const IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i;
32
+ type InlineImageLimitOptions = {
33
+ maxInlineBytes?: number;
34
+ maxInlineTotalBytes?: number;
35
+ };
36
+
37
+ const IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i;
27
38
 
28
39
  export const IMG_SRC_RE = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
29
40
  export const ATTACHMENT_TAG_RE = /<attachment[^>]+id=["']([^"']+)["'][^>]*>/gi;
30
41
 
31
- export const DEFAULT_MEDIA_HOST_ALLOWLIST = [
42
+ const DEFAULT_MEDIA_HOST_ALLOWLIST = [
32
43
  "graph.microsoft.com",
33
44
  "graph.microsoft.us",
34
45
  "graph.microsoft.de",
@@ -56,9 +67,12 @@ export const DEFAULT_MEDIA_HOST_ALLOWLIST = [
56
67
  "microsoft.com",
57
68
  ] as const;
58
69
 
59
- export const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [
70
+ const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [
60
71
  "api.botframework.com",
61
72
  "botframework.com",
73
+ // Bot Framework Service URL (smba.trafficmanager.net) used for outbound
74
+ // replies and inbound attachment downloads (clipboard-pasted images).
75
+ "smba.trafficmanager.net",
62
76
  "graph.microsoft.com",
63
77
  "graph.microsoft.us",
64
78
  "graph.microsoft.de",
@@ -66,9 +80,114 @@ export const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [
66
80
  ] as const;
67
81
 
68
82
  export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
83
+ export { isRecord };
84
+
85
+ // Keep this local; importing the broad media-runtime SDK barrel pulls image/audio runtimes into
86
+ // hot MSTeams attachment tests for one tiny estimator.
87
+ export function estimateBase64DecodedBytes(base64: string): number {
88
+ let effectiveLen = 0;
89
+ for (let i = 0; i < base64.length; i += 1) {
90
+ const code = base64.charCodeAt(i);
91
+ if (code <= 0x20) {
92
+ continue;
93
+ }
94
+ effectiveLen += 1;
95
+ }
96
+
97
+ if (effectiveLen === 0) {
98
+ return 0;
99
+ }
100
+
101
+ let padding = 0;
102
+ let end = base64.length - 1;
103
+ while (end >= 0 && base64.charCodeAt(end) <= 0x20) {
104
+ end -= 1;
105
+ }
106
+ if (end >= 0 && base64[end] === "=") {
107
+ padding = 1;
108
+ end -= 1;
109
+ while (end >= 0 && base64.charCodeAt(end) <= 0x20) {
110
+ end -= 1;
111
+ }
112
+ if (end >= 0 && base64[end] === "=") {
113
+ padding = 2;
114
+ }
115
+ }
116
+
117
+ const estimated = Math.floor((effectiveLen * 3) / 4) - padding;
118
+ return Math.max(0, estimated);
119
+ }
120
+
121
+ /**
122
+ * Host suffixes for SharePoint/OneDrive shared links that must be fetched via
123
+ * the Graph `/shares/{shareId}/driveItem/content` endpoint instead of directly.
124
+ *
125
+ * Direct fetches of SharePoint/OneDrive shared URLs return empty/HTML landing
126
+ * pages unless encoded as a Graph share id. See
127
+ * https://learn.microsoft.com/en-us/graph/api/shares-get for the encoding.
128
+ */
129
+ const GRAPH_SHARED_LINK_HOST_SUFFIXES = [
130
+ ".sharepoint.com",
131
+ ".sharepoint.us",
132
+ ".sharepoint.de",
133
+ ".sharepoint.cn",
134
+ ".sharepoint-df.com",
135
+ "1drv.ms",
136
+ "onedrive.live.com",
137
+ "onedrive.com",
138
+ ] as const;
139
+
140
+ /**
141
+ * Returns true when the URL points at a SharePoint or OneDrive host whose
142
+ * shared-link content must be fetched through the Graph shares API rather
143
+ * than directly.
144
+ */
145
+ export function isGraphSharedLinkUrl(url: string): boolean {
146
+ let host: string;
147
+ try {
148
+ host = normalizeLowercaseStringOrEmpty(new URL(url).hostname);
149
+ } catch {
150
+ return false;
151
+ }
152
+ if (!host) {
153
+ return false;
154
+ }
155
+ return GRAPH_SHARED_LINK_HOST_SUFFIXES.some((suffix) => host === suffix || host.endsWith(suffix));
156
+ }
69
157
 
70
- export function isRecord(value: unknown): value is Record<string, unknown> {
71
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
158
+ /**
159
+ * Encode a SharePoint/OneDrive URL as a Graph shareId using the documented
160
+ * `u!` + base64url (no padding) scheme:
161
+ * https://learn.microsoft.com/en-us/graph/api/shares-get#encoding-sharing-urls
162
+ */
163
+ export function encodeGraphShareId(url: string): string {
164
+ // Buffer.from(...).toString("base64url") already returns base64url without
165
+ // padding, matching the Graph spec exactly.
166
+ return `u!${Buffer.from(url, "utf8").toString("base64url")}`;
167
+ }
168
+
169
+ /**
170
+ * When `url` is a SharePoint/OneDrive shared link, return the matching
171
+ * `GET /shares/{shareId}/driveItem/content` URL that actually yields the file
172
+ * bytes. Returns `undefined` for non-shared-link URLs so callers can fall
173
+ * through to the existing fetch path.
174
+ */
175
+ export function tryBuildGraphSharesUrlForSharedLink(url: string): string | undefined {
176
+ if (!isGraphSharedLinkUrl(url)) {
177
+ return undefined;
178
+ }
179
+ return `${GRAPH_ROOT}/shares/${encodeGraphShareId(url)}/driveItem/content`;
180
+ }
181
+
182
+ export function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
183
+ let current: unknown = value;
184
+ for (const key of keys) {
185
+ if (!isRecord(current)) {
186
+ return undefined;
187
+ }
188
+ current = current[key as keyof typeof current];
189
+ }
190
+ return normalizeOptionalString(current);
72
191
  }
73
192
 
74
193
  export function resolveRequestUrl(input: RequestInfo | URL): string {
@@ -81,7 +200,11 @@ export function resolveRequestUrl(input: RequestInfo | URL): string {
81
200
  if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
82
201
  return input.url;
83
202
  }
84
- return String(input);
203
+ try {
204
+ return JSON.stringify(input);
205
+ } catch {
206
+ return "";
207
+ }
85
208
  }
86
209
 
87
210
  export function normalizeContentType(value: unknown): string | undefined {
@@ -97,9 +220,9 @@ export function inferPlaceholder(params: {
97
220
  fileName?: string;
98
221
  fileType?: string;
99
222
  }): string {
100
- const mime = params.contentType?.toLowerCase() ?? "";
101
- const name = params.fileName?.toLowerCase() ?? "";
102
- const fileType = params.fileType?.toLowerCase() ?? "";
223
+ const mime = normalizeLowercaseStringOrEmpty(params.contentType ?? "");
224
+ const name = normalizeLowercaseStringOrEmpty(params.fileName ?? "");
225
+ const fileType = normalizeLowercaseStringOrEmpty(params.fileType ?? "");
103
226
 
104
227
  const looksLikeImage =
105
228
  mime.startsWith("image/") || IMAGE_EXT_RE.test(name) || IMAGE_EXT_RE.test(`x.${fileType}`);
@@ -184,25 +307,44 @@ export function extractHtmlFromAttachment(att: MSTeamsAttachmentLike): string |
184
307
  return text;
185
308
  }
186
309
 
187
- function decodeDataImage(src: string): InlineImageCandidate | null {
310
+ function isLikelyBase64Payload(value: string): boolean {
311
+ return /^[A-Za-z0-9+/=\r\n]+$/.test(value);
312
+ }
313
+
314
+ function decodeDataImageWithLimits(
315
+ src: string,
316
+ opts: { maxInlineBytes?: number },
317
+ ): { candidate: InlineImageCandidate | null; estimatedBytes: number } {
188
318
  const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src);
189
319
  if (!match) {
190
- return null;
320
+ return { candidate: null, estimatedBytes: 0 };
191
321
  }
192
- const contentType = match[1]?.toLowerCase();
322
+ const contentType = normalizeLowercaseStringOrEmpty(match[1] ?? "");
193
323
  const isBase64 = Boolean(match[2]);
194
324
  if (!isBase64) {
195
- return null;
325
+ return { candidate: null, estimatedBytes: 0 };
196
326
  }
197
327
  const payload = match[3] ?? "";
198
- if (!payload) {
199
- return null;
328
+ if (!payload || !isLikelyBase64Payload(payload)) {
329
+ return { candidate: null, estimatedBytes: 0 };
200
330
  }
331
+
332
+ const estimatedBytes = estimateBase64DecodedBytes(payload);
333
+ if (estimatedBytes <= 0) {
334
+ return { candidate: null, estimatedBytes: 0 };
335
+ }
336
+ if (typeof opts.maxInlineBytes === "number" && estimatedBytes > opts.maxInlineBytes) {
337
+ return { candidate: null, estimatedBytes };
338
+ }
339
+
201
340
  try {
202
341
  const data = Buffer.from(payload, "base64");
203
- return { kind: "data", data, contentType, placeholder: "<media:image>" };
342
+ return {
343
+ candidate: { kind: "data", data, contentType, placeholder: "<media:image>" },
344
+ estimatedBytes,
345
+ };
204
346
  } catch {
205
- return null;
347
+ return { candidate: null, estimatedBytes: 0 };
206
348
  }
207
349
  }
208
350
 
@@ -218,9 +360,11 @@ function fileHintFromUrl(src: string): string | undefined {
218
360
 
219
361
  export function extractInlineImageCandidates(
220
362
  attachments: MSTeamsAttachmentLike[],
363
+ limits?: InlineImageLimitOptions,
221
364
  ): InlineImageCandidate[] {
222
365
  const out: InlineImageCandidate[] = [];
223
- for (const att of attachments) {
366
+ let totalEstimatedInlineBytes = 0;
367
+ outerLoop: for (const att of attachments) {
224
368
  const html = extractHtmlFromAttachment(att);
225
369
  if (!html) {
226
370
  continue;
@@ -231,8 +375,18 @@ export function extractInlineImageCandidates(
231
375
  const src = match[1]?.trim();
232
376
  if (src && !src.startsWith("cid:")) {
233
377
  if (src.startsWith("data:")) {
234
- const decoded = decodeDataImage(src);
378
+ const { candidate: decoded, estimatedBytes } = decodeDataImageWithLimits(src, {
379
+ maxInlineBytes: limits?.maxInlineBytes,
380
+ });
235
381
  if (decoded) {
382
+ const nextTotal = totalEstimatedInlineBytes + estimatedBytes;
383
+ if (
384
+ typeof limits?.maxInlineTotalBytes === "number" &&
385
+ nextTotal > limits.maxInlineTotalBytes
386
+ ) {
387
+ break outerLoop;
388
+ }
389
+ totalEstimatedInlineBytes = nextTotal;
236
390
  out.push(decoded);
237
391
  }
238
392
  } else {
@@ -252,7 +406,7 @@ export function extractInlineImageCandidates(
252
406
 
253
407
  export function safeHostForUrl(url: string): string {
254
408
  try {
255
- return new URL(url).hostname.toLowerCase();
409
+ return normalizeLowercaseStringOrEmpty(new URL(url).hostname);
256
410
  } catch {
257
411
  return "invalid-url";
258
412
  }
@@ -271,6 +425,19 @@ export type MSTeamsAttachmentFetchPolicy = {
271
425
  authAllowHosts: string[];
272
426
  };
273
427
 
428
+ /**
429
+ * Logger surface for attachment download errors. Structured so callers can
430
+ * pass `MSTeamsMonitorLogger` directly without adapters. Optional `warn`/
431
+ * `error` methods prevent silent swallowing of fetch failures — see issue
432
+ * #63396 where empty `catch {}` blocks hid a Node 24+ undici incompatibility.
433
+ */
434
+ export type MSTeamsAttachmentDownloadLogger = {
435
+ warn?: (message: string, meta?: Record<string, unknown>) => void;
436
+ error?: (message: string, meta?: Record<string, unknown>) => void;
437
+ };
438
+
439
+ export type MSTeamsAttachmentResolveFn = (hostname: string) => Promise<{ address: string }>;
440
+
274
441
  export function resolveAttachmentFetchPolicy(params?: {
275
442
  allowHosts?: string[];
276
443
  authAllowHosts?: string[];
@@ -322,7 +489,7 @@ export const isPrivateOrReservedIP: (ip: string) => boolean = isPrivateIpAddress
322
489
  */
323
490
  export async function resolveAndValidateIP(
324
491
  hostname: string,
325
- resolveFn?: (hostname: string) => Promise<{ address: string }>,
492
+ resolveFn?: MSTeamsAttachmentResolveFn,
326
493
  ): Promise<string> {
327
494
  const resolve = resolveFn ?? lookup;
328
495
  let resolved: { address: string };
@@ -359,10 +526,10 @@ export async function safeFetch(params: {
359
526
  authorizationAllowHosts?: string[];
360
527
  fetchFn?: typeof fetch;
361
528
  requestInit?: RequestInit;
362
- resolveFn?: (hostname: string) => Promise<{ address: string }>;
529
+ resolveFn?: MSTeamsAttachmentResolveFn;
363
530
  }): Promise<Response> {
364
531
  const fetchFn = params.fetchFn ?? fetch;
365
- const resolveFn = params.resolveFn;
532
+ const resolveFn = params.resolveFn ?? lookup;
366
533
  const hasDispatcher = Boolean(
367
534
  params.requestInit &&
368
535
  typeof params.requestInit === "object" &&
@@ -446,7 +613,7 @@ export async function safeFetchWithPolicy(params: {
446
613
  policy: MSTeamsAttachmentFetchPolicy;
447
614
  fetchFn?: typeof fetch;
448
615
  requestInit?: RequestInit;
449
- resolveFn?: (hostname: string) => Promise<{ address: string }>;
616
+ resolveFn?: MSTeamsAttachmentResolveFn;
450
617
  }): Promise<Response> {
451
618
  return await safeFetch({
452
619
  url: params.url,
@@ -35,3 +35,13 @@ export type MSTeamsGraphMediaResult = {
35
35
  messageUrl?: string;
36
36
  tokenError?: boolean;
37
37
  };
38
+
39
+ /**
40
+ * Narrow logger surface used by `downloadMSTeamsGraphMedia` for diagnostic
41
+ * events. Accepting an optional callback keeps the helper testable without
42
+ * pulling in the full channel logger type, while still allowing the monitor
43
+ * handler to forward its plugin logger.
44
+ */
45
+ export type MSTeamsGraphMediaLogger = {
46
+ debug?: (message: string, meta?: Record<string, unknown>) => void;
47
+ };