@openclaw/msteams 2026.2.21 → 2026.2.22

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.
@@ -1,3 +1,5 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isPrivateIpAddress } from "openclaw/plugin-sdk";
1
3
  import type { MSTeamsAttachmentLike } from "./types.js";
2
4
 
3
5
  type InlineImageCandidate =
@@ -63,6 +65,19 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
63
65
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
64
66
  }
65
67
 
68
+ export function resolveRequestUrl(input: RequestInfo | URL): string {
69
+ if (typeof input === "string") {
70
+ return input;
71
+ }
72
+ if (input instanceof URL) {
73
+ return input.toString();
74
+ }
75
+ if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
76
+ return input.url;
77
+ }
78
+ return String(input);
79
+ }
80
+
66
81
  export function normalizeContentType(value: unknown): string | undefined {
67
82
  if (typeof value !== "string") {
68
83
  return undefined;
@@ -289,3 +304,101 @@ export function isUrlAllowed(url: string, allowlist: string[]): boolean {
289
304
  return false;
290
305
  }
291
306
  }
307
+
308
+ /**
309
+ * Returns true if the given IPv4 or IPv6 address is in a private, loopback,
310
+ * or link-local range that must never be reached from media downloads.
311
+ *
312
+ * Delegates to the SDK's `isPrivateIpAddress` which handles IPv4-mapped IPv6,
313
+ * expanded notation, NAT64, 6to4, Teredo, octal IPv4, and fails closed on
314
+ * parse errors.
315
+ */
316
+ export const isPrivateOrReservedIP: (ip: string) => boolean = isPrivateIpAddress;
317
+
318
+ /**
319
+ * Resolve a hostname via DNS and reject private/reserved IPs.
320
+ * Throws if the resolved IP is private or resolution fails.
321
+ */
322
+ export async function resolveAndValidateIP(
323
+ hostname: string,
324
+ resolveFn?: (hostname: string) => Promise<{ address: string }>,
325
+ ): Promise<string> {
326
+ const resolve = resolveFn ?? lookup;
327
+ let resolved: { address: string };
328
+ try {
329
+ resolved = await resolve(hostname);
330
+ } catch {
331
+ throw new Error(`DNS resolution failed for "${hostname}"`);
332
+ }
333
+ if (isPrivateOrReservedIP(resolved.address)) {
334
+ throw new Error(`Hostname "${hostname}" resolves to private/reserved IP (${resolved.address})`);
335
+ }
336
+ return resolved.address;
337
+ }
338
+
339
+ /** Maximum number of redirects to follow in safeFetch. */
340
+ const MAX_SAFE_REDIRECTS = 5;
341
+
342
+ /**
343
+ * Fetch a URL with redirect: "manual", validating each redirect target
344
+ * against the hostname allowlist and DNS-resolved IP (anti-SSRF).
345
+ *
346
+ * This prevents:
347
+ * - Auto-following redirects to non-allowlisted hosts
348
+ * - DNS rebinding attacks where an allowlisted domain resolves to a private IP
349
+ */
350
+ export async function safeFetch(params: {
351
+ url: string;
352
+ allowHosts: string[];
353
+ fetchFn?: typeof fetch;
354
+ requestInit?: RequestInit;
355
+ resolveFn?: (hostname: string) => Promise<{ address: string }>;
356
+ }): Promise<Response> {
357
+ const fetchFn = params.fetchFn ?? fetch;
358
+ const resolveFn = params.resolveFn;
359
+ let currentUrl = params.url;
360
+
361
+ // Validate the initial URL's resolved IP
362
+ try {
363
+ const initialHost = new URL(currentUrl).hostname;
364
+ await resolveAndValidateIP(initialHost, resolveFn);
365
+ } catch {
366
+ throw new Error(`Initial download URL blocked: ${currentUrl}`);
367
+ }
368
+
369
+ for (let i = 0; i <= MAX_SAFE_REDIRECTS; i++) {
370
+ const res = await fetchFn(currentUrl, {
371
+ ...params.requestInit,
372
+ redirect: "manual",
373
+ });
374
+
375
+ if (![301, 302, 303, 307, 308].includes(res.status)) {
376
+ return res;
377
+ }
378
+
379
+ const location = res.headers.get("location");
380
+ if (!location) {
381
+ return res;
382
+ }
383
+
384
+ let redirectUrl: string;
385
+ try {
386
+ redirectUrl = new URL(location, currentUrl).toString();
387
+ } catch {
388
+ throw new Error(`Invalid redirect URL: ${location}`);
389
+ }
390
+
391
+ // Validate redirect target against hostname allowlist
392
+ if (!isUrlAllowed(redirectUrl, params.allowHosts)) {
393
+ throw new Error(`Media redirect target blocked by allowlist: ${redirectUrl}`);
394
+ }
395
+
396
+ // Validate redirect target's resolved IP
397
+ const redirectHost = new URL(redirectUrl).hostname;
398
+ await resolveAndValidateIP(redirectHost, resolveFn);
399
+
400
+ currentUrl = redirectUrl;
401
+ }
402
+
403
+ throw new Error(`Too many redirects (>${MAX_SAFE_REDIRECTS})`);
404
+ }
@@ -2,11 +2,37 @@ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { setMSTeamsRuntime } from "./runtime.js";
4
4
 
5
+ /** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */
6
+ const publicResolveFn = async () => ({ address: "13.107.136.10" });
7
+
5
8
  const detectMimeMock = vi.fn(async () => "image/png");
6
9
  const saveMediaBufferMock = vi.fn(async () => ({
7
10
  path: "/tmp/saved.png",
8
11
  contentType: "image/png",
9
12
  }));
13
+ const fetchRemoteMediaMock = vi.fn(
14
+ async (params: {
15
+ url: string;
16
+ maxBytes?: number;
17
+ filePathHint?: string;
18
+ fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
19
+ }) => {
20
+ const fetchFn = params.fetchImpl ?? fetch;
21
+ const res = await fetchFn(params.url);
22
+ if (!res.ok) {
23
+ throw new Error(`HTTP ${res.status}`);
24
+ }
25
+ const buffer = Buffer.from(await res.arrayBuffer());
26
+ if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
27
+ throw new Error(`payload exceeds maxBytes ${params.maxBytes}`);
28
+ }
29
+ return {
30
+ buffer,
31
+ contentType: res.headers.get("content-type") ?? undefined,
32
+ fileName: params.filePathHint,
33
+ };
34
+ },
35
+ );
10
36
 
