@openclaw/msteams 2026.1.29 → 2026.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/index.ts +0 -1
  3. package/openclaw.plugin.json +1 -3
  4. package/package.json +13 -10
  5. package/src/attachments/download.ts +98 -21
  6. package/src/attachments/graph.ts +50 -16
  7. package/src/attachments/html.ts +23 -9
  8. package/src/attachments/shared.ts +74 -18
  9. package/src/attachments.test.ts +37 -2
  10. package/src/channel.directory.test.ts +7 -5
  11. package/src/channel.ts +46 -23
  12. package/src/conversation-store-fs.test.ts +7 -8
  13. package/src/conversation-store-fs.ts +15 -5
  14. package/src/conversation-store-memory.ts +3 -1
  15. package/src/directory-live.ts +41 -15
  16. package/src/errors.test.ts +0 -1
  17. package/src/errors.ts +48 -16
  18. package/src/file-consent-helpers.test.ts +12 -3
  19. package/src/file-consent.ts +6 -2
  20. package/src/graph-chat.ts +5 -4
  21. package/src/graph-upload.ts +23 -15
  22. package/src/inbound.test.ts +0 -1
  23. package/src/inbound.ts +15 -5
  24. package/src/media-helpers.test.ts +9 -6
  25. package/src/media-helpers.ts +15 -6
  26. package/src/messenger.test.ts +7 -4
  27. package/src/messenger.ts +55 -20
  28. package/src/monitor-handler/inbound-media.ts +7 -2
  29. package/src/monitor-handler/message-handler.ts +66 -55
  30. package/src/monitor-handler.ts +3 -7
  31. package/src/monitor.ts +19 -14
  32. package/src/onboarding.ts +10 -11
  33. package/src/outbound.ts +0 -1
  34. package/src/pending-uploads.ts +7 -5
  35. package/src/policy.test.ts +1 -2
  36. package/src/policy.ts +39 -13
  37. package/src/polls-store-memory.ts +3 -1
  38. package/src/polls-store.test.ts +1 -3
  39. package/src/polls.test.ts +5 -6
  40. package/src/polls.ts +24 -9
  41. package/src/probe.test.ts +4 -3
  42. package/src/probe.ts +18 -10
  43. package/src/reply-dispatcher.ts +5 -3
  44. package/src/resolve-allowlist.ts +39 -19
  45. package/src/send-context.ts +12 -4
  46. package/src/send.ts +49 -19
  47. package/src/sent-message-cache.test.ts +0 -1
  48. package/src/sent-message-cache.ts +9 -3
  49. package/src/storage.ts +6 -3
  50. package/src/store-fs.ts +6 -3
package/CHANGELOG.md CHANGED
@@ -1,48 +1,75 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.1
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.1.31
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
15
+ ## 2026.1.30
16
+
17
+ ### Changes
18
+
19
+ - Version alignment with core OpenClaw release numbers.
20
+
3
21
  ## 2026.1.29
4
22
 
5
23
  ### Changes
24
+
6
25
  - Version alignment with core OpenClaw release numbers.
7
26
 
8
27
  ## 2026.1.23
9
28
 
10
29
  ### Changes
30
+
11
31
  - Version alignment with core OpenClaw release numbers.
12
32
 
13
33
  ## 2026.1.22
14
34
 
15
35
  ### Changes
36
+
16
37
  - Version alignment with core OpenClaw release numbers.
17
38
 
18
39
  ## 2026.1.21
19
40
 
20
41
  ### Changes
42
+
21
43
  - Version alignment with core OpenClaw release numbers.
22
44
 
23
45
  ## 2026.1.20
24
46
 
25
47
  ### Changes
48
+
26
49
  - Version alignment with core OpenClaw release numbers.
27
50
 
28
51
  ## 2026.1.17-1
29
52
 
30
53
  ### Changes
54
+
31
55
  - Version alignment with core OpenClaw release numbers.
32
56
 
33
57
  ## 2026.1.17
34
58
 
35
59
  ### Changes
60
+
36
61
  - Version alignment with core OpenClaw release numbers.
37
62
 
38
63
  ## 2026.1.16
39
64
 
40
65
  ### Changes
66
+
41
67
  - Version alignment with core OpenClaw release numbers.
42
68
 
43
69
  ## 2026.1.15
44
70
 
45
71
  ### Features
72
+
46
73
  - Bot Framework gateway monitor (Express + JWT auth) with configurable webhook path/port and `/api/messages` fallback.
