@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.
Files changed (58) hide show
  1. package/api.ts +5 -0
  2. package/channel-plugin-api.ts +1 -0
  3. package/index.ts +88 -0
  4. package/openclaw.plugin.json +21 -0
  5. package/package.json +1 -17
  6. package/runtime-api.ts +2 -0
  7. package/src/accounts.test.ts +236 -0
  8. package/src/accounts.ts +96 -0
  9. package/src/bot.test.ts +520 -0
  10. package/src/bot.ts +345 -0
  11. package/src/channel-actions.test.ts +289 -0
  12. package/src/channel-actions.ts +242 -0
  13. package/src/channel.test.ts +150 -0
  14. package/src/channel.ts +256 -0
  15. package/src/cli-metadata.ts +19 -0
  16. package/src/cli.test.ts +311 -0
  17. package/src/cli.ts +341 -0
  18. package/src/client.test.ts +550 -0
  19. package/src/client.ts +685 -0
  20. package/src/connect-code.test.ts +98 -0
  21. package/src/connect-code.ts +108 -0
  22. package/src/connect-verify.test.ts +210 -0
  23. package/src/connect-verify.ts +201 -0
  24. package/src/dedup.ts +53 -0
  25. package/src/fc-keepalive.test.ts +200 -0
  26. package/src/fc-keepalive.ts +184 -0
  27. package/src/host-pairing-access.ts +48 -0
  28. package/src/host-sdk.ts +43 -0
  29. package/src/monitor-secret-file.test.ts +151 -0
  30. package/src/monitor-ws.test.ts +155 -0
  31. package/src/monitor.ts +344 -0
  32. package/src/outbound.ts +92 -0
  33. package/src/probe.test.ts +191 -0
  34. package/src/probe.ts +90 -0
  35. package/src/register.test.ts +158 -0
  36. package/src/register.ts +116 -0
  37. package/src/reply-dispatcher.test.ts +419 -0
  38. package/src/reply-dispatcher.ts +398 -0
  39. package/src/runtime.ts +14 -0
  40. package/src/send.test.ts +65 -0
  41. package/src/send.ts +30 -0
  42. package/src/setup-surface.ts +388 -0
  43. package/src/subagent-keepalive-hooks.test.ts +50 -0
  44. package/src/subagent-keepalive-hooks.ts +47 -0
  45. package/src/types.ts +162 -0
  46. package/src/webhook.test.ts +88 -0
  47. package/src/webhook.ts +51 -0
  48. package/src/ws.test.ts +341 -0
  49. package/src/ws.ts +294 -0
  50. package/dist/api-lhR0QgC_.js +0 -12
  51. package/dist/api.js +0 -3
  52. package/dist/channel-plugin-api-YGbkWmVM.js +0 -729
  53. package/dist/channel-plugin-api.js +0 -2
  54. package/dist/client-AsD46gcK.js +0 -367
  55. package/dist/index.js +0 -55
  56. package/dist/monitor-CHFjRu2J.js +0 -1277
  57. package/dist/runtime-api.js +0 -2
  58. 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
+ });