@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.
- 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
package/src/attachments/graph.ts
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
|
-
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/
|
|
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
|
-
|
|
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
|
|
59
|
+
const conversationType = normalizeLowercaseStringOrEmpty(params.conversationType ?? "");
|
|
60
60
|
const messageIdCandidates = new Set<string>();
|
|
61
61
|
const pushCandidate = (value: string | null | undefined) => {
|
|
62
|
-
const trimmed =
|
|
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 =
|
|
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
|
|
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:
|
|
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?:
|
|
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
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
+
let msgData: {
|
|
277
336
|
body?: { content?: string; contentType?: string };
|
|
278
|
-
attachments?:
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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:
|
|
481
|
+
attachmentStatus: messageStatus,
|
|
391
482
|
messageUrl,
|
|
392
483
|
};
|
|
393
484
|
}
|
package/src/attachments/html.ts
CHANGED
|
@@ -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)` : ""}`;
|
|
@@ -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
|
+
});
|