@openclaw/msteams 2026.3.13 → 2026.5.2-beta.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.
Files changed (175) hide show
  1. package/api.ts +3 -0
  2. package/channel-config-api.ts +1 -0
  3. package/channel-plugin-api.ts +2 -0
  4. package/config-api.ts +4 -0
  5. package/contract-api.ts +4 -0
  6. package/index.ts +15 -12
  7. package/openclaw.plugin.json +553 -1
  8. package/package.json +46 -12
  9. package/runtime-api.ts +73 -0
  10. package/secret-contract-api.ts +5 -0
  11. package/setup-entry.ts +13 -0
  12. package/setup-plugin-api.ts +3 -0
  13. package/src/ai-entity.ts +7 -0
  14. package/src/approval-auth.ts +44 -0
  15. package/src/attachments/bot-framework.test.ts +461 -0
  16. package/src/attachments/bot-framework.ts +362 -0
  17. package/src/attachments/download.ts +63 -19
  18. package/src/attachments/graph.test.ts +416 -0
  19. package/src/attachments/graph.ts +163 -72
  20. package/src/attachments/html.ts +33 -1
  21. package/src/attachments/payload.ts +1 -1
  22. package/src/attachments/remote-media.test.ts +137 -0
  23. package/src/attachments/remote-media.ts +75 -8
  24. package/src/attachments/shared.test.ts +138 -1
  25. package/src/attachments/shared.ts +193 -26
  26. package/src/attachments/types.ts +10 -0
  27. package/src/attachments.graph.test.ts +342 -0
  28. package/src/attachments.helpers.test.ts +246 -0
  29. package/src/attachments.test-helpers.ts +17 -0
  30. package/src/attachments.test.ts +163 -418
  31. package/src/attachments.ts +5 -5
  32. package/src/block-streaming-config.test.ts +61 -0
  33. package/src/channel-api.ts +1 -0
  34. package/src/channel.actions.test.ts +742 -0
  35. package/src/channel.directory.test.ts +145 -4
  36. package/src/channel.runtime.ts +56 -0
  37. package/src/channel.setup.ts +77 -0
  38. package/src/channel.test.ts +128 -0
  39. package/src/channel.ts +1077 -395
  40. package/src/config-schema.ts +6 -0
  41. package/src/config-ui-hints.ts +12 -0
  42. package/src/conversation-store-fs.test.ts +4 -5
  43. package/src/conversation-store-fs.ts +35 -51
  44. package/src/conversation-store-helpers.test.ts +202 -0
  45. package/src/conversation-store-helpers.ts +105 -0
  46. package/src/conversation-store-memory.ts +27 -23
  47. package/src/conversation-store.shared.test.ts +225 -0
  48. package/src/conversation-store.ts +30 -0
  49. package/src/directory-live.test.ts +156 -0
  50. package/src/directory-live.ts +7 -4
  51. package/src/doctor.ts +27 -0
  52. package/src/errors.test.ts +64 -1
  53. package/src/errors.ts +50 -9
  54. package/src/feedback-reflection-prompt.ts +117 -0
  55. package/src/feedback-reflection-store.ts +114 -0
  56. package/src/feedback-reflection.test.ts +237 -0
  57. package/src/feedback-reflection.ts +283 -0
  58. package/src/file-consent-helpers.test.ts +83 -0
  59. package/src/file-consent-helpers.ts +64 -11
  60. package/src/file-consent-invoke.ts +150 -0
  61. package/src/file-consent.test.ts +363 -0
  62. package/src/file-consent.ts +165 -4
  63. package/src/graph-chat.ts +5 -3
  64. package/src/graph-group-management.test.ts +318 -0
  65. package/src/graph-group-management.ts +168 -0
  66. package/src/graph-members.test.ts +89 -0
  67. package/src/graph-members.ts +48 -0
  68. package/src/graph-messages.actions.test.ts +243 -0
  69. package/src/graph-messages.read.test.ts +391 -0
  70. package/src/graph-messages.search.test.ts +213 -0
  71. package/src/graph-messages.test-helpers.ts +50 -0
  72. package/src/graph-messages.ts +534 -0
  73. package/src/graph-teams.test.ts +215 -0
  74. package/src/graph-teams.ts +114 -0
  75. package/src/graph-thread.test.ts +246 -0
  76. package/src/graph-thread.ts +146 -0
  77. package/src/graph-upload.test.ts +161 -4
  78. package/src/graph-upload.ts +147 -56
  79. package/src/graph.test.ts +516 -0
  80. package/src/graph.ts +233 -21
  81. package/src/inbound.test.ts +156 -1
  82. package/src/inbound.ts +101 -1
  83. package/src/media-helpers.ts +1 -1
  84. package/src/mentions.test.ts +27 -18
  85. package/src/mentions.ts +2 -2
  86. package/src/messenger.test.ts +504 -23
  87. package/src/messenger.ts +133 -52
  88. package/src/monitor-handler/access.ts +125 -0
  89. package/src/monitor-handler/inbound-media.test.ts +289 -0
  90. package/src/monitor-handler/inbound-media.ts +57 -5
  91. package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
  92. package/src/monitor-handler/message-handler.authz.test.ts +588 -74
  93. package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
  94. package/src/monitor-handler/message-handler.test-support.ts +100 -0
  95. package/src/monitor-handler/message-handler.thread-parent.test.ts +223 -0
  96. package/src/monitor-handler/message-handler.thread-session.test.ts +77 -0
  97. package/src/monitor-handler/message-handler.ts +470 -164
  98. package/src/monitor-handler/reaction-handler.test.ts +267 -0
  99. package/src/monitor-handler/reaction-handler.ts +210 -0
  100. package/src/monitor-handler/thread-session.ts +17 -0
  101. package/src/monitor-handler.adaptive-card.test.ts +162 -0
  102. package/src/monitor-handler.feedback-authz.test.ts +314 -0
  103. package/src/monitor-handler.file-consent.test.ts +281 -79
  104. package/src/monitor-handler.sso.test.ts +563 -0
  105. package/src/monitor-handler.test-helpers.ts +180 -0
  106. package/src/monitor-handler.ts +459 -115
  107. package/src/monitor-handler.types.ts +27 -0
  108. package/src/monitor-types.ts +1 -0
  109. package/src/monitor.lifecycle.test.ts +74 -10
  110. package/src/monitor.test.ts +35 -1
  111. package/src/monitor.ts +143 -46
  112. package/src/oauth.flow.ts +77 -0
  113. package/src/oauth.shared.ts +37 -0
  114. package/src/oauth.test.ts +305 -0
  115. package/src/oauth.token.ts +158 -0
  116. package/src/oauth.ts +130 -0
  117. package/src/outbound.test.ts +10 -11
  118. package/src/outbound.ts +62 -44
  119. package/src/pending-uploads-fs.test.ts +246 -0
  120. package/src/pending-uploads-fs.ts +235 -0
  121. package/src/pending-uploads.test.ts +173 -0
  122. package/src/pending-uploads.ts +34 -2
  123. package/src/policy.test.ts +11 -5
  124. package/src/policy.ts +5 -5
  125. package/src/polls.test.ts +106 -5
  126. package/src/polls.ts +15 -7
  127. package/src/presentation.ts +68 -0
  128. package/src/probe.test.ts +27 -8
  129. package/src/probe.ts +43 -9
  130. package/src/reply-dispatcher.test.ts +437 -0
  131. package/src/reply-dispatcher.ts +259 -73
  132. package/src/reply-stream-controller.test.ts +235 -0
  133. package/src/reply-stream-controller.ts +147 -0
  134. package/src/resolve-allowlist.test.ts +105 -1
  135. package/src/resolve-allowlist.ts +112 -7
  136. package/src/runtime.ts +6 -3
  137. package/src/sdk-types.ts +43 -3
  138. package/src/sdk.test.ts +666 -0
  139. package/src/sdk.ts +867 -16
  140. package/src/secret-contract.ts +49 -0
  141. package/src/secret-input.ts +1 -1
  142. package/src/send-context.ts +76 -9
  143. package/src/send.test.ts +389 -5
  144. package/src/send.ts +140 -32
  145. package/src/sent-message-cache.ts +30 -18
  146. package/src/session-route.ts +40 -0
  147. package/src/setup-core.ts +160 -0
  148. package/src/setup-surface.test.ts +202 -0
  149. package/src/setup-surface.ts +320 -0
  150. package/src/sso-token-store.test.ts +72 -0
  151. package/src/sso-token-store.ts +166 -0
  152. package/src/sso.ts +300 -0
  153. package/src/storage.ts +1 -1
  154. package/src/store-fs.ts +2 -2
  155. package/src/streaming-message.test.ts +262 -0
  156. package/src/streaming-message.ts +297 -0
  157. package/src/test-runtime.ts +1 -1
  158. package/src/thread-parent-context.test.ts +224 -0
  159. package/src/thread-parent-context.ts +159 -0
  160. package/src/token.test.ts +237 -50
  161. package/src/token.ts +162 -7
  162. package/src/user-agent.test.ts +86 -0
  163. package/src/user-agent.ts +53 -0
  164. package/src/webhook-timeouts.ts +27 -0
  165. package/src/welcome-card.test.ts +81 -0
  166. package/src/welcome-card.ts +57 -0
  167. package/test-api.ts +1 -0
  168. package/tsconfig.json +16 -0
  169. package/CHANGELOG.md +0 -107
  170. package/src/file-lock.ts +0 -1
  171. package/src/graph-users.test.ts +0 -66
  172. package/src/onboarding.ts +0 -381
  173. package/src/polls-store.test.ts +0 -38
  174. package/src/revoked-context.test.ts +0 -39
  175. package/src/token-response.test.ts +0 -23
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Background reflection triggered by negative user feedback (thumbs-down).
3
+ *
4
+ * Flow:
5
+ * 1. User thumbs-down -> invoke handler acks immediately
6
+ * 2. This module runs in the background (fire-and-forget)
7
+ * 3. Reads recent session context
8
+ * 4. Sends a synthetic reflection prompt to the agent
9
+ * 5. Stores the derived learning in session
10
+ * 6. Optionally sends a proactive follow-up to the user
11
+ */
12
+
13
+ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
14
+ import {
15
+ dispatchReplyFromConfigWithSettledDispatcher,
16
+ type OpenClawConfig,
17
+ } from "../runtime-api.js";
18
+ import type { StoredConversationReference } from "./conversation-store.js";
19
+ import { formatUnknownError } from "./errors.js";
20
+ import { buildReflectionPrompt, parseReflectionResponse } from "./feedback-reflection-prompt.js";
21
+ import {
22
+ DEFAULT_COOLDOWN_MS,
23
+ clearReflectionCooldowns,
24
+ isReflectionAllowed,
25
+ loadSessionLearnings,
26
+ recordReflectionTime,
27
+ storeSessionLearning,
28
+ } from "./feedback-reflection-store.js";
29
+ import type { MSTeamsAdapter } from "./messenger.js";
30
+ import { buildConversationReference } from "./messenger.js";
31
+ import type { MSTeamsMonitorLogger } from "./monitor-types.js";
32
+ import { getMSTeamsRuntime } from "./runtime.js";
33
+
34
+ type FeedbackEvent = {
35
+ type: "custom";
36
+ event: "feedback";
37
+ ts: number;
38
+ messageId: string;
39
+ value: "positive" | "negative";
40
+ comment?: string;
41
+ sessionKey: string;
42
+ agentId: string;
43
+ conversationId: string;
44
+ reflectionLearning?: string;
45
+ };
46
+
47
+ export function buildFeedbackEvent(params: {
48
+ messageId: string;
49
+ value: "positive" | "negative";
50
+ comment?: string;
51
+ sessionKey: string;
52
+ agentId: string;
53
+ conversationId: string;
54
+ }): FeedbackEvent {
55
+ return {
56
+ type: "custom",
57
+ event: "feedback",
58
+ ts: Date.now(),
59
+ messageId: params.messageId,
60
+ value: params.value,
61
+ comment: params.comment,
62
+ sessionKey: params.sessionKey,
63
+ agentId: params.agentId,
64
+ conversationId: params.conversationId,
65
+ };
66
+ }
67
+
68
+ type RunFeedbackReflectionParams = {
69
+ cfg: OpenClawConfig;
70
+ adapter: MSTeamsAdapter;
71
+ appId: string;
72
+ conversationRef: StoredConversationReference;
73
+ sessionKey: string;
74
+ agentId: string;
75
+ conversationId: string;
76
+ feedbackMessageId: string;
77
+ thumbedDownResponse?: string;
78
+ userComment?: string;
79
+ log: MSTeamsMonitorLogger;
80
+ };
81
+
82
+ function buildReflectionContext(params: {
83
+ cfg: OpenClawConfig;
84
+ conversationId: string;
85
+ sessionKey: string;
86
+ reflectionPrompt: string;
87
+ }) {
88
+ const core = getMSTeamsRuntime();
89
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(params.cfg);
90
+ const body = core.channel.reply.formatAgentEnvelope({
91
+ channel: "Teams",
92
+ from: "system",
93
+ body: params.reflectionPrompt,
94
+ envelope: envelopeOptions,
95
+ });
96
+
97
+ return {
98
+ ctxPayload: core.channel.reply.finalizeInboundContext({
99
+ Body: body,
100
+ BodyForAgent: params.reflectionPrompt,
101
+ RawBody: params.reflectionPrompt,
102
+ CommandBody: params.reflectionPrompt,
103
+ From: `msteams:system:${params.conversationId}`,
104
+ To: `conversation:${params.conversationId}`,
105
+ SessionKey: params.sessionKey,
106
+ ChatType: "direct" as const,
107
+ SenderName: "system",
108
+ SenderId: "system",
109
+ Provider: "msteams" as const,
110
+ Surface: "msteams" as const,
111
+ Timestamp: Date.now(),
112
+ WasMentioned: true,
113
+ CommandAuthorized: false,
114
+ OriginatingChannel: "msteams" as const,
115
+ OriginatingTo: `conversation:${params.conversationId}`,
116
+ }),
117
+ };
118
+ }
119
+
120
+ function createReflectionCaptureDispatcher(params: {
121
+ cfg: OpenClawConfig;
122
+ agentId: string;
123
+ log: MSTeamsMonitorLogger;
124
+ }) {
125
+ const core = getMSTeamsRuntime();
126
+ let response = "";
127
+ const noopTypingCallbacks = {
128
+ onReplyStart: async () => {},
129
+ onIdle: () => {},
130
+ onCleanup: () => {},
131
+ };
132
+
133
+ const { dispatcher, replyOptions } = core.channel.reply.createReplyDispatcherWithTyping({
134
+ deliver: async (payload) => {
135
+ if (payload.text) {
136
+ response += (response ? "\n" : "") + payload.text;
137
+ }
138
+ },
139
+ typingCallbacks: noopTypingCallbacks,
140
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
141
+ onError: (err) => {
142
+ params.log.debug?.("reflection reply error", { error: formatUnknownError(err) });
143
+ },
144
+ });
145
+
146
+ return {
147
+ dispatcher,
148
+ replyOptions,
149
+ readResponse: () => response,
150
+ };
151
+ }
152
+
153
+ async function sendReflectionFollowUp(params: {
154
+ adapter: MSTeamsAdapter;
155
+ appId: string;
156
+ conversationRef: StoredConversationReference;
157
+ userMessage: string;
158
+ }): Promise<void> {
159
+ const baseRef = buildConversationReference(params.conversationRef);
160
+ const proactiveRef = { ...baseRef, activityId: undefined };
161
+
162
+ await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
163
+ await ctx.sendActivity({
164
+ type: "message",
165
+ text: params.userMessage,
166
+ });
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Run a background reflection after negative feedback.
172
+ * This is designed to be called fire-and-forget (don't await in the invoke handler).
173
+ */
174
+ export async function runFeedbackReflection(params: RunFeedbackReflectionParams): Promise<void> {
175
+ const { cfg, log, sessionKey } = params;
176
+ const cooldownMs = cfg.channels?.msteams?.feedbackReflectionCooldownMs ?? DEFAULT_COOLDOWN_MS;
177
+ if (!isReflectionAllowed(sessionKey, cooldownMs)) {
178
+ log.debug?.("skipping reflection (cooldown active)", { sessionKey });
179
+ return;
180
+ }
181
+
182
+ const reflectionPrompt = buildReflectionPrompt({
183
+ thumbedDownResponse: params.thumbedDownResponse,
184
+ userComment: params.userComment,
185
+ });
186
+ const runtime = getMSTeamsRuntime();
187
+ const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
188
+ agentId: params.agentId,
189
+ });
190
+ const { ctxPayload } = buildReflectionContext({
191
+ cfg,
192
+ conversationId: params.conversationId,
193
+ sessionKey: params.sessionKey,
194
+ reflectionPrompt,
195
+ });
196
+
197
+ const capture = createReflectionCaptureDispatcher({
198
+ cfg,
199
+ agentId: params.agentId,
200
+ log,
201
+ });
202
+
203
+ try {
204
+ await dispatchReplyFromConfigWithSettledDispatcher({
205
+ ctxPayload,
206
+ cfg,
207
+ dispatcher: capture.dispatcher,
208
+ onSettled: () => {},
209
+ replyOptions: capture.replyOptions,
210
+ });
211
+ } catch (err) {
212
+ log.error("reflection dispatch failed", { error: formatUnknownError(err) });
213
+ return;
214
+ }
215
+
216
+ const reflectionResponse = capture.readResponse().trim();
217
+ if (!reflectionResponse) {
218
+ log.debug?.("reflection produced no output");
219
+ return;
220
+ }
221
+
222
+ const parsedReflection = parseReflectionResponse(reflectionResponse);
223
+ if (!parsedReflection) {
224
+ log.debug?.("reflection produced no structured output");
225
+ return;
226
+ }
227
+
228
+ recordReflectionTime(sessionKey, cooldownMs);
229
+ log.info("reflection complete", {
230
+ sessionKey,
231
+ responseLength: reflectionResponse.length,
232
+ followUp: parsedReflection.followUp,
233
+ });
234
+
235
+ try {
236
+ await storeSessionLearning({
237
+ storePath,
238
+ sessionKey: params.sessionKey,
239
+ learning: parsedReflection.learning,
240
+ });
241
+ } catch (err) {
242
+ log.debug?.("failed to store reflection learning", { error: formatUnknownError(err) });
243
+ }
244
+
245
+ const conversationType = normalizeOptionalLowercaseString(
246
+ params.conversationRef.conversation?.conversationType,
247
+ );
248
+ const shouldNotify =
249
+ conversationType === "personal" &&
250
+ parsedReflection.followUp &&
251
+ Boolean(parsedReflection.userMessage);
252
+
253
+ if (!shouldNotify) {
254
+ if (parsedReflection.followUp && conversationType !== "personal") {
255
+ log.debug?.("skipping reflection follow-up outside direct message", {
256
+ sessionKey,
257
+ conversationType,
258
+ });
259
+ }
260
+ return;
261
+ }
262
+
263
+ try {
264
+ await sendReflectionFollowUp({
265
+ adapter: params.adapter,
266
+ appId: params.appId,
267
+ conversationRef: params.conversationRef,
268
+ userMessage: parsedReflection.userMessage!,
269
+ });
270
+ log.info("sent reflection follow-up", { sessionKey });
271
+ } catch (err) {
272
+ log.debug?.("failed to send reflection follow-up", { error: formatUnknownError(err) });
273
+ }
274
+ }
275
+
276
+ export {
277
+ buildReflectionPrompt,
278
+ clearReflectionCooldowns,
279
+ isReflectionAllowed,
280
+ loadSessionLearnings,
281
+ parseReflectionResponse,
282
+ recordReflectionTime,
283
+ };
@@ -1,5 +1,12 @@
1
1
  import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
