@openclaw/msteams 2026.1.29

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 (61) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/index.ts +18 -0
  3. package/openclaw.plugin.json +11 -0
  4. package/package.json +36 -0
  5. package/src/attachments/download.ts +206 -0
  6. package/src/attachments/graph.ts +319 -0
  7. package/src/attachments/html.ts +76 -0
  8. package/src/attachments/payload.ts +22 -0
  9. package/src/attachments/shared.ts +235 -0
  10. package/src/attachments/types.ts +37 -0
  11. package/src/attachments.test.ts +424 -0
  12. package/src/attachments.ts +18 -0
  13. package/src/channel.directory.test.ts +46 -0
  14. package/src/channel.ts +436 -0
  15. package/src/conversation-store-fs.test.ts +89 -0
  16. package/src/conversation-store-fs.ts +155 -0
  17. package/src/conversation-store-memory.ts +45 -0
  18. package/src/conversation-store.ts +41 -0
  19. package/src/directory-live.ts +179 -0
  20. package/src/errors.test.ts +46 -0
  21. package/src/errors.ts +158 -0
  22. package/src/file-consent-helpers.test.ts +234 -0
  23. package/src/file-consent-helpers.ts +73 -0
  24. package/src/file-consent.ts +122 -0
  25. package/src/graph-chat.ts +52 -0
  26. package/src/graph-upload.ts +445 -0
  27. package/src/inbound.test.ts +67 -0
  28. package/src/inbound.ts +38 -0
  29. package/src/index.ts +4 -0
  30. package/src/media-helpers.test.ts +186 -0
  31. package/src/media-helpers.ts +77 -0
  32. package/src/messenger.test.ts +245 -0
  33. package/src/messenger.ts +460 -0
  34. package/src/monitor-handler/inbound-media.ts +123 -0
  35. package/src/monitor-handler/message-handler.ts +629 -0
  36. package/src/monitor-handler.ts +166 -0
  37. package/src/monitor-types.ts +5 -0
  38. package/src/monitor.ts +290 -0
  39. package/src/onboarding.ts +432 -0
  40. package/src/outbound.ts +47 -0
  41. package/src/pending-uploads.ts +87 -0
  42. package/src/policy.test.ts +210 -0
  43. package/src/policy.ts +247 -0
  44. package/src/polls-store-memory.ts +30 -0
  45. package/src/polls-store.test.ts +40 -0
  46. package/src/polls.test.ts +73 -0
  47. package/src/polls.ts +300 -0
  48. package/src/probe.test.ts +57 -0
  49. package/src/probe.ts +99 -0
  50. package/src/reply-dispatcher.ts +128 -0
  51. package/src/resolve-allowlist.ts +277 -0
  52. package/src/runtime.ts +14 -0
  53. package/src/sdk-types.ts +19 -0
  54. package/src/sdk.ts +33 -0
  55. package/src/send-context.ts +156 -0
  56. package/src/send.ts +489 -0
  57. package/src/sent-message-cache.test.ts +16 -0
  58. package/src/sent-message-cache.ts +41 -0
  59. package/src/storage.ts +22 -0
  60. package/src/store-fs.ts +80 -0
  61. package/src/token.ts +19 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,56 @@
