@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.
- package/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/attachments/download.ts +67 -68
- package/src/attachments/graph.ts +29 -28
- package/src/attachments/remote-media.ts +42 -0
- package/src/attachments/shared.test.ts +279 -0
- package/src/attachments/shared.ts +113 -0
- package/src/attachments.test.ts +126 -18
- package/src/channel.ts +8 -2
- package/src/directory-live.ts +2 -20
- package/src/graph-users.test.ts +66 -0
- package/src/graph-users.ts +29 -0
- package/src/graph.ts +1 -12
- package/src/messenger.ts +10 -21
- package/src/monitor-handler/message-handler.ts +7 -5
- package/src/probe.ts +1 -12
- package/src/resolve-allowlist.ts +2 -20
- package/src/token-response.test.ts +23 -0
- package/src/token-response.ts +11 -0
|
@@ -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
|
+
}
|
package/src/attachments.test.ts
CHANGED
|
@@ -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).
|
|
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).
|
|
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).
|
|
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).
|
|
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
|
|
226
|
-
|
|
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
|
|
259
|
-
|
|
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).
|
|
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
|
|
131
|
-
const
|
|
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
|
}
|
package/src/directory-live.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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) {
|