2
  import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
3
+ import {
4
+ clearPendingUploads,
5
+ getPendingUpload,
6
+ getPendingUploadCount,
7
+ removePendingUpload,
8
+ storePendingUpload,
9
+ } from "./pending-uploads.js";
3
10
  import * as pendingUploads from "./pending-uploads.js";
4
11
 
5
12
  describe("requiresFileConsent", () => {
@@ -241,3 +248,79 @@ describe("prepareFileConsentActivity", () => {
241
248
  expect(result.activity.type).toBe("message");
242
249
  });
243
250
  });
251
+
252
+ describe("msteams pending uploads", () => {
253
+ beforeEach(() => {
254
+ vi.useFakeTimers();
255
+ clearPendingUploads();
256
+ });
257
+
258
+ afterEach(() => {
259
+ clearPendingUploads();
260
+ vi.useRealTimers();
261
+ });
262
+
263
+ it("stores uploads, exposes them by id, and tracks count", () => {
264
+ const id = storePendingUpload({
265
+ buffer: Buffer.from("hello"),
266
+ filename: "hello.txt",
267
+ contentType: "text/plain",
268
+ conversationId: "conv-1",
269
+ });
270
+
271
+ expect(getPendingUploadCount()).toBe(1);
272
+ expect(getPendingUpload(id)).toEqual(
273
+ expect.objectContaining({
274
+ id,
275
+ filename: "hello.txt",
276
+ contentType: "text/plain",
277
+ conversationId: "conv-1",
278
+ }),
279
+ );
280
+ });
281
+
282
+ it("removes uploads explicitly and ignores empty ids", () => {
283
+ const id = storePendingUpload({
284
+ buffer: Buffer.from("hello"),
285
+ filename: "hello.txt",
286
+ conversationId: "conv-1",
287
+ });
288
+
289
+ removePendingUpload(undefined);
290
+ expect(getPendingUploadCount()).toBe(1);
291
+
292
+ removePendingUpload(id);
293
+ expect(getPendingUpload(id)).toBeUndefined();
294
+ expect(getPendingUploadCount()).toBe(0);
295
+ });
296
+
297
+ it("expires uploads by ttl even if the timeout callback has not been observed yet", () => {
298
+ const id = storePendingUpload({
299
+ buffer: Buffer.from("hello"),
300
+ filename: "hello.txt",
301
+ conversationId: "conv-1",
302
+ });
303
+
304
+ vi.advanceTimersByTime(5 * 60 * 1000 + 1);
305
+
306
+ expect(getPendingUpload(id)).toBeUndefined();
307
+ expect(getPendingUploadCount()).toBe(0);
308
+ });
309
+
310
+ it("clears all uploads for test cleanup", () => {
311
+ storePendingUpload({
312
+ buffer: Buffer.from("a"),
313
+ filename: "a.txt",
314
+ conversationId: "conv-1",
315
+ });
316
+ storePendingUpload({
317
+ buffer: Buffer.from("b"),
318
+ filename: "b.txt",
319
+ conversationId: "conv-2",
320
+ });
321
+
322
+ clearPendingUploads();
323
+
324
+ expect(getPendingUploadCount()).toBe(0);
325
+ });
326
+ });
@@ -9,23 +9,48 @@
9
9
  * and messenger.ts (reply path) to avoid duplication.
