@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.
- package/api.ts +3 -0
- package/channel-config-api.ts +1 -0
- package/channel-plugin-api.ts +2 -0
- package/config-api.ts +4 -0
- package/contract-api.ts +4 -0
- package/index.ts +15 -12
- package/openclaw.plugin.json +553 -1
- package/package.json +46 -12
- package/runtime-api.ts +73 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/src/ai-entity.ts +7 -0
- package/src/approval-auth.ts +44 -0
- package/src/attachments/bot-framework.test.ts +461 -0
- package/src/attachments/bot-framework.ts +362 -0
- package/src/attachments/download.ts +63 -19
- package/src/attachments/graph.test.ts +416 -0
- package/src/attachments/graph.ts +163 -72
- package/src/attachments/html.ts +33 -1
- package/src/attachments/payload.ts +1 -1
- package/src/attachments/remote-media.test.ts +137 -0
- package/src/attachments/remote-media.ts +75 -8
- package/src/attachments/shared.test.ts +138 -1
- package/src/attachments/shared.ts +193 -26
- package/src/attachments/types.ts +10 -0
- package/src/attachments.graph.test.ts +342 -0
- package/src/attachments.helpers.test.ts +246 -0
- package/src/attachments.test-helpers.ts +17 -0
- package/src/attachments.test.ts +163 -418
- package/src/attachments.ts +5 -5
- package/src/block-streaming-config.test.ts +61 -0
- package/src/channel-api.ts +1 -0
- package/src/channel.actions.test.ts +742 -0
- package/src/channel.directory.test.ts +145 -4
- package/src/channel.runtime.ts +56 -0
- package/src/channel.setup.ts +77 -0
- package/src/channel.test.ts +128 -0
- package/src/channel.ts +1077 -395
- package/src/config-schema.ts +6 -0
- package/src/config-ui-hints.ts +12 -0
- package/src/conversation-store-fs.test.ts +4 -5
- package/src/conversation-store-fs.ts +35 -51
- package/src/conversation-store-helpers.test.ts +202 -0
- package/src/conversation-store-helpers.ts +105 -0
- package/src/conversation-store-memory.ts +27 -23
- package/src/conversation-store.shared.test.ts +225 -0
- package/src/conversation-store.ts +30 -0
- package/src/directory-live.test.ts +156 -0
- package/src/directory-live.ts +7 -4
- package/src/doctor.ts +27 -0
- package/src/errors.test.ts +64 -1
- package/src/errors.ts +50 -9
- package/src/feedback-reflection-prompt.ts +117 -0
- package/src/feedback-reflection-store.ts +114 -0
- package/src/feedback-reflection.test.ts +237 -0
- package/src/feedback-reflection.ts +283 -0
- package/src/file-consent-helpers.test.ts +83 -0
- package/src/file-consent-helpers.ts +64 -11
- package/src/file-consent-invoke.ts +150 -0
- package/src/file-consent.test.ts +363 -0
- package/src/file-consent.ts +165 -4
- package/src/graph-chat.ts +5 -3
- package/src/graph-group-management.test.ts +318 -0
- package/src/graph-group-management.ts +168 -0
- package/src/graph-members.test.ts +89 -0
- package/src/graph-members.ts +48 -0
- package/src/graph-messages.actions.test.ts +243 -0
- package/src/graph-messages.read.test.ts +391 -0
- package/src/graph-messages.search.test.ts +213 -0
- package/src/graph-messages.test-helpers.ts +50 -0
- package/src/graph-messages.ts +534 -0
- package/src/graph-teams.test.ts +215 -0
- package/src/graph-teams.ts +114 -0
- package/src/graph-thread.test.ts +246 -0
- package/src/graph-thread.ts +146 -0
- package/src/graph-upload.test.ts +161 -4
- package/src/graph-upload.ts +147 -56
- package/src/graph.test.ts +516 -0
- package/src/graph.ts +233 -21
- package/src/inbound.test.ts +156 -1
- package/src/inbound.ts +101 -1
- package/src/media-helpers.ts +1 -1
- package/src/mentions.test.ts +27 -18
- package/src/mentions.ts +2 -2
- package/src/messenger.test.ts +504 -23
- package/src/messenger.ts +133 -52
- package/src/monitor-handler/access.ts +125 -0
- package/src/monitor-handler/inbound-media.test.ts +289 -0
- package/src/monitor-handler/inbound-media.ts +57 -5
- package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
- package/src/monitor-handler/message-handler.authz.test.ts +588 -74
- package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
- package/src/monitor-handler/message-handler.test-support.ts +100 -0
- package/src/monitor-handler/message-handler.thread-parent.test.ts +223 -0
- package/src/monitor-handler/message-handler.thread-session.test.ts +77 -0
- package/src/monitor-handler/message-handler.ts +470 -164
- package/src/monitor-handler/reaction-handler.test.ts +267 -0
- package/src/monitor-handler/reaction-handler.ts +210 -0
- package/src/monitor-handler/thread-session.ts +17 -0
- package/src/monitor-handler.adaptive-card.test.ts +162 -0
- package/src/monitor-handler.feedback-authz.test.ts +314 -0
- package/src/monitor-handler.file-consent.test.ts +281 -79
- package/src/monitor-handler.sso.test.ts +563 -0
- package/src/monitor-handler.test-helpers.ts +180 -0
- package/src/monitor-handler.ts +459 -115
- package/src/monitor-handler.types.ts +27 -0
- package/src/monitor-types.ts +1 -0
- package/src/monitor.lifecycle.test.ts +74 -10
- package/src/monitor.test.ts +35 -1
- package/src/monitor.ts +143 -46
- package/src/oauth.flow.ts +77 -0
- package/src/oauth.shared.ts +37 -0
- package/src/oauth.test.ts +305 -0
- package/src/oauth.token.ts +158 -0
- package/src/oauth.ts +130 -0
- package/src/outbound.test.ts +10 -11
- package/src/outbound.ts +62 -44
- package/src/pending-uploads-fs.test.ts +246 -0
- package/src/pending-uploads-fs.ts +235 -0
- package/src/pending-uploads.test.ts +173 -0
- package/src/pending-uploads.ts +34 -2
- package/src/policy.test.ts +11 -5
- package/src/policy.ts +5 -5
- package/src/polls.test.ts +106 -5
- package/src/polls.ts +15 -7
- package/src/presentation.ts +68 -0
- package/src/probe.test.ts +27 -8
- package/src/probe.ts +43 -9
- package/src/reply-dispatcher.test.ts +437 -0
- package/src/reply-dispatcher.ts +259 -73
- package/src/reply-stream-controller.test.ts +235 -0
- package/src/reply-stream-controller.ts +147 -0
- package/src/resolve-allowlist.test.ts +105 -1
- package/src/resolve-allowlist.ts +112 -7
- package/src/runtime.ts +6 -3
- package/src/sdk-types.ts +43 -3
- package/src/sdk.test.ts +666 -0
- package/src/sdk.ts +867 -16
- package/src/secret-contract.ts +49 -0
- package/src/secret-input.ts +1 -1
- package/src/send-context.ts +76 -9
- package/src/send.test.ts +389 -5
- package/src/send.ts +140 -32
- package/src/sent-message-cache.ts +30 -18
- package/src/session-route.ts +40 -0
- package/src/setup-core.ts +160 -0
- package/src/setup-surface.test.ts +202 -0
- package/src/setup-surface.ts +320 -0
- package/src/sso-token-store.test.ts +72 -0
- package/src/sso-token-store.ts +166 -0
- package/src/sso.ts +300 -0
- package/src/storage.ts +1 -1
- package/src/store-fs.ts +2 -2
- package/src/streaming-message.test.ts +262 -0
- package/src/streaming-message.ts +297 -0
- package/src/test-runtime.ts +1 -1
- package/src/thread-parent-context.test.ts +224 -0
- package/src/thread-parent-context.ts +159 -0
- package/src/token.test.ts +237 -50
- package/src/token.ts +162 -7
- package/src/user-agent.test.ts +86 -0
- package/src/user-agent.ts +53 -0
- package/src/webhook-timeouts.ts +27 -0
- package/src/welcome-card.test.ts +81 -0
- package/src/welcome-card.ts +57 -0
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -107
- package/src/file-lock.ts +0 -1
- package/src/graph-users.test.ts +0 -66
- package/src/onboarding.ts +0 -381
- package/src/polls-store.test.ts +0 -38
- package/src/revoked-context.test.ts +0 -39
- package/src/token-response.test.ts +0 -23
|
@@ -1,10 +1,61 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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)
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
101
|
-
const name = params.fileName
|
|
102
|
-
const fileType = params.fileType
|
|
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
|
|
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]
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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?:
|
|
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?:
|
|
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?:
|
|
616
|
+
resolveFn?: MSTeamsAttachmentResolveFn;
|
|
450
617
|
}): Promise<Response> {
|
|
451
618
|
return await safeFetch({
|
|
452
619
|
url: params.url,
|
package/src/attachments/types.ts
CHANGED
|
@@ -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
|
+
};
|