47
74
  - Onboarding flow for Azure Bot credentials (config + env var detection) and DM policy setup.
48
75
  - Channel capabilities: DMs, group chats, channels, threads, media, polls, and `teams` alias.
package/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
-
4
3
  import { msteamsPlugin } from "./src/channel.js";
5
4
  import { setMSTeamsRuntime } from "./src/runtime.js";
6
5
 
@@ -1,8 +1,6 @@
1
1
  {
2
2
  "id": "msteams",
3
- "channels": [
4
- "msteams"
5
- ],
3
+ "channels": ["msteams"],
6
4
  "configSchema": {
7
5
  "type": "object",
8
6
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,8 +1,19 @@
1
1
  {
2
2
  "name": "@openclaw/msteams",
3
- "version": "2026.1.29",
4
- "type": "module",
3
+ "version": "2026.2.1",
5
4
  "description": "OpenClaw Microsoft Teams channel plugin",
5
+ "type": "module",
6
+ "dependencies": {
7
+ "@microsoft/agents-hosting": "^1.2.3",
8
+ "@microsoft/agents-hosting-express": "^1.2.3",
9
+ "@microsoft/agents-hosting-extensions-teams": "^1.2.3",
10
+ "express": "^5.2.1",
11
+ "openclaw": "workspace:*",
12
+ "proper-lockfile": "^4.1.2"
13
+ },
14
+ "devDependencies": {
15
+ "openclaw": "workspace:*"
16
+ },
6
17
  "openclaw": {
7
18
  "extensions": [
8
19
  "./index.ts"
@@ -24,13 +35,5 @@
24
35
  "localPath": "extensions/msteams",
25
36
  "defaultChoice": "npm"
26
37
  }
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
38
  }
36
39
  }
@@ -1,3 +1,8 @@
1
+ import type {
2
+ MSTeamsAccessTokenProvider,
3
+ MSTeamsAttachmentLike,
4
+ MSTeamsInboundMedia,
5
+ } from "./types.js";
1
6
  import { getMSTeamsRuntime } from "../runtime.js";
2
7
  import {
3
8
  extractInlineImageCandidates,
@@ -6,13 +11,9 @@ import {
6
11
  isRecord,
7
12
  isUrlAllowed,
8
13
  normalizeContentType,
14
+ resolveAuthAllowedHosts,
9
15
  resolveAllowedHosts,
10
16
  } from "./shared.js";
11
- import type {
12
- MSTeamsAccessTokenProvider,
13
- MSTeamsAttachmentLike,
14
- MSTeamsInboundMedia,
15
- } from "./types.js";
16
17
 
17
18
  type DownloadCandidate = {
18
19
  url: string;
@@ -26,10 +27,14 @@ function resolveDownloadCandidate(att: MSTeamsAttachmentLike): DownloadCandidate
26
27
  const name = typeof att.name === "string" ? att.name.trim() : "";
27
28
 
28
29
  if (contentType === "application/vnd.microsoft.teams.file.download.info") {
29
- if (!isRecord(att.content)) return null;
30
+ if (!isRecord(att.content)) {
31
+ return null;
32
+ }
30
33
  const downloadUrl =
31
34
  typeof att.content.downloadUrl === "string" ? att.content.downloadUrl.trim() : "";
32
- if (!downloadUrl) return null;
35
+ if (!downloadUrl) {
36
+ return null;
37
+ }
33
38
 
34
39
  const fileType = typeof att.content.fileType === "string" ? att.content.fileType.trim() : "";
35
40
  const uniqueId = typeof att.content.uniqueId === "string" ? att.content.uniqueId.trim() : "";
@@ -49,7 +54,9 @@ function resolveDownloadCandidate(att: MSTeamsAttachmentLike): DownloadCandidate
49
54
  }
50
55
 
51
56
  const contentUrl = typeof att.contentUrl === "string" ? att.contentUrl.trim() : "";
52
- if (!contentUrl) return null;
57
+ if (!contentUrl) {
58
+ return null;
59
+ }
53
60
 
54
61
  return {
55
62
  url: contentUrl,
@@ -79,12 +86,23 @@ async function fetchWithAuthFallback(params: {
79
86
  url: string;
80
87
  tokenProvider?: MSTeamsAccessTokenProvider;
81
88
  fetchFn?: typeof fetch;
89
+ allowHosts: string[];
90
+ authAllowHosts: string[];
82
91
  }): Promise<Response> {
83
92
  const fetchFn = params.fetchFn ?? fetch;
84
93
  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;
94
+ if (firstAttempt.ok) {
95
+ return firstAttempt;
96
+ }
97
+ if (!params.tokenProvider) {
98
+ return firstAttempt;
99
+ }
100
+ if (firstAttempt.status !== 401 && firstAttempt.status !== 403) {
101
+ return firstAttempt;
102
+ }
103
+ if (!isUrlAllowed(params.url, params.authAllowHosts)) {
104
+ return firstAttempt;
105
+ }
88
106
 
89
107
  const scopes = scopeCandidatesForUrl(params.url);
90
108
  for (const scope of scopes) {
@@ -92,8 +110,30 @@ async function fetchWithAuthFallback(params: {
92
110
  const token = await params.tokenProvider.getAccessToken(scope);
93
111
  const res = await fetchFn(params.url, {
94
112
  headers: { Authorization: `Bearer ${token}` },
113
+ redirect: "manual",
95
114
  });
96
- if (res.ok) return res;
115
+ if (res.ok) {
116
+ return res;
117
+ }
118
+ const redirectUrl = readRedirectUrl(params.url, res);
119
+ if (redirectUrl && isUrlAllowed(redirectUrl, params.allowHosts)) {
120
+ const redirectRes = await fetchFn(redirectUrl);
121
+ if (redirectRes.ok) {
122
+ return redirectRes;
123
+ }
124
+ if (
125
+ (redirectRes.status === 401 || redirectRes.status === 403) &&
126
+ isUrlAllowed(redirectUrl, params.authAllowHosts)
127
+ ) {
128
+ const redirectAuthRes = await fetchFn(redirectUrl, {
129
+ headers: { Authorization: `Bearer ${token}` },
130
+ redirect: "manual",
131
+ });
132
+ if (redirectAuthRes.ok) {
133
+ return redirectAuthRes;
134
+ }
135
+ }
136
+ }
97
137
  } catch {
98
138
  // Try the next scope.
99
139
  }
@@ -102,6 +142,21 @@ async function fetchWithAuthFallback(params: {
102
142
  return firstAttempt;
103
143
  }
104
144
 
145
+ function readRedirectUrl(baseUrl: string, res: Response): string | null {
146
+ if (![301, 302, 303, 307, 308].includes(res.status)) {
147
+ return null;
148
+ }
149
+ const location = res.headers.get("location");
150
+ if (!location) {
151
+ return null;
152
+ }
153
+ try {
154
+ return new URL(location, baseUrl).toString();
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
105
160
  /**
106
161
  * Download all file attachments from a Teams message (images, documents, etc.).
107
162
  * Renamed from downloadMSTeamsImageAttachments to support all file types.
@@ -111,13 +166,17 @@ export async function downloadMSTeamsAttachments(params: {
111
166
  maxBytes: number;
112
167
  tokenProvider?: MSTeamsAccessTokenProvider;
113
168
  allowHosts?: string[];
169
+ authAllowHosts?: string[];
114
170
  fetchFn?: typeof fetch;
115
171
  /** When true, embeds original filename in stored path for later extraction. */
116
172
  preserveFilenames?: boolean;
117
173
  }): Promise<MSTeamsInboundMedia[]> {
118
174
  const list = Array.isArray(params.attachments) ? params.attachments : [];
119
- if (list.length === 0) return [];
175
+ if (list.length === 0) {
176
+ return [];
177
+ }
120
178
  const allowHosts = resolveAllowedHosts(params.allowHosts);
179
+ const authAllowHosts = resolveAuthAllowedHosts(params.authAllowHosts);
121
180
 
122
181
  // Download ANY downloadable attachment (not just images)
123
182
  const downloadable = list.filter(isDownloadableAttachment);
@@ -130,8 +189,12 @@ export async function downloadMSTeamsAttachments(params: {
130
189
  const seenUrls = new Set<string>();
131
190
  for (const inline of inlineCandidates) {
132
191
  if (inline.kind === "url") {
133
- if (!isUrlAllowed(inline.url, allowHosts)) continue;
134
- if (seenUrls.has(inline.url)) continue;
192
+ if (!isUrlAllowed(inline.url, allowHosts)) {
193
+ continue;
194
+ }
195
+ if (seenUrls.has(inline.url)) {
196
+ continue;
197
+ }
135
198
  seenUrls.add(inline.url);
136
199
  candidates.push({
137
200
  url: inline.url,
@@ -141,12 +204,18 @@ export async function downloadMSTeamsAttachments(params: {
141
204
  });
142
205
  }
143
206
  }
144
- if (candidates.length === 0 && inlineCandidates.length === 0) return [];
207
+ if (candidates.length === 0 && inlineCandidates.length === 0) {
208
+ return [];
209
+ }
145
210
 
146
211
  const out: MSTeamsInboundMedia[] = [];
147
212
  for (const inline of inlineCandidates) {
148
- if (inline.kind !== "data") continue;
149
- if (inline.data.byteLength > params.maxBytes) continue;
213
+ if (inline.kind !== "data") {
214
+ continue;
215
+ }
216
+ if (inline.data.byteLength > params.maxBytes) {
217
+ continue;
218
+ }
150
219
  try {
151
220
  // Data inline candidates (base64 data URLs) don't have original filenames
152
221
  const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
@@ -165,16 +234,24 @@ export async function downloadMSTeamsAttachments(params: {
165
234
  }
166
235
  }
167
236
  for (const candidate of candidates) {
168
- if (!isUrlAllowed(candidate.url, allowHosts)) continue;
237
+ if (!isUrlAllowed(candidate.url, allowHosts)) {
238
+ continue;
239
+ }
169
240
  try {
170
241
  const res = await fetchWithAuthFallback({
171
242
  url: candidate.url,
172
243
  tokenProvider: params.tokenProvider,
173
244
  fetchFn: params.fetchFn,
245
+ allowHosts,
246
+ authAllowHosts,
174
247
  });
175
- if (!res.ok) continue;
248
+ if (!res.ok) {
249
+ continue;
250
+ }
176
251
  const buffer = Buffer.from(await res.arrayBuffer());
177
- if (buffer.byteLength > params.maxBytes) continue;
252
+ if (buffer.byteLength > params.maxBytes) {
253
+ continue;
254
+ }
178
255
  const mime = await getMSTeamsRuntime().media.detectMime({
179
256
  buffer,
180
257
  headerMime: res.headers.get("content-type"),
@@ -1,12 +1,18 @@
1
- import { getMSTeamsRuntime } from "../runtime.js";
2
- import { downloadMSTeamsAttachments } from "./download.js";
3
- import { GRAPH_ROOT, inferPlaceholder, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
4
1
  import type {
5
2
  MSTeamsAccessTokenProvider,
6
3
  MSTeamsAttachmentLike,
7
4
  MSTeamsGraphMediaResult,
8
5
  MSTeamsInboundMedia,
9
6
  } from "./types.js";
7
+ import { getMSTeamsRuntime } from "../runtime.js";
8
+ import { downloadMSTeamsAttachments } from "./download.js";
9
+ import {
10
+ GRAPH_ROOT,
11
+ inferPlaceholder,
12
+ isRecord,
13
+ normalizeContentType,
14
+ resolveAllowedHosts,
15
+ } from "./shared.js";
10
16
 
11
17
  type GraphHostedContent = {
12
18
  id?: string | null;
@@ -26,7 +32,9 @@ type GraphAttachment = {
26
32
  function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
27
33
  let current: unknown = value;
28
34
  for (const key of keys) {
29
- if (!isRecord(current)) return undefined;
35
+ if (!isRecord(current)) {
36
+ return undefined;
37
+ }
30
38
  current = current[key as keyof typeof current];
31
39
  }
32
40
  return typeof current === "string" && current.trim() ? current.trim() : undefined;
@@ -44,7 +52,9 @@ export function buildMSTeamsGraphMessageUrls(params: {
44
52
  const messageIdCandidates = new Set<string>();
45
53
  const pushCandidate = (value: string | null | undefined) => {
46
54
  const trimmed = typeof value === "string" ? value.trim() : "";
47
- if (trimmed) messageIdCandidates.add(trimmed);
55
+ if (trimmed) {
56
+ messageIdCandidates.add(trimmed);
57
+ }
48
58
  };
49
59
 
50
60
  pushCandidate(params.messageId);
@@ -62,17 +72,23 @@ export function buildMSTeamsGraphMessageUrls(params: {
62
72
  readNestedString(params.channelData, ["channel", "id"]) ??
63
73
  readNestedString(params.channelData, ["channelId"]) ??
64
74
  readNestedString(params.channelData, ["teamsChannelId"]);
65
- if (!teamId || !channelId) return [];
75
+ if (!teamId || !channelId) {
76
+ return [];
77
+ }
66
78
  const urls: string[] = [];
67
79
  if (replyToId) {
68
80
  for (const candidate of messageIdCandidates) {
69
- if (candidate === replyToId) continue;
81
+ if (candidate === replyToId) {
82
+ continue;
83
+ }
70
84
  urls.push(
71
85
  `${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(replyToId)}/replies/${encodeURIComponent(candidate)}`,
72
86
  );
73
87
  }
74
88
  }
75
- if (messageIdCandidates.size === 0 && replyToId) messageIdCandidates.add(replyToId);
89
+ if (messageIdCandidates.size === 0 && replyToId) {
90
+ messageIdCandidates.add(replyToId);
91
+ }
76
92
  for (const candidate of messageIdCandidates) {
77
93
  urls.push(
78
94
  `${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`,
@@ -82,8 +98,12 @@ export function buildMSTeamsGraphMessageUrls(params: {
82
98
  }
83
99
 
84
100
  const chatId = params.conversationId?.trim() || readNestedString(params.channelData, ["chatId"]);
85
- if (!chatId) return [];
86
- if (messageIdCandidates.size === 0 && replyToId) messageIdCandidates.add(replyToId);
101
+ if (!chatId) {
102
+ return [];
103
+ }
104
+ if (messageIdCandidates.size === 0 && replyToId) {
105
+ messageIdCandidates.add(replyToId);
106
+ }
87
107
  const urls = Array.from(messageIdCandidates).map(
88
108
  (candidate) =>
89
109
  `${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`,
@@ -101,7 +121,9 @@ async function fetchGraphCollection<T>(params: {
101
121
  headers: { Authorization: `Bearer ${params.accessToken}` },
102
122
  });
103
123
  const status = res.status;
104
- if (!res.ok) return { status, items: [] };
124
+ if (!res.ok) {
125
+ return { status, items: [] };
126
+ }
105
127
  try {
106
128
  const data = (await res.json()) as { value?: T[] };
107
129
  return { status, items: Array.isArray(data.value) ? data.value : [] };
@@ -151,14 +173,18 @@ async function downloadGraphHostedContent(params: {
151
173
  const out: MSTeamsInboundMedia[] = [];
152
174
  for (const item of hosted.items) {
153
175
  const contentBytes = typeof item.contentBytes === "string" ? item.contentBytes : "";
154
- if (!contentBytes) continue;
176
+ if (!contentBytes) {
177
+ continue;
178
+ }
155
179
  let buffer: Buffer;
156
180
  try {
157
181
  buffer = Buffer.from(contentBytes, "base64");
158
182
  } catch {
159
183
  continue;
160
184
  }
161
- if (buffer.byteLength > params.maxBytes) continue;
185
+ if (buffer.byteLength > params.maxBytes) {
186
+ continue;
187
+ }
162
188
  const mime = await getMSTeamsRuntime().media.detectMime({
163
189
  buffer,
164
190
  headerMime: item.contentType ?? undefined,
@@ -189,11 +215,14 @@ export async function downloadMSTeamsGraphMedia(params: {
189
215
  tokenProvider?: MSTeamsAccessTokenProvider;
190
216
  maxBytes: number;
191
217
  allowHosts?: string[];
218
+ authAllowHosts?: string[];
192
219
  fetchFn?: typeof fetch;
193
220
  /** When true, embeds original filename in stored path for later extraction. */
194
221
  preserveFilenames?: boolean;
195
222
  }): Promise<MSTeamsGraphMediaResult> {
196
- if (!params.messageUrl || !params.tokenProvider) return { media: [] };
223
+ if (!params.messageUrl || !params.tokenProvider) {
224
+ return { media: [] };
225
+ }
197
226
  const allowHosts = resolveAllowedHosts(params.allowHosts);
198
227
  const messageUrl = params.messageUrl;
199
228
  let accessToken: string;
@@ -293,9 +322,13 @@ export async function downloadMSTeamsGraphMedia(params: {
293
322
  sharePointMedia.length > 0
294
323
  ? normalizedAttachments.filter((att) => {
295
324
  const contentType = att.contentType?.toLowerCase();
296
- if (contentType !== "reference") return true;
325
+ if (contentType !== "reference") {
326
+ return true;
327
+ }
297
328
  const url = typeof att.contentUrl === "string" ? att.contentUrl : "";
298
- if (!url) return true;
329
+ if (!url) {
330
+ return true;
331
+ }
299
332
  return !downloadedReferenceUrls.has(url);
300
333
  })
301
334
  : normalizedAttachments;
@@ -304,6 +337,7 @@ export async function downloadMSTeamsGraphMedia(params: {
304
337
  maxBytes: params.maxBytes,
305
338
  tokenProvider: params.tokenProvider,
306
339
  allowHosts,
340
+ authAllowHosts: params.authAllowHosts,
307
341
  fetchFn: params.fetchFn,
308
342
  preserveFilenames: params.preserveFilenames,
309
343
  });
@@ -1,3 +1,4 @@
1
+ import type { MSTeamsAttachmentLike, MSTeamsHtmlAttachmentSummary } from "./types.js";
1
2
  import {
2
3
  ATTACHMENT_TAG_RE,
3
4
  extractHtmlFromAttachment,
@@ -6,13 +7,14 @@ import {
6
7
  isLikelyImageAttachment,
7
8
  safeHostForUrl,
8
9
  } from "./shared.js";
9
- import type { MSTeamsAttachmentLike, MSTeamsHtmlAttachmentSummary } from "./types.js";
10
10
 
11
11
  export function summarizeMSTeamsHtmlAttachments(
12
12
  attachments: MSTeamsAttachmentLike[] | undefined,
13
13
  ): MSTeamsHtmlAttachmentSummary | undefined {
14
14
  const list = Array.isArray(attachments) ? attachments : [];
15
- if (list.length === 0) return undefined;
15
+ if (list.length === 0) {
16
+ return undefined;
17
+ }
16
18
  let htmlAttachments = 0;
17
19
  let imgTags = 0;
18
20
  let dataImages = 0;
@@ -23,7 +25,9 @@ export function summarizeMSTeamsHtmlAttachments(
23
25
 
24
26
  for (const att of list) {
25
27
  const html = extractHtmlFromAttachment(att);
26
- if (!html) continue;
28
+ if (!html) {
29
+ continue;
30
+ }
27
31
  htmlAttachments += 1;
28
32
  IMG_SRC_RE.lastIndex = 0;
29
33
  let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
@@ -31,9 +35,13 @@ export function summarizeMSTeamsHtmlAttachments(
31
35
  imgTags += 1;
32
36
  const src = match[1]?.trim();
33
37
  if (src) {
34
- if (src.startsWith("data:")) dataImages += 1;
35
- else if (src.startsWith("cid:")) cidImages += 1;
36
- else srcHosts.add(safeHostForUrl(src));
38
+ if (src.startsWith("data:")) {
39
+ dataImages += 1;
40
+ } else if (src.startsWith("cid:")) {
41
+ cidImages += 1;
42
+ } else {
43
+ srcHosts.add(safeHostForUrl(src));
44
+ }
37
45
  }
38
46
  match = IMG_SRC_RE.exec(html);
39
47
  }
@@ -43,12 +51,16 @@ export function summarizeMSTeamsHtmlAttachments(
43
51
  while (attachmentMatch) {
44
52
  attachmentTags += 1;
45
53
  const id = attachmentMatch[1]?.trim();
46
- if (id) attachmentIds.add(id);
54
+ if (id) {
55
+ attachmentIds.add(id);
56
+ }
47
57
  attachmentMatch = ATTACHMENT_TAG_RE.exec(html);
48
58
  }
49
59
  }
50
60
 
51
- if (htmlAttachments === 0) return undefined;
61
+ if (htmlAttachments === 0) {
62
+ return undefined;
63
+ }
52
64
  return {
53
65
  htmlAttachments,
54
66
  imgTags,
@@ -64,7 +76,9 @@ export function buildMSTeamsAttachmentPlaceholder(
64
76
  attachments: MSTeamsAttachmentLike[] | undefined,
65
77
  ): string {
66
78
  const list = Array.isArray(attachments) ? attachments : [];
67
- if (list.length === 0) return "";
79
+ if (list.length === 0) {
80
+ return "";
81
+ }
68
82
  const imageCount = list.filter(isLikelyImageAttachment).length;
69
83
  const inlineCount = extractInlineImageCandidates(list).length;
70
84
  const totalImages = imageCount + inlineCount;