@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/index.ts +18 -0
  3. package/openclaw.plugin.json +11 -0
  4. package/package.json +36 -0
  5. package/src/attachments/download.ts +206 -0
  6. package/src/attachments/graph.ts +319 -0
  7. package/src/attachments/html.ts +76 -0
  8. package/src/attachments/payload.ts +22 -0
  9. package/src/attachments/shared.ts +235 -0
  10. package/src/attachments/types.ts +37 -0
  11. package/src/attachments.test.ts +424 -0
  12. package/src/attachments.ts +18 -0
  13. package/src/channel.directory.test.ts +46 -0
  14. package/src/channel.ts +436 -0
  15. package/src/conversation-store-fs.test.ts +89 -0
  16. package/src/conversation-store-fs.ts +155 -0
  17. package/src/conversation-store-memory.ts +45 -0
  18. package/src/conversation-store.ts +41 -0
  19. package/src/directory-live.ts +179 -0
  20. package/src/errors.test.ts +46 -0
  21. package/src/errors.ts +158 -0
  22. package/src/file-consent-helpers.test.ts +234 -0
  23. package/src/file-consent-helpers.ts +73 -0
  24. package/src/file-consent.ts +122 -0
  25. package/src/graph-chat.ts +52 -0
  26. package/src/graph-upload.ts +445 -0
  27. package/src/inbound.test.ts +67 -0
  28. package/src/inbound.ts +38 -0
  29. package/src/index.ts +4 -0
  30. package/src/media-helpers.test.ts +186 -0
  31. package/src/media-helpers.ts +77 -0
  32. package/src/messenger.test.ts +245 -0
  33. package/src/messenger.ts +460 -0
  34. package/src/monitor-handler/inbound-media.ts +123 -0
  35. package/src/monitor-handler/message-handler.ts +629 -0
  36. package/src/monitor-handler.ts +166 -0
  37. package/src/monitor-types.ts +5 -0
  38. package/src/monitor.ts +290 -0
  39. package/src/onboarding.ts +432 -0
  40. package/src/outbound.ts +47 -0
  41. package/src/pending-uploads.ts +87 -0
  42. package/src/policy.test.ts +210 -0
  43. package/src/policy.ts +247 -0
  44. package/src/polls-store-memory.ts +30 -0
  45. package/src/polls-store.test.ts +40 -0
  46. package/src/polls.test.ts +73 -0
  47. package/src/polls.ts +300 -0
  48. package/src/probe.test.ts +57 -0
  49. package/src/probe.ts +99 -0
  50. package/src/reply-dispatcher.ts +128 -0
  51. package/src/resolve-allowlist.ts +277 -0
  52. package/src/runtime.ts +14 -0
  53. package/src/sdk-types.ts +19 -0
  54. package/src/sdk.ts +33 -0
  55. package/src/send-context.ts +156 -0
  56. package/src/send.ts +489 -0
  57. package/src/sent-message-cache.test.ts +16 -0
  58. package/src/sent-message-cache.ts +41 -0
  59. package/src/storage.ts +22 -0
  60. package/src/store-fs.ts +80 -0
  61. package/src/token.ts +19 -0