11
37
  const runtimeStub = {
12
38
  media: {
@@ -14,6 +40,8 @@ const runtimeStub = {
14
40
  },
15
41
  channel: {
16
42
  media: {
43
+ fetchRemoteMedia:
44
+ fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
17
45
  saveMediaBuffer:
18
46
  saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
19
47
  },
@@ -28,6 +56,7 @@ describe("msteams attachments", () => {
28
56
  beforeEach(() => {
29
57
  detectMimeMock.mockClear();
30
58
  saveMediaBufferMock.mockClear();
59
+ fetchRemoteMediaMock.mockClear();
31
60
  setMSTeamsRuntime(runtimeStub);
32
61
  });
33
62
 
@@ -116,9 +145,10 @@ describe("msteams attachments", () => {
116
145
  maxBytes: 1024 * 1024,
117
146
  allowHosts: ["x"],
118
147
  fetchFn: fetchMock as unknown as typeof fetch,
148
+ resolveFn: publicResolveFn,
119
149
  });
120
150
 
121
- expect(fetchMock).toHaveBeenCalledWith("https://x/img");
151
+ expect(fetchMock).toHaveBeenCalled();
122
152
  expect(saveMediaBufferMock).toHaveBeenCalled();
123
153
  expect(media).toHaveLength(1);
124
154
  expect(media[0]?.path).toBe("/tmp/saved.png");
@@ -143,9 +173,10 @@ describe("msteams attachments", () => {
143
173
  maxBytes: 1024 * 1024,
144
174
  allowHosts: ["x"],
145
175
  fetchFn: fetchMock as unknown as typeof fetch,
176
+ resolveFn: publicResolveFn,
146
177
  });
147
178
 
148
- expect(fetchMock).toHaveBeenCalledWith("https://x/dl");
179
+ expect(fetchMock).toHaveBeenCalled();
149
180
  expect(media).toHaveLength(1);
150
181
  });
151
182
 
@@ -168,9 +199,10 @@ describe("msteams attachments", () => {
168
199
  maxBytes: 1024 * 1024,
169
200
  allowHosts: ["x"],
170
201
  fetchFn: fetchMock as unknown as typeof fetch,
202
+ resolveFn: publicResolveFn,
171
203
  });
172
204
 
173
- expect(fetchMock).toHaveBeenCalledWith("https://x/doc.pdf");
205
+ expect(fetchMock).toHaveBeenCalled();
174
206
  expect(media).toHaveLength(1);
175
207
  expect(media[0]?.path).toBe("/tmp/saved.pdf");
176
208
  expect(media[0]?.placeholder).toBe("<media:document>");
@@ -195,10 +227,11 @@ describe("msteams attachments", () => {
195
227
  maxBytes: 1024 * 1024,
196
228
  allowHosts: ["x"],
197
229
  fetchFn: fetchMock as unknown as typeof fetch,
230
+ resolveFn: publicResolveFn,
198
231
  });
199
232
 
200
233
  expect(media).toHaveLength(1);
201
- expect(fetchMock).toHaveBeenCalledWith("https://x/inline.png");
234
+ expect(fetchMock).toHaveBeenCalled();
202
235
  });
203
236
 
204
237
  it("stores inline data:image base64 payloads", async () => {
@@ -222,12 +255,8 @@ describe("msteams attachments", () => {
222
255
  it("retries with auth when the first request is unauthorized", async () => {
223
256
  const { downloadMSTeamsAttachments } = await load();
224
257
  const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
225
- const hasAuth = Boolean(
226
- opts &&
227
- typeof opts === "object" &&
228
- "headers" in opts &&
229
- (opts.headers as Record<string, string>)?.Authorization,
230
- );
258
+ const headers = new Headers(opts?.headers);
259
+ const hasAuth = Boolean(headers.get("Authorization"));
231
260
  if (!hasAuth) {
232
261
  return new Response("unauthorized", { status: 401 });
233
262
  }
@@ -244,23 +273,19 @@ describe("msteams attachments", () => {
244
273
  allowHosts: ["x"],
245
274
  authAllowHosts: ["x"],
246
275
  fetchFn: fetchMock as unknown as typeof fetch,
276
+ resolveFn: publicResolveFn,
247
277
  });
248
278
 
249
279
  expect(fetchMock).toHaveBeenCalled();
250
280
  expect(media).toHaveLength(1);
251
- expect(fetchMock).toHaveBeenCalledTimes(2);
252
281
  });
253
282
 
254
283
  it("skips auth retries when the host is not in auth allowlist", async () => {
255
284
  const { downloadMSTeamsAttachments } = await load();
256
285
  const tokenProvider = { getAccessToken: vi.fn(async () => "token") };
257
286
  const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
258
- const hasAuth = Boolean(
259
- opts &&
260
- typeof opts === "object" &&
261
- "headers" in opts &&
262
- (opts.headers as Record<string, string>)?.Authorization,
263
- );
287
+ const headers = new Headers(opts?.headers);
288
+ const hasAuth = Boolean(headers.get("Authorization"));
264
289
  if (!hasAuth) {
265
290
  return new Response("forbidden", { status: 403 });
266
291
  }
@@ -279,10 +304,11 @@ describe("msteams attachments", () => {
279
304
  allowHosts: ["azureedge.net"],
280
305
  authAllowHosts: ["graph.microsoft.com"],
281
306
  fetchFn: fetchMock as unknown as typeof fetch,
307
+ resolveFn: publicResolveFn,
282
308
  });
283
309
 
284
310
  expect(media).toHaveLength(0);
285
- expect(fetchMock).toHaveBeenCalledTimes(1);
311
+ expect(fetchMock).toHaveBeenCalled();
286
312
  expect(tokenProvider.getAccessToken).not.toHaveBeenCalled();
287
313
  });
288
314
 
@@ -441,6 +467,88 @@ describe("msteams attachments", () => {
441
467
 
442
468
  expect(media.media).toHaveLength(2);
443
469
  });
470
+
471
+ it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
472
+ const { downloadMSTeamsGraphMedia } = await load();
473
+ const shareUrl = "https://contoso.sharepoint.com/site/file";
474
+ const escapedUrl = "https://evil.example/internal.pdf";
475
+ fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
476
+ const fetchFn = params.fetchImpl ?? fetch;
477
+ let currentUrl = params.url;
478
+ for (let i = 0; i < 5; i += 1) {
479
+ const res = await fetchFn(currentUrl, { redirect: "manual" });
480
+ if ([301, 302, 303, 307, 308].includes(res.status)) {
481
+ const location = res.headers.get("location");
482
+ if (!location) {
483
+ throw new Error("redirect missing location");
484
+ }
485
+ currentUrl = new URL(location, currentUrl).toString();
486
+ continue;
487
+ }
488
+ if (!res.ok) {
489
+ throw new Error(`HTTP ${res.status}`);
490
+ }
491
+ return {
492
+ buffer: Buffer.from(await res.arrayBuffer()),
493
+ contentType: res.headers.get("content-type") ?? undefined,
494
+ fileName: params.filePathHint,
495
+ };
496
+ }
497
+ throw new Error("too many redirects");
498
+ });
499
+
500
+ const fetchMock = vi.fn(async (url: string) => {
501
+ if (url.endsWith("/hostedContents")) {
502
+ return new Response(JSON.stringify({ value: [] }), { status: 200 });
503
+ }
504
+ if (url.endsWith("/attachments")) {
505
+ return new Response(JSON.stringify({ value: [] }), { status: 200 });
506
+ }
507
+ if (url.endsWith("/messages/123")) {
508
+ return new Response(
509
+ JSON.stringify({
510
+ attachments: [
511
+ {
512
+ id: "ref-1",
513
+ contentType: "reference",
514
+ contentUrl: shareUrl,
515
+ name: "report.pdf",
516
+ },
517
+ ],
518
+ }),
519
+ { status: 200 },
520
+ );
521
+ }
522
+ if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) {
523
+ return new Response(null, {
524
+ status: 302,
525
+ headers: { location: escapedUrl },
526
+ });
527
+ }
528
+ if (url === escapedUrl) {
529
+ return new Response(Buffer.from("should-not-be-fetched"), {
530
+ status: 200,
531
+ headers: { "content-type": "application/pdf" },
532
+ });
533
+ }
534
+ return new Response("not found", { status: 404 });
535
+ });
536
+
537
+ const media = await downloadMSTeamsGraphMedia({
538
+ messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
539
+ tokenProvider: { getAccessToken: vi.fn(async () => "token") },
540
+ maxBytes: 1024 * 1024,
541
+ allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"],
542
+ fetchFn: fetchMock as unknown as typeof fetch,
543
+ });
544
+
545
+ expect(media.media).toHaveLength(0);
546
+ const calledUrls = fetchMock.mock.calls.map((call) => String(call[0]));
547
+ expect(
548
+ calledUrls.some((url) => url.startsWith("https://graph.microsoft.com/v1.0/shares/")),
549
+ ).toBe(true);
550
+ expect(calledUrls).not.toContain(escapedUrl);
551
+ });
444
552
  });
