@openclaw/msteams 2026.1.29
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 +56 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +36 -0
- package/src/attachments/download.ts +206 -0
- package/src/attachments/graph.ts +319 -0
- package/src/attachments/html.ts +76 -0
- package/src/attachments/payload.ts +22 -0
- package/src/attachments/shared.ts +235 -0
- package/src/attachments/types.ts +37 -0
- package/src/attachments.test.ts +424 -0
- package/src/attachments.ts +18 -0
- package/src/channel.directory.test.ts +46 -0
- package/src/channel.ts +436 -0
- package/src/conversation-store-fs.test.ts +89 -0
- package/src/conversation-store-fs.ts +155 -0
- package/src/conversation-store-memory.ts +45 -0
- package/src/conversation-store.ts +41 -0
- package/src/directory-live.ts +179 -0
- package/src/errors.test.ts +46 -0
- package/src/errors.ts +158 -0
- package/src/file-consent-helpers.test.ts +234 -0
- package/src/file-consent-helpers.ts +73 -0
- package/src/file-consent.ts +122 -0
- package/src/graph-chat.ts +52 -0
- package/src/graph-upload.ts +445 -0
- package/src/inbound.test.ts +67 -0
- package/src/inbound.ts +38 -0
- package/src/index.ts +4 -0
- package/src/media-helpers.test.ts +186 -0
- package/src/media-helpers.ts +77 -0
- package/src/messenger.test.ts +245 -0
- package/src/messenger.ts +460 -0
- package/src/monitor-handler/inbound-media.ts +123 -0
- package/src/monitor-handler/message-handler.ts +629 -0
- package/src/monitor-handler.ts +166 -0
- package/src/monitor-types.ts +5 -0
- package/src/monitor.ts +290 -0
- package/src/onboarding.ts +432 -0
- package/src/outbound.ts +47 -0
- package/src/pending-uploads.ts +87 -0
- package/src/policy.test.ts +210 -0
- package/src/policy.ts +247 -0
- package/src/polls-store-memory.ts +30 -0
- package/src/polls-store.test.ts +40 -0
- package/src/polls.test.ts +73 -0
- package/src/polls.ts +300 -0
- package/src/probe.test.ts +57 -0
- package/src/probe.ts +99 -0
- package/src/reply-dispatcher.ts +128 -0
- package/src/resolve-allowlist.ts +277 -0
- package/src/runtime.ts +14 -0
- package/src/sdk-types.ts +19 -0
- package/src/sdk.ts +33 -0
- package/src/send-context.ts +156 -0
- package/src/send.ts +489 -0
- package/src/sent-message-cache.test.ts +16 -0
- package/src/sent-message-cache.ts +41 -0
- package/src/storage.ts +22 -0
- package/src/store-fs.ts +80 -0
- package/src/token.ts +19 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import type { ChannelMessageActionName, ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import {
|
|
3
|
+
buildChannelConfigSchema,
|
|
4
|
+
DEFAULT_ACCOUNT_ID,
|
|
5
|
+
MSTeamsConfigSchema,
|
|
6
|
+
PAIRING_APPROVED_MESSAGE,
|
|
7
|
+
} from "openclaw/plugin-sdk";
|
|
8
|
+
|
|
9
|
+
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
|
10
|
+
import { msteamsOutbound } from "./outbound.js";
|
|
11
|
+
import { probeMSTeams } from "./probe.js";
|
|
12
|
+
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
|
|
13
|
+
import {
|
|
14
|
+
normalizeMSTeamsMessagingTarget,
|
|
15
|
+
normalizeMSTeamsUserInput,
|
|
16
|
+
parseMSTeamsConversationId,
|
|
17
|
+
parseMSTeamsTeamChannelInput,
|
|
18
|
+
resolveMSTeamsChannelAllowlist,
|
|
19
|
+
resolveMSTeamsUserAllowlist,
|
|
20
|
+
} from "./resolve-allowlist.js";
|
|
21
|
+
import { sendAdaptiveCardMSTeams, sendMessageMSTeams } from "./send.js";
|
|
22
|
+
import { resolveMSTeamsCredentials } from "./token.js";
|
|
23
|
+
import {
|
|
24
|
+
listMSTeamsDirectoryGroupsLive,
|
|
25
|
+
listMSTeamsDirectoryPeersLive,
|
|
26
|
+
} from "./directory-live.js";
|
|
27
|
+
|
|
28
|
+
type ResolvedMSTeamsAccount = {
|
|
29
|
+
accountId: string;
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
configured: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const meta = {
|
|
35
|
+
id: "msteams",
|
|
36
|
+
label: "Microsoft Teams",
|
|
37
|
+
selectionLabel: "Microsoft Teams (Bot Framework)",
|
|
38
|
+
docsPath: "/channels/msteams",
|
|
39
|
+
docsLabel: "msteams",
|
|
40
|
+
blurb: "Bot Framework; enterprise support.",
|
|
41
|
+
aliases: ["teams"],
|
|
42
|
+
order: 60,
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|
46
|
+
id: "msteams",
|
|
47
|
+
meta: {
|
|
48
|
+
...meta,
|
|
49
|
+
},
|
|
50
|
+
onboarding: msteamsOnboardingAdapter,
|
|
51
|
+
pairing: {
|
|
52
|
+
idLabel: "msteamsUserId",
|
|
53
|
+
normalizeAllowEntry: (entry) => entry.replace(/^(msteams|user):/i, ""),
|
|
54
|
+
notifyApproval: async ({ cfg, id }) => {
|
|
55
|
+
await sendMessageMSTeams({
|
|
56
|
+
cfg,
|
|
57
|
+
to: id,
|
|
58
|
+
text: PAIRING_APPROVED_MESSAGE,
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
capabilities: {
|
|
63
|
+
chatTypes: ["direct", "channel", "thread"],
|
|
64
|
+
polls: true,
|
|
65
|
+
threads: true,
|
|
66
|
+
media: true,
|
|
67
|
+
},
|
|
68
|
+
agentPrompt: {
|
|
69
|
+
messageToolHints: () => [
|
|
70
|
+
"- Adaptive Cards supported. Use `action=send` with `card={type,version,body}` to send rich cards.",
|
|
71
|
+
"- MSTeams targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:ID` or `user:Display Name` (requires Graph API) for DMs, `conversation:19:...@thread.tacv2` for groups/channels. Prefer IDs over display names for speed.",
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
threading: {
|
|
75
|
+
buildToolContext: ({ context, hasRepliedRef }) => ({
|
|
76
|
+
currentChannelId: context.To?.trim() || undefined,
|
|
77
|
+
currentThreadTs: context.ReplyToId,
|
|
78
|
+
hasRepliedRef,
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
groups: {
|
|
82
|
+
resolveToolPolicy: resolveMSTeamsGroupToolPolicy,
|
|
83
|
+
},
|
|
84
|
+
reload: { configPrefixes: ["channels.msteams"] },
|
|
85
|
+
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
|
|
86
|
+
config: {
|
|
87
|
+
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
|
88
|
+
resolveAccount: (cfg) => ({
|
|
89
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
90
|
+
enabled: cfg.channels?.msteams?.enabled !== false,
|
|
91
|
+
configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
|
|
92
|
+
}),
|
|
93
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
94
|
+
setAccountEnabled: ({ cfg, enabled }) => ({
|
|
95
|
+
...cfg,
|
|
96
|
+
channels: {
|
|
97
|
+
...cfg.channels,
|
|
98
|
+
msteams: {
|
|
99
|
+
...cfg.channels?.msteams,
|
|
100
|
+
enabled,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
deleteAccount: ({ cfg }) => {
|
|
105
|
+
const next = { ...cfg } as OpenClawConfig;
|
|
106
|
+
const nextChannels = { ...cfg.channels };
|
|
107
|
+
delete nextChannels.msteams;
|
|
108
|
+
if (Object.keys(nextChannels).length > 0) {
|
|
109
|
+
next.channels = nextChannels;
|
|
110
|
+
} else {
|
|
111
|
+
delete next.channels;
|
|
112
|
+
}
|
|
113
|
+
return next;
|
|
114
|
+
},
|
|
115
|
+
isConfigured: (_account, cfg) => Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
|
|
116
|
+
describeAccount: (account) => ({
|
|
117
|
+
accountId: account.accountId,
|
|
118
|
+
enabled: account.enabled,
|
|
119
|
+
configured: account.configured,
|
|
120
|
+
}),
|
|
121
|
+
resolveAllowFrom: ({ cfg }) => cfg.channels?.msteams?.allowFrom ?? [],
|
|
122
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
123
|
+
allowFrom
|
|
124
|
+
.map((entry) => String(entry).trim())
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.map((entry) => entry.toLowerCase()),
|
|
127
|
+
},
|
|
128
|
+
security: {
|
|
129
|
+
collectWarnings: ({ cfg }) => {
|
|
130
|
+
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
|
131
|
+
const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
|
132
|
+
if (groupPolicy !== "open") return [];
|
|
133
|
+
return [
|
|
134
|
+
`- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.`,
|
|
135
|
+
];
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
setup: {
|
|
139
|
+
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
140
|
+
applyAccountConfig: ({ cfg }) => ({
|
|
141
|
+
...cfg,
|
|
142
|
+
channels: {
|
|
143
|
+
...cfg.channels,
|
|
144
|
+
msteams: {
|
|
145
|
+
...cfg.channels?.msteams,
|
|
146
|
+
enabled: true,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
},
|
|
151
|
+
messaging: {
|
|
152
|
+
normalizeTarget: normalizeMSTeamsMessagingTarget,
|
|
153
|
+
targetResolver: {
|
|
154
|
+
looksLikeId: (raw) => {
|
|
155
|
+
const trimmed = raw.trim();
|
|
156
|
+
if (!trimmed) return false;
|
|
157
|
+
if (/^conversation:/i.test(trimmed)) return true;
|
|
158
|
+
if (/^user:/i.test(trimmed)) {
|
|
159
|
+
// Only treat as ID if the value after user: looks like a UUID
|
|
160
|
+
const id = trimmed.slice("user:".length).trim();
|
|
161
|
+
return /^[0-9a-fA-F-]{16,}$/.test(id);
|
|
162
|
+
}
|
|
163
|
+
return trimmed.includes("@thread");
|
|
164
|
+
},
|
|
165
|
+
hint: "<conversationId|user:ID|conversation:ID>",
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
directory: {
|
|
169
|
+
self: async () => null,
|
|
170
|
+
listPeers: async ({ cfg, query, limit }) => {
|
|
171
|
+
const q = query?.trim().toLowerCase() || "";
|
|
172
|
+
const ids = new Set<string>();
|
|
173
|
+
for (const entry of cfg.channels?.msteams?.allowFrom ?? []) {
|
|
174
|
+
const trimmed = String(entry).trim();
|
|
175
|
+
if (trimmed && trimmed !== "*") ids.add(trimmed);
|
|
176
|
+
}
|
|
177
|
+
for (const userId of Object.keys(cfg.channels?.msteams?.dms ?? {})) {
|
|
178
|
+
const trimmed = userId.trim();
|
|
179
|
+
if (trimmed) ids.add(trimmed);
|
|
180
|
+
}
|
|
181
|
+
return Array.from(ids)
|
|
182
|
+
.map((raw) => raw.trim())
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
.map((raw) => normalizeMSTeamsMessagingTarget(raw) ?? raw)
|
|
185
|
+
.map((raw) => {
|
|
186
|
+
const lowered = raw.toLowerCase();
|
|
187
|
+
if (lowered.startsWith("user:")) return raw;
|
|
188
|
+
if (lowered.startsWith("conversation:")) return raw;
|
|
189
|
+
return `user:${raw}`;
|
|
190
|
+
})
|
|
191
|
+
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
|
192
|
+
.slice(0, limit && limit > 0 ? limit : undefined)
|
|
193
|
+
.map((id) => ({ kind: "user", id }) as const);
|
|
194
|
+
},
|
|
195
|
+
listGroups: async ({ cfg, query, limit }) => {
|
|
196
|
+
const q = query?.trim().toLowerCase() || "";
|
|
197
|
+
const ids = new Set<string>();
|
|
198
|
+
for (const team of Object.values(cfg.channels?.msteams?.teams ?? {})) {
|
|
199
|
+
for (const channelId of Object.keys(team.channels ?? {})) {
|
|
200
|
+
const trimmed = channelId.trim();
|
|
201
|
+
if (trimmed && trimmed !== "*") ids.add(trimmed);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return Array.from(ids)
|
|
205
|
+
.map((raw) => raw.trim())
|
|
206
|
+
.filter(Boolean)
|
|
207
|
+
.map((raw) => raw.replace(/^conversation:/i, "").trim())
|
|
208
|
+
.map((id) => `conversation:${id}`)
|
|
209
|
+
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
|
210
|
+
.slice(0, limit && limit > 0 ? limit : undefined)
|
|
211
|
+
.map((id) => ({ kind: "group", id }) as const);
|
|
212
|
+
},
|
|
213
|
+
listPeersLive: async ({ cfg, query, limit }) =>
|
|
214
|
+
listMSTeamsDirectoryPeersLive({ cfg, query, limit }),
|
|
215
|
+
listGroupsLive: async ({ cfg, query, limit }) =>
|
|
216
|
+
listMSTeamsDirectoryGroupsLive({ cfg, query, limit }),
|
|
217
|
+
},
|
|
218
|
+
resolver: {
|
|
219
|
+
resolveTargets: async ({ cfg, inputs, kind, runtime }) => {
|
|
220
|
+
const results = inputs.map((input) => ({
|
|
221
|
+
input,
|
|
222
|
+
resolved: false,
|
|
223
|
+
id: undefined as string | undefined,
|
|
224
|
+
name: undefined as string | undefined,
|
|
225
|
+
note: undefined as string | undefined,
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
const stripPrefix = (value: string) =>
|
|
229
|
+
normalizeMSTeamsUserInput(value);
|
|
230
|
+
|
|
231
|
+
if (kind === "user") {
|
|
232
|
+
const pending: Array<{ input: string; query: string; index: number }> = [];
|
|
233
|
+
results.forEach((entry, index) => {
|
|
234
|
+
const trimmed = entry.input.trim();
|
|
235
|
+
if (!trimmed) {
|
|
236
|
+
entry.note = "empty input";
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const cleaned = stripPrefix(trimmed);
|
|
240
|
+
if (/^[0-9a-fA-F-]{16,}$/.test(cleaned) || cleaned.includes("@")) {
|
|
241
|
+
entry.resolved = true;
|
|
242
|
+
entry.id = cleaned;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
pending.push({ input: entry.input, query: cleaned, index });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (pending.length > 0) {
|
|
249
|
+
try {
|
|
250
|
+
const resolved = await resolveMSTeamsUserAllowlist({
|
|
251
|
+
cfg,
|
|
252
|
+
entries: pending.map((entry) => entry.query),
|
|
253
|
+
});
|
|
254
|
+
resolved.forEach((entry, idx) => {
|
|
255
|
+
const target = results[pending[idx]?.index ?? -1];
|
|
256
|
+
if (!target) return;
|
|
257
|
+
target.resolved = entry.resolved;
|
|
258
|
+
target.id = entry.id;
|
|
259
|
+
target.name = entry.name;
|
|
260
|
+
target.note = entry.note;
|
|
261
|
+
});
|
|
262
|
+
} catch (err) {
|
|
263
|
+
runtime.error?.(`msteams resolve failed: ${String(err)}`);
|
|
264
|
+
pending.forEach(({ index }) => {
|
|
265
|
+
const entry = results[index];
|
|
266
|
+
if (entry) entry.note = "lookup failed";
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return results;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const pending: Array<{ input: string; query: string; index: number }> = [];
|
|
275
|
+
results.forEach((entry, index) => {
|
|
276
|
+
const trimmed = entry.input.trim();
|
|
277
|
+
if (!trimmed) {
|
|
278
|
+
entry.note = "empty input";
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const conversationId = parseMSTeamsConversationId(trimmed);
|
|
282
|
+
if (conversationId !== null) {
|
|
283
|
+
entry.resolved = Boolean(conversationId);
|
|
284
|
+
entry.id = conversationId || undefined;
|
|
285
|
+
entry.note = conversationId ? "conversation id" : "empty conversation id";
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const parsed = parseMSTeamsTeamChannelInput(trimmed);
|
|
289
|
+
if (!parsed.team) {
|
|
290
|
+
entry.note = "missing team";
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const query = parsed.channel ? `${parsed.team}/${parsed.channel}` : parsed.team;
|
|
294
|
+
pending.push({ input: entry.input, query, index });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (pending.length > 0) {
|
|
298
|
+
try {
|
|
299
|
+
const resolved = await resolveMSTeamsChannelAllowlist({
|
|
300
|
+
cfg,
|
|
301
|
+
entries: pending.map((entry) => entry.query),
|
|
302
|
+
});
|
|
303
|
+
resolved.forEach((entry, idx) => {
|
|
304
|
+
const target = results[pending[idx]?.index ?? -1];
|
|
305
|
+
if (!target) return;
|
|
306
|
+
if (!entry.resolved || !entry.teamId) {
|
|
307
|
+
target.resolved = false;
|
|
308
|
+
target.note = entry.note;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
target.resolved = true;
|
|
312
|
+
if (entry.channelId) {
|
|
313
|
+
target.id = `${entry.teamId}/${entry.channelId}`;
|
|
314
|
+
target.name =
|
|
315
|
+
entry.channelName && entry.teamName
|
|
316
|
+
? `${entry.teamName}/${entry.channelName}`
|
|
317
|
+
: entry.channelName ?? entry.teamName;
|
|
318
|
+
} else {
|
|
319
|
+
target.id = entry.teamId;
|
|
320
|
+
target.name = entry.teamName;
|
|
321
|
+
target.note = "team id";
|
|
322
|
+
}
|
|
323
|
+
if (entry.note) target.note = entry.note;
|
|
324
|
+
});
|
|
325
|
+
} catch (err) {
|
|
326
|
+
runtime.error?.(`msteams resolve failed: ${String(err)}`);
|
|
327
|
+
pending.forEach(({ index }) => {
|
|
328
|
+
const entry = results[index];
|
|
329
|
+
if (entry) entry.note = "lookup failed";
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return results;
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
actions: {
|
|
338
|
+
listActions: ({ cfg }) => {
|
|
339
|
+
const enabled =
|
|
340
|
+
cfg.channels?.msteams?.enabled !== false &&
|
|
341
|
+
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams));
|
|
342
|
+
if (!enabled) return [];
|
|
343
|
+
return ["poll"] satisfies ChannelMessageActionName[];
|
|
344
|
+
},
|
|
345
|
+
supportsCards: ({ cfg }) => {
|
|
346
|
+
return (
|
|
347
|
+
cfg.channels?.msteams?.enabled !== false &&
|
|
348
|
+
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams))
|
|
349
|
+
);
|
|
350
|
+
},
|
|
351
|
+
handleAction: async (ctx) => {
|
|
352
|
+
// Handle send action with card parameter
|
|
353
|
+
if (ctx.action === "send" && ctx.params.card) {
|
|
354
|
+
const card = ctx.params.card as Record<string, unknown>;
|
|
355
|
+
const to =
|
|
356
|
+
typeof ctx.params.to === "string"
|
|
357
|
+
? ctx.params.to.trim()
|
|
358
|
+
: typeof ctx.params.target === "string"
|
|
359
|
+
? ctx.params.target.trim()
|
|
360
|
+
: "";
|
|
361
|
+
if (!to) {
|
|
362
|
+
return {
|
|
363
|
+
isError: true,
|
|
364
|
+
content: [{ type: "text", text: "Card send requires a target (to)." }],
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const result = await sendAdaptiveCardMSTeams({
|
|
368
|
+
cfg: ctx.cfg,
|
|
369
|
+
to,
|
|
370
|
+
card,
|
|
371
|
+
});
|
|
372
|
+
return {
|
|
373
|
+
content: [
|
|
374
|
+
{
|
|
375
|
+
type: "text",
|
|
376
|
+
text: JSON.stringify({
|
|
377
|
+
ok: true,
|
|
378
|
+
channel: "msteams",
|
|
379
|
+
messageId: result.messageId,
|
|
380
|
+
conversationId: result.conversationId,
|
|
381
|
+
}),
|
|
382
|
+
},
|
|
383
|
+
],
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
// Return null to fall through to default handler
|
|
387
|
+
return null as never;
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
outbound: msteamsOutbound,
|
|
391
|
+
status: {
|
|
392
|
+
defaultRuntime: {
|
|
393
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
394
|
+
running: false,
|
|
395
|
+
lastStartAt: null,
|
|
396
|
+
lastStopAt: null,
|
|
397
|
+
lastError: null,
|
|
398
|
+
port: null,
|
|
399
|
+
},
|
|
400
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
401
|
+
configured: snapshot.configured ?? false,
|
|
402
|
+
running: snapshot.running ?? false,
|
|
403
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
404
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
405
|
+
lastError: snapshot.lastError ?? null,
|
|
406
|
+
port: snapshot.port ?? null,
|
|
407
|
+
probe: snapshot.probe,
|
|
408
|
+
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
409
|
+
}),
|
|
410
|
+
probeAccount: async ({ cfg }) => await probeMSTeams(cfg.channels?.msteams),
|
|
411
|
+
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
412
|
+
accountId: account.accountId,
|
|
413
|
+
enabled: account.enabled,
|
|
414
|
+
configured: account.configured,
|
|
415
|
+
running: runtime?.running ?? false,
|
|
416
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
417
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
418
|
+
lastError: runtime?.lastError ?? null,
|
|
419
|
+
port: runtime?.port ?? null,
|
|
420
|
+
probe,
|
|
421
|
+
}),
|
|
422
|
+
},
|
|
423
|
+
gateway: {
|
|
424
|
+
startAccount: async (ctx) => {
|
|
425
|
+
const { monitorMSTeamsProvider } = await import("./index.js");
|
|
426
|
+
const port = ctx.cfg.channels?.msteams?.webhook?.port ?? 3978;
|
|
427
|
+
ctx.setStatus({ accountId: ctx.accountId, port });
|
|
428
|
+
ctx.log?.info(`starting provider (port ${port})`);
|
|
429
|
+
return monitorMSTeamsProvider({
|
|
430
|
+
cfg: ctx.cfg,
|
|
431
|
+
runtime: ctx.runtime,
|
|
432
|
+
abortSignal: ctx.abortSignal,
|
|
433
|
+
});
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
8
|
+
import type { StoredConversationReference } from "./conversation-store.js";
|
|
9
|
+
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
|
10
|
+
import { setMSTeamsRuntime } from "./runtime.js";
|
|
11
|
+
|
|
12
|
+
const runtimeStub = {
|
|
13
|
+
state: {
|
|
14
|
+
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;
|
|
18
|
+
const resolvedHome = homedir ? homedir() : os.homedir();
|
|
19
|
+
return path.join(resolvedHome, ".openclaw");
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
} as unknown as PluginRuntime;
|
|
23
|
+
|
|
24
|
+
describe("msteams conversation store (fs)", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
setMSTeamsRuntime(runtimeStub);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("filters and prunes expired entries (but keeps legacy ones)", async () => {
|
|
30
|
+
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-store-"));
|
|
31
|
+
|
|
32
|
+
const env: NodeJS.ProcessEnv = {
|
|
33
|
+
...process.env,
|
|
34
|
+
OPENCLAW_STATE_DIR: stateDir,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const store = createMSTeamsConversationStoreFs({ env, ttlMs: 1_000 });
|
|
38
|
+
|
|
39
|
+
const ref: StoredConversationReference = {
|
|
40
|
+
conversation: { id: "19:active@thread.tacv2" },
|
|
41
|
+
channelId: "msteams",
|
|
42
|
+
serviceUrl: "https://service.example.com",
|
|
43
|
+
user: { id: "u1", aadObjectId: "aad1" },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
await store.upsert("19:active@thread.tacv2", ref);
|
|
47
|
+
|
|
48
|
+
const filePath = path.join(stateDir, "msteams-conversations.json");
|
|
49
|
+
const raw = await fs.promises.readFile(filePath, "utf-8");
|
|
50
|
+
const json = JSON.parse(raw) as {
|
|
51
|
+
version: number;
|
|
52
|
+
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
json.conversations["19:old@thread.tacv2"] = {
|
|
56
|
+
...ref,
|
|
57
|
+
conversation: { id: "19:old@thread.tacv2" },
|
|
58
|
+
lastSeenAt: new Date(Date.now() - 60_000).toISOString(),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Legacy entry without lastSeenAt should be preserved.
|
|
62
|
+
json.conversations["19:legacy@thread.tacv2"] = {
|
|
63
|
+
...ref,
|
|
64
|
+
conversation: { id: "19:legacy@thread.tacv2" },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
await fs.promises.writeFile(filePath, `${JSON.stringify(json, null, 2)}\n`);
|
|
68
|
+
|
|
69
|
+
const list = await store.list();
|
|
70
|
+
const ids = list.map((e) => e.conversationId).sort();
|
|
71
|
+
expect(ids).toEqual(["19:active@thread.tacv2", "19:legacy@thread.tacv2"]);
|
|
72
|
+
|
|
73
|
+
expect(await store.get("19:old@thread.tacv2")).toBeNull();
|
|
74
|
+
expect(await store.get("19:legacy@thread.tacv2")).not.toBeNull();
|
|
75
|
+
|
|
76
|
+
await store.upsert("19:new@thread.tacv2", {
|
|
77
|
+
...ref,
|
|
78
|
+
conversation: { id: "19:new@thread.tacv2" },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const rawAfter = await fs.promises.readFile(filePath, "utf-8");
|
|
82
|
+
const jsonAfter = JSON.parse(rawAfter) as typeof json;
|
|
83
|
+
expect(Object.keys(jsonAfter.conversations).sort()).toEqual([
|
|
84
|
+
"19:active@thread.tacv2",
|
|
85
|
+
"19:legacy@thread.tacv2",
|
|
86
|
+
"19:new@thread.tacv2",
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MSTeamsConversationStore,
|
|
3
|
+
MSTeamsConversationStoreEntry,
|
|
4
|
+
StoredConversationReference,
|
|
5
|
+
} from "./conversation-store.js";
|
|
6
|
+
import { resolveMSTeamsStorePath } from "./storage.js";
|
|
7
|
+
import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js";
|
|
8
|
+
|
|
9
|
+
type ConversationStoreData = {
|
|
10
|
+
version: 1;
|
|
11
|
+
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const STORE_FILENAME = "msteams-conversations.json";
|
|
15
|
+
const MAX_CONVERSATIONS = 1000;
|
|
16
|
+
const CONVERSATION_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
function parseTimestamp(value: string | undefined): number | null {
|
|
19
|
+
if (!value) return null;
|
|
20
|
+
const parsed = Date.parse(value);
|
|
21
|
+
if (!Number.isFinite(parsed)) return null;
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pruneToLimit(
|
|
26
|
+
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>,
|
|
27
|
+
) {
|
|
28
|
+
const entries = Object.entries(conversations);
|
|
29
|
+
if (entries.length <= MAX_CONVERSATIONS) return conversations;
|
|
30
|
+
|
|
31
|
+
entries.sort((a, b) => {
|
|
32
|
+
const aTs = parseTimestamp(a[1].lastSeenAt) ?? 0;
|
|
33
|
+
const bTs = parseTimestamp(b[1].lastSeenAt) ?? 0;
|
|
34
|
+
return aTs - bTs;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const keep = entries.slice(entries.length - MAX_CONVERSATIONS);
|
|
38
|
+
return Object.fromEntries(keep);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function pruneExpired(
|
|
42
|
+
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>,
|
|
43
|
+
nowMs: number,
|
|
44
|
+
ttlMs: number,
|
|
45
|
+
) {
|
|
46
|
+
let removed = false;
|
|
47
|
+
const kept: typeof conversations = {};
|
|
48
|
+
for (const [conversationId, reference] of Object.entries(conversations)) {
|
|
49
|
+
const lastSeenAt = parseTimestamp(reference.lastSeenAt);
|
|
50
|
+
// Preserve legacy entries that have no lastSeenAt until they're seen again.
|
|
51
|
+
if (lastSeenAt != null && nowMs - lastSeenAt > ttlMs) {
|
|
52
|
+
removed = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
kept[conversationId] = reference;
|
|
56
|
+
}
|
|
57
|
+
return { conversations: kept, removed };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeConversationId(raw: string): string {
|
|
61
|
+
return raw.split(";")[0] ?? raw;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createMSTeamsConversationStoreFs(params?: {
|
|
65
|
+
env?: NodeJS.ProcessEnv;
|
|
66
|
+
homedir?: () => string;
|
|
67
|
+
ttlMs?: number;
|
|
68
|
+
stateDir?: string;
|
|
69
|
+
storePath?: string;
|
|
70
|
+
}): MSTeamsConversationStore {
|
|
71
|
+
const ttlMs = params?.ttlMs ?? CONVERSATION_TTL_MS;
|
|
72
|
+
const filePath = resolveMSTeamsStorePath({
|
|
73
|
+
filename: STORE_FILENAME,
|
|
74
|
+
env: params?.env,
|
|
75
|
+
homedir: params?.homedir,
|
|
76
|
+
stateDir: params?.stateDir,
|
|
77
|
+
storePath: params?.storePath,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const empty: ConversationStoreData = { version: 1, conversations: {} };
|
|
81
|
+
|
|
82
|
+
const readStore = async (): Promise<ConversationStoreData> => {
|
|
83
|
+
const { value } = await readJsonFile<ConversationStoreData>(filePath, empty);
|
|
84
|
+
if (
|
|
85
|
+
value.version !== 1 ||
|
|
86
|
+
!value.conversations ||
|
|
87
|
+
typeof value.conversations !== "object" ||
|
|
88
|
+
Array.isArray(value.conversations)
|
|
89
|
+
) {
|
|
90
|
+
return empty;
|
|
91
|
+
}
|
|
92
|
+
const nowMs = Date.now();
|
|
93
|
+
const pruned = pruneExpired(value.conversations, nowMs, ttlMs).conversations;
|
|
94
|
+
return { version: 1, conversations: pruneToLimit(pruned) };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const list = async (): Promise<MSTeamsConversationStoreEntry[]> => {
|
|
98
|
+
const store = await readStore();
|
|
99
|
+
return Object.entries(store.conversations).map(([conversationId, reference]) => ({
|
|
100
|
+
conversationId,
|
|
101
|
+
reference,
|
|
102
|
+
}));
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const get = async (conversationId: string): Promise<StoredConversationReference | null> => {
|
|
106
|
+
const store = await readStore();
|
|
107
|
+
return store.conversations[normalizeConversationId(conversationId)] ?? null;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const findByUserId = async (id: string): Promise<MSTeamsConversationStoreEntry | null> => {
|
|
111
|
+
const target = id.trim();
|
|
112
|
+
if (!target) return null;
|
|
113
|
+
for (const entry of await list()) {
|
|
114
|
+
const { conversationId, reference } = entry;
|
|
115
|
+
if (reference.user?.aadObjectId === target) {
|
|
116
|
+
return { conversationId, reference };
|
|
117
|
+
}
|
|
118
|
+
if (reference.user?.id === target) {
|
|
119
|
+
return { conversationId, reference };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const upsert = async (
|
|
126
|
+
conversationId: string,
|
|
127
|
+
reference: StoredConversationReference,
|
|
128
|
+
): Promise<void> => {
|
|
129
|
+
const normalizedId = normalizeConversationId(conversationId);
|
|
130
|
+
await withFileLock(filePath, empty, async () => {
|
|
131
|
+
const store = await readStore();
|
|
132
|
+
store.conversations[normalizedId] = {
|
|
133
|
+
...reference,
|
|
134
|
+
lastSeenAt: new Date().toISOString(),
|
|
135
|
+
};
|
|
136
|
+
const nowMs = Date.now();
|
|
137
|
+
store.conversations = pruneExpired(store.conversations, nowMs, ttlMs).conversations;
|
|
138
|
+
store.conversations = pruneToLimit(store.conversations);
|
|
139
|
+
await writeJsonFile(filePath, store);
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const remove = async (conversationId: string): Promise<boolean> => {
|
|
144
|
+
const normalizedId = normalizeConversationId(conversationId);
|
|
145
|
+
return await withFileLock(filePath, empty, async () => {
|
|
146
|
+
const store = await readStore();
|
|
147
|
+
if (!(normalizedId in store.conversations)) return false;
|
|
148
|
+
delete store.conversations[normalizedId];
|
|
149
|
+
await writeJsonFile(filePath, store);
|
|
150
|
+
return true;
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return { upsert, get, list, remove, findByUserId };
|
|
155
|
+
}
|