1
+ # Changelog
2
+
3
+ ## 2026.1.29
4
+
5
+ ### Changes
6
+ - Version alignment with core OpenClaw release numbers.
7
+
8
+ ## 2026.1.23
9
+
10
+ ### Changes
11
+ - Version alignment with core OpenClaw release numbers.
12
+
13
+ ## 2026.1.22
14
+
15
+ ### Changes
16
+ - Version alignment with core OpenClaw release numbers.
17
+
18
+ ## 2026.1.21
19
+
20
+ ### Changes
21
+ - Version alignment with core OpenClaw release numbers.
22
+
23
+ ## 2026.1.20
24
+
25
+ ### Changes
26
+ - Version alignment with core OpenClaw release numbers.
27
+
28
+ ## 2026.1.17-1
29
+
30
+ ### Changes
31
+ - Version alignment with core OpenClaw release numbers.
32
+
33
+ ## 2026.1.17
34
+
35
+ ### Changes
36
+ - Version alignment with core OpenClaw release numbers.
37
+
38
+ ## 2026.1.16
39
+
40
+ ### Changes
41
+ - Version alignment with core OpenClaw release numbers.
42
+
43
+ ## 2026.1.15
44
+
45
+ ### Features
46
+ - Bot Framework gateway monitor (Express + JWT auth) with configurable webhook path/port and `/api/messages` fallback.
47
+ - Onboarding flow for Azure Bot credentials (config + env var detection) and DM policy setup.
48
+ - Channel capabilities: DMs, group chats, channels, threads, media, polls, and `teams` alias.
49
+ - DM pairing/allowlist enforcement plus group policies with per-team/channel overrides and mention gating.
50
+ - Inbound debounce + history context for room/group chats; mention tag stripping and timestamp parsing.
51
+ - Proactive messaging via stored conversation references (file store with TTL/size pruning).
52
+ - Outbound text/media send with markdown chunking, 4k limit, split/inline media handling.
53
+ - Adaptive Card polls: build cards, parse votes, and persist poll state with vote tracking.
54
+ - Attachment processing: placeholders + HTML summaries, inline image extraction (including data: URLs).
55
+ - Media downloads with host allowlist, auth scope fallback, and Graph hostedContents/attachments fallback.
56
+ - Retry/backoff on transient/throttled sends with classified errors + helpful hints.
package/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
+
4
+ import { msteamsPlugin } from "./src/channel.js";
5
+ import { setMSTeamsRuntime } from "./src/runtime.js";
6
+
7
+ const plugin = {
8
+ id: "msteams",
9
+ name: "Microsoft Teams",
10
+ description: "Microsoft Teams channel plugin (Bot Framework)",
11
+ configSchema: emptyPluginConfigSchema(),
12
+ register(api: OpenClawPluginApi) {
13
+ setMSTeamsRuntime(api.runtime);
14
+ api.registerChannel({ plugin: msteamsPlugin });
15
+ },
16
+ };
17
+
18
+ export default plugin;
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "msteams",
3
+ "channels": [
4
+ "msteams"
5
+ ],
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {}
10
+ }
11
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@openclaw/msteams",
3
+ "version": "2026.1.29",
4
+ "type": "module",
5
+ "description": "OpenClaw Microsoft Teams channel plugin",
6
+ "openclaw": {
7
+ "extensions": [
8
+ "./index.ts"
9
+ ],
10
+ "channel": {
11
+ "id": "msteams",
12
+ "label": "Microsoft Teams",
13
+ "selectionLabel": "Microsoft Teams (Bot Framework)",
14
+ "docsPath": "/channels/msteams",
15
+ "docsLabel": "msteams",
16
+ "blurb": "Bot Framework; enterprise support.",
17
+ "aliases": [
18
+ "teams"
19
+ ],
20
+ "order": 60
21
+ },
22
+ "install": {
23
+ "npmSpec": "@openclaw/msteams",
24
+ "localPath": "extensions/msteams",
25
+ "defaultChoice": "npm"
26
+ }
27
+ },
28
+ "dependencies": {
29
+ "@microsoft/agents-hosting": "^1.2.2",
30
+ "@microsoft/agents-hosting-express": "^1.2.2",
31
+ "@microsoft/agents-hosting-extensions-teams": "^1.2.2",
32
+ "openclaw": "workspace:*",
33
+ "express": "^5.2.1",
34
+ "proper-lockfile": "^4.1.2"
35
+ }
36
+ }
@@ -0,0 +1,206 @@
1
+ import { getMSTeamsRuntime } from "../runtime.js";
2
+ import {
3
+ extractInlineImageCandidates,
4
+ inferPlaceholder,
5
+ isDownloadableAttachment,
6
+ isRecord,
7
+ isUrlAllowed,
8
+ normalizeContentType,
9
+ resolveAllowedHosts,
10
+ } from "./shared.js";
11
+ import type {
12
+ MSTeamsAccessTokenProvider,
13
+ MSTeamsAttachmentLike,
14
+ MSTeamsInboundMedia,
15
+ } from "./types.js";
16
+
17
+ type DownloadCandidate = {
18
+ url: string;
19
+ fileHint?: string;
20
+ contentTypeHint?: string;
21
+ placeholder: string;
22
+ };
23
+
24
+ function resolveDownloadCandidate(att: MSTeamsAttachmentLike): DownloadCandidate | null {
25
+ const contentType = normalizeContentType(att.contentType);
26
+ const name = typeof att.name === "string" ? att.name.trim() : "";
27
+
28
+ if (contentType === "application/vnd.microsoft.teams.file.download.info") {
29
+ if (!isRecord(att.content)) return null;
30
+ const downloadUrl =
31
+ typeof att.content.downloadUrl === "string" ? att.content.downloadUrl.trim() : "";
32
+ if (!downloadUrl) return null;
33
+
34
+ const fileType = typeof att.content.fileType === "string" ? att.content.fileType.trim() : "";
35
+ const uniqueId = typeof att.content.uniqueId === "string" ? att.content.uniqueId.trim() : "";
36
+ const fileName = typeof att.content.fileName === "string" ? att.content.fileName.trim() : "";
37
+
38
+ const fileHint = name || fileName || (uniqueId && fileType ? `${uniqueId}.${fileType}` : "");
39
+ return {
40
+ url: downloadUrl,
41
+ fileHint: fileHint || undefined,
42
+ contentTypeHint: undefined,
43
+ placeholder: inferPlaceholder({
44
+ contentType,
45
+ fileName: fileHint,
46
+ fileType,
47
+ }),
48
+ };
49
+ }
50
+
51
+ const contentUrl = typeof att.contentUrl === "string" ? att.contentUrl.trim() : "";
52
+ if (!contentUrl) return null;
53
+
54
+ return {
55
+ url: contentUrl,
56
+ fileHint: name || undefined,
57
+ contentTypeHint: contentType,
58
+ placeholder: inferPlaceholder({ contentType, fileName: name }),
59
+ };
60
+ }
61
+
62
+ function scopeCandidatesForUrl(url: string): string[] {
63
+ try {
64
+ const host = new URL(url).hostname.toLowerCase();
65
+ const looksLikeGraph =
66
+ host.endsWith("graph.microsoft.com") ||
67
+ host.endsWith("sharepoint.com") ||
68
+ host.endsWith("1drv.ms") ||
69
+ host.includes("sharepoint");
70
+ return looksLikeGraph
71
+ ? ["https://graph.microsoft.com", "https://api.botframework.com"]
72
+ : ["https://api.botframework.com", "https://graph.microsoft.com"];
73
+ } catch {
74
+ return ["https://api.botframework.com", "https://graph.microsoft.com"];
75
+ }
76
+ }
77
+
78
+ async function fetchWithAuthFallback(params: {
79
+ url: string;
80
+ tokenProvider?: MSTeamsAccessTokenProvider;
81
+ fetchFn?: typeof fetch;
82
+ }): Promise<Response> {
83
+ const fetchFn = params.fetchFn ?? fetch;
84
+ const firstAttempt = await fetchFn(params.url);
85
+ if (firstAttempt.ok) return firstAttempt;
86
+ if (!params.tokenProvider) return firstAttempt;
87
+ if (firstAttempt.status !== 401 && firstAttempt.status !== 403) return firstAttempt;
88
+
89
+ const scopes = scopeCandidatesForUrl(params.url);
90
+ for (const scope of scopes) {
91
+ try {
92
+ const token = await params.tokenProvider.getAccessToken(scope);
93
+ const res = await fetchFn(params.url, {
94
+ headers: { Authorization: `Bearer ${token}` },
95
+ });
96
+ if (res.ok) return res;
97
+ } catch {
98
+ // Try the next scope.
99
+ }
100
+ }
101
+
102
+ return firstAttempt;
103
+ }
104
+
105
+ /**
106
+ * Download all file attachments from a Teams message (images, documents, etc.).
107
+ * Renamed from downloadMSTeamsImageAttachments to support all file types.
108
+ */
109
+ export async function downloadMSTeamsAttachments(params: {
110
+ attachments: MSTeamsAttachmentLike[] | undefined;
111
+ maxBytes: number;
112
+ tokenProvider?: MSTeamsAccessTokenProvider;
113
+ allowHosts?: string[];
114
+ fetchFn?: typeof fetch;
115
+ /** When true, embeds original filename in stored path for later extraction. */
116
+ preserveFilenames?: boolean;
117
+ }): Promise<MSTeamsInboundMedia[]> {
118
+ const list = Array.isArray(params.attachments) ? params.attachments : [];
119
+ if (list.length === 0) return [];
120
+ const allowHosts = resolveAllowedHosts(params.allowHosts);
121
+
122
+ // Download ANY downloadable attachment (not just images)
123
+ const downloadable = list.filter(isDownloadableAttachment);
124
+ const candidates: DownloadCandidate[] = downloadable
125
+ .map(resolveDownloadCandidate)
126
+ .filter(Boolean) as DownloadCandidate[];
127
+
128
+ const inlineCandidates = extractInlineImageCandidates(list);
129
+
130
+ const seenUrls = new Set<string>();
131
+ for (const inline of inlineCandidates) {
132
+ if (inline.kind === "url") {
133
+ if (!isUrlAllowed(inline.url, allowHosts)) continue;
134
+ if (seenUrls.has(inline.url)) continue;
135
+ seenUrls.add(inline.url);
136
+ candidates.push({
137
+ url: inline.url,
138
+ fileHint: inline.fileHint,
139
+ contentTypeHint: inline.contentType,
140
+ placeholder: inline.placeholder,
141
+ });
142
+ }
143
+ }
144
+ if (candidates.length === 0 && inlineCandidates.length === 0) return [];
145
+
146
+ const out: MSTeamsInboundMedia[] = [];
147
+ for (const inline of inlineCandidates) {
148
+ if (inline.kind !== "data") continue;
149
+ if (inline.data.byteLength > params.maxBytes) continue;
150
+ try {
151
+ // Data inline candidates (base64 data URLs) don't have original filenames
152
+ const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
153
+ inline.data,
154
+ inline.contentType,
155
+ "inbound",
156
+ params.maxBytes,
157
+ );
158
+ out.push({
159
+ path: saved.path,
160
+ contentType: saved.contentType,
161
+ placeholder: inline.placeholder,
162
+ });
163
+ } catch {
164
+ // Ignore decode failures and continue.
165
+ }
166
+ }
167
+ for (const candidate of candidates) {
168
+ if (!isUrlAllowed(candidate.url, allowHosts)) continue;
169
+ try {
170
+ const res = await fetchWithAuthFallback({
171
+ url: candidate.url,
172
+ tokenProvider: params.tokenProvider,
173
+ fetchFn: params.fetchFn,
174
+ });
175
+ if (!res.ok) continue;
176
+ const buffer = Buffer.from(await res.arrayBuffer());
177
+ if (buffer.byteLength > params.maxBytes) continue;
178
+ const mime = await getMSTeamsRuntime().media.detectMime({
179
+ buffer,
180
+ headerMime: res.headers.get("content-type"),
181
+ filePath: candidate.fileHint ?? candidate.url,
182
+ });
183
+ const originalFilename = params.preserveFilenames ? candidate.fileHint : undefined;
184
+ const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
185
+ buffer,
186
+ mime ?? candidate.contentTypeHint,
187
+ "inbound",
188
+ params.maxBytes,
189
+ originalFilename,
190
+ );
191
+ out.push({
192
+ path: saved.path,
193
+ contentType: saved.contentType,
194
+ placeholder: candidate.placeholder,
195
+ });
196
+ } catch {
197
+ // Ignore download failures and continue with next candidate.
198
+ }
199
+ }
200
+ return out;
201
+ }
202
+
203
+ /**
204
+ * @deprecated Use `downloadMSTeamsAttachments` instead (supports all file types).
205
+ */
206
+ export const downloadMSTeamsImageAttachments = downloadMSTeamsAttachments;
@@ -0,0 +1,319 @@
1
+ import { getMSTeamsRuntime } from "../runtime.js";
2
+ import { downloadMSTeamsAttachments } from "./download.js";
3
+ import { GRAPH_ROOT, inferPlaceholder, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
4
+ import type {
5
+ MSTeamsAccessTokenProvider,
6
+ MSTeamsAttachmentLike,
7
+ MSTeamsGraphMediaResult,
8
+ MSTeamsInboundMedia,
9
+ } from "./types.js";
10
+
11
+ type GraphHostedContent = {
12
+ id?: string | null;
13
+ contentType?: string | null;
14
+ contentBytes?: string | null;
15
+ };
16
+
17
+ type GraphAttachment = {
18
+ id?: string | null;
19
+ contentType?: string | null;
20
+ contentUrl?: string | null;
21
+ name?: string | null;
22
+ thumbnailUrl?: string | null;
23
+ content?: unknown;
24
+ };
25
+
26
+ function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
27
+ let current: unknown = value;
28
+ for (const key of keys) {
29
+ if (!isRecord(current)) return undefined;
30
+ current = current[key as keyof typeof current];
31
+ }
32
+ return typeof current === "string" && current.trim() ? current.trim() : undefined;
33
+ }
34
+
35
+ export function buildMSTeamsGraphMessageUrls(params: {
36
+ conversationType?: string | null;
37
+ conversationId?: string | null;
38
+ messageId?: string | null;
39
+ replyToId?: string | null;
40
+ conversationMessageId?: string | null;
41
+ channelData?: unknown;
42
+ }): string[] {
43
+ const conversationType = params.conversationType?.trim().toLowerCase() ?? "";
44
+ const messageIdCandidates = new Set<string>();
45
+ const pushCandidate = (value: string | null | undefined) => {
46
+ const trimmed = typeof value === "string" ? value.trim() : "";
47
+ if (trimmed) messageIdCandidates.add(trimmed);
48
+ };
49
+
50
+ pushCandidate(params.messageId);
51
+ pushCandidate(params.conversationMessageId);
52
+ pushCandidate(readNestedString(params.channelData, ["messageId"]));
53
+ pushCandidate(readNestedString(params.channelData, ["teamsMessageId"]));
54
+
55
+ const replyToId = typeof params.replyToId === "string" ? params.replyToId.trim() : "";
56
+
57
+ if (conversationType === "channel") {
58
+ const teamId =
59
+ readNestedString(params.channelData, ["team", "id"]) ??
60
+ readNestedString(params.channelData, ["teamId"]);
61
+ const channelId =
62
+ readNestedString(params.channelData, ["channel", "id"]) ??
63
+ readNestedString(params.channelData, ["channelId"]) ??
64
+ readNestedString(params.channelData, ["teamsChannelId"]);
65
+ if (!teamId || !channelId) return [];
66
+ const urls: string[] = [];
67
+ if (replyToId) {
68
+ for (const candidate of messageIdCandidates) {
69
+ if (candidate === replyToId) continue;
70
+ urls.push(
71
+ `${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(replyToId)}/replies/${encodeURIComponent(candidate)}`,
72
+ );
73
+ }
74
+ }
75
+ if (messageIdCandidates.size === 0 && replyToId) messageIdCandidates.add(replyToId);
76
+ for (const candidate of messageIdCandidates) {
77
+ urls.push(
78
+ `${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`,
79
+ );
80
+ }
81
+ return Array.from(new Set(urls));
82
+ }
83
+
84
+ const chatId = params.conversationId?.trim() || readNestedString(params.channelData, ["chatId"]);
85
+ if (!chatId) return [];
86
+ if (messageIdCandidates.size === 0 && replyToId) messageIdCandidates.add(replyToId);
87
+ const urls = Array.from(messageIdCandidates).map(
88
+ (candidate) =>
89
+ `${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`,
90
+ );
91
+ return Array.from(new Set(urls));
92
+ }
93
+
94
+ async function fetchGraphCollection<T>(params: {
95
+ url: string;
96
+ accessToken: string;
97
+ fetchFn?: typeof fetch;
98
+ }): Promise<{ status: number; items: T[] }> {
99
+ const fetchFn = params.fetchFn ?? fetch;
100
+ const res = await fetchFn(params.url, {
101
+ headers: { Authorization: `Bearer ${params.accessToken}` },
102
+ });
103
+ const status = res.status;
104
+ if (!res.ok) return { status, items: [] };
105
+ try {
106
+ const data = (await res.json()) as { value?: T[] };
107
+ return { status, items: Array.isArray(data.value) ? data.value : [] };
108
+ } catch {
109
+ return { status, items: [] };
110
+ }
111
+ }
112
+
113
+ function normalizeGraphAttachment(att: GraphAttachment): MSTeamsAttachmentLike {
114
+ let content: unknown = att.content;
115
+ if (typeof content === "string") {
116
+ try {
117
+ content = JSON.parse(content);
118
+ } catch {
119
+ // Keep as raw string if it's not JSON.
120
+ }
121
+ }
122
+ return {
123
+ contentType: normalizeContentType(att.contentType) ?? undefined,
124
+ contentUrl: att.contentUrl ?? undefined,
125
+ name: att.name ?? undefined,
126
+ thumbnailUrl: att.thumbnailUrl ?? undefined,
127
+ content,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Download all hosted content from a Teams message (images, documents, etc.).
133
+ * Renamed from downloadGraphHostedImages to support all file types.
134
+ */
135
+ async function downloadGraphHostedContent(params: {
136
+ accessToken: string;
137
+ messageUrl: string;
138
+ maxBytes: number;
139
+ fetchFn?: typeof fetch;
140
+ preserveFilenames?: boolean;
141
+ }): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
142
+ const hosted = await fetchGraphCollection<GraphHostedContent>({
143
+ url: `${params.messageUrl}/hostedContents`,
144
+ accessToken: params.accessToken,
145
+ fetchFn: params.fetchFn,
146
+ });
147
+ if (hosted.items.length === 0) {
148
+ return { media: [], status: hosted.status, count: 0 };
149
+ }
150
+
151
+ const out: MSTeamsInboundMedia[] = [];
152
+ for (const item of hosted.items) {
153
+ const contentBytes = typeof item.contentBytes === "string" ? item.contentBytes : "";
154
+ if (!contentBytes) continue;
155
+ let buffer: Buffer;
156
+ try {
157
+ buffer = Buffer.from(contentBytes, "base64");
158
+ } catch {
159
+ continue;
160
+ }
161
+ if (buffer.byteLength > params.maxBytes) continue;
162
+ const mime = await getMSTeamsRuntime().media.detectMime({
163
+ buffer,
164
+ headerMime: item.contentType ?? undefined,
165
+ });
166
+ // Download any file type, not just images
167
+ try {
168
+ const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
169
+ buffer,
170
+ mime ?? item.contentType ?? undefined,
171
+ "inbound",
172
+ params.maxBytes,
173
+ );
174
+ out.push({
175
+ path: saved.path,
176
+ contentType: saved.contentType,
177
+ placeholder: inferPlaceholder({ contentType: saved.contentType }),
178
+ });
179
+ } catch {
180
+ // Ignore save failures.
181
+ }
182
+ }
183
+
184
+ return { media: out, status: hosted.status, count: hosted.items.length };
185
+ }
186
+
187
+ export async function downloadMSTeamsGraphMedia(params: {
188
+ messageUrl?: string | null;
189
+ tokenProvider?: MSTeamsAccessTokenProvider;
190
+ maxBytes: number;
191
+ allowHosts?: string[];
192
+ fetchFn?: typeof fetch;
193
+ /** When true, embeds original filename in stored path for later extraction. */
194
+ preserveFilenames?: boolean;
195
+ }): Promise<MSTeamsGraphMediaResult> {
196
+ if (!params.messageUrl || !params.tokenProvider) return { media: [] };
197
+ const allowHosts = resolveAllowedHosts(params.allowHosts);
198
+ const messageUrl = params.messageUrl;
199
+ let accessToken: string;
200
+ try {
201
+ accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com");
202
+ } catch {
203
+ return { media: [], messageUrl, tokenError: true };
204
+ }
205
+
206
+ // Fetch the full message to get SharePoint file attachments (for group chats)
207
+ const fetchFn = params.fetchFn ?? fetch;
208
+ const sharePointMedia: MSTeamsInboundMedia[] = [];
209
+ const downloadedReferenceUrls = new Set<string>();
210
+ try {
211
+ const msgRes = await fetchFn(messageUrl, {
212
+ headers: { Authorization: `Bearer ${accessToken}` },
213
+ });
214
+ if (msgRes.ok) {
215
+ const msgData = (await msgRes.json()) as {
216
+ body?: { content?: string; contentType?: string };
217
+ attachments?: Array<{
218
+ id?: string;
219
+ contentUrl?: string;
220
+ contentType?: string;
221
+ name?: string;
222
+ }>;
223
+ };
224
+
225
+ // Extract SharePoint file attachments (contentType: "reference")
226
+ // Download any file type, not just images
227
+ const spAttachments = (msgData.attachments ?? []).filter(
228
+ (a) => a.contentType === "reference" && a.contentUrl && a.name,
229
+ );
230
+ for (const att of spAttachments) {
231
+ const name = att.name ?? "file";
232
+
233
+ try {
234
+ // SharePoint URLs need to be accessed via Graph shares API
235
+ const shareUrl = att.contentUrl!;
236
+ const encodedUrl = Buffer.from(shareUrl).toString("base64url");
237
+ const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
238
+
239
+ const spRes = await fetchFn(sharesUrl, {
240
+ headers: { Authorization: `Bearer ${accessToken}` },
241
+ redirect: "follow",
242
+ });
243
+
244
+ if (spRes.ok) {
245
+ const buffer = Buffer.from(await spRes.arrayBuffer());
246
+ if (buffer.byteLength <= params.maxBytes) {
247
+ const mime = await getMSTeamsRuntime().media.detectMime({
248
+ buffer,
249
+ headerMime: spRes.headers.get("content-type") ?? undefined,
250
+ filePath: name,
251
+ });
252
+ const originalFilename = params.preserveFilenames ? name : undefined;
253
+ const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
254
+ buffer,
255
+ mime ?? "application/octet-stream",
256
+ "inbound",
257
+ params.maxBytes,
258
+ originalFilename,
259
+ );
260
+ sharePointMedia.push({
261
+ path: saved.path,
262
+ contentType: saved.contentType,
263
+ placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: name }),
264
+ });
265
+ downloadedReferenceUrls.add(shareUrl);
266
+ }
267
+ }
268
+ } catch {
269
+ // Ignore SharePoint download failures.
270
+ }
271
+ }
272
+ }
273
+ } catch {
274
+ // Ignore message fetch failures.
275
+ }
276
+
277
+ const hosted = await downloadGraphHostedContent({
278
+ accessToken,
279
+ messageUrl,
280
+ maxBytes: params.maxBytes,
281
+ fetchFn: params.fetchFn,
282
+ preserveFilenames: params.preserveFilenames,
283
+ });
284
+
285
+ const attachments = await fetchGraphCollection<GraphAttachment>({
286
+ url: `${messageUrl}/attachments`,
287
+ accessToken,
288
+ fetchFn: params.fetchFn,
289
+ });
290
+
291
+ const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);
292
+ const filteredAttachments =
293
+ sharePointMedia.length > 0
294
+ ? normalizedAttachments.filter((att) => {
295
+ const contentType = att.contentType?.toLowerCase();
296
+ if (contentType !== "reference") return true;
297
+ const url = typeof att.contentUrl === "string" ? att.contentUrl : "";
298
+ if (!url) return true;
299
+ return !downloadedReferenceUrls.has(url);
300
+ })
301
+ : normalizedAttachments;
302
+ const attachmentMedia = await downloadMSTeamsAttachments({
303
+ attachments: filteredAttachments,
304
+ maxBytes: params.maxBytes,
305
+ tokenProvider: params.tokenProvider,
306
+ allowHosts,
307
+ fetchFn: params.fetchFn,
308
+ preserveFilenames: params.preserveFilenames,
309
+ });
310
+
311
+ return {
312
+ media: [...sharePointMedia, ...hosted.media, ...attachmentMedia],
313
+ hostedCount: hosted.count,
314
+ attachmentCount: filteredAttachments.length + sharePointMedia.length,
315
+ hostedStatus: hosted.status,
316
+ attachmentStatus: attachments.status,
317
+ messageUrl,
318
+ };
319
+ }