From 9b4dceecfe69bb224a369a33a1c064003040aedd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 24 Nov 2025 18:33:50 +0100 Subject: [PATCH] Add web provider inbound monitor with auto-replies --- README.md | 24 +++- src/index.ts | 301 ++++++++++++++++++++++++++++++++++++++++---- src/provider-web.ts | 189 ++++++++++++++++++++++++---- src/utils.ts | 8 ++ 4 files changed, 474 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 273bbdbd2..acb200f7d 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,13 @@ You can also use a personal WhatsApp Web session (QR login) via `--provider web` ## Providers (choose per command) - **Twilio (default)** — full feature set: send, wait/poll delivery, status, inbound polling/webhook, auto-replies. Requires `.env` Twilio creds and a WhatsApp-enabled number (`TWILIO_WHATSAPP_FROM`). -- **Web (`--provider web`)** — uses your personal WhatsApp Web session via QR. Currently **send-only** (no inbound/auto-reply/status yet) and returns immediately without delivery polling. Setup: `pnpm warelay web:login` then send with `--provider web`. Session data lives in `~/.warelay/waweb/`; if logged out, rerun `web:login`. Use at your own risk (personal-account automation can be rate-limited or logged out by WhatsApp). +- **Web (`--provider web`)** — personal WhatsApp Web session via QR. Supports outbound sends (`send --provider web`) and inbound auto-replies when you run `pnpm warelay web:monitor`. No delivery-status polling for web sends. Setup: `pnpm warelay web:login` then either send with `--provider web` or keep `web:monitor` running. Session data lives in `~/.warelay/waweb/`; if logged out, rerun `web:login`. Use at your own risk (personal-account automation can be rate-limited or logged out by WhatsApp). ## Common Commands - Send: `pnpm warelay send --to +12345550000 --message "Hello" --wait 20 --poll 2` - Send via personal WhatsApp Web: first `pnpm warelay web:login` (scan QR), then `pnpm warelay send --provider web --to +12345550000 --message "Hi"` +- Web auto-replies (personal WA): `pnpm warelay web:login` once, then run `pnpm warelay web:monitor` to listen and auto-reply using your `~/.warelay/warelay.json` config - Poll (lightweight): `pnpm warelay poll --interval 5 --lookback 10 --verbose` - Webhook only: `pnpm warelay webhook --port 42873 --path /webhook/whatsapp --verbose` - Webhook + Funnel + Twilio update: `pnpm warelay up --port 42873 --path /webhook/whatsapp --verbose` @@ -46,9 +47,19 @@ You can also use a personal WhatsApp Web session (QR login) via `--provider web` command: [ "claude", "-p", + "--output-format", + "json", "--dangerously-skip-permissions", "{{Body}}" - ] + ], + session: { + scope: "per-sender", + resetTriggers: ["/new"], + idleMinutes: 60, + sessionArgNew: ["--session-id", "{{SessionId}}"], + sessionArgResume: ["--resume", "{{SessionId}}"], + sessionArgBeforeBody: true + } } } } @@ -64,7 +75,8 @@ You can also use a personal WhatsApp Web session (QR login) via `--provider web` ``` Notes: -- Templates support `{{Body}}`, `{{From}}`, `{{To}}`, `{{MessageSid}}`. +- Templates support `{{Body}}`, `{{BodyStripped}}`, `{{From}}`, `{{To}}`, `{{MessageSid}}`, plus `{{SessionId}}`/`{{IsNewSession}}` when session reuse is enabled. +- `/new` (or any `resetTriggers` value) resets the session. `/new ask…` resets and sends `ask…` as the prompt (via `BodyStripped`). - When an auto-reply starts (text or command), warelay sends a WhatsApp typing indicator tied to the inbound `MessageSid`. ## Troubleshooting Delivery @@ -83,6 +95,12 @@ Notes: | `inbound.reply.command` | `string[]` | — | Argv to run for command mode; templated per element. Stdout (trimmed) is sent. | | `inbound.reply.template` | `string` | — | Optional string inserted as second argv element (prompt prefix). | | `inbound.reply.bodyPrefix` | `string` | — | Prepends to `Body` before templating (ideal for system instructions). | +| `inbound.reply.session.scope` | `"per-sender" \| "global"` | `per-sender` | Session key: one per sender or single global chat. | +| `inbound.reply.session.resetTriggers` | `string[]` | `["/new"]` | Any entry acts as both exact reset token and prefix (`/new hi`). | +| `inbound.reply.session.idleMinutes` | `number` | `60` | Expire and recreate session after this idle time. | +| `inbound.reply.session.sessionArgNew` | `string[]` | `["--session-id","{{SessionId}}"]` | Args inserted for a new session run. | +| `inbound.reply.session.sessionArgResume` | `string[]` | `["--resume","{{SessionId}}"]` | Args inserted when resuming an existing session. | +| `inbound.reply.session.sessionArgBeforeBody` | `boolean` | `true` | Place session args before the final body argument. | | `inbound.reply.timeoutSeconds` | `number` | 600 | Command timeout. | ## Dev Notes diff --git a/src/index.ts b/src/index.ts index b0f773cc6..c0af3669b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { execFile, spawn } from "node:child_process"; +import crypto from "node:crypto"; import fs from "node:fs"; import net from "node:net"; import os from "node:os"; @@ -8,6 +9,7 @@ import process, { stdin as input, stdout as output } from "node:process"; import readline from "node:readline/promises"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; + import bodyParser from "body-parser"; import chalk from "chalk"; import { Command } from "commander"; @@ -16,21 +18,22 @@ import express, { type Request, type Response } from "express"; import JSON5 from "json5"; import Twilio from "twilio"; import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js"; + import { danger, info, - isYes, isVerbose, + isYes, logVerbose, setVerbose, setYes, success, warn, } from "./globals.js"; -import { loginWeb, sendMessageWeb } from "./provider-web.js"; +import { loginWeb, monitorWebInbox, sendMessageWeb } from "./provider-web.js"; import { - Provider, assertProvider, + CONFIG_DIR, normalizeE164, normalizePath, sleep, @@ -379,10 +382,23 @@ type WarelayConfig = { template?: string; // prepend template string when building command/prompt timeoutSeconds?: number; // optional command timeout; defaults to 600s bodyPrefix?: string; // optional string prepended to Body before templating + session?: SessionConfig; }; }; }; +type SessionScope = "per-sender" | "global"; + +type SessionConfig = { + scope?: SessionScope; + resetTriggers?: string[]; + idleMinutes?: number; + store?: string; + sessionArgNew?: string[]; + sessionArgResume?: string[]; + sessionArgBeforeBody?: boolean; +}; + function loadConfig(): WarelayConfig { // Read ~/.warelay/warelay.json (JSON5) if present. try { @@ -408,7 +424,7 @@ type GetReplyOptions = { onReplyStart?: () => Promise | void; }; -function applyTemplate(str: string, ctx: MsgContext) { +function applyTemplate(str: string, ctx: TemplateContext) { // Simple {{Placeholder}} interpolation using inbound message context. return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => { const value = (ctx as Record)[key]; @@ -416,12 +432,56 @@ function applyTemplate(str: string, ctx: MsgContext) { }); } +type TemplateContext = MsgContext & { + BodyStripped?: string; + SessionId?: string; + IsNewSession?: string; +}; + +type SessionEntry = { sessionId: string; updatedAt: number }; + +const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json"); +const DEFAULT_RESET_TRIGGER = "/new"; +const DEFAULT_IDLE_MINUTES = 60; + +function resolveStorePath(store?: string) { + if (!store) return SESSION_STORE_DEFAULT; + if (store.startsWith("~")) return path.resolve(store.replace("~", os.homedir())); + return path.resolve(store); +} + +function loadSessionStore(storePath: string): Record { + try { + const raw = fs.readFileSync(storePath, "utf-8"); + const parsed = JSON5.parse(raw); + if (parsed && typeof parsed === "object") { + return parsed as Record; + } + } catch { + // ignore missing/invalid store; we'll recreate it + } + return {}; +} + +async function saveSessionStore(storePath: string, store: Record) { + await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); + await fs.promises.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); +} + +function deriveSessionKey(scope: SessionScope, ctx: MsgContext) { + if (scope === "global") return "global"; + const from = ctx.From ? normalizeE164(ctx.From) : ""; + return from || "unknown"; +} + async function getReplyFromConfig( ctx: MsgContext, opts?: GetReplyOptions, + configOverride?: WarelayConfig, + commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout, ): Promise { // Choose reply from config: static text or external command stdout. - const cfg = loadConfig(); + const cfg = configOverride ?? loadConfig(); const reply = cfg.inbound?.reply; const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1); const timeoutMs = timeoutSeconds * 1000; @@ -432,14 +492,73 @@ async function getReplyFromConfig( await opts?.onReplyStart?.(); }; + // Optional session handling (conversation reuse + /new resets) + const sessionCfg = reply?.session; + const resetTriggers = + sessionCfg?.resetTriggers?.length + ? sessionCfg.resetTriggers + : [DEFAULT_RESET_TRIGGER]; + const idleMinutes = Math.max(sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1); + const sessionScope = sessionCfg?.scope ?? "per-sender"; + const storePath = resolveStorePath(sessionCfg?.store); + + let sessionId: string | undefined; + let isNewSession = false; + let bodyStripped: string | undefined; + + if (sessionCfg) { + const trimmedBody = (ctx.Body ?? "").trim(); + for (const trigger of resetTriggers) { + if (!trigger) continue; + if (trimmedBody === trigger) { + isNewSession = true; + bodyStripped = ""; + break; + } + const triggerPrefix = `${trigger} `; + if (trimmedBody.startsWith(triggerPrefix)) { + isNewSession = true; + bodyStripped = trimmedBody.slice(trigger.length).trimStart(); + break; + } + } + + const sessionKey = deriveSessionKey(sessionScope, ctx); + const store = loadSessionStore(storePath); + const entry = store[sessionKey]; + const idleMs = idleMinutes * 60_000; + const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs; + + if (!isNewSession && freshEntry) { + sessionId = entry.sessionId; + } else { + sessionId = crypto.randomUUID(); + isNewSession = true; + } + + store[sessionKey] = { sessionId, updatedAt: Date.now() }; + await saveSessionStore(storePath, store); + } + + const sessionCtx: TemplateContext = { + ...ctx, + BodyStripped: bodyStripped ?? ctx.Body, + SessionId: sessionId, + IsNewSession: isNewSession ? "true" : "false", + }; + // Optional prefix injected before Body for templating/command prompts. const bodyPrefix = reply?.bodyPrefix - ? applyTemplate(reply.bodyPrefix, ctx) + ? applyTemplate(reply.bodyPrefix, sessionCtx) : ""; - const templatingCtx: MsgContext = - bodyPrefix && (ctx.Body ?? "").length >= 0 - ? { ...ctx, Body: `${bodyPrefix}${ctx.Body ?? ""}` } - : ctx; + const prefixedBody = bodyPrefix + ? `${bodyPrefix}${sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""}` + : sessionCtx.BodyStripped ?? sessionCtx.Body; + const templatingCtx: TemplateContext = { + ...sessionCtx, + Body: prefixedBody, + BodyStripped: prefixedBody, + }; // Optional allowlist by origin number (E.164 without whatsapp: prefix) const allowFrom = cfg.inbound?.allowFrom; @@ -465,20 +584,36 @@ async function getReplyFromConfig( if (reply.mode === "command" && reply.command?.length) { await onReplyStart(); - const argv = reply.command.map((part) => - applyTemplate(part, templatingCtx), - ); + let argv = reply.command.map((part) => applyTemplate(part, templatingCtx)); const templatePrefix = reply.template ? applyTemplate(reply.template, templatingCtx) : ""; - const finalArgv = templatePrefix - ? [argv[0], templatePrefix, ...argv.slice(1)] - : argv; + if (templatePrefix && argv.length > 0) { + argv = [argv[0], templatePrefix, ...argv.slice(1)]; + } + + // Inject session args if configured (use resume for existing, session-id for new) + if (reply.session) { + const sessionArgList = (isNewSession + ? reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"] + : reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"] + ).map((part) => applyTemplate(part, templatingCtx)); + if (sessionArgList.length) { + const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true; + const insertAt = insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length; + argv = [ + ...argv.slice(0, insertAt), + ...sessionArgList, + ...argv.slice(insertAt), + ]; + } + } + const finalArgv = argv; logVerbose(`Running command auto-reply: ${finalArgv.join(" ")}`); const started = Date.now(); try { const { stdout, stderr, code, signal, killed } = - await runCommandWithTimeout(finalArgv, timeoutMs); + await commandRunner(finalArgv, timeoutMs); const trimmed = stdout.trim(); if (stderr?.trim()) { logVerbose(`Command auto-reply stderr: ${stderr.trim()}`); @@ -528,6 +663,7 @@ async function getReplyFromConfig( async function autoReplyIfConfigured( client: ReturnType, message: MessageInstance, + configOverride?: WarelayConfig, ): Promise { // Fire a config-driven reply (text or command) for the inbound message, if configured. const ctx: MsgContext = { @@ -537,9 +673,13 @@ async function autoReplyIfConfigured( MessageSid: message.sid, }; - const replyText = await getReplyFromConfig(ctx, { - onReplyStart: () => sendTypingIndicator(client, message.sid), - }); + const replyText = await getReplyFromConfig( + ctx, + { + onReplyStart: () => sendTypingIndicator(client, message.sid), + }, + configOverride, + ); if (!replyText) return; const replyFrom = message.to; @@ -1290,6 +1430,56 @@ async function monitor(intervalSeconds: number, lookbackMinutes: number) { } } +async function monitorWebProvider(verbose: boolean) { + // Listen for inbound personal WhatsApp Web messages and auto-reply if configured. + const listener = await monitorWebInbox({ + verbose, + onMessage: async (msg) => { + const ts = msg.timestamp + ? new Date(msg.timestamp).toISOString() + : new Date().toISOString(); + console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`); + + const replyText = await getReplyFromConfig( + { + Body: msg.body, + From: msg.from, + To: msg.to, + MessageSid: msg.id, + }, + { + onReplyStart: msg.sendComposing, + }, + ); + if (!replyText) return; + try { + await msg.reply(replyText); + if (isVerbose()) { + console.log(success(`↩️ Auto-replied to ${msg.from} (web)`)); + } + } catch (err) { + console.error( + danger(`Failed sending web auto-reply to ${msg.from}: ${String(err)}`), + ); + } + }, + }); + + console.log( + info( + "📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.", + ), + ); + process.on("SIGINT", () => { + void listener.close().finally(() => { + console.log("\n👋 Web monitor stopped"); + process.exit(0); + }); + }); + + await waitForever(); +} + type ListedMessage = { sid: string; status: string | null; @@ -1343,9 +1533,10 @@ function formatMessageLine(m: ListedMessage): string { async function listRecentMessages( lookbackMinutes: number, limit: number, + clientOverride?: ReturnType, ): Promise { const env = readEnv(); - const client = createClient(env); + const client = clientOverride ?? createClient(env); const from = withWhatsAppPrefix(env.whatsappFrom); const since = new Date(Date.now() - lookbackMinutes * 60_000); @@ -1390,7 +1581,12 @@ program .option("--verbose", "Verbose connection logs", false) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); - await loginWeb(Boolean(opts.verbose)); + try { + await loginWeb(Boolean(opts.verbose)); + } catch (err) { + console.error(danger(`Web login failed: ${String(err)}`)); + process.exit(1); + } }); program @@ -1475,6 +1671,23 @@ Examples: await monitor(intervalSeconds, lookbackMinutes); }); +program + .command("web:monitor") + .description("Listen for inbound messages via personal WhatsApp Web and auto-reply") + .option("--verbose", "Verbose logging", false) + .addHelpText( + "after", + ` +Examples: + warelay web:monitor # start auto-replies on your linked web session + warelay web:monitor --verbose # show low-level Baileys logs +`, + ) + .action(async (opts) => { + setVerbose(Boolean(opts.verbose)); + await monitorWebProvider(Boolean(opts.verbose)); + }); + program .command("status") .description("Show recent WhatsApp messages (sent and received)") @@ -1669,7 +1882,49 @@ program await waitForever(); }); -export { normalizeE164, toWhatsappJid, assertProvider }; +export { + assertProvider, + autoReplyIfConfigured, + applyTemplate, + createClient, + deriveSessionKey, + describePortOwner, + ensureBinary, + ensureFunnel, + ensureGoInstalled, + ensurePortAvailable, + ensureTailscaledInstalled, + findIncomingNumberSid, + findMessagingServiceSid, + findWhatsappSenderSid, + formatMessageLine, + getReplyFromConfig, + getTailnetHostname, + handlePortError, + listRecentMessages, + loadConfig, + loadSessionStore, + monitor, + monitorWebProvider, + normalizeE164, + PortInUseError, + promptYesNo, + readEnv, + resolveStorePath, + runCommandWithTimeout, + runExec, + saveSessionStore, + sendMessage, + sendTypingIndicator, + setMessagingServiceWebhook, + sortByDateDesc, + startWebhook, + updateWebhook, + uniqueBySid, + waitForFinalStatus, + waitForever, + toWhatsappJid, +}; const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; diff --git a/src/provider-web.ts b/src/provider-web.ts index 330a206e1..82b9c856a 100644 --- a/src/provider-web.ts +++ b/src/provider-web.ts @@ -1,5 +1,6 @@ -import path from "node:path"; +import fs from "node:fs/promises"; import os from "node:os"; +import path from "node:path"; import { DisconnectReason, fetchLatestBaileysVersion, @@ -7,10 +8,11 @@ import { makeWASocket, useMultiFileAuthState, } from "baileys"; +import type { proto } from "baileys"; import pino from "pino"; import qrcode from "qrcode-terminal"; import { danger, info, logVerbose, success } from "./globals.js"; -import { ensureDir, toWhatsappJid } from "./utils.js"; +import { ensureDir, jidToE164, toWhatsappJid } from "./utils.js"; const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "waweb"); @@ -25,6 +27,7 @@ export async function createWaSocket(printQr: boolean, verbose: boolean) { keys: makeCacheableSignalKeyStore(state.keys, logger), }, version, + logger, printQRInTerminal: false, browser: ["Warelay", "CLI", "1.0.0"], syncFullHistory: false, @@ -32,25 +35,27 @@ export async function createWaSocket(printQr: boolean, verbose: boolean) { }); sock.ev.on("creds.update", saveCreds); - sock.ev.on("connection.update", (update: Partial) => { - const { connection, lastDisconnect, qr } = update; - if (qr && printQr) { - console.log("Scan this QR in WhatsApp (Linked Devices):"); - qrcode.generate(qr, { small: true }); - } - if (connection === "close") { - const code = (lastDisconnect?.error as { output?: { statusCode?: number } }) - ?.output?.statusCode; - if (code === DisconnectReason.loggedOut) { - console.error( - danger("WhatsApp session logged out. Run: warelay web:login"), - ); + sock.ev.on( + "connection.update", + (update: Partial) => { + const { connection, lastDisconnect, qr } = update; + if (qr && printQr) { + console.log("Scan this QR in WhatsApp (Linked Devices):"); + qrcode.generate(qr, { small: true }); } - } - if (connection === "open" && verbose) { - console.log(success("WhatsApp Web connected.")); - } - }); + if (connection === "close") { + const status = getStatusCode(lastDisconnect?.error); + if (status === DisconnectReason.loggedOut) { + console.error( + danger("WhatsApp session logged out. Run: warelay web:login"), + ); + } + } + if (connection === "open" && verbose) { + console.log(success("WhatsApp Web connected.")); + } + }, + ); return sock; } @@ -104,12 +109,44 @@ export async function sendMessageWeb( } } -export async function loginWeb(verbose: boolean) { +export async function loginWeb( + verbose: boolean, + waitForConnection: typeof waitForWaConnection = waitForWaConnection, +) { const sock = await createWaSocket(true, verbose); console.log(info("Waiting for WhatsApp connection...")); try { - await waitForWaConnection(sock); + await waitForConnection(sock); console.log(success("✅ Linked! Credentials saved for future sends.")); + } catch (err) { + const code = + (err as { error?: { output?: { statusCode?: number } } })?.error?.output + ?.statusCode ?? + (err as { output?: { statusCode?: number } })?.output?.statusCode; + if (code === 515) { + console.log( + info( + "WhatsApp asked for a restart after pairing (code 515); creds are saved. You can now send with provider=web.", + ), + ); + return; + } + if (code === DisconnectReason.loggedOut) { + await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true }); + console.error( + danger( + "WhatsApp reported the session is logged out. Cleared cached web session; please rerun warelay web:login and scan the QR again.", + ), + ); + throw new Error("Session logged out; cache cleared. Re-run web:login."); + } + const formatted = formatError(err); + console.error( + danger( + `WhatsApp Web connection ended before fully opening. ${formatted}`, + ), + ); + throw new Error(formatted); } finally { setTimeout(() => { try { @@ -122,3 +159,111 @@ export async function loginWeb(verbose: boolean) { } export { WA_WEB_AUTH_DIR }; + +export type WebInboundMessage = { + id?: string; + from: string; + to: string; + body: string; + pushName?: string; + timestamp?: number; + sendComposing: () => Promise; + reply: (text: string) => Promise; +}; + +export async function monitorWebInbox(options: { + verbose: boolean; + onMessage: (msg: WebInboundMessage) => Promise; +}) { + const sock = await createWaSocket(false, options.verbose); + await waitForWaConnection(sock); + const selfJid = sock.user?.id; + const selfE164 = selfJid ? jidToE164(selfJid) : null; + const seen = new Set(); + + sock.ev.on("messages.upsert", async (upsert) => { + if (upsert.type !== "notify") return; + for (const msg of upsert.messages) { + const id = msg.key?.id ?? undefined; + // De-dupe on message id; Baileys can emit retries. + if (id && seen.has(id)) continue; + if (id) seen.add(id); + if (msg.key?.fromMe) continue; + const remoteJid = msg.key?.remoteJid; + if (!remoteJid) continue; + // Ignore status/broadcast traffic; we only care about direct chats. + if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast")) + continue; + const from = jidToE164(remoteJid); + if (!from) continue; + const body = extractText(msg.message); + if (!body) continue; + const chatJid = remoteJid; + const sendComposing = async () => { + try { + await sock.sendPresenceUpdate("composing", chatJid); + } catch (err) { + logVerbose(`Presence update failed: ${String(err)}`); + } + }; + const reply = async (text: string) => { + await sock.sendMessage(chatJid, { text }); + }; + const timestamp = msg.messageTimestamp + ? Number(msg.messageTimestamp) * 1000 + : undefined; + try { + await options.onMessage({ + id, + from, + to: selfE164 ?? "me", + body, + pushName: msg.pushName ?? undefined, + timestamp, + sendComposing, + reply, + }); + } catch (err) { + console.error(danger(`Failed handling inbound web message: ${String(err)}`)); + } + } + }); + + return { + close: async () => { + try { + sock.ws?.close(); + } catch (err) { + logVerbose(`Socket close failed: ${String(err)}`); + } + }, + }; +} + +function extractText(message: proto.IMessage | undefined): string | undefined { + if (!message) return undefined; + if (typeof message.conversation === "string" && message.conversation.trim()) { + return message.conversation.trim(); + } + const extended = message.extendedTextMessage?.text; + if (extended?.trim()) return extended.trim(); + const caption = message.imageMessage?.caption ?? message.videoMessage?.caption; + if (caption?.trim()) return caption.trim(); + return undefined; +} + +function getStatusCode(err: unknown) { + return ( + (err as { output?: { statusCode?: number } })?.output?.statusCode ?? + (err as { status?: number })?.status + ); +} + +function formatError(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + const status = getStatusCode(err); + const code = (err as { code?: unknown })?.code; + if (status || code) return `status=${status ?? "unknown"} code=${code ?? "unknown"}`; + return String(err); +} diff --git a/src/utils.ts b/src/utils.ts index 0ba0de4d6..72c8c227e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -35,6 +35,14 @@ export function toWhatsappJid(number: string): string { return `${digits}@s.whatsapp.net`; } +export function jidToE164(jid: string): string | null { + // Convert a WhatsApp JID (with optional device suffix, e.g. 1234:1@s.whatsapp.net) back to +1234. + const match = jid.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/); + if (!match) return null; + const digits = match[1]; + return `+${digits}`; +} + export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); }