@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.
- package/dist/api-C0vtNv5b.js +12 -0
- package/dist/api.js +3 -0
- package/dist/channel-plugin-api-CcZ_y9pT.js +700 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/client-AsD46gcK.js +367 -0
- package/dist/index.js +55 -0
- package/dist/probe-B2hFOc2Y.js +959 -0
- package/dist/runtime-api.js +2 -0
- package/dist/runtime-w-1oL50p.js +11 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +58 -0
|
@@ -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 };
|