From d88ede92b9af60ed36c928ff85583d7266771878 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 29 Nov 2025 04:50:56 +0000 Subject: [PATCH 01/18] feat: same-phone mode with echo detection and configurable marker Adds full support for self-messaging setups where you chat with yourself and an AI assistant replies in the same WhatsApp bubble. Changes: - Same-phone mode (from === to) always allowed, bypasses allowFrom check - Echo detection via bounded Set (max 100) prevents infinite loops - Configurable samePhoneMarker in config (default: "[same-phone]") - Messages prefixed with marker so assistants know the context - fromMe filter removed from inbound.ts (echo detection in auto-reply) - Verbose logging for same-phone detection and echo skips Tests: - Same-phone allowed without/despite allowFrom configuration - Body prefixed only when from === to - Non-same-phone rejected when not in allowFrom --- CHANGELOG.md | 8 +++++ README.md | 10 ++++++ bin/warelay.js | 0 src/auto-reply/reply.ts | 10 ++++-- src/config/config.ts | 2 ++ src/index.core.test.ts | 58 ++++++++++++++++++++++++++++++++ src/web/auto-reply.test.ts | 69 ++++++++++++++++++++++++++++++++++++++ src/web/auto-reply.ts | 47 +++++++++++++++++++++++++- src/web/inbound.ts | 2 +- 9 files changed, 202 insertions(+), 4 deletions(-) mode change 100644 => 100755 bin/warelay.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b084d92f6..fc3b329cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.2.3 β€” Unreleased + +### Changes +- **Same-phone mode (self-messaging):** warelay now supports running on the same phone number you message from. This enables setups where you chat with yourself to control an AI assistant. Same-phone mode (`from === to`) is always allowed, even without configuring `allowFrom`. Echo detection prevents infinite loops by tracking recently sent message text and skipping auto-replies when incoming messages match. +- **Echo detection:** The `fromMe` filter in `inbound.ts` is deliberately removed for same-phone setups; instead, text-based echo detection in `auto-reply.ts` tracks sent messages in a bounded Set (max 100 entries) and skips processing when a match is found. +- **Same-phone detection logging:** Verbose mode now logs `πŸ“± Same-phone mode detected` when `from === to`. +- **Configurable same-phone marker:** New `inbound.samePhoneMarker` config option to customize the prefix added to messages in same-phone mode (default: `[same-phone]`). Set it to something cute like `[🦞 same-phone]` to help distinguish bot replies. + ## 1.2.2 β€” 2025-11-28 ### Changes diff --git a/README.md b/README.md index d64c0bebd..51ece9202 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,16 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business account) for automation instead of your primary personal account to avoid unexpected logouts or rate limits. +### Same-phone mode (self-messaging) +warelay supports running on the same phone number you message fromβ€”you chat with yourself and an AI assistant replies in the same bubble. This requires: +- Adding your own number to `allowFrom` in `warelay.json` +- The `fromMe` filter is disabled; echo detection in `auto-reply.ts` prevents loops + +**Gotchas:** +- Messages appear in the same chat bubble (WhatsApp "Note to self") +- Echo detection relies on exact text matching; if the reply is identical to your input, it may be skipped +- Works best with a dedicated WhatsApp account + ## Configuration ### Environment (.env) diff --git a/bin/warelay.js b/bin/warelay.js old mode 100644 new mode 100755 diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 5728ff061..20aef572c 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -146,8 +146,14 @@ export async function getReplyFromConfig( // Optional allowlist by origin number (E.164 without whatsapp: prefix) const allowFrom = cfg.inbound?.allowFrom; - if (Array.isArray(allowFrom) && allowFrom.length > 0) { - const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); + const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); + const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); + const isSamePhone = from && to && from === to; + + // Same-phone mode (self-messaging) is always allowed + if (isSamePhone) { + logVerbose(`Allowing same-phone mode: from === to (${from})`); + } else if (Array.isArray(allowFrom) && allowFrom.length > 0) { // Support "*" as wildcard to allow all senders if (!allowFrom.includes("*") && !allowFrom.includes(from)) { logVerbose( diff --git a/src/config/config.ts b/src/config/config.ts index b2f8fb45e..41ebc2a89 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -46,6 +46,7 @@ export type WarelayConfig = { logging?: LoggingConfig; inbound?: { allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) + samePhoneMarker?: string; // Prefix for same-phone mode messages (default: "[same-phone]") transcribeAudio?: { // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. command: string[]; @@ -139,6 +140,7 @@ const WarelaySchema = z.object({ inbound: z .object({ allowFrom: z.array(z.string()).optional(), + samePhoneMarker: z.string().optional(), transcribeAudio: z .object({ command: z.array(z.string()), diff --git a/src/index.core.test.ts b/src/index.core.test.ts index f33ed2513..a6c7768c5 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -93,6 +93,64 @@ describe("config and templating", () => { expect(onReplyStart).toHaveBeenCalled(); }); + it("getReplyFromConfig allows same-phone mode (from === to) without allowFrom", async () => { + const cfg = { + inbound: { + // No allowFrom configured + reply: { + mode: "text" as const, + text: "Echo: {{Body}}", + }, + }, + }; + + const result = await index.getReplyFromConfig( + { Body: "hello", From: "+1555", To: "+1555" }, + undefined, + cfg, + ); + expect(result?.text).toBe("Echo: hello"); + }); + + it("getReplyFromConfig allows same-phone mode even when not in allowFrom list", async () => { + const cfg = { + inbound: { + allowFrom: ["+9999"], // Different number + reply: { + mode: "text" as const, + text: "Reply: {{Body}}", + }, + }, + }; + + // Same-phone mode should bypass allowFrom check + const result = await index.getReplyFromConfig( + { Body: "test", From: "+1555", To: "+1555" }, + undefined, + cfg, + ); + expect(result?.text).toBe("Reply: test"); + }); + + it("getReplyFromConfig rejects non-same-phone when not in allowFrom", async () => { + const cfg = { + inbound: { + allowFrom: ["+9999"], + reply: { + mode: "text" as const, + text: "Should not see this", + }, + }, + }; + + const result = await index.getReplyFromConfig( + { Body: "test", From: "+1555", To: "+2666" }, + undefined, + cfg, + ); + expect(result).toBeUndefined(); + }); + it("getReplyFromConfig templating includes media fields", async () => { const cfg = { inbound: { diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 429b4c457..1918b5619 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -945,4 +945,73 @@ describe("web auto-reply", () => { expect(content).toContain('"module":"web-auto-reply"'); expect(content).toContain('"text":"auto"'); }); + + it("prefixes body with same-phone marker when from === to", async () => { + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const resolver = vi.fn().mockResolvedValue({ text: "reply" }); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hello", + from: "+1555", + to: "+1555", // Same phone! + id: "msg1", + sendComposing: vi.fn(), + reply: vi.fn(), + sendMedia: vi.fn(), + }); + + // The resolver should receive a prefixed body (the exact marker depends on config) + // Key test: body should start with some marker and end with original message + const callArg = resolver.mock.calls[0]?.[0] as { Body?: string }; + expect(callArg?.Body).toBeDefined(); + expect(callArg?.Body).toMatch(/^\[.*\] hello$/); + expect(callArg?.Body).not.toBe("hello"); // Should be prefixed + }); + + it("does not prefix body when from !== to", async () => { + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const resolver = vi.fn().mockResolvedValue({ text: "reply" }); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hello", + from: "+1555", + to: "+2666", // Different phones + id: "msg1", + sendComposing: vi.fn(), + reply: vi.fn(), + sendMedia: vi.fn(), + }); + + // Body should NOT be prefixed + const callArg = resolver.mock.calls[0]?.[0] as { Body?: string }; + expect(callArg?.Body).toBe("hello"); + }); }); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 70e31a30a..d350d40b8 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -501,6 +501,10 @@ export async function monitorWebProvider( let reconnectAttempts = 0; + // Track recently sent messages to prevent echo loops + const recentlySent = new Set(); + const MAX_RECENT_MESSAGES = 100; + while (true) { if (stopRequested()) break; @@ -536,11 +540,38 @@ export async function monitorWebProvider( console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`); + // Detect same-phone mode (self-messaging) + const isSamePhoneMode = msg.from === msg.to; + if (isSamePhoneMode) { + logVerbose(`πŸ“± Same-phone mode detected (from === to: ${msg.from})`); + } + + // Skip if this is a message we just sent (echo detection) + if (recentlySent.has(msg.body)) { + console.log(`⏭️ Skipping echo: detected recently sent message`); + logVerbose( + `Skipping auto-reply: detected echo (message matches recently sent text)`, + ); + recentlySent.delete(msg.body); // Remove from set to allow future identical messages + return; + } + + logVerbose( + `Echo check: message not in recent set (size: ${recentlySent.size})`, + ); + lastInboundMsg = msg; + // Prefix body with marker in same-phone mode so the assistant knows to prefix replies + // The marker can be customized via config (default: "[same-phone]") + const samePhoneMarker = cfg.inbound?.samePhoneMarker ?? "[same-phone]"; + const bodyForCommand = isSamePhoneMode + ? `${samePhoneMarker} ${msg.body}` + : msg.body; + const replyResult = await (replyResolver ?? getReplyFromConfig)( { - Body: msg.body, + Body: bodyForCommand, From: msg.from, To: msg.to, MessageSid: msg.id, @@ -572,6 +603,20 @@ export async function monitorWebProvider( runtime, connectionId, }); + + // Track sent message to prevent echo loops + if (replyResult.text) { + recentlySent.add(replyResult.text); + logVerbose( + `Added to echo detection set (size now: ${recentlySent.size}): ${replyResult.text.substring(0, 50)}...`, + ); + // Keep set bounded - remove oldest if too large + if (recentlySent.size > MAX_RECENT_MESSAGES) { + const firstKey = recentlySent.values().next().value; + if (firstKey) recentlySent.delete(firstKey); + } + } + if (isVerbose()) { console.log( success( diff --git a/src/web/inbound.ts b/src/web/inbound.ts index b37a5cea7..00364706b 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -70,7 +70,7 @@ export async function monitorWebInbox(options: { // 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; + // Note: not filtering fromMe here - echo detection happens in auto-reply layer const remoteJid = msg.key?.remoteJid; if (!remoteJid) continue; // Ignore status/broadcast traffic; we only care about direct chats. From 25ec133574e4cb1b002b3fcad6ae95ba5a56b647 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 29 Nov 2025 05:24:01 +0000 Subject: [PATCH 02/18] Add samePhoneResponsePrefix config option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automatically prefixes responses with a configurable string when in same-phone mode. This helps distinguish bot replies from user messages in the same chat bubble. Example config: "samePhoneResponsePrefix": "🦞" Will prefix all same-phone replies with the lobster emoji. --- src/config/config.ts | 2 ++ src/web/auto-reply.ts | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/src/config/config.ts b/src/config/config.ts index 41ebc2a89..dfb9d87bd 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -47,6 +47,7 @@ export type WarelayConfig = { inbound?: { allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) samePhoneMarker?: string; // Prefix for same-phone mode messages (default: "[same-phone]") + samePhoneResponsePrefix?: string; // Prefix auto-added to replies in same-phone mode (e.g., "🦞") transcribeAudio?: { // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. command: string[]; @@ -141,6 +142,7 @@ const WarelaySchema = z.object({ .object({ allowFrom: z.array(z.string()).optional(), samePhoneMarker: z.string().optional(), + samePhoneResponsePrefix: z.string().optional(), transcribeAudio: z .object({ command: z.array(z.string()), diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index d350d40b8..96428a9f3 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -594,6 +594,15 @@ export async function monitorWebProvider( ); return; } + // Apply same-phone response prefix if configured and in same-phone mode + const samePhoneResponsePrefix = cfg.inbound?.samePhoneResponsePrefix; + if (isSamePhoneMode && samePhoneResponsePrefix && replyResult.text) { + // Only add prefix if not already present + if (!replyResult.text.startsWith(samePhoneResponsePrefix)) { + replyResult.text = `${samePhoneResponsePrefix} ${replyResult.text}`; + } + } + try { await deliverWebReply({ replyResult, From 26e02a9b8b9967b12ae661b525af2299fab35aa2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 29 Nov 2025 05:25:53 +0000 Subject: [PATCH 03/18] Add timestampPrefix config for datetime awareness New config options: - timestampPrefix: boolean - prepend timestamp to messages - timestampTimezone: string - IANA timezone (default: UTC) Format: [Nov 29 06:30] - compact but informative Helps AI assistants stay aware of current date/time. --- src/config/config.ts | 4 ++++ src/web/auto-reply.ts | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index dfb9d87bd..4964d21ef 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -48,6 +48,8 @@ export type WarelayConfig = { allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) samePhoneMarker?: string; // Prefix for same-phone mode messages (default: "[same-phone]") samePhoneResponsePrefix?: string; // Prefix auto-added to replies in same-phone mode (e.g., "🦞") + timestampPrefix?: boolean; // Prepend compact timestamp to messages (default: false) + timestampTimezone?: string; // IANA timezone for timestamp (default: UTC), e.g., "Europe/Vienna" transcribeAudio?: { // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. command: string[]; @@ -143,6 +145,8 @@ const WarelaySchema = z.object({ allowFrom: z.array(z.string()).optional(), samePhoneMarker: z.string().optional(), samePhoneResponsePrefix: z.string().optional(), + timestampPrefix: z.boolean().optional(), + timestampTimezone: z.string().optional(), transcribeAudio: z .object({ command: z.array(z.string()), diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 96428a9f3..8ea4bda43 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -562,12 +562,26 @@ export async function monitorWebProvider( lastInboundMsg = msg; + // Build timestamp prefix if enabled + let timestampStr = ""; + if (cfg.inbound?.timestampPrefix) { + const tz = cfg.inbound?.timestampTimezone ?? "UTC"; + const now = new Date(); + try { + // Format: "Nov 29 06:30" - compact but informative + timestampStr = `[${now.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: tz })} ${now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: tz })}] `; + } catch { + // Fallback to UTC if timezone invalid + timestampStr = `[${now.toISOString().slice(5, 16).replace("T", " ")}] `; + } + } + // Prefix body with marker in same-phone mode so the assistant knows to prefix replies // The marker can be customized via config (default: "[same-phone]") const samePhoneMarker = cfg.inbound?.samePhoneMarker ?? "[same-phone]"; const bodyForCommand = isSamePhoneMode - ? `${samePhoneMarker} ${msg.body}` - : msg.body; + ? `${timestampStr}${samePhoneMarker} ${msg.body}` + : `${timestampStr}${msg.body}`; const replyResult = await (replyResolver ?? getReplyFromConfig)( { From 7564c4e7f4d70aaa5c2975bf1d7f49b80b822a3a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 29 Nov 2025 05:27:58 +0000 Subject: [PATCH 04/18] Generalize prefix config: messagePrefix + responsePrefix Replaces samePhoneMarker/samePhoneResponsePrefix with: - messagePrefix: prefix for all inbound messages - Default: '[warelay]' if no allowFrom, else '' - responsePrefix: prefix for all outbound replies Also adds timestamp options: - timestampPrefix: boolean to enable [Nov 29 06:30] format - timestampTimezone: IANA timezone (default UTC) Updated README with new config table entries. --- README.md | 4 ++++ src/config/config.ts | 8 ++++---- src/web/auto-reply.ts | 26 +++++++++++++++----------- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 51ece9202..fc22cab6d 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,10 @@ warelay supports running on the same phone number you message fromβ€”you chat wi | Key | Type & default | Notes | | --- | --- | --- | | `inbound.allowFrom` | `string[]` (default: empty) | E.164 numbers allowed to trigger auto-reply (no `whatsapp:`); `"*"` allows any sender. | +| `inbound.messagePrefix` | `string` (default: `"[warelay]"` if no allowFrom, else `""`) | Prefix added to all inbound messages before passing to command. | +| `inbound.responsePrefix` | `string` (default: β€”) | Prefix auto-added to all outbound replies (e.g., `"🦞"`). | +| `inbound.timestampPrefix` | `boolean` (default: `false`) | Prepend compact timestamp `[Nov 29 06:30]` to messages. | +| `inbound.timestampTimezone` | `string` (default: `"UTC"`) | IANA timezone for timestamp (e.g., `"Europe/Vienna"`). | | `inbound.reply.mode` | `"text"` \| `"command"` (default: β€”) | Reply style. | | `inbound.reply.text` | `string` (default: β€”) | Used when `mode=text`; templating supported. | | `inbound.reply.command` | `string[]` (default: β€”) | Argv for `mode=command`; each element templated. Stdout (trimmed) is sent. | diff --git a/src/config/config.ts b/src/config/config.ts index 4964d21ef..fbadf4cab 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -46,8 +46,8 @@ export type WarelayConfig = { logging?: LoggingConfig; inbound?: { allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) - samePhoneMarker?: string; // Prefix for same-phone mode messages (default: "[same-phone]") - samePhoneResponsePrefix?: string; // Prefix auto-added to replies in same-phone mode (e.g., "🦞") + messagePrefix?: string; // Prefix added to all inbound messages (default: "[warelay]" if no allowFrom, else "") + responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞") timestampPrefix?: boolean; // Prepend compact timestamp to messages (default: false) timestampTimezone?: string; // IANA timezone for timestamp (default: UTC), e.g., "Europe/Vienna" transcribeAudio?: { @@ -143,8 +143,8 @@ const WarelaySchema = z.object({ inbound: z .object({ allowFrom: z.array(z.string()).optional(), - samePhoneMarker: z.string().optional(), - samePhoneResponsePrefix: z.string().optional(), + messagePrefix: z.string().optional(), + responsePrefix: z.string().optional(), timestampPrefix: z.boolean().optional(), timestampTimezone: z.string().optional(), transcribeAudio: z diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 8ea4bda43..f67053b44 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -576,12 +576,16 @@ export async function monitorWebProvider( } } - // Prefix body with marker in same-phone mode so the assistant knows to prefix replies - // The marker can be customized via config (default: "[same-phone]") - const samePhoneMarker = cfg.inbound?.samePhoneMarker ?? "[same-phone]"; - const bodyForCommand = isSamePhoneMode - ? `${timestampStr}${samePhoneMarker} ${msg.body}` - : `${timestampStr}${msg.body}`; + // Build message prefix: explicit config > default based on allowFrom + // If allowFrom is configured, user likely has a specific setup - no default prefix + // If no allowFrom, add "[warelay]" so AI knows it's coming through warelay + let messagePrefix = cfg.inbound?.messagePrefix; + if (messagePrefix === undefined) { + const hasAllowFrom = (cfg.inbound?.allowFrom?.length ?? 0) > 0; + messagePrefix = hasAllowFrom ? "" : "[warelay]"; + } + const prefixStr = messagePrefix ? `${messagePrefix} ` : ""; + const bodyForCommand = `${timestampStr}${prefixStr}${msg.body}`; const replyResult = await (replyResolver ?? getReplyFromConfig)( { @@ -608,12 +612,12 @@ export async function monitorWebProvider( ); return; } - // Apply same-phone response prefix if configured and in same-phone mode - const samePhoneResponsePrefix = cfg.inbound?.samePhoneResponsePrefix; - if (isSamePhoneMode && samePhoneResponsePrefix && replyResult.text) { + // Apply response prefix if configured (for all messages) + const responsePrefix = cfg.inbound?.responsePrefix; + if (responsePrefix && replyResult.text) { // Only add prefix if not already present - if (!replyResult.text.startsWith(samePhoneResponsePrefix)) { - replyResult.text = `${samePhoneResponsePrefix} ${replyResult.text}`; + if (!replyResult.text.startsWith(responsePrefix)) { + replyResult.text = `${responsePrefix} ${replyResult.text}`; } } From 8d20edb02810aac846cbe81f84c12371d44b180c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 29 Nov 2025 05:29:29 +0000 Subject: [PATCH 05/18] Simplify timestampPrefix: bool or timezone string, default true - timestampPrefix: true (UTC), false (off), or 'America/New_York' - Removed separate timestampTimezone option - Default is now enabled (true/UTC) unless explicitly false --- README.md | 3 +-- src/config/config.ts | 6 ++---- src/web/auto-reply.ts | 9 ++++++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index fc22cab6d..70cd06569 100644 --- a/README.md +++ b/README.md @@ -169,8 +169,7 @@ warelay supports running on the same phone number you message fromβ€”you chat wi | `inbound.allowFrom` | `string[]` (default: empty) | E.164 numbers allowed to trigger auto-reply (no `whatsapp:`); `"*"` allows any sender. | | `inbound.messagePrefix` | `string` (default: `"[warelay]"` if no allowFrom, else `""`) | Prefix added to all inbound messages before passing to command. | | `inbound.responsePrefix` | `string` (default: β€”) | Prefix auto-added to all outbound replies (e.g., `"🦞"`). | -| `inbound.timestampPrefix` | `boolean` (default: `false`) | Prepend compact timestamp `[Nov 29 06:30]` to messages. | -| `inbound.timestampTimezone` | `string` (default: `"UTC"`) | IANA timezone for timestamp (e.g., `"Europe/Vienna"`). | +| `inbound.timestampPrefix` | `boolean \| string` (default: `true`) | Timestamp prefix: `true` (UTC), `false` (disabled), or IANA timezone like `"Europe/Vienna"`. | | `inbound.reply.mode` | `"text"` \| `"command"` (default: β€”) | Reply style. | | `inbound.reply.text` | `string` (default: β€”) | Used when `mode=text`; templating supported. | | `inbound.reply.command` | `string[]` (default: β€”) | Argv for `mode=command`; each element templated. Stdout (trimmed) is sent. | diff --git a/src/config/config.ts b/src/config/config.ts index fbadf4cab..f0e46c6c4 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -48,8 +48,7 @@ export type WarelayConfig = { allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) messagePrefix?: string; // Prefix added to all inbound messages (default: "[warelay]" if no allowFrom, else "") responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞") - timestampPrefix?: boolean; // Prepend compact timestamp to messages (default: false) - timestampTimezone?: string; // IANA timezone for timestamp (default: UTC), e.g., "Europe/Vienna" + timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC) transcribeAudio?: { // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. command: string[]; @@ -145,8 +144,7 @@ const WarelaySchema = z.object({ allowFrom: z.array(z.string()).optional(), messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), - timestampPrefix: z.boolean().optional(), - timestampTimezone: z.string().optional(), + timestampPrefix: z.union([z.boolean(), z.string()]).optional(), transcribeAudio: z .object({ command: z.array(z.string()), diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index f67053b44..1edaf0165 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -562,10 +562,13 @@ export async function monitorWebProvider( lastInboundMsg = msg; - // Build timestamp prefix if enabled + // Build timestamp prefix (default: enabled with UTC) + // Can be: true (UTC), false (disabled), or "America/New_York" (custom timezone) let timestampStr = ""; - if (cfg.inbound?.timestampPrefix) { - const tz = cfg.inbound?.timestampTimezone ?? "UTC"; + const tsCfg = cfg.inbound?.timestampPrefix; + const tsEnabled = tsCfg !== false; // default true + if (tsEnabled) { + const tz = typeof tsCfg === "string" ? tsCfg : "UTC"; const now = new Date(); try { // Format: "Nov 29 06:30" - compact but informative From 37d8e559917a509dbe00feac2270ec034d3f99db Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 29 Nov 2025 06:02:21 +0000 Subject: [PATCH 06/18] Skip responsePrefix for HEARTBEAT_OK responses Preserve exact match so warelay recognizes heartbeat responses and doesn't send them as messages. --- src/web/auto-reply.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 1edaf0165..dfbcce225 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -615,9 +615,9 @@ export async function monitorWebProvider( ); return; } - // Apply response prefix if configured (for all messages) + // Apply response prefix if configured (skip for HEARTBEAT_OK to preserve exact match) const responsePrefix = cfg.inbound?.responsePrefix; - if (responsePrefix && replyResult.text) { + if (responsePrefix && replyResult.text && replyResult.text.trim() !== HEARTBEAT_TOKEN) { // Only add prefix if not already present if (!replyResult.text.startsWith(responsePrefix)) { replyResult.text = `${responsePrefix} ${replyResult.text}`; From 69319a05690106ac76027e0802cfafc7bcf20d12 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 30 Nov 2025 17:53:32 +0000 Subject: [PATCH 07/18] Add auto-recovery from stuck WhatsApp sessions Fixes issue where unauthorized messages from +212652169245 (5elements spa) triggered Bad MAC errors and silently killed the event emitter, preventing all future message processing. Changes: 1. Early allowFrom filtering in inbound.ts - blocks unauthorized senders before they trigger encryption errors 2. Message timeout watchdog - auto-restarts connection if no messages received for 10 minutes 3. Health monitoring in heartbeat - warns if >30 min without messages 4. Mock loadConfig in tests to handle new dependency Root cause: Event emitter stopped firing after Bad MAC errors from decryption attempts on messages from unauthorized senders. Connection stayed alive but all subsequent messages.upsert events silently failed. --- src/web/auto-reply.ts | 62 ++++++++++++++++++++++++++++------- src/web/inbound.media.test.ts | 8 +++++ src/web/inbound.ts | 17 +++++++++- src/web/monitor-inbox.test.ts | 8 +++++ 4 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index dfbcce225..040ca31f6 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -512,10 +512,15 @@ export async function monitorWebProvider( const startedAt = Date.now(); let heartbeat: NodeJS.Timeout | null = null; let replyHeartbeatTimer: NodeJS.Timeout | null = null; + let watchdogTimer: NodeJS.Timeout | null = null; let lastMessageAt: number | null = null; let handledMessages = 0; let lastInboundMsg: WebInboundMsg | null = null; + // Watchdog to detect stuck message processing (e.g., event emitter died) + const MESSAGE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes without any messages + const WATCHDOG_CHECK_MS = 60 * 1000; // Check every minute + const listener = await (listenerFactory ?? monitorWebInbox)({ verbose, onMessage: async (msg) => { @@ -673,6 +678,7 @@ export async function monitorWebProvider( const closeListener = async () => { if (heartbeat) clearInterval(heartbeat); if (replyHeartbeatTimer) clearInterval(replyHeartbeatTimer); + if (watchdogTimer) clearInterval(watchdogTimer); try { await listener.close(); } catch (err) { @@ -683,18 +689,52 @@ export async function monitorWebProvider( if (keepAlive) { heartbeat = setInterval(() => { const authAgeMs = getWebAuthAgeMs(); - heartbeatLogger.info( - { - connectionId, - reconnectAttempts, - messagesHandled: handledMessages, - lastMessageAt, - authAgeMs, - uptimeMs: Date.now() - startedAt, - }, - "web relay heartbeat", - ); + const minutesSinceLastMessage = lastMessageAt + ? Math.floor((Date.now() - lastMessageAt) / 60000) + : null; + + const logData = { + connectionId, + reconnectAttempts, + messagesHandled: handledMessages, + lastMessageAt, + authAgeMs, + uptimeMs: Date.now() - startedAt, + ...(minutesSinceLastMessage !== null && minutesSinceLastMessage > 30 + ? { minutesSinceLastMessage } + : {}), + }; + + // Warn if no messages in 30+ minutes + if (minutesSinceLastMessage && minutesSinceLastMessage > 30) { + heartbeatLogger.warn(logData, "⚠️ web relay heartbeat - no messages in 30+ minutes"); + } else { + heartbeatLogger.info(logData, "web relay heartbeat"); + } }, heartbeatSeconds * 1000); + + // Watchdog: Auto-restart if no messages received for MESSAGE_TIMEOUT_MS + watchdogTimer = setInterval(() => { + if (lastMessageAt) { + const timeSinceLastMessage = Date.now() - lastMessageAt; + if (timeSinceLastMessage > MESSAGE_TIMEOUT_MS) { + const minutesSinceLastMessage = Math.floor(timeSinceLastMessage / 60000); + heartbeatLogger.warn( + { + connectionId, + minutesSinceLastMessage, + lastMessageAt: new Date(lastMessageAt), + messagesHandled: handledMessages, + }, + "Message timeout detected - forcing reconnect", + ); + console.error( + `⚠️ No messages received in ${minutesSinceLastMessage}m - restarting connection`, + ); + closeListener(); // Trigger reconnect + } + } + }, WATCHDOG_CHECK_MS); } const runReplyHeartbeat = async () => { diff --git a/src/web/inbound.media.test.ts b/src/web/inbound.media.test.ts index 84994cdd6..fe234953f 100644 --- a/src/web/inbound.media.test.ts +++ b/src/web/inbound.media.test.ts @@ -5,6 +5,14 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +vi.mock("../config/config.js", () => ({ + loadConfig: vi.fn().mockReturnValue({ + inbound: { + allowFrom: ["*"], // Allow all in tests + }, + }), +})); + const HOME = path.join( os.tmpdir(), `warelay-inbound-media-${crypto.randomUUID()}`, diff --git a/src/web/inbound.ts b/src/web/inbound.ts index 00364706b..209824229 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -8,10 +8,11 @@ import { downloadMediaMessage, } from "@whiskeysockets/baileys"; +import { loadConfig } from "../config/config.js"; import { isVerbose, logVerbose } from "../globals.js"; import { getChildLogger } from "../logging.js"; import { saveMediaBuffer } from "../media/store.js"; -import { jidToE164 } from "../utils.js"; +import { jidToE164, normalizeE164 } from "../utils.js"; import { createWaSocket, getStatusCode, @@ -94,6 +95,20 @@ export async function monitorWebInbox(options: { } const from = jidToE164(remoteJid); if (!from) continue; + + // Filter unauthorized senders early to prevent wasted processing + // and potential session corruption from Bad MAC errors + const cfg = loadConfig(); + const allowFrom = cfg.inbound?.allowFrom; + const isSamePhone = from === selfE164; + + if (!isSamePhone && Array.isArray(allowFrom) && allowFrom.length > 0) { + if (!allowFrom.includes("*") && !allowFrom.map(normalizeE164).includes(from)) { + logVerbose(`Blocked unauthorized sender ${from} (not in allowFrom list)`); + continue; // Skip processing entirely + } + } + let body = extractText(msg.message ?? undefined); if (!body) { body = extractMediaPlaceholder(msg.message ?? undefined); diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index 99f857189..b37398d92 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -9,6 +9,14 @@ vi.mock("../media/store.js", () => ({ }), })); +vi.mock("../config/config.js", () => ({ + loadConfig: vi.fn().mockReturnValue({ + inbound: { + allowFrom: ["*"], // Allow all in tests + }, + }), +})); + vi.mock("./session.js", () => { const { EventEmitter } = require("node:events"); const ev = new EventEmitter(); From 21ba0fb8a4e4304284169fec11527d57674d446e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 30 Nov 2025 18:00:57 +0000 Subject: [PATCH 08/18] Fix test isolation to prevent loading real user config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests were picking up real ~/.warelay/warelay.json with emojis and prefixes (like "🦞"), causing test assertions to fail. Added proper config mocks to all test files. Changes: - Mock loadConfig() in index.core.test.ts, inbound.media.test.ts, monitor-inbox.test.ts - Update test-helpers.ts default mock to disable all prefixes - Tests now use clean config: no messagePrefix, no responsePrefix, no timestamp, allowFrom=["*"] This ensures tests validate core behavior without user-specific config. The responsePrefix feature itself is already fully config-driven - this only fixes test isolation. --- CHANGELOG.md | 3 +++ src/index.core.test.ts | 12 ++++++++++++ src/web/inbound.media.test.ts | 3 +++ src/web/monitor-inbox.test.ts | 3 +++ src/web/test-helpers.ts | 18 ++++++++++++++++-- 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3b329cd..e68934d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## 1.2.3 β€” Unreleased ### Changes +- **Auto-recovery from stuck WhatsApp sessions:** Added watchdog timer that detects when WhatsApp event emitter stops firing (e.g., after Bad MAC decryption errors) and automatically restarts the connection after 10 minutes of no message activity. Heartbeat logging now includes `minutesSinceLastMessage` and warns when >30 minutes without messages. +- **Early allowFrom filtering:** Unauthorized senders are now blocked in `inbound.ts` BEFORE encryption/decryption attempts, preventing Bad MAC errors from corrupting session state. Previously, messages from unauthorized senders would trigger decryption failures that could silently kill the event emitter. +- **Test isolation improvements:** Mock `loadConfig()` in all test files to prevent loading real user config (with emojis/prefixes) during tests. Default test config now has no prefixes/timestamps for cleaner assertions. - **Same-phone mode (self-messaging):** warelay now supports running on the same phone number you message from. This enables setups where you chat with yourself to control an AI assistant. Same-phone mode (`from === to`) is always allowed, even without configuring `allowFrom`. Echo detection prevents infinite loops by tracking recently sent message text and skipping auto-replies when incoming messages match. - **Echo detection:** The `fromMe` filter in `inbound.ts` is deliberately removed for same-phone setups; instead, text-based echo detection in `auto-reply.ts` tracks sent messages in a bounded Set (max 100 entries) and skips processing when a match is found. - **Same-phone detection logging:** Verbose mode now logs `πŸ“± Same-phone mode detected` when `from === to`. diff --git a/src/index.core.test.ts b/src/index.core.test.ts index a6c7768c5..63093b801 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -9,6 +9,18 @@ import { createMockTwilio } from "../test/mocks/twilio.js"; import * as exec from "./process/exec.js"; import { withWhatsAppPrefix } from "./utils.js"; +// Mock config to avoid loading real user config +vi.mock("../src/config/config.js", () => ({ + loadConfig: vi.fn().mockReturnValue({ + inbound: { + allowFrom: ["*"], + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }), +})); + // Twilio mock factory shared across tests vi.mock("twilio", () => { const { factory } = createMockTwilio(); diff --git a/src/web/inbound.media.test.ts b/src/web/inbound.media.test.ts index fe234953f..f0205b27e 100644 --- a/src/web/inbound.media.test.ts +++ b/src/web/inbound.media.test.ts @@ -9,6 +9,9 @@ vi.mock("../config/config.js", () => ({ loadConfig: vi.fn().mockReturnValue({ inbound: { allowFrom: ["*"], // Allow all in tests + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, }, }), })); diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index b37398d92..10e312691 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -13,6 +13,9 @@ vi.mock("../config/config.js", () => ({ loadConfig: vi.fn().mockReturnValue({ inbound: { allowFrom: ["*"], // Allow all in tests + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, }, }), })); diff --git a/src/web/test-helpers.ts b/src/web/test-helpers.ts index 2e5856b9d..8a0b02195 100644 --- a/src/web/test-helpers.ts +++ b/src/web/test-helpers.ts @@ -3,14 +3,28 @@ import { vi } from "vitest"; import type { MockBaileysSocket } from "../../test/mocks/baileys.js"; import { createMockBaileys } from "../../test/mocks/baileys.js"; -let loadConfigMock: () => unknown = () => ({}); +let loadConfigMock: () => unknown = () => ({ + inbound: { + allowFrom: ["*"], // Allow all in tests by default + messagePrefix: undefined, // No message prefix in tests + responsePrefix: undefined, // No response prefix in tests + timestampPrefix: false, // No timestamp in tests + }, +}); export function setLoadConfigMock(fn: () => unknown) { loadConfigMock = fn; } export function resetLoadConfigMock() { - loadConfigMock = () => ({}); + loadConfigMock = () => ({ + inbound: { + allowFrom: ["*"], + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); } vi.mock("../config/config.js", () => ({ From c5677df56e1b90d2390acb91bc5784f4a657a687 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 30 Nov 2025 18:03:19 +0000 Subject: [PATCH 09/18] Increase watchdog timeout to 30 minutes Changed from 10 to 30 minutes to avoid false positives when heartbeatMinutes is set to 10. The watchdog should be significantly longer than the heartbeat interval to account for: - Network latency - Slow command responses - Brief connection hiccups With heartbeatMinutes=10, a 30-minute watchdog gives 3x buffer before triggering auto-restart. --- CHANGELOG.md | 2 +- src/web/auto-reply.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e68934d8b..ba6829ada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 1.2.3 β€” Unreleased ### Changes -- **Auto-recovery from stuck WhatsApp sessions:** Added watchdog timer that detects when WhatsApp event emitter stops firing (e.g., after Bad MAC decryption errors) and automatically restarts the connection after 10 minutes of no message activity. Heartbeat logging now includes `minutesSinceLastMessage` and warns when >30 minutes without messages. +- **Auto-recovery from stuck WhatsApp sessions:** Added watchdog timer that detects when WhatsApp event emitter stops firing (e.g., after Bad MAC decryption errors) and automatically restarts the connection after 30 minutes of no message activity. Heartbeat logging now includes `minutesSinceLastMessage` and warns when >30 minutes without messages. The 30-minute timeout is intentionally longer than typical `heartbeatMinutes` configs to avoid false positives. - **Early allowFrom filtering:** Unauthorized senders are now blocked in `inbound.ts` BEFORE encryption/decryption attempts, preventing Bad MAC errors from corrupting session state. Previously, messages from unauthorized senders would trigger decryption failures that could silently kill the event emitter. - **Test isolation improvements:** Mock `loadConfig()` in all test files to prevent loading real user config (with emojis/prefixes) during tests. Default test config now has no prefixes/timestamps for cleaner assertions. - **Same-phone mode (self-messaging):** warelay now supports running on the same phone number you message from. This enables setups where you chat with yourself to control an AI assistant. Same-phone mode (`from === to`) is always allowed, even without configuring `allowFrom`. Echo detection prevents infinite loops by tracking recently sent message text and skipping auto-replies when incoming messages match. diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 040ca31f6..2b1c358ff 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -518,7 +518,8 @@ export async function monitorWebProvider( let lastInboundMsg: WebInboundMsg | null = null; // Watchdog to detect stuck message processing (e.g., event emitter died) - const MESSAGE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes without any messages + // Should be significantly longer than heartbeatMinutes to avoid false positives + const MESSAGE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes without any messages const WATCHDOG_CHECK_MS = 60 * 1000; // Check every minute const listener = await (listenerFactory ?? monitorWebInbox)({ From c5ab442f46f336daeb1949fe8405f072494925e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Dec 2025 04:29:17 +0000 Subject: [PATCH 10/18] Fix empty result JSON dump and missing heartbeat prefix Bug fixes: - Empty result field handling: Changed truthy check to explicit type check (`typeof parsed?.text === "string"`) in command-reply.ts. Previously, Claude CLI returning `result: ""` would cause raw JSON to be sent to WhatsApp. - Response prefix on heartbeat: Apply `responsePrefix` to heartbeat alert messages in runReplyHeartbeat, matching behavior of regular message handler. --- CHANGELOG.md | 4 ++++ src/auto-reply/command-reply.ts | 2 +- src/web/auto-reply.ts | 9 ++++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba6829ada..c4a655c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 1.2.3 β€” Unreleased +### Bug Fixes +- **Empty result field handling:** Fixed bug where Claude CLI returning `result: ""` (empty string) would cause raw JSON to be sent to WhatsApp instead of being treated as valid empty output. Changed truthy check to explicit type check in `command-reply.ts`. +- **Response prefix on heartbeat replies:** Fixed `responsePrefix` (e.g., `🦞`) not being applied to heartbeat alert messages. The prefix was only applied in the regular message handler, not in `runReplyHeartbeat`. + ### Changes - **Auto-recovery from stuck WhatsApp sessions:** Added watchdog timer that detects when WhatsApp event emitter stops firing (e.g., after Bad MAC decryption errors) and automatically restarts the connection after 30 minutes of no message activity. Heartbeat logging now includes `minutesSinceLastMessage` and warns when >30 minutes without messages. The 30-minute timeout is intentionally longer than typical `heartbeatMinutes` configs to avoid false positives. - **Early allowFrom filtering:** Unauthorized senders are now blocked in `inbound.ts` BEFORE encryption/decryption attempts, preventing Bad MAC errors from corrupting session state. Previously, messages from unauthorized senders would trigger decryption failures that could silently kill the event emitter. diff --git a/src/auto-reply/command-reply.ts b/src/auto-reply/command-reply.ts index 914f25d96..4d8b7141f 100644 --- a/src/auto-reply/command-reply.ts +++ b/src/auto-reply/command-reply.ts @@ -230,7 +230,7 @@ export async function runCommandReply( `Claude JSON raw: ${JSON.stringify(parsed.parsed, null, 2)}`, ); } - if (parsed?.text) { + if (typeof parsed?.text === "string") { logVerbose( `Claude JSON parsed -> ${parsed.text.slice(0, 120)}${parsed.text.length > 120 ? "…" : ""}`, ); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 2b1c358ff..858a3f7a8 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -862,9 +862,16 @@ export async function monitorWebProvider( return; } + // Apply response prefix if configured (same as regular messages) + let finalText = stripped.text; + const responsePrefix = cfg.inbound?.responsePrefix; + if (responsePrefix && finalText && !finalText.startsWith(responsePrefix)) { + finalText = `${responsePrefix} ${finalText}`; + } + const cleanedReply: ReplyPayload = { ...replyResult, - text: stripped.text, + text: finalText, }; await deliverWebReply({ From d107b79c6319944628f9fd56d797342ffff68005 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Dec 2025 05:54:31 +0000 Subject: [PATCH 11/18] Fix test corrupting production sessions.json The test 'falls back to most recent session when no to is provided' was using resolveStorePath() which returns the real ~/.warelay/sessions.json. This overwrote production session data with test values, causing session fragmentation issues. Changed to use a temp directory like other tests. --- src/auto-reply/command-reply.test.ts | 75 ++++++++++++ src/auto-reply/command-reply.ts | 13 +- src/web/auto-reply.test.ts | 171 ++++++++++++++++++++++++--- src/web/monitor-inbox.test.ts | 160 +++++++++++++++++++++++-- src/web/test-helpers.ts | 31 +++-- 5 files changed, 412 insertions(+), 38 deletions(-) diff --git a/src/auto-reply/command-reply.test.ts b/src/auto-reply/command-reply.test.ts index fb47c0414..db1e64767 100644 --- a/src/auto-reply/command-reply.test.ts +++ b/src/auto-reply/command-reply.test.ts @@ -292,4 +292,79 @@ describe("runCommandReply", () => { expect(meta.queuedMs).toBe(25); expect(meta.queuedAhead).toBe(2); }); + + it("handles empty result string without dumping raw JSON", async () => { + // Bug fix: Claude CLI returning {"result": ""} should not send raw JSON to WhatsApp + // The fix changed from truthy check to explicit typeof check + const runner = makeRunner({ + stdout: '{"result":"","duration_ms":50,"total_cost_usd":0.001}', + }); + const { payload } = await runCommandReply({ + reply: { + mode: "command", + command: ["claude", "{{Body}}"], + claudeOutputFormat: "json", + }, + templatingCtx: noopTemplateCtx, + sendSystemOnce: false, + isNewSession: true, + isFirstTurnInSession: true, + systemSent: false, + timeoutMs: 1000, + timeoutSeconds: 1, + commandRunner: runner, + enqueue: enqueueImmediate, + }); + // Should NOT contain raw JSON - empty result should produce fallback message + expect(payload?.text).not.toContain('{"result"'); + expect(payload?.text).toContain("command produced no output"); + }); + + it("handles empty text string in Claude JSON", async () => { + const runner = makeRunner({ + stdout: '{"text":"","duration_ms":50}', + }); + const { payload } = await runCommandReply({ + reply: { + mode: "command", + command: ["claude", "{{Body}}"], + claudeOutputFormat: "json", + }, + templatingCtx: noopTemplateCtx, + sendSystemOnce: false, + isNewSession: true, + isFirstTurnInSession: true, + systemSent: false, + timeoutMs: 1000, + timeoutSeconds: 1, + commandRunner: runner, + enqueue: enqueueImmediate, + }); + // Empty text should produce fallback message, not raw JSON + expect(payload?.text).not.toContain('{"text"'); + expect(payload?.text).toContain("command produced no output"); + }); + + it("returns actual text when result is non-empty", async () => { + const runner = makeRunner({ + stdout: '{"result":"hello world","duration_ms":50}', + }); + const { payload } = await runCommandReply({ + reply: { + mode: "command", + command: ["claude", "{{Body}}"], + claudeOutputFormat: "json", + }, + templatingCtx: noopTemplateCtx, + sendSystemOnce: false, + isNewSession: true, + isFirstTurnInSession: true, + systemSent: false, + timeoutMs: 1000, + timeoutSeconds: 1, + commandRunner: runner, + enqueue: enqueueImmediate, + }); + expect(payload?.text).toBe("hello world"); + }); }); diff --git a/src/auto-reply/command-reply.ts b/src/auto-reply/command-reply.ts index 4d8b7141f..71e564948 100644 --- a/src/auto-reply/command-reply.ts +++ b/src/auto-reply/command-reply.ts @@ -259,8 +259,11 @@ export async function runCommandReply( console.error( `Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`, ); + // Include any partial output or stderr in error message + const partialOut = trimmed ? `\n\nOutput: ${trimmed.slice(0, 500)}${trimmed.length > 500 ? "..." : ""}` : ""; + const errorText = `⚠️ Command exited with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}${partialOut}`; return { - payload: undefined, + payload: { text: errorText }, meta: { durationMs: Date.now() - started, queuedMs, @@ -278,8 +281,9 @@ export async function runCommandReply( console.error( `Command auto-reply process killed before completion (exit code ${code ?? "unknown"})`, ); + const errorText = `⚠️ Command was killed before completion (exit code ${code ?? "unknown"})`; return { - payload: undefined, + payload: { text: errorText }, meta: { durationMs: Date.now() - started, queuedMs, @@ -379,8 +383,11 @@ export async function runCommandReply( }; } logError(`Command auto-reply failed after ${elapsed}ms: ${String(err)}`); + // Send error message to user so they know the command failed + const errMsg = err instanceof Error ? err.message : String(err); + const errorText = `⚠️ Command failed: ${errMsg}`; return { - payload: undefined, + payload: { text: errorText }, meta: { durationMs: elapsed, queuedMs, diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 1918b5619..ff59e05d9 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -1,3 +1,10 @@ +// Import test-helpers FIRST to set up mocks before other imports +import { + resetBaileysMocks, + resetLoadConfigMock, + setLoadConfigMock, +} from "./test-helpers.js"; + import crypto from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; @@ -18,11 +25,6 @@ import { stripHeartbeatToken, } from "./auto-reply.js"; import type { sendMessageWeb } from "./outbound.js"; -import { - resetBaileysMocks, - resetLoadConfigMock, - setLoadConfigMock, -} from "./test-helpers.js"; describe("heartbeat helpers", () => { it("strips heartbeat token and skips when only token", () => { @@ -186,6 +188,11 @@ describe("runWebHeartbeatOnce", () => { }); it("falls back to most recent session when no to is provided", async () => { + // Use temp directory to avoid corrupting production sessions.json + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), "warelay-fallback-session-"), + ); + const storePath = path.join(tmpDir, "sessions.json"); const sender: typeof sendMessageWeb = vi .fn() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); @@ -196,15 +203,11 @@ describe("runWebHeartbeatOnce", () => { "+1222": { sessionId: "s1", updatedAt: now - 1000 }, "+1333": { sessionId: "s2", updatedAt: now }, }; - const storePath = resolveStorePath(); - await fs.mkdir(resolveStorePath().replace("sessions.json", ""), { - recursive: true, - }); await fs.writeFile(storePath, JSON.stringify(store)); setLoadConfigMock({ inbound: { allowFrom: ["+1999"], - reply: { mode: "command", session: {} }, + reply: { mode: "command", session: { store: storePath } }, }, }); await runWebHeartbeatOnce({ @@ -947,6 +950,16 @@ describe("web auto-reply", () => { }); it("prefixes body with same-phone marker when from === to", async () => { + // Enable messagePrefix for same-phone mode testing + setLoadConfigMock(() => ({ + inbound: { + allowFrom: ["*"], + messagePrefix: "[same-phone]", + responsePrefix: undefined, + timestampPrefix: false, + }, + })); + let capturedOnMessage: | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; @@ -974,12 +987,11 @@ describe("web auto-reply", () => { sendMedia: vi.fn(), }); - // The resolver should receive a prefixed body (the exact marker depends on config) - // Key test: body should start with some marker and end with original message + // The resolver should receive a prefixed body with the configured marker const callArg = resolver.mock.calls[0]?.[0] as { Body?: string }; expect(callArg?.Body).toBeDefined(); - expect(callArg?.Body).toMatch(/^\[.*\] hello$/); - expect(callArg?.Body).not.toBe("hello"); // Should be prefixed + expect(callArg?.Body).toBe("[same-phone] hello"); + resetLoadConfigMock(); }); it("does not prefix body when from !== to", async () => { @@ -1014,4 +1026,135 @@ describe("web auto-reply", () => { const callArg = resolver.mock.calls[0]?.[0] as { Body?: string }; expect(callArg?.Body).toBe("hello"); }); + + it("applies responsePrefix to regular replies", async () => { + setLoadConfigMock(() => ({ + inbound: { + allowFrom: ["*"], + messagePrefix: undefined, + responsePrefix: "🦞", + timestampPrefix: false, + }, + })); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const reply = vi.fn(); + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const resolver = vi.fn().mockResolvedValue({ text: "hello there" }); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hi", + from: "+1555", + to: "+2666", + id: "msg1", + sendComposing: vi.fn(), + reply, + sendMedia: vi.fn(), + }); + + // Reply should have responsePrefix prepended + expect(reply).toHaveBeenCalledWith("🦞 hello there"); + resetLoadConfigMock(); + }); + + it("skips responsePrefix for HEARTBEAT_OK responses", async () => { + setLoadConfigMock(() => ({ + inbound: { + allowFrom: ["*"], + messagePrefix: undefined, + responsePrefix: "🦞", + timestampPrefix: false, + }, + })); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const reply = vi.fn(); + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + // Resolver returns exact HEARTBEAT_OK + const resolver = vi.fn().mockResolvedValue({ text: HEARTBEAT_TOKEN }); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "test", + from: "+1555", + to: "+2666", + id: "msg1", + sendComposing: vi.fn(), + reply, + sendMedia: vi.fn(), + }); + + // HEARTBEAT_OK should NOT have prefix - warelay needs exact match + expect(reply).toHaveBeenCalledWith(HEARTBEAT_TOKEN); + resetLoadConfigMock(); + }); + + it("does not double-prefix if responsePrefix already present", async () => { + setLoadConfigMock(() => ({ + inbound: { + allowFrom: ["*"], + messagePrefix: undefined, + responsePrefix: "🦞", + timestampPrefix: false, + }, + })); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const reply = vi.fn(); + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + // Resolver returns text that already has prefix + const resolver = vi.fn().mockResolvedValue({ text: "🦞 already prefixed" }); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "test", + from: "+1555", + to: "+2666", + id: "msg1", + sendComposing: vi.fn(), + reply, + sendMedia: vi.fn(), + }); + + // Should not double-prefix + expect(reply).toHaveBeenCalledWith("🦞 already prefixed"); + resetLoadConfigMock(); + }); }); diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index 10e312691..b696d2513 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -9,15 +9,17 @@ vi.mock("../media/store.js", () => ({ }), })); +const mockLoadConfig = vi.fn().mockReturnValue({ + inbound: { + allowFrom: ["*"], // Allow all in tests + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, +}); + vi.mock("../config/config.js", () => ({ - loadConfig: vi.fn().mockReturnValue({ - inbound: { - allowFrom: ["*"], // Allow all in tests - messagePrefix: undefined, - responsePrefix: undefined, - timestampPrefix: false, - }, - }), + loadConfig: () => mockLoadConfig(), })); vi.mock("./session.js", () => { @@ -227,4 +229,146 @@ describe("web monitor inbox", () => { ]); await listener.close(); }); + + it("blocks messages from unauthorized senders not in allowFrom", async () => { + // Test for auto-recovery fix: early allowFrom filtering prevents Bad MAC errors + // from unauthorized senders corrupting sessions + mockLoadConfig.mockReturnValue({ + inbound: { + allowFrom: ["+111"], // Only allow +111 + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + // Message from unauthorized sender +999 (not in allowFrom) + const upsert = { + type: "notify", + messages: [ + { + key: { id: "unauth1", fromMe: false, remoteJid: "999@s.whatsapp.net" }, + message: { conversation: "unauthorized message" }, + messageTimestamp: 1_700_000_000, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Should NOT call onMessage for unauthorized senders + expect(onMessage).not.toHaveBeenCalled(); + + // Reset mock for other tests + mockLoadConfig.mockReturnValue({ + inbound: { + allowFrom: ["*"], + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + await listener.close(); + }); + + it("allows messages from senders in allowFrom list", async () => { + mockLoadConfig.mockReturnValue({ + inbound: { + allowFrom: ["+111", "+999"], // Allow +999 + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + const upsert = { + type: "notify", + messages: [ + { + key: { id: "auth1", fromMe: false, remoteJid: "999@s.whatsapp.net" }, + message: { conversation: "authorized message" }, + messageTimestamp: 1_700_000_000, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Should call onMessage for authorized senders + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ body: "authorized message", from: "+999" }), + ); + + // Reset mock for other tests + mockLoadConfig.mockReturnValue({ + inbound: { + allowFrom: ["*"], + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + await listener.close(); + }); + + it("allows same-phone messages even if not in allowFrom", async () => { + // Same-phone mode: when from === selfJid, should always be allowed + // This allows users to message themselves even with restrictive allowFrom + mockLoadConfig.mockReturnValue({ + inbound: { + allowFrom: ["+111"], // Only allow +111, but self is +123 + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + // Message from self (sock.user.id is "123@s.whatsapp.net" in mock) + const upsert = { + type: "notify", + messages: [ + { + key: { id: "self1", fromMe: false, remoteJid: "123@s.whatsapp.net" }, + message: { conversation: "self message" }, + messageTimestamp: 1_700_000_000, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Should allow self-messages even if not in allowFrom + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ body: "self message", from: "+123" }), + ); + + // Reset mock for other tests + mockLoadConfig.mockReturnValue({ + inbound: { + allowFrom: ["*"], + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + await listener.close(); + }); }); diff --git a/src/web/test-helpers.ts b/src/web/test-helpers.ts index 8a0b02195..923fa19f3 100644 --- a/src/web/test-helpers.ts +++ b/src/web/test-helpers.ts @@ -3,32 +3,37 @@ import { vi } from "vitest"; import type { MockBaileysSocket } from "../../test/mocks/baileys.js"; import { createMockBaileys } from "../../test/mocks/baileys.js"; -let loadConfigMock: () => unknown = () => ({ +// Use globalThis to store the mock config so it survives vi.mock hoisting +const CONFIG_KEY = Symbol.for("warelay:testConfigMock"); +const DEFAULT_CONFIG = { inbound: { allowFrom: ["*"], // Allow all in tests by default messagePrefix: undefined, // No message prefix in tests responsePrefix: undefined, // No response prefix in tests timestampPrefix: false, // No timestamp in tests }, -}); +}; -export function setLoadConfigMock(fn: () => unknown) { - loadConfigMock = fn; +// Initialize default if not set +if (!(globalThis as Record)[CONFIG_KEY]) { + (globalThis as Record)[CONFIG_KEY] = () => DEFAULT_CONFIG; +} + +export function setLoadConfigMock(fn: (() => unknown) | unknown) { + (globalThis as Record)[CONFIG_KEY] = + typeof fn === "function" ? fn : () => fn; } export function resetLoadConfigMock() { - loadConfigMock = () => ({ - inbound: { - allowFrom: ["*"], - messagePrefix: undefined, - responsePrefix: undefined, - timestampPrefix: false, - }, - }); + (globalThis as Record)[CONFIG_KEY] = () => DEFAULT_CONFIG; } vi.mock("../config/config.js", () => ({ - loadConfig: () => loadConfigMock(), + loadConfig: () => { + const getter = (globalThis as Record)[CONFIG_KEY]; + if (typeof getter === "function") return getter(); + return DEFAULT_CONFIG; + }, })); vi.mock("../media/store.js", () => ({ From 1b0e1edb084cdeaef5852581cc7356122d0b2ade Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Dec 2025 05:59:31 +0000 Subject: [PATCH 12/18] Update changelog with error message and test isolation fixes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4a655c12..696203b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Bug Fixes - **Empty result field handling:** Fixed bug where Claude CLI returning `result: ""` (empty string) would cause raw JSON to be sent to WhatsApp instead of being treated as valid empty output. Changed truthy check to explicit type check in `command-reply.ts`. - **Response prefix on heartbeat replies:** Fixed `responsePrefix` (e.g., `🦞`) not being applied to heartbeat alert messages. The prefix was only applied in the regular message handler, not in `runReplyHeartbeat`. +- **User-visible error messages:** Command failures (non-zero exit, killed processes, exceptions) now return user-friendly error messages to WhatsApp instead of silently failing with empty responses. +- **Test session isolation:** Fixed tests corrupting production `sessions.json` by mocking session persistence in all test files. ### Changes - **Auto-recovery from stuck WhatsApp sessions:** Added watchdog timer that detects when WhatsApp event emitter stops firing (e.g., after Bad MAC decryption errors) and automatically restarts the connection after 30 minutes of no message activity. Heartbeat logging now includes `minutesSinceLastMessage` and warns when >30 minutes without messages. The 30-minute timeout is intentionally longer than typical `heartbeatMinutes` configs to avoid false positives. From 2fc3a822c83be14779d3903441d4a661328580e4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Dec 2025 06:15:20 +0000 Subject: [PATCH 13/18] web: isolate session fixtures and skip heartbeat when busy --- CHANGELOG.md | 6 ++ src/web/auto-reply.test.ts | 148 +++++++++++++++++++++++++++---------- src/web/auto-reply.ts | 10 +++ 3 files changed, 123 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 696203b9f..c66c5f794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ ### Changes - **Manual heartbeat sends:** `warelay heartbeat` accepts `--message/--body` with `--provider web|twilio` to push real outbound messages through the same plumbing; `--dry-run` previews payloads without sending. +## Unreleased + +### Changes +- **Heartbeat backpressure:** Web reply heartbeats now check the shared command queue and skip while any command/Claude runs are in flight, preventing concurrent prompts during long-running requests. +- **Isolated session fixtures in web tests:** Heartbeat/auto-reply tests now create temporary session stores instead of using the default `~/.warelay/sessions.json`, preventing local config pollution during test runs. + ## 1.2.1 β€” 2025-11-28 ### Changes diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index ff59e05d9..059c6e570 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -13,7 +13,7 @@ import sharp from "sharp"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { WarelayConfig } from "../config/config.js"; -import { resolveStorePath } from "../config/sessions.js"; +import * as commandQueue from "../process/command-queue.js"; import { resetLogger, setLoggerOverride } from "../logging.js"; import { HEARTBEAT_PROMPT, @@ -26,6 +26,18 @@ import { } from "./auto-reply.js"; import type { sendMessageWeb } from "./outbound.js"; +const makeSessionStore = async ( + entries: Record = {}, +): Promise<{ storePath: string; cleanup: () => Promise }> => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "warelay-session-")); + const storePath = path.join(dir, "sessions.json"); + await fs.writeFile(storePath, JSON.stringify(entries)); + return { + storePath, + cleanup: () => fs.rm(dir, { recursive: true, force: true }), + }; +}; + describe("heartbeat helpers", () => { it("strips heartbeat token and skips when only token", () => { expect(stripHeartbeatToken(undefined)).toEqual({ @@ -80,19 +92,9 @@ describe("heartbeat helpers", () => { }); describe("resolveHeartbeatRecipients", () => { - const makeStore = async (entries: Record) => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "warelay-heartbeat-")); - const storePath = path.join(dir, "sessions.json"); - await fs.writeFile(storePath, JSON.stringify(entries)); - return { - storePath, - cleanup: async () => fs.rm(dir, { recursive: true, force: true }), - }; - }; - it("returns the sole session recipient", async () => { const now = Date.now(); - const store = await makeStore({ "+1000": { updatedAt: now } }); + const store = await makeSessionStore({ "+1000": { updatedAt: now } }); const cfg: WarelayConfig = { inbound: { allowFrom: ["+1999"], @@ -107,7 +109,7 @@ describe("resolveHeartbeatRecipients", () => { it("surfaces ambiguity when multiple sessions exist", async () => { const now = Date.now(); - const store = await makeStore({ + const store = await makeSessionStore({ "+1000": { updatedAt: now }, "+2000": { updatedAt: now - 10 }, }); @@ -124,7 +126,7 @@ describe("resolveHeartbeatRecipients", () => { }); it("filters wildcard allowFrom when no sessions exist", async () => { - const store = await makeStore({}); + const store = await makeSessionStore({}); const cfg: WarelayConfig = { inbound: { allowFrom: ["*"], @@ -139,7 +141,7 @@ describe("resolveHeartbeatRecipients", () => { it("merges sessions and allowFrom when --all is set", async () => { const now = Date.now(); - const store = await makeStore({ "+1000": { updatedAt: now } }); + const store = await makeSessionStore({ "+1000": { updatedAt: now } }); const cfg: WarelayConfig = { inbound: { allowFrom: ["+1999"], @@ -155,12 +157,16 @@ describe("resolveHeartbeatRecipients", () => { describe("runWebHeartbeatOnce", () => { it("skips when heartbeat token returned", async () => { + const store = await makeSessionStore(); const sender: typeof sendMessageWeb = vi.fn(); const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN })); - setLoadConfigMock({ - inbound: { allowFrom: ["+1555"], reply: { mode: "command" } }, - }); await runWebHeartbeatOnce({ + cfg: { + inbound: { + allowFrom: ["+1555"], + reply: { mode: "command", session: { store: store.storePath } }, + }, + }, to: "+1555", verbose: false, sender, @@ -168,55 +174,58 @@ describe("runWebHeartbeatOnce", () => { }); expect(resolver).toHaveBeenCalled(); expect(sender).not.toHaveBeenCalled(); + await store.cleanup(); }); it("sends when alert text present", async () => { + const store = await makeSessionStore(); const sender: typeof sendMessageWeb = vi .fn() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); const resolver = vi.fn(async () => ({ text: "ALERT" })); - setLoadConfigMock({ - inbound: { allowFrom: ["+1555"], reply: { mode: "command" } }, - }); await runWebHeartbeatOnce({ + cfg: { + inbound: { + allowFrom: ["+1555"], + reply: { mode: "command", session: { store: store.storePath } }, + }, + }, to: "+1555", verbose: false, sender, replyResolver: resolver, }); expect(sender).toHaveBeenCalledWith("+1555", "ALERT", { verbose: false }); + await store.cleanup(); }); it("falls back to most recent session when no to is provided", async () => { - // Use temp directory to avoid corrupting production sessions.json - const tmpDir = await fs.mkdtemp( - path.join(os.tmpdir(), "warelay-fallback-session-"), - ); - const storePath = path.join(tmpDir, "sessions.json"); + const store = await makeSessionStore(); + const storePath = store.storePath; const sender: typeof sendMessageWeb = vi .fn() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); const resolver = vi.fn(async () => ({ text: "ALERT" })); - // Seed session store const now = Date.now(); - const store = { + const sessionEntries = { "+1222": { sessionId: "s1", updatedAt: now - 1000 }, "+1333": { sessionId: "s2", updatedAt: now }, }; - await fs.writeFile(storePath, JSON.stringify(store)); - setLoadConfigMock({ - inbound: { - allowFrom: ["+1999"], - reply: { mode: "command", session: { store: storePath } }, - }, - }); + await fs.writeFile(storePath, JSON.stringify(sessionEntries)); await runWebHeartbeatOnce({ + cfg: { + inbound: { + allowFrom: ["+1999"], + reply: { mode: "command", session: { store: storePath } }, + }, + }, to: "+1999", verbose: false, sender, replyResolver: resolver, }); expect(sender).toHaveBeenCalledWith("+1999", "ALERT", { verbose: false }); + await store.cleanup(); }); it("does not refresh updatedAt when heartbeat is skipped", async () => { @@ -356,14 +365,18 @@ describe("runWebHeartbeatOnce", () => { }); it("sends overrideBody directly and skips resolver", async () => { + const store = await makeSessionStore(); const sender: typeof sendMessageWeb = vi .fn() .mockResolvedValue({ messageId: "m1", toJid: "jid" }); const resolver = vi.fn(); - setLoadConfigMock({ - inbound: { allowFrom: ["+1555"], reply: { mode: "command" } }, - }); await runWebHeartbeatOnce({ + cfg: { + inbound: { + allowFrom: ["+1555"], + reply: { mode: "command", session: { store: store.storePath } }, + }, + }, to: "+1555", verbose: false, sender, @@ -374,15 +387,20 @@ describe("runWebHeartbeatOnce", () => { verbose: false, }); expect(resolver).not.toHaveBeenCalled(); + await store.cleanup(); }); it("dry-run overrideBody prints and skips send", async () => { + const store = await makeSessionStore(); const sender: typeof sendMessageWeb = vi.fn(); const resolver = vi.fn(); - setLoadConfigMock({ - inbound: { allowFrom: ["+1555"], reply: { mode: "command" } }, - }); await runWebHeartbeatOnce({ + cfg: { + inbound: { + allowFrom: ["+1555"], + reply: { mode: "command", session: { store: store.storePath } }, + }, + }, to: "+1555", verbose: false, sender, @@ -392,6 +410,7 @@ describe("runWebHeartbeatOnce", () => { }); expect(sender).not.toHaveBeenCalled(); expect(resolver).not.toHaveBeenCalled(); + await store.cleanup(); }); }); @@ -507,6 +526,53 @@ describe("web auto-reply", () => { ); }); + it("skips reply heartbeat when requests are running", async () => { + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), "warelay-heartbeat-queue-"), + ); + const storePath = path.join(tmpDir, "sessions.json"); + await fs.writeFile(storePath, JSON.stringify({})); + + const queueSpy = vi + .spyOn(commandQueue, "getQueueSize") + .mockReturnValue(2); + const replyResolver = vi.fn(); + const listenerFactory = vi.fn(async () => { + const onClose = new Promise(() => { + // stay open until aborted + }); + return { close: vi.fn(), onClose }; + }); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never; + + setLoadConfigMock(() => ({ + inbound: { + allowFrom: ["+1555"], + reply: { mode: "command", session: { store: storePath } }, + }, + })); + + const controller = new AbortController(); + const run = monitorWebProvider( + false, + listenerFactory, + true, + replyResolver, + runtime, + controller.signal, + { replyHeartbeatMinutes: 1, replyHeartbeatNow: true }, + ); + + try { + await Promise.resolve(); + controller.abort(); + await run; + expect(replyResolver).not.toHaveBeenCalled(); + } finally { + queueSpy.mockRestore(); + } + }); + it("falls back to text when media send fails", async () => { const sendMedia = vi.fn().mockRejectedValue(new Error("boom")); const reply = vi.fn().mockResolvedValue(undefined); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 858a3f7a8..5fdcbbe10 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -17,6 +17,7 @@ import { normalizeE164 } from "../utils.js"; import { monitorWebInbox } from "./inbound.js"; import { loadWebMedia } from "./media.js"; import { sendMessageWeb } from "./outbound.js"; +import { getQueueSize } from "../process/command-queue.js"; import { computeBackoff, newConnectionId, @@ -739,6 +740,15 @@ export async function monitorWebProvider( } const runReplyHeartbeat = async () => { + const queued = getQueueSize(); + if (queued > 0) { + heartbeatLogger.info( + { connectionId, reason: "requests-in-flight", queued }, + "reply heartbeat skipped", + ); + console.log(success("heartbeat: skipped (requests in flight)")); + return; + } if (!replyHeartbeatMinutes) return; const tickStart = Date.now(); if (!lastInboundMsg) { From e86b507da75d29be235aa99e59f7405ec0509dd0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Dec 2025 06:31:01 +0000 Subject: [PATCH 14/18] Add IPC to prevent Signal session corruption from concurrent connections When the relay is running, `warelay send` and `warelay heartbeat` now communicate via Unix socket IPC (~/.warelay/relay.sock) to send messages through the relay's existing WhatsApp connection. Previously, these commands created new Baileys sockets that wrote to the same auth state files, corrupting the Signal session ratchet and causing the relay's subsequent sends to fail silently. Changes: - Add src/web/ipc.ts with Unix socket server/client - Relay starts IPC server after connecting - send command tries IPC first, falls back to direct - heartbeat uses sendWithIpcFallback helper - inbound.ts exposes sendMessage on listener object - Messages sent via IPC are added to echo detection set --- CHANGELOG.md | 2 + src/commands/send.ts | 32 +++++- src/web/auto-reply.ts | 53 +++++++++- src/web/inbound.ts | 45 +++++++++ src/web/ipc.ts | 225 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 src/web/ipc.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c66c5f794..73f6caaa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ - **Response prefix on heartbeat replies:** Fixed `responsePrefix` (e.g., `🦞`) not being applied to heartbeat alert messages. The prefix was only applied in the regular message handler, not in `runReplyHeartbeat`. - **User-visible error messages:** Command failures (non-zero exit, killed processes, exceptions) now return user-friendly error messages to WhatsApp instead of silently failing with empty responses. - **Test session isolation:** Fixed tests corrupting production `sessions.json` by mocking session persistence in all test files. +- **Signal session corruption prevention:** Added IPC mechanism so `warelay send` and `warelay heartbeat` reuse the running relay's WhatsApp connection instead of creating new Baileys sockets. Previously, using these commands while the relay was running could corrupt the Signal session ratchet (both connections wrote to the same auth state), causing the relay's subsequent sends to fail silently. ### Changes +- **IPC server for relay:** The web relay now starts a Unix socket server at `~/.warelay/relay.sock`. Commands like `warelay send --provider web` automatically connect via IPC when the relay is running, falling back to direct connection otherwise. - **Auto-recovery from stuck WhatsApp sessions:** Added watchdog timer that detects when WhatsApp event emitter stops firing (e.g., after Bad MAC decryption errors) and automatically restarts the connection after 30 minutes of no message activity. Heartbeat logging now includes `minutesSinceLastMessage` and warns when >30 minutes without messages. The 30-minute timeout is intentionally longer than typical `heartbeatMinutes` configs to avoid false positives. - **Early allowFrom filtering:** Unauthorized senders are now blocked in `inbound.ts` BEFORE encryption/decryption attempts, preventing Bad MAC errors from corrupting session state. Previously, messages from unauthorized senders would trigger decryption failures that could silently kill the event emitter. - **Test isolation improvements:** Mock `loadConfig()` in all test files to prevent loading real user config (with emojis/prefixes) during tests. Default test config now has no prefixes/timestamps for cleaner assertions. diff --git a/src/commands/send.ts b/src/commands/send.ts index 193c407cf..45b770f74 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -1,7 +1,8 @@ import type { CliDeps } from "../cli/deps.js"; -import { info } from "../globals.js"; +import { info, success } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import type { Provider } from "../utils.js"; +import { sendViaIpc } from "../web/ipc.js"; export async function sendCommand( opts: { @@ -39,6 +40,34 @@ export async function sendCommand( if (waitSeconds !== 0) { runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web.")); } + + // Try to send via IPC to running relay first (avoids Signal session corruption) + const ipcResult = await sendViaIpc(opts.to, opts.message, opts.media); + if (ipcResult) { + if (ipcResult.success) { + runtime.log(success(`βœ… Sent via relay IPC. Message ID: ${ipcResult.messageId}`)); + if (opts.json) { + runtime.log( + JSON.stringify( + { + provider: "web", + via: "ipc", + to: opts.to, + messageId: ipcResult.messageId, + mediaUrl: opts.media ?? null, + }, + null, + 2, + ), + ); + } + return; + } + // IPC failed but relay is running - warn and fall back + runtime.log(info(`IPC send failed (${ipcResult.error}), falling back to direct connection`)); + } + + // Fall back to direct connection (creates new Baileys socket) const res = await deps .sendMessageWeb(opts.to, opts.message, { verbose: false, @@ -53,6 +82,7 @@ export async function sendCommand( JSON.stringify( { provider: "web", + via: "direct", to: opts.to, messageId: res.messageId, mediaUrl: opts.media ?? null, diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 5fdcbbe10..9020a21fd 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -9,12 +9,13 @@ import { resolveStorePath, saveSessionStore, } from "../config/sessions.js"; -import { danger, isVerbose, logVerbose, success } from "../globals.js"; +import { danger, info, isVerbose, logVerbose, success } from "../globals.js"; import { logInfo } from "../logger.js"; import { getChildLogger } from "../logging.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { normalizeE164 } from "../utils.js"; import { monitorWebInbox } from "./inbound.js"; +import { sendViaIpc, startIpcServer, stopIpcServer } from "./ipc.js"; import { loadWebMedia } from "./media.js"; import { sendMessageWeb } from "./outbound.js"; import { getQueueSize } from "../process/command-queue.js"; @@ -28,6 +29,26 @@ import { } from "./reconnect.js"; import { getWebAuthAgeMs } from "./session.js"; +/** + * Send a message via IPC if relay is running, otherwise fall back to direct. + * This avoids Signal session corruption from multiple Baileys connections. + */ +async function sendWithIpcFallback( + to: string, + message: string, + opts: { verbose: boolean; mediaUrl?: string }, +): Promise<{ messageId: string; toJid: string }> { + const ipcResult = await sendViaIpc(to, message, opts.mediaUrl); + if (ipcResult?.success && ipcResult.messageId) { + if (opts.verbose) { + console.log(info(`Sent via relay IPC (avoiding session corruption)`)); + } + return { messageId: ipcResult.messageId, toJid: `${to}@s.whatsapp.net` }; + } + // Fall back to direct send + return sendMessageWeb(to, message, opts); +} + const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024; type WebInboundMsg = Parameters< typeof monitorWebInbox @@ -95,7 +116,7 @@ export async function runWebHeartbeatOnce(opts: { } = opts; const _runtime = opts.runtime ?? defaultRuntime; const replyResolver = opts.replyResolver ?? getReplyFromConfig; - const sender = opts.sender ?? sendMessageWeb; + const sender = opts.sender ?? sendWithIpcFallback; const runId = newConnectionId(); const heartbeatLogger = getChildLogger({ module: "web-heartbeat", @@ -526,6 +547,8 @@ export async function monitorWebProvider( const listener = await (listenerFactory ?? monitorWebInbox)({ verbose, onMessage: async (msg) => { + // Also add IPC-sent messages to echo detection + // (this is handled below in the IPC sendHandler) handledMessages += 1; lastMessageAt = Date.now(); const ts = msg.timestamp @@ -677,7 +700,33 @@ export async function monitorWebProvider( }, }); + // Start IPC server so `warelay send` can use this connection + // instead of creating a new one (which would corrupt Signal session) + if ("sendMessage" in listener) { + startIpcServer(async (to, message, mediaUrl) => { + let mediaBuffer: Buffer | undefined; + let mediaType: string | undefined; + if (mediaUrl) { + const media = await loadWebMedia(mediaUrl); + mediaBuffer = media.buffer; + mediaType = media.contentType; + } + const result = await listener.sendMessage(to, message, mediaBuffer, mediaType); + // Add to echo detection so we don't process our own message + if (message) { + recentlySent.add(message); + if (recentlySent.size > MAX_RECENT_MESSAGES) { + const firstKey = recentlySent.values().next().value; + if (firstKey) recentlySent.delete(firstKey); + } + } + logInfo(`πŸ“€ IPC send to ${to}: ${message.substring(0, 50)}...`, runtime); + return result; + }); + } + const closeListener = async () => { + stopIpcServer(); if (heartbeat) clearInterval(heartbeat); if (replyHeartbeatTimer) clearInterval(replyHeartbeatTimer); if (watchdogTimer) clearInterval(watchdogTimer); diff --git a/src/web/inbound.ts b/src/web/inbound.ts index 209824229..bafcac10d 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -212,6 +212,51 @@ export async function monitorWebInbox(options: { } }, onClose, + /** + * Send a message through this connection's socket. + * Used by IPC to avoid creating new connections. + */ + sendMessage: async ( + to: string, + text: string, + mediaBuffer?: Buffer, + mediaType?: string, + ): Promise<{ messageId: string }> => { + const jid = `${to.replace(/^\+/, "")}@s.whatsapp.net`; + let payload: AnyMessageContent; + if (mediaBuffer && mediaType) { + if (mediaType.startsWith("image/")) { + payload = { + image: mediaBuffer, + caption: text || undefined, + mimetype: mediaType, + }; + } else if (mediaType.startsWith("audio/")) { + payload = { + audio: mediaBuffer, + ptt: true, + mimetype: mediaType, + }; + } else if (mediaType.startsWith("video/")) { + payload = { + video: mediaBuffer, + caption: text || undefined, + mimetype: mediaType, + }; + } else { + payload = { + document: mediaBuffer, + fileName: "file", + caption: text || undefined, + mimetype: mediaType, + }; + } + } else { + payload = { text }; + } + const result = await sock.sendMessage(jid, payload); + return { messageId: result?.key?.id ?? "unknown" }; + }, } as const; } diff --git a/src/web/ipc.ts b/src/web/ipc.ts new file mode 100644 index 000000000..99e09e0f5 --- /dev/null +++ b/src/web/ipc.ts @@ -0,0 +1,225 @@ +/** + * IPC server for warelay relay. + * + * When the relay is running, it starts a Unix socket server that allows + * `warelay send` and `warelay heartbeat` to send messages through the + * existing WhatsApp connection instead of creating new ones. + * + * This prevents Signal session ratchet corruption from multiple connections. + */ + +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import { getChildLogger } from "../logging.js"; + +const SOCKET_PATH = path.join(os.homedir(), ".warelay", "relay.sock"); + +export interface IpcSendRequest { + type: "send"; + to: string; + message: string; + mediaUrl?: string; +} + +export interface IpcSendResponse { + success: boolean; + messageId?: string; + error?: string; +} + +type SendHandler = ( + to: string, + message: string, + mediaUrl?: string, +) => Promise<{ messageId: string }>; + +let server: net.Server | null = null; + +/** + * Start the IPC server. Called by the relay when it starts. + */ +export function startIpcServer(sendHandler: SendHandler): void { + const logger = getChildLogger({ module: "ipc-server" }); + + // Clean up stale socket file + try { + fs.unlinkSync(SOCKET_PATH); + } catch { + // Ignore if doesn't exist + } + + server = net.createServer((conn) => { + let buffer = ""; + + conn.on("data", async (data) => { + buffer += data.toString(); + + // Try to parse complete JSON messages (newline-delimited) + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; // Keep incomplete line in buffer + + for (const line of lines) { + if (!line.trim()) continue; + + try { + const request = JSON.parse(line) as IpcSendRequest; + + if (request.type === "send") { + try { + const result = await sendHandler( + request.to, + request.message, + request.mediaUrl, + ); + const response: IpcSendResponse = { + success: true, + messageId: result.messageId, + }; + conn.write(JSON.stringify(response) + "\n"); + } catch (err) { + const response: IpcSendResponse = { + success: false, + error: String(err), + }; + conn.write(JSON.stringify(response) + "\n"); + } + } + } catch (err) { + logger.warn({ error: String(err) }, "failed to parse IPC request"); + const response: IpcSendResponse = { + success: false, + error: "Invalid request format", + }; + conn.write(JSON.stringify(response) + "\n"); + } + } + }); + + conn.on("error", (err) => { + logger.debug({ error: String(err) }, "IPC connection error"); + }); + }); + + server.listen(SOCKET_PATH, () => { + logger.info({ socketPath: SOCKET_PATH }, "IPC server started"); + // Make socket accessible + fs.chmodSync(SOCKET_PATH, 0o600); + }); + + server.on("error", (err) => { + logger.error({ error: String(err) }, "IPC server error"); + }); +} + +/** + * Stop the IPC server. Called when relay shuts down. + */ +export function stopIpcServer(): void { + if (server) { + server.close(); + server = null; + } + try { + fs.unlinkSync(SOCKET_PATH); + } catch { + // Ignore + } +} + +/** + * Check if the relay IPC server is running. + */ +export function isRelayRunning(): boolean { + try { + fs.accessSync(SOCKET_PATH); + return true; + } catch { + return false; + } +} + +/** + * Send a message through the running relay's IPC. + * Returns null if relay is not running. + */ +export async function sendViaIpc( + to: string, + message: string, + mediaUrl?: string, +): Promise { + if (!isRelayRunning()) { + return null; + } + + return new Promise((resolve) => { + const client = net.createConnection(SOCKET_PATH); + let buffer = ""; + let resolved = false; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + client.destroy(); + resolve({ success: false, error: "IPC timeout" }); + } + }, 30000); // 30 second timeout + + client.on("connect", () => { + const request: IpcSendRequest = { + type: "send", + to, + message, + mediaUrl, + }; + client.write(JSON.stringify(request) + "\n"); + }); + + client.on("data", (data) => { + buffer += data.toString(); + const lines = buffer.split("\n"); + + for (const line of lines) { + if (!line.trim()) continue; + try { + const response = JSON.parse(line) as IpcSendResponse; + if (!resolved) { + resolved = true; + clearTimeout(timeout); + client.end(); + resolve(response); + } + return; + } catch { + // Keep reading + } + } + }); + + client.on("error", (err) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + // Socket exists but can't connect - relay might have crashed + resolve(null); + } + }); + + client.on("close", () => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + resolve({ success: false, error: "Connection closed" }); + } + }); + }); +} + +/** + * Get the IPC socket path for debugging/status. + */ +export function getSocketPath(): string { + return SOCKET_PATH; +} From e881b3c5debdfeadb1b6d669f35c668624d77b08 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Dec 2025 06:52:56 +0000 Subject: [PATCH 15/18] Document exclamation mark escaping workaround for Claude Code Add symlink CLAUDE.md -> AGENTS.md for Claude Code compatibility. --- AGENTS.md | 16 ++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 17 insertions(+) create mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 6c364db09..1212fa1c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,3 +36,19 @@ ## Agent-Specific Notes - If the relay is running in tmux (`warelay-relay`), restart it after code changes: kill pane/session and run `pnpm warelay relay --verbose` inside tmux. Check tmux before editing; keep the watcher healthy if you start it. - Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there. + +## Exclamation Mark Escaping Workaround +The Claude Code Bash tool escapes `!` to `\!` in command arguments. When using `warelay send` with messages containing exclamation marks, use heredoc syntax: + +```bash +# WRONG - will send "Hello\!" with backslash +warelay send --provider web --to "+1234" --message 'Hello!' + +# CORRECT - use heredoc to avoid escaping +warelay send --provider web --to "+1234" --message "$(cat <<'EOF' +Hello! +EOF +)" +``` + +This is a Claude Code quirk, not a warelay bug. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 96152f6577ee348853ee8504f6fd6422ecb4a266 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Dec 2025 06:58:17 +0000 Subject: [PATCH 16/18] Add typing indicator after IPC send After sending via IPC, automatically show "composing" indicator so user knows more messages may be coming from the running session. --- CHANGELOG.md | 1 + src/web/auto-reply.ts | 8 +++++++- src/web/inbound.ts | 8 ++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f6caaa1..eff96cb5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Changes - **IPC server for relay:** The web relay now starts a Unix socket server at `~/.warelay/relay.sock`. Commands like `warelay send --provider web` automatically connect via IPC when the relay is running, falling back to direct connection otherwise. +- **Typing indicator after IPC send:** After sending a message via IPC (e.g., `warelay send`), the relay now automatically shows the typing indicator ("composing") to signal that more messages may be coming. - **Auto-recovery from stuck WhatsApp sessions:** Added watchdog timer that detects when WhatsApp event emitter stops firing (e.g., after Bad MAC decryption errors) and automatically restarts the connection after 30 minutes of no message activity. Heartbeat logging now includes `minutesSinceLastMessage` and warns when >30 minutes without messages. The 30-minute timeout is intentionally longer than typical `heartbeatMinutes` configs to avoid false positives. - **Early allowFrom filtering:** Unauthorized senders are now blocked in `inbound.ts` BEFORE encryption/decryption attempts, preventing Bad MAC errors from corrupting session state. Previously, messages from unauthorized senders would trigger decryption failures that could silently kill the event emitter. - **Test isolation improvements:** Mock `loadConfig()` in all test files to prevent loading real user config (with emojis/prefixes) during tests. Default test config now has no prefixes/timestamps for cleaner assertions. diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 9020a21fd..cc965dab0 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -702,7 +702,7 @@ export async function monitorWebProvider( // Start IPC server so `warelay send` can use this connection // instead of creating a new one (which would corrupt Signal session) - if ("sendMessage" in listener) { + if ("sendMessage" in listener && "sendComposingTo" in listener) { startIpcServer(async (to, message, mediaUrl) => { let mediaBuffer: Buffer | undefined; let mediaType: string | undefined; @@ -721,6 +721,12 @@ export async function monitorWebProvider( } } logInfo(`πŸ“€ IPC send to ${to}: ${message.substring(0, 50)}...`, runtime); + // Show typing indicator after send so user knows more may be coming + try { + await listener.sendComposingTo(to); + } catch { + // Ignore typing indicator errors - not critical + } return result; }); } diff --git a/src/web/inbound.ts b/src/web/inbound.ts index bafcac10d..9d6a4df6a 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -257,6 +257,14 @@ export async function monitorWebInbox(options: { const result = await sock.sendMessage(jid, payload); return { messageId: result?.key?.id ?? "unknown" }; }, + /** + * Send typing indicator ("composing") to a chat. + * Used after IPC send to show more messages are coming. + */ + sendComposingTo: async (to: string): Promise => { + const jid = `${to.replace(/^\+/, "")}@s.whatsapp.net`; + await sock.sendPresenceUpdate("composing", jid); + }, } as const; } From 5b54d4de7a992abb2bbc742226af14c326ad6695 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Dec 2025 07:54:13 +0000 Subject: [PATCH 17/18] feat(web): batch inbound messages --- src/web/auto-reply.test.ts | 74 +++++++++ src/web/auto-reply.ts | 311 +++++++++++++++++++++---------------- 2 files changed, 250 insertions(+), 135 deletions(-) diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 059c6e570..945df6212 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -25,6 +25,8 @@ import { stripHeartbeatToken, } from "./auto-reply.js"; import type { sendMessageWeb } from "./outbound.js"; +import * as commandQueue from "../process/command-queue.js"; +import { getQueueSize } from "../process/command-queue.js"; const makeSessionStore = async ( entries: Record = {}, @@ -573,6 +575,78 @@ describe("web auto-reply", () => { } }); + it("batches inbound messages while queue is busy and preserves timestamps", async () => { + vi.useFakeTimers(); + const originalMax = process.getMaxListeners(); + process.setMaxListeners?.(1); // force low to confirm bump + + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "batched" }); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + // Queue starts busy, then frees after one polling tick. + let queueBusy = true; + const queueSpy = vi + .spyOn(commandQueue, "getQueueSize") + .mockImplementation(() => (queueBusy ? 1 : 0)); + + setLoadConfigMock(() => ({ inbound: { timestampPrefix: "UTC" } })); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + // Two messages from the same sender with fixed timestamps + await capturedOnMessage?.({ + body: "first", + from: "+1", + to: "+2", + id: "m1", + timestamp: 1735689600000, // Jan 1 2025 00:00:00 UTC + sendComposing, + reply, + sendMedia, + }); + await capturedOnMessage?.({ + body: "second", + from: "+1", + to: "+2", + id: "m2", + timestamp: 1735693200000, // Jan 1 2025 01:00:00 UTC + sendComposing, + reply, + sendMedia, + }); + + // Let the queued batch flush once the queue is free + queueBusy = false; + vi.advanceTimersByTime(200); + + expect(resolver).toHaveBeenCalledTimes(1); + const args = resolver.mock.calls[0][0]; + expect(args.Body).toContain("[Jan 1 00:00] [warelay] first"); + expect(args.Body).toContain("[Jan 1 01:00] [warelay] second"); + + // Max listeners bumped to avoid warnings in multi-instance test runs + expect(process.getMaxListeners?.()).toBeGreaterThanOrEqual(50); + + queueSpy.mockRestore(); + process.setMaxListeners?.(originalMax); + vi.useRealTimers(); + }); + it("falls back to text when media send fails", async () => { const sendMedia = vi.fn().mockRejectedValue(new Error("boom")); const reply = vi.fn().mockResolvedValue(undefined); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index cc965dab0..19634a3f1 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -515,6 +515,13 @@ export async function monitorWebProvider( }), ); + // Avoid noisy MaxListenersExceeded warnings in test environments where + // multiple relay instances may be constructed. + const currentMaxListeners = process.getMaxListeners?.() ?? 10; + if (process.setMaxListeners && currentMaxListeners < 50) { + process.setMaxListeners(50); + } + let sigintStop = false; const handleSigint = () => { sigintStop = true; @@ -544,35 +551,179 @@ export async function monitorWebProvider( const MESSAGE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes without any messages const WATCHDOG_CHECK_MS = 60 * 1000; // Check every minute + // Batch inbound messages while command queue is busy, then send one + // combined prompt with per-message timestamps (inbound-only behavior). + type PendingBatch = { messages: WebInboundMsg[]; timer?: NodeJS.Timeout }; + const pendingBatches = new Map(); + + const formatTimestamp = (ts?: number) => { + const tsCfg = cfg.inbound?.timestampPrefix; + const tsEnabled = tsCfg !== false; // default true + if (!tsEnabled) return ""; + const tz = typeof tsCfg === "string" ? tsCfg : "UTC"; + const date = ts ? new Date(ts) : new Date(); + try { + return `[${date.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: tz })} ${date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: tz })}] `; + } catch { + return `[${date.toISOString().slice(5, 16).replace("T", " ")}] `; + } + }; + + const buildLine = (msg: WebInboundMsg) => { + // Build message prefix: explicit config > default based on allowFrom + let messagePrefix = cfg.inbound?.messagePrefix; + if (messagePrefix === undefined) { + const hasAllowFrom = (cfg.inbound?.allowFrom?.length ?? 0) > 0; + messagePrefix = hasAllowFrom ? "" : "[warelay]"; + } + const prefixStr = messagePrefix ? `${messagePrefix} ` : ""; + return `${formatTimestamp(msg.timestamp)}${prefixStr}${msg.body}`; + }; + + const processBatch = async (from: string) => { + const batch = pendingBatches.get(from); + if (!batch || batch.messages.length === 0) return; + if (getQueueSize() > 0) { + // Wait until command queue is free to run the combined prompt. + batch.timer = setTimeout(() => void processBatch(from), 150); + return; + } + pendingBatches.delete(from); + + const messages = batch.messages; + const latest = messages[messages.length - 1]; + const combinedBody = messages.map(buildLine).join("\n"); + + // Echo detection uses combined body so we don't respond twice. + if (recentlySent.has(combinedBody)) { + logVerbose(`Skipping auto-reply: detected echo for combined batch`); + recentlySent.delete(combinedBody); + return; + } + + const correlationId = latest.id ?? newConnectionId(); + replyLogger.info( + { + connectionId, + correlationId, + from, + to: latest.to, + body: combinedBody, + mediaType: latest.mediaType ?? null, + mediaPath: latest.mediaPath ?? null, + batchSize: messages.length, + }, + "inbound web message (batched)", + ); + + const tsDisplay = latest.timestamp + ? new Date(latest.timestamp).toISOString() + : new Date().toISOString(); + console.log(`\n[${tsDisplay}] ${from} -> ${latest.to}: ${combinedBody}`); + + const replyResult = await (replyResolver ?? getReplyFromConfig)( + { + Body: combinedBody, + From: latest.from, + To: latest.to, + MessageSid: latest.id, + MediaPath: latest.mediaPath, + MediaUrl: latest.mediaUrl, + MediaType: latest.mediaType, + }, + { + onReplyStart: latest.sendComposing, + }, + ); + + if ( + !replyResult || + (!replyResult.text && + !replyResult.mediaUrl && + !replyResult.mediaUrls?.length) + ) { + logVerbose("Skipping auto-reply: no text/media returned from resolver"); + return; + } + + // Apply response prefix if configured (skip for HEARTBEAT_OK to preserve exact match) + const responsePrefix = cfg.inbound?.responsePrefix; + if ( + responsePrefix && + replyResult.text && + replyResult.text.trim() !== HEARTBEAT_TOKEN + ) { + if (!replyResult.text.startsWith(responsePrefix)) { + replyResult.text = `${responsePrefix} ${replyResult.text}`; + } + } + + try { + await deliverWebReply({ + replyResult, + msg: latest, + maxMediaBytes, + replyLogger, + runtime, + connectionId, + }); + + if (replyResult.text) { + recentlySent.add(replyResult.text); + recentlySent.add(combinedBody); // Prevent echo on the batch text itself + logVerbose( + `Added to echo detection set (size now: ${recentlySent.size}): ${replyResult.text.substring(0, 50)}...`, + ); + if (recentlySent.size > MAX_RECENT_MESSAGES) { + const firstKey = recentlySent.values().next().value; + if (firstKey) recentlySent.delete(firstKey); + } + } + + if (isVerbose()) { + console.log( + success( + `↩️ Auto-replied to ${from} (web${replyResult.mediaUrl || replyResult.mediaUrls?.length ? ", media" : ""}; batched ${messages.length})`, + ), + ); + } else { + console.log( + success( + `↩️ ${replyResult.text ?? ""}${replyResult.mediaUrl || replyResult.mediaUrls?.length ? " (media)" : ""}`, + ), + ); + } + } catch (err) { + console.error( + danger( + `Failed sending web auto-reply to ${from}: ${String(err)}`, + ), + ); + } + }; + + const enqueueBatch = async (msg: WebInboundMsg) => { + const bucket = pendingBatches.get(msg.from) ?? { messages: [] }; + bucket.messages.push(msg); + pendingBatches.set(msg.from, bucket); + + // Process immediately when queue is free; otherwise wait until it drains. + if (getQueueSize() === 0) { + await processBatch(msg.from); + } else { + bucket.timer = bucket.timer ?? setTimeout(() => void processBatch(msg.from), 150); + } + }; + const listener = await (listenerFactory ?? monitorWebInbox)({ verbose, onMessage: async (msg) => { - // Also add IPC-sent messages to echo detection - // (this is handled below in the IPC sendHandler) handledMessages += 1; lastMessageAt = Date.now(); - const ts = msg.timestamp - ? new Date(msg.timestamp).toISOString() - : new Date().toISOString(); - const correlationId = msg.id ?? newConnectionId(); - replyLogger.info( - { - connectionId, - correlationId, - from: msg.from, - to: msg.to, - body: msg.body, - mediaType: msg.mediaType ?? null, - mediaPath: msg.mediaPath ?? null, - }, - "inbound web message", - ); + lastInboundMsg = msg; - console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`); - - // Detect same-phone mode (self-messaging) - const isSamePhoneMode = msg.from === msg.to; - if (isSamePhoneMode) { + // Same-phone mode logging retained + if (msg.from === msg.to) { logVerbose(`πŸ“± Same-phone mode detected (from === to: ${msg.from})`); } @@ -582,121 +733,11 @@ export async function monitorWebProvider( logVerbose( `Skipping auto-reply: detected echo (message matches recently sent text)`, ); - recentlySent.delete(msg.body); // Remove from set to allow future identical messages + recentlySent.delete(msg.body); return; } - logVerbose( - `Echo check: message not in recent set (size: ${recentlySent.size})`, - ); - - lastInboundMsg = msg; - - // Build timestamp prefix (default: enabled with UTC) - // Can be: true (UTC), false (disabled), or "America/New_York" (custom timezone) - let timestampStr = ""; - const tsCfg = cfg.inbound?.timestampPrefix; - const tsEnabled = tsCfg !== false; // default true - if (tsEnabled) { - const tz = typeof tsCfg === "string" ? tsCfg : "UTC"; - const now = new Date(); - try { - // Format: "Nov 29 06:30" - compact but informative - timestampStr = `[${now.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: tz })} ${now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: tz })}] `; - } catch { - // Fallback to UTC if timezone invalid - timestampStr = `[${now.toISOString().slice(5, 16).replace("T", " ")}] `; - } - } - - // Build message prefix: explicit config > default based on allowFrom - // If allowFrom is configured, user likely has a specific setup - no default prefix - // If no allowFrom, add "[warelay]" so AI knows it's coming through warelay - let messagePrefix = cfg.inbound?.messagePrefix; - if (messagePrefix === undefined) { - const hasAllowFrom = (cfg.inbound?.allowFrom?.length ?? 0) > 0; - messagePrefix = hasAllowFrom ? "" : "[warelay]"; - } - const prefixStr = messagePrefix ? `${messagePrefix} ` : ""; - const bodyForCommand = `${timestampStr}${prefixStr}${msg.body}`; - - const replyResult = await (replyResolver ?? getReplyFromConfig)( - { - Body: bodyForCommand, - From: msg.from, - To: msg.to, - MessageSid: msg.id, - MediaPath: msg.mediaPath, - MediaUrl: msg.mediaUrl, - MediaType: msg.mediaType, - }, - { - onReplyStart: msg.sendComposing, - }, - ); - if ( - !replyResult || - (!replyResult.text && - !replyResult.mediaUrl && - !replyResult.mediaUrls?.length) - ) { - logVerbose( - "Skipping auto-reply: no text/media returned from resolver", - ); - return; - } - // Apply response prefix if configured (skip for HEARTBEAT_OK to preserve exact match) - const responsePrefix = cfg.inbound?.responsePrefix; - if (responsePrefix && replyResult.text && replyResult.text.trim() !== HEARTBEAT_TOKEN) { - // Only add prefix if not already present - if (!replyResult.text.startsWith(responsePrefix)) { - replyResult.text = `${responsePrefix} ${replyResult.text}`; - } - } - - try { - await deliverWebReply({ - replyResult, - msg, - maxMediaBytes, - replyLogger, - runtime, - connectionId, - }); - - // Track sent message to prevent echo loops - if (replyResult.text) { - recentlySent.add(replyResult.text); - logVerbose( - `Added to echo detection set (size now: ${recentlySent.size}): ${replyResult.text.substring(0, 50)}...`, - ); - // Keep set bounded - remove oldest if too large - if (recentlySent.size > MAX_RECENT_MESSAGES) { - const firstKey = recentlySent.values().next().value; - if (firstKey) recentlySent.delete(firstKey); - } - } - - if (isVerbose()) { - console.log( - success( - `↩️ Auto-replied to ${msg.from} (web${replyResult.mediaUrl || replyResult.mediaUrls?.length ? ", media" : ""})`, - ), - ); - } else { - console.log( - success( - `↩️ ${replyResult.text ?? ""}${replyResult.mediaUrl || replyResult.mediaUrls?.length ? " (media)" : ""}`, - ), - ); - } - } catch (err) { - console.error( - danger( - `Failed sending web auto-reply to ${msg.from}: ${String(err)}`, - ), - ); - } + return enqueueBatch(msg); }, }); From 52c311e47f189f0482defce911312819668fdc41 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Dec 2025 07:54:49 +0000 Subject: [PATCH 18/18] chore: bump version to 1.3.0 --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eff96cb5b..33fb74ca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.2.3 β€” Unreleased +## 1.3.0 β€” Unreleased ### Bug Fixes - **Empty result field handling:** Fixed bug where Claude CLI returning `result: ""` (empty string) would cause raw JSON to be sent to WhatsApp instead of being treated as valid empty output. Changed truthy check to explicit type check in `command-reply.ts`. diff --git a/package.json b/package.json index 18f6e340e..63f3d036e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "warelay", - "version": "1.2.2", + "version": "1.3.0", "description": "WhatsApp relay CLI (send, monitor, webhook, auto-reply) using Twilio", "type": "module", "main": "dist/index.js",