@openclaw/msteams 2026.3.12 → 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 +161 -9
  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 +174 -437
  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 +148 -14
  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 +258 -0
  78. package/src/graph-upload.ts +87 -8
  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 +522 -45
  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 +477 -174
  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 +301 -106
  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 +34 -40
  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 -101
  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,35 +98,58 @@ 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
 
108
+ function createConsentInvokeHarness(params: {
109
+ pendingConversationId?: string;
110
+ invokeConversationId: string;
111
+ action: "accept" | "decline";
112
+ consentCardActivityId?: string;
113
+ }) {
114
+ const uploadId = storePendingUpload({
115
+ buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
116
+ filename: "secret.txt",
117
+ contentType: "text/plain",
118
+ conversationId: params.pendingConversationId ?? "19:victim@thread.v2",
119
+ consentCardActivityId: params.consentCardActivityId,
120
+ });
121
+ const { context, sendActivity, updateActivity } = createInvokeContext({
122
+ conversationId: params.invokeConversationId,
123
+ uploadId,
124
+ action: params.action,
125
+ });
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;
135
+ }
136
+
126
137
  describe("msteams file consent invoke authz", () => {
127
138
  beforeEach(() => {
128
139
  setMSTeamsRuntime(runtimeStub);
129
140
  clearPendingUploads();
141
+ vi.clearAllMocks();
130
142
  fileConsentMockState.uploadToConsentUrl.mockReset();
131
143
  fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined);
132
144
  });
133
145
 
134
146
  it("uploads when invoke conversation matches pending upload conversation", async () => {
135
- const uploadId = storePendingUpload({
136
- buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
137
- filename: "secret.txt",
138
- contentType: "text/plain",
139
- conversationId: "19:victim@thread.v2",
140
- });
141
- const deps = createDeps();
142
- const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
143
- const { context, sendActivity } = createInvokeContext({
144
- conversationId: "19:victim@thread.v2;messageid=abc123",
145
- uploadId,
147
+ const { uploadId, context, sendActivity } = createConsentInvokeHarness({
148
+ invokeConversationId: "19:victim@thread.v2;messageid=abc123",
146
149
  action: "accept",
147
150
  });
148
151
 
149
- await handler.run?.(context);
152
+ await respondToMSTeamsFileConsentInvoke(context, log);
150
153
 
151
154
  // invokeResponse should be sent immediately
152
155
  expect(sendActivity).toHaveBeenCalledWith(
@@ -165,22 +168,97 @@ describe("msteams file consent invoke authz", () => {
165
168
  expect(getPendingUpload(uploadId)).toBeUndefined();
166
169
  });
167
170
 
168
- it("rejects cross-conversation accept invoke and keeps pending upload", async () => {
169
- const uploadId = storePendingUpload({
170
- buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
171
- filename: "secret.txt",
172
- contentType: "text/plain",
173
- conversationId: "19:victim@thread.v2",
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",
174
176
  });
175
- const deps = createDeps();
176
- const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
177
- const { context, sendActivity } = createInvokeContext({
178
- conversationId: "19:attacker@thread.v2",
179
- uploadId,
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",
180
201
  action: "accept",
202
+ consentCardActivityId: "consent-card-activity-id-happy",
181
203
  });
182
204
 
183
- await handler.run?.(context);
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
+
255
+ it("rejects cross-conversation accept invoke and keeps pending upload", async () => {
256
+ const { uploadId, context, sendActivity } = createConsentInvokeHarness({
257
+ invokeConversationId: "19:attacker@thread.v2",
258
+ action: "accept",
259
+ });
260
+
261
+ await respondToMSTeamsFileConsentInvoke(context, log);
184
262
 
185
263
  // invokeResponse should be sent immediately
186
264
  expect(sendActivity).toHaveBeenCalledWith(
@@ -194,25 +272,20 @@ describe("msteams file consent invoke authz", () => {
194
272
  );
195
273
 
196
274
  expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
197
- expect(getPendingUpload(uploadId)).toBeDefined();
198
- });
199
-
200
- it("ignores cross-conversation decline invoke and keeps pending upload", async () => {
201
- const uploadId = storePendingUpload({
202
- buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
275
+ expect(requirePendingUpload(uploadId)).toMatchObject({
276
+ conversationId: "19:victim@thread.v2",
203
277
  filename: "secret.txt",
204
278
  contentType: "text/plain",
205
- conversationId: "19:victim@thread.v2",
206
279
  });
207
- const deps = createDeps();
208
- const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
209
- const { context, sendActivity } = createInvokeContext({
210
- conversationId: "19:attacker@thread.v2",
211
- uploadId,
280
+ });
281
+
282
+ it("ignores cross-conversation decline invoke and keeps pending upload", async () => {
283
+ const { uploadId, context, sendActivity } = createConsentInvokeHarness({
284
+ invokeConversationId: "19:attacker@thread.v2",
212
285
  action: "decline",
213
286
  });
214
287
 
215
- await handler.run?.(context);
288
+ await respondToMSTeamsFileConsentInvoke(context, log);
216
289
 
217
290
  // invokeResponse should be sent immediately
218
291
  expect(sendActivity).toHaveBeenCalledWith(
@@ -222,7 +295,129 @@ describe("msteams file consent invoke authz", () => {
222
295
  );
223
296
 
224
297
  expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
225
- expect(getPendingUpload(uploadId)).toBeDefined();
298
+ expect(requirePendingUpload(uploadId)).toMatchObject({
299
+ conversationId: "19:victim@thread.v2",
300
+ filename: "secret.txt",
301
+ contentType: "text/plain",
302
+ });
226
303
  expect(sendActivity).toHaveBeenCalledTimes(1);
227
304
  });
228
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
+ });