@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.test.ts
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
|
2
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk/vama";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { handleVamaMessage } from "./bot.js";
|
|
5
|
+
import type { VamaMessageEvent } from "./types.js";
|
|
6
|
+
|
|
7
|
+
// --- Module mocks ---
|
|
8
|
+
|
|
9
|
+
vi.mock("./dedup.js", () => ({
|
|
10
|
+
tryRecordMessagePersistent: vi.fn().mockResolvedValue(true),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("./reply-dispatcher.js", () => ({
|
|
14
|
+
createVamaReplyDispatcher: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("./send.js", () => ({
|
|
18
|
+
sendMessageVama: vi.fn().mockResolvedValue({ messageId: "msg-1", channelId: "ch-1" }),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("./runtime.js", () => ({
|
|
22
|
+
getVamaRuntime: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
import { tryRecordMessagePersistent } from "./dedup.js";
|
|
26
|
+
import { createVamaReplyDispatcher } from "./reply-dispatcher.js";
|
|
27
|
+
import { getVamaRuntime } from "./runtime.js";
|
|
28
|
+
import { sendMessageVama } from "./send.js";
|
|
29
|
+
|
|
30
|
+
// --- Helpers ---
|
|
31
|
+
|
|
32
|
+
function makeCfg(overrides?: Record<string, unknown>): ClawdbotConfig {
|
|
33
|
+
return {
|
|
34
|
+
channels: {
|
|
35
|
+
vama: {
|
|
36
|
+
enabled: true,
|
|
37
|
+
botToken: "tok-test",
|
|
38
|
+
bothubUrl: "https://bothub.example.com",
|
|
39
|
+
dmPolicy: "open",
|
|
40
|
+
...overrides,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
} as ClawdbotConfig;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeEvent(overrides?: Partial<VamaMessageEvent>): VamaMessageEvent {
|
|
47
|
+
return {
|
|
48
|
+
message_id: "msg-abc",
|
|
49
|
+
channel_id: "ch-xyz",
|
|
50
|
+
sender_id: "user-1",
|
|
51
|
+
sender_name: "Test User",
|
|
52
|
+
text: "hello world",
|
|
53
|
+
created_at: new Date().toISOString(),
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeDispatcher() {
|
|
59
|
+
const markDispatchIdle = vi.fn();
|
|
60
|
+
const dispatcher = {};
|
|
61
|
+
const replyOptions = {};
|
|
62
|
+
return { dispatcher, replyOptions, markDispatchIdle };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeRuntime() {
|
|
66
|
+
const enqueueSystemEvent = vi.fn();
|
|
67
|
+
const withReplyDispatcher = vi
|
|
68
|
+
.fn()
|
|
69
|
+
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
|
|
70
|
+
const dispatchReplyFromConfig = vi.fn().mockResolvedValue(undefined);
|
|
71
|
+
// Default media stubs: produce a deterministic local path per attachment
|
|
72
|
+
// so tests can assert MediaPaths/MediaUrls were threaded through.
|
|
73
|
+
// Tests that exercise failure paths replace these per-call.
|
|
74
|
+
const fetchRemoteMedia = vi.fn().mockImplementation(async ({ url }: { url: string }) => ({
|
|
75
|
+
buffer: Buffer.from(`bytes:${url}`),
|
|
76
|
+
contentType: "image/png",
|
|
77
|
+
}));
|
|
78
|
+
let savedCount = 0;
|
|
79
|
+
const saveMediaBuffer = vi
|
|
80
|
+
.fn()
|
|
81
|
+
.mockImplementation(async (_buffer: Buffer, contentType: string | undefined) => {
|
|
82
|
+
const idx = savedCount++;
|
|
83
|
+
return { id: `id-${idx}`, path: `/tmp/inbound-${idx}.bin`, size: 0, contentType };
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
channel: {
|
|
88
|
+
routing: {
|
|
89
|
+
resolveAgentRoute: vi.fn().mockReturnValue({
|
|
90
|
+
sessionKey: "session-key-1",
|
|
91
|
+
agentId: "agent-1",
|
|
92
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
reply: {
|
|
96
|
+
resolveEnvelopeFormatOptions: vi.fn().mockReturnValue({}),
|
|
97
|
+
formatAgentEnvelope: vi.fn().mockReturnValue("formatted body"),
|
|
98
|
+
finalizeInboundContext: vi.fn().mockImplementation((payload) => payload),
|
|
99
|
+
resolveHumanDelayConfig: vi.fn().mockReturnValue(undefined),
|
|
100
|
+
withReplyDispatcher,
|
|
101
|
+
dispatchReplyFromConfig,
|
|
102
|
+
},
|
|
103
|
+
commands: {
|
|
104
|
+
shouldComputeCommandAuthorized: vi.fn().mockReturnValue(false),
|
|
105
|
+
resolveCommandAuthorizedFromAuthorizers: vi.fn().mockReturnValue(true),
|
|
106
|
+
},
|
|
107
|
+
pairing: {
|
|
108
|
+
buildPairingReply: vi.fn().mockReturnValue("Pairing request received."),
|
|
109
|
+
},
|
|
110
|
+
text: {
|
|
111
|
+
resolveTextChunkLimit: vi.fn().mockReturnValue(10000),
|
|
112
|
+
resolveChunkMode: vi.fn().mockReturnValue("word"),
|
|
113
|
+
chunkTextWithMode: vi.fn().mockReturnValue(["reply text"]),
|
|
114
|
+
},
|
|
115
|
+
media: {
|
|
116
|
+
fetchRemoteMedia,
|
|
117
|
+
saveMediaBuffer,
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
system: {
|
|
121
|
+
enqueueSystemEvent,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- Tests ---
|
|
127
|
+
|
|
128
|
+
describe("handleVamaMessage", () => {
|
|
129
|
+
let mockRuntime: ReturnType<typeof makeRuntime>;
|
|
130
|
+
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
mockRuntime = makeRuntime();
|
|
133
|
+
vi.mocked(getVamaRuntime).mockReturnValue(mockRuntime as any);
|
|
134
|
+
vi.mocked(tryRecordMessagePersistent).mockResolvedValue(true);
|
|
135
|
+
vi.mocked(createVamaReplyDispatcher).mockReturnValue(makeDispatcher() as any);
|
|
136
|
+
vi.mocked(sendMessageVama).mockResolvedValue({ messageId: "msg-1", channelId: "ch-1" });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
afterEach(() => {
|
|
140
|
+
vi.clearAllMocks();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("skips duplicate messages", async () => {
|
|
144
|
+
vi.mocked(tryRecordMessagePersistent).mockResolvedValueOnce(false);
|
|
145
|
+
const log = vi.fn();
|
|
146
|
+
|
|
147
|
+
await handleVamaMessage({
|
|
148
|
+
cfg: makeCfg(),
|
|
149
|
+
event: makeEvent(),
|
|
150
|
+
runtime: { log } as any,
|
|
151
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(log).toHaveBeenCalledWith(expect.stringContaining("duplicate"));
|
|
155
|
+
expect(mockRuntime.system.enqueueSystemEvent).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("bypasses dedup when replayId is set (post-payment replay)", async () => {
|
|
159
|
+
// Even if the dedup store says "already seen", a replay must re-dispatch.
|
|
160
|
+
vi.mocked(tryRecordMessagePersistent).mockResolvedValueOnce(false);
|
|
161
|
+
const log = vi.fn();
|
|
162
|
+
|
|
163
|
+
await handleVamaMessage({
|
|
164
|
+
cfg: makeCfg(),
|
|
165
|
+
event: makeEvent(),
|
|
166
|
+
runtime: { log } as any,
|
|
167
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
168
|
+
replayId: "pi_test_123",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(log).toHaveBeenCalledWith(
|
|
172
|
+
expect.stringContaining("replay (pi_test_123) — bypassing dedup"),
|
|
173
|
+
);
|
|
174
|
+
// Dedup record was NOT consulted (we short-circuited the gate).
|
|
175
|
+
expect(vi.mocked(tryRecordMessagePersistent)).not.toHaveBeenCalled();
|
|
176
|
+
// And the message did dispatch.
|
|
177
|
+
expect(mockRuntime.system.enqueueSystemEvent).toHaveBeenCalledOnce();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("suffixes MessageSid on replay so the SDK inbound-dedupe cache misses", async () => {
|
|
181
|
+
// The openclaw SDK's dispatchReplyFromConfig has its own in-process
|
|
182
|
+
// inbound-dedupe (auto-reply/reply/inbound-dedupe.ts, 20-min TTL) keyed
|
|
183
|
+
// partly on MessageSid. Without a replay-distinct sid the second dispatch
|
|
184
|
+
// would early-bail with queuedFinal=false, leaving the user's prompt
|
|
185
|
+
// unanswered after top-up. See bot.ts for the full rationale.
|
|
186
|
+
const log = vi.fn();
|
|
187
|
+
const replayId = "pi_AAAAAAAAAAAAAAAAA123456789ABC";
|
|
188
|
+
const expectedSuffix = replayId.slice(-12);
|
|
189
|
+
|
|
190
|
+
await handleVamaMessage({
|
|
191
|
+
cfg: makeCfg(),
|
|
192
|
+
event: makeEvent({ message_id: "uuid-original" }),
|
|
193
|
+
runtime: { log } as any,
|
|
194
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
195
|
+
replayId,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(mockRuntime.channel.reply.finalizeInboundContext).toHaveBeenCalledWith(
|
|
199
|
+
expect.objectContaining({
|
|
200
|
+
MessageSid: `uuid-original#replay-${expectedSuffix}`,
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("does not suffix MessageSid on a normal (non-replay) inbound", async () => {
|
|
206
|
+
const log = vi.fn();
|
|
207
|
+
// Defensive: an earlier test may have queued a mockResolvedValueOnce
|
|
208
|
+
// that vitest's clearAllMocks does not drain. Reset and reassert.
|
|
209
|
+
vi.mocked(tryRecordMessagePersistent).mockReset();
|
|
210
|
+
vi.mocked(tryRecordMessagePersistent).mockResolvedValue(true);
|
|
211
|
+
|
|
212
|
+
await handleVamaMessage({
|
|
213
|
+
cfg: makeCfg(),
|
|
214
|
+
event: makeEvent({ message_id: "uuid-original" }),
|
|
215
|
+
runtime: { log } as any,
|
|
216
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(mockRuntime.channel.reply.finalizeInboundContext).toHaveBeenCalledWith(
|
|
220
|
+
expect.objectContaining({ MessageSid: "uuid-original" }),
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("maps parent message summary into reply context", async () => {
|
|
225
|
+
await handleVamaMessage({
|
|
226
|
+
cfg: makeCfg(),
|
|
227
|
+
event: makeEvent({
|
|
228
|
+
parent_id: "parent-1",
|
|
229
|
+
parent_sender_id: "bot-1",
|
|
230
|
+
parent_sender_name: "Atlas",
|
|
231
|
+
parent_text: "Pretty good - chilling in the digital ether.",
|
|
232
|
+
}),
|
|
233
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(mockRuntime.channel.reply.finalizeInboundContext).toHaveBeenCalledWith(
|
|
237
|
+
expect.objectContaining({
|
|
238
|
+
ReplyToId: "parent-1",
|
|
239
|
+
ReplyToBody: "Pretty good - chilling in the digital ether.",
|
|
240
|
+
ReplyToSender: "Atlas",
|
|
241
|
+
ReplyToIsQuote: true,
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("fetches parent attachments and appends them as reply-target media inputs", async () => {
|
|
247
|
+
await handleVamaMessage({
|
|
248
|
+
cfg: makeCfg(),
|
|
249
|
+
event: makeEvent({
|
|
250
|
+
text: "what is that?",
|
|
251
|
+
attachments: [{ type: "media_image", url: "https://example.invalid/current.png" }],
|
|
252
|
+
parent_id: "parent-1",
|
|
253
|
+
parent_sender_name: "Atlas",
|
|
254
|
+
parent_text: "[attachments]\n- type=image url=https://example.invalid/parent.png",
|
|
255
|
+
parent_attachments: [
|
|
256
|
+
{ type: "image", url: "https://example.invalid/parent.png", caption: "parent image" },
|
|
257
|
+
],
|
|
258
|
+
}),
|
|
259
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(mockRuntime.channel.media.fetchRemoteMedia).toHaveBeenNthCalledWith(
|
|
263
|
+
1,
|
|
264
|
+
expect.objectContaining({ url: "https://example.invalid/current.png" }),
|
|
265
|
+
);
|
|
266
|
+
expect(mockRuntime.channel.media.fetchRemoteMedia).toHaveBeenNthCalledWith(
|
|
267
|
+
2,
|
|
268
|
+
expect.objectContaining({ url: "https://example.invalid/parent.png" }),
|
|
269
|
+
);
|
|
270
|
+
expect(mockRuntime.channel.reply.finalizeInboundContext).toHaveBeenCalledWith(
|
|
271
|
+
expect.objectContaining({
|
|
272
|
+
ReplyToId: "parent-1",
|
|
273
|
+
ReplyToSender: "Atlas",
|
|
274
|
+
ReplyToBody: expect.stringContaining(
|
|
275
|
+
"[reply target media attachments included as additional media inputs: 1]",
|
|
276
|
+
),
|
|
277
|
+
MediaPaths: ["/tmp/inbound-0.bin", "/tmp/inbound-1.bin"],
|
|
278
|
+
MediaUrls: ["/tmp/inbound-0.bin", "/tmp/inbound-1.bin"],
|
|
279
|
+
MediaTypes: ["image/png", "image/png"],
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("ignores empty-text messages with no attachments", async () => {
|
|
285
|
+
const log = vi.fn();
|
|
286
|
+
|
|
287
|
+
await handleVamaMessage({
|
|
288
|
+
cfg: makeCfg(),
|
|
289
|
+
event: makeEvent({ text: " " }),
|
|
290
|
+
runtime: { log } as any,
|
|
291
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(log).toHaveBeenCalledWith(expect.stringContaining("empty message"));
|
|
295
|
+
expect(mockRuntime.system.enqueueSystemEvent).not.toHaveBeenCalled();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("dispatches attachments-only messages and threads MediaPath/MediaUrls into ctx", async () => {
|
|
299
|
+
await handleVamaMessage({
|
|
300
|
+
cfg: makeCfg({ dmPolicy: "open" }),
|
|
301
|
+
event: makeEvent({
|
|
302
|
+
text: "",
|
|
303
|
+
attachments: [{ type: "media_image", url: "https://example.invalid/cat.png" }],
|
|
304
|
+
}),
|
|
305
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(mockRuntime.channel.media.fetchRemoteMedia).toHaveBeenCalledWith(
|
|
309
|
+
expect.objectContaining({ url: "https://example.invalid/cat.png" }),
|
|
310
|
+
);
|
|
311
|
+
expect(mockRuntime.channel.media.saveMediaBuffer).toHaveBeenCalledWith(
|
|
312
|
+
expect.any(Buffer),
|
|
313
|
+
"image/png",
|
|
314
|
+
"inbound",
|
|
315
|
+
expect.any(Number),
|
|
316
|
+
);
|
|
317
|
+
expect(mockRuntime.channel.reply.finalizeInboundContext).toHaveBeenCalledWith(
|
|
318
|
+
expect.objectContaining({
|
|
319
|
+
MediaPath: "/tmp/inbound-0.bin",
|
|
320
|
+
MediaUrl: "/tmp/inbound-0.bin",
|
|
321
|
+
MediaType: "image/png",
|
|
322
|
+
MediaPaths: ["/tmp/inbound-0.bin"],
|
|
323
|
+
MediaUrls: ["/tmp/inbound-0.bin"],
|
|
324
|
+
MediaTypes: ["image/png"],
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
expect(mockRuntime.system.enqueueSystemEvent).toHaveBeenCalledOnce();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("threads multiple attachments into MediaPaths/MediaUrls in order", async () => {
|
|
331
|
+
await handleVamaMessage({
|
|
332
|
+
cfg: makeCfg({ dmPolicy: "open" }),
|
|
333
|
+
event: makeEvent({
|
|
334
|
+
text: "look at these",
|
|
335
|
+
attachments: [
|
|
336
|
+
{ type: "media_image", url: "https://example.invalid/a.png" },
|
|
337
|
+
{ type: "media_image", url: "https://example.invalid/b.png" },
|
|
338
|
+
],
|
|
339
|
+
}),
|
|
340
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(mockRuntime.channel.media.fetchRemoteMedia).toHaveBeenCalledTimes(2);
|
|
344
|
+
expect(mockRuntime.channel.media.saveMediaBuffer).toHaveBeenCalledTimes(2);
|
|
345
|
+
expect(mockRuntime.channel.reply.finalizeInboundContext).toHaveBeenCalledWith(
|
|
346
|
+
expect.objectContaining({
|
|
347
|
+
MediaPaths: ["/tmp/inbound-0.bin", "/tmp/inbound-1.bin"],
|
|
348
|
+
MediaUrls: ["/tmp/inbound-0.bin", "/tmp/inbound-1.bin"],
|
|
349
|
+
MediaTypes: ["image/png", "image/png"],
|
|
350
|
+
// singular keys mirror the first item.
|
|
351
|
+
MediaPath: "/tmp/inbound-0.bin",
|
|
352
|
+
MediaUrl: "/tmp/inbound-0.bin",
|
|
353
|
+
MediaType: "image/png",
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("dispatches without media when every attachment fetch fails", async () => {
|
|
359
|
+
const error = vi.fn();
|
|
360
|
+
vi.mocked(mockRuntime.channel.media.fetchRemoteMedia).mockRejectedValue(
|
|
361
|
+
new Error("HTTP 403 RBAC"),
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
await handleVamaMessage({
|
|
365
|
+
cfg: makeCfg({ dmPolicy: "open" }),
|
|
366
|
+
event: makeEvent({
|
|
367
|
+
text: "what is this",
|
|
368
|
+
attachments: [{ type: "media_image", url: "https://example.invalid/x.png" }],
|
|
369
|
+
}),
|
|
370
|
+
runtime: { error } as any,
|
|
371
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
expect(error).toHaveBeenCalledWith(expect.stringContaining("failed to fetch attachment"));
|
|
375
|
+
// Text-only fallback: dispatch still happens, but no Media* keys.
|
|
376
|
+
expect(mockRuntime.channel.media.saveMediaBuffer).not.toHaveBeenCalled();
|
|
377
|
+
const ctxCall = mockRuntime.channel.reply.finalizeInboundContext.mock.calls.at(-1);
|
|
378
|
+
expect(ctxCall).toBeDefined();
|
|
379
|
+
const ctx = ctxCall?.[0] as Record<string, unknown>;
|
|
380
|
+
expect(ctx.MediaPaths).toBeUndefined();
|
|
381
|
+
expect(ctx.MediaUrls).toBeUndefined();
|
|
382
|
+
expect(ctx.MediaTypes).toBeUndefined();
|
|
383
|
+
expect(ctx.MediaPath).toBeUndefined();
|
|
384
|
+
expect(mockRuntime.channel.reply.withReplyDispatcher).toHaveBeenCalledOnce();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("ignores attachments with empty/missing URLs", async () => {
|
|
388
|
+
await handleVamaMessage({
|
|
389
|
+
cfg: makeCfg({ dmPolicy: "open" }),
|
|
390
|
+
event: makeEvent({
|
|
391
|
+
text: "hi",
|
|
392
|
+
attachments: [
|
|
393
|
+
{ type: "media_image", url: "" },
|
|
394
|
+
// Cast — runtime payloads from BotHub can carry malformed entries.
|
|
395
|
+
{ type: "media_image" } as any,
|
|
396
|
+
{ type: "media_image", url: "https://example.invalid/ok.png" },
|
|
397
|
+
],
|
|
398
|
+
}),
|
|
399
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
expect(mockRuntime.channel.media.fetchRemoteMedia).toHaveBeenCalledTimes(1);
|
|
403
|
+
expect(mockRuntime.channel.media.fetchRemoteMedia).toHaveBeenCalledWith(
|
|
404
|
+
expect.objectContaining({ url: "https://example.invalid/ok.png" }),
|
|
405
|
+
);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("dispatches a message to the agent in open policy", async () => {
|
|
409
|
+
await handleVamaMessage({
|
|
410
|
+
cfg: makeCfg({ dmPolicy: "open" }),
|
|
411
|
+
event: makeEvent(),
|
|
412
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
expect(mockRuntime.system.enqueueSystemEvent).toHaveBeenCalledOnce();
|
|
416
|
+
expect(mockRuntime.channel.reply.withReplyDispatcher).toHaveBeenCalledOnce();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("enqueues a system event with sender name and message preview", async () => {
|
|
420
|
+
await handleVamaMessage({
|
|
421
|
+
cfg: makeCfg(),
|
|
422
|
+
event: makeEvent({ sender_name: "Alice", text: "hi there" }),
|
|
423
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
expect(mockRuntime.system.enqueueSystemEvent).toHaveBeenCalledWith(
|
|
427
|
+
expect.stringContaining("Alice"),
|
|
428
|
+
expect.objectContaining({ sessionKey: "session-key-1" }),
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("blocks unauthorized sender in allowlist policy", async () => {
|
|
433
|
+
const log = vi.fn();
|
|
434
|
+
|
|
435
|
+
await handleVamaMessage({
|
|
436
|
+
cfg: makeCfg({ dmPolicy: "allowlist", allowFrom: ["user-allowed"] }),
|
|
437
|
+
event: makeEvent({ sender_id: "user-blocked" }),
|
|
438
|
+
runtime: { log } as any,
|
|
439
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
expect(log).toHaveBeenCalledWith(expect.stringContaining("blocked"));
|
|
443
|
+
expect(mockRuntime.system.enqueueSystemEvent).not.toHaveBeenCalled();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("allows a sender in the allowFrom list under allowlist policy", async () => {
|
|
447
|
+
await handleVamaMessage({
|
|
448
|
+
cfg: makeCfg({ dmPolicy: "allowlist", allowFrom: ["user-1"] }),
|
|
449
|
+
event: makeEvent({ sender_id: "user-1" }),
|
|
450
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
expect(mockRuntime.system.enqueueSystemEvent).toHaveBeenCalledOnce();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("sends a pairing reply in pairing policy for unknown sender", async () => {
|
|
457
|
+
// Mock pairing runtime
|
|
458
|
+
const upsertPairingRequest = vi.fn().mockResolvedValue({ code: "CODE-1234", created: true });
|
|
459
|
+
const readAllowFromStore = vi.fn().mockResolvedValue([]);
|
|
460
|
+
|
|
461
|
+
// patch createScopedPairingAccess
|
|
462
|
+
vi.doMock("openclaw/plugin-sdk/vama", async (importOriginal) => {
|
|
463
|
+
const mod = await importOriginal<typeof import("openclaw/plugin-sdk/vama")>();
|
|
464
|
+
return {
|
|
465
|
+
...mod,
|
|
466
|
+
createScopedPairingAccess: vi.fn().mockReturnValue({
|
|
467
|
+
upsertPairingRequest,
|
|
468
|
+
readAllowFromStore,
|
|
469
|
+
}),
|
|
470
|
+
};
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const log = vi.fn();
|
|
474
|
+
|
|
475
|
+
await handleVamaMessage({
|
|
476
|
+
cfg: makeCfg({ dmPolicy: "pairing" }),
|
|
477
|
+
event: makeEvent({ sender_id: "user-unknown" }),
|
|
478
|
+
runtime: { log } as any,
|
|
479
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Whether pairing triggers depends on createScopedPairingAccess stub loading;
|
|
483
|
+
// at minimum the agent should NOT have received the message
|
|
484
|
+
expect(mockRuntime.system.enqueueSystemEvent).not.toHaveBeenCalled();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("resolves agent route using channel and accountId", async () => {
|
|
488
|
+
await handleVamaMessage({
|
|
489
|
+
cfg: makeCfg(),
|
|
490
|
+
event: makeEvent({ sender_id: "user-1", channel_id: "ch-99" }),
|
|
491
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
expect(mockRuntime.channel.routing.resolveAgentRoute).toHaveBeenCalledWith(
|
|
495
|
+
expect.objectContaining({
|
|
496
|
+
channel: "vama",
|
|
497
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
498
|
+
peer: expect.objectContaining({ id: "user-1" }),
|
|
499
|
+
}),
|
|
500
|
+
);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("logs error and does not throw on runtime failure", async () => {
|
|
504
|
+
const error = vi.fn();
|
|
505
|
+
vi.mocked(mockRuntime.channel.reply.withReplyDispatcher).mockRejectedValueOnce(
|
|
506
|
+
new Error("dispatch error"),
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
await expect(
|
|
510
|
+
handleVamaMessage({
|
|
511
|
+
cfg: makeCfg(),
|
|
512
|
+
event: makeEvent(),
|
|
513
|
+
runtime: { error } as any,
|
|
514
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
515
|
+
}),
|
|
516
|
+
).resolves.toBeUndefined();
|
|
517
|
+
|
|
518
|
+
expect(error).toHaveBeenCalledWith(expect.stringContaining("dispatch error"));
|
|
519
|
+
});
|
|
520
|
+
});
|