@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,432 @@
1
+ import type {
2
+ ChannelOnboardingAdapter,
3
+ ChannelOnboardingDmPolicy,
4
+ OpenClawConfig,
5
+ DmPolicy,
6
+ WizardPrompter,
7
+ } from "openclaw/plugin-sdk";
8
+ import {
9
+ addWildcardAllowFrom,
10
+ DEFAULT_ACCOUNT_ID,
11
+ formatDocsLink,
12
+ promptChannelAccessConfig,
13
+ } from "openclaw/plugin-sdk";
14
+
15
+ import { resolveMSTeamsCredentials } from "./token.js";
16
+ import {
17
+ parseMSTeamsTeamEntry,
18
+ resolveMSTeamsChannelAllowlist,
19
+ resolveMSTeamsUserAllowlist,
20
+ } from "./resolve-allowlist.js";
21
+
22
+ const channel = "msteams" as const;
23
+
24
+ function setMSTeamsDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) {
25
+ const allowFrom =
26
+ dmPolicy === "open"
27
+ ? addWildcardAllowFrom(cfg.channels?.msteams?.allowFrom)?.map((entry) => String(entry))
28
+ : undefined;
29
+ return {
30
+ ...cfg,
31
+ channels: {
32
+ ...cfg.channels,
33
+ msteams: {
34
+ ...cfg.channels?.msteams,
35
+ dmPolicy,
36
+ ...(allowFrom ? { allowFrom } : {}),
37
+ },
38
+ },
39
+ };
40
+ }
41
+
42
+ function setMSTeamsAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig {
43
+ return {
44
+ ...cfg,
45
+ channels: {
46
+ ...cfg.channels,
47
+ msteams: {
48
+ ...cfg.channels?.msteams,
49
+ allowFrom,
50
+ },
51
+ },
52
+ };
53
+ }
54
+
55
+ function parseAllowFromInput(raw: string): string[] {
56
+ return raw
57
+ .split(/[\n,;]+/g)
58
+ .map((entry) => entry.trim())
59
+ .filter(Boolean);
60
+ }
61
+
62
+ function looksLikeGuid(value: string): boolean {
63
+ return /^[0-9a-fA-F-]{16,}$/.test(value);
64
+ }
65
+
66
+ async function promptMSTeamsAllowFrom(params: {
67
+ cfg: OpenClawConfig;
68
+ prompter: WizardPrompter;
69
+ }): Promise<OpenClawConfig> {
70
+ const existing = params.cfg.channels?.msteams?.allowFrom ?? [];
71
+ await params.prompter.note(
72
+ [
73
+ "Allowlist MS Teams DMs by display name, UPN/email, or user id.",
74
+ "We resolve names to user IDs via Microsoft Graph when credentials allow.",
75
+ "Examples:",
76
+ "- alex@example.com",
77
+ "- Alex Johnson",
78
+ "- 00000000-0000-0000-0000-000000000000",
79
+ ].join("\n"),
80
+ "MS Teams allowlist",
81
+ );
82
+
83
+ while (true) {
84
+ const entry = await params.prompter.text({
85
+ message: "MS Teams allowFrom (usernames or ids)",
86
+ placeholder: "alex@example.com, Alex Johnson",
87
+ initialValue: existing[0] ? String(existing[0]) : undefined,
88
+ validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
89
+ });
90
+ const parts = parseAllowFromInput(String(entry));
91
+ if (parts.length === 0) {
92
+ await params.prompter.note("Enter at least one user.", "MS Teams allowlist");
93
+ continue;
94
+ }
95
+
96
+ const resolved = await resolveMSTeamsUserAllowlist({
97
+ cfg: params.cfg,
98
+ entries: parts,
99
+ }).catch(() => null);
100
+
101
+ if (!resolved) {
102
+ const ids = parts.filter((part) => looksLikeGuid(part));
103
+ if (ids.length !== parts.length) {
104
+ await params.prompter.note(
105
+ "Graph lookup unavailable. Use user IDs only.",
106
+ "MS Teams allowlist",
107
+ );
108
+ continue;
109
+ }
110
+ const unique = [
111
+ ...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
112
+ ];
113
+ return setMSTeamsAllowFrom(params.cfg, unique);
114
+ }
115
+
116
+ const unresolved = resolved.filter((item) => !item.resolved || !item.id);
117
+ if (unresolved.length > 0) {
118
+ await params.prompter.note(
119
+ `Could not resolve: ${unresolved.map((item) => item.input).join(", ")}`,
120
+ "MS Teams allowlist",
121
+ );
122
+ continue;
123
+ }
124
+
125
+ const ids = resolved.map((item) => item.id as string);
126
+ const unique = [
127
+ ...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
128
+ ];
129
+ return setMSTeamsAllowFrom(params.cfg, unique);
130
+ }
131
+ }
132
+
133
+ async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void> {
134
+ await prompter.note(
135
+ [
136
+ "1) Azure Bot registration → get App ID + Tenant ID",
137
+ "2) Add a client secret (App Password)",
138
+ "3) Set webhook URL + messaging endpoint",
139
+ "Tip: you can also set MSTEAMS_APP_ID / MSTEAMS_APP_PASSWORD / MSTEAMS_TENANT_ID.",
140
+ `Docs: ${formatDocsLink("/channels/msteams", "msteams")}`,
141
+ ].join("\n"),
142
+ "MS Teams credentials",
143
+ );
144
+ }
145
+
146
+ function setMSTeamsGroupPolicy(
147
+ cfg: OpenClawConfig,
148
+ groupPolicy: "open" | "allowlist" | "disabled",
149
+ ): OpenClawConfig {
150
+ return {
151
+ ...cfg,
152
+ channels: {
153
+ ...cfg.channels,
154
+ msteams: {
155
+ ...cfg.channels?.msteams,
156
+ enabled: true,
157
+ groupPolicy,
158
+ },
159
+ },
160
+ };
161
+ }
162
+
163
+ function setMSTeamsTeamsAllowlist(
164
+ cfg: OpenClawConfig,
165
+ entries: Array<{ teamKey: string; channelKey?: string }>,
166
+ ): OpenClawConfig {
167
+ const baseTeams = cfg.channels?.msteams?.teams ?? {};
168
+ const teams: Record<string, { channels?: Record<string, unknown> }> = { ...baseTeams };
169
+ for (const entry of entries) {
170
+ const teamKey = entry.teamKey;
171
+ if (!teamKey) continue;
172
+ const existing = teams[teamKey] ?? {};
173
+ if (entry.channelKey) {
174
+ const channels = { ...(existing.channels ?? {}) };
175
+ channels[entry.channelKey] = channels[entry.channelKey] ?? {};
176
+ teams[teamKey] = { ...existing, channels };
177
+ } else {
178
+ teams[teamKey] = existing;
179
+ }
180
+ }
181
+ return {
182
+ ...cfg,
183
+ channels: {
184
+ ...cfg.channels,
185
+ msteams: {
186
+ ...cfg.channels?.msteams,
187
+ enabled: true,
188
+ teams,
189
+ },
190
+ },
191
+ };
192
+ }
193
+
194
+ const dmPolicy: ChannelOnboardingDmPolicy = {
195
+ label: "MS Teams",
196
+ channel,
197
+ policyKey: "channels.msteams.dmPolicy",
198
+ allowFromKey: "channels.msteams.allowFrom",
199
+ getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing",
200
+ setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy),
201
+ promptAllowFrom: promptMSTeamsAllowFrom,
202
+ };
203
+
204
+ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
205
+ channel,
206
+ getStatus: async ({ cfg }) => {
207
+ const configured = Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams));
208
+ return {
209
+ channel,
210
+ configured,
211
+ statusLines: [`MS Teams: ${configured ? "configured" : "needs app credentials"}`],
212
+ selectionHint: configured ? "configured" : "needs app creds",
213
+ quickstartScore: configured ? 2 : 0,
214
+ };
215
+ },
216
+ configure: async ({ cfg, prompter }) => {
217
+ const resolved = resolveMSTeamsCredentials(cfg.channels?.msteams);
218
+ const hasConfigCreds = Boolean(
219
+ cfg.channels?.msteams?.appId?.trim() &&
220
+ cfg.channels?.msteams?.appPassword?.trim() &&
221
+ cfg.channels?.msteams?.tenantId?.trim(),
222
+ );
223
+ const canUseEnv = Boolean(
224
+ !hasConfigCreds &&
225
+ process.env.MSTEAMS_APP_ID?.trim() &&
226
+ process.env.MSTEAMS_APP_PASSWORD?.trim() &&
227
+ process.env.MSTEAMS_TENANT_ID?.trim(),
228
+ );
229
+
230
+ let next = cfg;
231
+ let appId: string | null = null;
232
+ let appPassword: string | null = null;
233
+ let tenantId: string | null = null;
234
+
235
+ if (!resolved) {
236
+ await noteMSTeamsCredentialHelp(prompter);
237
+ }
238
+
239
+ if (canUseEnv) {
240
+ const keepEnv = await prompter.confirm({
241
+ message:
242
+ "MSTEAMS_APP_ID + MSTEAMS_APP_PASSWORD + MSTEAMS_TENANT_ID detected. Use env vars?",
243
+ initialValue: true,
244
+ });
245
+ if (keepEnv) {
246
+ next = {
247
+ ...next,
248
+ channels: {
249
+ ...next.channels,
250
+ msteams: { ...next.channels?.msteams, enabled: true },
251
+ },
252
+ };
253
+ } else {
254
+ appId = String(
255
+ await prompter.text({
256
+ message: "Enter MS Teams App ID",
257
+ validate: (value) => (value?.trim() ? undefined : "Required"),
258
+ }),
259
+ ).trim();
260
+ appPassword = String(
261
+ await prompter.text({
262
+ message: "Enter MS Teams App Password",
263
+ validate: (value) => (value?.trim() ? undefined : "Required"),
264
+ }),
265
+ ).trim();
266
+ tenantId = String(
267
+ await prompter.text({
268
+ message: "Enter MS Teams Tenant ID",
269
+ validate: (value) => (value?.trim() ? undefined : "Required"),
270
+ }),
271
+ ).trim();
272
+ }
273
+ } else if (hasConfigCreds) {
274
+ const keep = await prompter.confirm({
275
+ message: "MS Teams credentials already configured. Keep them?",
276
+ initialValue: true,
277
+ });
278
+ if (!keep) {
279
+ appId = String(
280
+ await prompter.text({
281
+ message: "Enter MS Teams App ID",
282
+ validate: (value) => (value?.trim() ? undefined : "Required"),
283
+ }),
284
+ ).trim();
285
+ appPassword = String(
286
+ await prompter.text({
287
+ message: "Enter MS Teams App Password",
288
+ validate: (value) => (value?.trim() ? undefined : "Required"),
289
+ }),
290
+ ).trim();
291
+ tenantId = String(
292
+ await prompter.text({
293
+ message: "Enter MS Teams Tenant ID",
294
+ validate: (value) => (value?.trim() ? undefined : "Required"),
295
+ }),
296
+ ).trim();
297
+ }
298
+ } else {
299
+ appId = String(
300
+ await prompter.text({
301
+ message: "Enter MS Teams App ID",
302
+ validate: (value) => (value?.trim() ? undefined : "Required"),
303
+ }),
304
+ ).trim();
305
+ appPassword = String(
306
+ await prompter.text({
307
+ message: "Enter MS Teams App Password",
308
+ validate: (value) => (value?.trim() ? undefined : "Required"),
309
+ }),
310
+ ).trim();
311
+ tenantId = String(
312
+ await prompter.text({
313
+ message: "Enter MS Teams Tenant ID",
314
+ validate: (value) => (value?.trim() ? undefined : "Required"),
315
+ }),
316
+ ).trim();
317
+ }
318
+
319
+ if (appId && appPassword && tenantId) {
320
+ next = {
321
+ ...next,
322
+ channels: {
323
+ ...next.channels,
324
+ msteams: {
325
+ ...next.channels?.msteams,
326
+ enabled: true,
327
+ appId,
328
+ appPassword,
329
+ tenantId,
330
+ },
331
+ },
332
+ };
333
+ }
334
+
335
+ const currentEntries = Object.entries(next.channels?.msteams?.teams ?? {}).flatMap(
336
+ ([teamKey, value]) => {
337
+ const channels = value?.channels ?? {};
338
+ const channelKeys = Object.keys(channels);
339
+ if (channelKeys.length === 0) return [teamKey];
340
+ return channelKeys.map((channelKey) => `${teamKey}/${channelKey}`);
341
+ },
342
+ );
343
+ const accessConfig = await promptChannelAccessConfig({
344
+ prompter,
345
+ label: "MS Teams channels",
346
+ currentPolicy: next.channels?.msteams?.groupPolicy ?? "allowlist",
347
+ currentEntries,
348
+ placeholder: "Team Name/Channel Name, teamId/conversationId",
349
+ updatePrompt: Boolean(next.channels?.msteams?.teams),
350
+ });
351
+ if (accessConfig) {
352
+ if (accessConfig.policy !== "allowlist") {
353
+ next = setMSTeamsGroupPolicy(next, accessConfig.policy);
354
+ } else {
355
+ let entries = accessConfig.entries
356
+ .map((entry) => parseMSTeamsTeamEntry(entry))
357
+ .filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>;
358
+ if (accessConfig.entries.length > 0 && resolveMSTeamsCredentials(next.channels?.msteams)) {
359
+ try {
360
+ const resolved = await resolveMSTeamsChannelAllowlist({
361
+ cfg: next,
362
+ entries: accessConfig.entries,
363
+ });
364
+ const resolvedChannels = resolved.filter(
365
+ (entry) => entry.resolved && entry.teamId && entry.channelId,
366
+ );
367
+ const resolvedTeams = resolved.filter(
368
+ (entry) => entry.resolved && entry.teamId && !entry.channelId,
369
+ );
370
+ const unresolved = resolved
371
+ .filter((entry) => !entry.resolved)
372
+ .map((entry) => entry.input);
373
+
374
+ entries = [
375
+ ...resolvedChannels.map((entry) => ({
376
+ teamKey: entry.teamId as string,
377
+ channelKey: entry.channelId as string,
378
+ })),
379
+ ...resolvedTeams.map((entry) => ({
380
+ teamKey: entry.teamId as string,
381
+ })),
382
+ ...unresolved
383
+ .map((entry) => parseMSTeamsTeamEntry(entry))
384
+ .filter(Boolean),
385
+ ] as Array<{ teamKey: string; channelKey?: string }>;
386
+
387
+ if (resolvedChannels.length > 0 || resolvedTeams.length > 0 || unresolved.length > 0) {
388
+ const summary: string[] = [];
389
+ if (resolvedChannels.length > 0) {
390
+ summary.push(
391
+ `Resolved channels: ${resolvedChannels
392
+ .map((entry) => entry.channelId)
393
+ .filter(Boolean)
394
+ .join(", ")}`,
395
+ );
396
+ }
397
+ if (resolvedTeams.length > 0) {
398
+ summary.push(
399
+ `Resolved teams: ${resolvedTeams
400
+ .map((entry) => entry.teamId)
401
+ .filter(Boolean)
402
+ .join(", ")}`,
403
+ );
404
+ }
405
+ if (unresolved.length > 0) {
406
+ summary.push(`Unresolved (kept as typed): ${unresolved.join(", ")}`);
407
+ }
408
+ await prompter.note(summary.join("\n"), "MS Teams channels");
409
+ }
410
+ } catch (err) {
411
+ await prompter.note(
412
+ `Channel lookup failed; keeping entries as typed. ${String(err)}`,
413
+ "MS Teams channels",
414
+ );
415
+ }
416
+ }
417
+ next = setMSTeamsGroupPolicy(next, "allowlist");
418
+ next = setMSTeamsTeamsAllowlist(next, entries);
419
+ }
420
+ }
421
+
422
+ return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
423
+ },
424
+ dmPolicy,
425
+ disable: (cfg) => ({
426
+ ...cfg,
427
+ channels: {
428
+ ...cfg.channels,
429
+ msteams: { ...cfg.channels?.msteams, enabled: false },
430
+ },
431
+ }),
432
+ };
@@ -0,0 +1,47 @@
1
+ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
2
+
3
+ import { createMSTeamsPollStoreFs } from "./polls.js";
4
+ import { getMSTeamsRuntime } from "./runtime.js";
5
+ import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
6
+
7
+ export const msteamsOutbound: ChannelOutboundAdapter = {
8
+ deliveryMode: "direct",
9
+ chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
10
+ chunkerMode: "markdown",
11
+ textChunkLimit: 4000,
12
+ pollMaxOptions: 12,
13
+ sendText: async ({ cfg, to, text, deps }) => {
14
+ const send = deps?.sendMSTeams ?? ((to, text) => sendMessageMSTeams({ cfg, to, text }));
15
+ const result = await send(to, text);
16
+ return { channel: "msteams", ...result };
17
+ },
18
+ sendMedia: async ({ cfg, to, text, mediaUrl, deps }) => {
19
+ const send =
20
+ deps?.sendMSTeams ??
21
+ ((to, text, opts) => sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl }));
22
+ const result = await send(to, text, { mediaUrl });
23
+ return { channel: "msteams", ...result };
24
+ },
25
+ sendPoll: async ({ cfg, to, poll }) => {
26
+ const maxSelections = poll.maxSelections ?? 1;
27
+ const result = await sendPollMSTeams({
28
+ cfg,
29
+ to,
30
+ question: poll.question,
31
+ options: poll.options,
32
+ maxSelections,
33
+ });
34
+ const pollStore = createMSTeamsPollStoreFs();
35
+ await pollStore.createPoll({
36
+ id: result.pollId,
37
+ question: poll.question,
38
+ options: poll.options,
39
+ maxSelections,
40
+ createdAt: new Date().toISOString(),
41
+ conversationId: result.conversationId,
42
+ messageId: result.messageId,
43
+ votes: {},
44
+ });
45
+ return result;
46
+ },
47
+ };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * In-memory storage for files awaiting user consent in the FileConsentCard flow.
3
+ *
4
+ * When sending large files (>=4MB) in personal chats, Teams requires user consent
5
+ * before upload. This module stores the file data temporarily until the user
6
+ * accepts or declines, or until the TTL expires.
7
+ */
8
+
9
+ import crypto from "node:crypto";
10
+
11
+ export interface PendingUpload {
12
+ id: string;
13
+ buffer: Buffer;
14
+ filename: string;
15
+ contentType?: string;
16
+ conversationId: string;
17
+ createdAt: number;
18
+ }
19
+
20
+ const pendingUploads = new Map<string, PendingUpload>();
21
+
22
+ /** TTL for pending uploads: 5 minutes */
23
+ const PENDING_UPLOAD_TTL_MS = 5 * 60 * 1000;
24
+
25
+ /**
26
+ * Store a file pending user consent.
27
+ * Returns the upload ID to include in the FileConsentCard context.
28
+ */
29
+ export function storePendingUpload(
30
+ upload: Omit<PendingUpload, "id" | "createdAt">,
31
+ ): string {
32
+ const id = crypto.randomUUID();
33
+ const entry: PendingUpload = {
34
+ ...upload,
35
+ id,
36
+ createdAt: Date.now(),
37
+ };
38
+ pendingUploads.set(id, entry);
39
+
40
+ // Auto-cleanup after TTL
41
+ setTimeout(() => {
42
+ pendingUploads.delete(id);
43
+ }, PENDING_UPLOAD_TTL_MS);
44
+
45
+ return id;
46
+ }
47
+
48
+ /**
49
+ * Retrieve a pending upload by ID.
50
+ * Returns undefined if not found or expired.
51
+ */
52
+ export function getPendingUpload(id?: string): PendingUpload | undefined {
53
+ if (!id) return undefined;
54
+ const entry = pendingUploads.get(id);
55
+ if (!entry) return undefined;
56
+
57
+ // Check if expired (in case timeout hasn't fired yet)
58
+ if (Date.now() - entry.createdAt > PENDING_UPLOAD_TTL_MS) {
59
+ pendingUploads.delete(id);
60
+ return undefined;
61
+ }
62
+
63
+ return entry;
64
+ }
65
+
66
+ /**
67
+ * Remove a pending upload (after successful upload or user decline).
68
+ */
69
+ export function removePendingUpload(id?: string): void {
70
+ if (id) {
71
+ pendingUploads.delete(id);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Get the count of pending uploads (for monitoring/debugging).
77
+ */
78
+ export function getPendingUploadCount(): number {
79
+ return pendingUploads.size;
80
+ }
81
+
82
+ /**
83
+ * Clear all pending uploads (for testing).
84
+ */
85
+ export function clearPendingUploads(): void {
86
+ pendingUploads.clear();
87
+ }