@lofa199419/waha-v2 2026.3.2

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,264 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { extname, join } from "node:path";
5
+ import type { IncomingMessage, ServerResponse } from "node:http";
6
+ import { readJsonBodyWithLimit } from "openclaw/plugin-sdk";
7
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
8
+ import { resolveWahaV2Account, resolveWahaV2AccountBySession } from "./accounts.js";
9
+ import { getWahaV2Logger, getWahaV2Runtime, requireWahaV2Client } from "./runtime.js";
10
+ import {
11
+ WAHA_V2_CHANNEL_ID,
12
+ type WahaV2MediaInfo,
13
+ type WahaV2WebhookEvent,
14
+ type WahaV2WebhookPayload,
15
+ } from "./types.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** WAHA chatId can be a plain string or `{ _serialized: "..." }` (WEBJS engine). */
22
+ function parseChatId(chatId: WahaV2WebhookPayload["chatId"]): string | undefined {
23
+ if (!chatId) return undefined;
24
+ if (typeof chatId === "string") return chatId;
25
+ return chatId._serialized;
26
+ }
27
+
28
+ /** Normalize E.164-ish numbers to `<digits>@c.us`. */
29
+ function normalizeSenderId(raw: string): string {
30
+ if (raw.includes("@")) return raw;
31
+ return `${raw.replace(/\D+/g, "")}@c.us`;
32
+ }
33
+
34
+ /** Map MIME type to a file extension. */
35
+ function mimeToExt(mime: string): string {
36
+ const map: Record<string, string> = {
37
+ "image/jpeg": ".jpg",
38
+ "image/png": ".png",
39
+ "image/gif": ".gif",
40
+ "image/webp": ".webp",
41
+ "video/mp4": ".mp4",
42
+ "video/webm": ".webm",
43
+ "audio/ogg": ".ogg",
44
+ "audio/mpeg": ".mp3",
45
+ "audio/mp4": ".m4a",
46
+ "audio/webm": ".webm",
47
+ "audio/ogg; codecs=opus": ".ogg",
48
+ "application/pdf": ".pdf",
49
+ };
50
+ const base = mime.split(";")[0]?.trim() ?? mime;
51
+ return map[base] ?? map[mime] ?? ".bin";
52
+ }
53
+
54
+ /** Human-readable media type label for BodyForAgent. */
55
+ function mediaTypeLabel(type?: string, mime?: string): string {
56
+ if (type === "ptt" || type === "audio" || mime?.startsWith("audio/")) return "voice message";
57
+ if (type === "image" || mime?.startsWith("image/")) return "image";
58
+ if (type === "video" || mime?.startsWith("video/")) return "video";
59
+ if (type === "document") return "document";
60
+ if (type === "sticker") return "sticker";
61
+ return "media file";
62
+ }
63
+
64
+ /**
65
+ * Resolve inbound media to a local temp file path.
66
+ * Tries base64 `data` first (already embedded), then downloads from `url`.
67
+ * Returns the temp file path and MIME type, or undefined if no media.
68
+ */
69
+ async function resolveInboundMedia(
70
+ media: WahaV2MediaInfo,
71
+ accountId: string,
72
+ ): Promise<{ path: string; mimeType: string } | undefined> {
73
+ const client = requireWahaV2Client(accountId);
74
+ let buffer: Buffer | undefined;
75
+ let mimeType = media.mimetype ?? "application/octet-stream";
76
+
77
+ if (media.data) {
78
+ // Some engines embed base64 data directly in the webhook payload.
79
+ buffer = Buffer.from(media.data, "base64");
80
+ } else if (media.url) {
81
+ try {
82
+ const downloaded = await client.downloadMediaBuffer(media.url);
83
+ buffer = downloaded.buffer;
84
+ // Prefer server-reported content-type over the webhook field.
85
+ if (downloaded.contentType && downloaded.contentType !== "application/octet-stream") {
86
+ mimeType = downloaded.contentType;
87
+ }
88
+ } catch (err) {
89
+ getWahaV2Logger().warn(`waha-v2: media download failed (${media.url}): ${String(err)}`);
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ if (!buffer || buffer.length === 0) return undefined;
95
+
96
+ // Pick a filename: use original or derive from MIME type.
97
+ const origExt = media.filename ? extname(media.filename) : "";
98
+ const ext = origExt || mimeToExt(mimeType);
99
+ const tmpPath = join(tmpdir(), `openclaw-waha-v2-${randomUUID()}${ext}`);
100
+ await writeFile(tmpPath, buffer);
101
+ return { path: tmpPath, mimeType };
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Inbound event processor (runs async, after 200 is sent)
106
+ // ---------------------------------------------------------------------------
107
+
108
+ async function handleWahaV2Event(
109
+ event: WahaV2WebhookEvent,
110
+ cfg: OpenClawConfig,
111
+ accountId?: string,
112
+ ): Promise<void> {
113
+ const core = getWahaV2Runtime();
114
+
115
+ // Only handle inbound messages — skip ack, reaction, status, and self-sent events.
116
+ if (event.event !== "message" && event.event !== "message.any") return;
117
+ const payload = event.payload;
118
+ if (!payload || payload.fromMe) return;
119
+
120
+ const text = payload.body?.trim() ?? "";
121
+ // Resolve media from the payload (checking both top-level and _data).
122
+ const rawMedia: WahaV2MediaInfo | undefined = payload.media ?? payload._data?.media;
123
+ const hasMedia = Boolean(payload.hasMedia || rawMedia);
124
+
125
+ // Skip if no text and no media — nothing to dispatch.
126
+ if (!text && !hasMedia) return;
127
+
128
+ // Find the account that owns this session.
129
+ const account = accountId
130
+ ? resolveWahaV2Account(cfg, accountId)
131
+ : (resolveWahaV2AccountBySession(cfg, event.session) ?? resolveWahaV2Account(cfg, undefined));
132
+
133
+ const isGroup = Boolean(payload.isGroupMsg);
134
+ const senderId = payload.from ? normalizeSenderId(payload.from) : "";
135
+ const chatId = parseChatId(payload.chatId) ?? senderId;
136
+
137
+ // Download/decode media to a local temp file so the agent can inspect it.
138
+ let mediaPath: string | undefined;
139
+ let mediaMime: string | undefined;
140
+ if (hasMedia && rawMedia) {
141
+ const resolved = await resolveInboundMedia(rawMedia, account.accountId).catch((err) => {
142
+ getWahaV2Logger().warn(`waha-v2: media resolution error: ${String(err)}`);
143
+ return undefined;
144
+ });
145
+ mediaPath = resolved?.path;
146
+ mediaMime = resolved?.mimeType;
147
+ }
148
+
149
+ // Build a descriptive body when there's media but no caption text.
150
+ const mediaLabel = mediaPath ? mediaTypeLabel(payload.type, mediaMime) : undefined;
151
+ const bodyForAgent = text || (mediaLabel ? `[${mediaLabel}]` : "");
152
+
153
+ // If we have both caption text and media, surface both in Body.
154
+ const combinedBody = text && mediaLabel ? `${text}\n[${mediaLabel}]` : bodyForAgent;
155
+
156
+ const route = core.channel.routing.resolveAgentRoute({
157
+ cfg,
158
+ channel: WAHA_V2_CHANNEL_ID,
159
+ accountId: account.accountId,
160
+ peer: { kind: isGroup ? "group" : "direct", id: chatId },
161
+ });
162
+
163
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
164
+ agentId: route.agentId,
165
+ });
166
+
167
+ const ctx = core.channel.reply.finalizeInboundContext({
168
+ Body: combinedBody,
169
+ BodyForAgent: bodyForAgent,
170
+ RawBody: text,
171
+ CommandBody: text,
172
+ From: `${WAHA_V2_CHANNEL_ID}:${senderId}`,
173
+ To: `${WAHA_V2_CHANNEL_ID}:${account.session}`,
174
+ SessionKey: route.sessionKey,
175
+ AccountId: route.accountId,
176
+ ChatType: isGroup ? ("group" as const) : ("direct" as const),
177
+ Provider: WAHA_V2_CHANNEL_ID,
178
+ Surface: WAHA_V2_CHANNEL_ID,
179
+ SenderId: senderId,
180
+ MessageSid: payload.id,
181
+ Timestamp: payload.timestamp ? payload.timestamp * 1000 : Date.now(),
182
+ // Media fields — consumed by the agent framework to send the file to the AI.
183
+ MediaUrl: mediaPath,
184
+ MediaPath: mediaPath,
185
+ MediaType: mediaMime,
186
+ MediaUrls: mediaPath ? [mediaPath] : undefined,
187
+ MediaPaths: mediaPath ? [mediaPath] : undefined,
188
+ MediaTypes: mediaMime ? [mediaMime] : undefined,
189
+ });
190
+
191
+ await core.channel.session.recordInboundSession({
192
+ storePath,
193
+ sessionKey: ctx.SessionKey ?? route.sessionKey,
194
+ ctx,
195
+ onRecordError: (err) => {
196
+ getWahaV2Logger().warn(`waha-v2: session record error: ${String(err)}`);
197
+ },
198
+ });
199
+
200
+ const client = requireWahaV2Client(account.accountId);
201
+
202
+ const { dispatcher, replyOptions, markDispatchIdle } =
203
+ core.channel.reply.createReplyDispatcherWithTyping({
204
+ deliver: async (replyPayload) => {
205
+ const replyText = replyPayload.text?.trim() ?? "";
206
+ if (!replyText) return;
207
+ await client.sendText(account.session, chatId, replyText);
208
+ },
209
+ });
210
+
211
+ try {
212
+ await core.channel.reply.dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyOptions });
213
+ } finally {
214
+ markDispatchIdle();
215
+ }
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // HTTP handler — registered via api.registerHttpHandler
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /**
223
+ * Handles inbound WAHA webhook POSTs.
224
+ * Responds 200 immediately, then processes the event asynchronously.
225
+ */
226
+ export async function handleWahaV2WebhookRequest(
227
+ req: IncomingMessage,
228
+ res: ServerResponse,
229
+ cfg: OpenClawConfig,
230
+ /** accountId extracted from the URL path — makes routing unambiguous. */
231
+ accountId?: string,
232
+ ): Promise<void> {
233
+ if (req.method !== "POST") {
234
+ res.statusCode = 405;
235
+ res.end("Method Not Allowed");
236
+ return;
237
+ }
238
+
239
+ const body = await readJsonBodyWithLimit(req, {
240
+ maxBytes: 10 * 1024 * 1024, // 10 MB — some engines embed base64 media in webhook body
241
+ timeoutMs: 30_000,
242
+ emptyObjectOnEmpty: true,
243
+ });
244
+
245
+ if (!body.ok) {
246
+ res.statusCode = body.code === "PAYLOAD_TOO_LARGE" ? 413 : 400;
247
+ res.end(body.error ?? "Bad Request");
248
+ return;
249
+ }
250
+
251
+ // Acknowledge immediately — WAHA expects a fast 200.
252
+ res.statusCode = 200;
253
+ res.setHeader("Content-Type", "application/json");
254
+ res.end('{"ok":true}');
255
+
256
+ // Process asynchronously so WAHA's retry logic isn't triggered by agent latency.
257
+ handleWahaV2Event(body.value as WahaV2WebhookEvent, cfg, accountId).catch((err) => {
258
+ try {
259
+ getWahaV2Logger().warn(`waha-v2 webhook error: ${String(err)}`);
260
+ } catch {
261
+ // Runtime may not be initialized in edge cases; swallow.
262
+ }
263
+ });
264
+ }