@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.
- package/CHANGELOG.md +27 -0
- package/index.ts +0 -1
- package/openclaw.plugin.json +1 -3
- package/package.json +13 -10
- package/src/attachments/download.ts +98 -21
- package/src/attachments/graph.ts +50 -16
- package/src/attachments/html.ts +23 -9
- package/src/attachments/shared.ts +74 -18
- package/src/attachments.test.ts +37 -2
- package/src/channel.directory.test.ts +7 -5
- package/src/channel.ts +46 -23
- package/src/conversation-store-fs.test.ts +7 -8
- package/src/conversation-store-fs.ts +15 -5
- package/src/conversation-store-memory.ts +3 -1
- package/src/directory-live.ts +41 -15
- package/src/errors.test.ts +0 -1
- package/src/errors.ts +48 -16
- package/src/file-consent-helpers.test.ts +12 -3
- package/src/file-consent.ts +6 -2
- package/src/graph-chat.ts +5 -4
- package/src/graph-upload.ts +23 -15
- package/src/inbound.test.ts +0 -1
- package/src/inbound.ts +15 -5
- package/src/media-helpers.test.ts +9 -6
- package/src/media-helpers.ts +15 -6
- package/src/messenger.test.ts +7 -4
- package/src/messenger.ts +55 -20
- package/src/monitor-handler/inbound-media.ts +7 -2
- package/src/monitor-handler/message-handler.ts +66 -55
- package/src/monitor-handler.ts +3 -7
- package/src/monitor.ts +19 -14
- package/src/onboarding.ts +10 -11
- package/src/outbound.ts +0 -1
- package/src/pending-uploads.ts +7 -5
- package/src/policy.test.ts +1 -2
- package/src/policy.ts +39 -13
- package/src/polls-store-memory.ts +3 -1
- package/src/polls-store.test.ts +1 -3
- package/src/polls.test.ts +5 -6
- package/src/polls.ts +24 -9
- package/src/probe.test.ts +4 -3
- package/src/probe.ts +18 -10
- package/src/reply-dispatcher.ts +5 -3
- package/src/resolve-allowlist.ts +39 -19
- package/src/send-context.ts +12 -4
- package/src/send.ts +49 -19
- package/src/sent-message-cache.test.ts +0 -1
- package/src/sent-message-cache.ts +9 -3
- package/src/storage.ts +6 -3
- 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")
|
|
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/"))
|
|
82
|
-
|
|
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}`))
|
|
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))
|
|
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))
|
|
128
|
-
|
|
129
|
-
|
|
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)
|
|
168
|
+
if (!match) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
144
171
|
const contentType = match[1]?.toLowerCase();
|
|
145
172
|
const isBase64 = Boolean(match[2]);
|
|
146
|
-
if (!isBase64)
|
|
173
|
+
if (!isBase64) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
147
176
|
const payload = match[3] ?? "";
|
|
148
|
-
if (!payload)
|
|
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)
|
|
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)
|
|
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)
|
|
208
|
-
|
|
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("*"))
|
|
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("*"))
|
|
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:")
|
|
284
|
+
if (parsed.protocol !== "https:") {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
231
287
|
return isHostAllowed(parsed.hostname, allowlist);
|
|
232
288
|
} catch {
|
|
233
289
|
return false;
|
package/src/attachments.test.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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")
|
|
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)
|
|
157
|
-
|
|
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 !== "*")
|
|
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)
|
|
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:"))
|
|
188
|
-
|
|
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 !== "*")
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
-
|
|
17
|
-
|
|
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).
|
|
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).
|
|
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)
|
|
19
|
+
if (!value) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
20
22
|
const parsed = Date.parse(value);
|
|
21
|
-
if (!Number.isFinite(parsed))
|
|
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)
|
|
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)
|
|
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))
|
|
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)
|
|
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 };
|
package/src/directory-live.ts
CHANGED
|
@@ -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")
|
|
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
|
-
...
|
|
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(
|
|
64
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
|
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)
|
|
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)
|
|
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)
|
|
166
|
-
|
|
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)
|
|
198
|
+
if (results.length >= limit) {
|
|
199
|
+
return results;
|
|
200
|
+
}
|
|
175
201
|
}
|
|
176
202
|
}
|
|
177
203
|
|