@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
@@ -48,6 +48,15 @@ export const DEFAULT_MEDIA_HOST_ALLOWLIST = [
48
48
  "microsoft.com",
49
49
  ] as const;
50
50
 
51
+ export const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [
52
+ "api.botframework.com",
53
+ "botframework.com",
54
+ "graph.microsoft.com",
55
+ "graph.microsoft.us",
56
+ "graph.microsoft.de",
57
+ "graph.microsoft.cn",
58
+ ] as const;
59
+
51
60
  export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
52
61
 
53
62
  export function isRecord(value: unknown): value is Record<string, unknown> {
@@ -55,7 +64,9 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
55
64
  }
56
65
 
57
66
  export function normalizeContentType(value: unknown): string | undefined {
58
- if (typeof value !== "string") return undefined;
67
+ if (typeof value !== "string") {
68
+ return undefined;
69
+ }
59
70
  const trimmed = value.trim();
60
71
  return trimmed ? trimmed : undefined;
61
72
  }
@@ -78,17 +89,25 @@ export function inferPlaceholder(params: {
78
89
  export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
79
90
  const contentType = normalizeContentType(att.contentType) ?? "";
80
91
  const name = typeof att.name === "string" ? att.name : "";
81
- if (contentType.startsWith("image/")) return true;
82
- if (IMAGE_EXT_RE.test(name)) return true;
92
+ if (contentType.startsWith("image/")) {
93
+ return true;
94
+ }
95
+ if (IMAGE_EXT_RE.test(name)) {
96
+ return true;
97
+ }
83
98
 
84
99
  if (
85
100
  contentType === "application/vnd.microsoft.teams.file.download.info" &&
86
101
  isRecord(att.content)
87
102
  ) {
88
103
  const fileType = typeof att.content.fileType === "string" ? att.content.fileType : "";
89
- if (fileType && IMAGE_EXT_RE.test(`x.${fileType}`)) return true;
104
+ if (fileType && IMAGE_EXT_RE.test(`x.${fileType}`)) {
105
+ return true;
106
+ }
90
107
  const fileName = typeof att.content.fileName === "string" ? att.content.fileName : "";
91
- if (fileName && IMAGE_EXT_RE.test(fileName)) return true;
108
+ if (fileName && IMAGE_EXT_RE.test(fileName)) {
109
+ return true;
110
+ }
92
111
  }
93
112
 
94
113
  return false;
@@ -124,9 +143,15 @@ function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
124
143
  }
125
144
 
126
145
  export function extractHtmlFromAttachment(att: MSTeamsAttachmentLike): string | undefined {
127
- if (!isHtmlAttachment(att)) return undefined;
128
- if (typeof att.content === "string") return att.content;
129
- if (!isRecord(att.content)) return undefined;
146
+ if (!isHtmlAttachment(att)) {
147
+ return undefined;
148
+ }
149
+ if (typeof att.content === "string") {
150
+ return att.content;
151
+ }
152
+ if (!isRecord(att.content)) {
153
+ return undefined;
154
+ }
130
155
  const text =
131
156
  typeof att.content.text === "string"
132
157
  ? att.content.text
@@ -140,12 +165,18 @@ export function extractHtmlFromAttachment(att: MSTeamsAttachmentLike): string |
140
165
 
141
166
  function decodeDataImage(src: string): InlineImageCandidate | null {
142
167
  const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src);
143
- if (!match) return null;
168
+ if (!match) {
169
+ return null;
170
+ }
144
171
  const contentType = match[1]?.toLowerCase();
145
172
  const isBase64 = Boolean(match[2]);
146
- if (!isBase64) return null;
173
+ if (!isBase64) {
174
+ return null;
175
+ }
147
176
  const payload = match[3] ?? "";
148
- if (!payload) return null;
177
+ if (!payload) {
178
+ return null;
179
+ }
149
180
  try {
150
181
  const data = Buffer.from(payload, "base64");
151
182
  return { kind: "data", data, contentType, placeholder: "<media:image>" };
@@ -170,7 +201,9 @@ export function extractInlineImageCandidates(
170
201
  const out: InlineImageCandidate[] = [];
171
202
  for (const att of attachments) {
172
203
  const html = extractHtmlFromAttachment(att);
173
- if (!html) continue;
204
+ if (!html) {
205
+ continue;
206
+ }
174
207
  IMG_SRC_RE.lastIndex = 0;
175
208
  let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
176
209
  while (match) {
@@ -178,7 +211,9 @@ export function extractInlineImageCandidates(
178
211
  if (src && !src.startsWith("cid:")) {
179
212
  if (src.startsWith("data:")) {
180
213
  const decoded = decodeDataImage(src);
181
- if (decoded) out.push(decoded);
214
+ if (decoded) {
215
+ out.push(decoded);
216
+ }
182
217
  } else {
183
218
  out.push({
184
219
  kind: "url",
@@ -204,8 +239,12 @@ export function safeHostForUrl(url: string): string {
204
239
 
205
240
  function normalizeAllowHost(value: string): string {
206
241
  const trimmed = value.trim().toLowerCase();
207
- if (!trimmed) return "";
208
- if (trimmed === "*") return "*";
242
+ if (!trimmed) {
243
+ return "";
244
+ }
245
+ if (trimmed === "*") {
246
+ return "*";
247
+ }
209
248
  return trimmed.replace(/^\*\.?/, "");
210
249
  }
211
250
 
@@ -214,12 +253,27 @@ export function resolveAllowedHosts(input?: string[]): string[] {
214
253
  return DEFAULT_MEDIA_HOST_ALLOWLIST.slice();
215
254
  }
216
255
  const normalized = input.map(normalizeAllowHost).filter(Boolean);
217
- if (normalized.includes("*")) return ["*"];
256
+ if (normalized.includes("*")) {
257
+ return ["*"];
258
+ }
259
+ return normalized;
260
+ }
261
+
262
+ export function resolveAuthAllowedHosts(input?: string[]): string[] {
263
+ if (!Array.isArray(input) || input.length === 0) {
264
+ return DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST.slice();
265
+ }
266
+ const normalized = input.map(normalizeAllowHost).filter(Boolean);
267
+ if (normalized.includes("*")) {
268
+ return ["*"];
269
+ }
218
270
  return normalized;
219
271
  }
220
272
 
221
273
  function isHostAllowed(host: string, allowlist: string[]): boolean {
222
- if (allowlist.includes("*")) return true;
274
+ if (allowlist.includes("*")) {
275
+ return true;
276
+ }
223
277
  const normalized = host.toLowerCase();
224
278
  return allowlist.some((entry) => normalized === entry || normalized.endsWith(`.${entry}`));
225
279
  }
@@ -227,7 +281,9 @@ function isHostAllowed(host: string, allowlist: string[]): boolean {
227
281
  export function isUrlAllowed(url: string, allowlist: string[]): boolean {
228
282
  try {
229
283
  const parsed = new URL(url);
230
- if (parsed.protocol !== "https:") return false;
284
+ if (parsed.protocol !== "https:") {
285
+ return false;
286
+ }
231
287
  return isHostAllowed(parsed.hostname, allowlist);
232
288
  } catch {
233
289
  return false;
@@ -1,6 +1,5 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
-
3
1
  import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
3
  import { setMSTeamsRuntime } from "./runtime.js";
5
4
 
6
5
  const detectMimeMock = vi.fn(async () => "image/png");
@@ -242,6 +241,7 @@ describe("msteams attachments", () => {
242
241
  maxBytes: 1024 * 1024,
243
242
  tokenProvider: { getAccessToken: vi.fn(async () => "token") },
244
243
  allowHosts: ["x"],
244
+ authAllowHosts: ["x"],
245
245
  fetchFn: fetchMock as unknown as typeof fetch,
246
246
  });
247
247
 
@@ -250,6 +250,41 @@ describe("msteams attachments", () => {
250
250
  expect(fetchMock).toHaveBeenCalledTimes(2);
251
251
  });
252
252
 
253
+ it("skips auth retries when the host is not in auth allowlist", async () => {
254
+ const { downloadMSTeamsAttachments } = await load();
255
+ const tokenProvider = { getAccessToken: vi.fn(async () => "token") };
256
+ const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
257
+ const hasAuth = Boolean(
258
+ opts &&
259
+ typeof opts === "object" &&
260
+ "headers" in opts &&
261
+ (opts.headers as Record<string, string>)?.Authorization,
262
+ );
263
+ if (!hasAuth) {
264
+ return new Response("forbidden", { status: 403 });
265
+ }
266
+ return new Response(Buffer.from("png"), {
267
+ status: 200,
268
+ headers: { "content-type": "image/png" },
269
+ });
270
+ });
271
+
272
+ const media = await downloadMSTeamsAttachments({
273
+ attachments: [
274
+ { contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" },
275
+ ],
276
+ maxBytes: 1024 * 1024,
277
+ tokenProvider,
278
+ allowHosts: ["azureedge.net"],
279
+ authAllowHosts: ["graph.microsoft.com"],
280
+ fetchFn: fetchMock as unknown as typeof fetch,
281
+ });
282
+
283
+ expect(media).toHaveLength(0);
284
+ expect(fetchMock).toHaveBeenCalledTimes(1);
285
+ expect(tokenProvider.getAccessToken).not.toHaveBeenCalled();
286
+ });
287
+
253
288
  it("skips urls outside the allowlist", async () => {
254
289
  const { downloadMSTeamsAttachments } = await load();
255
290
  const fetchMock = vi.fn();
@@ -1,7 +1,5 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
-
2
+ import { describe, expect, it } from "vitest";
5
3
  import { msteamsPlugin } from "./channel.js";
6
4
 
7
5
  describe("msteams directory", () => {
@@ -27,7 +25,9 @@ describe("msteams directory", () => {
27
25
  expect(msteamsPlugin.directory?.listPeers).toBeTruthy();
28
26
  expect(msteamsPlugin.directory?.listGroups).toBeTruthy();
29
27
 
30
- await expect(msteamsPlugin.directory!.listPeers({ cfg, query: undefined, limit: undefined })).resolves.toEqual(
28
+ await expect(
29
+ msteamsPlugin.directory!.listPeers({ cfg, query: undefined, limit: undefined }),
30
+ ).resolves.toEqual(
31
31
  expect.arrayContaining([
32
32
  { kind: "user", id: "user:alice" },
33
33
  { kind: "user", id: "user:Bob" },
@@ -36,7 +36,9 @@ describe("msteams directory", () => {
36
36
  ]),
37
37
  );
38
38
 
39
- await expect(msteamsPlugin.directory!.listGroups({ cfg, query: undefined, limit: undefined })).resolves.toEqual(
39
+ await expect(
40
+ msteamsPlugin.directory!.listGroups({ cfg, query: undefined, limit: undefined }),
41
+ ).resolves.toEqual(
40
42
  expect.arrayContaining([
41
43
  { kind: "group", id: "conversation:chan1" },
42
44
  { kind: "group", id: "conversation:chan2" },
package/src/channel.ts CHANGED
@@ -5,11 +5,11 @@ import {
5
5
  MSTeamsConfigSchema,
6
6
  PAIRING_APPROVED_MESSAGE,
7
7
  } from "openclaw/plugin-sdk";
8
-
8
+ import { listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive } from "./directory-live.js";
9
9
  import { msteamsOnboardingAdapter } from "./onboarding.js";
10
10
  import { msteamsOutbound } from "./outbound.js";
11
- import { probeMSTeams } from "./probe.js";
12
11
  import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
12
+ import { probeMSTeams } from "./probe.js";
13
13
  import {
14
14
  normalizeMSTeamsMessagingTarget,
15
15
  normalizeMSTeamsUserInput,
@@ -20,10 +20,6 @@ import {
20
20
  } from "./resolve-allowlist.js";
21
21
  import { sendAdaptiveCardMSTeams, sendMessageMSTeams } from "./send.js";
22
22
  import { resolveMSTeamsCredentials } from "./token.js";
23
- import {
24
- listMSTeamsDirectoryGroupsLive,
25
- listMSTeamsDirectoryPeersLive,
26
- } from "./directory-live.js";
27
23
 
28
24
  type ResolvedMSTeamsAccount = {
29
25
  accountId: string;
@@ -129,7 +125,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
129
125
  collectWarnings: ({ cfg }) => {
130
126
  const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
131
127
  const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
132
- if (groupPolicy !== "open") return [];
128
+ if (groupPolicy !== "open") {
129
+ return [];
130
+ }
133
131
  return [
134
132
  `- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.`,
135
133
  ];
@@ -153,8 +151,12 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
153
151
  targetResolver: {
154
152
  looksLikeId: (raw) => {
155
153
  const trimmed = raw.trim();
156
- if (!trimmed) return false;
157
- if (/^conversation:/i.test(trimmed)) return true;
154
+ if (!trimmed) {
155
+ return false;
156
+ }
157
+ if (/^conversation:/i.test(trimmed)) {
158
+ return true;
159
+ }
158
160
  if (/^user:/i.test(trimmed)) {
159
161
  // Only treat as ID if the value after user: looks like a UUID
160
162
  const id = trimmed.slice("user:".length).trim();
@@ -172,11 +174,15 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
172
174
  const ids = new Set<string>();
173
175
  for (const entry of cfg.channels?.msteams?.allowFrom ?? []) {
174
176
  const trimmed = String(entry).trim();
175
- if (trimmed && trimmed !== "*") ids.add(trimmed);
177
+ if (trimmed && trimmed !== "*") {
178
+ ids.add(trimmed);
179
+ }
176
180
  }
177
181
  for (const userId of Object.keys(cfg.channels?.msteams?.dms ?? {})) {
178
182
  const trimmed = userId.trim();
179
- if (trimmed) ids.add(trimmed);
183
+ if (trimmed) {
184
+ ids.add(trimmed);
185
+ }
180
186
  }
181
187
  return Array.from(ids)
182
188
  .map((raw) => raw.trim())
@@ -184,8 +190,12 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
184
190
  .map((raw) => normalizeMSTeamsMessagingTarget(raw) ?? raw)
185
191
  .map((raw) => {
186
192
  const lowered = raw.toLowerCase();
187
- if (lowered.startsWith("user:")) return raw;
188
- if (lowered.startsWith("conversation:")) return raw;
193
+ if (lowered.startsWith("user:")) {
194
+ return raw;
195
+ }
196
+ if (lowered.startsWith("conversation:")) {
197
+ return raw;
198
+ }
189
199
  return `user:${raw}`;
190
200
  })
191
201
  .filter((id) => (q ? id.toLowerCase().includes(q) : true))
@@ -198,7 +208,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
198
208
  for (const team of Object.values(cfg.channels?.msteams?.teams ?? {})) {
199
209
  for (const channelId of Object.keys(team.channels ?? {})) {
200
210
  const trimmed = channelId.trim();
201
- if (trimmed && trimmed !== "*") ids.add(trimmed);
211
+ if (trimmed && trimmed !== "*") {
212
+ ids.add(trimmed);
213
+ }
202
214
  }
203
215
  }
204
216
  return Array.from(ids)
@@ -225,8 +237,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
225
237
  note: undefined as string | undefined,
226
238
  }));
227
239
 
228
- const stripPrefix = (value: string) =>
229
- normalizeMSTeamsUserInput(value);
240
+ const stripPrefix = (value: string) => normalizeMSTeamsUserInput(value);
230
241
 
231
242
  if (kind === "user") {
232
243
  const pending: Array<{ input: string; query: string; index: number }> = [];
@@ -253,7 +264,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
253
264
  });
254
265
  resolved.forEach((entry, idx) => {
255
266
  const target = results[pending[idx]?.index ?? -1];
256
- if (!target) return;
267
+ if (!target) {
268
+ return;
269
+ }
257
270
  target.resolved = entry.resolved;
258
271
  target.id = entry.id;
259
272
  target.name = entry.name;
@@ -263,7 +276,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
263
276
  runtime.error?.(`msteams resolve failed: ${String(err)}`);
264
277
  pending.forEach(({ index }) => {
265
278
  const entry = results[index];
266
- if (entry) entry.note = "lookup failed";
279
+ if (entry) {
280
+ entry.note = "lookup failed";
281
+ }
267
282
  });
268
283
  }
269
284
  }
@@ -302,7 +317,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
302
317
  });
303
318
  resolved.forEach((entry, idx) => {
304
319
  const target = results[pending[idx]?.index ?? -1];
305
- if (!target) return;
320
+ if (!target) {
321
+ return;
322
+ }
306
323
  if (!entry.resolved || !entry.teamId) {
307
324
  target.resolved = false;
308
325
  target.note = entry.note;
@@ -314,19 +331,23 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
314
331
  target.name =
315
332
  entry.channelName && entry.teamName
316
333
  ? `${entry.teamName}/${entry.channelName}`
317
- : entry.channelName ?? entry.teamName;
334
+ : (entry.channelName ?? entry.teamName);
318
335
  } else {
319
336
  target.id = entry.teamId;
320
337
  target.name = entry.teamName;
321
338
  target.note = "team id";
322
339
  }
323
- if (entry.note) target.note = entry.note;
340
+ if (entry.note) {
341
+ target.note = entry.note;
342
+ }
324
343
  });
325
344
  } catch (err) {
326
345
  runtime.error?.(`msteams resolve failed: ${String(err)}`);
327
346
  pending.forEach(({ index }) => {
328
347
  const entry = results[index];
329
- if (entry) entry.note = "lookup failed";
348
+ if (entry) {
349
+ entry.note = "lookup failed";
350
+ }
330
351
  });
331
352
  }
332
353
  }
@@ -339,7 +360,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
339
360
  const enabled =
340
361
  cfg.channels?.msteams?.enabled !== false &&
341
362
  Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams));
342
- if (!enabled) return [];
363
+ if (!enabled) {
364
+ return [];
365
+ }
343
366
  return ["poll"] satisfies ChannelMessageActionName[];
344
367
  },
345
368
  supportsCards: ({ cfg }) => {
@@ -1,10 +1,8 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
-
5
5
  import { beforeEach, describe, expect, it } from "vitest";
6
-
7
- import type { PluginRuntime } from "openclaw/plugin-sdk";
8
6
  import type { StoredConversationReference } from "./conversation-store.js";
9
7
  import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
10
8
  import { setMSTeamsRuntime } from "./runtime.js";
@@ -12,9 +10,10 @@ import { setMSTeamsRuntime } from "./runtime.js";
12
10
  const runtimeStub = {
13
11
  state: {
14
12
  resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
15
- const override =
16
- env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim();
17
- if (override) return override;
13
+ const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim();
14
+ if (override) {
15
+ return override;
16
+ }
18
17
  const resolvedHome = homedir ? homedir() : os.homedir();
19
18
  return path.join(resolvedHome, ".openclaw");
20
19
  },
@@ -67,7 +66,7 @@ describe("msteams conversation store (fs)", () => {
67
66
  await fs.promises.writeFile(filePath, `${JSON.stringify(json, null, 2)}\n`);
68
67
 
69
68
  const list = await store.list();
70
- const ids = list.map((e) => e.conversationId).sort();
69
+ const ids = list.map((e) => e.conversationId).toSorted();
71
70
  expect(ids).toEqual(["19:active@thread.tacv2", "19:legacy@thread.tacv2"]);
72
71
 
73
72
  expect(await store.get("19:old@thread.tacv2")).toBeNull();
@@ -80,7 +79,7 @@ describe("msteams conversation store (fs)", () => {
80
79
 
81
80
  const rawAfter = await fs.promises.readFile(filePath, "utf-8");
82
81
  const jsonAfter = JSON.parse(rawAfter) as typeof json;
83
- expect(Object.keys(jsonAfter.conversations).sort()).toEqual([
82
+ expect(Object.keys(jsonAfter.conversations).toSorted()).toEqual([
84
83
  "19:active@thread.tacv2",
85
84
  "19:legacy@thread.tacv2",
86
85
  "19:new@thread.tacv2",
@@ -16,9 +16,13 @@ const MAX_CONVERSATIONS = 1000;
16
16
  const CONVERSATION_TTL_MS = 365 * 24 * 60 * 60 * 1000;
17
17
 
18
18
  function parseTimestamp(value: string | undefined): number | null {
19
- if (!value) return null;
19
+ if (!value) {
20
+ return null;
21
+ }
20
22
  const parsed = Date.parse(value);
21
- if (!Number.isFinite(parsed)) return null;
23
+ if (!Number.isFinite(parsed)) {
24
+ return null;
25
+ }
22
26
  return parsed;
23
27
  }
24
28
 
@@ -26,7 +30,9 @@ function pruneToLimit(
26
30
  conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>,
27
31
  ) {
28
32
  const entries = Object.entries(conversations);
29
- if (entries.length <= MAX_CONVERSATIONS) return conversations;
33
+ if (entries.length <= MAX_CONVERSATIONS) {
34
+ return conversations;
35
+ }
30
36
 
31
37
  entries.sort((a, b) => {
32
38
  const aTs = parseTimestamp(a[1].lastSeenAt) ?? 0;
@@ -109,7 +115,9 @@ export function createMSTeamsConversationStoreFs(params?: {
109
115
 
110
116
  const findByUserId = async (id: string): Promise<MSTeamsConversationStoreEntry | null> => {
111
117
  const target = id.trim();
112
- if (!target) return null;
118
+ if (!target) {
119
+ return null;
120
+ }
113
121
  for (const entry of await list()) {
114
122
  const { conversationId, reference } = entry;
115
123
  if (reference.user?.aadObjectId === target) {
@@ -144,7 +152,9 @@ export function createMSTeamsConversationStoreFs(params?: {
144
152
  const normalizedId = normalizeConversationId(conversationId);
145
153
  return await withFileLock(filePath, empty, async () => {
146
154
  const store = await readStore();
147
- if (!(normalizedId in store.conversations)) return false;
155
+ if (!(normalizedId in store.conversations)) {
156
+ return false;
157
+ }
148
158
  delete store.conversations[normalizedId];
149
159
  await writeJsonFile(filePath, store);
150
160
  return true;
@@ -30,7 +30,9 @@ export function createMSTeamsConversationStoreMemory(
30
30
  },
31
31
  findByUserId: async (id) => {
32
32
  const target = id.trim();
33
- if (!target) return null;
33
+ if (!target) {
34
+ return null;
35
+ }
34
36
  for (const [conversationId, reference] of map.entries()) {
35
37
  if (reference.user?.aadObjectId === target) {
36
38
  return { conversationId, reference };
@@ -1,5 +1,4 @@
1
1
  import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
2
-
3
2
  import { GRAPH_ROOT } from "./attachments/shared.js";
4
3
  import { loadMSTeamsSdkWithAuth } from "./sdk.js";
5
4
  import { resolveMSTeamsCredentials } from "./token.js";
@@ -24,7 +23,9 @@ type GraphChannel = {
24
23
  type GraphResponse<T> = { value?: T[] };
25
24
 
26
25
  function readAccessToken(value: unknown): string | null {
27
- if (typeof value === "string") return value;
26
+ if (typeof value === "string") {
27
+ return value;
28
+ }
28
29
  if (value && typeof value === "object") {
29
30
  const token =
30
31
  (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
@@ -49,7 +50,7 @@ async function fetchGraphJson<T>(params: {
49
50
  const res = await fetch(`${GRAPH_ROOT}${params.path}`, {
50
51
  headers: {
51
52
  Authorization: `Bearer ${params.token}`,
52
- ...(params.headers ?? {}),
53
+ ...params.headers,
53
54
  },
54
55
  });
55
56
  if (!res.ok) {
@@ -60,13 +61,19 @@ async function fetchGraphJson<T>(params: {
60
61
  }
61
62
 
62
63
  async function resolveGraphToken(cfg: unknown): Promise<string> {
63
- const creds = resolveMSTeamsCredentials((cfg as { channels?: { msteams?: unknown } })?.channels?.msteams);
64
- if (!creds) throw new Error("MS Teams credentials missing");
64
+ const creds = resolveMSTeamsCredentials(
65
+ (cfg as { channels?: { msteams?: unknown } })?.channels?.msteams,
66
+ );
67
+ if (!creds) {
68
+ throw new Error("MS Teams credentials missing");
69
+ }
65
70
  const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
66
71
  const tokenProvider = new sdk.MsalTokenProvider(authConfig);
67
72
  const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
68
73
  const accessToken = readAccessToken(token);
69
- if (!accessToken) throw new Error("MS Teams graph token unavailable");
74
+ if (!accessToken) {
75
+ throw new Error("MS Teams graph token unavailable");
76
+ }
70
77
  return accessToken;
71
78
  }
72
79
 
@@ -90,7 +97,9 @@ export async function listMSTeamsDirectoryPeersLive(params: {
90
97
  limit?: number | null;
91
98
  }): Promise<ChannelDirectoryEntry[]> {
92
99
  const query = normalizeQuery(params.query);
93
- if (!query) return [];
100
+ if (!query) {
101
+ return [];
102
+ }
94
103
  const token = await resolveGraphToken(params.cfg);
95
104
  const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
96
105
 
@@ -114,7 +123,9 @@ export async function listMSTeamsDirectoryPeersLive(params: {
114
123
  return users
115
124
  .map((user) => {
116
125
  const id = user.id?.trim();
117
- if (!id) return null;
126
+ if (!id) {
127
+ return null;
128
+ }
118
129
  const name = user.displayName?.trim();
119
130
  const handle = user.userPrincipalName?.trim() || user.mail?.trim();
120
131
  return {
@@ -134,11 +145,16 @@ export async function listMSTeamsDirectoryGroupsLive(params: {
134
145
  limit?: number | null;
135
146
  }): Promise<ChannelDirectoryEntry[]> {
136
147
  const rawQuery = normalizeQuery(params.query);
137
- if (!rawQuery) return [];
148
+ if (!rawQuery) {
149
+ return [];
150
+ }
138
151
  const token = await resolveGraphToken(params.cfg);
139
152
  const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
140
153
  const [teamQuery, channelQuery] = rawQuery.includes("/")
141
- ? rawQuery.split("/", 2).map((part) => part.trim()).filter(Boolean)
154
+ ? rawQuery
155
+ .split("/", 2)
156
+ .map((part) => part.trim())
157
+ .filter(Boolean)
142
158
  : [rawQuery, null];
143
159
 
144
160
  const teams = await listTeamsByName(token, teamQuery);
@@ -146,7 +162,9 @@ export async function listMSTeamsDirectoryGroupsLive(params: {
146
162
 
147
163
  for (const team of teams) {
148
164
  const teamId = team.id?.trim();
149
- if (!teamId) continue;
165
+ if (!teamId) {
166
+ continue;
167
+ }
150
168
  const teamName = team.displayName?.trim() || teamQuery;
151
169
  if (!channelQuery) {
152
170
  results.push({
@@ -156,14 +174,20 @@ export async function listMSTeamsDirectoryGroupsLive(params: {
156
174
  handle: teamName ? `#${teamName}` : undefined,
157
175
  raw: team,
158
176
  });
159
- if (results.length >= limit) return results;
177
+ if (results.length >= limit) {
178
+ return results;
179
+ }
160
180
  continue;
161
181
  }
162
182
  const channels = await listChannelsForTeam(token, teamId);
163
183
  for (const channel of channels) {
164
184
  const name = channel.displayName?.trim();
165
- if (!name) continue;
166
- if (!name.toLowerCase().includes(channelQuery.toLowerCase())) continue;
185
+ if (!name) {
186
+ continue;
187
+ }
188
+ if (!name.toLowerCase().includes(channelQuery.toLowerCase())) {
189
+ continue;
190
+ }
167
191
  results.push({
168
192
  kind: "group",
169
193
  id: `conversation:${channel.id}`,
@@ -171,7 +195,9 @@ export async function listMSTeamsDirectoryGroupsLive(params: {
171
195
  handle: `#${name}`,
172
196
  raw: channel,
173
197
  });
174
- if (results.length >= limit) return results;
198
+ if (results.length >= limit) {
199
+ return results;
200
+ }
175
201
  }
176
202
  }
177
203
 
@@ -1,5 +1,4 @@
1
1
  import { describe, expect, it } from "vitest";
2
-
3
2
  import {
4
3
  classifyMSTeamsSendError,
5
4
  formatMSTeamsSendErrorHint,