diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e25d1f56..1c9f74f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - Added `warelay relay:heartbeat` (no tmux) and `warelay relay:heartbeat:tmux` helpers to start relay with an immediate startup heartbeat. - Relay now prints the active file log path and level on startup so you can tail logs without attaching. - Heartbeat session handling now supports `inbound.reply.session.heartbeatIdleMinutes` and does not refresh `updatedAt` on skipped heartbeats, so sessions still expire on idle. +- Web inbound now resolves WhatsApp Linked IDs (`@lid`) using Baileys’ reverse mapping files, so new-format senders are no longer dropped. +- `allowFrom: ["*"]` is honored for auto-replies; manual heartbeats require `--to` or `--all` when multiple sessions exist or the allowlist is wildcard-only. ## 1.1.0 — 2025-11-26 diff --git a/README.md b/README.md index 5ec25819f..c337910ee 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on | `warelay send` | Send a WhatsApp message (Twilio or Web) | `--to ` `--message ` `--wait ` `--poll ` `--provider twilio\|web` `--json` `--dry-run` `--verbose` | | `warelay relay` | Auto-reply loop (poll Twilio or listen on Web) | `--provider ` `--interval ` `--lookback ` `--verbose` | | `warelay status` | Show recent sent/received messages | `--limit ` `--lookback ` `--json` `--verbose` | -| `warelay heartbeat` | Trigger one heartbeat poll (web) | `--provider ` `--to ` `--verbose` | +| `warelay heartbeat` | Trigger one heartbeat poll (web) | `--provider ` `--to ` `--all` `--verbose` | | `warelay relay:heartbeat` | Run relay with an immediate heartbeat (no tmux) | `--provider ` `--verbose` | | `warelay relay:heartbeat:tmux` | Start relay in tmux and fire a heartbeat on start (web) | _no flags_ | | `warelay webhook` | Run inbound webhook (`ingress=tailscale` updates Twilio; `none` is local-only) | `--ingress tailscale\|none` `--port ` `--path ` `--reply ` `--verbose` `--yes` `--dry-run` | @@ -126,6 +126,7 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a - If Claude replies exactly `HEARTBEAT_OK`, the message is suppressed; otherwise the reply (or media) is forwarded. Suppressions are still logged so you know the heartbeat ran. - Override session freshness for heartbeats with `session.heartbeatIdleMinutes` (defaults to `session.idleMinutes`). Heartbeat skips do **not** bump `updatedAt`, so sessions still expire normally. - Trigger one manually with `warelay heartbeat` (web provider only, `--verbose` prints session info). Use `warelay relay:heartbeat` for a full relay run with an immediate heartbeat, or `--heartbeat-now` on `relay`/`relay:heartbeat:tmux`. +- When multiple active sessions exist, `warelay heartbeat` requires `--to ` or `--all`; if `allowFrom` is just `"*"`, you must choose a target with one of those flags. ### Logging (optional) - File logs are written to `/tmp/warelay/warelay.log` by default. Levels: `silent | fatal | error | warn | info | debug | trace` (CLI `--verbose` forces `debug`). Web-provider inbound/outbound entries include message bodies and auto-reply text for easier auditing. @@ -149,7 +150,7 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a ### Auto-reply parameter table (compact) | Key | Type & default | Notes | | --- | --- | --- | -| `inbound.allowFrom` | `string[]` (default: empty) | E.164 numbers allowed to trigger auto-reply (no `whatsapp:`). | +| `inbound.allowFrom` | `string[]` (default: empty) | E.164 numbers allowed to trigger auto-reply (no `whatsapp:`); `"*"` allows any sender. | | `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/docs/heartbeat.md b/docs/heartbeat.md index 87fb71905..75bbae96b 100644 --- a/docs/heartbeat.md +++ b/docs/heartbeat.md @@ -40,3 +40,4 @@ Goal: add a simple heartbeat poll for command-based auto-replies (Claude-driven) - `warelay relay:heartbeat` to run the relay loop with an immediate heartbeat (no tmux) - `warelay relay:heartbeat:tmux` to run the same in tmux (detached, attachable) - Relay supports `--heartbeat-now` to fire once at startup (including the tmux helper). + - When multiple sessions are active or `allowFrom` is only `"*"`, require `--to ` or `--all` for manual heartbeats to avoid ambiguous targets. diff --git a/src/cli/program.ts b/src/cli/program.ts index acbd601f9..190357e91 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -181,6 +181,11 @@ Examples: .description("Trigger a heartbeat poll once (web provider, no tmux)") .option("--provider ", "auto | web", "auto") .option("--to ", "Override target E.164; defaults to allowFrom[0]") + .option( + "--all", + "Send heartbeat to all active sessions (or allowFrom entries when none)", + false, + ) .option("--verbose", "Verbose logging", false) .addHelpText( "after", @@ -188,21 +193,35 @@ Examples: Examples: warelay heartbeat # uses web session + first allowFrom contact warelay heartbeat --verbose # prints detailed heartbeat logs - warelay heartbeat --to +1555123 # override destination`, + warelay heartbeat --to +1555123 # override destination + warelay heartbeat --all # send to every active session recipient`, ) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); const cfg = loadConfig(); - const to = - opts.to ?? - (Array.isArray(cfg.inbound?.allowFrom) && - cfg.inbound?.allowFrom?.length > 0 - ? cfg.inbound.allowFrom[0] - : null); - if (!to) { + const allowAll = Boolean(opts.all); + const resolution = resolveHeartbeatRecipients(cfg, { + to: opts.to, + all: allowAll, + }); + if ( + !opts.to && + !allowAll && + resolution.source === "session-ambiguous" && + resolution.recipients.length > 1 + ) { defaultRuntime.error( danger( - "No destination found. Set inbound.allowFrom in ~/.warelay/warelay.json or pass --to .", + `Multiple active sessions found (${resolution.recipients.join(", ")}). Pass --to or --all to send to all.`, + ), + ); + defaultRuntime.exit(1); + } + const recipients = resolution.recipients; + if (!recipients || recipients.length === 0) { + defaultRuntime.error( + danger( + "No destination found. Add inbound.allowFrom numbers or pass --to .", ), ); defaultRuntime.exit(1); @@ -222,11 +241,13 @@ Examples: defaultRuntime.exit(1); } try { - await runWebHeartbeatOnce({ - to, - verbose: Boolean(opts.verbose), - runtime: defaultRuntime, - }); + for (const to of recipients) { + await runWebHeartbeatOnce({ + to, + verbose: Boolean(opts.verbose), + runtime: defaultRuntime, + }); + } } catch { defaultRuntime.exit(1); } diff --git a/src/provider-web.ts b/src/provider-web.ts index 320f2171d..6b2a3922a 100644 --- a/src/provider-web.ts +++ b/src/provider-web.ts @@ -5,6 +5,7 @@ export { HEARTBEAT_PROMPT, HEARTBEAT_TOKEN, monitorWebProvider, + resolveHeartbeatRecipients, runWebHeartbeatOnce, type WebMonitorTuning, } from "./web/auto-reply.js"; diff --git a/src/utils.test.ts b/src/utils.test.ts index 4d105bbee..997fea1a5 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -4,7 +4,9 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { assertProvider, + CONFIG_DIR, ensureDir, + jidToE164, normalizeE164, normalizePath, sleep, @@ -67,3 +69,19 @@ describe("normalizeE164 & toWhatsappJid", () => { ); }); }); + +describe("jidToE164", () => { + it("maps @lid using reverse mapping file", () => { + const mappingPath = `${CONFIG_DIR}/credentials/lid-mapping-123_reverse.json`; + const original = fs.readFileSync; + const spy = vi + .spyOn(fs, "readFileSync") + // biome-ignore lint/suspicious/noExplicitAny: forwarding to native signature + .mockImplementation((path: any, encoding?: any) => { + if (path === mappingPath) return `"5551234"`; + return original(path, encoding); + }); + expect(jidToE164("123@lid")).toBe("+5551234"); + spy.mockRestore(); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index eab2a9aa6..4fe5dfb09 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import os from "node:os"; +import { isVerbose, logVerbose } from "./globals.js"; export async function ensureDir(dir: string) { await fs.promises.mkdir(dir, { recursive: true }); @@ -53,6 +54,11 @@ export function jidToE164(jid: string): string | null { const phone = JSON.parse(data); if (phone) return `+${phone}`; } catch { + if (isVerbose()) { + logVerbose( + `LID mapping not found for ${lid}; skipping inbound message`, + ); + } // Mapping not found, fall through } } diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 2be608655..c01478efe 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -9,8 +9,10 @@ import type { WarelayConfig } from "../config/config.js"; import { resolveStorePath } from "../config/sessions.js"; import { resetLogger, setLoggerOverride } from "../logging.js"; import { + HEARTBEAT_PROMPT, HEARTBEAT_TOKEN, monitorWebProvider, + resolveHeartbeatRecipients, resolveReplyHeartbeatMinutes, runWebHeartbeatOnce, stripHeartbeatToken, @@ -75,6 +77,80 @@ 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 cfg: WarelayConfig = { + inbound: { + allowFrom: ["+1999"], + reply: { mode: "command", session: { store: store.storePath } }, + }, + }; + const result = resolveHeartbeatRecipients(cfg); + expect(result.source).toBe("session-single"); + expect(result.recipients).toEqual(["+1000"]); + await store.cleanup(); + }); + + it("surfaces ambiguity when multiple sessions exist", async () => { + const now = Date.now(); + const store = await makeStore({ + "+1000": { updatedAt: now }, + "+2000": { updatedAt: now - 10 }, + }); + const cfg: WarelayConfig = { + inbound: { + allowFrom: ["+1999"], + reply: { mode: "command", session: { store: store.storePath } }, + }, + }; + const result = resolveHeartbeatRecipients(cfg); + expect(result.source).toBe("session-ambiguous"); + expect(result.recipients).toEqual(["+1000", "+2000"]); + await store.cleanup(); + }); + + it("filters wildcard allowFrom when no sessions exist", async () => { + const store = await makeStore({}); + const cfg: WarelayConfig = { + inbound: { + allowFrom: ["*"], + reply: { mode: "command", session: { store: store.storePath } }, + }, + }; + const result = resolveHeartbeatRecipients(cfg); + expect(result.recipients).toHaveLength(0); + expect(result.source).toBe("allowFrom"); + await store.cleanup(); + }); + + it("merges sessions and allowFrom when --all is set", async () => { + const now = Date.now(); + const store = await makeStore({ "+1000": { updatedAt: now } }); + const cfg: WarelayConfig = { + inbound: { + allowFrom: ["+1999"], + reply: { mode: "command", session: { store: store.storePath } }, + }, + }; + const result = resolveHeartbeatRecipients(cfg, { all: true }); + expect(result.source).toBe("all"); + expect(result.recipients.sort()).toEqual(["+1000", "+1999"].sort()); + await store.cleanup(); + }); +}); + describe("runWebHeartbeatOnce", () => { it("skips when heartbeat token returned", async () => { const sender: typeof sendMessageWeb = vi.fn(); @@ -179,6 +255,56 @@ describe("runWebHeartbeatOnce", () => { expect(after["+1555"].updatedAt).toBe(originalUpdated); expect(sender).not.toHaveBeenCalled(); }); + + it("heartbeat reuses existing session id when last inbound is present", async () => { + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), "warelay-heartbeat-session-"), + ); + const storePath = path.join(tmpDir, "sessions.json"); + const sessionId = "sess-keep"; + await fs.writeFile( + storePath, + JSON.stringify({ + "+4367": { sessionId, updatedAt: Date.now(), systemSent: false }, + }), + ); + + setLoadConfigMock(() => ({ + inbound: { + allowFrom: ["+4367"], + reply: { + mode: "command", + heartbeatMinutes: 0.001, + session: { store: storePath, idleMinutes: 60 }, + }, + }, + })); + + const replyResolver = vi.fn().mockResolvedValue({ text: HEARTBEAT_TOKEN }); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never; + const cfg: WarelayConfig = { + inbound: { + allowFrom: ["+4367"], + reply: { + mode: "command", + session: { store: storePath, idleMinutes: 60 }, + }, + }, + }; + + await runWebHeartbeatOnce({ + cfg, + to: "+4367", + verbose: false, + replyResolver, + runtime, + }); + + const heartbeatCall = replyResolver.mock.calls.find( + (call) => call[0]?.Body === HEARTBEAT_PROMPT, + ); + expect(heartbeatCall?.[0]?.MessageSid).toBe(sessionId); + }); }); describe("web auto-reply", () => { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 6425ead15..496d1b325 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -75,13 +75,14 @@ export function stripHeartbeatToken(raw?: string) { } export async function runWebHeartbeatOnce(opts: { + cfg?: ReturnType; to: string; verbose?: boolean; replyResolver?: typeof getReplyFromConfig; runtime?: RuntimeEnv; sender?: typeof sendMessageWeb; }) { - const { to, verbose = false } = opts; + const { cfg: cfgOverride, to, verbose = false } = opts; const _runtime = opts.runtime ?? defaultRuntime; const replyResolver = opts.replyResolver ?? getReplyFromConfig; const sender = opts.sender ?? sendMessageWeb; @@ -92,7 +93,7 @@ export async function runWebHeartbeatOnce(opts: { to, }); - const cfg = loadConfig(); + const cfg = cfgOverride ?? loadConfig(); const sessionSnapshot = getSessionSnapshot(cfg, to, true); if (verbose) { heartbeatLogger.info( @@ -184,10 +185,12 @@ function getFallbackRecipient(cfg: ReturnType) { const store = loadSessionStore(storePath); const candidates = Object.entries(store).filter(([key]) => key !== "global"); if (candidates.length === 0) { - return ( - (Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom[0]) || - null - ); + const allowFrom = + Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom.length > 0 + ? cfg.inbound.allowFrom.filter((v) => v !== "*") + : []; + if (allowFrom.length === 0) return null; + return allowFrom[0] ? normalizeE164(allowFrom[0]) : null; } const mostRecent = candidates.sort( (a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0), @@ -195,6 +198,54 @@ function getFallbackRecipient(cfg: ReturnType) { return mostRecent ? normalizeE164(mostRecent[0]) : null; } +function getSessionRecipients(cfg: ReturnType) { + const sessionCfg = cfg.inbound?.reply?.session; + const scope = sessionCfg?.scope ?? "per-sender"; + if (scope === "global") return []; + const storePath = resolveStorePath(sessionCfg?.store); + const store = loadSessionStore(storePath); + return Object.entries(store) + .filter(([key]) => key !== "global" && key !== "unknown") + .map(([key, entry]) => ({ + to: normalizeE164(key), + updatedAt: entry?.updatedAt ?? 0, + })) + .filter(({ to }) => Boolean(to)) + .sort((a, b) => b.updatedAt - a.updatedAt); +} + +export function resolveHeartbeatRecipients( + cfg: ReturnType, + opts: { to?: string; all?: boolean } = {}, +) { + if (opts.to) return { recipients: [normalizeE164(opts.to)], source: "flag" }; + + const sessionRecipients = getSessionRecipients(cfg); + const allowFrom = + Array.isArray(cfg.inbound?.allowFrom) && cfg.inbound.allowFrom.length > 0 + ? cfg.inbound.allowFrom.filter((v) => v !== "*").map(normalizeE164) + : []; + + const unique = (list: string[]) => [...new Set(list.filter(Boolean))]; + + if (opts.all) { + const all = unique([...sessionRecipients.map((s) => s.to), ...allowFrom]); + return { recipients: all, source: "all" as const }; + } + + if (sessionRecipients.length === 1) { + return { recipients: [sessionRecipients[0].to], source: "session-single" }; + } + if (sessionRecipients.length > 1) { + return { + recipients: sessionRecipients.map((s) => s.to), + source: "session-ambiguous" as const, + }; + } + + return { recipients: allowFrom, source: "allowFrom" as const }; +} + function getSessionSnapshot( cfg: ReturnType, from: string, @@ -551,8 +602,8 @@ export async function monitorWebProvider( } try { + const snapshot = getSessionSnapshot(cfg, lastInboundMsg.from); if (isVerbose()) { - const snapshot = getSessionSnapshot(cfg, lastInboundMsg.from); heartbeatLogger.info( { connectionId, @@ -570,7 +621,7 @@ export async function monitorWebProvider( Body: HEARTBEAT_PROMPT, From: lastInboundMsg.from, To: lastInboundMsg.to, - MessageSid: undefined, + MessageSid: snapshot.entry?.sessionId, MediaPath: undefined, MediaUrl: undefined, MediaType: undefined,