Skip to content

Commit 5e0b7a9

Browse files
ClawdbotBradGroux
authored andcommitted
fix(msteams): delete FileConsentCard after user accepts, declines, or upload expires
The FileConsentCard (Allow/Decline prompt) previously remained in the chat after the user acted on it, cluttering the conversation with stale cards. Now the consent card message is deleted via context.deleteActivity() on all four paths: - User accepts and upload succeeds - User declines - Pending upload expired (not found in memory) - Conversation ID mismatch (cross-conversation replay protection) Deletion is best-effort with a try/catch — if it fails, the upload flow continues normally. Per Microsoft docs: 'Optionally, remove the original consent card if you do not want the user to accept further uploads of the same file.' https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4 Co-authored-by: Clawdbot <clawdbot@openclaw.ai>
1 parent b16bcda commit 5e0b7a9

3 files changed

Lines changed: 127 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ Docs: https://docs.openclaw.ai
177177
- Agents/context engine: invalidate cached assembled context views when source history shrinks or assembly fails, preventing stale pre-reset history from being reused. Fixes #77968. (#78163) Thanks @brokemac79 and @ChrisBot2026.
178178
- Plugin SDK: add a generic `api.runtime.llm.complete` host completion helper with runtime-derived caller attribution, config-gated model/agent overrides, session-bound context-engine access, request-scoped config, audit metadata, and normalized usage attribution. (#64294) Thanks @DaevMithran.
179179
- Control UI/exec approvals: highlight parsed shell command fragments that may deserve extra review in approval prompts. (#77153) Thanks @jesse-merhi.
180+
- MS Teams: clean up stale FileConsentCard prompts after file upload accept, decline, expiry, mismatch, or upload failure paths while preserving successful FileInfoCard replacement. (#57364) Thanks @HangGlidersRule.
180181

181182
### Breaking
182183

extensions/msteams/src/file-consent-invoke.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,24 @@ async function handleMSTeamsFileConsentInvoke(
4545
consentCardActivityId?: string;
4646
}
4747
| undefined = inMemoryFile ?? fsFile;
48+
const replyToActivityId = typeof activity.replyToId === "string" ? activity.replyToId : undefined;
49+
50+
async function tryDeleteConsentCard(reason: string, activityId?: string): Promise<void> {
51+
if (!activityId) {
52+
return;
53+
}
54+
try {
55+
await context.deleteActivity(activityId);
56+
log.debug?.("deleted file consent card", { activityId, reason });
57+
} catch (err) {
58+
log.debug?.("failed to delete file consent card", {
59+
activityId,
60+
reason,
61+
error: formatUnknownError(err),
62+
});
63+
}
64+
}
65+
4866
if (pendingFile) {
4967
const pendingConversationId = normalizeMSTeamsConversationId(pendingFile.conversationId);
5068
const invokeConversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
@@ -57,6 +75,7 @@ async function handleMSTeamsFileConsentInvoke(
5775
if (consentResponse.action === "accept") {
5876
await context.sendActivity(expiredUploadMessage);
5977
}
78+
await tryDeleteConsentCard("conversation-mismatch", replyToActivityId);
6079
return true;
6180
}
6281
}
@@ -102,7 +121,13 @@ async function handleMSTeamsFileConsentInvoke(
102121
type: "message",
103122
attachments: [fileInfoCard],
104123
});
124+
await tryDeleteConsentCard(
125+
"upload-success-update-failed",
126+
pendingFile.consentCardActivityId,
127+
);
105128
}
129+
} else {
130+
await tryDeleteConsentCard("upload-success", replyToActivityId);
106131
}
107132

108133
log.info("file upload complete", {
@@ -112,17 +137,26 @@ async function handleMSTeamsFileConsentInvoke(
112137
});
113138
} catch (err) {
114139
log.error("file upload failed", { uploadId, error: formatUnknownError(err) });
140+
await tryDeleteConsentCard(
141+
"upload-failed",
142+
pendingFile.consentCardActivityId ?? replyToActivityId,
143+
);
115144
await context.sendActivity("File upload failed. Please try again.");
116145
} finally {
117146
removePendingUpload(uploadId);
118147
await removePendingUploadFs(uploadId);
119148
}
120149
} else {
121150
log.debug?.("pending file not found for consent", { uploadId });
151+
await tryDeleteConsentCard("expired-pending-not-found", replyToActivityId);
122152
await context.sendActivity(expiredUploadMessage);
123153
}
124154
} else {
125155
log.debug?.("user declined file consent", { uploadId });
156+
await tryDeleteConsentCard(
157+
"user-declined",
158+
pendingFile?.consentCardActivityId ?? replyToActivityId,
159+
);
126160
removePendingUpload(uploadId);
127161
await removePendingUploadFs(uploadId);
128162
}

