@vama/openclaw 2026.5.5-4 → 2026.5.5-6
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 +5 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +88 -0
- package/openclaw.plugin.json +21 -0
- package/package.json +1 -17
- package/runtime-api.ts +2 -0
- package/src/accounts.test.ts +236 -0
- package/src/accounts.ts +96 -0
- package/src/bot.test.ts +520 -0
- package/src/bot.ts +345 -0
- package/src/channel-actions.test.ts +289 -0
- package/src/channel-actions.ts +242 -0
- package/src/channel.test.ts +150 -0
- package/src/channel.ts +256 -0
- package/src/cli-metadata.ts +19 -0
- package/src/cli.test.ts +311 -0
- package/src/cli.ts +341 -0
- package/src/client.test.ts +550 -0
- package/src/client.ts +685 -0
- package/src/connect-code.test.ts +98 -0
- package/src/connect-code.ts +108 -0
- package/src/connect-verify.test.ts +210 -0
- package/src/connect-verify.ts +201 -0
- package/src/dedup.ts +53 -0
- package/src/fc-keepalive.test.ts +200 -0
- package/src/fc-keepalive.ts +184 -0
- package/src/host-pairing-access.ts +48 -0
- package/src/host-sdk.ts +43 -0
- package/src/monitor-secret-file.test.ts +151 -0
- package/src/monitor-ws.test.ts +155 -0
- package/src/monitor.ts +344 -0
- package/src/outbound.ts +92 -0
- package/src/probe.test.ts +191 -0
- package/src/probe.ts +90 -0
- package/src/register.test.ts +158 -0
- package/src/register.ts +116 -0
- package/src/reply-dispatcher.test.ts +419 -0
- package/src/reply-dispatcher.ts +398 -0
- package/src/runtime.ts +14 -0
- package/src/send.test.ts +65 -0
- package/src/send.ts +30 -0
- package/src/setup-surface.ts +388 -0
- package/src/subagent-keepalive-hooks.test.ts +50 -0
- package/src/subagent-keepalive-hooks.ts +47 -0
- package/src/types.ts +162 -0
- package/src/webhook.test.ts +88 -0
- package/src/webhook.ts +51 -0
- package/src/ws.test.ts +341 -0
- package/src/ws.ts +294 -0
- package/dist/api-lhR0QgC_.js +0 -12
- package/dist/api.js +0 -3
- package/dist/channel-plugin-api-YGbkWmVM.js +0 -729
- package/dist/channel-plugin-api.js +0 -2
- package/dist/client-AsD46gcK.js +0 -367
- package/dist/index.js +0 -55
- package/dist/monitor-CHFjRu2J.js +0 -1277
- package/dist/runtime-api.js +0 -2
- package/dist/runtime-w-1oL50p.js +0 -11
package/src/bot.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { resolveVamaAccount } from "./accounts.js";
|
|
2
|
+
import { tryRecordMessagePersistent } from "./dedup.js";
|
|
3
|
+
import { dispatchStarted, dispatchEnded } from "./fc-keepalive.js";
|
|
4
|
+
import type { ClawdbotConfig, RuntimeEnv } from "./host-sdk.js";
|
|
5
|
+
import { createScopedPairingAccess } from "./host-sdk.js";
|
|
6
|
+
import { createVamaReplyDispatcher } from "./reply-dispatcher.js";
|
|
7
|
+
import { getVamaRuntime } from "./runtime.js";
|
|
8
|
+
import { sendMessageVama } from "./send.js";
|
|
9
|
+
import type { VamaAttachment, VamaMessageEvent } from "./types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Per-attachment fetch ceiling. Mirrors the limit `core.channel.media`
|
|
13
|
+
* already enforces internally on most provider channels (Telegram /
|
|
14
|
+
* WhatsApp / Zalo); kept explicit here so the ceiling is visible at the
|
|
15
|
+
* call site and can be surfaced later as a config knob without changing
|
|
16
|
+
* the `core` API contract.
|
|
17
|
+
*/
|
|
18
|
+
const VAMA_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
export async function handleVamaMessage(params: {
|
|
21
|
+
cfg: ClawdbotConfig;
|
|
22
|
+
event: VamaMessageEvent;
|
|
23
|
+
runtime?: RuntimeEnv;
|
|
24
|
+
accountId?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Optional replay marker forwarded by the OCC api when the Stripe
|
|
27
|
+
* webhook re-delivers a user message that was stashed mid-flight
|
|
28
|
+
* (vama-billing-model-2026 replay-on-payment). When set, we bypass
|
|
29
|
+
* the message-id dedup gate so the original prompt is re-dispatched
|
|
30
|
+
* against the now-replenished wallet — without this, the message_id
|
|
31
|
+
* matches the prior delivery and `tryRecordMessagePersistent` filters
|
|
32
|
+
* the replay as a duplicate, leaving the user's prompt unanswered.
|
|
33
|
+
* Value is `pi_<paymentIntentId>`; opaque to this plugin.
|
|
34
|
+
*/
|
|
35
|
+
replayId?: string;
|
|
36
|
+
}): Promise<void> {
|
|
37
|
+
const { cfg, event, runtime, accountId, replayId } = params;
|
|
38
|
+
const account = resolveVamaAccount({ cfg, accountId });
|
|
39
|
+
const vamaCfg = account.config;
|
|
40
|
+
|
|
41
|
+
const log = runtime?.log ?? console.log;
|
|
42
|
+
const error = runtime?.error ?? console.error;
|
|
43
|
+
|
|
44
|
+
// Dedup check — skipped on replay so the user's pending prompt actually
|
|
45
|
+
// re-dispatches after a top-up (the OCC api forwards x-replay-id on
|
|
46
|
+
// post-payment loopbacks; see `apps/api/src/routes/vama-webhook.ts`).
|
|
47
|
+
if (replayId) {
|
|
48
|
+
log(
|
|
49
|
+
`vama[${account.accountId}]: replay (${replayId}) — bypassing dedup for ${event.message_id}`,
|
|
50
|
+
);
|
|
51
|
+
} else if (!(await tryRecordMessagePersistent(event.message_id, account.accountId, log))) {
|
|
52
|
+
log(`vama: skipping duplicate message ${event.message_id}`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const senderId = event.sender_id;
|
|
57
|
+
const senderName = event.sender_name ?? senderId;
|
|
58
|
+
const text = event.text ?? "";
|
|
59
|
+
const channelId = event.channel_id;
|
|
60
|
+
const messageId = event.message_id;
|
|
61
|
+
const parentId = event.parent_id;
|
|
62
|
+
const parentText =
|
|
63
|
+
typeof event.parent_text === "string" && event.parent_text.trim().length > 0
|
|
64
|
+
? event.parent_text
|
|
65
|
+
: undefined;
|
|
66
|
+
const parentSenderName =
|
|
67
|
+
typeof event.parent_sender_name === "string" && event.parent_sender_name.trim().length > 0
|
|
68
|
+
? event.parent_sender_name
|
|
69
|
+
: undefined;
|
|
70
|
+
const parentSenderId =
|
|
71
|
+
typeof event.parent_sender_id === "string" && event.parent_sender_id.trim().length > 0
|
|
72
|
+
? event.parent_sender_id
|
|
73
|
+
: undefined;
|
|
74
|
+
const parentSender = parentSenderName ?? parentSenderId;
|
|
75
|
+
// BotHub forwards inbound media as an `attachments` array (one entry
|
|
76
|
+
// per file the user uploaded). Filter to attachments with a usable
|
|
77
|
+
// URL so we don't spend a fetch round-trip on malformed payloads.
|
|
78
|
+
const attachments: VamaAttachment[] = Array.isArray(event.attachments)
|
|
79
|
+
? event.attachments.filter(
|
|
80
|
+
(a): a is VamaAttachment => Boolean(a) && typeof a.url === "string" && a.url.length > 0,
|
|
81
|
+
)
|
|
82
|
+
: [];
|
|
83
|
+
const parentAttachments: VamaAttachment[] = Array.isArray(event.parent_attachments)
|
|
84
|
+
? event.parent_attachments.filter(
|
|
85
|
+
(a): a is VamaAttachment => Boolean(a) && typeof a.url === "string" && a.url.length > 0,
|
|
86
|
+
)
|
|
87
|
+
: [];
|
|
88
|
+
|
|
89
|
+
// Attachments-only messages (e.g. user sends a photo with no caption)
|
|
90
|
+
// arrive with `text=""`. Don't drop them — multimodal LLMs can answer
|
|
91
|
+
// off the image alone.
|
|
92
|
+
if (!text.trim() && attachments.length === 0) {
|
|
93
|
+
log(`vama[${account.accountId}]: ignoring empty message ${messageId}`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
log(`vama[${account.accountId}]: received message from ${senderId} in ${channelId}`);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const core = getVamaRuntime();
|
|
101
|
+
|
|
102
|
+
// DM policy check
|
|
103
|
+
const dmPolicy = vamaCfg?.dmPolicy ?? "open";
|
|
104
|
+
const configAllowFrom = (vamaCfg?.allowFrom ?? []).map((e) => String(e));
|
|
105
|
+
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
|
106
|
+
|
|
107
|
+
const pairing = createScopedPairingAccess({
|
|
108
|
+
core,
|
|
109
|
+
channel: "vama",
|
|
110
|
+
accountId: account.accountId,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
|
|
114
|
+
text,
|
|
115
|
+
cfg,
|
|
116
|
+
);
|
|
117
|
+
const storeAllowFrom =
|
|
118
|
+
dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeCommandAuthorized)
|
|
119
|
+
? await pairing.readAllowFromStore().catch(() => [])
|
|
120
|
+
: [];
|
|
121
|
+
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
|
122
|
+
const dmAllowed =
|
|
123
|
+
dmPolicy === "open"
|
|
124
|
+
? effectiveDmAllowFrom.length === 0 || effectiveDmAllowFrom.includes(senderId)
|
|
125
|
+
: effectiveDmAllowFrom.includes(senderId);
|
|
126
|
+
|
|
127
|
+
if (dmPolicy !== "open" && !dmAllowed) {
|
|
128
|
+
if (dmPolicy === "pairing") {
|
|
129
|
+
const { code, created } = await pairing.upsertPairingRequest({
|
|
130
|
+
id: senderId,
|
|
131
|
+
meta: { name: senderName },
|
|
132
|
+
});
|
|
133
|
+
if (created) {
|
|
134
|
+
log(`vama[${account.accountId}]: pairing request sender=${senderId}`);
|
|
135
|
+
try {
|
|
136
|
+
await sendMessageVama({
|
|
137
|
+
cfg,
|
|
138
|
+
to: channelId,
|
|
139
|
+
text: core.channel.pairing.buildPairingReply({
|
|
140
|
+
channel: "vama",
|
|
141
|
+
idLine: `Your Vama user id: ${senderId}`,
|
|
142
|
+
code,
|
|
143
|
+
}),
|
|
144
|
+
accountId: account.accountId,
|
|
145
|
+
});
|
|
146
|
+
} catch (err) {
|
|
147
|
+
log(`vama[${account.accountId}]: pairing reply failed for ${senderId}: ${String(err)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
log(
|
|
152
|
+
`vama[${account.accountId}]: blocked unauthorized sender ${senderId} (dmPolicy=${dmPolicy})`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const commandAllowFrom = effectiveDmAllowFrom;
|
|
159
|
+
const senderAllowedForCommands =
|
|
160
|
+
commandAllowFrom.length === 0 || commandAllowFrom.includes(senderId);
|
|
161
|
+
const commandAuthorized = shouldComputeCommandAuthorized
|
|
162
|
+
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
163
|
+
useAccessGroups,
|
|
164
|
+
authorizers: [
|
|
165
|
+
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
|
166
|
+
],
|
|
167
|
+
})
|
|
168
|
+
: undefined;
|
|
169
|
+
|
|
170
|
+
const vamaFrom = `vama:${senderId}`;
|
|
171
|
+
const vamaTo = `channel:${channelId}`;
|
|
172
|
+
|
|
173
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
174
|
+
cfg,
|
|
175
|
+
channel: "vama",
|
|
176
|
+
accountId: account.accountId,
|
|
177
|
+
peer: {
|
|
178
|
+
kind: "direct",
|
|
179
|
+
id: senderId,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const preview = text.replace(/\s+/g, " ").slice(0, 160);
|
|
184
|
+
core.system.enqueueSystemEvent(`Vama[${account.accountId}] DM from ${senderName}: ${preview}`, {
|
|
185
|
+
sessionKey: route.sessionKey,
|
|
186
|
+
contextKey: `vama:message:${channelId}:${messageId}`,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
190
|
+
const messageBody = `${senderName}: ${text}`;
|
|
191
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
192
|
+
channel: "Vama",
|
|
193
|
+
from: senderId,
|
|
194
|
+
timestamp: new Date(),
|
|
195
|
+
envelope: envelopeOptions,
|
|
196
|
+
body: messageBody,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// On replay, suffix MessageSid with the replay marker so the openclaw
|
|
200
|
+
// SDK's in-process inbound-dedupe cache (auto-reply/reply/inbound-dedupe.ts,
|
|
201
|
+
// 20-min TTL keyed on provider|account|session|peer|thread|messageId) sees
|
|
202
|
+
// a fresh key. Bypassing only the persistent message-id dedup at the top
|
|
203
|
+
// of this handler isn't enough — the SDK has its own in-memory gate that
|
|
204
|
+
// also fires on identical inbound ids, returning queuedFinal=false in
|
|
205
|
+
// ~3ms and leaving the user's pending prompt unanswered post-top-up.
|
|
206
|
+
// Vama uses UUID message ids (non-numeric), so the abort-cutoff path's
|
|
207
|
+
// numeric MessageSid comparator is unaffected.
|
|
208
|
+
const dispatchMessageSid = replayId ? `${messageId}#replay-${replayId.slice(-12)}` : messageId;
|
|
209
|
+
|
|
210
|
+
// Resolve attachment URLs into local files before we hand control to
|
|
211
|
+
// the agent runtime. `core.channel.media.fetchRemoteMedia` performs
|
|
212
|
+
// the HTTP fetch with SSRF / size guards, then `saveMediaBuffer`
|
|
213
|
+
// persists the bytes under `<media-dir>/inbound/<uuid>` and returns
|
|
214
|
+
// the absolute path. `finalizeInboundContext` reads MediaPaths /
|
|
215
|
+
// MediaUrls / MediaTypes (or the singular variants) to construct
|
|
216
|
+
// multimodal LLM content blocks — same path Telegram and Zalo
|
|
217
|
+
// already use for inbound media. Per-attachment failures are
|
|
218
|
+
// logged and skipped: text-only fallback is preferable to dropping
|
|
219
|
+
// the whole message.
|
|
220
|
+
const fetchAttachments = async (items: VamaAttachment[], label: string) => {
|
|
221
|
+
const resolved: { path: string; contentType?: string }[] = [];
|
|
222
|
+
for (const attachment of items) {
|
|
223
|
+
try {
|
|
224
|
+
const fetched = await core.channel.media.fetchRemoteMedia({
|
|
225
|
+
url: attachment.url,
|
|
226
|
+
maxBytes: VAMA_ATTACHMENT_MAX_BYTES,
|
|
227
|
+
});
|
|
228
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
229
|
+
fetched.buffer,
|
|
230
|
+
fetched.contentType,
|
|
231
|
+
"inbound",
|
|
232
|
+
VAMA_ATTACHMENT_MAX_BYTES,
|
|
233
|
+
);
|
|
234
|
+
resolved.push({ path: saved.path, contentType: saved.contentType });
|
|
235
|
+
} catch (err) {
|
|
236
|
+
error(
|
|
237
|
+
`vama[${account.accountId}]: failed to fetch ${label} ${attachment.url}: ${String(err)}`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return resolved;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const resolvedCurrentMedia = await fetchAttachments(attachments, "attachment");
|
|
245
|
+
const resolvedParentMedia = await fetchAttachments(
|
|
246
|
+
parentAttachments,
|
|
247
|
+
"reply-target attachment",
|
|
248
|
+
);
|
|
249
|
+
const resolvedMedia = [...resolvedCurrentMedia, ...resolvedParentMedia];
|
|
250
|
+
const mediaPaths = resolvedMedia.map((m) => m.path);
|
|
251
|
+
const mediaTypes = resolvedMedia
|
|
252
|
+
.map((m) => m.contentType)
|
|
253
|
+
.filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
254
|
+
const replyTargetMediaNote =
|
|
255
|
+
resolvedParentMedia.length > 0
|
|
256
|
+
? `[reply target media attachments included as additional media inputs: ${resolvedParentMedia.length}]`
|
|
257
|
+
: undefined;
|
|
258
|
+
const replyToBody =
|
|
259
|
+
parentText && replyTargetMediaNote
|
|
260
|
+
? `${parentText}\n\n${replyTargetMediaNote}`
|
|
261
|
+
: (parentText ?? replyTargetMediaNote);
|
|
262
|
+
|
|
263
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
264
|
+
Body: body,
|
|
265
|
+
BodyForAgent: messageBody,
|
|
266
|
+
RawBody: text,
|
|
267
|
+
CommandBody: text,
|
|
268
|
+
From: vamaFrom,
|
|
269
|
+
To: vamaTo,
|
|
270
|
+
SessionKey: route.sessionKey,
|
|
271
|
+
AccountId: route.accountId,
|
|
272
|
+
ChatType: "direct",
|
|
273
|
+
SenderName: senderName,
|
|
274
|
+
SenderId: senderId,
|
|
275
|
+
Provider: "vama" as const,
|
|
276
|
+
Surface: "vama" as const,
|
|
277
|
+
MessageSid: dispatchMessageSid,
|
|
278
|
+
ReplyToId: parentId,
|
|
279
|
+
ReplyToBody: replyToBody,
|
|
280
|
+
ReplyToSender: parentSender,
|
|
281
|
+
ReplyToIsQuote: replyToBody ? true : undefined,
|
|
282
|
+
Timestamp: Date.now(),
|
|
283
|
+
CommandAuthorized: commandAuthorized,
|
|
284
|
+
OriginatingChannel: "vama" as const,
|
|
285
|
+
OriginatingTo: vamaTo,
|
|
286
|
+
// First-item singular keys preserved for older agent-runtime
|
|
287
|
+
// builds; full lists for the multimodal-content-block path.
|
|
288
|
+
// `finalizeInboundContext` itself pads MediaTypes to match
|
|
289
|
+
// MediaPaths length and supplies a default MIME if missing.
|
|
290
|
+
MediaPath: mediaPaths[0],
|
|
291
|
+
MediaType: mediaTypes[0],
|
|
292
|
+
MediaUrl: mediaPaths[0],
|
|
293
|
+
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
294
|
+
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
295
|
+
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const { dispatcher, replyOptions, markDispatchIdle } = createVamaReplyDispatcher({
|
|
299
|
+
cfg,
|
|
300
|
+
agentId: route.agentId,
|
|
301
|
+
runtime,
|
|
302
|
+
channelId,
|
|
303
|
+
replyToMessageId: parentId,
|
|
304
|
+
accountId: account.accountId,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
log(`vama[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
|
|
308
|
+
// Bracket the dispatch with FC keepalive so vmctl's idle-pauser
|
|
309
|
+
// doesn't snapshot the VM mid-tool-call. See fc-keepalive.ts for
|
|
310
|
+
// the rationale (it's a no-op outside FC).
|
|
311
|
+
//
|
|
312
|
+
// dispatchStarted() goes inside the try so that if it ever throws
|
|
313
|
+
// synchronously (it shouldn't — it only mutates module state and
|
|
314
|
+
// schedules a timer — but this is a counter-leak failsafe), the
|
|
315
|
+
// counter doesn't get stuck at >0 with no matching dispatchEnded.
|
|
316
|
+
let keepaliveActive = false;
|
|
317
|
+
try {
|
|
318
|
+
dispatchStarted();
|
|
319
|
+
keepaliveActive = true;
|
|
320
|
+
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
|
|
321
|
+
dispatcher,
|
|
322
|
+
onSettled: () => {
|
|
323
|
+
markDispatchIdle();
|
|
324
|
+
},
|
|
325
|
+
run: () =>
|
|
326
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
327
|
+
ctx: ctxPayload,
|
|
328
|
+
cfg,
|
|
329
|
+
dispatcher,
|
|
330
|
+
replyOptions,
|
|
331
|
+
}),
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
log(
|
|
335
|
+
`vama[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
|
|
336
|
+
);
|
|
337
|
+
} finally {
|
|
338
|
+
if (keepaliveActive) {
|
|
339
|
+
dispatchEnded();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
} catch (err) {
|
|
343
|
+
error(`vama[${account.accountId}]: failed to dispatch message: ${String(err)}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk/vama";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const sendMessageVamaMock = vi.hoisted(() => vi.fn());
|
|
5
|
+
const sendFileMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
const createBotHubClientMock = vi.hoisted(() =>
|
|
7
|
+
vi.fn(() => ({
|
|
8
|
+
sendFile: sendFileMock,
|
|
9
|
+
})),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
vi.mock("./send.js", () => ({
|
|
13
|
+
sendMessageVama: sendMessageVamaMock,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("./client.js", async (importOriginal) => {
|
|
17
|
+
const mod = await importOriginal<typeof import("./client.js")>();
|
|
18
|
+
return {
|
|
19
|
+
...mod,
|
|
20
|
+
createBotHubClient: createBotHubClientMock,
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
import { vamaMessageActions } from "./channel-actions.js";
|
|
25
|
+
|
|
26
|
+
function makeCfg(vama?: Record<string, unknown>): ClawdbotConfig {
|
|
27
|
+
return { channels: { vama } } as ClawdbotConfig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CONFIGURED_CFG = makeCfg({ enabled: true, botToken: "tok", bothubUrl: "https://b.example" });
|
|
31
|
+
const UNCONFIGURED_CFG = makeCfg({ enabled: true });
|
|
32
|
+
|
|
33
|
+
describe("vamaMessageActions", () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
sendMessageVamaMock.mockReset().mockResolvedValue({
|
|
36
|
+
messageId: "msg-text-123",
|
|
37
|
+
channelId: "chan-1",
|
|
38
|
+
});
|
|
39
|
+
sendFileMock.mockReset().mockResolvedValue({
|
|
40
|
+
message_id: "msg-file-456",
|
|
41
|
+
channel_id: "chan-1",
|
|
42
|
+
});
|
|
43
|
+
createBotHubClientMock.mockClear();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("describeMessageTool", () => {
|
|
51
|
+
it("exposes `send` when account is configured", () => {
|
|
52
|
+
const discovery = vamaMessageActions.describeMessageTool({
|
|
53
|
+
cfg: CONFIGURED_CFG,
|
|
54
|
+
accountId: "default",
|
|
55
|
+
});
|
|
56
|
+
expect(discovery?.actions).toContain("send");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns empty actions when account is not configured", () => {
|
|
60
|
+
const discovery = vamaMessageActions.describeMessageTool({
|
|
61
|
+
cfg: UNCONFIGURED_CFG,
|
|
62
|
+
accountId: "default",
|
|
63
|
+
});
|
|
64
|
+
expect(discovery?.actions ?? []).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("declares no extra capabilities (no presentations/buttons/polls/etc)", () => {
|
|
68
|
+
const discovery = vamaMessageActions.describeMessageTool({
|
|
69
|
+
cfg: CONFIGURED_CFG,
|
|
70
|
+
accountId: "default",
|
|
71
|
+
});
|
|
72
|
+
expect(discovery?.capabilities ?? []).toEqual([]);
|
|
73
|
+
expect(discovery?.schema ?? null).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("resolveExecutionMode", () => {
|
|
78
|
+
it("always runs in the gateway", () => {
|
|
79
|
+
for (const action of ["send"] as const) {
|
|
80
|
+
expect(vamaMessageActions.resolveExecutionMode?.({ action })).toBe("gateway");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("extractToolSend", () => {
|
|
86
|
+
it("extracts target when action is `send`", () => {
|
|
87
|
+
const send = vamaMessageActions.extractToolSend?.({
|
|
88
|
+
args: { action: "send", to: "chan-1" },
|
|
89
|
+
});
|
|
90
|
+
expect(send).toMatchObject({ to: "chan-1" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns null for non-send actions", () => {
|
|
94
|
+
const send = vamaMessageActions.extractToolSend?.({
|
|
95
|
+
args: { action: "react", to: "chan-1" },
|
|
96
|
+
});
|
|
97
|
+
expect(send).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("handleAction", () => {
|
|
102
|
+
it("rejects non-send actions", async () => {
|
|
103
|
+
await expect(
|
|
104
|
+
vamaMessageActions.handleAction!({
|
|
105
|
+
channel: "vama",
|
|
106
|
+
action: "react",
|
|
107
|
+
params: { to: "chan-1" },
|
|
108
|
+
cfg: CONFIGURED_CFG,
|
|
109
|
+
accountId: "default",
|
|
110
|
+
} as never),
|
|
111
|
+
).rejects.toThrow(/Unsupported Vama action: react/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("rejects sends when account is not configured", async () => {
|
|
115
|
+
await expect(
|
|
116
|
+
vamaMessageActions.handleAction!({
|
|
117
|
+
channel: "vama",
|
|
118
|
+
action: "send",
|
|
119
|
+
params: { to: "chan-1", caption: "hi" },
|
|
120
|
+
cfg: UNCONFIGURED_CFG,
|
|
121
|
+
accountId: "default",
|
|
122
|
+
} as never),
|
|
123
|
+
).rejects.toThrow(/not configured/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("text-only sends go through sendMessageVama and return a jsonResult", async () => {
|
|
127
|
+
const result = await vamaMessageActions.handleAction!({
|
|
128
|
+
channel: "vama",
|
|
129
|
+
action: "send",
|
|
130
|
+
params: { to: "chan-1", caption: "hello world" },
|
|
131
|
+
cfg: CONFIGURED_CFG,
|
|
132
|
+
accountId: "default",
|
|
133
|
+
} as never);
|
|
134
|
+
|
|
135
|
+
expect(sendMessageVamaMock).toHaveBeenCalledWith(
|
|
136
|
+
expect.objectContaining({
|
|
137
|
+
to: "chan-1",
|
|
138
|
+
text: "hello world",
|
|
139
|
+
accountId: "default",
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
expect(sendFileMock).not.toHaveBeenCalled();
|
|
143
|
+
// The tool result is text-encoded JSON (jsonResult) — assert the
|
|
144
|
+
// shape via the embedded `details` payload that openclaw attaches.
|
|
145
|
+
const details = (result as { details?: { ok?: boolean; messageId?: string } }).details;
|
|
146
|
+
expect(details).toMatchObject({
|
|
147
|
+
ok: true,
|
|
148
|
+
messageId: "msg-text-123",
|
|
149
|
+
channelId: "chan-1",
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("local-path media sends go through client.sendFile", async () => {
|
|
154
|
+
const result = await vamaMessageActions.handleAction!({
|
|
155
|
+
channel: "vama",
|
|
156
|
+
action: "send",
|
|
157
|
+
params: {
|
|
158
|
+
to: "chan-1",
|
|
159
|
+
media: "/home/app/.openclaw/workspace/paper.html",
|
|
160
|
+
caption: "Here you go",
|
|
161
|
+
},
|
|
162
|
+
cfg: CONFIGURED_CFG,
|
|
163
|
+
accountId: "default",
|
|
164
|
+
} as never);
|
|
165
|
+
|
|
166
|
+
expect(createBotHubClientMock).toHaveBeenCalled();
|
|
167
|
+
expect(sendFileMock).toHaveBeenCalledWith(
|
|
168
|
+
expect.objectContaining({
|
|
169
|
+
channelId: "chan-1",
|
|
170
|
+
path: "/home/app/.openclaw/workspace/paper.html",
|
|
171
|
+
caption: "Here you go",
|
|
172
|
+
// `.html` maps to the catch-all "file" attachment hint.
|
|
173
|
+
attachmentType: "file",
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
expect(sendMessageVamaMock).not.toHaveBeenCalled();
|
|
177
|
+
const details = (result as { details?: { messageId?: string } }).details;
|
|
178
|
+
expect(details).toMatchObject({
|
|
179
|
+
ok: true,
|
|
180
|
+
messageId: "msg-file-456",
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it.each([
|
|
185
|
+
{ name: "media", body: { media: "/work/a.png" } },
|
|
186
|
+
{ name: "path", body: { path: "/work/a.png" } },
|
|
187
|
+
{ name: "filePath", body: { filePath: "/work/a.png" } },
|
|
188
|
+
])("accepts `$name` as the media field alias", async ({ body }) => {
|
|
189
|
+
await vamaMessageActions.handleAction!({
|
|
190
|
+
channel: "vama",
|
|
191
|
+
action: "send",
|
|
192
|
+
params: { to: "chan-1", ...body },
|
|
193
|
+
cfg: CONFIGURED_CFG,
|
|
194
|
+
accountId: "default",
|
|
195
|
+
} as never);
|
|
196
|
+
|
|
197
|
+
expect(sendFileMock).toHaveBeenCalledWith(
|
|
198
|
+
expect.objectContaining({ path: "/work/a.png", attachmentType: "image" }),
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it.each([
|
|
203
|
+
{ name: "to", body: { to: "chan-1" } },
|
|
204
|
+
{ name: "channelId", body: { channelId: "chan-1" } },
|
|
205
|
+
{ name: "target", body: { target: "chan-1" } },
|
|
206
|
+
])("accepts `$name` as the channel target alias", async ({ body }) => {
|
|
207
|
+
await vamaMessageActions.handleAction!({
|
|
208
|
+
channel: "vama",
|
|
209
|
+
action: "send",
|
|
210
|
+
params: { ...body, caption: "hi" },
|
|
211
|
+
cfg: CONFIGURED_CFG,
|
|
212
|
+
accountId: "default",
|
|
213
|
+
} as never);
|
|
214
|
+
|
|
215
|
+
expect(sendMessageVamaMock).toHaveBeenCalledWith(
|
|
216
|
+
expect.objectContaining({ to: "chan-1", text: "hi" }),
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("remote https URLs fall back to sending the link as text", async () => {
|
|
221
|
+
const result = await vamaMessageActions.handleAction!({
|
|
222
|
+
channel: "vama",
|
|
223
|
+
action: "send",
|
|
224
|
+
params: {
|
|
225
|
+
to: "chan-1",
|
|
226
|
+
media: "https://example.com/image.jpg",
|
|
227
|
+
caption: "look at this",
|
|
228
|
+
},
|
|
229
|
+
cfg: CONFIGURED_CFG,
|
|
230
|
+
accountId: "default",
|
|
231
|
+
} as never);
|
|
232
|
+
|
|
233
|
+
expect(sendFileMock).not.toHaveBeenCalled();
|
|
234
|
+
expect(sendMessageVamaMock).toHaveBeenCalledWith(
|
|
235
|
+
expect.objectContaining({
|
|
236
|
+
to: "chan-1",
|
|
237
|
+
text: "look at this\nhttps://example.com/image.jpg",
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
const details = (result as { details?: { warning?: string } }).details;
|
|
241
|
+
expect(details?.warning).toBe("remote_media_unsupported_sent_as_link");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("file:// URLs are unwrapped to a local path", async () => {
|
|
245
|
+
await vamaMessageActions.handleAction!({
|
|
246
|
+
channel: "vama",
|
|
247
|
+
action: "send",
|
|
248
|
+
params: {
|
|
249
|
+
to: "chan-1",
|
|
250
|
+
media: "file:///work/scratch.txt",
|
|
251
|
+
},
|
|
252
|
+
cfg: CONFIGURED_CFG,
|
|
253
|
+
accountId: "default",
|
|
254
|
+
} as never);
|
|
255
|
+
|
|
256
|
+
expect(sendFileMock).toHaveBeenCalledWith(
|
|
257
|
+
expect.objectContaining({ path: "/work/scratch.txt" }),
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("forwards replyTo as parentId on text sends", async () => {
|
|
262
|
+
await vamaMessageActions.handleAction!({
|
|
263
|
+
channel: "vama",
|
|
264
|
+
action: "send",
|
|
265
|
+
params: { to: "chan-1", caption: "thread reply", replyTo: "msg-99" },
|
|
266
|
+
cfg: CONFIGURED_CFG,
|
|
267
|
+
accountId: "default",
|
|
268
|
+
} as never);
|
|
269
|
+
|
|
270
|
+
expect(sendMessageVamaMock).toHaveBeenCalledWith(
|
|
271
|
+
expect.objectContaining({ parentId: "msg-99" }),
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("forwards replyTo as parentId on file sends", async () => {
|
|
276
|
+
await vamaMessageActions.handleAction!({
|
|
277
|
+
channel: "vama",
|
|
278
|
+
action: "send",
|
|
279
|
+
params: { to: "chan-1", media: "/work/a.pdf", replyTo: "msg-100" },
|
|
280
|
+
cfg: CONFIGURED_CFG,
|
|
281
|
+
accountId: "default",
|
|
282
|
+
} as never);
|
|
283
|
+
|
|
284
|
+
expect(sendFileMock).toHaveBeenCalledWith(
|
|
285
|
+
expect.objectContaining({ parentId: "msg-100", attachmentType: "file" }),
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|