Add web provider inbound monitor with auto-replies

This commit is contained in:
Peter Steinberger
2025-11-24 18:33:50 +01:00
parent 5ee4f3219d
commit 9b4dceecfe
4 changed files with 474 additions and 48 deletions

View File

@@ -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

View File

@@ -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> | 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<string, unknown>)[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<string, SessionEntry> {
try {
const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") {
return parsed as Record<string, SessionEntry>;
}
} catch {
// ignore missing/invalid store; we'll recreate it
}
return {};
}
async function saveSessionStore(storePath: string, store: Record<string, SessionEntry>) {
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<string | undefined> {
// 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<typeof createClient>,
message: MessageInstance,
configOverride?: WarelayConfig,
): Promise<void> {
// 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<typeof createClient>,
): Promise<ListedMessage[]> {
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];

View File

@@ -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<import("baileys").ConnectionState>) => {
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<import("baileys").ConnectionState>) => {
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<void>;
reply: (text: string) => Promise<void>;
};
export async function monitorWebInbox(options: {
verbose: boolean;
onMessage: (msg: WebInboundMessage) => Promise<void>;
}) {
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<string>();
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);
}

View File

@@ -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));
}