From d88ede92b9af60ed36c928ff85583d7266771878 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 29 Nov 2025 04:50:56 +0000 Subject: [PATCH] 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.