@lofa199419/waha-v2 2.1.0

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/src/webhook.ts ADDED
@@ -0,0 +1,841 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { homedir } from "node:os";
5
+ import { extname, join } from "node:path";
6
+ import { readJsonBodyWithLimit, sleep } from "openclaw/plugin-sdk";
7
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
8
+ import { resolveWahaV2Account, resolveWahaV2AccountBySession } from "./accounts.js";
9
+ import { calcTypingDelayMs, chunkWahaMessage } from "./deliver.js";
10
+ import { getWahaV2Logger, getWahaV2Runtime, requireWahaV2Client } from "./runtime.js";
11
+ import {
12
+ WAHA_V2_CHANNEL_ID,
13
+ type WahaV2MediaInfo,
14
+ type WahaV2WebhookEvent,
15
+ type WahaV2WebhookPayload,
16
+ } from "./types.js";
17
+
18
+ const WA_STATUS_CHAT_ID = "status@broadcast";
19
+ const LABEL_CACHE_DEFAULT_TTL_SEC = 120;
20
+ const labelCache = new Map<string, { expiresAt: number; labels: Array<{ id?: string; name?: string }> }>();
21
+ const sessionLabelCache = new Map<
22
+ string,
23
+ { expiresAt: number; labels: Array<{ id?: string; name?: string; color?: number; colorHex?: string }> }
24
+ >();
25
+ type PauseReason = "owner-message" | "owner-word";
26
+ const manuallyPausedChats = new Map<string, { reason: PauseReason; updatedAt: number }>();
27
+ type PendingInboundEvent = { event: WahaV2WebhookEvent; cfg: OpenClawConfig; accountId?: string };
28
+ const inboundDebouncerByAccount = new Map<
29
+ string,
30
+ {
31
+ debounceMs: number;
32
+ enqueue: (item: PendingInboundEvent) => Promise<void>;
33
+ }
34
+ >();
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** WAHA chatId can be a plain string or `{ _serialized: "..." }` (WEBJS engine). */
41
+ function parseChatId(chatId: WahaV2WebhookPayload["chatId"]): string | undefined {
42
+ if (!chatId) return undefined;
43
+ if (typeof chatId === "string") return chatId;
44
+ return chatId._serialized;
45
+ }
46
+
47
+ function normalizeChatId(value: string): string {
48
+ return String(value ?? "").trim().toLowerCase();
49
+ }
50
+
51
+ function pausedChatKey(accountId: string, chatId: string): string {
52
+ return `${accountId}:${normalizeChatId(chatId)}`;
53
+ }
54
+
55
+ function isStatusBroadcastId(value: string | undefined): boolean {
56
+ return String(value ?? "")
57
+ .trim()
58
+ .toLowerCase() === WA_STATUS_CHAT_ID;
59
+ }
60
+
61
+ function resolveLabelRoutingInstruction(
62
+ labels: Array<{ id?: string; name?: string }>,
63
+ routing:
64
+ | {
65
+ enabled?: boolean;
66
+ defaultInstruction?: string;
67
+ rules?: Array<{ match: string; instruction: string; by?: "name" | "id" }>;
68
+ }
69
+ | undefined,
70
+ ): string | undefined {
71
+ if (!routing) return undefined;
72
+ if (routing.enabled === false) return undefined;
73
+
74
+ const rules = Array.isArray(routing.rules) ? routing.rules : [];
75
+ const names = new Set(
76
+ labels
77
+ .map((l) => String(l?.name ?? "").trim().toLowerCase())
78
+ .filter(Boolean),
79
+ );
80
+ const ids = new Set(
81
+ labels
82
+ .map((l) => String(l?.id ?? "").trim())
83
+ .filter(Boolean),
84
+ );
85
+
86
+ for (const rule of rules) {
87
+ const match = String(rule?.match ?? "").trim();
88
+ const instruction = String(rule?.instruction ?? "").trim();
89
+ if (!match || !instruction) continue;
90
+ if (rule.by === "id") {
91
+ if (ids.has(match)) return instruction;
92
+ continue;
93
+ }
94
+ if (names.has(match.toLowerCase())) return instruction;
95
+ }
96
+
97
+ const fallback = String(routing.defaultInstruction ?? "").trim();
98
+ return fallback || undefined;
99
+ }
100
+
101
+ function hasMatchingPauseLabel(
102
+ labels: Array<{ id?: string; name?: string }>,
103
+ pauseLabels?: string[],
104
+ ): string | undefined {
105
+ const wanted = new Set(
106
+ (pauseLabels ?? [])
107
+ .map((entry) => String(entry).trim().toLowerCase())
108
+ .filter(Boolean),
109
+ );
110
+ if (wanted.size === 0) return undefined;
111
+
112
+ for (const label of labels) {
113
+ const name = String(label.name ?? "")
114
+ .trim()
115
+ .toLowerCase();
116
+ const id = String(label.id ?? "").trim().toLowerCase();
117
+ if (name && wanted.has(name)) return label.name ?? label.id ?? name;
118
+ if (id && wanted.has(id)) return label.name ?? label.id ?? id;
119
+ }
120
+ return undefined;
121
+ }
122
+
123
+ async function getChatLabelsCached(
124
+ client: ReturnType<typeof requireWahaV2Client>,
125
+ accountId: string,
126
+ session: string,
127
+ chatId: string,
128
+ cacheTtlSec?: number,
129
+ ): Promise<Array<{ id?: string; name?: string }>> {
130
+ const ttlSec = Math.max(0, Math.floor(cacheTtlSec ?? LABEL_CACHE_DEFAULT_TTL_SEC));
131
+ const cacheKey = `${accountId}:${session}:${chatId}`;
132
+ const now = Date.now();
133
+ const cached = labelCache.get(cacheKey);
134
+ if (cached && cached.expiresAt > now) {
135
+ return cached.labels;
136
+ }
137
+ const labels = await client.getChatLabels(session, chatId);
138
+ if (ttlSec > 0) {
139
+ labelCache.set(cacheKey, { labels, expiresAt: now + ttlSec * 1000 });
140
+ } else {
141
+ labelCache.delete(cacheKey);
142
+ }
143
+ return labels;
144
+ }
145
+
146
+ async function getPauseLabelMatch(
147
+ client: ReturnType<typeof requireWahaV2Client>,
148
+ account: {
149
+ accountId: string;
150
+ session: string;
151
+ labelRouting?: { cacheTtlSec?: number; pauseLabels?: string[] };
152
+ },
153
+ chatId: string,
154
+ forceFresh = false,
155
+ ): Promise<string | undefined> {
156
+ const pauseLabels = account.labelRouting?.pauseLabels;
157
+ if (!pauseLabels || pauseLabels.length === 0) return undefined;
158
+ const labels = await getChatLabelsCached(
159
+ client,
160
+ account.accountId,
161
+ account.session,
162
+ chatId,
163
+ forceFresh ? 0 : account.labelRouting?.cacheTtlSec,
164
+ );
165
+ return hasMatchingPauseLabel(labels, pauseLabels);
166
+ }
167
+
168
+ async function getSessionLabelsCached(
169
+ client: ReturnType<typeof requireWahaV2Client>,
170
+ accountId: string,
171
+ session: string,
172
+ cacheTtlSec?: number,
173
+ ): Promise<Array<{ id?: string; name?: string; color?: number; colorHex?: string }>> {
174
+ const ttlSec = Math.max(0, Math.floor(cacheTtlSec ?? LABEL_CACHE_DEFAULT_TTL_SEC));
175
+ const cacheKey = `${accountId}:${session}`;
176
+ const now = Date.now();
177
+ const cached = sessionLabelCache.get(cacheKey);
178
+ if (cached && cached.expiresAt > now) {
179
+ return cached.labels;
180
+ }
181
+ const labels = await client.listLabels(session);
182
+ if (ttlSec > 0) {
183
+ sessionLabelCache.set(cacheKey, { labels, expiresAt: now + ttlSec * 1000 });
184
+ } else {
185
+ sessionLabelCache.delete(cacheKey);
186
+ }
187
+ return labels;
188
+ }
189
+
190
+ function extractLabelDirectives(text: string): { cleanText: string; labels: string[] } {
191
+ const labels: string[] = [];
192
+ const cleanText = text.replace(/\[\[LABEL:([^[\]]+)\]\]/gi, (_full, value: string) => {
193
+ const label = String(value ?? "").trim();
194
+ if (label) labels.push(label);
195
+ return "";
196
+ });
197
+ const uniqueLabels = Array.from(new Set(labels.map((l) => l.toLowerCase()))).map((lower) => {
198
+ const original = labels.find((l) => l.toLowerCase() === lower);
199
+ return original ?? lower;
200
+ });
201
+ return { cleanText, labels: uniqueLabels };
202
+ }
203
+
204
+ async function applyLabelDirectivesToChat(
205
+ client: ReturnType<typeof requireWahaV2Client>,
206
+ account: {
207
+ accountId: string;
208
+ session: string;
209
+ labelRouting?: { cacheTtlSec?: number };
210
+ },
211
+ chatId: string,
212
+ directives: string[],
213
+ ): Promise<void> {
214
+ if (directives.length === 0) return;
215
+ const allLabels = await getSessionLabelsCached(
216
+ client,
217
+ account.accountId,
218
+ account.session,
219
+ account.labelRouting?.cacheTtlSec,
220
+ );
221
+ const targets: Array<{ id: string; name?: string }> = [];
222
+ for (const directive of directives) {
223
+ const wanted = String(directive ?? "").trim();
224
+ if (!wanted) continue;
225
+ const wantedLc = wanted.toLowerCase();
226
+ const found = allLabels.find((l) => {
227
+ const byName = String(l.name ?? "")
228
+ .trim()
229
+ .toLowerCase();
230
+ const byId = String(l.id ?? "").trim();
231
+ return byName === wantedLc || byId === wanted;
232
+ });
233
+ if (!found?.id) {
234
+ getWahaV2Logger().warn(`waha-v2: label directive not found "${wanted}" (${chatId})`);
235
+ continue;
236
+ }
237
+ targets.push({ id: String(found.id), name: found.name });
238
+ }
239
+ if (targets.length === 0) return;
240
+
241
+ const currentLabels = await getChatLabelsCached(
242
+ client,
243
+ account.accountId,
244
+ account.session,
245
+ chatId,
246
+ account.labelRouting?.cacheTtlSec,
247
+ );
248
+ const mergedIds = new Set(currentLabels.map((l) => String(l.id ?? "").trim()).filter(Boolean));
249
+ let changed = false;
250
+ for (const target of targets) {
251
+ if (!mergedIds.has(target.id)) {
252
+ mergedIds.add(target.id);
253
+ changed = true;
254
+ }
255
+ }
256
+ if (!changed) return;
257
+
258
+ const nextIds = Array.from(mergedIds);
259
+ await client.setChatLabels(account.session, chatId, nextIds);
260
+
261
+ const nextLabels = [...currentLabels];
262
+ for (const target of targets) {
263
+ if (!nextLabels.some((l) => String(l.id ?? "").trim() === target.id)) {
264
+ nextLabels.push({ id: target.id, name: target.name });
265
+ }
266
+ }
267
+ const ttlSec = Math.max(
268
+ 0,
269
+ Math.floor(account.labelRouting?.cacheTtlSec ?? LABEL_CACHE_DEFAULT_TTL_SEC),
270
+ );
271
+ const cacheKey = `${account.accountId}:${account.session}:${chatId}`;
272
+ if (ttlSec > 0) {
273
+ labelCache.set(cacheKey, { labels: nextLabels, expiresAt: Date.now() + ttlSec * 1000 });
274
+ } else {
275
+ labelCache.delete(cacheKey);
276
+ }
277
+
278
+ getWahaV2Logger().info(
279
+ `waha-v2: applied label directives (${chatId}): ${targets.map((t) => t.name ?? t.id).join(", ")}`,
280
+ );
281
+ }
282
+
283
+ /** Normalize E.164-ish numbers to `<digits>@c.us`. */
284
+ function normalizeSenderId(raw: string): string {
285
+ if (raw.includes("@")) return raw;
286
+ return `${raw.replace(/\D+/g, "")}@c.us`;
287
+ }
288
+
289
+ function matchesOwnerPauseWord(text: string, words?: string[]): string | undefined {
290
+ const normalized = String(text ?? "").trim().toLowerCase();
291
+ if (!normalized) return undefined;
292
+ return words?.find((entry) => entry.trim().toLowerCase() === normalized);
293
+ }
294
+
295
+ function matchesOwnerResumeWord(text: string, words?: string[]): string | undefined {
296
+ const normalized = String(text ?? "").trim().toLowerCase();
297
+ if (!normalized) return undefined;
298
+ return words?.find((entry) => entry.trim().toLowerCase() === normalized);
299
+ }
300
+
301
+ /** Map MIME type to a file extension. */
302
+ function mimeToExt(mime: string): string {
303
+ const map: Record<string, string> = {
304
+ "image/jpeg": ".jpg",
305
+ "image/png": ".png",
306
+ "image/gif": ".gif",
307
+ "image/webp": ".webp",
308
+ "video/mp4": ".mp4",
309
+ "video/webm": ".webm",
310
+ "audio/ogg": ".ogg",
311
+ "audio/mpeg": ".mp3",
312
+ "audio/mp4": ".m4a",
313
+ "audio/webm": ".webm",
314
+ "audio/ogg; codecs=opus": ".ogg",
315
+ "application/pdf": ".pdf",
316
+ };
317
+ const base = mime.split(";")[0]?.trim() ?? mime;
318
+ return map[base] ?? map[mime] ?? ".bin";
319
+ }
320
+
321
+ /** Human-readable media type label for BodyForAgent. */
322
+ function mediaTypeLabel(type?: string, mime?: string): string {
323
+ if (type === "ptt" || type === "audio" || mime?.startsWith("audio/")) return "voice message";
324
+ if (type === "image" || mime?.startsWith("image/")) return "image";
325
+ if (type === "video" || mime?.startsWith("video/")) return "video";
326
+ if (type === "document") return "document";
327
+ if (type === "sticker") return "sticker";
328
+ return "media file";
329
+ }
330
+
331
+ /**
332
+ * Resolve inbound media to a local temp file path.
333
+ * Tries base64 `data` first (already embedded), then downloads from `url`.
334
+ * Returns the temp file path and MIME type, or undefined if no media.
335
+ */
336
+ async function resolveInboundMedia(
337
+ media: WahaV2MediaInfo,
338
+ accountId: string,
339
+ ): Promise<{ path: string; mimeType: string } | undefined> {
340
+ const client = requireWahaV2Client(accountId);
341
+ let buffer: Buffer | undefined;
342
+ let mimeType = media.mimetype ?? "application/octet-stream";
343
+
344
+ if (media.data) {
345
+ // Some engines embed base64 data directly in the webhook payload.
346
+ buffer = Buffer.from(media.data, "base64");
347
+ } else if (media.url) {
348
+ try {
349
+ const downloaded = await client.downloadMediaBuffer(media.url);
350
+ buffer = downloaded.buffer;
351
+ // Prefer server-reported content-type over the webhook field.
352
+ if (downloaded.contentType && downloaded.contentType !== "application/octet-stream") {
353
+ mimeType = downloaded.contentType;
354
+ }
355
+ } catch (err) {
356
+ getWahaV2Logger().warn(`waha-v2: media download failed (${media.url}): ${String(err)}`);
357
+ return undefined;
358
+ }
359
+ }
360
+
361
+ if (!buffer || buffer.length === 0) return undefined;
362
+
363
+ // Pick a filename: use original or derive from MIME type.
364
+ const origExt = media.filename ? extname(media.filename) : "";
365
+ const ext = origExt || mimeToExt(mimeType);
366
+
367
+ // Store inbound media under ~/.openclaw so image tools with workspace-only
368
+ // allowlists can still access files after a safe copy/link into workspace.
369
+ const mediaDir = join(homedir(), ".openclaw", "media", "waha-v2");
370
+ await mkdir(mediaDir, { recursive: true });
371
+
372
+ const tmpPath = join(mediaDir, `openclaw-waha-v2-${randomUUID()}${ext}`);
373
+ await writeFile(tmpPath, buffer);
374
+ return { path: tmpPath, mimeType };
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Policy helpers
379
+ // ---------------------------------------------------------------------------
380
+
381
+ /** Case-insensitive allowFrom check. Returns true when the list is empty (open). */
382
+
383
+ /** Normalize group id to <id>@g.us (best-effort). */
384
+ function normalizeGroupId(raw: string): string {
385
+ const t = String(raw ?? "").trim().toLowerCase();
386
+ if (!t) return "";
387
+ if (t.endsWith("@g.us")) return t;
388
+ if (t.includes("@")) return t;
389
+ return `${t}@g.us`;
390
+ }
391
+
392
+ /** Group allowlist check for groupPolicy=allowlist. */
393
+ function isGroupAllowed(chatId: string, allowGroups: Array<string | number>): boolean {
394
+ const list = allowGroups.map((e) => normalizeGroupId(String(e))).filter(Boolean);
395
+ if (list.length === 0) return false;
396
+ if (list.includes("*")) return true;
397
+ return list.includes(normalizeGroupId(chatId));
398
+ }
399
+ function isSenderAllowed(senderId: string, allowFrom: Array<string | number>): boolean {
400
+ const list = allowFrom.map((e) => String(e).trim().toLowerCase()).filter(Boolean);
401
+ if (list.length === 0) return true;
402
+ if (list.includes("*")) return true;
403
+ return list.includes(senderId.trim().toLowerCase());
404
+ }
405
+
406
+ // ---------------------------------------------------------------------------
407
+ // Inbound event processor (runs async, after 200 is sent)
408
+ // ---------------------------------------------------------------------------
409
+
410
+ async function processWahaV2EventImmediate(
411
+ event: WahaV2WebhookEvent,
412
+ cfg: OpenClawConfig,
413
+ accountId?: string,
414
+ ): Promise<void> {
415
+ const core = getWahaV2Runtime();
416
+ const eventName = String(event.event ?? "").trim().toLowerCase();
417
+
418
+ if (eventName !== "message" && eventName !== "message.any") return;
419
+
420
+ // Find the account that owns this session.
421
+ const account = accountId
422
+ ? resolveWahaV2Account(cfg, accountId)
423
+ : (resolveWahaV2AccountBySession(cfg, event.session) ?? resolveWahaV2Account(cfg, undefined));
424
+
425
+ const payload = event.payload;
426
+ if (!payload) return;
427
+
428
+ const text = payload.body?.trim() ?? payload._data?.body?.trim() ?? "";
429
+ const isGroup = Boolean(payload.isGroupMsg);
430
+ const senderId = payload.from ? normalizeSenderId(payload.from) : "";
431
+ const chatId = parseChatId(payload.chatId) ?? senderId;
432
+ const pausedKey = pausedChatKey(account.accountId, chatId);
433
+
434
+ // Hard-drop WhatsApp Status/Stories pseudo-thread before any session writes.
435
+ if (isStatusBroadcastId(chatId) || isStatusBroadcastId(senderId)) return;
436
+
437
+ if (payload.fromMe) {
438
+ const matchedResumeWord = matchesOwnerResumeWord(text, account.ownerResumeWords);
439
+ if (matchedResumeWord) {
440
+ const hadManualPause = manuallyPausedChats.delete(pausedKey);
441
+ getWahaV2Logger().info(
442
+ hadManualPause
443
+ ? `waha-v2: resumed chat ${chatId} for account "${account.accountId}" via owner word "${matchedResumeWord}"`
444
+ : `waha-v2: owner resume word "${matchedResumeWord}" received for unpaused chat ${chatId} on account "${account.accountId}"`,
445
+ );
446
+ return;
447
+ }
448
+
449
+ const matchedPauseWord = matchesOwnerPauseWord(text, account.ownerPauseWords);
450
+ if (matchedPauseWord) {
451
+ manuallyPausedChats.set(pausedKey, { reason: "owner-word", updatedAt: Date.now() });
452
+ getWahaV2Logger().info(
453
+ `waha-v2: paused chat ${chatId} for account "${account.accountId}" via owner word "${matchedPauseWord}"`,
454
+ );
455
+ } else if (account.pauseOnOwnerMessage) {
456
+ manuallyPausedChats.set(pausedKey, { reason: "owner-message", updatedAt: Date.now() });
457
+ getWahaV2Logger().info(
458
+ `waha-v2: paused chat ${chatId} for account "${account.accountId}" via owner message`,
459
+ );
460
+ }
461
+ return;
462
+ }
463
+
464
+ // Resolve media from the payload (checking both top-level and _data).
465
+ const rawMedia: WahaV2MediaInfo | undefined = payload.media ?? payload._data?.media;
466
+ const hasMedia = Boolean(payload.hasMedia || rawMedia);
467
+
468
+ // Skip if no text and no media — nothing to dispatch.
469
+ if (!text && !hasMedia) return;
470
+
471
+ // Apply inbound access policies before dispatching to the agent runtime.
472
+ if (isGroup) {
473
+ if (account.groupPolicy === "deny") return;
474
+ if (account.groupPolicy === "allowlist" && !isGroupAllowed(chatId, account.allowGroups ?? [])) {
475
+ return;
476
+ }
477
+ } else {
478
+ if (account.dmPolicy === "deny") return;
479
+ if (!isSenderAllowed(senderId, account.allowFrom ?? [])) return;
480
+ }
481
+
482
+ const client = requireWahaV2Client(account.accountId);
483
+
484
+ const manualPause = manuallyPausedChats.get(pausedKey);
485
+ if (manualPause) {
486
+ getWahaV2Logger().info(
487
+ `waha-v2: skipped inbound message for paused chat ${chatId} on account "${account.accountId}" (${manualPause.reason})`,
488
+ );
489
+ return;
490
+ }
491
+
492
+ const pauseLabelMatch = await getPauseLabelMatch(client, account, chatId);
493
+ if (pauseLabelMatch) {
494
+ getWahaV2Logger().info(
495
+ `waha-v2: skipped inbound message for paused chat ${chatId} on account "${account.accountId}" via label "${pauseLabelMatch}"`,
496
+ );
497
+ return;
498
+ }
499
+
500
+ let policyInstruction: string | undefined;
501
+ let labelSummary = "none";
502
+ if (account.labelRouting) {
503
+ try {
504
+ const labels = await getChatLabelsCached(
505
+ client,
506
+ account.accountId,
507
+ account.session,
508
+ chatId,
509
+ account.labelRouting.cacheTtlSec,
510
+ );
511
+
512
+ if (labels.length > 0) {
513
+ labelSummary = labels.map((l) => l.name || l.id || "?").join(", ");
514
+ }
515
+ policyInstruction = resolveLabelRoutingInstruction(labels, account.labelRouting);
516
+ } catch (err) {
517
+ getWahaV2Logger().warn(`waha-v2: label lookup failed (${chatId}): ${String(err)}`);
518
+ }
519
+ }
520
+
521
+ // Download/decode media to a local temp file so the agent can inspect it.
522
+ let mediaPath: string | undefined;
523
+ let mediaMime: string | undefined;
524
+ if (hasMedia && rawMedia) {
525
+ const resolved = await resolveInboundMedia(rawMedia, account.accountId).catch((err) => {
526
+ getWahaV2Logger().warn(`waha-v2: media resolution error: ${String(err)}`);
527
+ return undefined;
528
+ });
529
+ mediaPath = resolved?.path;
530
+ mediaMime = resolved?.mimeType;
531
+ }
532
+
533
+ // Build a descriptive body when there's media but no caption text.
534
+ const mediaLabel = mediaPath ? mediaTypeLabel(payload.type, mediaMime) : undefined;
535
+ const bodyForAgent = text || (mediaLabel ? `[${mediaLabel}]` : "");
536
+ const policyPrefix = policyInstruction
537
+ ? `[CHAT POLICY]\nLabels: ${labelSummary}\nInstruction: ${policyInstruction}\n\n`
538
+ : "";
539
+ const bodyForAgentWithPolicy = `${policyPrefix}${bodyForAgent}`.trim();
540
+
541
+ // If we have both caption text and media, surface both in Body.
542
+ const combinedBody = text && mediaLabel ? `${text}\n[${mediaLabel}]` : bodyForAgent;
543
+
544
+ const route = core.channel.routing.resolveAgentRoute({
545
+ cfg,
546
+ channel: WAHA_V2_CHANNEL_ID,
547
+ accountId: account.accountId,
548
+ peer: { kind: isGroup ? "group" : "direct", id: chatId },
549
+ });
550
+
551
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
552
+ agentId: route.agentId,
553
+ });
554
+
555
+ const ctx = core.channel.reply.finalizeInboundContext({
556
+ Body: combinedBody,
557
+ BodyForAgent: bodyForAgentWithPolicy,
558
+ RawBody: text,
559
+ CommandBody: text,
560
+ From: `${WAHA_V2_CHANNEL_ID}:${senderId}`,
561
+ To: `${WAHA_V2_CHANNEL_ID}:${account.session}`,
562
+ SessionKey: route.sessionKey,
563
+ AccountId: route.accountId,
564
+ ChatType: isGroup ? ("group" as const) : ("direct" as const),
565
+ Provider: WAHA_V2_CHANNEL_ID,
566
+ Surface: WAHA_V2_CHANNEL_ID,
567
+ SenderId: senderId,
568
+ MessageSid: payload.id,
569
+ Timestamp: payload.timestamp ? payload.timestamp * 1000 : Date.now(),
570
+ // Media fields — consumed by the agent framework to send the file to the AI.
571
+ MediaUrl: mediaPath,
572
+ MediaPath: mediaPath,
573
+ MediaType: mediaMime,
574
+ MediaUrls: mediaPath ? [mediaPath] : undefined,
575
+ MediaPaths: mediaPath ? [mediaPath] : undefined,
576
+ MediaTypes: mediaMime ? [mediaMime] : undefined,
577
+ });
578
+
579
+ await core.channel.session.recordInboundSession({
580
+ storePath,
581
+ sessionKey: ctx.SessionKey ?? route.sessionKey,
582
+ ctx,
583
+ onRecordError: (err) => {
584
+ getWahaV2Logger().warn(`waha-v2: session record error: ${String(err)}`);
585
+ },
586
+ });
587
+
588
+ // Resolve typing/chunking config — all default to enabled/on.
589
+ const typingEnabled = account.typing?.enabled !== false;
590
+ const chunkingEnabled = account.typing?.chunking !== false;
591
+ const charsPerSecond = account.typing?.charsPerSecond;
592
+ const maxChunkLength = account.typing?.maxChunkLength;
593
+ const deliveryDebugEnabled = account.typing?.debug === true;
594
+ const seenDelayMs = 3000;
595
+ const seenMessageIds = payload.id ? [String(payload.id)] : undefined;
596
+ let preReplyTypingStarted = false;
597
+
598
+ const logDeliveryDebug = (message: string): void => {
599
+ if (!deliveryDebugEnabled) return;
600
+ getWahaV2Logger().info(`[waha-v2 delivery] ${new Date().toISOString()} ${message}`);
601
+ };
602
+
603
+ const startTypingWithLog = async (phase: string): Promise<void> => {
604
+ if (!typingEnabled) return;
605
+ await client.startTyping(account.session, chatId);
606
+ logDeliveryDebug(`${phase}: started typing`);
607
+ };
608
+
609
+ const stopTypingWithLog = async (phase: string): Promise<void> => {
610
+ if (!typingEnabled) return;
611
+ await client.stopTyping(account.session, chatId);
612
+ logDeliveryDebug(`${phase}: stopped typing`);
613
+ };
614
+
615
+ const preReplySequencePromise = (async () => {
616
+ logDeliveryDebug(`queued to agent for ${chatId}`);
617
+ await sleep(seenDelayMs);
618
+ await client.sendSeen(account.session, chatId, seenMessageIds);
619
+ logDeliveryDebug(`marked seen after ${seenDelayMs}ms for ${chatId}`);
620
+ if (typingEnabled) {
621
+ await startTypingWithLog("pre-output");
622
+ preReplyTypingStarted = true;
623
+ }
624
+ })().catch((err) => {
625
+ getWahaV2Logger().warn(`waha-v2: pre-reply sequence failed (${chatId}): ${String(err)}`);
626
+ });
627
+
628
+ const { dispatcher, replyOptions, markDispatchIdle } =
629
+ core.channel.reply.createReplyDispatcherWithTyping({
630
+ typingCallbacks: undefined,
631
+ humanDelay: undefined,
632
+ deliver: async (replyPayload) => {
633
+ await preReplySequencePromise;
634
+ const manualPause = manuallyPausedChats.get(pausedKey);
635
+ if (manualPause) {
636
+ getWahaV2Logger().info(
637
+ `waha-v2: dropping reply for paused chat ${chatId} on account "${account.accountId}" (${manualPause.reason})`,
638
+ );
639
+ return;
640
+ }
641
+
642
+ const sendPauseLabelMatch = await getPauseLabelMatch(client, account, chatId, true);
643
+ if (sendPauseLabelMatch) {
644
+ getWahaV2Logger().info(
645
+ `waha-v2: dropping reply for paused chat ${chatId} on account "${account.accountId}" via label "${sendPauseLabelMatch}"`,
646
+ );
647
+ return;
648
+ }
649
+
650
+ const rawReplyText = replyPayload.text?.trim() ?? "";
651
+ if (!rawReplyText) return;
652
+
653
+ const { cleanText, labels: labelDirectives } = extractLabelDirectives(rawReplyText);
654
+ if (labelDirectives.length > 0) {
655
+ await applyLabelDirectivesToChat(client, account, chatId, labelDirectives).catch((err) => {
656
+ getWahaV2Logger().warn(
657
+ `waha-v2: failed applying label directives (${chatId}): ${String(err)}`,
658
+ );
659
+ });
660
+ }
661
+
662
+ const replyText = cleanText.trim();
663
+ if (!replyText) return;
664
+
665
+ const chunks = chunkingEnabled ? chunkWahaMessage(replyText, maxChunkLength) : [replyText];
666
+ logDeliveryDebug(
667
+ `output ready for ${chatId}; chunking=${chunkingEnabled} chunks=${chunks.length} totalLen=${replyText.length}`,
668
+ );
669
+
670
+ if (preReplyTypingStarted) {
671
+ await stopTypingWithLog("pre-output");
672
+ preReplyTypingStarted = false;
673
+ }
674
+
675
+ if (!chunkingEnabled || chunks.length <= 1) {
676
+ getWahaV2Logger().debug?.(
677
+ `waha-v2: send single reply (${chatId}) len=${replyText.length}`,
678
+ );
679
+ await client.sendText(account.session, chatId, chunks[0] ?? replyText);
680
+ logDeliveryDebug(`sent single reply for ${chatId}`);
681
+ return;
682
+ }
683
+ getWahaV2Logger().debug?.(
684
+ `waha-v2: send chunked reply (${chatId}) chunks=${chunks.length} totalLen=${replyText.length}`,
685
+ );
686
+ for (let i = 0; i < chunks.length; i++) {
687
+ const chunkManualPause = manuallyPausedChats.get(pausedKey);
688
+ if (chunkManualPause) {
689
+ getWahaV2Logger().info(
690
+ `waha-v2: stopping chunked reply for paused chat ${chatId} on account "${account.accountId}" (${chunkManualPause.reason})`,
691
+ );
692
+ return;
693
+ }
694
+
695
+ const chunkPauseLabelMatch = await getPauseLabelMatch(client, account, chatId, true);
696
+ if (chunkPauseLabelMatch) {
697
+ getWahaV2Logger().info(
698
+ `waha-v2: stopping chunked reply for paused chat ${chatId} on account "${account.accountId}" via label "${chunkPauseLabelMatch}"`,
699
+ );
700
+ return;
701
+ }
702
+
703
+ const chunk = chunks[i];
704
+ if (!chunk) continue;
705
+ if (i > 0 && typingEnabled) {
706
+ try {
707
+ await startTypingWithLog(`chunk ${i + 1}/${chunks.length}`);
708
+ await sleep(calcTypingDelayMs(chunk, charsPerSecond));
709
+ await stopTypingWithLog(`chunk ${i + 1}/${chunks.length}`);
710
+ } catch (err) {
711
+ getWahaV2Logger().warn(
712
+ `waha-v2: inter-chunk typing failed (${chatId}, ${i + 1}/${chunks.length}): ${String(err)}`,
713
+ );
714
+ }
715
+ }
716
+ getWahaV2Logger().debug?.(
717
+ `waha-v2: send chunk (${chatId}) index=${i + 1}/${chunks.length} len=${chunk.length}`,
718
+ );
719
+ await client.sendText(account.session, chatId, chunk);
720
+ logDeliveryDebug(`sent chunk ${i + 1}/${chunks.length} for ${chatId} len=${chunk.length}`);
721
+ }
722
+ },
723
+ });
724
+
725
+ try {
726
+ await core.channel.reply.dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyOptions });
727
+ } finally {
728
+ await preReplySequencePromise;
729
+ if (preReplyTypingStarted) {
730
+ try {
731
+ await stopTypingWithLog("finalize");
732
+ } catch (err) {
733
+ getWahaV2Logger().warn(`waha-v2: final typing stop failed (${chatId}): ${String(err)}`);
734
+ } finally {
735
+ preReplyTypingStarted = false;
736
+ }
737
+ }
738
+ markDispatchIdle();
739
+ }
740
+ }
741
+
742
+ async function handleWahaV2Event(
743
+ event: WahaV2WebhookEvent,
744
+ cfg: OpenClawConfig,
745
+ accountId?: string,
746
+ ): Promise<void> {
747
+ const core = getWahaV2Runtime();
748
+ const account = accountId
749
+ ? resolveWahaV2Account(cfg, accountId)
750
+ : (resolveWahaV2AccountBySession(cfg, event.session) ?? resolveWahaV2Account(cfg, undefined));
751
+ const debounceMs = core.channel.debounce.resolveInboundDebounceMs({
752
+ cfg,
753
+ channel: WAHA_V2_CHANNEL_ID,
754
+ overrideMs: account.debounceMs,
755
+ });
756
+
757
+ if (debounceMs <= 0) {
758
+ await processWahaV2EventImmediate(event, cfg, accountId);
759
+ return;
760
+ }
761
+
762
+ const debouncerKey = account.accountId || "default";
763
+ const existing = inboundDebouncerByAccount.get(debouncerKey);
764
+ if (!existing || existing.debounceMs !== debounceMs) {
765
+ const debouncer = core.channel.debounce.createInboundDebouncer<PendingInboundEvent>({
766
+ debounceMs,
767
+ buildKey: (item) => {
768
+ const payload = item.event.payload;
769
+ const sender = payload?.from ? normalizeSenderId(payload.from) : "";
770
+ const chatId = parseChatId(payload?.chatId) ?? sender;
771
+ if (!chatId) return null;
772
+ return `${item.event.session}:${chatId}`;
773
+ },
774
+ shouldDebounce: (item) => {
775
+ const payload = item.event.payload;
776
+ if (!payload || payload.fromMe) return false;
777
+ const hasMedia = Boolean(payload.hasMedia || payload.media || payload._data?.media);
778
+ return !hasMedia;
779
+ },
780
+ onFlush: async (items) => {
781
+ const last = items[items.length - 1];
782
+ if (!last) return;
783
+ await processWahaV2EventImmediate(last.event, last.cfg, last.accountId);
784
+ },
785
+ onError: (err) => {
786
+ getWahaV2Logger().warn(`waha-v2: inbound debounce flush error: ${String(err)}`);
787
+ },
788
+ });
789
+ inboundDebouncerByAccount.set(debouncerKey, { debounceMs, enqueue: debouncer.enqueue });
790
+ }
791
+
792
+ await inboundDebouncerByAccount.get(debouncerKey)?.enqueue({ event, cfg, accountId });
793
+ }
794
+
795
+ // ---------------------------------------------------------------------------
796
+ // HTTP handler — registered via api.registerHttpHandler
797
+ // ---------------------------------------------------------------------------
798
+
799
+ /**
800
+ * Handles inbound WAHA webhook POSTs.
801
+ * Responds 200 immediately, then processes the event asynchronously.
802
+ */
803
+ export async function handleWahaV2WebhookRequest(
804
+ req: IncomingMessage,
805
+ res: ServerResponse,
806
+ cfg: OpenClawConfig,
807
+ /** accountId extracted from the URL path — makes routing unambiguous. */
808
+ accountId?: string,
809
+ ): Promise<void> {
810
+ if (req.method !== "POST") {
811
+ res.statusCode = 405;
812
+ res.end("Method Not Allowed");
813
+ return;
814
+ }
815
+
816
+ const body = await readJsonBodyWithLimit(req, {
817
+ maxBytes: 10 * 1024 * 1024, // 10 MB — some engines embed base64 media in webhook body
818
+ timeoutMs: 30_000,
819
+ emptyObjectOnEmpty: true,
820
+ });
821
+
822
+ if (!body.ok) {
823
+ res.statusCode = body.code === "PAYLOAD_TOO_LARGE" ? 413 : 400;
824
+ res.end(body.error ?? "Bad Request");
825
+ return;
826
+ }
827
+
828
+ // Acknowledge immediately — WAHA expects a fast 200.
829
+ res.statusCode = 200;
830
+ res.setHeader("Content-Type", "application/json");
831
+ res.end('{"ok":true}');
832
+
833
+ // Process asynchronously so WAHA's retry logic isn't triggered by agent latency.
834
+ handleWahaV2Event(body.value as WahaV2WebhookEvent, cfg, accountId).catch((err) => {
835
+ try {
836
+ getWahaV2Logger().warn(`waha-v2 webhook error: ${String(err)}`);
837
+ } catch {
838
+ // Runtime may not be initialized in edge cases; swallow.
839
+ }
840
+ });
841
+ }