@openclaw/msteams 2026.3.13 → 2026.5.1-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
@@ -1,14 +1,11 @@
1
- import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/msteams";
2
- import { beforeEach, describe, expect, it, vi } from "vitest";
3
- import type { MSTeamsConversationStore } from "./conversation-store.js";
4
- import type { MSTeamsAdapter } from "./messenger.js";
5
- import {
6
- type MSTeamsActivityHandler,
7
- type MSTeamsMessageHandlerDeps,
8
- registerMSTeamsHandlers,
9
- } from "./monitor-handler.js";
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { PluginRuntime } from "../runtime-api.js";
6
+ import { respondToMSTeamsFileConsentInvoke } from "./file-consent-invoke.js";
7
+ import { getPendingUploadFs, storePendingUploadFs } from "./pending-uploads-fs.js";
10
8
  import { clearPendingUploads, getPendingUpload, storePendingUpload } from "./pending-uploads.js";
11
- import type { MSTeamsPollStore } from "./polls.js";
12
9
  import { setMSTeamsRuntime } from "./runtime.js";
13
10
  import type { MSTeamsTurnContext } from "./sdk-types.js";
14
11
 
@@ -16,6 +13,14 @@ const fileConsentMockState = vi.hoisted(() => ({
16
13
  uploadToConsentUrl: vi.fn(),
17
14
  }));
18
15
 