extensions/msteams/src/monitor-handler.file-consent.test.ts

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,16 @@ function createInvokeContext(params: {
6666
conversationId: string;
6767
uploadId: string;
6868
action: "accept" | "decline";
69+
replyToId?: string;
6970
}): {
7071
context: MSTeamsTurnContext;
7172
sendActivity: ReturnType<typeof vi.fn>;
7273
updateActivity: ReturnType<typeof vi.fn>;
74+
deleteActivity: ReturnType<typeof vi.fn>;
7375
} {
7476
const sendActivity = vi.fn(async () => ({ id: "activity-id" }));
7577
const updateActivity = vi.fn(async () => ({ id: "activity-id" }));
78+
const deleteActivity = vi.fn(async () => {});
7679
const uploadInfo =
7780
params.action === "accept"
7881
? {
@@ -89,6 +92,7 @@ function createInvokeContext(params: {
8992
type: "invoke",
9093
name: "fileConsent/invoke",
9194
conversation: { id: params.conversationId },
95+
replyToId: params.replyToId,
9296
value: {
9397
type: "fileUpload",
9498
action: params.action,
@@ -99,9 +103,11 @@ function createInvokeContext(params: {
99103
sendActivity,
100104
sendActivities: async () => [],
101105
updateActivity,
106+
deleteActivity,
102107
} as unknown as MSTeamsTurnContext,
103108
sendActivity,
104109
updateActivity,
110+
deleteActivity,
105111
};
106112
}
107113

@@ -110,6 +116,7 @@ function createConsentInvokeHarness(params: {
110116
invokeConversationId: string;
111117
action: "accept" | "decline";
112118
consentCardActivityId?: string;
119+
replyToId?: string;
113120
}) {
114121
const uploadId = storePendingUpload({
115122
buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
@@ -118,12 +125,13 @@ function createConsentInvokeHarness(params: {
118125
conversationId: params.pendingConversationId ?? "19:victim@thread.v2",
119126
consentCardActivityId: params.consentCardActivityId,
120127
});
121-
const { context, sendActivity, updateActivity } = createInvokeContext({
128+
const { context, sendActivity, updateActivity, deleteActivity } = createInvokeContext({
122129
conversationId: params.invokeConversationId,
123130
uploadId,
124131
action: params.action,
132+
replyToId: params.replyToId,
125133
});
126-
return { uploadId, context, sendActivity, updateActivity };
134+
return { uploadId, context, sendActivity, updateActivity, deleteActivity };
127135
}
128136

129137
function requirePendingUpload(uploadId: string) {
@@ -236,8 +244,21 @@ describe("msteams file consent invoke authz", () => {
236244
expect(updateActivity).not.toHaveBeenCalled();
237245
});
238246

247+
it("deletes the replied-to consent card after successful upload when no stored card id exists", async () => {
248+
const { context, deleteActivity } = createConsentInvokeHarness({
249+
invokeConversationId: "19:victim@thread.v2;messageid=abc123",
250+
action: "accept",
251+
replyToId: "reply-to-consent-card",
252+
});
253+
254+
await respondToMSTeamsFileConsentInvoke(context, log);
255+
256+
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
257+
expect(deleteActivity).toHaveBeenCalledWith("reply-to-consent-card");
258+
});
259+
239260
it("still completes upload if updateActivity throws", async () => {
240-
const { uploadId, context, updateActivity } = createConsentInvokeHarness({
261+
const { uploadId, context, updateActivity, deleteActivity } = createConsentInvokeHarness({
241262
invokeConversationId: "19:victim@thread.v2;messageid=abc123",
242263
action: "accept",
243264
consentCardActivityId: "consent-card-activity-id-fail",
@@ -250,12 +271,28 @@ describe("msteams file consent invoke authz", () => {
250271
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
251272
expect(getPendingUpload(uploadId)).toBeUndefined();
252273
expect(updateActivity).toHaveBeenCalledTimes(1);
274+
expect(deleteActivity).toHaveBeenCalledWith("consent-card-activity-id-fail");
275+
});
276+
277+
it("deletes the consent card when upload fails", async () => {
278+
fileConsentMockState.uploadToConsentUrl.mockRejectedValueOnce(new Error("upload failed"));
279+
const { context, sendActivity, deleteActivity } = createConsentInvokeHarness({
280+
invokeConversationId: "19:victim@thread.v2;messageid=abc123",
281+
action: "accept",
282+
consentCardActivityId: "consent-card-upload-failed",
283+
});
284+
285+
await respondToMSTeamsFileConsentInvoke(context, log);
286+
287+
expect(deleteActivity).toHaveBeenCalledWith("consent-card-upload-failed");
288+
expect(sendActivity).toHaveBeenCalledWith("File upload failed. Please try again.");
253289
});
254290

255291
it("rejects cross-conversation accept invoke and keeps pending upload", async () => {
256-
const { uploadId, context, sendActivity } = createConsentInvokeHarness({
292+
const { uploadId, context, sendActivity, deleteActivity } = createConsentInvokeHarness({
257293
invokeConversationId: "19:attacker@thread.v2",
258294
action: "accept",
295+
replyToId: "mismatched-consent-card",
259296
});
260297

261298
await respondToMSTeamsFileConsentInvoke(context, log);
@@ -272,6 +309,7 @@ describe("msteams file consent invoke authz", () => {
272309
);
273310

274311
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
312+
expect(deleteActivity).toHaveBeenCalledWith("mismatched-consent-card");
275313
expect(requirePendingUpload(uploadId)).toMatchObject({
276314
conversationId: "19:victim@thread.v2",
277315
filename: "secret.txt",
@@ -280,9 +318,10 @@ describe("msteams file consent invoke authz", () => {
280318
});
281319

282320
it("ignores cross-conversation decline invoke and keeps pending upload", async () => {
283-
const { uploadId, context, sendActivity } = createConsentInvokeHarness({
321+
const { uploadId, context, sendActivity, deleteActivity } = createConsentInvokeHarness({
284322
invokeConversationId: "19:attacker@thread.v2",
285323
action: "decline",
324+
replyToId: "mismatched-decline-consent-card",
286325
});
287326

288327
await respondToMSTeamsFileConsentInvoke(context, log);
@@ -295,13 +334,57 @@ describe("msteams file consent invoke authz", () => {
295334
);
296335

297336
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
337+
expect(deleteActivity).toHaveBeenCalledWith("mismatched-decline-consent-card");
298338
expect(requirePendingUpload(uploadId)).toMatchObject({
299339
conversationId: "19:victim@thread.v2",
300340
filename: "secret.txt",
301341
contentType: "text/plain",
302342
});
303343
expect(sendActivity).toHaveBeenCalledTimes(1);
304344
});
345+
346+
it("deletes the consent card when the pending upload has expired", async () => {
347+
const { context, sendActivity, deleteActivity } = createInvokeContext({
348+
conversationId: "19:victim@thread.v2;messageid=abc123",
349+
uploadId: "missing-upload-id",
350+
action: "accept",
351+
replyToId: "expired-consent-card",
352+
});
353+
354+
await respondToMSTeamsFileConsentInvoke(context, log);
355+
356+
expect(deleteActivity).toHaveBeenCalledWith("expired-consent-card");
357+
expect(sendActivity).toHaveBeenCalledWith(
358+
"The file upload request has expired. Please try sending the file again.",
359+
);
360+
});
361+
362+
it("deletes the consent card when the user declines", async () => {
363+
const { context, deleteActivity } = createConsentInvokeHarness({
364+
invokeConversationId: "19:victim@thread.v2;messageid=abc123",
365+
action: "decline",
366+
consentCardActivityId: "declined-consent-card",
367+
});
368+
369+
await respondToMSTeamsFileConsentInvoke(context, log);
370+
371+
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
372+
expect(deleteActivity).toHaveBeenCalledWith("declined-consent-card");
373+
});
374+
375+
it("continues when consent card deletion fails", async () => {
376+
const { uploadId, context, deleteActivity } = createConsentInvokeHarness({
377+
invokeConversationId: "19:victim@thread.v2;messageid=abc123",
378+
action: "decline",
379+
consentCardActivityId: "delete-fails-consent-card",
380+
});
381+
deleteActivity.mockRejectedValueOnce(new Error("delete failed"));
382+
383+
await respondToMSTeamsFileConsentInvoke(context, log);
384+
385+
expect(deleteActivity).toHaveBeenCalledWith("delete-fails-consent-card");
386+
expect(getPendingUpload(uploadId)).toBeUndefined();
387+
});
305388
});
306389

307390
describe("msteams file consent invoke FS fallback", () => {
@@ -349,6 +432,7 @@ describe("msteams file consent invoke FS fallback", () => {
349432

350433
const sendActivity = vi.fn(async () => ({ id: "activity-id" }));
351434
const updateActivity = vi.fn(async () => ({ id: "activity-id" }));
435+
const deleteActivity = vi.fn(async () => {});
352436
const context = {
353437
activity: {
354438
type: "invoke",
@@ -370,6 +454,7 @@ describe("msteams file consent invoke FS fallback", () => {
370454
sendActivity,
371455
sendActivities: async () => [],
372456
updateActivity,
457+
deleteActivity,
373458
} as unknown as MSTeamsTurnContext;
374459

375460
await respondToMSTeamsFileConsentInvoke(context, log);
@@ -399,6 +484,7 @@ describe("msteams file consent invoke FS fallback", () => {
399484

400485
const sendActivity = vi.fn(async () => ({ id: "activity-id" }));
401486
const updateActivity = vi.fn(async () => ({ id: "activity-id" }));
487+
const deleteActivity = vi.fn(async () => {});
402488
const context = {
403489
activity: {
404490
type: "invoke",
@@ -413,6 +499,7 @@ describe("msteams file consent invoke FS fallback", () => {
413499
sendActivity,
414500
sendActivities: async () => [],
415501
updateActivity,
502+
deleteActivity,
416503
} as unknown as MSTeamsTurnContext;
417504

418505
await respondToMSTeamsFileConsentInvoke(context, log);

0 commit comments

Comments
 (0)