@@ -0,0 +1,277 @@
1
+ import { GRAPH_ROOT } from "./attachments/shared.js";
2
+ import { loadMSTeamsSdkWithAuth } from "./sdk.js";
3
+ import { resolveMSTeamsCredentials } from "./token.js";
4
+
5
+ type GraphUser = {
6
+ id?: string;
7
+ displayName?: string;
8
+ userPrincipalName?: string;
9
+ mail?: string;
10
+ };
11
+
12
+ type GraphGroup = {
13
+ id?: string;
14
+ displayName?: string;
15
+ };
16
+
17
+ type GraphChannel = {
18
+ id?: string;
19
+ displayName?: string;
20
+ };
21
+
22
+ type GraphResponse<T> = { value?: T[] };
23
+
24
+ export type MSTeamsChannelResolution = {
25
+ input: string;
26
+ resolved: boolean;
27
+ teamId?: string;
28
+ teamName?: string;
29
+ channelId?: string;
30
+ channelName?: string;
31
+ note?: string;
32
+ };
33
+
34
+ export type MSTeamsUserResolution = {
35
+ input: string;
36
+ resolved: boolean;
37
+ id?: string;
38
+ name?: string;
39
+ note?: string;
40
+ };
41
+
42
+ function readAccessToken(value: unknown): string | null {
43
+ if (typeof value === "string") return value;
44
+ if (value && typeof value === "object") {
45
+ const token =
46
+ (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
47
+ return typeof token === "string" ? token : null;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function stripProviderPrefix(raw: string): string {
53
+ return raw.replace(/^(msteams|teams):/i, "");
54
+ }
55
+
56
+ export function normalizeMSTeamsMessagingTarget(raw: string): string | undefined {
57
+ let trimmed = raw.trim();
58
+ if (!trimmed) return undefined;
59
+ trimmed = stripProviderPrefix(trimmed).trim();
60
+ if (/^conversation:/i.test(trimmed)) {
61
+ const id = trimmed.slice("conversation:".length).trim();
62
+ return id ? `conversation:${id}` : undefined;
63
+ }
64
+ if (/^user:/i.test(trimmed)) {
65
+ const id = trimmed.slice("user:".length).trim();
66
+ return id ? `user:${id}` : undefined;
67
+ }
68
+ return trimmed || undefined;
69
+ }
70
+
71
+ export function normalizeMSTeamsUserInput(raw: string): string {
72
+ return stripProviderPrefix(raw).replace(/^(user|conversation):/i, "").trim();
73
+ }
74
+
75
+ export function parseMSTeamsConversationId(raw: string): string | null {
76
+ const trimmed = stripProviderPrefix(raw).trim();
77
+ if (!/^conversation:/i.test(trimmed)) return null;
78
+ const id = trimmed.slice("conversation:".length).trim();
79
+ return id;
80
+ }
81
+
82
+ function normalizeMSTeamsTeamKey(raw: string): string | undefined {
83
+ const trimmed = stripProviderPrefix(raw).replace(/^team:/i, "").trim();
84
+ return trimmed || undefined;
85
+ }
86
+
87
+ function normalizeMSTeamsChannelKey(raw?: string | null): string | undefined {
88
+ const trimmed = raw?.trim().replace(/^#/, "").trim() ?? "";
89
+ return trimmed || undefined;
90
+ }
91
+
92
+ export function parseMSTeamsTeamChannelInput(raw: string): { team?: string; channel?: string } {
93
+ const trimmed = stripProviderPrefix(raw).trim();
94
+ if (!trimmed) return {};
95
+ const parts = trimmed.split("/");
96
+ const team = normalizeMSTeamsTeamKey(parts[0] ?? "");
97
+ const channel = parts.length > 1 ? normalizeMSTeamsChannelKey(parts.slice(1).join("/")) : undefined;
98
+ return {
99
+ ...(team ? { team } : {}),
100
+ ...(channel ? { channel } : {}),
101
+ };
102
+ }
103
+
104
+ export function parseMSTeamsTeamEntry(
105
+ raw: string,
106
+ ): { teamKey: string; channelKey?: string } | null {
107
+ const { team, channel } = parseMSTeamsTeamChannelInput(raw);
108
+ if (!team) return null;
109
+ return {
110
+ teamKey: team,
111
+ ...(channel ? { channelKey: channel } : {}),
112
+ };
113
+ }
114
+
115
+ function normalizeQuery(value?: string | null): string {
116
+ return value?.trim() ?? "";
117
+ }
118
+
119
+ function escapeOData(value: string): string {
120
+ return value.replace(/'/g, "''");
121
+ }
122
+
123
+ async function fetchGraphJson<T>(params: {
124
+ token: string;
125
+ path: string;
126
+ headers?: Record<string, string>;
127
+ }): Promise<T> {
128
+ const res = await fetch(`${GRAPH_ROOT}${params.path}`, {
129
+ headers: {
130
+ Authorization: `Bearer ${params.token}`,
131
+ ...(params.headers ?? {}),
132
+ },
133
+ });
134
+ if (!res.ok) {
135
+ const text = await res.text().catch(() => "");
136
+ throw new Error(`Graph ${params.path} failed (${res.status}): ${text || "unknown error"}`);
137
+ }
138
+ return (await res.json()) as T;
139
+ }
140
+
141
+ async function resolveGraphToken(cfg: unknown): Promise<string> {
142
+ const creds = resolveMSTeamsCredentials((cfg as { channels?: { msteams?: unknown } })?.channels?.msteams);
143
+ if (!creds) throw new Error("MS Teams credentials missing");
144
+ const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
145
+ const tokenProvider = new sdk.MsalTokenProvider(authConfig);
146
+ const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
147
+ const accessToken = readAccessToken(token);
148
+ if (!accessToken) throw new Error("MS Teams graph token unavailable");
149
+ return accessToken;
150
+ }
151
+
152
+ async function listTeamsByName(token: string, query: string): Promise<GraphGroup[]> {
153
+ const escaped = escapeOData(query);
154
+ const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`;
155
+ const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`;
156
+ const res = await fetchGraphJson<GraphResponse<GraphGroup>>({ token, path });
157
+ return res.value ?? [];
158
+ }
159
+
160
+ async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
161
+ const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
162
+ const res = await fetchGraphJson<GraphResponse<GraphChannel>>({ token, path });
163
+ return res.value ?? [];
164
+ }
165
+
166
+ export async function resolveMSTeamsChannelAllowlist(params: {
167
+ cfg: unknown;
168
+ entries: string[];
169
+ }): Promise<MSTeamsChannelResolution[]> {
170
+ const token = await resolveGraphToken(params.cfg);
171
+ const results: MSTeamsChannelResolution[] = [];
172
+
173
+ for (const input of params.entries) {
174
+ const { team, channel } = parseMSTeamsTeamChannelInput(input);
175
+ if (!team) {
176
+ results.push({ input, resolved: false });
177
+ continue;
178
+ }
179
+ const teams =
180
+ /^[0-9a-fA-F-]{16,}$/.test(team) ? [{ id: team, displayName: team }] : await listTeamsByName(token, team);
181
+ if (teams.length === 0) {
182
+ results.push({ input, resolved: false, note: "team not found" });
183
+ continue;
184
+ }
185
+ const teamMatch = teams[0];
186
+ const teamId = teamMatch.id?.trim();
187
+ const teamName = teamMatch.displayName?.trim() || team;
188
+ if (!teamId) {
189
+ results.push({ input, resolved: false, note: "team id missing" });
190
+ continue;
191
+ }
192
+ if (!channel) {
193
+ results.push({
194
+ input,
195
+ resolved: true,
196
+ teamId,
197
+ teamName,
198
+ note: teams.length > 1 ? "multiple teams; chose first" : undefined,
199
+ });
200
+ continue;
201
+ }
202
+ const channels = await listChannelsForTeam(token, teamId);
203
+ const channelMatch =
204
+ channels.find((item) => item.id === channel) ??
205
+ channels.find(
206
+ (item) => item.displayName?.toLowerCase() === channel.toLowerCase(),
207
+ ) ??
208
+ channels.find(
209
+ (item) => item.displayName?.toLowerCase().includes(channel.toLowerCase() ?? ""),
210
+ );
211
+ if (!channelMatch?.id) {
212
+ results.push({ input, resolved: false, note: "channel not found" });
213
+ continue;
214
+ }
215
+ results.push({
216
+ input,
217
+ resolved: true,
218
+ teamId,
219
+ teamName,
220
+ channelId: channelMatch.id,
221
+ channelName: channelMatch.displayName ?? channel,
222
+ note: channels.length > 1 ? "multiple channels; chose first" : undefined,
223
+ });
224
+ }
225
+
226
+ return results;
227
+ }
228
+
229
+ export async function resolveMSTeamsUserAllowlist(params: {
230
+ cfg: unknown;
231
+ entries: string[];
232
+ }): Promise<MSTeamsUserResolution[]> {
233
+ const token = await resolveGraphToken(params.cfg);
234
+ const results: MSTeamsUserResolution[] = [];
235
+
236
+ for (const input of params.entries) {
237
+ const query = normalizeQuery(normalizeMSTeamsUserInput(input));
238
+ if (!query) {
239
+ results.push({ input, resolved: false });
240
+ continue;
241
+ }
242
+ if (/^[0-9a-fA-F-]{16,}$/.test(query)) {
243
+ results.push({ input, resolved: true, id: query });
244
+ continue;
245
+ }
246
+ let users: GraphUser[] = [];
247
+ if (query.includes("@")) {
248
+ const escaped = escapeOData(query);
249
+ const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
250
+ const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
251
+ const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token, path });
252
+ users = res.value ?? [];
253
+ } else {
254
+ const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=10`;
255
+ const res = await fetchGraphJson<GraphResponse<GraphUser>>({
256
+ token,
257
+ path,
258
+ headers: { ConsistencyLevel: "eventual" },
259
+ });
260
+ users = res.value ?? [];
261
+ }
262
+ const match = users[0];
263
+ if (!match?.id) {
264
+ results.push({ input, resolved: false });
265
+ continue;
266
+ }
267
+ results.push({
268
+ input,
269
+ resolved: true,
270
+ id: match.id,
271
+ name: match.displayName ?? undefined,
272
+ note: users.length > 1 ? "multiple matches; chose first" : undefined,
273
+ });
274
+ }
275
+
276
+ return results;
277
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setMSTeamsRuntime(next: PluginRuntime) {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getMSTeamsRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("MSTeams runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }
@@ -0,0 +1,19 @@
1
+ import type { TurnContext } from "@microsoft/agents-hosting";
2
+
3
+ /**
4
+ * Minimal public surface we depend on from the Microsoft SDK types.
5
+ *
6
+ * Note: we intentionally avoid coupling to SDK classes with private members
7
+ * (like TurnContext) in our own public signatures. The SDK's TS surface is also
8
+ * stricter than what the runtime accepts (e.g. it allows plain activity-like
9
+ * objects), so we model the minimal structural shape we rely on.
10
+ */
11
+ export type MSTeamsActivity = TurnContext["activity"];
12
+
13
+ export type MSTeamsTurnContext = {
14
+ activity: MSTeamsActivity;
15
+ sendActivity: (textOrActivity: string | object) => Promise<unknown>;
16
+ sendActivities: (
17
+ activities: Array<{ type: string } & Record<string, unknown>>,
18
+ ) => Promise<unknown>;
19
+ };
package/src/sdk.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { MSTeamsAdapter } from "./messenger.js";
2
+ import type { MSTeamsCredentials } from "./token.js";
3
+
4
+ export type MSTeamsSdk = typeof import("@microsoft/agents-hosting");
5
+ export type MSTeamsAuthConfig = ReturnType<MSTeamsSdk["getAuthConfigWithDefaults"]>;
6
+
7
+ export async function loadMSTeamsSdk(): Promise<MSTeamsSdk> {
8
+ return await import("@microsoft/agents-hosting");
9
+ }
10
+
11
+ export function buildMSTeamsAuthConfig(
12
+ creds: MSTeamsCredentials,
13
+ sdk: MSTeamsSdk,
14
+ ): MSTeamsAuthConfig {
15
+ return sdk.getAuthConfigWithDefaults({
16
+ clientId: creds.appId,
17
+ clientSecret: creds.appPassword,
18
+ tenantId: creds.tenantId,
19
+ });
20
+ }
21
+
22
+ export function createMSTeamsAdapter(
23
+ authConfig: MSTeamsAuthConfig,
24
+ sdk: MSTeamsSdk,
25
+ ): MSTeamsAdapter {
26
+ return new sdk.CloudAdapter(authConfig) as unknown as MSTeamsAdapter;
27
+ }
28
+
29
+ export async function loadMSTeamsSdkWithAuth(creds: MSTeamsCredentials) {
30
+ const sdk = await loadMSTeamsSdk();
31
+ const authConfig = buildMSTeamsAuthConfig(creds, sdk);
32
+ return { sdk, authConfig };
33
+ }
@@ -0,0 +1,156 @@
1
+ import { resolveChannelMediaMaxBytes, type OpenClawConfig, type PluginRuntime } from "openclaw/plugin-sdk";
2
+ import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
3
+ import type {
4
+ MSTeamsConversationStore,
5
+ StoredConversationReference,
6
+ } from "./conversation-store.js";
7
+ import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
8
+ import type { MSTeamsAdapter } from "./messenger.js";
9
+ import { getMSTeamsRuntime } from "./runtime.js";
10
+ import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
11
+ import { resolveMSTeamsCredentials } from "./token.js";
12
+
13
+ export type MSTeamsConversationType = "personal" | "groupChat" | "channel";
14
+
15
+ export type MSTeamsProactiveContext = {
16
+ appId: string;
17
+ conversationId: string;
18
+ ref: StoredConversationReference;
19
+ adapter: MSTeamsAdapter;
20
+ log: ReturnType<PluginRuntime["logging"]["getChildLogger"]>;
21
+ /** The type of conversation: personal (1:1), groupChat, or channel */
22
+ conversationType: MSTeamsConversationType;
23
+ /** Token provider for Graph API / OneDrive operations */
24
+ tokenProvider: MSTeamsAccessTokenProvider;
25
+ /** SharePoint site ID for file uploads in group chats/channels */
26
+ sharePointSiteId?: string;
27
+ /** Resolved media max bytes from config (default: 100MB) */
28
+ mediaMaxBytes?: number;
29
+ };
30
+
31
+ /**
32
+ * Parse the target value into a conversation reference lookup key.
33
+ * Supported formats:
34
+ * - conversation:19:abc@thread.tacv2 → lookup by conversation ID
35
+ * - user:aad-object-id → lookup by user AAD object ID
36
+ * - 19:abc@thread.tacv2 → direct conversation ID
37
+ */
38
+ function parseRecipient(to: string): {
39
+ type: "conversation" | "user";
40
+ id: string;
41
+ } {
42
+ const trimmed = to.trim();
43
+ const finalize = (type: "conversation" | "user", id: string) => {
44
+ const normalized = id.trim();
45
+ if (!normalized) {
46
+ throw new Error(`Invalid target value: missing ${type} id`);
47
+ }
48
+ return { type, id: normalized };
49
+ };
50
+ if (trimmed.startsWith("conversation:")) {
51
+ return finalize("conversation", trimmed.slice("conversation:".length));
52
+ }
53
+ if (trimmed.startsWith("user:")) {
54
+ return finalize("user", trimmed.slice("user:".length));
55
+ }
56
+ // Assume it's a conversation ID if it looks like one
57
+ if (trimmed.startsWith("19:") || trimmed.includes("@thread")) {
58
+ return finalize("conversation", trimmed);
59
+ }
60
+ // Otherwise treat as user ID
61
+ return finalize("user", trimmed);
62
+ }
63
+
64
+ /**
65
+ * Find a stored conversation reference for the given recipient.
66
+ */
67
+ async function findConversationReference(recipient: {
68
+ type: "conversation" | "user";
69
+ id: string;
70
+ store: MSTeamsConversationStore;
71
+ }): Promise<{
72
+ conversationId: string;
73
+ ref: StoredConversationReference;
74
+ } | null> {
75
+ if (recipient.type === "conversation") {
76
+ const ref = await recipient.store.get(recipient.id);
77
+ if (ref) return { conversationId: recipient.id, ref };
78
+ return null;
79
+ }
80
+
81
+ const found = await recipient.store.findByUserId(recipient.id);
82
+ if (!found) return null;
83
+ return { conversationId: found.conversationId, ref: found.reference };
84
+ }
85
+
86
+ export async function resolveMSTeamsSendContext(params: {
87
+ cfg: OpenClawConfig;
88
+ to: string;
89
+ }): Promise<MSTeamsProactiveContext> {
90
+ const msteamsCfg = params.cfg.channels?.msteams;
91
+
92
+ if (!msteamsCfg?.enabled) {
93
+ throw new Error("msteams provider is not enabled");
94
+ }
95
+
96
+ const creds = resolveMSTeamsCredentials(msteamsCfg);
97
+ if (!creds) {
98
+ throw new Error("msteams credentials not configured");
99
+ }
100
+
101
+ const store = createMSTeamsConversationStoreFs();
102
+
103
+ // Parse recipient and find conversation reference
104
+ const recipient = parseRecipient(params.to);
105
+ const found = await findConversationReference({ ...recipient, store });
106
+
107
+ if (!found) {
108
+ throw new Error(
109
+ `No conversation reference found for ${recipient.type}:${recipient.id}. ` +
110
+ `The bot must receive a message from this conversation before it can send proactively.`,
111
+ );
112
+ }
113
+
114
+ const { conversationId, ref } = found;
115
+ const core = getMSTeamsRuntime();
116
+ const log = core.logging.getChildLogger({ name: "msteams:send" });
117
+
118
+ const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
119
+ const adapter = createMSTeamsAdapter(authConfig, sdk);
120
+
121
+ // Create token provider for Graph API / OneDrive operations
122
+ const tokenProvider = new sdk.MsalTokenProvider(authConfig) as MSTeamsAccessTokenProvider;
123
+
124
+ // Determine conversation type from stored reference
125
+ const storedConversationType = ref.conversation?.conversationType?.toLowerCase() ?? "";
126
+ let conversationType: MSTeamsConversationType;
127
+ if (storedConversationType === "personal") {
128
+ conversationType = "personal";
129
+ } else if (storedConversationType === "channel") {
130
+ conversationType = "channel";
131
+ } else {
132
+ // groupChat, or unknown defaults to groupChat behavior
133
+ conversationType = "groupChat";
134
+ }
135
+
136
+ // Get SharePoint site ID from config (required for file uploads in group chats/channels)
137
+ const sharePointSiteId = msteamsCfg.sharePointSiteId;
138
+
139
+ // Resolve media max bytes from config
140
+ const mediaMaxBytes = resolveChannelMediaMaxBytes({
141
+ cfg: params.cfg,
142
+ resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
143
+ });
144
+
145
+ return {
146
+ appId: creds.appId,
147
+ conversationId,
148
+ ref,
149
+ adapter: adapter as unknown as MSTeamsAdapter,
150
+ log,
151
+ conversationType,
152
+ tokenProvider,
153
+ sharePointSiteId,
154
+ mediaMaxBytes,
155
+ };
156
+ }