16
+ vi.mock("./monitor-handler/message-handler.js", () => ({
17
+ createMSTeamsMessageHandler: () => async () => {},
18
+ }));
19
+
20
+ vi.mock("./monitor-handler/reaction-handler.js", () => ({
21
+ createMSTeamsReactionHandler: () => async () => {},
22
+ }));
23
+
19
24
  vi.mock("./file-consent.js", async () => {
20
25
  const actual = await vi.importActual<typeof import("./file-consent.js")>("./file-consent.js");
21
26
  return {
@@ -24,75 +29,50 @@ vi.mock("./file-consent.js", async () => {
24
29
  };
25
30
  });
26
31
 
27
- const runtimeStub: PluginRuntime = {
28
- logging: {
29
- shouldLogVerbose: () => false,
30
- },
31
- channel: {
32
- debounce: {
33
- resolveInboundDebounceMs: () => 0,
34
- createInboundDebouncer: () => ({
35
- enqueue: async () => {},
36
- }),
37
- },
38
- },
39
- } as unknown as PluginRuntime;
40
-
41
- function createDeps(): MSTeamsMessageHandlerDeps {
42
- const adapter: MSTeamsAdapter = {
43
- continueConversation: async () => {},
44
- process: async () => {},
45
- };
46
- const conversationStore: MSTeamsConversationStore = {
47
- upsert: async () => {},
48
- get: async () => null,
49
- list: async () => [],
50
- remove: async () => false,
51
- findByUserId: async () => null,
52
- };
53
- const pollStore: MSTeamsPollStore = {
54
- createPoll: async () => {},
55
- getPoll: async () => null,
56
- recordVote: async () => null,
57
- };
32
+ function createRuntimeStub(stateDir?: string): PluginRuntime {
58
33
  return {
59
- cfg: {} as OpenClawConfig,
60
- runtime: {
61
- error: vi.fn(),
62
- } as unknown as RuntimeEnv,
63
- appId: "test-app-id",
64
- adapter,
65
- tokenProvider: {
66
- getAccessToken: async () => "token",
34
+ logging: {
35
+ shouldLogVerbose: () => false,
67
36
  },
68
- textLimit: 4000,
69
- mediaMaxBytes: 8 * 1024 * 1024,
70
- conversationStore,
71
- pollStore,
72
- log: {
73
- info: vi.fn(),
74
- error: vi.fn(),
75
- debug: vi.fn(),
37
+ channel: {
38
+ debounce: {
39
+ resolveInboundDebounceMs: () => 0,
40
+ createInboundDebouncer: () => ({
41
+ enqueue: async () => {},
42
+ }),
43
+ },
76
44
  },
77
- };
45
+ state: {
46
+ resolveStateDir: (env?: NodeJS.ProcessEnv) => {
47
+ const override = env?.OPENCLAW_STATE_DIR?.trim();
48
+ if (override) {
49
+ return override;
50
+ }
51
+ return stateDir ?? path.join(os.homedir(), ".openclaw");
52
+ },
53
+ },
54
+ } as unknown as PluginRuntime;
78
55
  }
79
56
 
80
- function createActivityHandler(): MSTeamsActivityHandler {
81
- let handler: MSTeamsActivityHandler;
82
- handler = {
83
- onMessage: () => handler,
84
- onMembersAdded: () => handler,
85
- run: async () => {},
86
- };
87
- return handler;
88
- }
57
+ const runtimeStub: PluginRuntime = createRuntimeStub();
58
+
59
+ const log = {
60
+ debug: vi.fn(),
61
+ info: vi.fn(),
62
+ error: vi.fn(),
63
+ };
89
64
 
90
65
  function createInvokeContext(params: {
91
66
  conversationId: string;
92
67
  uploadId: string;
93
68
  action: "accept" | "decline";
94
- }): { context: MSTeamsTurnContext; sendActivity: ReturnType<typeof vi.fn> } {
69
+ }): {
70
+ context: MSTeamsTurnContext;
71
+ sendActivity: ReturnType<typeof vi.fn>;
72
+ updateActivity: ReturnType<typeof vi.fn>;
73
+ } {
95
74
  const sendActivity = vi.fn(async () => ({ id: "activity-id" }));
75
+ const updateActivity = vi.fn(async () => ({ id: "activity-id" }));
96
76
  const uploadInfo =
97
77
  params.action === "accept"
98
78
  ? {
@@ -118,8 +98,10 @@ function createInvokeContext(params: {
118
98
  },
119
99
  sendActivity,
120
100
  sendActivities: async () => [],
101
+ updateActivity,
121
102
  } as unknown as MSTeamsTurnContext,
122
103
  sendActivity,
104
+ updateActivity,
123
105
  };
124
106
  }
125
107
 
@@ -127,37 +109,47 @@ function createConsentInvokeHarness(params: {
127
109
  pendingConversationId?: string;
128
110
  invokeConversationId: string;
129
111
  action: "accept" | "decline";
112
+ consentCardActivityId?: string;
130
113
  }) {
131
114
  const uploadId = storePendingUpload({
132
115
  buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
133
116
  filename: "secret.txt",
134
117
  contentType: "text/plain",
135
118
  conversationId: params.pendingConversationId ?? "19:victim@thread.v2",
119
+ consentCardActivityId: params.consentCardActivityId,
136
120
  });
137
- const handler = registerMSTeamsHandlers(createActivityHandler(), createDeps());
138
- const { context, sendActivity } = createInvokeContext({
121
+ const { context, sendActivity, updateActivity } = createInvokeContext({
139
122
  conversationId: params.invokeConversationId,
140
123
  uploadId,
141
124
  action: params.action,
142
125
  });
143
- return { uploadId, handler, context, sendActivity };
126
+ return { uploadId, context, sendActivity, updateActivity };
127
+ }
128
+
129
+ function requirePendingUpload(uploadId: string) {
130
+ const upload = getPendingUpload(uploadId);
131
+ if (!upload) {
132
+ throw new Error(`expected pending upload ${uploadId}`);
133
+ }
134
+ return upload;
144
135
  }
145
136
 
146
137
  describe("msteams file consent invoke authz", () => {
147
138
  beforeEach(() => {
148
139
  setMSTeamsRuntime(runtimeStub);
149
140
  clearPendingUploads();
141
+ vi.clearAllMocks();
150
142
  fileConsentMockState.uploadToConsentUrl.mockReset();
151
143
  fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined);
152
144
  });
153
145
 
154
146
  it("uploads when invoke conversation matches pending upload conversation", async () => {
155
- const { uploadId, handler, context, sendActivity } = createConsentInvokeHarness({
147
+ const { uploadId, context, sendActivity } = createConsentInvokeHarness({
156
148
  invokeConversationId: "19:victim@thread.v2;messageid=abc123",
157
149
  action: "accept",
158
150
  });
159
151
 
160
- await handler.run?.(context);
152
+ await respondToMSTeamsFileConsentInvoke(context, log);
161
153
 
162
154
  // invokeResponse should be sent immediately
163
155
  expect(sendActivity).toHaveBeenCalledWith(
@@ -176,13 +168,97 @@ describe("msteams file consent invoke authz", () => {
176
168
  expect(getPendingUpload(uploadId)).toBeUndefined();
177
169
  });
178
170
 
171
+ it("calls updateActivity to replace the consent card when consentCardActivityId is set", async () => {
172
+ const { context, sendActivity, updateActivity } = createConsentInvokeHarness({
173
+ invokeConversationId: "19:victim@thread.v2;messageid=abc123",
174
+ action: "accept",
175
+ consentCardActivityId: "consent-card-activity-id-123",
176
+ });
177
+
178
+ await respondToMSTeamsFileConsentInvoke(context, log);
179
+
180
+ expect(sendActivity).toHaveBeenCalledWith(expect.objectContaining({ type: "invokeResponse" }));
181
+ expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
182
+
183
+ // Should replace the original consent card with the file info card
184
+ expect(updateActivity).toHaveBeenCalledTimes(1);
185
+ expect(updateActivity).toHaveBeenCalledWith(
186
+ expect.objectContaining({
187
+ id: "consent-card-activity-id-123",
188
+ type: "message",
189
+ attachments: expect.arrayContaining([
190
+ expect.objectContaining({
191
+ contentType: "application/vnd.microsoft.teams.card.file.info",
192
+ }),
193
+ ]),
194
+ }),
195
+ );
196
+ });
197
+
198
+ it("does not send file info card via sendActivity when updateActivity succeeds", async () => {
199
+ const { context, sendActivity, updateActivity } = createConsentInvokeHarness({
200
+ invokeConversationId: "19:victim@thread.v2;messageid=abc123",
201
+ action: "accept",
202
+ consentCardActivityId: "consent-card-activity-id-happy",
203
+ });
204
+
205
+ await respondToMSTeamsFileConsentInvoke(context, log);
206
+
207
+ // updateActivity should replace the consent card in-place
208
+ expect(updateActivity).toHaveBeenCalledTimes(1);
209
+
210
+ // sendActivity should only be called once for the invokeResponse, NOT for the file info card
211
+ expect(sendActivity).toHaveBeenCalledTimes(1);
212
+ expect(sendActivity).toHaveBeenCalledWith(expect.objectContaining({ type: "invokeResponse" }));
213
+
214
+ // Explicitly verify no file info card was sent via sendActivity
215
+ for (const call of sendActivity.mock.calls) {
216
+ const arg = call[0] as Record<string, unknown>;
217
+ if (typeof arg === "object" && arg !== null && "attachments" in arg) {
218
+ const attachments = arg.attachments as Array<{ contentType?: string }>;
219
+ for (const att of attachments) {
220
+ expect(att.contentType).not.toBe("application/vnd.microsoft.teams.card.file.info");
221
+ }
222
+ }
223
+ }
224
+ });
225
+
226
+ it("does not call updateActivity when no consentCardActivityId is stored", async () => {
227
+ const { context, updateActivity } = createConsentInvokeHarness({
228
+ invokeConversationId: "19:victim@thread.v2;messageid=abc123",
229
+ action: "accept",
230
+ // no consentCardActivityId
231
+ });
232
+
233
+ await respondToMSTeamsFileConsentInvoke(context, log);
234
+
235
+ expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
236
+ expect(updateActivity).not.toHaveBeenCalled();
237
+ });
238
+
239
+ it("still completes upload if updateActivity throws", async () => {
240
+ const { uploadId, context, updateActivity } = createConsentInvokeHarness({
241
+ invokeConversationId: "19:victim@thread.v2;messageid=abc123",
242
+ action: "accept",
243
+ consentCardActivityId: "consent-card-activity-id-fail",
244
+ });
245
+ updateActivity.mockRejectedValueOnce(new Error("Teams API error"));
246
+
247
+ await respondToMSTeamsFileConsentInvoke(context, log);
248
+
249
+ // Upload should have completed despite updateActivity failure
250
+ expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
251
+ expect(getPendingUpload(uploadId)).toBeUndefined();
252
+ expect(updateActivity).toHaveBeenCalledTimes(1);
253
+ });
254
+
179
255
  it("rejects cross-conversation accept invoke and keeps pending upload", async () => {
180
- const { uploadId, handler, context, sendActivity } = createConsentInvokeHarness({
256
+ const { uploadId, context, sendActivity } = createConsentInvokeHarness({
181
257
  invokeConversationId: "19:attacker@thread.v2",
182
258
  action: "accept",
183
259
  });
184
260
 
185
- await handler.run?.(context);
261
+ await respondToMSTeamsFileConsentInvoke(context, log);
186
262
 
187
263
  // invokeResponse should be sent immediately
188
264
  expect(sendActivity).toHaveBeenCalledWith(
@@ -196,16 +272,20 @@ describe("msteams file consent invoke authz", () => {
196
272
  );
197
273
 
198
274
  expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
199
- expect(getPendingUpload(uploadId)).toBeDefined();
275
+ expect(requirePendingUpload(uploadId)).toMatchObject({
276
+ conversationId: "19:victim@thread.v2",
277
+ filename: "secret.txt",
278
+ contentType: "text/plain",
279
+ });
200
280
  });
201
281
 
202
282
  it("ignores cross-conversation decline invoke and keeps pending upload", async () => {
203
- const { uploadId, handler, context, sendActivity } = createConsentInvokeHarness({
283
+ const { uploadId, context, sendActivity } = createConsentInvokeHarness({
204
284
  invokeConversationId: "19:attacker@thread.v2",
205
285
  action: "decline",
206
286
  });
207
287
 
208
- await handler.run?.(context);
288
+ await respondToMSTeamsFileConsentInvoke(context, log);
209
289
 
210
290
  // invokeResponse should be sent immediately
211
291
  expect(sendActivity).toHaveBeenCalledWith(
@@ -215,7 +295,129 @@ describe("msteams file consent invoke authz", () => {
215
295
  );
216
296
 
217
297
  expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
218
- expect(getPendingUpload(uploadId)).toBeDefined();
298
+ expect(requirePendingUpload(uploadId)).toMatchObject({
299
+ conversationId: "19:victim@thread.v2",
300
+ filename: "secret.txt",
301
+ contentType: "text/plain",
302
+ });
219
303
  expect(sendActivity).toHaveBeenCalledTimes(1);
220
304
  });
221
305
  });
306
+
307
+ describe("msteams file consent invoke FS fallback", () => {
308
+ let tmpDir: string;
309
+ let originalStateDir: string | undefined;
310
+
311
+ beforeEach(async () => {
312
+ originalStateDir = process.env.OPENCLAW_STATE_DIR;
313
+ tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-invoke-"));
314
+ process.env.OPENCLAW_STATE_DIR = tmpDir;
315
+ setMSTeamsRuntime(createRuntimeStub(tmpDir));
316
+ clearPendingUploads();
317
+ vi.clearAllMocks();
318
+ fileConsentMockState.uploadToConsentUrl.mockReset();
319
+ fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined);
320
+ });
321
+
322
+ afterEach(async () => {
323
+ if (originalStateDir === undefined) {
324
+ delete process.env.OPENCLAW_STATE_DIR;
325
+ } else {
326
+ process.env.OPENCLAW_STATE_DIR = originalStateDir;
327
+ }
328
+ try {
329
+ await fs.promises.rm(tmpDir, { recursive: true, force: true });
330
+ } catch {
331
+ // tmp dir may already be gone
332
+ }
333
+ });
334
+
335
+ it("reads pending upload from FS store when in-memory store is empty (cross-process CLI path)", async () => {
336
+ // Simulate the CLI process writing to the FS store before exiting; the
337
+ // in-memory store in this (monitor) process is empty.
338
+ const uploadId = "cli-upload-id-123";
339
+ const conversationId = "19:victim@thread.v2";
340
+ await storePendingUploadFs({
341
+ id: uploadId,
342
+ buffer: Buffer.from("CLI PAYLOAD"),
343
+ filename: "cli.bin",
344
+ contentType: "application/octet-stream",
345
+ conversationId,
346
+ });
347
+
348
+ expect(getPendingUpload(uploadId)).toBeUndefined();
349
+
350
+ const sendActivity = vi.fn(async () => ({ id: "activity-id" }));
351
+ const updateActivity = vi.fn(async () => ({ id: "activity-id" }));
352
+ const context = {
353
+ activity: {
354
+ type: "invoke",
355
+ name: "fileConsent/invoke",
356
+ conversation: { id: `${conversationId};messageid=abc123` },
357
+ value: {
358
+ type: "fileUpload",
359
+ action: "accept",
360
+ uploadInfo: {
361
+ name: "cli.bin",
362
+ uploadUrl: "https://upload.example.com/put",
363
+ contentUrl: "https://content.example.com/cli.bin",
364
+ uniqueId: "unique-cli",
365
+ fileType: "bin",
366
+ },
367
+ context: { uploadId },
368
+ },
369
+ },
370
+ sendActivity,
371
+ sendActivities: async () => [],
372
+ updateActivity,
373
+ } as unknown as MSTeamsTurnContext;
374
+
375
+ await respondToMSTeamsFileConsentInvoke(context, log);
376
+
377
+ // The upload should have run using the FS-loaded buffer
378
+ expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
379
+ expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledWith(
380
+ expect.objectContaining({
381
+ url: "https://upload.example.com/put",
382
+ }),
383
+ );
384
+
385
+ // FS entry should have been cleaned up after successful upload
386
+ expect(await getPendingUploadFs(uploadId)).toBeUndefined();
387
+ });
388
+
389
+ it("cleans up FS entry on decline even when in-memory store is empty", async () => {
390
+ const uploadId = "cli-decline-id";
391
+ const conversationId = "19:victim@thread.v2";
392
+ await storePendingUploadFs({
393
+ id: uploadId,
394
+ buffer: Buffer.from("DECLINED"),
395
+ filename: "decline.txt",
396
+ contentType: "text/plain",
397
+ conversationId,
398
+ });
399
+
400
+ const sendActivity = vi.fn(async () => ({ id: "activity-id" }));
401
+ const updateActivity = vi.fn(async () => ({ id: "activity-id" }));
402
+ const context = {
403
+ activity: {
404
+ type: "invoke",
405
+ name: "fileConsent/invoke",
406
+ conversation: { id: `${conversationId};messageid=abc123` },
407
+ value: {
408
+ type: "fileUpload",
409
+ action: "decline",
410
+ context: { uploadId },
411
+ },
412
+ },
413
+ sendActivity,
414
+ sendActivities: async () => [],
415
+ updateActivity,
416
+ } as unknown as MSTeamsTurnContext;
417
+
418
+ await respondToMSTeamsFileConsentInvoke(context, log);
419
+
420
+ expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
421
+ expect(await getPendingUploadFs(uploadId)).toBeUndefined();
422
+ });
423
+ });