445
553
 
446
554
  describe("buildMSTeamsMediaPayload", () => {
package/src/channel.ts CHANGED
@@ -6,6 +6,8 @@ import {
6
6
  DEFAULT_ACCOUNT_ID,
7
7
  MSTeamsConfigSchema,
8
8
  PAIRING_APPROVED_MESSAGE,
9
+ resolveAllowlistProviderRuntimeGroupPolicy,
10
+ resolveDefaultGroupPolicy,
9
11
  } from "openclaw/plugin-sdk";
10
12
  import { listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive } from "./directory-live.js";
11
13
  import { msteamsOnboardingAdapter } from "./onboarding.js";
@@ -127,8 +129,12 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
127
129
  },
128
130
  security: {
129
131
  collectWarnings: ({ cfg }) => {
130
- const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
131
- const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
132
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
133
+ const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
134
+ providerConfigPresent: cfg.channels?.msteams !== undefined,
135
+ groupPolicy: cfg.channels?.msteams?.groupPolicy,
136
+ defaultGroupPolicy,
137
+ });
132
138
  if (groupPolicy !== "open") {
133
139
  return [];
134
140
  }
@@ -1,11 +1,8 @@
1
1
  import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
2
+ import { searchGraphUsers } from "./graph-users.js";
2
3
  import {
3
- escapeOData,
4
- fetchGraphJson,
5
4
  type GraphChannel,
6
5
  type GraphGroup,
7
- type GraphResponse,
8
- type GraphUser,
9
6
  listChannelsForTeam,
10
7
  listTeamsByName,
11
8
  normalizeQuery,
@@ -24,22 +21,7 @@ export async function listMSTeamsDirectoryPeersLive(params: {
24
21
  const token = await resolveGraphToken(params.cfg);
25
22
  const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
26
23
 
27
- let users: GraphUser[] = [];
28
- if (query.includes("@")) {
29
- const escaped = escapeOData(query);
30
- const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
31
- const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
32
- const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token, path });
33
- users = res.value ?? [];
34
- } else {
35
- const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${limit}`;
36
- const res = await fetchGraphJson<GraphResponse<GraphUser>>({
37
- token,
38
- path,
39
- headers: { ConsistencyLevel: "eventual" },
40
- });
41
- users = res.value ?? [];
42
- }
24
+ const users = await searchGraphUsers({ token, query, top: limit });
43
25
 
44
26
  return users
45
27
  .map((user) => {
@@ -0,0 +1,66 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { searchGraphUsers } from "./graph-users.js";
3
+ import { fetchGraphJson } from "./graph.js";
4
+
5
+ vi.mock("./graph.js", () => ({
6
+ escapeOData: vi.fn((value: string) => value.replace(/'/g, "''")),
7
+ fetchGraphJson: vi.fn(),
8
+ }));
9
+
10
+ describe("searchGraphUsers", () => {
11
+ beforeEach(() => {
12
+ vi.mocked(fetchGraphJson).mockReset();
13
+ });
14
+
15
+ it("returns empty array for blank queries", async () => {
16
+ await expect(searchGraphUsers({ token: "token-1", query: " " })).resolves.toEqual([]);
17
+ expect(fetchGraphJson).not.toHaveBeenCalled();
18
+ });
19
+
20
+ it("uses exact mail/upn filter lookup for email-like queries", async () => {
21
+ vi.mocked(fetchGraphJson).mockResolvedValueOnce({
22
+ value: [{ id: "user-1", displayName: "User One" }],
23
+ } as never);
24
+
25
+ const result = await searchGraphUsers({
26
+ token: "token-2",
27
+ query: "alice.o'hara@example.com",
28
+ });
29
+
30
+ expect(fetchGraphJson).toHaveBeenCalledWith({
31
+ token: "token-2",
32
+ path: "/users?$filter=(mail%20eq%20'alice.o''hara%40example.com'%20or%20userPrincipalName%20eq%20'alice.o''hara%40example.com')&$select=id,displayName,mail,userPrincipalName",
33
+ });
34
+ expect(result).toEqual([{ id: "user-1", displayName: "User One" }]);
35
+ });
36
+
37
+ it("uses displayName search with eventual consistency and custom top", async () => {
38
+ vi.mocked(fetchGraphJson).mockResolvedValueOnce({
39
+ value: [{ id: "user-2", displayName: "Bob" }],
40
+ } as never);
41
+
42
+ const result = await searchGraphUsers({
43
+ token: "token-3",
44
+ query: "bob",
45
+ top: 25,
46
+ });
47
+
48
+ expect(fetchGraphJson).toHaveBeenCalledWith({
49
+ token: "token-3",
50
+ path: "/users?$search=%22displayName%3Abob%22&$select=id,displayName,mail,userPrincipalName&$top=25",
51
+ headers: { ConsistencyLevel: "eventual" },
52
+ });
53
+ expect(result).toEqual([{ id: "user-2", displayName: "Bob" }]);
54
+ });
55
+
56
+ it("falls back to default top and empty value handling", async () => {
57
+ vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
58
+
59
+ await expect(searchGraphUsers({ token: "token-4", query: "carol" })).resolves.toEqual([]);
60
+ expect(fetchGraphJson).toHaveBeenCalledWith({
61
+ token: "token-4",
62
+ path: "/users?$search=%22displayName%3Acarol%22&$select=id,displayName,mail,userPrincipalName&$top=10",
63
+ headers: { ConsistencyLevel: "eventual" },
64
+ });
65
+ });
66
+ });
@@ -0,0 +1,29 @@
1
+ import { escapeOData, fetchGraphJson, type GraphResponse, type GraphUser } from "./graph.js";
2
+
3
+ export async function searchGraphUsers(params: {
4
+ token: string;
5
+ query: string;
6
+ top?: number;
7
+ }): Promise<GraphUser[]> {
8
+ const query = params.query.trim();
9
+ if (!query) {
10
+ return [];
11
+ }
12
+
13
+ if (query.includes("@")) {
14
+ const escaped = escapeOData(query);
15
+ const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
16
+ const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
17
+ const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token: params.token, path });
18
+ return res.value ?? [];
19
+ }
20
+
21
+ const top = typeof params.top === "number" && params.top > 0 ? params.top : 10;
22
+ const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${top}`;
23
+ const res = await fetchGraphJson<GraphResponse<GraphUser>>({
24
+ token: params.token,
25
+ path,
26
+ headers: { ConsistencyLevel: "eventual" },
27
+ });
28
+ return res.value ?? [];
29
+ }
package/src/graph.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { MSTeamsConfig } from "openclaw/plugin-sdk";
2
2
  import { GRAPH_ROOT } from "./attachments/shared.js";
3
3
  import { loadMSTeamsSdkWithAuth } from "./sdk.js";
4
+ import { readAccessToken } from "./token-response.js";
4
5
  import { resolveMSTeamsCredentials } from "./token.js";
5
6
 
6
7
  export type GraphUser = {
@@ -22,18 +23,6 @@ export type GraphChannel = {
22
23
 
23
24
  export type GraphResponse<T> = { value?: T[] };
24
25
 
25
- function readAccessToken(value: unknown): string | null {
26
- if (typeof value === "string") {
27
- return value;
28
- }
29
- if (value && typeof value === "object") {
30
- const token =
31
- (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
32
- return typeof token === "string" ? token : null;
33
- }
34
- return null;
35
- }
36
-
37
26
  export function normalizeQuery(value?: string | null): string {
38
27
  return value?.trim() ?? "";
39
28
  }
package/src/messenger.ts CHANGED
@@ -441,11 +441,7 @@ export async function sendMSTeamsMessages(params: {
441
441
  }
442
442
  };
443
443
 
444
- if (params.replyStyle === "thread") {
445
- const ctx = params.context;
446
- if (!ctx) {
447
- throw new Error("Missing context for replyStyle=thread");
448
- }
444
+ const sendMessagesInContext = async (ctx: SendContext): Promise<string[]> => {
449
445
  const messageIds: string[] = [];
450
446
  for (const [idx, message] of messages.entries()) {
451
447
  const response = await sendWithRetry(
@@ -464,6 +460,14 @@ export async function sendMSTeamsMessages(params: {
464
460
  messageIds.push(extractMessageId(response) ?? "unknown");
465
461
  }
466
462
  return messageIds;
463
+ };
464
+
465
+ if (params.replyStyle === "thread") {
466
+ const ctx = params.context;
467
+ if (!ctx) {
468
+ throw new Error("Missing context for replyStyle=thread");
469
+ }
470
+ return await sendMessagesInContext(ctx);
467
471
  }
468
472
 
469
473
  const baseRef = buildConversationReference(params.conversationRef);
@@ -474,22 +478,7 @@ export async function sendMSTeamsMessages(params: {
474
478
 
475
479
  const messageIds: string[] = [];
476
480
  await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
477
- for (const [idx, message] of messages.entries()) {
478
- const response = await sendWithRetry(
479
- async () =>
480
- await ctx.sendActivity(
481
- await buildActivity(
482
- message,
483
- params.conversationRef,
484
- params.tokenProvider,
485
- params.sharePointSiteId,
486
- params.mediaMaxBytes,
487
- ),
488
- ),
489
- { messageIndex: idx, messageCount: messages.length },
490
- );
491
- messageIds.push(extractMessageId(response) ?? "unknown");
492
- }
481
+ messageIds.push(...(await sendMessagesInContext(ctx)));
493
482
  });
494
483
  return messageIds;
495
484
  }
@@ -5,6 +5,7 @@ import {
5
5
  logInboundDrop,
6
6
  recordPendingHistoryEntryIfEnabled,
7
7
  resolveControlCommandGate,
8
+ resolveDefaultGroupPolicy,
8
9
  resolveMentionGating,
9
10
  formatAllowlistMatchMeta,
10
11
  type HistoryEntry,
@@ -124,16 +125,17 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
124
125
 
125
126
  const senderName = from.name ?? from.id;
126
127
  const senderId = from.aadObjectId ?? from.id;
127
- const storedAllowFrom = await core.channel.pairing
128
- .readAllowFromStore("msteams")
129
- .catch(() => []);
128
+ const dmPolicy = msteamsCfg?.dmPolicy ?? "pairing";
129
+ const storedAllowFrom =
130
+ dmPolicy === "allowlist"
131
+ ? []
132
+ : await core.channel.pairing.readAllowFromStore("msteams").catch(() => []);
130
133
  const useAccessGroups = cfg.commands?.useAccessGroups !== false;
131
134
 
132
135
  // Check DM policy for direct messages.
133
136
  const dmAllowFrom = msteamsCfg?.allowFrom ?? [];
134
137
  const effectiveDmAllowFrom = [...dmAllowFrom.map((v) => String(v)), ...storedAllowFrom];
135
138
  if (isDirectMessage && msteamsCfg) {
136
- const dmPolicy = msteamsCfg.dmPolicy ?? "pairing";
137
139
  const allowFrom = dmAllowFrom;
138
140
 
139
141
  if (dmPolicy === "disabled") {
@@ -173,7 +175,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
173
175
  }
174
176
  }
175
177
 
176
- const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
178
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
177
179
  const groupPolicy =
178
180
  !isDirectMessage && msteamsCfg
179
181
  ? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist")
package/src/probe.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { BaseProbeResult, MSTeamsConfig } from "openclaw/plugin-sdk";
2
2
  import { formatUnknownError } from "./errors.js";
3
3
  import { loadMSTeamsSdkWithAuth } from "./sdk.js";
4
+ import { readAccessToken } from "./token-response.js";
4
5
  import { resolveMSTeamsCredentials } from "./token.js";
5
6
 
6
7
  export type ProbeMSTeamsResult = BaseProbeResult<string> & {
@@ -13,18 +14,6 @@ export type ProbeMSTeamsResult = BaseProbeResult<string> & {
13
14
  };
14
15
  };
15
16
 
16
- function readAccessToken(value: unknown): string | null {
17
- if (typeof value === "string") {
18
- return value;
19
- }
20
- if (value && typeof value === "object") {
21
- const token =
22
- (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
23
- return typeof token === "string" ? token : null;
24
- }
25
- return null;
26
- }
27
-
28
17
  function decodeJwtPayload(token: string): Record<string, unknown> | null {
29
18
  const parts = token.split(".");
30
19
  if (parts.length < 2) {