10
10
  */
11
11
 
12
+ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
12
13
  import { buildFileConsentCard } from "./file-consent.js";
14
+ import { storePendingUploadFs } from "./pending-uploads-fs.js";
13
15
  import { storePendingUpload } from "./pending-uploads.js";
14
16
 
15
- export type FileConsentMedia = {
17
+ type FileConsentMedia = {
16
18
  buffer: Buffer;
17
19
  filename: string;
18
20
  contentType?: string;
19
21
  };
20
22
 
21
- export type FileConsentActivityResult = {
23
+ type FileConsentActivityResult = {
22
24
  activity: Record<string, unknown>;
23
25
  uploadId: string;
24
26
  };
25
27
 
28
+ function buildConsentActivity(params: {
29
+ media: FileConsentMedia;
30
+ description?: string;
31
+ uploadId: string;
32
+ }): Record<string, unknown> {
33
+ const { media, description, uploadId } = params;
34
+ const consentCard = buildFileConsentCard({
35
+ filename: media.filename,
36
+ description: description || `File: ${media.filename}`,
37
+ sizeInBytes: media.buffer.length,
38
+ context: { uploadId },
39
+ });
40
+ return {
41
+ type: "message",
42
+ attachments: [consentCard],
43
+ };
44
+ }
45
+
26
46
  /**
27
47
  * Prepare a FileConsentCard activity for large files or non-images in personal chats.
28
48
  * Returns the activity object and uploadId - caller is responsible for sending.
49
+ *
50
+ * This variant only writes to the in-memory store. Use it when the caller and
51
+ * the `fileConsent/invoke` handler share the same process (for example the
52
+ * messenger reply path). For proactive CLI sends where the invoke arrives in
53
+ * a different process, use {@link prepareFileConsentActivityFs} instead.
29
54
  */
30
55
  export function prepareFileConsentActivity(params: {
31
56
  media: FileConsentMedia;
@@ -41,18 +66,46 @@ export function prepareFileConsentActivity(params: {
41
66
  conversationId,
42
67
  });
43
68
 
44
- const consentCard = buildFileConsentCard({
69
+ const activity = buildConsentActivity({ media, description, uploadId });
70
+ return { activity, uploadId };
71
+ }
72
+
73
+ /**
74
+ * Prepare a FileConsentCard activity and persist the pending upload to the
75
+ * filesystem so a different process can read it when the user accepts.
76
+ *
77
+ * This is used by the proactive CLI `message send --media` path: the CLI
78
+ * process sends the card and exits, but the `fileConsent/invoke` callback is
79
+ * delivered to the long-lived gateway monitor process. The FS-backed store
80
+ * bridges those two processes. The in-memory store is also populated so
81
+ * same-process flows keep the fast path.
82
+ */
83
+ export async function prepareFileConsentActivityFs(params: {
84
+ media: FileConsentMedia;
85
+ conversationId: string;
86
+ description?: string;
87
+ }): Promise<FileConsentActivityResult> {
88
+ const { media, conversationId, description } = params;
89
+
90
+ // Populate the in-memory store first so the uploadId is consistent, then
91
+ // mirror the same entry to the FS store under the same id so an invoke
92
+ // handler in another process can find it.
93
+ const uploadId = storePendingUpload({
94
+ buffer: media.buffer,
45
95
  filename: media.filename,
46
- description: description || `File: ${media.filename}`,
47
- sizeInBytes: media.buffer.length,
48
- context: { uploadId },
96
+ contentType: media.contentType,
97
+ conversationId,
49
98
  });
50
99
 
51
- const activity: Record<string, unknown> = {
52
- type: "message",
53
- attachments: [consentCard],
54
- };
100
+ await storePendingUploadFs({
101
+ id: uploadId,
102
+ buffer: media.buffer,
103
+ filename: media.filename,
104
+ contentType: media.contentType,
105
+ conversationId,
106
+ });
55
107
 
108
+ const activity = buildConsentActivity({ media, description, uploadId });
56
109
  return { activity, uploadId };
57
110
  }
58
111
 
@@ -66,7 +119,7 @@ export function requiresFileConsent(params: {
66
119
  bufferSize: number;
67
120
  thresholdBytes: number;
68
121
  }): boolean {
69
- const isPersonal = params.conversationType?.toLowerCase() === "personal";
122
+ const isPersonal = normalizeOptionalLowercaseString(params.conversationType) === "personal";
70
123
  const isImage = params.contentType?.startsWith("image/") ?? false;
71
124
  const isLargeFile = params.bufferSize >= params.thresholdBytes;
72
125
  return isPersonal && (isLargeFile || !isImage);
@@ -0,0 +1,150 @@
1
+ import { formatUnknownError } from "./errors.js";
2
+ import { buildFileInfoCard, parseFileConsentInvoke, uploadToConsentUrl } from "./file-consent.js";
3
+ import { normalizeMSTeamsConversationId } from "./inbound.js";
4
+ import type { MSTeamsMonitorLogger } from "./monitor-types.js";
5
+ import { getPendingUploadFs, removePendingUploadFs } from "./pending-uploads-fs.js";
6
+ import { getPendingUpload, removePendingUpload } from "./pending-uploads.js";
7
+ import { withRevokedProxyFallback } from "./revoked-context.js";
8
+ import type { MSTeamsTurnContext } from "./sdk-types.js";
9
+
10
+ /**
11
+ * Handle fileConsent/invoke activities for large file uploads.
12
+ */
13
+ async function handleMSTeamsFileConsentInvoke(
14
+ context: MSTeamsTurnContext,
15
+ log: MSTeamsMonitorLogger,
16
+ ): Promise<boolean> {
17
+ const expiredUploadMessage =
18
+ "The file upload request has expired. Please try sending the file again.";
19
+ const activity = context.activity;
20
+ if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") {
21
+ return false;
22
+ }
23
+
24
+ const consentResponse = parseFileConsentInvoke(activity);
25
+ if (!consentResponse) {
26
+ log.debug?.("invalid file consent invoke", { value: activity.value });
27
+ return false;
28
+ }
29
+
30
+ const uploadId =
31
+ typeof consentResponse.context?.uploadId === "string"
32
+ ? consentResponse.context.uploadId
33
+ : undefined;
34
+ // Prefer the in-memory store (same-process reply path); fall back to the
35
+ // FS-backed store so CLI `message send --media` flows work even when the
36
+ // invoke callback is delivered to a different process.
37
+ const inMemoryFile = getPendingUpload(uploadId);
38
+ const fsFile = inMemoryFile ? undefined : await getPendingUploadFs(uploadId);
39
+ const pendingFile:
40
+ | {
41
+ buffer: Buffer;
42
+ filename: string;
43
+ contentType?: string;
44
+ conversationId: string;
45
+ consentCardActivityId?: string;
46
+ }
47
+ | undefined = inMemoryFile ?? fsFile;
48
+ if (pendingFile) {
49
+ const pendingConversationId = normalizeMSTeamsConversationId(pendingFile.conversationId);
50
+ const invokeConversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
51
+ if (!invokeConversationId || pendingConversationId !== invokeConversationId) {
52
+ log.info("file consent conversation mismatch", {
53
+ uploadId,
54
+ expectedConversationId: pendingConversationId,
55
+ receivedConversationId: invokeConversationId || undefined,
56
+ });
57
+ if (consentResponse.action === "accept") {
58
+ await context.sendActivity(expiredUploadMessage);
59
+ }
60
+ return true;
61
+ }
62
+ }
63
+
64
+ if (consentResponse.action === "accept" && consentResponse.uploadInfo) {
65
+ if (pendingFile) {
66
+ log.debug?.("user accepted file consent, uploading", {
67
+ uploadId,
68
+ filename: pendingFile.filename,
69
+ size: pendingFile.buffer.length,
70
+ });
71
+
72
+ try {
73
+ await uploadToConsentUrl({
74
+ url: consentResponse.uploadInfo.uploadUrl,
75
+ buffer: pendingFile.buffer,
76
+ contentType: pendingFile.contentType,
77
+ });
78
+
79
+ const fileInfoCard = buildFileInfoCard({
80
+ filename: consentResponse.uploadInfo.name,
81
+ contentUrl: consentResponse.uploadInfo.contentUrl,
82
+ uniqueId: consentResponse.uploadInfo.uniqueId,
83
+ fileType: consentResponse.uploadInfo.fileType,
84
+ });
85
+
86
+ if (!pendingFile.consentCardActivityId) {
87
+ await context.sendActivity({
88
+ type: "message",
89
+ attachments: [fileInfoCard],
90
+ });
91
+ }
92
+
93
+ if (pendingFile.consentCardActivityId) {
94
+ try {
95
+ await context.updateActivity({
96
+ id: pendingFile.consentCardActivityId,
97
+ type: "message",
98
+ attachments: [fileInfoCard],
99
+ });
100
+ } catch {
101
+ await context.sendActivity({
102
+ type: "message",
103
+ attachments: [fileInfoCard],
104
+ });
105
+ }
106
+ }
107
+
108
+ log.info("file upload complete", {
109
+ uploadId,
110
+ filename: consentResponse.uploadInfo.name,
111
+ uniqueId: consentResponse.uploadInfo.uniqueId,
112
+ });
113
+ } catch (err) {
114
+ log.error("file upload failed", { uploadId, error: formatUnknownError(err) });
115
+ await context.sendActivity("File upload failed. Please try again.");
116
+ } finally {
117
+ removePendingUpload(uploadId);
118
+ await removePendingUploadFs(uploadId);
119
+ }
120
+ } else {
121
+ log.debug?.("pending file not found for consent", { uploadId });
122
+ await context.sendActivity(expiredUploadMessage);
123
+ }
124
+ } else {
125
+ log.debug?.("user declined file consent", { uploadId });
126
+ removePendingUpload(uploadId);
127
+ await removePendingUploadFs(uploadId);
128
+ }
129
+
130
+ return true;
131
+ }
132
+
133
+ export async function respondToMSTeamsFileConsentInvoke(
134
+ context: MSTeamsTurnContext,
135
+ log: MSTeamsMonitorLogger,
136
+ ): Promise<void> {
137
+ await context.sendActivity({ type: "invokeResponse", value: { status: 200 } });
138
+
139
+ try {
140
+ await withRevokedProxyFallback({
141
+ run: async () => await handleMSTeamsFileConsentInvoke(context, log),
142
+ onRevoked: async () => true,
143
+ onRevokedLog: () => {
144
+ log.debug?.("turn context revoked during file consent invoke; skipping delayed response");
145
+ },
146
+ });
147
+ } catch (err) {
148
+ log.debug?.("file consent handler error", { error: formatUnknownError(err) });
149
+ }
150
+ }