@vama/openclaw 2026.5.5

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.
@@ -0,0 +1,959 @@
1
+ import { i as createBotHubClient, n as attachmentHintFromExtension } from "./client-AsD46gcK.js";
2
+ import { t as getVamaRuntime } from "./runtime-w-1oL50p.js";
3
+ import * as fs from "node:fs";
4
+ import { promises } from "node:fs";
5
+ import * as http from "node:http";
6
+ import { createDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime";
7
+ import { installRequestBodyLimitGuard } from "openclaw/plugin-sdk/webhook-request-guards";
8
+ import { logTypingFailure } from "openclaw/plugin-sdk/channel-logging";
9
+ import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-plugin-common";
10
+ import { createReplyPrefixContext, createTypingCallbacks } from "openclaw/plugin-sdk/channel-message";
11
+ import { createPersistentDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
12
+ import { buildBaseChannelStatusSummary, createDefaultChannelRuntimeState } from "openclaw/plugin-sdk/status-helpers";
13
+ import { DEFAULT_ACCOUNT_ID, DEFAULT_ACCOUNT_ID as DEFAULT_ACCOUNT_ID$1, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
14
+ import path from "node:path";
15
+ import { homedir } from "node:os";
16
+ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
17
+ import * as crypto from "node:crypto";
18
+ import { createHash } from "node:crypto";
19
+ //#region extensions/vama/src/host-pairing-access.ts
20
+ /** Scope pairing store operations to one channel/account pair for plugin-facing helpers. */
21
+ function createScopedPairingAccess(params) {
22
+ const resolvedAccountId = normalizeAccountId(params.accountId);
23
+ return {
24
+ accountId: resolvedAccountId,
25
+ readAllowFromStore: () => params.core.channel.pairing.readAllowFromStore({
26
+ channel: params.channel,
27
+ accountId: resolvedAccountId
28
+ }),
29
+ readStoreForDmPolicy: (provider, accountId) => params.core.channel.pairing.readAllowFromStore({
30
+ channel: provider,
31
+ accountId: normalizeAccountId(accountId)
32
+ }),
33
+ upsertPairingRequest: (input) => params.core.channel.pairing.upsertPairingRequest({
34
+ channel: params.channel,
35
+ accountId: resolvedAccountId,
36
+ ...input
37
+ })
38
+ };
39
+ }
40
+ //#endregion
41
+ //#region extensions/vama/src/accounts.ts
42
+ function listConfiguredAccountIds(cfg) {
43
+ const accounts = (cfg.channels?.vama)?.accounts;
44
+ if (!accounts || typeof accounts !== "object") return [];
45
+ return Object.keys(accounts).filter(Boolean);
46
+ }
47
+ function listVamaAccountIds(cfg) {
48
+ const ids = listConfiguredAccountIds(cfg);
49
+ if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
50
+ return [...ids].toSorted((a, b) => a.localeCompare(b));
51
+ }
52
+ function resolveDefaultVamaAccountId(cfg) {
53
+ const ids = listVamaAccountIds(cfg);
54
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
55
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
56
+ }
57
+ function resolveAccountConfig(cfg, accountId) {
58
+ const accounts = (cfg.channels?.vama)?.accounts;
59
+ if (!accounts || typeof accounts !== "object") return;
60
+ return accounts[accountId];
61
+ }
62
+ function mergeVamaAccountConfig(cfg, accountId) {
63
+ const { accounts: _ignored, ...base } = cfg.channels?.vama ?? {};
64
+ const account = resolveAccountConfig(cfg, accountId) ?? {};
65
+ return {
66
+ ...base,
67
+ ...account
68
+ };
69
+ }
70
+ function resolveVamaAccount(params) {
71
+ const accountId = normalizeAccountId(params.accountId);
72
+ const baseEnabled = (params.cfg.channels?.vama)?.enabled !== false;
73
+ const merged = mergeVamaAccountConfig(params.cfg, accountId);
74
+ const accountEnabled = merged.enabled !== false;
75
+ const enabled = baseEnabled && accountEnabled;
76
+ const botToken = merged.botToken?.trim() || void 0;
77
+ const webhookSecret = merged.webhookSecret?.trim() || void 0;
78
+ const webhookSecretFile = merged.webhookSecretFile?.trim() || void 0;
79
+ const bothubUrl = merged.bothubUrl?.trim() || "https://bothub.vama.com";
80
+ return {
81
+ accountId,
82
+ enabled,
83
+ configured: Boolean(botToken),
84
+ name: merged.name?.trim() || void 0,
85
+ botToken,
86
+ webhookSecret,
87
+ webhookSecretFile,
88
+ bothubUrl,
89
+ config: merged
90
+ };
91
+ }
92
+ //#endregion
93
+ //#region extensions/vama/src/dedup.ts
94
+ const DEDUP_TTL_MS = 1440 * 60 * 1e3;
95
+ const MEMORY_MAX_SIZE = 1e3;
96
+ const FILE_MAX_ENTRIES = 1e4;
97
+ createDedupeCache({
98
+ ttlMs: DEDUP_TTL_MS,
99
+ maxSize: MEMORY_MAX_SIZE
100
+ });
101
+ function resolveStateDirFromEnv(env = process.env) {
102
+ const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
103
+ if (stateOverride) return stateOverride;
104
+ if (env.VITEST || env.NODE_ENV === "test") return path.join(resolvePreferredOpenClawTmpDir(), ["openclaw-vitest", String(process.pid)].join("-"));
105
+ return path.join(homedir(), ".openclaw");
106
+ }
107
+ function resolveNamespaceFilePath(namespace) {
108
+ const safe = namespace.replace(/[^a-zA-Z0-9_-]/g, "_");
109
+ return path.join(resolveStateDirFromEnv(), "vama", "dedup", `${safe}.json`);
110
+ }
111
+ const persistentDedupe = createPersistentDedupe({
112
+ ttlMs: DEDUP_TTL_MS,
113
+ memoryMaxSize: MEMORY_MAX_SIZE,
114
+ fileMaxEntries: FILE_MAX_ENTRIES,
115
+ resolveFilePath: resolveNamespaceFilePath
116
+ });
117
+ async function tryRecordMessagePersistent(messageId, namespace = "global", log) {
118
+ return persistentDedupe.checkAndRecord(messageId, {
119
+ namespace,
120
+ onDiskError: (error) => {
121
+ log?.(`vama-dedup: disk error, falling back to memory: ${String(error)}`);
122
+ }
123
+ });
124
+ }
125
+ //#endregion
126
+ //#region extensions/vama/src/fc-keepalive.ts
127
+ /**
128
+ * FC keepalive bridge — keeps the host's vmctl idle-pauser at bay
129
+ * while a VAMA dispatch is in flight.
130
+ *
131
+ * Why this lives in the VAMA extension:
132
+ *
133
+ * Firecracker hosts auto-pause idle VMs after 2 minutes of no
134
+ * `lastAccess` bumps. The cascade-side keepalive (`packages/core/src/
135
+ * keepalive.ts` in the OCC platform) doesn't actually reach FC VMs —
136
+ * cascade is on EKS, FC `instanceUrl` is a Tailscale CGNAT address
137
+ * (`http://100.x.x.x:80`), and EKS pods can't route to those without
138
+ * going through the `tailscale-router` HTTP proxy. The keepalive
139
+ * pings silently 404 / time out.
140
+ *
141
+ * Inbound webhooks DO bump `lastAccess` (smart-proxy.js calls vmctl's
142
+ * `/keepalive` endpoint on each delivery), so a VM that's actively
143
+ * receiving user messages stays alive. The gap is during long agent
144
+ * tool calls — e.g. an SSE listen of a 4-minute audio stream — where
145
+ * the agent goes minutes without making any cascade request and no
146
+ * fresh webhook arrives. After 2 minutes of no `lastAccess` bump,
147
+ * vmctl's auto-pauser sees CPU at ~0% (Node event loop awaiting a
148
+ * network read) and pauses the VM mid-tool. Carlos's instance hit
149
+ * exactly this on 2026-04-25.
150
+ *
151
+ * Threat model: the signal MUST be specific to "OpenClaw is doing a
152
+ * dispatch turn." Network-throughput signals are jailbreakable (a
153
+ * user-spawned `curl` loop would keep the VM alive). Log-tail signals
154
+ * are also jailbreakable (an agent subprocess inheriting the gateway's
155
+ * cgroup can `echo` fake log lines that journalctl attributes to
156
+ * `openclaw-gateway.service`). The only signal that's both reliable
157
+ * AND not jailbreakable is in-process gateway state — which is what
158
+ * this module exposes via `dispatchStarted()` / `dispatchEnded()`
159
+ * called from the VAMA dispatch entry/exit points in bot.ts.
160
+ *
161
+ * Scope: VAMA channel only. Telegram dispatches are not covered here.
162
+ * That's acceptable for OCC because non-VAMA channels run on dedicated
163
+ * (paid) FC instances which vmctl doesn't auto-pause anyway (see
164
+ * `pauseIdleVMs` skipping `vm.Dedicated == true`).
165
+ */
166
+ const VMCTL_HOST = "10.0.200.1";
167
+ const VMCTL_PORT = 9090;
168
+ const KEEPALIVE_INTERVAL_MS = 3e4;
169
+ const VMCTL_REQUEST_TIMEOUT_MS = 3e3;
170
+ let activeDispatches = 0;
171
+ let interval = null;
172
+ let envCacheLoaded = false;
173
+ let cachedVmName;
174
+ let cachedAuthToken;
175
+ function loadFcEnv() {
176
+ if (envCacheLoaded) return;
177
+ envCacheLoaded = true;
178
+ try {
179
+ cachedVmName = fs.readFileSync("/etc/openclaw/vm-name", "utf8").trim() || void 0;
180
+ } catch {}
181
+ try {
182
+ const m = fs.readFileSync("/etc/vmctl.env", "utf8").match(/FC_AUTH_TOKEN=["']?([^"'\n]+)/);
183
+ if (m) cachedAuthToken = m[1];
184
+ } catch {
185
+ if (process.env.FC_AUTH_TOKEN) cachedAuthToken = process.env.FC_AUTH_TOKEN;
186
+ }
187
+ }
188
+ function sendVmctlKeepalive() {
189
+ loadFcEnv();
190
+ if (!cachedVmName) return;
191
+ const path = `/api/v1/vms/${encodeURIComponent(cachedVmName)}/keepalive`;
192
+ const headers = {};
193
+ if (cachedAuthToken) headers["Authorization"] = `Bearer ${cachedAuthToken}`;
194
+ const req = http.request({
195
+ hostname: VMCTL_HOST,
196
+ port: VMCTL_PORT,
197
+ path,
198
+ method: "POST",
199
+ timeout: VMCTL_REQUEST_TIMEOUT_MS,
200
+ headers
201
+ }, (res) => {
202
+ res.resume();
203
+ });
204
+ req.on("error", () => {});
205
+ req.on("timeout", () => {
206
+ req.destroy();
207
+ });
208
+ req.end();
209
+ }
210
+ /**
211
+ * Marks a VAMA dispatch as in-flight. Idempotent across concurrent
212
+ * dispatches: each call increments a counter, only the FIRST starts
213
+ * the keepalive interval. The counter is decremented by
214
+ * `dispatchEnded()`. Always pair these in a try/finally so a thrown
215
+ * error doesn't leak the counter.
216
+ *
217
+ * Outside of FC (no `/etc/openclaw/vm-name`) this is a true no-op:
218
+ * the counter still increments for symmetry with `dispatchEnded`,
219
+ * but no interval is armed and no HTTP call is ever made. This keeps
220
+ * dev / local runs free of a stray 30s timer.
221
+ */
222
+ function dispatchStarted() {
223
+ activeDispatches += 1;
224
+ if (interval !== null) return;
225
+ loadFcEnv();
226
+ if (!cachedVmName) return;
227
+ sendVmctlKeepalive();
228
+ interval = setInterval(sendVmctlKeepalive, KEEPALIVE_INTERVAL_MS);
229
+ }
230
+ /**
231
+ * Pairs with `dispatchStarted()`. When the last in-flight dispatch
232
+ * settles, the keepalive interval stops and vmctl's `lastAccess`
233
+ * naturally ages out as designed.
234
+ */
235
+ function dispatchEnded() {
236
+ if (activeDispatches > 0) activeDispatches -= 1;
237
+ if (activeDispatches === 0 && interval !== null) {
238
+ clearInterval(interval);
239
+ interval = null;
240
+ }
241
+ }
242
+ //#endregion
243
+ //#region extensions/vama/src/send.ts
244
+ async function sendMessageVama(params) {
245
+ const { cfg, to, text, parentId, accountId } = params;
246
+ const account = resolveVamaAccount({
247
+ cfg,
248
+ accountId
249
+ });
250
+ if (!account.configured) throw new Error(`Vama account "${account.accountId}" not configured`);
251
+ const result = await createBotHubClient(account).sendMessage({
252
+ channelId: to,
253
+ text,
254
+ parentId
255
+ });
256
+ return {
257
+ messageId: result.message_id,
258
+ channelId: result.channel_id
259
+ };
260
+ }
261
+ //#endregion
262
+ //#region extensions/vama/src/reply-dispatcher.ts
263
+ /**
264
+ * Strip `MEDIA:` directives out of an agent reply, returning the cleaned
265
+ * text plus the list of media targets the agent asked to attach.
266
+ *
267
+ * This intentionally re-implements a focused subset of openclaw core's
268
+ * `splitMediaFromOutput`. The bundled `plugin-sdk/reply-payload` exports
269
+ * `resolvePayloadMediaUrls` (which inspects already-populated
270
+ * `payload.mediaUrl[s]`), but the directive-parsing helper that turns
271
+ * inline `MEDIA: /path/to/file` lines into `mediaUrls` lives in
272
+ * `auto-reply/reply/reply-directives.ts` — not on the public plugin-SDK
273
+ * surface. Pulling it through that boundary is a separate refactor;
274
+ * inlining the line-prefix parser here avoids that detour and matches
275
+ * the directive shape openclaw renders into the agent's response stream.
276
+ *
277
+ * Recognised inputs (one per line, leading whitespace tolerated):
278
+ * MEDIA:/abs/path/to/file.pdf
279
+ * MEDIA: ./relative/file.png
280
+ * MEDIA: https://example.com/image.jpg (remote — outbound adapter
281
+ * falls back to text)
282
+ * MEDIA: `quoted/path with spaces.txt`
283
+ */
284
+ function parseInlineMediaTargets(raw) {
285
+ if (!raw || !raw.toLowerCase().includes("media:")) return {
286
+ text: raw ?? "",
287
+ mediaUrls: []
288
+ };
289
+ const lines = raw.split("\n");
290
+ const kept = [];
291
+ const mediaUrls = [];
292
+ for (const line of lines) {
293
+ const m = /^\s*MEDIA:\s*(.+?)\s*$/i.exec(line);
294
+ if (!m) {
295
+ kept.push(line);
296
+ continue;
297
+ }
298
+ let candidate = m[1].trim();
299
+ candidate = candidate.replace(/^[`"']|[`"']$/g, "").trim();
300
+ if (!candidate) {
301
+ kept.push(line);
302
+ continue;
303
+ }
304
+ mediaUrls.push(candidate);
305
+ }
306
+ return {
307
+ text: kept.join("\n").replace(/\n{3,}/g, "\n\n").trim(),
308
+ mediaUrls
309
+ };
310
+ }
311
+ /**
312
+ * Resolve the merged set of media targets to send for one payload, with
313
+ * dedup. Order of precedence: `payload.mediaUrls`, `payload.mediaUrl`,
314
+ * then any `MEDIA:` directives left in the agent text. The cleaned text
315
+ * (with `MEDIA:` lines stripped) is returned alongside.
316
+ */
317
+ function resolveOutboundMedia(payload) {
318
+ const { text: cleanedText, mediaUrls: inline } = parseInlineMediaTargets(payload.text ?? "");
319
+ const merged = [];
320
+ const seen = /* @__PURE__ */ new Set();
321
+ const push = (url) => {
322
+ if (!url) return;
323
+ const trimmed = url.trim();
324
+ if (!trimmed || seen.has(trimmed)) return;
325
+ seen.add(trimmed);
326
+ merged.push(trimmed);
327
+ };
328
+ if (Array.isArray(payload.mediaUrls)) for (const url of payload.mediaUrls) push(url);
329
+ push(payload.mediaUrl);
330
+ for (const url of inline) push(url);
331
+ return {
332
+ text: cleanedText,
333
+ mediaUrls: merged
334
+ };
335
+ }
336
+ /**
337
+ * Decide whether a media URL points at a local workspace artefact we can
338
+ * upload via BotHub's multipart endpoint. Mirrors `localPathFromMediaUrl`
339
+ * in `outbound.ts` so the dispatcher and the standalone outbound adapter
340
+ * agree on what counts as "uploadable from disk" — keeps the fallback
341
+ * behaviour identical regardless of which path delivers the payload.
342
+ */
343
+ function localPathFromMediaUrl(mediaUrl) {
344
+ if (mediaUrl.startsWith("file://")) return mediaUrl.slice(7);
345
+ if (mediaUrl.startsWith("/") || mediaUrl.startsWith("./")) return mediaUrl;
346
+ return null;
347
+ }
348
+ function extensionOf(path) {
349
+ const idx = path.lastIndexOf(".");
350
+ if (idx < 0 || idx === path.length - 1) return "";
351
+ if (idx < Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))) return "";
352
+ return path.slice(idx + 1).toLowerCase();
353
+ }
354
+ function createVamaReplyDispatcher(params) {
355
+ const core = getVamaRuntime();
356
+ const { cfg, agentId, channelId, replyToMessageId, accountId } = params;
357
+ const account = resolveVamaAccount({
358
+ cfg,
359
+ accountId
360
+ });
361
+ const prefixContext = createReplyPrefixContext({
362
+ cfg,
363
+ agentId
364
+ });
365
+ const typingCallbacks = createTypingCallbacks({
366
+ start: async () => {
367
+ if (!account.configured) return;
368
+ try {
369
+ await createBotHubClient(account).sendTyping({ channelId });
370
+ } catch {}
371
+ },
372
+ stop: async () => {},
373
+ onStartError: (err) => logTypingFailure({
374
+ log: (message) => params.runtime?.log?.(message),
375
+ channel: "vama",
376
+ action: "start",
377
+ error: err
378
+ }),
379
+ onStopError: (err) => logTypingFailure({
380
+ log: (message) => params.runtime?.log?.(message),
381
+ channel: "vama",
382
+ action: "stop",
383
+ error: err
384
+ })
385
+ });
386
+ const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, "vama", accountId, { fallbackLimit: 1e4 });
387
+ const chunkMode = core.channel.text.resolveChunkMode(cfg, "vama");
388
+ const sentMediaHashes = /* @__PURE__ */ new Set();
389
+ const PERSISTED_ID_RE = /^(.+?)---[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(\.[A-Za-z0-9]{1,16})?$/;
390
+ const deriveFriendlyFileName = (localPath) => {
391
+ const ext = extensionOf(localPath);
392
+ const match = (localPath.split("/").pop() ?? localPath).match(PERSISTED_ID_RE);
393
+ if (match) {
394
+ const [, original, suffixExt] = match;
395
+ return `${original}${suffixExt ?? (ext ? `.${ext}` : "")}`;
396
+ }
397
+ return ext ? `attachment.${ext}` : "attachment";
398
+ };
399
+ const computeContentHash = async (localPath) => {
400
+ try {
401
+ const bytes = await promises.readFile(localPath);
402
+ return createHash("sha256").update(bytes).digest("hex");
403
+ } catch {
404
+ return;
405
+ }
406
+ };
407
+ const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
408
+ responsePrefix: prefixContext.responsePrefix,
409
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
410
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
411
+ onReplyStart: () => {
412
+ typingCallbacks.onReplyStart?.();
413
+ },
414
+ deliver: async (payload) => {
415
+ const { text, mediaUrls } = resolveOutboundMedia(payload);
416
+ let captionAttached = false;
417
+ for (let i = 0; i < mediaUrls.length; i += 1) {
418
+ const mediaUrl = mediaUrls[i];
419
+ const localPath = localPathFromMediaUrl(mediaUrl);
420
+ const captionForThisItem = i === 0 && text.trim() ? text.trim() : void 0;
421
+ if (!localPath) {
422
+ await sendMessageVama({
423
+ cfg,
424
+ to: channelId,
425
+ text: captionForThisItem ? `${captionForThisItem}\n${mediaUrl}` : mediaUrl,
426
+ parentId: replyToMessageId,
427
+ accountId
428
+ });
429
+ if (captionForThisItem) captionAttached = true;
430
+ continue;
431
+ }
432
+ if (!account.configured) {
433
+ params.runtime?.error?.(`vama[${account.accountId}] sendFile skipped: account not configured`);
434
+ continue;
435
+ }
436
+ const contentHash = await computeContentHash(localPath);
437
+ if (contentHash && sentMediaHashes.has(contentHash)) {
438
+ if (captionForThisItem) {
439
+ await sendMessageVama({
440
+ cfg,
441
+ to: channelId,
442
+ text: captionForThisItem,
443
+ parentId: replyToMessageId,
444
+ accountId
445
+ });
446
+ captionAttached = true;
447
+ }
448
+ continue;
449
+ }
450
+ try {
451
+ await createBotHubClient(account).sendFile({
452
+ channelId,
453
+ path: localPath,
454
+ fileName: deriveFriendlyFileName(localPath),
455
+ caption: captionForThisItem,
456
+ parentId: replyToMessageId,
457
+ attachmentType: attachmentHintFromExtension(extensionOf(localPath))
458
+ });
459
+ if (contentHash) sentMediaHashes.add(contentHash);
460
+ if (captionForThisItem) captionAttached = true;
461
+ } catch (err) {
462
+ params.runtime?.error?.(`vama[${account.accountId}] sendFile failed for ${localPath}: ${String(err)}`);
463
+ await sendMessageVama({
464
+ cfg,
465
+ to: channelId,
466
+ text: captionForThisItem ? `${captionForThisItem}\n[attachment unavailable: ${localPath}]` : `[attachment unavailable: ${localPath}]`,
467
+ parentId: replyToMessageId,
468
+ accountId
469
+ });
470
+ if (captionForThisItem) captionAttached = true;
471
+ }
472
+ }
473
+ const trimmed = text.trim();
474
+ if (trimmed && !captionAttached) for (const chunk of core.channel.text.chunkTextWithMode(trimmed, textChunkLimit, chunkMode)) await sendMessageVama({
475
+ cfg,
476
+ to: channelId,
477
+ text: chunk,
478
+ parentId: replyToMessageId,
479
+ accountId
480
+ });
481
+ },
482
+ onError: async (error) => {
483
+ params.runtime?.error?.(`vama[${account.accountId}] reply failed: ${String(error)}`);
484
+ typingCallbacks.onIdle?.();
485
+ },
486
+ onIdle: async () => {
487
+ typingCallbacks.onIdle?.();
488
+ },
489
+ onCleanup: () => {
490
+ typingCallbacks.onCleanup?.();
491
+ }
492
+ });
493
+ return {
494
+ dispatcher,
495
+ replyOptions: {
496
+ ...replyOptions,
497
+ onModelSelected: prefixContext.onModelSelected
498
+ },
499
+ markDispatchIdle
500
+ };
501
+ }
502
+ //#endregion
503
+ //#region extensions/vama/src/bot.ts
504
+ /**
505
+ * Per-attachment fetch ceiling. Mirrors the limit `core.channel.media`
506
+ * already enforces internally on most provider channels (Telegram /
507
+ * WhatsApp / Zalo); kept explicit here so the ceiling is visible at the
508
+ * call site and can be surfaced later as a config knob without changing
509
+ * the `core` API contract.
510
+ */
511
+ const VAMA_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
512
+ async function handleVamaMessage(params) {
513
+ const { cfg, event, runtime, accountId, replayId } = params;
514
+ const account = resolveVamaAccount({
515
+ cfg,
516
+ accountId
517
+ });
518
+ const vamaCfg = account.config;
519
+ const log = runtime?.log ?? console.log;
520
+ const error = runtime?.error ?? console.error;
521
+ if (replayId) log(`vama[${account.accountId}]: replay (${replayId}) — bypassing dedup for ${event.message_id}`);
522
+ else if (!await tryRecordMessagePersistent(event.message_id, account.accountId, log)) {
523
+ log(`vama: skipping duplicate message ${event.message_id}`);
524
+ return;
525
+ }
526
+ const senderId = event.sender_id;
527
+ const senderName = event.sender_name ?? senderId;
528
+ const text = event.text ?? "";
529
+ const channelId = event.channel_id;
530
+ const messageId = event.message_id;
531
+ const parentId = event.parent_id;
532
+ const parentText = typeof event.parent_text === "string" && event.parent_text.trim().length > 0 ? event.parent_text : void 0;
533
+ const parentSenderName = typeof event.parent_sender_name === "string" && event.parent_sender_name.trim().length > 0 ? event.parent_sender_name : void 0;
534
+ const parentSenderId = typeof event.parent_sender_id === "string" && event.parent_sender_id.trim().length > 0 ? event.parent_sender_id : void 0;
535
+ const parentSender = parentSenderName ?? parentSenderId;
536
+ const attachments = Array.isArray(event.attachments) ? event.attachments.filter((a) => Boolean(a) && typeof a.url === "string" && a.url.length > 0) : [];
537
+ const parentAttachments = Array.isArray(event.parent_attachments) ? event.parent_attachments.filter((a) => Boolean(a) && typeof a.url === "string" && a.url.length > 0) : [];
538
+ if (!text.trim() && attachments.length === 0) {
539
+ log(`vama[${account.accountId}]: ignoring empty message ${messageId}`);
540
+ return;
541
+ }
542
+ log(`vama[${account.accountId}]: received message from ${senderId} in ${channelId}`);
543
+ try {
544
+ const core = getVamaRuntime();
545
+ const dmPolicy = vamaCfg?.dmPolicy ?? "open";
546
+ const configAllowFrom = (vamaCfg?.allowFrom ?? []).map((e) => String(e));
547
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
548
+ const pairing = createScopedPairingAccess({
549
+ core,
550
+ channel: "vama",
551
+ accountId: account.accountId
552
+ });
553
+ const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(text, cfg);
554
+ const storeAllowFrom = dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeCommandAuthorized) ? await pairing.readAllowFromStore().catch(() => []) : [];
555
+ const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
556
+ const dmAllowed = dmPolicy === "open" ? effectiveDmAllowFrom.length === 0 || effectiveDmAllowFrom.includes(senderId) : effectiveDmAllowFrom.includes(senderId);
557
+ if (dmPolicy !== "open" && !dmAllowed) {
558
+ if (dmPolicy === "pairing") {
559
+ const { code, created } = await pairing.upsertPairingRequest({
560
+ id: senderId,
561
+ meta: { name: senderName }
562
+ });
563
+ if (created) {
564
+ log(`vama[${account.accountId}]: pairing request sender=${senderId}`);
565
+ try {
566
+ await sendMessageVama({
567
+ cfg,
568
+ to: channelId,
569
+ text: core.channel.pairing.buildPairingReply({
570
+ channel: "vama",
571
+ idLine: `Your Vama user id: ${senderId}`,
572
+ code
573
+ }),
574
+ accountId: account.accountId
575
+ });
576
+ } catch (err) {
577
+ log(`vama[${account.accountId}]: pairing reply failed for ${senderId}: ${String(err)}`);
578
+ }
579
+ }
580
+ } else log(`vama[${account.accountId}]: blocked unauthorized sender ${senderId} (dmPolicy=${dmPolicy})`);
581
+ return;
582
+ }
583
+ const commandAllowFrom = effectiveDmAllowFrom;
584
+ const senderAllowedForCommands = commandAllowFrom.length === 0 || commandAllowFrom.includes(senderId);
585
+ const commandAuthorized = shouldComputeCommandAuthorized ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
586
+ useAccessGroups,
587
+ authorizers: [{
588
+ configured: commandAllowFrom.length > 0,
589
+ allowed: senderAllowedForCommands
590
+ }]
591
+ }) : void 0;
592
+ const vamaFrom = `vama:${senderId}`;
593
+ const vamaTo = `channel:${channelId}`;
594
+ const route = core.channel.routing.resolveAgentRoute({
595
+ cfg,
596
+ channel: "vama",
597
+ accountId: account.accountId,
598
+ peer: {
599
+ kind: "direct",
600
+ id: senderId
601
+ }
602
+ });
603
+ const preview = text.replace(/\s+/g, " ").slice(0, 160);
604
+ core.system.enqueueSystemEvent(`Vama[${account.accountId}] DM from ${senderName}: ${preview}`, {
605
+ sessionKey: route.sessionKey,
606
+ contextKey: `vama:message:${channelId}:${messageId}`
607
+ });
608
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
609
+ const messageBody = `${senderName}: ${text}`;
610
+ const body = core.channel.reply.formatAgentEnvelope({
611
+ channel: "Vama",
612
+ from: senderId,
613
+ timestamp: /* @__PURE__ */ new Date(),
614
+ envelope: envelopeOptions,
615
+ body: messageBody
616
+ });
617
+ const dispatchMessageSid = replayId ? `${messageId}#replay-${replayId.slice(-12)}` : messageId;
618
+ const fetchAttachments = async (items, label) => {
619
+ const resolved = [];
620
+ for (const attachment of items) try {
621
+ const fetched = await core.channel.media.fetchRemoteMedia({
622
+ url: attachment.url,
623
+ maxBytes: VAMA_ATTACHMENT_MAX_BYTES
624
+ });
625
+ const saved = await core.channel.media.saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", VAMA_ATTACHMENT_MAX_BYTES);
626
+ resolved.push({
627
+ path: saved.path,
628
+ contentType: saved.contentType
629
+ });
630
+ } catch (err) {
631
+ error(`vama[${account.accountId}]: failed to fetch ${label} ${attachment.url}: ${String(err)}`);
632
+ }
633
+ return resolved;
634
+ };
635
+ const resolvedCurrentMedia = await fetchAttachments(attachments, "attachment");
636
+ const resolvedParentMedia = await fetchAttachments(parentAttachments, "reply-target attachment");
637
+ const resolvedMedia = [...resolvedCurrentMedia, ...resolvedParentMedia];
638
+ const mediaPaths = resolvedMedia.map((m) => m.path);
639
+ const mediaTypes = resolvedMedia.map((m) => m.contentType).filter((value) => typeof value === "string" && value.length > 0);
640
+ const replyTargetMediaNote = resolvedParentMedia.length > 0 ? `[reply target media attachments included as additional media inputs: ${resolvedParentMedia.length}]` : void 0;
641
+ const replyToBody = parentText && replyTargetMediaNote ? `${parentText}\n\n${replyTargetMediaNote}` : parentText ?? replyTargetMediaNote;
642
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
643
+ Body: body,
644
+ BodyForAgent: messageBody,
645
+ RawBody: text,
646
+ CommandBody: text,
647
+ From: vamaFrom,
648
+ To: vamaTo,
649
+ SessionKey: route.sessionKey,
650
+ AccountId: route.accountId,
651
+ ChatType: "direct",
652
+ SenderName: senderName,
653
+ SenderId: senderId,
654
+ Provider: "vama",
655
+ Surface: "vama",
656
+ MessageSid: dispatchMessageSid,
657
+ ReplyToId: parentId,
658
+ ReplyToBody: replyToBody,
659
+ ReplyToSender: parentSender,
660
+ ReplyToIsQuote: replyToBody ? true : void 0,
661
+ Timestamp: Date.now(),
662
+ CommandAuthorized: commandAuthorized,
663
+ OriginatingChannel: "vama",
664
+ OriginatingTo: vamaTo,
665
+ MediaPath: mediaPaths[0],
666
+ MediaType: mediaTypes[0],
667
+ MediaUrl: mediaPaths[0],
668
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : void 0,
669
+ MediaUrls: mediaPaths.length > 0 ? mediaPaths : void 0,
670
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : void 0
671
+ });
672
+ const { dispatcher, replyOptions, markDispatchIdle } = createVamaReplyDispatcher({
673
+ cfg,
674
+ agentId: route.agentId,
675
+ runtime,
676
+ channelId,
677
+ replyToMessageId: parentId,
678
+ accountId: account.accountId
679
+ });
680
+ log(`vama[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
681
+ let keepaliveActive = false;
682
+ try {
683
+ dispatchStarted();
684
+ keepaliveActive = true;
685
+ const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
686
+ dispatcher,
687
+ onSettled: () => {
688
+ markDispatchIdle();
689
+ },
690
+ run: () => core.channel.reply.dispatchReplyFromConfig({
691
+ ctx: ctxPayload,
692
+ cfg,
693
+ dispatcher,
694
+ replyOptions
695
+ })
696
+ });
697
+ log(`vama[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`);
698
+ } finally {
699
+ if (keepaliveActive) dispatchEnded();
700
+ }
701
+ } catch (err) {
702
+ error(`vama[${account.accountId}]: failed to dispatch message: ${String(err)}`);
703
+ }
704
+ }
705
+ //#endregion
706
+ //#region extensions/vama/src/webhook.ts
707
+ const MAX_TIMESTAMP_AGE_S = 300;
708
+ /**
709
+ * Verify a BotHub webhook signature.
710
+ *
711
+ * BotHub signs webhooks as: HMAC-SHA256(secret, "{timestamp}.{body}")
712
+ * The signature header value is prefixed with "sha256=".
713
+ */
714
+ function verifyWebhookSignature(params) {
715
+ const { body, secret, signature, timestamp } = params;
716
+ if (!signature || !timestamp || !secret) return false;
717
+ const ts = Number.parseInt(timestamp, 10);
718
+ if (Number.isNaN(ts)) return false;
719
+ const nowS = Math.floor(Date.now() / 1e3);
720
+ if (Math.abs(nowS - ts) > MAX_TIMESTAMP_AGE_S) return false;
721
+ const expectedHex = signature.startsWith("sha256=") ? signature.slice(7) : signature;
722
+ if (!expectedHex) return false;
723
+ const payload = `${timestamp}.${body}`;
724
+ const mac = crypto.createHmac("sha256", secret);
725
+ mac.update(payload);
726
+ const computedHex = mac.digest("hex");
727
+ try {
728
+ return crypto.timingSafeEqual(Buffer.from(computedHex, "hex"), Buffer.from(expectedHex, "hex"));
729
+ } catch {
730
+ return false;
731
+ }
732
+ }
733
+ //#endregion
734
+ //#region extensions/vama/src/monitor.ts
735
+ const SECRET_CACHE = /* @__PURE__ */ new Map();
736
+ function readSecretFromFile(path, log) {
737
+ let stat;
738
+ try {
739
+ stat = fs.statSync(path);
740
+ } catch (e) {
741
+ log(`vama: webhookSecretFile stat failed (${path}): ${e instanceof Error ? e.message : String(e)}`);
742
+ return;
743
+ }
744
+ const cached = SECRET_CACHE.get(path);
745
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.sizeBytes === stat.size) return cached.secret;
746
+ let raw;
747
+ try {
748
+ raw = fs.readFileSync(path, "utf8");
749
+ } catch (e) {
750
+ log(`vama: webhookSecretFile read failed (${path}): ${e instanceof Error ? e.message : String(e)}`);
751
+ return;
752
+ }
753
+ const secret = raw.trim();
754
+ if (!secret) {
755
+ log(`vama: webhookSecretFile is empty (${path}) — treating as absent`);
756
+ return;
757
+ }
758
+ SECRET_CACHE.set(path, {
759
+ mtimeMs: stat.mtimeMs,
760
+ sizeBytes: stat.size,
761
+ secret
762
+ });
763
+ return secret;
764
+ }
765
+ const WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
766
+ const WEBHOOK_BODY_TIMEOUT_MS = 3e4;
767
+ const WEBHOOK_RATE_LIMIT_WINDOW_MS = 6e4;
768
+ const WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
769
+ const WEBHOOK_RATE_LIMIT_MAX_TRACKED_KEYS = 4096;
770
+ const rateLimits = /* @__PURE__ */ new Map();
771
+ let lastRateLimitCleanupMs = 0;
772
+ function isRateLimited(key, nowMs) {
773
+ if (rateLimits.size > 0 && nowMs - lastRateLimitCleanupMs >= WEBHOOK_RATE_LIMIT_WINDOW_MS) {
774
+ lastRateLimitCleanupMs = nowMs;
775
+ for (const [k, state] of rateLimits) if (nowMs - state.windowStartMs >= WEBHOOK_RATE_LIMIT_WINDOW_MS) rateLimits.delete(k);
776
+ }
777
+ const state = rateLimits.get(key);
778
+ if (!state || nowMs - state.windowStartMs >= WEBHOOK_RATE_LIMIT_WINDOW_MS) {
779
+ rateLimits.set(key, {
780
+ count: 1,
781
+ windowStartMs: nowMs
782
+ });
783
+ while (rateLimits.size > WEBHOOK_RATE_LIMIT_MAX_TRACKED_KEYS) {
784
+ const oldest = rateLimits.keys().next().value;
785
+ if (typeof oldest !== "string") break;
786
+ rateLimits.delete(oldest);
787
+ }
788
+ return false;
789
+ }
790
+ state.count += 1;
791
+ return state.count > WEBHOOK_RATE_LIMIT_MAX_REQUESTS;
792
+ }
793
+ function isJsonContentType(value) {
794
+ const first = Array.isArray(value) ? value[0] : value;
795
+ if (!first) return false;
796
+ const mediaType = first.split(";", 1)[0]?.trim().toLowerCase();
797
+ return mediaType === "application/json" || (mediaType?.endsWith("+json") ?? false);
798
+ }
799
+ function collectBody(req) {
800
+ return new Promise((resolve, reject) => {
801
+ const chunks = [];
802
+ req.on("data", (chunk) => chunks.push(chunk));
803
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
804
+ req.on("error", reject);
805
+ });
806
+ }
807
+ async function monitorVamaProvider(opts = {}) {
808
+ const cfg = opts.config;
809
+ if (!cfg) throw new Error("Config is required for Vama monitor");
810
+ if (!opts.accountId) throw new Error("accountId is required for Vama monitor");
811
+ const account = resolveVamaAccount({
812
+ cfg,
813
+ accountId: opts.accountId
814
+ });
815
+ if (!account.enabled || !account.configured) throw new Error(`Vama account "${opts.accountId}" not configured or disabled`);
816
+ const log = opts.runtime?.log ?? console.log;
817
+ const error = opts.runtime?.error ?? console.error;
818
+ const accountId = account.accountId;
819
+ const port = account.config?.webhookPort ?? 3001;
820
+ const webhookPath = account.config?.webhookPath ?? "/vama/events";
821
+ const host = account.config?.webhookHost ?? "127.0.0.1";
822
+ log(`vama[${accountId}]: starting webhook server on ${host}:${port}, path ${webhookPath}...`);
823
+ const server = http.createServer();
824
+ server.on("request", (req, res) => {
825
+ if (isRateLimited(`${accountId}:${req.socket.remoteAddress ?? "unknown"}`, Date.now())) {
826
+ res.statusCode = 429;
827
+ res.end("Too Many Requests");
828
+ return;
829
+ }
830
+ if (req.method !== "POST" || req.url !== webhookPath) {
831
+ res.statusCode = 404;
832
+ res.end("Not Found");
833
+ return;
834
+ }
835
+ if (!isJsonContentType(req.headers["content-type"])) {
836
+ res.statusCode = 415;
837
+ res.end("Unsupported Media Type");
838
+ return;
839
+ }
840
+ const guard = installRequestBodyLimitGuard(req, res, {
841
+ maxBytes: WEBHOOK_MAX_BODY_BYTES,
842
+ timeoutMs: WEBHOOK_BODY_TIMEOUT_MS,
843
+ responseFormat: "text"
844
+ });
845
+ if (guard.isTripped()) return;
846
+ collectBody(req).then((rawBody) => {
847
+ guard.dispose();
848
+ const signature = req.headers["x-bothub-signature"];
849
+ const timestamp = req.headers["x-bothub-timestamp"];
850
+ const webhookSecret = (account.webhookSecretFile ? readSecretFromFile(account.webhookSecretFile, log) : void 0) ?? account.webhookSecret;
851
+ if (webhookSecret && signature && timestamp) {
852
+ if (!verifyWebhookSignature({
853
+ body: rawBody,
854
+ secret: webhookSecret,
855
+ signature,
856
+ timestamp
857
+ })) {
858
+ log(`vama[${accountId}]: webhook signature verification failed`);
859
+ res.statusCode = 401;
860
+ res.end("Invalid Signature");
861
+ return;
862
+ }
863
+ } else if (webhookSecret) {
864
+ log(`vama[${accountId}]: webhook missing signature headers`);
865
+ res.statusCode = 401;
866
+ res.end("Missing Signature");
867
+ return;
868
+ }
869
+ res.statusCode = 200;
870
+ res.end("OK");
871
+ let event;
872
+ try {
873
+ event = JSON.parse(rawBody);
874
+ } catch {
875
+ error(`vama[${accountId}]: failed to parse webhook body`);
876
+ return;
877
+ }
878
+ const eventType = event.type;
879
+ const deliveryId = req.headers["x-bothub-delivery-id"];
880
+ const replayId = req.headers["x-replay-id"];
881
+ if (eventType === "message.create") {
882
+ const messageEvent = event.data;
883
+ handleVamaMessage({
884
+ cfg,
885
+ event: messageEvent,
886
+ runtime: opts.runtime,
887
+ accountId,
888
+ replayId
889
+ }).catch((err) => {
890
+ error(`vama[${accountId}]: error handling message: ${String(err)}`);
891
+ });
892
+ } else log(`vama[${accountId}]: ignoring event type=${eventType} delivery=${deliveryId}`);
893
+ }).catch((err) => {
894
+ if (!guard.isTripped()) error(`vama[${accountId}]: webhook handler error: ${String(err)}`);
895
+ guard.dispose();
896
+ });
897
+ });
898
+ return new Promise((resolve, reject) => {
899
+ const cleanup = () => {
900
+ server.close();
901
+ };
902
+ const handleAbort = () => {
903
+ log(`vama[${accountId}]: abort signal received, stopping webhook server`);
904
+ cleanup();
905
+ resolve();
906
+ };
907
+ if (opts.abortSignal?.aborted) {
908
+ cleanup();
909
+ resolve();
910
+ return;
911
+ }
912
+ opts.abortSignal?.addEventListener("abort", handleAbort, { once: true });
913
+ server.listen(port, host, () => {
914
+ log(`vama[${accountId}]: webhook server listening on ${host}:${port}`);
915
+ });
916
+ server.on("error", (err) => {
917
+ error(`vama[${accountId}]: webhook server error: ${err}`);
918
+ opts.abortSignal?.removeEventListener("abort", handleAbort);
919
+ cleanup();
920
+ reject(err);
921
+ });
922
+ });
923
+ }
924
+ //#endregion
925
+ //#region extensions/vama/src/probe.ts
926
+ const probeCache = /* @__PURE__ */ new Map();
927
+ const PROBE_CACHE_TTL_MS = 600 * 1e3;
928
+ const MAX_PROBE_CACHE_SIZE = 64;
929
+ async function probeVama(account) {
930
+ if (!account.botToken) return {
931
+ ok: false,
932
+ error: "missing credentials (botToken)"
933
+ };
934
+ const cacheKey = account.accountId;
935
+ const cached = probeCache.get(cacheKey);
936
+ if (cached && cached.expiresAt > Date.now()) return cached.result;
937
+ try {
938
+ const result = {
939
+ ok: true,
940
+ botId: (await createBotHubClient(account).getMe()).bot_id
941
+ };
942
+ probeCache.set(cacheKey, {
943
+ result,
944
+ expiresAt: Date.now() + PROBE_CACHE_TTL_MS
945
+ });
946
+ if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
947
+ const oldest = probeCache.keys().next().value;
948
+ if (oldest !== void 0) probeCache.delete(oldest);
949
+ }
950
+ return result;
951
+ } catch (err) {
952
+ return {
953
+ ok: false,
954
+ error: err instanceof Error ? err.message : String(err)
955
+ };
956
+ }
957
+ }
958
+ //#endregion
959
+ export { dispatchStarted as a, resolveVamaAccount as c, buildBaseChannelStatusSummary as d, createDefaultChannelRuntimeState as f, dispatchEnded as i, DEFAULT_ACCOUNT_ID$1 as l, monitorVamaProvider as n, listVamaAccountIds as o, sendMessageVama as r, resolveDefaultVamaAccountId as s, probeVama as t, PAIRING_APPROVED_MESSAGE as u };