@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.
- package/api.ts +3 -0
- package/channel-config-api.ts +1 -0
- package/channel-plugin-api.ts +2 -0
- package/config-api.ts +4 -0
- package/contract-api.ts +4 -0
- package/index.ts +15 -12
- package/openclaw.plugin.json +553 -1
- package/package.json +46 -12
- package/runtime-api.ts +73 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/src/ai-entity.ts +7 -0
- package/src/approval-auth.ts +44 -0
- package/src/attachments/bot-framework.test.ts +461 -0
- package/src/attachments/bot-framework.ts +362 -0
- package/src/attachments/download.ts +63 -19
- package/src/attachments/graph.test.ts +416 -0
- package/src/attachments/graph.ts +163 -72
- package/src/attachments/html.ts +33 -1
- package/src/attachments/payload.ts +1 -1
- package/src/attachments/remote-media.test.ts +137 -0
- package/src/attachments/remote-media.ts +75 -8
- package/src/attachments/shared.test.ts +138 -1
- package/src/attachments/shared.ts +193 -26
- package/src/attachments/types.ts +10 -0
- package/src/attachments.graph.test.ts +342 -0
- package/src/attachments.helpers.test.ts +246 -0
- package/src/attachments.test-helpers.ts +17 -0
- package/src/attachments.test.ts +163 -418
- package/src/attachments.ts +5 -5
- package/src/block-streaming-config.test.ts +61 -0
- package/src/channel-api.ts +1 -0
- package/src/channel.actions.test.ts +742 -0
- package/src/channel.directory.test.ts +145 -4
- package/src/channel.runtime.ts +56 -0
- package/src/channel.setup.ts +77 -0
- package/src/channel.test.ts +128 -0
- package/src/channel.ts +1077 -395
- package/src/config-schema.ts +6 -0
- package/src/config-ui-hints.ts +12 -0
- package/src/conversation-store-fs.test.ts +4 -5
- package/src/conversation-store-fs.ts +35 -51
- package/src/conversation-store-helpers.test.ts +202 -0
- package/src/conversation-store-helpers.ts +105 -0
- package/src/conversation-store-memory.ts +27 -23
- package/src/conversation-store.shared.test.ts +225 -0
- package/src/conversation-store.ts +30 -0
- package/src/directory-live.test.ts +156 -0
- package/src/directory-live.ts +7 -4
- package/src/doctor.ts +27 -0
- package/src/errors.test.ts +64 -1
- package/src/errors.ts +50 -9
- package/src/feedback-reflection-prompt.ts +117 -0
- package/src/feedback-reflection-store.ts +114 -0
- package/src/feedback-reflection.test.ts +237 -0
- package/src/feedback-reflection.ts +283 -0
- package/src/file-consent-helpers.test.ts +83 -0
- package/src/file-consent-helpers.ts +64 -11
- package/src/file-consent-invoke.ts +150 -0
- package/src/file-consent.test.ts +363 -0
- package/src/file-consent.ts +165 -4
- package/src/graph-chat.ts +5 -3
- package/src/graph-group-management.test.ts +318 -0
- package/src/graph-group-management.ts +168 -0
- package/src/graph-members.test.ts +89 -0
- package/src/graph-members.ts +48 -0
- package/src/graph-messages.actions.test.ts +243 -0
- package/src/graph-messages.read.test.ts +391 -0
- package/src/graph-messages.search.test.ts +213 -0
- package/src/graph-messages.test-helpers.ts +50 -0
- package/src/graph-messages.ts +534 -0
- package/src/graph-teams.test.ts +215 -0
- package/src/graph-teams.ts +114 -0
- package/src/graph-thread.test.ts +246 -0
- package/src/graph-thread.ts +146 -0
- package/src/graph-upload.test.ts +161 -4
- package/src/graph-upload.ts +147 -56
- package/src/graph.test.ts +516 -0
- package/src/graph.ts +233 -21
- package/src/inbound.test.ts +156 -1
- package/src/inbound.ts +101 -1
- package/src/media-helpers.ts +1 -1
- package/src/mentions.test.ts +27 -18
- package/src/mentions.ts +2 -2
- package/src/messenger.test.ts +504 -23
- package/src/messenger.ts +133 -52
- package/src/monitor-handler/access.ts +125 -0
- package/src/monitor-handler/inbound-media.test.ts +289 -0
- package/src/monitor-handler/inbound-media.ts +57 -5
- package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
- package/src/monitor-handler/message-handler.authz.test.ts +588 -74
- package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
- package/src/monitor-handler/message-handler.test-support.ts +100 -0
- package/src/monitor-handler/message-handler.thread-parent.test.ts +223 -0
- package/src/monitor-handler/message-handler.thread-session.test.ts +77 -0
- package/src/monitor-handler/message-handler.ts +470 -164
- package/src/monitor-handler/reaction-handler.test.ts +267 -0
- package/src/monitor-handler/reaction-handler.ts +210 -0
- package/src/monitor-handler/thread-session.ts +17 -0
- package/src/monitor-handler.adaptive-card.test.ts +162 -0
- package/src/monitor-handler.feedback-authz.test.ts +314 -0
- package/src/monitor-handler.file-consent.test.ts +281 -79
- package/src/monitor-handler.sso.test.ts +563 -0
- package/src/monitor-handler.test-helpers.ts +180 -0
- package/src/monitor-handler.ts +459 -115
- package/src/monitor-handler.types.ts +27 -0
- package/src/monitor-types.ts +1 -0
- package/src/monitor.lifecycle.test.ts +74 -10
- package/src/monitor.test.ts +35 -1
- package/src/monitor.ts +143 -46
- package/src/oauth.flow.ts +77 -0
- package/src/oauth.shared.ts +37 -0
- package/src/oauth.test.ts +305 -0
- package/src/oauth.token.ts +158 -0
- package/src/oauth.ts +130 -0
- package/src/outbound.test.ts +10 -11
- package/src/outbound.ts +62 -44
- package/src/pending-uploads-fs.test.ts +246 -0
- package/src/pending-uploads-fs.ts +235 -0
- package/src/pending-uploads.test.ts +173 -0
- package/src/pending-uploads.ts +34 -2
- package/src/policy.test.ts +11 -5
- package/src/policy.ts +5 -5
- package/src/polls.test.ts +106 -5
- package/src/polls.ts +15 -7
- package/src/presentation.ts +68 -0
- package/src/probe.test.ts +27 -8
- package/src/probe.ts +43 -9
- package/src/reply-dispatcher.test.ts +437 -0
- package/src/reply-dispatcher.ts +259 -73
- package/src/reply-stream-controller.test.ts +235 -0
- package/src/reply-stream-controller.ts +147 -0
- package/src/resolve-allowlist.test.ts +105 -1
- package/src/resolve-allowlist.ts +112 -7
- package/src/runtime.ts +6 -3
- package/src/sdk-types.ts +43 -3
- package/src/sdk.test.ts +666 -0
- package/src/sdk.ts +867 -16
- package/src/secret-contract.ts +49 -0
- package/src/secret-input.ts +1 -1
- package/src/send-context.ts +76 -9
- package/src/send.test.ts +389 -5
- package/src/send.ts +140 -32
- package/src/sent-message-cache.ts +30 -18
- package/src/session-route.ts +40 -0
- package/src/setup-core.ts +160 -0
- package/src/setup-surface.test.ts +202 -0
- package/src/setup-surface.ts +320 -0
- package/src/sso-token-store.test.ts +72 -0
- package/src/sso-token-store.ts +166 -0
- package/src/sso.ts +300 -0
- package/src/storage.ts +1 -1
- package/src/store-fs.ts +2 -2
- package/src/streaming-message.test.ts +262 -0
- package/src/streaming-message.ts +297 -0
- package/src/test-runtime.ts +1 -1
- package/src/thread-parent-context.test.ts +224 -0
- package/src/thread-parent-context.ts +159 -0
- package/src/token.test.ts +237 -50
- package/src/token.ts +162 -7
- package/src/user-agent.test.ts +86 -0
- package/src/user-agent.ts +53 -0
- package/src/webhook-timeouts.ts +27 -0
- package/src/welcome-card.test.ts +81 -0
- package/src/welcome-card.ts +57 -0
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -107
- package/src/file-lock.ts +0 -1
- package/src/graph-users.test.ts +0 -66
- package/src/onboarding.ts +0 -381
- package/src/polls-store.test.ts +0 -38
- package/src/revoked-context.test.ts +0 -39
- 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
|
-
|
|
17
|
+
type FileConsentMedia = {
|
|
16
18
|
buffer: Buffer;
|
|
17
19
|
filename: string;
|
|
18
20
|
contentType?: string;
|
|
19
21
|
};
|
|
20
22
|
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
context: { uploadId },
|
|
96
|
+
contentType: media.contentType,
|
|
97
|
+
conversationId,
|
|
49
98
|
});
|
|
50
99
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
+
}
|