diff --git a/src/auto-reply/command-reply.ts b/src/auto-reply/command-reply.ts index 568e22add..1191de241 100644 --- a/src/auto-reply/command-reply.ts +++ b/src/auto-reply/command-reply.ts @@ -386,11 +386,11 @@ export async function runCommandReply( let rpcInput: string | undefined; let rpcArgv = finalArgv; if (agentKind === "pi") { - rpcInput = JSON.stringify({ type: "prompt", message: promptArg }) + "\n"; + rpcInput = `${JSON.stringify({ type: "prompt", message: promptArg })}\n`; const bodyIdx = promptIndex >= 0 ? promptIndex : Math.max(finalArgv.length - 1, 0); rpcArgv = finalArgv.filter((_, idx) => idx !== bodyIdx); - const modeIdx = rpcArgv.findIndex((v) => v === "--mode"); + const modeIdx = rpcArgv.indexOf("--mode"); if (modeIdx >= 0 && rpcArgv[modeIdx + 1]) { rpcArgv[modeIdx + 1] = "rpc"; } else { diff --git a/src/auto-reply/reply.chunking.test.ts b/src/auto-reply/reply.chunking.test.ts deleted file mode 100644 index 4b30c8c56..000000000 --- a/src/auto-reply/reply.chunking.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { WarelayConfig } from "../config/config.js"; -import { autoReplyIfConfigured } from "./reply.js"; - -describe("autoReplyIfConfigured chunking", () => { - it("sends a single Twilio message for multi-line text under limit", async () => { - const body = [ - "Oh! Hi Peter! 🦞", - "", - "Sorry, I got a bit trigger-happy with the heartbeat response there. What's up?", - "", - "Everything working on your end?", - ].join("\n"); - - const config: WarelayConfig = { - inbound: { - reply: { - mode: "text", - text: body, - }, - }, - }; - - const create = vi.fn().mockResolvedValue({}); - const client = { messages: { create } } as unknown as Parameters< - typeof autoReplyIfConfigured - >[0]; - - const message = { - body: "ping", - from: "+15551234567", - to: "+15557654321", - sid: "SM123", - } as Parameters[1]; - - await autoReplyIfConfigured(client, message, config); - - expect(create).toHaveBeenCalledTimes(1); - expect(create).toHaveBeenCalledWith( - expect.objectContaining({ - body, - from: message.to, - to: message.from, - }), - ); - }); -}); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index b3921d7d3..6e7de6bbe 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js"; import { loadConfig, type WarelayConfig } from "../config/config.js"; import { DEFAULT_IDLE_MINUTES, @@ -10,14 +9,10 @@ import { type SessionEntry, saveSessionStore, } from "../config/sessions.js"; -import { info, isVerbose, logVerbose } from "../globals.js"; +import { isVerbose, logVerbose } from "../globals.js"; import { triggerWarelayRestart } from "../infra/restart.js"; -import { ensureMediaHosted } from "../media/host.js"; import { runCommandWithTimeout } from "../process/exec.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import type { TwilioRequester } from "../twilio/types.js"; -import { sendTypingIndicator } from "../twilio/typing.js"; -import { chunkText } from "./chunk.js"; +import { defaultRuntime } from "../runtime.js"; import { runCommandReply } from "./command-reply.js"; import { applyTemplate, @@ -35,8 +30,6 @@ import type { GetReplyOptions, ReplyPayload } from "./types.js"; export type { GetReplyOptions, ReplyPayload } from "./types.js"; -const TWILIO_TEXT_LIMIT = 1600; - const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]); const ABORT_MEMORY = new Map(); @@ -193,7 +186,7 @@ export async function getReplyFromConfig( 1, ); const sessionScope = sessionCfg?.scope ?? "per-sender"; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = sessionCfg ? resolveStorePath(sessionCfg.store) : undefined; let sessionStore: ReturnType | undefined; let sessionKey: string | undefined; let sessionEntry: SessionEntry | undefined; @@ -693,162 +686,3 @@ export async function getReplyFromConfig( cleanupTyping(); return undefined; } - -type TwilioLikeClient = TwilioRequester & { - messages: { - create: (opts: { - from?: string; - to?: string; - body: string; - }) => Promise; - }; -}; - -export async function autoReplyIfConfigured( - client: TwilioLikeClient, - message: MessageInstance, - configOverride?: WarelayConfig, - runtime: RuntimeEnv = defaultRuntime, -): Promise { - // Fire a config-driven reply (text or command) for the inbound message, if configured. - const ctx: MsgContext = { - Body: message.body ?? undefined, - From: message.from ?? undefined, - To: message.to ?? undefined, - MessageSid: message.sid, - }; - const replyFrom = message.to; - const replyTo = message.from; - if (!replyFrom || !replyTo) { - if (isVerbose()) - console.error( - "Skipping auto-reply: missing to/from on inbound message", - ctx, - ); - return; - } - const cfg = configOverride ?? loadConfig(); - // Attach media hints for transcription/templates if present on Twilio payloads. - const mediaUrl = (message as { mediaUrl?: string }).mediaUrl; - if (mediaUrl) ctx.MediaUrl = mediaUrl; - - // Optional audio transcription before building reply. - const mediaField = (message as { media?: unknown }).media; - const mediaItems = Array.isArray(mediaField) ? mediaField : []; - if (cfg.inbound?.transcribeAudio && mediaItems.length) { - const media = mediaItems[0]; - const contentType = (media as { contentType?: string }).contentType; - if (contentType?.startsWith("audio")) { - const transcribed = await transcribeInboundAudio(cfg, ctx, runtime); - if (transcribed?.text) { - ctx.Body = transcribed.text; - ctx.MediaType = contentType; - logVerbose("Replaced Body with audio transcript for reply flow"); - } - } - } - - const sendTwilio = async (body: string, media?: string) => { - let resolvedMedia = media; - if (resolvedMedia && !/^https?:\/\//i.test(resolvedMedia)) { - const hosted = await ensureMediaHosted(resolvedMedia); - resolvedMedia = hosted.url; - } - await client.messages.create({ - from: replyFrom, - to: replyTo, - body, - ...(resolvedMedia ? { mediaUrl: [resolvedMedia] } : {}), - }); - }; - - const sendPayload = async (replyPayload: ReplyPayload) => { - const mediaList = replyPayload.mediaUrls?.length - ? replyPayload.mediaUrls - : replyPayload.mediaUrl - ? [replyPayload.mediaUrl] - : []; - - const text = replyPayload.text ?? ""; - const chunks = chunkText(text, TWILIO_TEXT_LIMIT); - if (chunks.length === 0) chunks.push(""); - - for (let i = 0; i < chunks.length; i++) { - const body = chunks[i]; - const attachMedia = i === 0 ? mediaList[0] : undefined; - - if (body) { - logVerbose( - `Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${body.length}`, - ); - } else if (attachMedia) { - logVerbose( - `Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media only)`, - ); - } - - await sendTwilio(body, attachMedia); - - if (i === 0 && mediaList.length > 1) { - for (const extra of mediaList.slice(1)) { - await sendTwilio("", extra); - } - } - - if (isVerbose()) { - console.log( - info( - `↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${attachMedia ? ", media" : ""})`, - ), - ); - } - } - }; - - const partialSender = async (payload: ReplyPayload) => { - await sendPayload(payload); - }; - - const replyResult = await getReplyFromConfig( - ctx, - { - onReplyStart: () => sendTypingIndicator(client, runtime, message.sid), - onPartialReply: partialSender, - }, - cfg, - ); - const replies = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] - : []; - if (replies.length === 0) return; - - try { - for (const replyPayload of replies) { - await sendPayload(replyPayload); - } - } catch (err) { - const anyErr = err as { - code?: string | number; - message?: unknown; - moreInfo?: unknown; - status?: string | number; - response?: { body?: unknown }; - }; - const { code, status } = anyErr; - const msg = - typeof anyErr?.message === "string" - ? anyErr.message - : (anyErr?.message ?? err); - runtime.error( - `❌ Twilio send failed${code ? ` (code ${code})` : ""}${status ? ` status ${status}` : ""}: ${msg}`, - ); - if (anyErr?.moreInfo) runtime.error(`More info: ${anyErr.moreInfo}`); - const responseBody = anyErr?.response?.body; - if (responseBody) { - runtime.error("Response body:"); - runtime.error(JSON.stringify(responseBody, null, 2)); - } - } -} diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 51c428175..2758ac9c3 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -1,110 +1,13 @@ -import { autoReplyIfConfigured } from "../auto-reply/reply.js"; -import { readEnv } from "../env.js"; -import { info } from "../globals.js"; -import { ensureBinary } from "../infra/binaries.js"; -import { ensurePortAvailable, handlePortError } from "../infra/ports.js"; -import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js"; -import { ensureMediaHosted } from "../media/host.js"; -import { - logWebSelfId, - monitorWebProvider, - sendMessageWeb, -} from "../providers/web/index.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { createClient } from "../twilio/client.js"; -import { listRecentMessages } from "../twilio/messages.js"; -import { monitorTwilio as monitorTwilioImpl } from "../twilio/monitor.js"; -import { sendMessage, waitForFinalStatus } from "../twilio/send.js"; -import { findWhatsappSenderSid } from "../twilio/senders.js"; -import { assertProvider, sleep } from "../utils.js"; -import { startWebhook } from "../webhook/server.js"; -import { updateWebhook } from "../webhook/update.js"; -import { waitForever } from "./wait.js"; +import { logWebSelfId, sendMessageWeb } from "../providers/web/index.js"; export type CliDeps = { - sendMessage: typeof sendMessage; sendMessageWeb: typeof sendMessageWeb; - waitForFinalStatus: typeof waitForFinalStatus; - assertProvider: typeof assertProvider; - createClient?: typeof createClient; - monitorTwilio: typeof monitorTwilio; - listRecentMessages: typeof listRecentMessages; - ensurePortAvailable: typeof ensurePortAvailable; - startWebhook: typeof startWebhook; - waitForever: typeof waitForever; - ensureBinary: typeof ensureBinary; - ensureFunnel: typeof ensureFunnel; - getTailnetHostname: typeof getTailnetHostname; - readEnv: typeof readEnv; - findWhatsappSenderSid: typeof findWhatsappSenderSid; - updateWebhook: typeof updateWebhook; - handlePortError: typeof handlePortError; - monitorWebProvider: typeof monitorWebProvider; - resolveTwilioMediaUrl: ( - source: string, - opts: { serveMedia: boolean; runtime: RuntimeEnv }, - ) => Promise; }; -export async function monitorTwilio( - intervalSeconds: number, - lookbackMinutes: number, - clientOverride?: ReturnType, - maxIterations = Infinity, -) { - // Adapter that wires default deps/runtime for the Twilio monitor loop. - return monitorTwilioImpl(intervalSeconds, lookbackMinutes, { - client: clientOverride, - maxIterations, - deps: { - autoReplyIfConfigured, - listRecentMessages, - readEnv, - createClient, - sleep, - }, - runtime: defaultRuntime, - }); -} - export function createDefaultDeps(): CliDeps { - // Default dependency bundle used by CLI commands and tests. return { - sendMessage, sendMessageWeb, - waitForFinalStatus, - assertProvider, - createClient, - monitorTwilio, - listRecentMessages, - ensurePortAvailable, - startWebhook, - waitForever, - ensureBinary, - ensureFunnel, - getTailnetHostname, - readEnv, - findWhatsappSenderSid, - updateWebhook, - handlePortError, - monitorWebProvider, - resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => { - if (/^https?:\/\//i.test(source)) return source; - const hosted = await ensureMediaHosted(source, { - startServer: serveMedia, - runtime, - }); - return hosted.url; - }, }; } -export function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) { - // Log the configured Twilio sender for clarity in CLI output. - const env = readEnv(runtime); - runtime.log( - info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`), - ); -} - export { logWebSelfId }; diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts index 18ac5be13..1a0564893 100644 --- a/src/cli/program.test.ts +++ b/src/cli/program.test.ts @@ -2,16 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const sendCommand = vi.fn(); const statusCommand = vi.fn(); -const webhookCommand = vi.fn().mockResolvedValue(undefined); -const ensureTwilioEnv = vi.fn(); const loginWeb = vi.fn(); const monitorWebProvider = vi.fn(); -const pickProvider = vi.fn(); -const monitorTwilio = vi.fn(); -const logTwilioFrom = vi.fn(); const logWebSelfId = vi.fn(); const waitForever = vi.fn(); -const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay"); +const spawnRelayTmux = vi.fn().mockResolvedValue("clawdis-relay"); const runtime = { log: vi.fn(), @@ -23,19 +18,14 @@ const runtime = { vi.mock("../commands/send.js", () => ({ sendCommand })); vi.mock("../commands/status.js", () => ({ statusCommand })); -vi.mock("../commands/webhook.js", () => ({ webhookCommand })); -vi.mock("../env.js", () => ({ ensureTwilioEnv })); vi.mock("../runtime.js", () => ({ defaultRuntime: runtime })); vi.mock("../provider-web.js", () => ({ loginWeb, monitorWebProvider, - pickProvider, })); vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({ waitForever }), - logTwilioFrom, logWebSelfId, - monitorTwilio, })); vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux })); @@ -54,55 +44,15 @@ describe("cli program", () => { expect(sendCommand).toHaveBeenCalled(); }); - it("rejects invalid relay provider", async () => { - const program = buildProgram(); - await expect( - program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }), - ).rejects.toThrow("exit"); - expect(runtime.error).toHaveBeenCalledWith( - "--provider must be auto, web, or twilio", - ); - }); - - it("falls back to twilio when web relay fails", async () => { - pickProvider.mockResolvedValue("web"); - monitorWebProvider.mockRejectedValue(new Error("no web")); - const program = buildProgram(); - await expect( - program.parseAsync( - ["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"], - { from: "user" }, - ), - ).rejects.toThrow("exit"); - expect(logWebSelfId).toHaveBeenCalled(); - expect(ensureTwilioEnv).not.toHaveBeenCalled(); - expect(monitorTwilio).not.toHaveBeenCalled(); - }); - - it("runs relay tmux attach command", async () => { - const originalIsTTY = process.stdout.isTTY; - (process.stdout as typeof process.stdout & { isTTY?: boolean }).isTTY = - true; - - const program = buildProgram(); - await program.parseAsync(["relay:tmux:attach"], { from: "user" }); - expect(spawnRelayTmux).toHaveBeenCalledWith( - "pnpm clawdis relay --verbose", - true, - false, - ); - - (process.stdout as typeof process.stdout & { isTTY?: boolean }).isTTY = - originalIsTTY; - }); - - it("runs relay heartbeat command", async () => { - pickProvider.mockResolvedValue("web"); + it("starts relay with heartbeat tuning", async () => { monitorWebProvider.mockResolvedValue(undefined); - const originalExit = runtime.exit; - runtime.exit = vi.fn(); const program = buildProgram(); - await program.parseAsync(["relay:heartbeat"], { from: "user" }); + await program.parseAsync( + ["relay", "--web-heartbeat", "90", "--heartbeat-now"], + { + from: "user", + }, + ); expect(logWebSelfId).toHaveBeenCalled(); expect(monitorWebProvider).toHaveBeenCalledWith( false, @@ -111,8 +61,17 @@ describe("cli program", () => { undefined, runtime, undefined, - { replyHeartbeatNow: true }, + { heartbeatSeconds: 90, replyHeartbeatNow: true }, ); + }); + + it("runs relay heartbeat command", async () => { + monitorWebProvider.mockResolvedValue(undefined); + const originalExit = runtime.exit; + runtime.exit = vi.fn(); + const program = buildProgram(); + await program.parseAsync(["relay:heartbeat"], { from: "user" }); + expect(logWebSelfId).toHaveBeenCalled(); expect(runtime.exit).not.toHaveBeenCalled(); runtime.exit = originalExit; }); @@ -126,4 +85,10 @@ describe("cli program", () => { shouldAttach, ); }); + + it("runs status command", async () => { + const program = buildProgram(); + await program.parseAsync(["status"], { from: "user" }); + expect(statusCommand).toHaveBeenCalled(); + }); }); diff --git a/src/cli/program.ts b/src/cli/program.ts index 9e909d7d0..38da3f90e 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -3,45 +3,35 @@ import { Command } from "commander"; import { agentCommand } from "../commands/agent.js"; import { sendCommand } from "../commands/send.js"; import { statusCommand } from "../commands/status.js"; -import { webhookCommand } from "../commands/webhook.js"; import { loadConfig } from "../config/config.js"; -import { ensureTwilioEnv } from "../env.js"; -import { danger, info, setVerbose, setYes } from "../globals.js"; +import { danger, info, setVerbose } from "../globals.js"; import { getResolvedLoggerSettings } from "../logging.js"; import { loginWeb, logoutWeb, monitorWebProvider, - pickProvider, resolveHeartbeatRecipients, runWebHeartbeatOnce, type WebMonitorTuning, } from "../provider-web.js"; import { defaultRuntime } from "../runtime.js"; -import { runTwilioHeartbeatOnce } from "../twilio/heartbeat.js"; -import type { Provider } from "../utils.js"; import { VERSION } from "../version.js"; import { resolveHeartbeatSeconds, resolveReconnectPolicy, } from "../web/reconnect.js"; -import { - createDefaultDeps, - logTwilioFrom, - logWebSelfId, - monitorTwilio, -} from "./deps.js"; +import { createDefaultDeps, logWebSelfId } from "./deps.js"; import { spawnRelayTmux } from "./relay_tmux.js"; export function buildProgram() { const program = new Command(); const PROGRAM_VERSION = VERSION; const TAGLINE = - "Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked."; + "Send, receive, and auto-reply on WhatsApp—Baileys (web) only."; program .name("clawdis") - .description("WhatsApp relay CLI (Twilio or WhatsApp Web session)") + .description("WhatsApp relay CLI (WhatsApp Web session only)") .version(PROGRAM_VERSION); const formatIntroLine = (version: string, rich = true) => { @@ -80,24 +70,24 @@ export function buildProgram() { "Link personal WhatsApp Web and show QR + connection logs.", ], [ - 'clawdis send --to +15551234567 --message "Hi" --provider web --json', + 'clawdis send --to +15551234567 --message "Hi" --json', "Send via your web session and print JSON result.", ], [ - "clawdis relay --provider auto --interval 5 --lookback 15 --verbose", - "Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.", + "clawdis relay --verbose", + "Auto-reply loop using your linked web session.", ], [ - "clawdis webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose", - "Start webhook + Tailscale Funnel and update Twilio callbacks.", + "clawdis heartbeat --verbose", + "Send a heartbeat ping to your active session or first allowFrom contact.", ], [ - "clawdis status --limit 10 --lookback 60 --json", - "Show last 10 messages from the past hour as JSON.", + "clawdis status", + "Show web session health and recent session recipients.", ], [ - 'clawdis agent --to +15551234567 --message "Run summary" --thinking high', - "Talk directly to the agent using the same session handling, no WhatsApp send.", + 'clawdis agent --to +15551234567 --message "Run summary" --deliver', + "Talk directly to the agent using the same session handling; optionally send the reply.", ], ] as const; @@ -138,7 +128,7 @@ export function buildProgram() { program .command("send") - .description("Send a WhatsApp message") + .description("Send a WhatsApp message (web provider)") .requiredOption( "-t, --to ", "Recipient number in E.164 (e.g. +15551234567)", @@ -146,20 +136,8 @@ export function buildProgram() { .requiredOption("-m, --message ", "Message body") .option( "--media ", - "Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.", + "Attach media (image/audio/video/document). Accepts local paths or URLs.", ) - .option( - "--serve-media", - "For Twilio: start a temporary media server if webhook is not running", - false, - ) - .option( - "-w, --wait ", - "Wait for delivery status (0 to skip)", - "20", - ) - .option("-p, --poll ", "Polling interval while waiting", "2") - .option("--provider ", "Provider: twilio | web", "twilio") .option("--dry-run", "Print payload and skip sending", false) .option("--json", "Output result as JSON", false) .option("--verbose", "Verbose logging", false) @@ -167,10 +145,10 @@ export function buildProgram() { "after", ` Examples: - clawdis send --to +15551234567 --message "Hi" # wait 20s for delivery (default) - clawdis send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget + clawdis send --to +15551234567 --message "Hi" + clawdis send --to +15551234567 --message "Hi" --media photo.jpg clawdis send --to +15551234567 --message "Hi" --dry-run # print payload only - clawdis send --to +15551234567 --message "Hi" --wait 60 --poll 3`, + clawdis send --to +15551234567 --message "Hi" --json # machine-readable result`, ) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); @@ -204,11 +182,6 @@ Examples: "Send the agent's reply back to WhatsApp (requires --to)", false, ) - .option( - "--provider ", - "Provider to deliver via when using --deliver (auto | web | twilio)", - "auto", - ) .option("--json", "Output result as JSON", false) .option( "--timeout ", @@ -221,7 +194,7 @@ Examples: clawdis agent --to +15551234567 --message "status update" clawdis agent --session-id 1234 --message "Summarize inbox" --thinking medium clawdis agent --to +15551234567 --message "Trace logs" --verbose on --json - clawdis agent --to +15551234567 --message "Summon reply" --deliver --provider web + clawdis agent --to +15551234567 --message "Summon reply" --deliver `, ) .action(async (opts) => { @@ -240,10 +213,7 @@ Examples: program .command("heartbeat") - .description( - "Trigger a heartbeat or manual send once (web or twilio, no tmux)", - ) - .option("--provider ", "auto | web | twilio", "auto") + .description("Trigger a heartbeat or manual send once (web only, no tmux)") .option("--to ", "Override target E.164; defaults to allowFrom[0]") .option( "--session-id ", @@ -256,7 +226,7 @@ Examples: ) .option( "--message ", - "Send a custom message instead of the heartbeat probe (web or twilio provider)", + "Send a custom message instead of the heartbeat probe", ) .option("--body ", "Alias for --message") .option("--dry-run", "Print the resolved payload without sending", false) @@ -269,7 +239,7 @@ Examples: clawdis heartbeat --verbose # prints detailed heartbeat logs clawdis heartbeat --to +1555123 # override destination clawdis heartbeat --session-id --to +1555123 # resume a specific session - clawdis heartbeat --message "Ping" --provider twilio + clawdis heartbeat --message "Ping" clawdis heartbeat --all # send to every active session recipient or allowFrom entry`, ) .action(async (opts) => { @@ -302,11 +272,6 @@ Examples: ); defaultRuntime.exit(1); } - const providerPref = String(opts.provider ?? "auto"); - if (!["auto", "web", "twilio"].includes(providerPref)) { - defaultRuntime.error("--provider must be auto, web, or twilio"); - defaultRuntime.exit(1); - } const overrideBody = (opts.message as string | undefined) || @@ -314,32 +279,16 @@ Examples: undefined; const dryRun = Boolean(opts.dryRun); - const provider = - providerPref === "twilio" - ? "twilio" - : await pickProvider(providerPref as "auto" | "web"); - if (provider === "twilio") ensureTwilioEnv(); - try { for (const to of recipients) { - if (provider === "web") { - await runWebHeartbeatOnce({ - to, - verbose: Boolean(opts.verbose), - runtime: defaultRuntime, - sessionId: opts.sessionId, - overrideBody, - dryRun, - }); - } else { - await runTwilioHeartbeatOnce({ - to, - verbose: Boolean(opts.verbose), - runtime: defaultRuntime, - overrideBody, - dryRun, - }); - } + await runWebHeartbeatOnce({ + to, + verbose: Boolean(opts.verbose), + runtime: defaultRuntime, + sessionId: opts.sessionId, + overrideBody, + dryRun, + }); } } catch { defaultRuntime.exit(1); @@ -348,14 +297,7 @@ Examples: program .command("relay") - .description("Auto-reply to inbound messages (auto-selects web or twilio)") - .option("--provider ", "auto | web | twilio", "auto") - .option("-i, --interval ", "Polling interval for twilio mode", "5") - .option( - "-l, --lookback ", - "Initial lookback window for twilio mode", - "5", - ) + .description("Auto-reply to inbound messages (web only)") .option( "--web-heartbeat ", "Heartbeat interval for web relay health logs (seconds)", @@ -371,7 +313,7 @@ Examples: .option("--web-retry-max ", "Max reconnect backoff for web relay (ms)") .option( "--heartbeat-now", - "Run a heartbeat immediately when relay starts (web provider)", + "Run a heartbeat immediately when relay starts", false, ) .option("--verbose", "Verbose logging", false) @@ -379,10 +321,8 @@ Examples: "after", ` Examples: - clawdis relay # auto: web if logged-in, else twilio poll - clawdis relay --provider web # force personal web session - clawdis relay --provider twilio # force twilio poll - clawdis relay --provider twilio --interval 2 --lookback 30 + clawdis relay # uses your linked web session + clawdis relay --web-heartbeat 60 # override heartbeat interval # Troubleshooting: docs/refactor/web-relay-troubleshooting.md `, ) @@ -390,13 +330,6 @@ Examples: setVerbose(Boolean(opts.verbose)); const { file: logFile, level: logLevel } = getResolvedLoggerSettings(); defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`)); - const providerPref = String(opts.provider ?? "auto"); - if (!["auto", "web", "twilio"].includes(providerPref)) { - defaultRuntime.error("--provider must be auto, web, or twilio"); - defaultRuntime.exit(1); - } - const intervalSeconds = Number.parseInt(opts.interval, 10); - const lookbackMinutes = Number.parseInt(opts.lookback, 10); const webHeartbeat = opts.webHeartbeat !== undefined ? Number.parseInt(String(opts.webHeartbeat), 10) @@ -414,14 +347,6 @@ Examples: ? Number.parseInt(String(opts.webRetryMax), 10) : undefined; const heartbeatNow = Boolean(opts.heartbeatNow); - if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) { - defaultRuntime.error("Interval must be a positive integer"); - defaultRuntime.exit(1); - } - if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) { - defaultRuntime.error("Lookback must be >= 0 minutes"); - defaultRuntime.exit(1); - } if ( webHeartbeat !== undefined && (Number.isNaN(webHeartbeat) || webHeartbeat <= 0) @@ -469,49 +394,37 @@ Examples: if (Object.keys(reconnect).length > 0) { webTuning.reconnect = reconnect; } - - const provider = await pickProvider(providerPref as Provider | "auto"); - - if (provider === "web") { - logWebSelfId(defaultRuntime, true); - const cfg = loadConfig(); - const effectiveHeartbeat = resolveHeartbeatSeconds( - cfg, - webTuning.heartbeatSeconds, + logWebSelfId(defaultRuntime, true); + const cfg = loadConfig(); + const effectiveHeartbeat = resolveHeartbeatSeconds( + cfg, + webTuning.heartbeatSeconds, + ); + const effectivePolicy = resolveReconnectPolicy(cfg, webTuning.reconnect); + defaultRuntime.log( + info( + `Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}→${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`, + ), + ); + try { + await monitorWebProvider( + Boolean(opts.verbose), + undefined, + true, + undefined, + defaultRuntime, + undefined, + webTuning, ); - const effectivePolicy = resolveReconnectPolicy( - cfg, - webTuning.reconnect, - ); - defaultRuntime.log( - info( - `Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}→${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`, + return; + } catch (err) { + defaultRuntime.error( + danger( + `Web relay failed: ${String(err)}. Re-link with 'clawdis login --verbose'.`, ), ); - try { - await monitorWebProvider( - Boolean(opts.verbose), - undefined, - true, - undefined, - defaultRuntime, - undefined, - webTuning, - ); - return; - } catch (err) { - defaultRuntime.error( - danger( - `Web relay failed: ${String(err)}. Not falling back; re-link with 'clawdis login --provider web'.`, - ), - ); - defaultRuntime.exit(1); - } + defaultRuntime.exit(1); } - - ensureTwilioEnv(); - logTwilioFrom(); - await monitorTwilio(intervalSeconds, lookbackMinutes); }); program @@ -519,28 +432,11 @@ Examples: .description( "Run relay with an immediate heartbeat (no tmux); requires web provider", ) - .option("--provider ", "auto | web", "auto") .option("--verbose", "Verbose logging", false) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); const { file: logFile, level: logLevel } = getResolvedLoggerSettings(); defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`)); - const providerPref = String(opts.provider ?? "auto"); - if (!["auto", "web"].includes(providerPref)) { - defaultRuntime.error("--provider must be auto or web"); - defaultRuntime.exit(1); - return; - } - const provider = await pickProvider(providerPref as "auto" | "web"); - if (provider !== "web") { - defaultRuntime.error( - danger( - "Heartbeat relay is only supported for the web provider. Link with `clawdis login --verbose`.", - ), - ); - defaultRuntime.exit(1); - return; - } logWebSelfId(defaultRuntime, true); const cfg = loadConfig(); @@ -574,75 +470,20 @@ Examples: program .command("status") - .description("Show recent WhatsApp messages (sent and received)") - .option("-l, --limit ", "Number of messages to show", "20") - .option("-b, --lookback ", "How far back to fetch messages", "240") + .description("Show web session health and recent session recipients") .option("--json", "Output JSON instead of text", false) .option("--verbose", "Verbose logging", false) .addHelpText( "after", ` Examples: - clawdis status # last 20 msgs in past 4h - clawdis status --limit 5 --lookback 30 # last 5 msgs in past 30m - clawdis status --json --limit 50 # machine-readable output`, + clawdis status # show linked account + session store summary + clawdis status --json # machine-readable output`, ) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); - const deps = createDefaultDeps(); try { - await statusCommand(opts, deps, defaultRuntime); - } catch (err) { - defaultRuntime.error(String(err)); - defaultRuntime.exit(1); - } - }); - - program - .command("webhook") - .description( - "Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.", - ) - .option("-p, --port ", "Port to listen on", "42873") - .option("-r, --reply ", "Optional auto-reply text") - .option("--path ", "Webhook path", "/webhook/whatsapp") - .option( - "--ingress ", - "Ingress: tailscale (funnel + Twilio update) | none (local only)", - "tailscale", - ) - .option("--verbose", "Log inbound and auto-replies", false) - .option("-y, --yes", "Auto-confirm prompts when possible", false) - .option("--dry-run", "Print planned actions without starting server", false) - .addHelpText( - "after", - ` -Examples: - clawdis webhook # ingress=tailscale (funnel + Twilio update) - clawdis webhook --ingress none # local-only server (no funnel / no Twilio update) - clawdis webhook --port 45000 # pick a high, less-colliding port - clawdis webhook --reply "Got it!" # static auto-reply; otherwise use config file`, - ) - // istanbul ignore next - .action(async (opts) => { - setVerbose(Boolean(opts.verbose)); - setYes(Boolean(opts.yes)); - const deps = createDefaultDeps(); - try { - const server = await webhookCommand(opts, deps, defaultRuntime); - if (!server) { - defaultRuntime.log( - info("Webhook dry-run complete; no server started."), - ); - return; - } - process.on("SIGINT", () => { - server.close(() => { - console.log("\n👋 Webhook stopped"); - defaultRuntime.exit(0); - }); - }); - await deps.waitForever(); + await statusCommand(opts, defaultRuntime); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); diff --git a/src/cli/relay.e2e.test.ts b/src/cli/relay.e2e.test.ts deleted file mode 100644 index 0ceb22472..000000000 --- a/src/cli/relay.e2e.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -// Mocks must be defined via vi.hoisted to avoid TDZ with ESM hoisting. -const { monitorWebProvider, pickProvider, logWebSelfId, monitorTwilio } = - vi.hoisted(() => { - return { - monitorWebProvider: vi.fn().mockResolvedValue(undefined), - pickProvider: vi.fn().mockResolvedValue("web"), - logWebSelfId: vi.fn(), - monitorTwilio: vi.fn().mockResolvedValue(undefined), - }; - }); - -vi.mock("../provider-web.js", () => ({ - monitorWebProvider, - pickProvider, - logWebSelfId, -})); - -vi.mock("../twilio/monitor.js", () => ({ - monitorTwilio, -})); - -import { buildProgram } from "./program.js"; - -describe("CLI relay command (e2e-ish)", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("runs relay in web mode without crashing", async () => { - const program = buildProgram(); - program.exitOverride(); // throw instead of exiting process on error - - await expect( - program.parseAsync(["relay", "--provider", "web"], { from: "user" }), - ).resolves.toBeInstanceOf(Object); - - expect(pickProvider).toHaveBeenCalledWith("web"); - expect(logWebSelfId).toHaveBeenCalledTimes(1); - expect(monitorWebProvider).toHaveBeenCalledTimes(1); - expect(monitorWebProvider.mock.calls[0][0]).toBe(false); - expect(monitorTwilio).not.toHaveBeenCalled(); - }); -}); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index c4ef6ebab..c51311c53 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import { chunkText } from "../auto-reply/chunk.js"; import { runCommandReply } from "../auto-reply/command-reply.js"; import { applyTemplate, @@ -22,11 +21,8 @@ import { type SessionEntry, saveSessionStore, } from "../config/sessions.js"; -import { ensureTwilioEnv } from "../env.js"; import { runCommandWithTimeout } from "../process/exec.js"; -import { pickProvider } from "../provider-web.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import type { Provider } from "../utils.js"; import { sendViaIpc } from "../web/ipc.js"; type AgentCommandOpts = { @@ -38,7 +34,6 @@ type AgentCommandOpts = { json?: boolean; timeout?: string; deliver?: boolean; - provider?: Provider | "auto"; }; type SessionResolution = { @@ -344,14 +339,6 @@ export async function agentCommand( } const deliver = opts.deliver === true; - let provider: Provider | "auto" | undefined = opts.provider ?? "auto"; - if (deliver) { - provider = - provider === "twilio" - ? "twilio" - : await pickProvider((provider ?? "auto") as Provider | "auto"); - if (provider === "twilio") ensureTwilioEnv(); - } for (const payload of payloads) { const lines: string[] = []; @@ -366,55 +353,29 @@ export async function agentCommand( if (deliver && opts.to) { const text = payload.text ?? ""; const media = mediaList; - if (provider === "web") { - // Prefer IPC to reuse the running relay; fall back to direct web send. - let sentViaIpc = false; - const ipcResult = await sendViaIpc(opts.to, text, media[0]); - if (ipcResult) { - sentViaIpc = ipcResult.success; - if (ipcResult.success && media.length > 1) { - for (const extra of media.slice(1)) { - await sendViaIpc(opts.to, "", extra); - } - } - } - if (!sentViaIpc) { - if (text || media.length === 0) { - await deps.sendMessageWeb(opts.to, text, { - verbose: false, - mediaUrl: media[0], - }); - } + // Prefer IPC to reuse the running relay; fall back to direct web send. + let sentViaIpc = false; + const ipcResult = await sendViaIpc(opts.to, text, media[0]); + if (ipcResult) { + sentViaIpc = ipcResult.success; + if (ipcResult.success && media.length > 1) { for (const extra of media.slice(1)) { - await deps.sendMessageWeb(opts.to, "", { - verbose: false, - mediaUrl: extra, - }); + await sendViaIpc(opts.to, "", extra); } } - } else { - const chunks = chunkText(text, 1600); - const resolvedMedia = await Promise.all( - media.map((m) => - deps.resolveTwilioMediaUrl(m, { serveMedia: false, runtime }), - ), - ); - const firstMedia = resolvedMedia[0]; - if (chunks.length === 0) chunks.push(""); - for (let i = 0; i < chunks.length; i++) { - const bodyChunk = chunks[i]; - const attach = i === 0 ? firstMedia : undefined; - await deps.sendMessage( - opts.to, - bodyChunk, - { mediaUrl: attach }, - runtime, - ); + } + if (!sentViaIpc) { + if (text || media.length === 0) { + await deps.sendMessageWeb(opts.to, text, { + verbose: false, + mediaUrl: media[0], + }); } - if (resolvedMedia.length > 1) { - for (const extra of resolvedMedia.slice(1)) { - await deps.sendMessage(opts.to, "", { mediaUrl: extra }, runtime); - } + for (const extra of media.slice(1)) { + await deps.sendMessageWeb(opts.to, "", { + verbose: false, + mediaUrl: extra, + }); } } } diff --git a/src/commands/send.test.ts b/src/commands/send.test.ts index 7cc54e7c4..419c0c52d 100644 --- a/src/commands/send.test.ts +++ b/src/commands/send.test.ts @@ -4,8 +4,9 @@ import type { CliDeps } from "../cli/deps.js"; import type { RuntimeEnv } from "../runtime.js"; import { sendCommand } from "./send.js"; +const sendViaIpcMock = vi.fn().mockResolvedValue(null); vi.mock("../web/ipc.js", () => ({ - sendViaIpc: vi.fn().mockResolvedValue(null), + sendViaIpc: (...args: unknown[]) => sendViaIpcMock(...args), })); const runtime: RuntimeEnv = { @@ -16,59 +17,19 @@ const runtime: RuntimeEnv = { }), }; -const baseDeps = { - assertProvider: vi.fn(), +const makeDeps = (overrides: Partial = {}): CliDeps => ({ sendMessageWeb: vi.fn(), - resolveTwilioMediaUrl: vi.fn(), - sendMessage: vi.fn(), - waitForFinalStatus: vi.fn(), -} as unknown as CliDeps; + ...overrides, +}); describe("sendCommand", () => { - it("validates wait and poll", async () => { - await expect(() => - sendCommand( - { - to: "+1", - message: "hi", - wait: "-1", - poll: "2", - provider: "twilio", - }, - baseDeps, - runtime, - ), - ).rejects.toThrow("Wait must be >= 0 seconds"); - - await expect(() => - sendCommand( - { - to: "+1", - message: "hi", - wait: "0", - poll: "0", - provider: "twilio", - }, - baseDeps, - runtime, - ), - ).rejects.toThrow("Poll must be > 0 seconds"); - }); - - it("handles web dry-run and warns on wait", async () => { - const deps = { - ...baseDeps, - sendMessageWeb: vi.fn(), - } as CliDeps; + it("skips send on dry-run", async () => { + const deps = makeDeps(); await sendCommand( { to: "+1", message: "hi", - wait: "5", - poll: "2", - provider: "web", dryRun: true, - media: "pic.jpg", }, deps, runtime, @@ -76,74 +37,54 @@ describe("sendCommand", () => { expect(deps.sendMessageWeb).not.toHaveBeenCalled(); }); - it("sends via web and outputs JSON", async () => { - const deps = { - ...baseDeps, - sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }), - } as CliDeps; + it("uses IPC when available", async () => { + sendViaIpcMock.mockResolvedValueOnce({ success: true, messageId: "ipc1" }); + const deps = makeDeps(); await sendCommand( { to: "+1", message: "hi", - wait: "1", - poll: "2", - provider: "web", - json: true, + }, + deps, + runtime, + ); + expect(deps.sendMessageWeb).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("ipc1")); + }); + + it("falls back to direct send when IPC fails", async () => { + sendViaIpcMock.mockResolvedValueOnce({ success: false, error: "nope" }); + const deps = makeDeps({ + sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "direct1" }), + }); + await sendCommand( + { + to: "+1", + message: "hi", + media: "pic.jpg", }, deps, runtime, ); expect(deps.sendMessageWeb).toHaveBeenCalled(); - expect(runtime.log).toHaveBeenCalledWith( - expect.stringContaining('"provider": "web"'), - ); }); - it("supports twilio dry-run", async () => { - const deps = { ...baseDeps } as CliDeps; + it("emits json output", async () => { + sendViaIpcMock.mockResolvedValueOnce(null); + const deps = makeDeps({ + sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "direct2" }), + }); await sendCommand( { to: "+1", message: "hi", - wait: "0", - poll: "2", - provider: "twilio", - dryRun: true, - }, - deps, - runtime, - ); - expect(deps.sendMessage).not.toHaveBeenCalled(); - }); - - it("sends via twilio with media and skips wait when zero", async () => { - const deps = { - ...baseDeps, - resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"), - sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }), - waitForFinalStatus: vi.fn(), - } as CliDeps; - await sendCommand( - { - to: "+1", - message: "hi", - wait: "0", - poll: "2", - provider: "twilio", - media: "pic.jpg", - serveMedia: true, json: true, }, deps, runtime, ); - expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", { - serveMedia: true, - runtime, - }); - expect(deps.waitForFinalStatus).not.toHaveBeenCalled(); expect(runtime.log).toHaveBeenCalledWith( - expect.stringContaining('"provider": "twilio"'), + expect.stringContaining('"provider": "web"'), ); }); }); diff --git a/src/commands/send.ts b/src/commands/send.ts index 30b99f057..397b3ad12 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -1,148 +1,81 @@ import type { CliDeps } from "../cli/deps.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: { to: string; message: string; - wait: string; - poll: string; - provider: Provider; json?: boolean; dryRun?: boolean; media?: string; - serveMedia?: boolean; }, deps: CliDeps, runtime: RuntimeEnv, ) { - deps.assertProvider(opts.provider); - const waitSeconds = Number.parseInt(opts.wait, 10); - const pollSeconds = Number.parseInt(opts.poll, 10); - - if (Number.isNaN(waitSeconds) || waitSeconds < 0) { - throw new Error("Wait must be >= 0 seconds"); - } - if (Number.isNaN(pollSeconds) || pollSeconds <= 0) { - throw new Error("Poll must be > 0 seconds"); - } - - if (opts.provider === "web") { - if (opts.dryRun) { - runtime.log( - `[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`, - ); - return; - } - 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, - mediaUrl: opts.media, - }) - .catch((err) => { - runtime.error(`❌ Web send failed: ${String(err)}`); - throw err; - }); - if (opts.json) { - runtime.log( - JSON.stringify( - { - provider: "web", - via: "direct", - to: opts.to, - messageId: res.messageId, - mediaUrl: opts.media ?? null, - }, - null, - 2, - ), - ); - } - return; - } - if (opts.dryRun) { runtime.log( - `[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`, + `[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`, ); return; } - let mediaUrl: string | undefined; - if (opts.media) { - mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, { - serveMedia: Boolean(opts.serveMedia), - runtime, - }); + // 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`, + ), + ); } - const result = await deps.sendMessage( - opts.to, - opts.message, - { mediaUrl }, - runtime, - ); + // Fall back to direct connection (creates new Baileys socket) + const res = await deps + .sendMessageWeb(opts.to, opts.message, { + verbose: false, + mediaUrl: opts.media, + }) + .catch((err) => { + runtime.error(`❌ Web send failed: ${String(err)}`); + throw err; + }); if (opts.json) { runtime.log( JSON.stringify( { - provider: "twilio", + provider: "web", + via: "direct", to: opts.to, - sid: result?.sid ?? null, - mediaUrl: mediaUrl ?? null, + messageId: res.messageId, + mediaUrl: opts.media ?? null, }, null, 2, ), ); } - if (!result) return; - if (waitSeconds === 0) return; - await deps.waitForFinalStatus( - result.client, - result.sid, - waitSeconds, - pollSeconds, - runtime, - ); } diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index e12e27c1a..0185ab443 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -1,50 +1,51 @@ import { describe, expect, it, vi } from "vitest"; -import type { CliDeps } from "../cli/deps.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { statusCommand } from "./status.js"; - -vi.mock("../twilio/messages.js", () => ({ - formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`, +const mocks = vi.hoisted(() => ({ + loadSessionStore: vi.fn().mockReturnValue({ + "+1000": { updatedAt: Date.now() - 60_000 }, + }), + resolveStorePath: vi.fn().mockReturnValue("/tmp/sessions.json"), + webAuthExists: vi.fn().mockResolvedValue(true), + getWebAuthAgeMs: vi.fn().mockReturnValue(5000), + logWebSelfId: vi.fn(), })); -const runtime: RuntimeEnv = { +vi.mock("../config/sessions.js", () => ({ + loadSessionStore: mocks.loadSessionStore, + resolveStorePath: mocks.resolveStorePath, +})); +vi.mock("../web/session.js", () => ({ + webAuthExists: mocks.webAuthExists, + getWebAuthAgeMs: mocks.getWebAuthAgeMs, + logWebSelfId: mocks.logWebSelfId, +})); +vi.mock("../config/config.js", () => ({ + loadConfig: () => ({ inbound: { reply: { session: {} } } }), +})); + +import { statusCommand } from "./status.js"; + +const runtime = { log: vi.fn(), error: vi.fn(), - exit: vi.fn(() => { - throw new Error("exit"); - }), + exit: vi.fn(), }; -const deps: CliDeps = { - listRecentMessages: vi.fn(), -} as unknown as CliDeps; - describe("statusCommand", () => { - it("validates limit and lookback", async () => { - await expect( - statusCommand({ limit: "0", lookback: "10" }, deps, runtime), - ).rejects.toThrow("limit must be between 1 and 200"); - await expect( - statusCommand({ limit: "10", lookback: "0" }, deps, runtime), - ).rejects.toThrow("lookback must be > 0 minutes"); - }); - it("prints JSON when requested", async () => { - (deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]); - await statusCommand( - { limit: "5", lookback: "10", json: true }, - deps, - runtime, - ); - expect(runtime.log).toHaveBeenCalledWith( - JSON.stringify([{ sid: "1" }], null, 2), - ); + await statusCommand({ json: true }, runtime as never); + const payload = JSON.parse((runtime.log as vi.Mock).mock.calls[0][0]); + expect(payload.web.linked).toBe(true); + expect(payload.sessions.count).toBe(1); + expect(payload.sessions.path).toBe("/tmp/sessions.json"); }); it("prints formatted lines otherwise", async () => { - (deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]); - await statusCommand({ limit: "1", lookback: "5" }, deps, runtime); - expect(runtime.log).toHaveBeenCalledWith("LINE:123"); + (runtime.log as vi.Mock).mockClear(); + await statusCommand({}, runtime as never); + const logs = (runtime.log as vi.Mock).mock.calls.map((c) => String(c[0])); + expect(logs.some((l) => l.includes("Web session"))).toBe(true); + expect(logs.some((l) => l.includes("Active sessions"))).toBe(true); + expect(mocks.logWebSelfId).toHaveBeenCalled(); }); }); diff --git a/src/commands/status.ts b/src/commands/status.ts index 74b233a36..c7b52f1bc 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,31 +1,81 @@ -import type { CliDeps } from "../cli/deps.js"; +import { loadConfig } from "../config/config.js"; +import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; +import { info } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; -import { formatMessageLine } from "../twilio/messages.js"; +import { resolveHeartbeatSeconds } from "../web/reconnect.js"; +import { + getWebAuthAgeMs, + logWebSelfId, + webAuthExists, +} from "../web/session.js"; + +const formatAge = (ms: number | null | undefined) => { + if (!ms || ms < 0) return "unknown"; + const minutes = Math.round(ms / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours}h ago`; + const days = Math.round(hours / 24); + return `${days}d ago`; +}; export async function statusCommand( - opts: { limit: string; lookback: string; json?: boolean }, - deps: CliDeps, + opts: { json?: boolean }, runtime: RuntimeEnv, ) { - const limit = Number.parseInt(opts.limit, 10); - const lookbackMinutes = Number.parseInt(opts.lookback, 10); - if (Number.isNaN(limit) || limit <= 0 || limit > 200) { - throw new Error("limit must be between 1 and 200"); - } - if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) { - throw new Error("lookback must be > 0 minutes"); + const cfg = loadConfig(); + const linked = await webAuthExists(); + const authAgeMs = getWebAuthAgeMs(); + const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); + + const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store); + const store = loadSessionStore(storePath); + const sessions = Object.entries(store) + .filter(([key]) => key !== "global" && key !== "unknown") + .map(([key, entry]) => ({ key, updatedAt: entry?.updatedAt ?? 0 })) + .sort((a, b) => b.updatedAt - a.updatedAt); + const recent = sessions.slice(0, 5).map((s) => ({ + key: s.key, + updatedAt: s.updatedAt || null, + age: s.updatedAt ? Date.now() - s.updatedAt : null, + })); + + const summary = { + web: { + linked, + authAgeMs, + }, + heartbeatSeconds, + sessions: { + path: storePath, + count: sessions.length, + recent, + }, + } as const; + + if (opts.json) { + runtime.log(JSON.stringify(summary, null, 2)); + return; } - const messages = await deps.listRecentMessages(lookbackMinutes, limit); - if (opts.json) { - runtime.log(JSON.stringify(messages, null, 2)); - return; + runtime.log( + `Web session: ${linked ? "linked" : "not linked"}${linked ? ` (last refreshed ${formatAge(authAgeMs)})` : ""}`, + ); + if (linked) { + logWebSelfId(runtime, true); } - if (messages.length === 0) { - runtime.log("No messages found in the requested window."); - return; - } - for (const m of messages) { - runtime.log(formatMessageLine(m)); + runtime.log(info(`Heartbeat: ${heartbeatSeconds}s`)); + runtime.log(info(`Session store: ${storePath}`)); + runtime.log(info(`Active sessions: ${sessions.length}`)); + if (recent.length > 0) { + runtime.log("Recent sessions:"); + for (const r of recent) { + runtime.log( + `- ${r.key} (${r.updatedAt ? formatAge(Date.now() - r.updatedAt) : "no activity"})`, + ); + } + } else { + runtime.log("No session activity yet."); } } diff --git a/src/commands/up.test.ts b/src/commands/up.test.ts deleted file mode 100644 index 0b2d5eb25..000000000 --- a/src/commands/up.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { CliDeps } from "../cli/deps.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { upCommand } from "./up.js"; - -const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(() => { - throw new Error("exit"); - }), -}; - -const makeDeps = (): CliDeps => ({ - ensurePortAvailable: vi.fn().mockResolvedValue(undefined), - readEnv: vi.fn().mockReturnValue({ - whatsappFrom: "whatsapp:+1555", - whatsappSenderSid: "WW", - }), - ensureBinary: vi.fn().mockResolvedValue(undefined), - ensureFunnel: vi.fn().mockResolvedValue(undefined), - getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"), - startWebhook: vi.fn().mockResolvedValue({ server: true }), - createClient: vi.fn().mockReturnValue({ client: true }), - findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"), - updateWebhook: vi.fn().mockResolvedValue(undefined), -}); - -describe("upCommand", () => { - it("throws on invalid port", async () => { - await expect(() => - upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime), - ).rejects.toThrow("Port must be between 1 and 65535"); - }); - - it("performs dry run and returns mock data", async () => { - runtime.log.mockClear(); - const result = await upCommand( - { port: "42873", path: "/cb", dryRun: true }, - makeDeps(), - runtime, - ); - expect(runtime.log).toHaveBeenCalledWith( - "[dry-run] would enable funnel on port 42873", - ); - expect(result?.publicUrl).toBe("https://dry-run/cb"); - expect(result?.senderSid).toBeUndefined(); - }); - - it("enables funnel, starts webhook, and updates Twilio", async () => { - const deps = makeDeps(); - const res = await upCommand( - { port: "42873", path: "/hook", verbose: true }, - deps, - runtime, - ); - expect(deps.ensureBinary).toHaveBeenCalledWith( - "tailscale", - undefined, - runtime, - ); - expect(deps.ensureFunnel).toHaveBeenCalled(); - expect(deps.startWebhook).toHaveBeenCalled(); - expect(deps.updateWebhook).toHaveBeenCalledWith( - expect.anything(), - "SID123", - "https://tailnet-host/hook", - "POST", - runtime, - ); - expect(res?.publicUrl).toBe("https://tailnet-host/hook"); - // waiter is returned to keep the process alive in real use. - expect(typeof res?.waiter).toBe("function"); - }); -}); diff --git a/src/commands/up.ts b/src/commands/up.ts deleted file mode 100644 index 3003f98fd..000000000 --- a/src/commands/up.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { CliDeps } from "../cli/deps.js"; -import { waitForever as defaultWaitForever } from "../cli/wait.js"; -import { retryAsync } from "../infra/retry.js"; -import type { RuntimeEnv } from "../runtime.js"; - -export async function upCommand( - opts: { - port: string; - path: string; - verbose?: boolean; - yes?: boolean; - dryRun?: boolean; - }, - deps: CliDeps, - runtime: RuntimeEnv, - waiter: typeof defaultWaitForever = defaultWaitForever, -) { - const port = Number.parseInt(opts.port, 10); - if (Number.isNaN(port) || port <= 0 || port >= 65536) { - throw new Error("Port must be between 1 and 65535"); - } - - await deps.ensurePortAvailable(port); - const env = deps.readEnv(runtime); - if (opts.dryRun) { - runtime.log(`[dry-run] would enable funnel on port ${port}`); - runtime.log(`[dry-run] would start webhook at path ${opts.path}`); - runtime.log(`[dry-run] would update Twilio sender webhook`); - const publicUrl = `https://dry-run${opts.path}`; - return { server: undefined, publicUrl, senderSid: undefined, waiter }; - } - await deps.ensureBinary("tailscale", undefined, runtime); - await retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500); - const host = await deps.getTailnetHostname(); - const publicUrl = `https://${host}${opts.path}`; - runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`); - - const server = await retryAsync( - () => - deps.startWebhook( - port, - opts.path, - undefined, - Boolean(opts.verbose), - runtime, - ), - 3, - 300, - ); - - if (!deps.createClient) { - throw new Error("Twilio client dependency missing"); - } - const twilioClient = deps.createClient(env); - const senderSid = await deps.findWhatsappSenderSid( - twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient, - env.whatsappFrom, - env.whatsappSenderSid, - runtime, - ); - await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime); - - runtime.log( - "\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.", - ); - - return { server, publicUrl, senderSid, waiter }; -} diff --git a/src/commands/webhook.test.ts b/src/commands/webhook.test.ts deleted file mode 100644 index e1b040ae6..000000000 --- a/src/commands/webhook.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { CliDeps } from "../cli/deps.js"; -import type { RuntimeEnv } from "../runtime.js"; - -import { webhookCommand } from "./webhook.js"; - -const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(() => { - throw new Error("exit"); - }), -}; - -const deps: CliDeps = { - ensurePortAvailable: vi.fn().mockResolvedValue(undefined), - startWebhook: vi.fn().mockResolvedValue({ server: true }), -}; - -describe("webhookCommand", () => { - it("throws on invalid port", async () => { - await expect(() => - webhookCommand({ port: "70000", path: "/hook" }, deps, runtime), - ).rejects.toThrow("Port must be between 1 and 65535"); - }); - - it("logs dry run instead of starting server", async () => { - runtime.log.mockClear(); - const res = await webhookCommand( - { port: "42873", path: "/hook", reply: "dry-run", ingress: "none" }, - deps, - runtime, - ); - expect(res).toBeUndefined(); - expect(runtime.log).toHaveBeenCalledWith( - "[dry-run] would start webhook on port 42873 path /hook", - ); - }); - - it("starts webhook when valid", async () => { - const res = await webhookCommand( - { - port: "42873", - path: "/hook", - reply: "ok", - verbose: true, - ingress: "none", - }, - deps, - runtime, - ); - expect(deps.startWebhook).toHaveBeenCalledWith( - 42873, - "/hook", - "ok", - true, - runtime, - ); - expect(res).toEqual({ server: true }); - }); -}); diff --git a/src/commands/webhook.ts b/src/commands/webhook.ts deleted file mode 100644 index cb134e391..000000000 --- a/src/commands/webhook.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { CliDeps } from "../cli/deps.js"; -import { retryAsync } from "../infra/retry.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { upCommand } from "./up.js"; - -export async function webhookCommand( - opts: { - port: string; - path: string; - reply?: string; - verbose?: boolean; - yes?: boolean; - ingress?: "tailscale" | "none"; - dryRun?: boolean; - }, - deps: CliDeps, - runtime: RuntimeEnv, -) { - const port = Number.parseInt(opts.port, 10); - if (Number.isNaN(port) || port <= 0 || port >= 65536) { - throw new Error("Port must be between 1 and 65535"); - } - - const ingress = opts.ingress ?? "tailscale"; - - // Tailscale ingress: reuse the `up` flow (Funnel + Twilio webhook update). - if (ingress === "tailscale") { - const result = await upCommand( - { - port: opts.port, - path: opts.path, - verbose: opts.verbose, - yes: opts.yes, - dryRun: opts.dryRun, - }, - deps, - runtime, - ); - return result.server; - } - - // Local-only webhook (no ingress / no Twilio update). - await deps.ensurePortAvailable(port); - if (opts.reply === "dry-run" || opts.dryRun) { - runtime.log( - `[dry-run] would start webhook on port ${port} path ${opts.path}`, - ); - return undefined; - } - const server = await retryAsync( - () => - deps.startWebhook( - port, - opts.path, - opts.reply, - Boolean(opts.verbose), - runtime, - ), - 3, - 300, - ); - return server; -} diff --git a/src/config/config.ts b/src/config/config.ts index 410a0b984..5707660d2 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -94,25 +94,6 @@ export const CONFIG_PATH_CLAWDIS = path.join( ".clawdis", "clawdis.json", ); -// Legacy path (fallback for backward compatibility) -export const CONFIG_PATH_LEGACY = path.join( - os.homedir(), - ".warelay", - "warelay.json", -); -// Deprecated: kept for backward compatibility -export const CONFIG_PATH = CONFIG_PATH_LEGACY; - -/** - * Resolve which config path to use. - * Prefers new clawdis.json, falls back to warelay.json. - */ -function resolveConfigPath(): string { - if (fs.existsSync(CONFIG_PATH_CLAWDIS)) { - return CONFIG_PATH_CLAWDIS; - } - return CONFIG_PATH_LEGACY; -} const ReplySchema = z .object({ @@ -231,8 +212,7 @@ const WarelaySchema = z.object({ export function loadConfig(): WarelayConfig { // Read config file (JSON5) if present. - // Prefers ~/.clawdis/clawdis.json, falls back to ~/.warelay/warelay.json - const configPath = resolveConfigPath(); + const configPath = CONFIG_PATH_CLAWDIS; try { if (!fs.existsSync(configPath)) return {}; const raw = fs.readFileSync(configPath, "utf-8"); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 81a068c71..3be3621b1 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -17,14 +17,17 @@ export type SessionEntry = { verboseLevel?: string; }; -export const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json"); +export const SESSION_STORE_DEFAULT = path.join( + CONFIG_DIR, + "sessions", + "sessions.json", +); export const DEFAULT_RESET_TRIGGER = "/new"; export const DEFAULT_IDLE_MINUTES = 60; export function resolveStorePath(store?: string) { if (!store) return SESSION_STORE_DEFAULT; - if (store.startsWith("~")) - return path.resolve(store.replace("~", os.homedir())); + if (store.startsWith("~")) return path.resolve(store.replace("~", os.homedir())); return path.resolve(store); } diff --git a/src/env.test.ts b/src/env.test.ts deleted file mode 100644 index 7c1502096..000000000 --- a/src/env.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { ensureTwilioEnv, readEnv } from "./env.js"; -import type { RuntimeEnv } from "./runtime.js"; - -const baseEnv = { - TWILIO_ACCOUNT_SID: "AC123", - TWILIO_WHATSAPP_FROM: "whatsapp:+1555", -}; - -describe("env helpers", () => { - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(() => { - throw new Error("exit"); - }), - }; - - beforeEach(() => { - vi.clearAllMocks(); - process.env = {}; - }); - - function setEnv(vars: Record) { - process.env = {}; - for (const [k, v] of Object.entries(vars)) { - if (v === undefined) delete process.env[k]; - else process.env[k] = v; - } - } - - it("reads env with auth token", () => { - setEnv({ - ...baseEnv, - TWILIO_AUTH_TOKEN: "token", - TWILIO_API_KEY: undefined, - TWILIO_API_SECRET: undefined, - }); - const cfg = readEnv(runtime); - expect(cfg.accountSid).toBe("AC123"); - expect(cfg.whatsappFrom).toBe("whatsapp:+1555"); - if ("authToken" in cfg.auth) { - expect(cfg.auth.authToken).toBe("token"); - } else { - throw new Error("Expected auth token"); - } - }); - - it("reads env with API key/secret", () => { - setEnv({ - ...baseEnv, - TWILIO_AUTH_TOKEN: undefined, - TWILIO_API_KEY: "key", - TWILIO_API_SECRET: "secret", - }); - const cfg = readEnv(runtime); - if ("apiKey" in cfg.auth && "apiSecret" in cfg.auth) { - expect(cfg.auth.apiKey).toBe("key"); - expect(cfg.auth.apiSecret).toBe("secret"); - } else { - throw new Error("Expected API key/secret"); - } - }); - - it("fails fast on invalid env", () => { - setEnv({ - TWILIO_ACCOUNT_SID: "", - TWILIO_WHATSAPP_FROM: "", - TWILIO_AUTH_TOKEN: undefined, - TWILIO_API_KEY: undefined, - TWILIO_API_SECRET: undefined, - }); - expect(() => readEnv(runtime)).toThrow("exit"); - expect(runtime.error).toHaveBeenCalled(); - }); - - it("ensureTwilioEnv passes when token present", () => { - setEnv({ - ...baseEnv, - TWILIO_AUTH_TOKEN: "token", - TWILIO_API_KEY: undefined, - TWILIO_API_SECRET: undefined, - }); - expect(() => ensureTwilioEnv(runtime)).not.toThrow(); - }); - - it("ensureTwilioEnv fails when missing auth", () => { - setEnv({ - ...baseEnv, - TWILIO_AUTH_TOKEN: undefined, - TWILIO_API_KEY: undefined, - TWILIO_API_SECRET: undefined, - }); - expect(() => ensureTwilioEnv(runtime)).toThrow("exit"); - }); -}); diff --git a/src/env.ts b/src/env.ts deleted file mode 100644 index 9061ff948..000000000 --- a/src/env.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { z } from "zod"; - -import { danger } from "./globals.js"; -import { defaultRuntime, type RuntimeEnv } from "./runtime.js"; - -export type AuthMode = - | { accountSid: string; authToken: string } - | { accountSid: string; apiKey: string; apiSecret: string }; - -export type EnvConfig = { - accountSid: string; - whatsappFrom: string; - whatsappSenderSid?: string; - auth: AuthMode; -}; - -const EnvSchema = z - .object({ - TWILIO_ACCOUNT_SID: z.string().min(1, "TWILIO_ACCOUNT_SID required"), - TWILIO_WHATSAPP_FROM: z.string().min(1, "TWILIO_WHATSAPP_FROM required"), - TWILIO_SENDER_SID: z.string().optional(), - TWILIO_AUTH_TOKEN: z.string().optional(), - TWILIO_API_KEY: z.string().optional(), - TWILIO_API_SECRET: z.string().optional(), - }) - .superRefine((val, ctx) => { - if (val.TWILIO_API_KEY && !val.TWILIO_API_SECRET) { - ctx.addIssue({ - code: "custom", - message: "TWILIO_API_SECRET required when TWILIO_API_KEY is set", - }); - } - if (val.TWILIO_API_SECRET && !val.TWILIO_API_KEY) { - ctx.addIssue({ - code: "custom", - message: "TWILIO_API_KEY required when TWILIO_API_SECRET is set", - }); - } - if ( - !val.TWILIO_AUTH_TOKEN && - !(val.TWILIO_API_KEY && val.TWILIO_API_SECRET) - ) { - ctx.addIssue({ - code: "custom", - message: - "Provide TWILIO_AUTH_TOKEN or both TWILIO_API_KEY and TWILIO_API_SECRET", - }); - } - }); - -export function readEnv(runtime: RuntimeEnv = defaultRuntime): EnvConfig { - // Load and validate Twilio auth + sender configuration from env. - const parsed = EnvSchema.safeParse(process.env); - if (!parsed.success) { - runtime.error("Invalid environment configuration:"); - parsed.error.issues.forEach((iss) => { - runtime.error(`- ${iss.message}`); - }); - runtime.exit(1); - } - - const { - TWILIO_ACCOUNT_SID: accountSid, - TWILIO_WHATSAPP_FROM: whatsappFrom, - TWILIO_SENDER_SID: whatsappSenderSid, - TWILIO_AUTH_TOKEN: authToken, - TWILIO_API_KEY: apiKey, - TWILIO_API_SECRET: apiSecret, - } = parsed.data; - - let auth: AuthMode; - if (apiKey && apiSecret) { - auth = { accountSid, apiKey, apiSecret }; - } else if (authToken) { - auth = { accountSid, authToken }; - } else { - runtime.error("Missing Twilio auth configuration"); - runtime.exit(1); - throw new Error("unreachable"); - } - - return { - accountSid, - whatsappFrom, - whatsappSenderSid, - auth, - }; -} - -export function ensureTwilioEnv(runtime: RuntimeEnv = defaultRuntime) { - // Guardrails: fail fast when Twilio env vars are missing or incomplete. - const required = ["TWILIO_ACCOUNT_SID", "TWILIO_WHATSAPP_FROM"]; - const missing = required.filter((k) => !process.env[k]); - const hasToken = Boolean(process.env.TWILIO_AUTH_TOKEN); - const hasKey = Boolean( - process.env.TWILIO_API_KEY && process.env.TWILIO_API_SECRET, - ); - if (missing.length > 0 || (!hasToken && !hasKey)) { - runtime.error( - danger( - `Missing Twilio env: ${missing.join(", ") || "auth token or api key/secret"}. Set them in .env before using provider=twilio.`, - ), - ); - runtime.exit(1); - } -} diff --git a/src/index.commands.test.ts b/src/index.commands.test.ts deleted file mode 100644 index 4cf8e5091..000000000 --- a/src/index.commands.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createMockTwilio } from "../test/mocks/twilio.js"; -import { statusCommand } from "./commands/status.js"; -import { createDefaultDeps } from "./index.js"; -import * as providerWeb from "./provider-web.js"; -import { defaultRuntime } from "./runtime.js"; - -vi.mock("twilio", () => { - const { factory } = createMockTwilio(); - return { default: factory }; -}); - -import * as index from "./index.js"; -import * as provider from "./provider-web.js"; - -beforeEach(() => { - index.program.exitOverride(); - process.env.TWILIO_ACCOUNT_SID = "AC123"; - process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567"; - process.env.TWILIO_AUTH_TOKEN = "token"; - vi.clearAllMocks(); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("CLI commands", () => { - it("exposes login command", () => { - const names = index.program.commands.map((c) => c.name()); - expect(names).toContain("login"); - }); - - it("send command routes to web provider", async () => { - const sendWeb = vi.spyOn(provider, "sendMessageWeb").mockResolvedValue(); - await index.program.parseAsync( - [ - "send", - "--to", - "+1555", - "--message", - "hi", - "--provider", - "web", - "--wait", - "0", - ], - { from: "user" }, - ); - expect(sendWeb).toHaveBeenCalled(); - }); - - it("send command uses twilio path when provider=twilio", async () => { - const twilio = (await import("twilio")).default; - twilio._client.messages.create.mockResolvedValue({ sid: "SM1" }); - const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue(); - await index.program.parseAsync( - ["send", "--to", "+1555", "--message", "hi", "--wait", "0"], - { from: "user" }, - ); - expect(twilio._client.messages.create).toHaveBeenCalled(); - expect(wait).not.toHaveBeenCalled(); - }); - - it("send command supports dry-run and skips sending", async () => { - const twilio = (await import("twilio")).default; - const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue(); - await index.program.parseAsync( - ["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--dry-run"], - { from: "user" }, - ); - expect(twilio._client.messages.create).not.toHaveBeenCalled(); - expect(wait).not.toHaveBeenCalled(); - }); - - it("send command outputs JSON when requested", async () => { - const twilio = (await import("twilio")).default; - twilio._client.messages.create.mockResolvedValue({ sid: "SMJSON" }); - const logSpy = vi.spyOn(defaultRuntime, "log"); - await index.program.parseAsync( - ["send", "--to", "+1555", "--message", "hi", "--wait", "0", "--json"], - { from: "user" }, - ); - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining('"sid": "SMJSON"'), - ); - }); - - it("login command calls web login", async () => { - const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue(); - await index.program.parseAsync(["login"], { from: "user" }); - expect(spy).toHaveBeenCalled(); - }); - - it("status command prints JSON", async () => { - const twilio = (await import("twilio")).default; - twilio._client.messages.list - .mockResolvedValueOnce([ - { - sid: "1", - status: "delivered", - direction: "inbound", - dateCreated: new Date("2024-01-01T00:00:00Z"), - from: "a", - to: "b", - body: "hi", - errorCode: null, - errorMessage: null, - }, - ]) - .mockResolvedValueOnce([ - { - sid: "2", - status: "sent", - direction: "outbound-api", - dateCreated: new Date("2024-01-02T00:00:00Z"), - from: "b", - to: "a", - body: "yo", - errorCode: null, - errorMessage: null, - }, - ]); - const runtime = { - ...defaultRuntime, - log: vi.fn(), - error: vi.fn(), - exit: ((code: number) => { - throw new Error(`exit ${code}`); - }) as (code: number) => never, - }; - await statusCommand( - { limit: "1", lookback: "10", json: true }, - createDefaultDeps(), - runtime, - ); - expect(runtime.log).toHaveBeenCalled(); - }); -}); diff --git a/src/index.core.test.ts b/src/index.core.test.ts deleted file mode 100644 index 4d6b9890f..000000000 --- a/src/index.core.test.ts +++ /dev/null @@ -1,2037 +0,0 @@ -import crypto from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; -import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createMockTwilio } from "../test/mocks/twilio.js"; -import * as exec from "./process/exec.js"; -import * as tauRpc from "./process/tau-rpc.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(); - return { default: factory }; -}); - -type TwilioFactoryMock = ReturnType["factory"]; -const twilioFactory = (await import("twilio")).default as TwilioFactoryMock; - -import * as index from "./index.js"; -import { splitMediaFromOutput } from "./media/parse.js"; - -const envBackup = { ...process.env } as Record; - -beforeEach(() => { - process.env.TWILIO_ACCOUNT_SID = "AC123"; - process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+15551234567"; - process.env.TWILIO_AUTH_TOKEN = "token"; - delete process.env.TWILIO_API_KEY; - delete process.env.TWILIO_API_SECRET; - vi.clearAllMocks(); -}); - -afterEach(() => { - Object.entries(envBackup).forEach(([k, v]) => { - if (v === undefined) { - delete process.env[k]; - } else { - process.env[k] = v; - } - }); - vi.restoreAllMocks(); -}); - -describe("command helpers", () => { - it("runCommandWithTimeout captures stdout and timeout", async () => { - const result = await index.runCommandWithTimeout( - [process.execPath, "-e", "console.log('ok')"], - 500, - ); - expect(result.stdout.trim()).toBe("ok"); - - const slow = index.runCommandWithTimeout( - [process.execPath, "-e", "setTimeout(()=>{}, 1000)"], - 20, - ); - const timedOut = await slow; - expect(timedOut.killed).toBe(true); - }); - - it("ensurePortAvailable rejects when in use", async () => { - const server = net.createServer(); - await new Promise((resolve) => server.listen(0, resolve)); - const port = (server.address() as net.AddressInfo).port; - await expect(index.ensurePortAvailable(port)).rejects.toBeInstanceOf( - index.PortInUseError, - ); - server.close(); - }); -}); - -describe("config and templating", () => { - it("getReplyFromConfig returns text when allowlist passes", async () => { - const cfg = { - inbound: { - allowFrom: ["+1555"], - reply: { - mode: "text" as const, - text: "Hello {{From}} {{Body}}", - bodyPrefix: "[pfx] ", - }, - }, - }; - - const onReplyStart = vi.fn(); - const result = await index.getReplyFromConfig( - { Body: "hi", From: "whatsapp:+1555", To: "x" }, - { onReplyStart }, - cfg, - ); - expect(result?.text).toBe("Hello whatsapp:+1555 [pfx] hi"); - 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 allows group chats even when not in allowFrom", async () => { - const cfg = { - inbound: { - allowFrom: ["+9999"], - reply: { - mode: "text" as const, - text: "Group: {{From}}", - }, - }, - }; - - const result = await index.getReplyFromConfig( - { Body: "hello", From: "120363422899103675@g.us", To: "+4475" }, - undefined, - cfg, - ); - expect(result?.text).toBe("Group: 120363422899103675@g.us"); - }); - - 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 runSpy = vi.fn().mockResolvedValue({ - stdout: - "/tmp/a.jpg\nimage/jpeg\nhttp://example.com/a.jpg\nMEDIA:https://example.com/a.jpg", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - }, - }, - }; - const result = await index.getReplyFromConfig( - { - Body: "", - From: "+1", - To: "+2", - MediaPath: "/tmp/a.jpg", - MediaType: "image/jpeg", - MediaUrl: "http://example.com/a.jpg", - }, - undefined, - cfg, - runSpy, - ); - expect(result?.text).toContain("/tmp/a.jpg"); - expect(result?.text).toContain("image/jpeg"); - expect(result?.text).toContain("http://example.com/a.jpg"); - }); - - it("getReplyFromConfig runs audio transcription command when configured", async () => { - const cfg = { - inbound: { - transcribeAudio: { - command: ["echo", "voice transcript"], - }, - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - }, - }, - }; - - const runExec = vi.spyOn(exec, "runExec").mockResolvedValue({ - stdout: "voice transcript\n", - stderr: "", - }); - const commandRunner = vi.fn().mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - - const result = await index.getReplyFromConfig( - { - Body: "", - From: "+1", - To: "+2", - MediaPath: "/tmp/voice.ogg", - MediaType: "audio/ogg", - }, - undefined, - cfg, - commandRunner, - ); - - expect(runExec).toHaveBeenCalled(); - expect(commandRunner).toHaveBeenCalled(); - const argv = commandRunner.mock.calls[0][0]; - const prompt = argv[argv.length - 1] as string; - expect(prompt).toContain("/tmp/voice.ogg"); - expect(prompt).toContain("Transcript:"); - expect(prompt).toContain("voice transcript"); - expect(result?.text).toBe("ok"); - }); - - it("getReplyFromConfig skips transcription when not configured", async () => { - const cfg = { - inbound: { - reply: { - mode: "text" as const, - text: "{{Body}}", - }, - }, - }; - - const runExec = vi.spyOn(exec, "runExec"); - const result = await index.getReplyFromConfig( - { - Body: "", - From: "+1", - To: "+2", - MediaPath: "/tmp/voice.ogg", - MediaType: "audio/ogg", - }, - undefined, - cfg, - ); - - expect(runExec).not.toHaveBeenCalled(); - expect(result?.text).toContain("/tmp/voice.ogg"); - expect(result?.text).toContain(""); - }); - - it("getReplyFromConfig extracts media URL from command stdout", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "hello\nMEDIA: https://example.com/img.jpg\n", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - }, - }, - }; - const result = await index.getReplyFromConfig( - { - Body: "hi", - From: "+1", - To: "+2", - }, - undefined, - cfg, - runSpy, - ); - expect(result?.text).toBe("hello"); - expect(result?.mediaUrl).toBe("https://example.com/img.jpg"); - }); - - it("extracts first MEDIA token even with trailing text", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "hello\nMEDIA:/tmp/pic.png extra words here\n", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - }, - }, - }; - const result = await index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - expect(result?.mediaUrl).toBe("/tmp/pic.png"); - }); - - it("extracts MEDIA token inline within a sentence", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "caption before MEDIA:/tmp/pic.png caption after", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - }, - }, - }; - const result = await index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - expect(result?.mediaUrl).toBe("/tmp/pic.png"); - expect(result?.text).toBe("caption before caption after"); - }); - - it("uses heartbeatCommand only for heartbeat polls", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "normal {{Body}}"], - heartbeatCommand: ["echo", "heartbeat {{Body}}"], - }, - }, - }; - - await index.getReplyFromConfig( - { Body: "PING", From: "+1", To: "+2" }, - { isHeartbeat: true }, - cfg, - runSpy, - ); - expect(runSpy).toHaveBeenCalledWith( - ["echo", "heartbeat PING"], - expect.any(Object), - ); - }); - - it("falls back to default command for non-heartbeat calls", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "normal {{Body}}"], - heartbeatCommand: ["echo", "heartbeat {{Body}}"], - }, - }, - }; - - await index.getReplyFromConfig( - { Body: "PING", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - expect(runSpy).toHaveBeenCalledWith( - ["echo", "normal PING"], - expect.any(Object), - ); - }); - - it("captures MEDIA wrapped in backticks", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "MEDIA:`/tmp/pic.png` cool", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - }, - }, - }; - const result = await index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - expect(result?.mediaUrl).toBe("/tmp/pic.png"); - }); - - it("captures MEDIA token with trailing JSON characters", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: 'MEDIA:/tmp/pic.png"} trailing', - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - }, - }, - }; - const result = await index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - expect(result?.mediaUrl).toBe("/tmp/pic.png"); - }); - - it("injects --thinking for pi when /think directive is present", async () => { - const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "--mode", "json", "{{Body}}"], - agent: { kind: "pi" }, - }, - }, - }; - await index.getReplyFromConfig( - { Body: "/think:high hello", From: "+1", To: "+2" }, - undefined, - cfg, - ); - expect(rpcSpy).toHaveBeenCalled(); - const args = rpcSpy.mock.calls[0][0].argv; - expect(args).toContain("--thinking"); - expect(args).toContain("high"); - expect(rpcSpy.mock.calls[0][0].prompt).toBe("hello"); - }); - - it("treats /think:off as no-op for pi (no --thinking injected)", async () => { - const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "--mode", "json", "{{Body}}"], - agent: { kind: "pi" }, - }, - }, - }; - await index.getReplyFromConfig( - { Body: "/think:off hello", From: "+1", To: "+2" }, - undefined, - cfg, - ); - expect(rpcSpy).toHaveBeenCalled(); - const args = rpcSpy.mock.calls[0][0].argv; - expect(args).not.toContain("--thinking"); - expect(rpcSpy.mock.calls[0][0].prompt).toBe("hello"); - }); - - it("confirms directive-only think level and skips command", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - }, - }, - }; - - const ack = await index.getReplyFromConfig( - { Body: "/thinking high", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - - expect(runSpy).not.toHaveBeenCalled(); - expect(ack?.text).toBe("Thinking level set to high."); - }); - - it("enables verbose via directive-only and skips command", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - }, - }, - }; - - const _ack = await index.getReplyFromConfig( - { Body: "/v:on", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - - // Directive may short-circuit or proceed; any behavior is fine as long as thinking persists. - }); - - it("rejects invalid verbose directive-only and preserves state", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const storeDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "warelay-session-"), - ); - const storePath = path.join(storeDir, "sessions.json"); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - session: { store: storePath }, - }, - }, - }; - - const ack = await index.getReplyFromConfig( - { Body: "/verbose maybe", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - - expect(runSpy).not.toHaveBeenCalled(); - expect(ack?.text).toContain("Unrecognized verbose level"); - - await index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - expect(runSpy).toHaveBeenCalledTimes(1); - const args = runSpy.mock.calls[0][0] as string[]; - const bodyArg = args[args.length - 1]; - expect(bodyArg).toBe("hi"); - }); - - it("shows tool results when verbose is on for pi", async () => { - const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ - stdout: - '{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"summary"}]}}\n' + - '{"type":"message_end","message":{"role":"tool_result","name":"bash","details":{"command":"ls"},"content":[{"type":"text","text":"ls output"}]}}', - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "--mode", "json", "{{Body}}"], - agent: { kind: "pi" }, - }, - }, - }; - - const res = await index.getReplyFromConfig( - { Body: "/v on hi", From: "+1", To: "+2" }, - undefined, - cfg, - ); - - expect(rpcSpy).toHaveBeenCalled(); - const payloads = Array.isArray(res) ? res : res ? [res] : []; - expect(payloads.length).toBeGreaterThanOrEqual(2); - expect(payloads[0]?.text).toBe("💻 ls — “ls output”"); - expect(payloads[1]?.text).toContain("summary"); - }); - - it("prepends session hint when new session and verbose on", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - vi.spyOn(crypto, "randomUUID").mockReturnValue("sess-uuid"); - const storeDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "warelay-session-"), - ); - const storePath = path.join(storeDir, "sessions.json"); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - session: { store: storePath }, - }, - }, - }; - - const res = await index.getReplyFromConfig( - { Body: "/new /v on hi", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - - const payloads = Array.isArray(res) ? res : res ? [res] : []; - expect(payloads[0]?.text).toBe("🧭 New session: sess-uuid"); - expect(payloads[1]?.text).toBe("ok"); - }); - - it("treats verbose directive-only inside group batch context", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const storeDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "warelay-session-"), - ); - const storePath = path.join(storeDir, "sessions.json"); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - session: { store: storePath }, - }, - }, - }; - - const batchBody = - "[Chat messages since your last reply - for context]\nAlice: hi\n\n[Current message - respond to this]\nBob: /v on\n[from: Bob (+222)]"; - - const ack = await index.getReplyFromConfig( - { - Body: batchBody, - From: "group:123@g.us", - To: "+2", - }, - undefined, - cfg, - runSpy, - ); - - // Combined directive may already persist and return ack; command should not be required, - // but if it runs, we still validate persistence on next turn. - expect(ack?.text).toBeDefined(); - - await index.getReplyFromConfig( - { Body: "hello", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - - expect(runSpy).toHaveBeenCalledTimes(1); - const args = runSpy.mock.calls[0][0] as string[]; - const bodyArg = args[args.length - 1]; - expect(bodyArg).toBe("hello"); - }); - - it("treats think directive-only with mentions in group batch context", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const storeDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "warelay-session-"), - ); - const storePath = path.join(storeDir, "sessions.json"); - const cfg = { - inbound: { - groupChat: { - mentionPatterns: ["@clawd", "\\\\+447511247203"], - }, - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - session: { store: storePath }, - }, - }, - }; - - const batchBody = - "[Current message - respond to this]\nPeter: @2350001479733 /thinking low"; - - const ack = await index.getReplyFromConfig( - { - Body: batchBody, - From: "group:123@g.us", - To: "+447511247203", - }, - undefined, - cfg, - runSpy, - ); - - expect(runSpy).not.toHaveBeenCalled(); - expect(ack?.text).toBe("Thinking level set to low."); - }); - - it("treats combined verbose+thinking directives with mention in group batch context", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const storeDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "warelay-session-"), - ); - const storePath = path.join(storeDir, "sessions.json"); - const cfg = { - inbound: { - groupChat: { - mentionPatterns: ["@clawd", "\\\\+447511247203", "clawd\\s*uk"], - }, - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - session: { store: storePath }, - }, - }, - }; - - const batchBody = - "[Current message - respond to this]\nPeter: @Clawd UK /thinking medium /v on"; - - const _ack = await index.getReplyFromConfig( - { - Body: batchBody, - From: "group:456@g.us", - To: "+447511247203", - }, - undefined, - cfg, - runSpy, - ); - - // Next message should inject persisted thinking=medium and verbose=on - await index.getReplyFromConfig( - { Body: "hello", From: "group:456@g.us", To: "+447511247203" }, - undefined, - cfg, - runSpy, - ); - const persisted = JSON.parse( - await fs.promises.readFile(storePath, "utf-8"), - ) as Record; - const _entry = Object.values(persisted)[0] as { - thinkingLevel?: string; - verboseLevel?: string; - }; - }); - - it("ignores directive-only when mention pattern doesn’t match self", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const storeDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "warelay-session-"), - ); - const storePath = path.join(storeDir, "sessions.json"); - const cfg = { - inbound: { - groupChat: { - mentionPatterns: ["@clawd"], // no match for @someoneelse - }, - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - session: { store: storePath }, - }, - }, - }; - - const batchBody = - "[Current message - respond to this]\nUser: @someoneelse /thinking high"; - - const res = await index.getReplyFromConfig( - { Body: batchBody, From: "group:789@g.us", To: "+447511247203" }, - undefined, - cfg, - runSpy, - ); - - // Because mention doesn’t match, it’s treated as normal text and forwarded. - expect(res?.text).toBe("ok"); - expect(runSpy).toHaveBeenCalledTimes(1); - }); - - it("rejects invalid directive-only think level without changing state", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const storeDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "warelay-session-"), - ); - const storePath = path.join(storeDir, "sessions.json"); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - session: { store: storePath }, - }, - }, - }; - - const ack = await index.getReplyFromConfig( - { Body: "/thinking big", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - - expect(runSpy).not.toHaveBeenCalled(); - expect(ack?.text).toContain('Unrecognized thinking level "big"'); - - // Send another message; state should not carry any level. - const second = await index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - expect(runSpy).toHaveBeenCalledTimes(1); - const args = runSpy.mock.calls[0][0] as string[]; - const bodyArg = args[args.length - 1]; - expect(bodyArg).toBe("hi"); - expect(second?.text).toBe("ok"); - }); - - it("uses global thinkingDefault when no directive or session override", async () => { - const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - thinkingDefault: "low" as const, - }, - }, - }; - await index.getReplyFromConfig( - { Body: "hello", From: "+1", To: "+2" }, - undefined, - cfg, - ); - expect(rpcSpy).toHaveBeenCalled(); - const args = rpcSpy.mock.calls[0][0].argv; - expect(args).toContain("--thinking"); - expect(args).toContain("low"); - }); - - it("accepts spaced directive form '/think high' and passes level to pi", async () => { - const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - }, - }, - }; - await index.getReplyFromConfig( - { Body: "/think high hello world", From: "+1", To: "+2" }, - undefined, - cfg, - ); - expect(rpcSpy).toHaveBeenCalled(); - const args = rpcSpy.mock.calls[0][0].argv; - expect(args).toContain("--thinking"); - expect(args).toContain("high"); - expect(rpcSpy.mock.calls[0][0].prompt).toBe("hello world"); - }); - - it("accepts shorthand '/t:medium' and passes level to pi", async () => { - const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - }, - }, - }; - await index.getReplyFromConfig( - { Body: "/t:medium greetings", From: "+1", To: "+2" }, - undefined, - cfg, - ); - expect(rpcSpy).toHaveBeenCalled(); - const args = rpcSpy.mock.calls[0][0].argv; - expect(args).toContain("--thinking"); - expect(args).toContain("medium"); - expect(rpcSpy.mock.calls[0][0].prompt).toBe("greetings"); - }); - - it("stores session thinking for pi and injects on next message", async () => { - const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const storeDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "warelay-session-"), - ); - const storePath = path.join(storeDir, "sessions.json"); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "--mode", "json", "{{Body}}"], - agent: { kind: "pi" }, - session: { store: storePath }, - }, - }, - }; - - await index.getReplyFromConfig( - { Body: "/thinking max", From: "+1", To: "+2" }, - undefined, - cfg, - ); - - await index.getReplyFromConfig( - { Body: "next run", From: "+1", To: "+2" }, - undefined, - cfg, - ); - - expect(rpcSpy).toHaveBeenCalled(); - const args = rpcSpy.mock.calls[0][0].argv; - expect(args).toContain("--thinking"); - expect(args).toContain("high"); - }); - - it("clears stored thinking when directive-only /think:off is sent", async () => { - const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const storeDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "warelay-session-"), - ); - const storePath = path.join(storeDir, "sessions.json"); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "--mode", "json", "{{Body}}"], - agent: { kind: "pi" }, - session: { store: storePath }, - }, - }, - }; - - await index.getReplyFromConfig( - { Body: "/think:medium", From: "+1", To: "+2" }, - undefined, - cfg, - ); - await index.getReplyFromConfig( - { Body: "/think:off", From: "+1", To: "+2" }, - undefined, - cfg, - ); - rpcSpy.mockClear(); - await index.getReplyFromConfig( - { Body: "plain text", From: "+1", To: "+2" }, - undefined, - cfg, - ); - expect(rpcSpy).toHaveBeenCalled(); - const args = rpcSpy.mock.calls[0][0].argv; - expect(args).not.toContain("--thinking"); - }); - - it("ignores invalid MEDIA lines with whitespace", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "hello\nMEDIA: not a url with spaces\nrest\n", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - }, - }, - }; - const result = await index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - expect(result?.text).toBe("hello\nrest"); - expect(result?.mediaUrl).toBeUndefined(); - }); - - it("injects fallback text when command returns nothing", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - }, - }, - }; - const result = await index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - expect(result?.text).toContain("command produced no output"); - expect(result?.mediaUrl).toBeUndefined(); - }); - - it("returns timeout reply with partial stdout snippet", async () => { - const partial = "x".repeat(900); - vi.spyOn(tauRpc, "runPiRpc").mockRejectedValue({ - killed: true, - signal: "SIGKILL", - stdout: partial, - stderr: "", - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "{{Body}}"], - timeoutSeconds: 42, - }, - }, - }; - - const result = await index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - undefined, - cfg, - ); - - expect(result?.text).toContain("Command timed out after 42s"); - expect(result?.text).toContain("Partial output before timeout"); - expect(result?.text).toContain(`${partial.slice(0, 800)}...`); - expect(result?.text).not.toContain(partial); - }); - - it("returns timeout reply without partial output when none is available", async () => { - vi.spyOn(tauRpc, "runPiRpc").mockRejectedValue({ - killed: true, - signal: "SIGKILL", - stdout: "", - stderr: "", - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "{{Body}}"], - timeoutSeconds: 5, - }, - }, - }; - - const result = await index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - undefined, - cfg, - ); - - expect(result?.text).toBe( - "Command timed out after 5s. Try a shorter prompt or split the request.", - ); - }); - - it("splitMediaFromOutput strips media token and preserves text", () => { - const { text, mediaUrl } = splitMediaFromOutput( - "line1\nMEDIA:https://x/y.png\nline2", - ); - expect(mediaUrl).toBe("https://x/y.png"); - expect(text).toBe("line1\nline2"); - }); - - it("getReplyFromConfig runs command and manages session store", async () => { - const tmpStore = path.join(os.tmpdir(), `warelay-store-${Date.now()}.json`); - vi.spyOn(crypto, "randomUUID").mockReturnValue("session-123"); - const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ - stdout: "cmd output\n", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "{{Body}}"], - template: "[tmpl]", - session: { - scope: "per-sender" as const, - resetTriggers: ["/new"], - store: tmpStore, - sessionArgNew: ["--sid", "{{SessionId}}"], - sessionArgResume: ["--resume", "{{SessionId}}"], - }, - }, - }, - }; - - const first = await index.getReplyFromConfig( - { Body: "/new hello", From: "+1555", To: "+1666" }, - undefined, - cfg, - ); - expect(first?.text).toBe("cmd output"); - const argvFirst = rpcSpy.mock.calls[0]?.[0]; - expect(argvFirst?.argv).toEqual( - expect.arrayContaining(["pi", "[tmpl]", "--sid", "session-123", "-p"]), - ); - expect(argvFirst?.prompt).toBe("hello"); - - const second = await index.getReplyFromConfig( - { Body: "next", From: "+1555", To: "+1666" }, - undefined, - cfg, - ); - expect(second?.text).toBe("cmd output"); - const argvSecond = rpcSpy.mock.calls[1]?.[0]; - expect(argvSecond?.argv).toContain("--resume"); - expect(argvSecond?.prompt).toBe("next"); - }); - - it("only sends system prompt once per session when configured", async () => { - const tmpStore = path.join(os.tmpdir(), `warelay-store-${Date.now()}.json`); - vi.spyOn(crypto, "randomUUID").mockReturnValue("sid-1"); - const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ - stdout: '"ok"', - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "{{Body}}"], - template: "[tmpl]", - bodyPrefix: "[pfx] ", - session: { - sendSystemOnce: true, - sessionIntro: "SYS", - store: tmpStore, - sessionArgNew: ["--sid", "{{SessionId}}"], - sessionArgResume: ["--resume", "{{SessionId}}"], - }, - }, - }, - }; - - await index.getReplyFromConfig( - { Body: "/new hi", From: "+1", To: "+2" }, - undefined, - cfg, - ); - await index.getReplyFromConfig( - { Body: "next", From: "+1", To: "+2" }, - undefined, - cfg, - ); - - const firstArgv = rpcSpy.mock.calls[0]?.[0]; - expect(firstArgv?.argv).toEqual( - expect.arrayContaining(["pi", "[tmpl]", "--sid", "sid-1", "-p"]), - ); - expect(firstArgv?.prompt).toBe("SYS\n\n[pfx] hi"); - - const secondArgv = rpcSpy.mock.calls[1]?.[0]; - expect(secondArgv?.argv).toContain("--resume"); - expect(secondArgv?.prompt).toBe("next"); - - expect(rpcSpy).toHaveBeenCalledTimes(2); - - const persisted = JSON.parse(fs.readFileSync(tmpStore, "utf-8")); - const firstEntry = Object.values(persisted)[0] as { systemSent?: boolean }; - expect(typeof firstEntry.systemSent).toBe("boolean"); - }); - - it("keeps sending system prompt when sendSystemOnce is disabled (default)", async () => { - const rpcSpy = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ - stdout: '"ok"', - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "{{Body}}"], - bodyPrefix: "[sys] ", - session: { - scope: "per-sender" as const, - resetTriggers: ["/new"], - idleMinutes: 60, - }, - }, - }, - }; - - await index.getReplyFromConfig( - { Body: "/new hi", From: "+1", To: "+2" }, - undefined, - cfg, - ); - await index.getReplyFromConfig( - { Body: "next", From: "+1", To: "+2" }, - undefined, - cfg, - ); - - const firstArgv = rpcSpy.mock.calls[0]?.[0]; - expect(firstArgv?.prompt).toBe("[sys] hi"); - - const secondArgv = rpcSpy.mock.calls[1]?.[0]; - expect(secondArgv?.prompt).toBe("[sys] next"); - }); - - it("aborts command when stop word is received and skips command runner", async () => { - const tmpStore = path.join( - os.tmpdir(), - `warelay-store-${Date.now()}-abort.json`, - ); - const runSpy = vi.fn().mockResolvedValue({ - stdout: "should-not-run", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "{{Body}}"], - session: { store: tmpStore }, - }, - }, - }; - - const result = await index.getReplyFromConfig( - { Body: "stop", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - - expect(result?.text).toMatch(/aborted/i); - expect(runSpy).not.toHaveBeenCalled(); - const persisted = JSON.parse(fs.readFileSync(tmpStore, "utf-8")); - const entry = Object.values(persisted)[0] as { abortedLastRun?: boolean }; - expect(entry.abortedLastRun).toBe(true); - }); - - it("adds an abort hint to the next prompt and then clears the flag", async () => { - const tmpStore = path.join( - os.tmpdir(), - `warelay-store-${Date.now()}-aborthint.json`, - ); - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "ok\n", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - session: { store: tmpStore }, - }, - }, - }; - - await index.getReplyFromConfig( - { Body: "abort", From: "+1555", To: "+2666" }, - undefined, - cfg, - ); - - const result = await index.getReplyFromConfig( - { Body: "continue", From: "+1555", To: "+2666" }, - undefined, - cfg, - runSpy, - ); - - const argv = runSpy.mock.calls[0][0]; - const prompt = argv.at(-1) as string; - expect(prompt).toMatch(/previous agent run was aborted/i); - expect(prompt).toMatch(/continue/); - const persisted = JSON.parse(fs.readFileSync(tmpStore, "utf-8")); - const entry = Object.values(persisted)[0] as { abortedLastRun?: boolean }; - expect(entry.abortedLastRun).toBe(false); - expect(result?.text).toBe("ok"); - }); - - it("refreshes typing indicator while command runs", async () => { - const onReplyStart = vi.fn(); - vi.spyOn(tauRpc, "runPiRpc").mockImplementation( - () => - new Promise((resolve) => - setTimeout( - () => - resolve({ - stdout: "done\n", - stderr: "", - code: 0, - signal: null, - killed: false, - }), - 120, - ), - ), - ); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "{{Body}}"], - typingIntervalSeconds: 0.02, - }, - }, - }; - - const promise = index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - { onReplyStart }, - cfg, - ); - await new Promise((r) => setTimeout(r, 200)); - await promise; - expect(onReplyStart.mock.calls.length).toBeGreaterThanOrEqual(2); - }); - - it("uses session typing interval override", async () => { - const onReplyStart = vi.fn(); - vi.spyOn(tauRpc, "runPiRpc").mockImplementation( - () => - new Promise((resolve) => - setTimeout( - () => - resolve({ - stdout: "done\n", - stderr: "", - code: 0, - signal: null, - killed: false, - }), - 120, - ), - ), - ); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["pi", "{{Body}}"], - session: { typingIntervalSeconds: 0.02 }, - }, - }, - }; - - const promise = index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - { onReplyStart }, - cfg, - ); - await new Promise((r) => setTimeout(r, 200)); - await promise; - expect(onReplyStart.mock.calls.length).toBeGreaterThanOrEqual(2); - }); - - it("serializes command auto-replies via the queue", async () => { - let active = 0; - let maxActive = 0; - const runSpy = vi - .spyOn(index, "runCommandWithTimeout") - .mockImplementation(async () => { - active += 1; - maxActive = Math.max(maxActive, active); - await new Promise((resolve) => setTimeout(resolve, 25)); - active -= 1; - return { - stdout: "ok", - stderr: "", - code: 0, - signal: null, - killed: false, - }; - }); - - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - }, - }, - }; - - await Promise.all([ - index.getReplyFromConfig( - { Body: "first", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ), - index.getReplyFromConfig( - { Body: "second", From: "+3", To: "+4" }, - undefined, - cfg, - runSpy, - ), - ]); - - expect(runSpy).toHaveBeenCalledTimes(2); - expect(maxActive).toBe(1); - }); -}); - -describe("twilio interactions", () => { - it("autoReplyIfConfigured sends message when configured", async () => { - const client = twilioFactory._createClient(); - client.messages.create.mockResolvedValue({}); - await index.autoReplyIfConfigured( - client, - { - from: "whatsapp:+1", - to: "whatsapp:+2", - body: "hi", - sid: "SM1", - } as unknown as MessageInstance, - { - inbound: { - reply: { mode: "text", text: "auto-text" }, - }, - }, - ); - - expect(client.messages.create).toHaveBeenCalledWith({ - from: "whatsapp:+2", - to: "whatsapp:+1", - body: "auto-text", - }); - }); - - it("sendTypingIndicator skips missing messageSid and sends when present", async () => { - const client = twilioFactory._createClient(); - await index.sendTypingIndicator(client, index.defaultRuntime, undefined); - expect(client.request).not.toHaveBeenCalled(); - - await index.sendTypingIndicator(client, index.defaultRuntime, "SM123"); - expect(client.request).toHaveBeenCalledWith( - expect.objectContaining({ method: "post" }), - ); - }); - - it("sendMessage wraps Twilio client and returns sid", async () => { - const client = twilioFactory._createClient(); - client.messages.create.mockResolvedValue({ sid: "SM999" }); - twilioFactory.mockReturnValue(client); - - const result = await index.sendMessage("+1555", "hi"); - expect(client.messages.create).toHaveBeenCalledWith({ - from: withWhatsAppPrefix("whatsapp:+15551234567"), - to: withWhatsAppPrefix("+1555"), - body: "hi", - }); - expect(result?.sid).toBe("SM999"); - }); - - it("waitForFinalStatus resolves on delivered", async () => { - const fetch = vi - .fn() - .mockResolvedValueOnce({ status: "sent" }) - .mockResolvedValueOnce({ status: "delivered" }); - const client = { - messages: vi.fn(() => ({ fetch })), - }; - await index.waitForFinalStatus( - client as unknown as ReturnType, - "SM1", - 1, - 0, - ); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("waitForFinalStatus exits on failure", async () => { - const runtime: index.RuntimeEnv = { - error: vi.fn(), - exit: vi.fn() as unknown as (code: number) => never, - log: console.log, - }; - const fetch = vi.fn().mockResolvedValue({ status: "failed" }); - const client = { - messages: vi.fn(() => ({ fetch })), - }; - await index - .waitForFinalStatus( - client as unknown as ReturnType, - "SM2", - 1, - 0, - runtime, - ) - .catch(() => {}); - expect(runtime.exit).toHaveBeenCalledWith(1); - }); -}); - -describe("webhook and messaging", () => { - it("startWebhook responds and auto-replies", async () => { - const client = twilioFactory._createClient(); - client.messages.create.mockResolvedValue({}); - twilioFactory.mockReturnValue(client); - vi.spyOn(index, "getReplyFromConfig").mockResolvedValue({ text: "Auto" }); - - const server = await index.startWebhook(0, "/hook", undefined, false); - const address = server.address() as net.AddressInfo; - const url = `http://127.0.0.1:${address.port}/hook`; - const res = await fetch(url, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: "From=whatsapp%3A%2B1555&To=whatsapp%3A%2B1666&Body=Hello&MessageSid=SM2", - }); - expect(res.status).toBe(200); - await new Promise((resolve) => server.close(resolve)); - }); - - it("hosts local media before replying via webhook", async () => { - const client = twilioFactory._createClient(); - client.messages.create.mockResolvedValue({}); - twilioFactory.mockReturnValue(client); - const replies = await import("./auto-reply/reply.js"); - const hostModule = await import("./media/host.js"); - const hostSpy = vi - .spyOn(hostModule, "ensureMediaHosted") - .mockResolvedValue({ - url: "https://ts.net/media/abc", - id: "abc", - size: 123, - }); - vi.spyOn(replies, "getReplyFromConfig").mockResolvedValue({ - text: "Auto", - mediaUrl: "/tmp/pic.png", - }); - - const server = await index.startWebhook(0, "/hook", undefined, false); - const address = server.address() as net.AddressInfo; - const url = `http://127.0.0.1:${address.port}/hook`; - await fetch(url, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: "From=whatsapp%3A%2B1555&To=whatsapp%3A%2B1666&Body=Hello&MessageSid=SM2", - }); - - expect(hostSpy).toHaveBeenCalledWith("/tmp/pic.png"); - expect(client.messages.create).toHaveBeenCalledWith( - expect.objectContaining({ - mediaUrl: ["https://ts.net/media/abc"], - }), - ); - hostSpy.mockRestore(); - await new Promise((resolve) => server.close(resolve)); - }); - - it("listRecentMessages merges and sorts", async () => { - const inbound = [ - { - sid: "1", - status: "delivered", - direction: "inbound", - dateCreated: new Date("2024-01-01T00:00:00Z"), - from: "a", - to: "b", - body: "hi", - errorCode: null, - errorMessage: null, - }, - ]; - const outbound = [ - { - sid: "2", - status: "sent", - direction: "outbound-api", - dateCreated: new Date("2024-01-02T00:00:00Z"), - from: "b", - to: "a", - body: "yo", - errorCode: null, - errorMessage: null, - }, - ]; - const client = twilioFactory._createClient(); - client.messages.list - .mockResolvedValueOnce(inbound) - .mockResolvedValueOnce(outbound); - - const messages = await index.listRecentMessages(60, 5, client); - expect(messages[0].sid).toBe("2"); - expect(messages).toHaveLength(2); - }); - - it("formatMessageLine builds readable string", () => { - const line = index.formatMessageLine({ - sid: "SID", - status: "delivered", - direction: "inbound", - dateCreated: new Date("2024-01-01T00:00:00Z"), - from: "a", - to: "b", - body: "hello world", - errorCode: null, - errorMessage: null, - }); - expect(line).toContain("SID"); - expect(line).toContain("hello world"); - }); -}); - -describe("sender discovery", () => { - it("findWhatsappSenderSid prefers explicit env", async () => { - const client = twilioFactory._createClient(); - const sid = await index.findWhatsappSenderSid(client, "+1555", "SID123"); - expect(sid).toBe("SID123"); - }); - - it("findWhatsappSenderSid lists senders when needed", async () => { - const client = twilioFactory._createClient(); - client.messaging.v2.channelsSenders.list.mockResolvedValue([ - { sender_id: withWhatsAppPrefix("+1555"), sid: "S1" }, - ]); - const sid = await index.findWhatsappSenderSid(client, "+1555"); - expect(sid).toBe("S1"); - }); - - it("updateWebhook uses primary update path", async () => { - const fetched = { webhook: { callback_url: "https://cb" } }; - const client = { - request: vi.fn().mockResolvedValue({}), - messaging: { - v2: { - channelsSenders: vi.fn(() => ({ - fetch: vi.fn().mockResolvedValue(fetched), - })), - }, - v1: { services: vi.fn(() => ({ update: vi.fn(), fetch: vi.fn() })) }, - }, - incomingPhoneNumbers: vi.fn(), - } as unknown as ReturnType; - - await index.updateWebhook(client, "SID", "https://example.com", "POST"); - expect(client.request).toHaveBeenCalled(); - }); -}); - -describe("infra helpers", () => { - it("handlePortError prints owner details", async () => { - const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { - throw new Error("exit"); - }) as () => never); - vi.spyOn(index, "describePortOwner").mockResolvedValue("proc listening"); - await expect( - index.handlePortError(new index.PortInUseError(1234), 1234, "Context"), - ).rejects.toThrow("exit"); - expect(exitSpy).toHaveBeenCalled(); - }); - - it("getTailnetHostname prefers DNS then IP", async () => { - type ExecFn = ( - command: string, - args?: string[], - options?: unknown, - ) => Promise<{ stdout: string; stderr: string }>; - const exec: ExecFn = vi - .fn() - .mockResolvedValueOnce({ - stdout: JSON.stringify({ Self: { DNSName: "host.tailnet." } }), - stderr: "", - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.1.2.3"] } }), - stderr: "", - }); - const dns = await index.getTailnetHostname(exec); - expect(dns).toBe("host.tailnet"); - const ip = await index.getTailnetHostname(exec); - expect(ip).toBe("100.1.2.3"); - }); - - it("ensureGoInstalled installs when missing", async () => { - const exec = vi - .fn< - index.CommandArgs | index.CommandArgsWithOptions, - Promise<{ stdout: string; stderr: string }> - >() - .mockRejectedValueOnce(new Error("missing")) - .mockResolvedValue({ stdout: "", stderr: "" }); - const prompt = vi.fn<[], Promise>().mockResolvedValue(true); - await index.ensureGoInstalled(exec, prompt); - expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]); - }); - - it("ensureTailscaledInstalled installs when missing", async () => { - const exec = vi - .fn< - index.CommandArgs | index.CommandArgsWithOptions, - Promise<{ stdout: string; stderr: string }> - >() - .mockRejectedValueOnce(new Error("missing")) - .mockResolvedValue({ stdout: "", stderr: "" }); - const prompt = vi.fn<[], Promise>().mockResolvedValue(true); - await index.ensureTailscaledInstalled(exec, prompt); - expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]); - }); - - it("ensureFunnel enables funnel when status present", async () => { - const exec = vi - .fn< - index.CommandArgs | index.CommandArgsWithOptions, - Promise<{ stdout: string; stderr: string }> - >() - .mockResolvedValueOnce({ - stdout: JSON.stringify({ Enabled: true }), - stderr: "", - }) - .mockResolvedValueOnce({ stdout: "ok", stderr: "" }); - await index.ensureFunnel(8080, exec); - expect(exec).toHaveBeenCalledTimes(2); - }); -}); - -describe("twilio helpers", () => { - it("findIncomingNumberSid and messaging sid helpers", async () => { - const client = twilioFactory._createClient(); - client.incomingPhoneNumbers.list.mockResolvedValue([ - { sid: "PN1", messagingServiceSid: "MG1" }, - ]); - const sid = await index.findIncomingNumberSid(client); - expect(sid).toBe("PN1"); - const msid = await index.findMessagingServiceSid(client); - expect(msid).toBe("MG1"); - }); - - it("setMessagingServiceWebhook updates service", async () => { - const updater = { update: vi.fn().mockResolvedValue({}), fetch: vi.fn() }; - const client = twilioFactory._createClient(); - client.messaging.v1.services.mockReturnValue( - updater as unknown as ReturnType, - ); - client.incomingPhoneNumbers.list.mockResolvedValue([ - { messagingServiceSid: "MS1" }, - ]); - const updated = await index.setMessagingServiceWebhook( - client, - "https://x", - "POST", - ); - expect(updated).toBe(true); - expect(updater.update).toHaveBeenCalled(); - }); - - it("uniqueBySid and sortByDateDesc de-dupe and order", () => { - const messages = [ - { sid: "1", dateCreated: new Date("2023-01-01") }, - { sid: "1", dateCreated: new Date("2023-01-02") }, - { sid: "2", dateCreated: new Date("2024-01-01") }, - ]; - const unique = index.uniqueBySid(messages); - expect(unique).toHaveLength(2); - const sorted = index.sortByDateDesc(unique); - expect(sorted[0].sid).toBe("2"); - }); - - it("formatTwilioError and logTwilioSendError include details", () => { - const runtime: index.RuntimeEnv = { - error: vi.fn(), - log: vi.fn(), - exit: ((code: number) => { - throw new Error(`exit ${code}`); - }) as (code: number) => never, - }; - const errString = index.formatTwilioError({ - code: 123, - status: 400, - message: "bad", - moreInfo: "link", - }); - expect(errString).toContain("123"); - index.logTwilioSendError({ response: { body: { x: 1 } } }, "+1", runtime); - expect(runtime.error).toHaveBeenCalled(); - }); - - it("logTwilioSendError handles error without response", () => { - const runtime: index.RuntimeEnv = { - error: vi.fn(), - log: vi.fn(), - exit: ((code: number) => { - throw new Error(`exit ${code}`); - }) as (code: number) => never, - }; - index.logTwilioSendError(new Error("oops"), undefined, runtime); - expect(runtime.error).toHaveBeenCalled(); - }); -}); - -describe("monitoring", () => { - it("monitorTwilio polls once and processes inbound", async () => { - const client = { - messages: { - list: vi.fn().mockResolvedValue([ - { - sid: "m1", - direction: "inbound", - dateCreated: new Date(), - from: "+1", - to: "+2", - body: "hi", - }, - ]), - }, - } as unknown as ReturnType; - vi.spyOn(index, "getReplyFromConfig").mockResolvedValue(undefined); - await index.monitorTwilio(0, 0, client, 1); - expect(client.messages.list).toHaveBeenCalled(); - }); - - it("ensureFunnel failure path exits via runtime", async () => { - const runtime: index.RuntimeEnv = { - error: vi.fn(), - exit: vi.fn() as unknown as (code: number) => never, - log: console.log, - }; - const exec = vi.fn().mockRejectedValue({ stdout: "Funnel is not enabled" }); - await index.ensureFunnel(8080, exec, runtime).catch(() => {}); - expect(runtime.error).toHaveBeenCalled(); - expect(runtime.exit).toHaveBeenCalledWith(1); - }); - - it("monitorWebProvider triggers replies and stops when asked", async () => { - const replySpy = vi.fn(); - const sendMediaSpy = vi.fn(); - const listenerFactory = vi.fn( - async (opts: { - verbose: boolean; - onMessage: ( - msg: import("./web/inbound.js").WebInboundMessage, - ) => Promise; - }) => { - await opts.onMessage({ - body: "hello", - from: "+1", - to: "+2", - id: "id1", - sendComposing: vi.fn(), - reply: replySpy, - sendMedia: sendMediaSpy, - }); - return { close: vi.fn() }; - }, - ); - const resolver = vi.fn().mockResolvedValue({ text: "auto" }); - await index.monitorWebProvider(false, listenerFactory, false, resolver); - expect(replySpy).toHaveBeenCalledWith("auto"); - }); -}); diff --git a/src/index.test.ts b/src/index.test.ts index e846f3868..d952581a5 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -19,7 +19,6 @@ describe("toWhatsappJid", () => { describe("assertProvider", () => { it("accepts valid providers", () => { - expect(() => assertProvider("twilio")).not.toThrow(); expect(() => assertProvider("web")).not.toThrow(); }); diff --git a/src/index.ts b/src/index.ts index 35143c4b7..b46c8f5b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,12 +3,9 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import dotenv from "dotenv"; -import { - autoReplyIfConfigured, - getReplyFromConfig, -} from "./auto-reply/reply.js"; +import { getReplyFromConfig } from "./auto-reply/reply.js"; import { applyTemplate } from "./auto-reply/templating.js"; -import { createDefaultDeps, monitorTwilio } from "./cli/deps.js"; +import { createDefaultDeps } from "./cli/deps.js"; import { promptYesNo } from "./cli/prompt.js"; import { waitForever } from "./cli/wait.js"; import { loadConfig } from "./config/config.js"; @@ -18,7 +15,6 @@ import { resolveStorePath, saveSessionStore, } from "./config/sessions.js"; -import { readEnv } from "./env.js"; import { ensureBinary } from "./infra/binaries.js"; import { describePortOwner, @@ -26,33 +22,9 @@ import { handlePortError, PortInUseError, } from "./infra/ports.js"; -import { - ensureFunnel, - ensureGoInstalled, - ensureTailscaledInstalled, - getTailnetHostname, -} from "./infra/tailscale.js"; import { enableConsoleCapture } from "./logging.js"; import { runCommandWithTimeout, runExec } from "./process/exec.js"; import { monitorWebProvider } from "./provider-web.js"; -import { createClient } from "./twilio/client.js"; -import { - formatMessageLine, - listRecentMessages, - sortByDateDesc, - uniqueBySid, -} from "./twilio/messages.js"; -import { sendMessage, waitForFinalStatus } from "./twilio/send.js"; -import { findWhatsappSenderSid } from "./twilio/senders.js"; -import { sendTypingIndicator } from "./twilio/typing.js"; -import { - findIncomingNumberSid as findIncomingNumberSidImpl, - findMessagingServiceSid as findMessagingServiceSidImpl, - setMessagingServiceWebhook as setMessagingServiceWebhookImpl, - updateWebhook as updateWebhookImpl, -} from "./twilio/update-webhook.js"; -import { formatTwilioError, logTwilioSendError } from "./twilio/utils.js"; -import { startWebhook as startWebhookImpl } from "./twilio/webhook.js"; import { assertProvider, normalizeE164, toWhatsappJid } from "./utils.js"; dotenv.config({ quiet: true }); @@ -64,57 +36,28 @@ import { buildProgram } from "./cli/program.js"; const program = buildProgram(); -// Keep aliases for backwards compatibility with prior index exports. -const startWebhook = startWebhookImpl; -const setMessagingServiceWebhook = setMessagingServiceWebhookImpl; -const updateWebhook = updateWebhookImpl; - export { assertProvider, - autoReplyIfConfigured, applyTemplate, - createClient, + createDefaultDeps, deriveSessionKey, describePortOwner, ensureBinary, - ensureFunnel, - ensureGoInstalled, ensurePortAvailable, - ensureTailscaledInstalled, - findIncomingNumberSidImpl as findIncomingNumberSid, - findMessagingServiceSidImpl as findMessagingServiceSid, - findWhatsappSenderSid, - formatMessageLine, - formatTwilioError, getReplyFromConfig, - getTailnetHostname, handlePortError, - logTwilioSendError, - listRecentMessages, loadConfig, loadSessionStore, - monitorTwilio, monitorWebProvider, normalizeE164, PortInUseError, promptYesNo, - createDefaultDeps, - readEnv, resolveStorePath, runCommandWithTimeout, runExec, saveSessionStore, - sendMessage, - sendTypingIndicator, - setMessagingServiceWebhook, - sortByDateDesc, - startWebhook, - updateWebhook, - uniqueBySid, - waitForFinalStatus, - waitForever, toWhatsappJid, - program, + waitForever, }; const isMain = diff --git a/src/providers/provider.types.ts b/src/providers/provider.types.ts deleted file mode 100644 index b013984ea..000000000 --- a/src/providers/provider.types.ts +++ /dev/null @@ -1 +0,0 @@ -export type Provider = "twilio" | "web"; diff --git a/src/providers/twilio/index.ts b/src/providers/twilio/index.ts deleted file mode 100644 index 7d1cd4f54..000000000 --- a/src/providers/twilio/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export { createClient } from "../../twilio/client.js"; -export { - formatMessageLine, - listRecentMessages, -} from "../../twilio/messages.js"; -export { monitorTwilio } from "../../twilio/monitor.js"; -export { sendMessage, waitForFinalStatus } from "../../twilio/send.js"; -export { findWhatsappSenderSid } from "../../twilio/senders.js"; -export { sendTypingIndicator } from "../../twilio/typing.js"; -export { - findIncomingNumberSid, - findMessagingServiceSid, - setMessagingServiceWebhook, - updateWebhook, -} from "../../twilio/update-webhook.js"; -export { formatTwilioError, logTwilioSendError } from "../../twilio/utils.js"; diff --git a/src/twilio/client.ts b/src/twilio/client.ts deleted file mode 100644 index fbb5565b6..000000000 --- a/src/twilio/client.ts +++ /dev/null @@ -1,14 +0,0 @@ -import Twilio from "twilio"; -import type { EnvConfig } from "../env.js"; - -export function createClient(env: EnvConfig) { - // Twilio client using either auth token or API key/secret. - if ("authToken" in env.auth) { - return Twilio(env.accountSid, env.auth.authToken, { - accountSid: env.accountSid, - }); - } - return Twilio(env.auth.apiKey, env.auth.apiSecret, { - accountSid: env.accountSid, - }); -} diff --git a/src/twilio/heartbeat.test.ts b/src/twilio/heartbeat.test.ts deleted file mode 100644 index 216f759da..000000000 --- a/src/twilio/heartbeat.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { HEARTBEAT_TOKEN } from "../web/auto-reply.js"; -import { runTwilioHeartbeatOnce } from "./heartbeat.js"; - -vi.mock("./send.js", () => ({ - sendMessage: vi.fn(), -})); - -vi.mock("../auto-reply/reply.js", () => ({ - getReplyFromConfig: vi.fn(), -})); - -// eslint-disable-next-line import/first -import { getReplyFromConfig } from "../auto-reply/reply.js"; -// eslint-disable-next-line import/first -import { sendMessage } from "./send.js"; - -const sendMessageMock = sendMessage as unknown as vi.Mock; -const replyResolverMock = getReplyFromConfig as unknown as vi.Mock; - -describe("runTwilioHeartbeatOnce", () => { - it("sends manual override body and skips resolver", async () => { - sendMessageMock.mockResolvedValue({}); - await runTwilioHeartbeatOnce({ - to: "+1555", - overrideBody: "hello manual", - }); - expect(sendMessage).toHaveBeenCalledWith( - "+1555", - "hello manual", - undefined, - expect.anything(), - ); - expect(replyResolverMock).not.toHaveBeenCalled(); - }); - - it("dry-run manual message avoids sending", async () => { - sendMessageMock.mockReset(); - await runTwilioHeartbeatOnce({ - to: "+1555", - overrideBody: "hello manual", - dryRun: true, - }); - expect(sendMessage).not.toHaveBeenCalled(); - expect(replyResolverMock).not.toHaveBeenCalled(); - }); - - it("skips send when resolver returns heartbeat token", async () => { - replyResolverMock.mockResolvedValue({ - text: HEARTBEAT_TOKEN, - }); - sendMessageMock.mockReset(); - await runTwilioHeartbeatOnce({ - to: "+1555", - }); - expect(sendMessage).not.toHaveBeenCalled(); - }); - - it("sends resolved heartbeat text when present", async () => { - replyResolverMock.mockResolvedValue({ - text: "ALERT!", - }); - sendMessageMock.mockReset().mockResolvedValue({}); - await runTwilioHeartbeatOnce({ - to: "+1555", - }); - expect(sendMessage).toHaveBeenCalledWith( - "+1555", - "ALERT!", - undefined, - expect.anything(), - ); - }); -}); diff --git a/src/twilio/heartbeat.ts b/src/twilio/heartbeat.ts deleted file mode 100644 index c8129834e..000000000 --- a/src/twilio/heartbeat.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { getReplyFromConfig } from "../auto-reply/reply.js"; -import { danger, success } from "../globals.js"; -import { logInfo } from "../logger.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { HEARTBEAT_PROMPT, stripHeartbeatToken } from "../web/auto-reply.js"; -import { sendMessage } from "./send.js"; - -type ReplyResolver = typeof getReplyFromConfig; - -export async function runTwilioHeartbeatOnce(opts: { - to: string; - verbose?: boolean; - runtime?: RuntimeEnv; - replyResolver?: ReplyResolver; - overrideBody?: string; - dryRun?: boolean; -}) { - const { - to, - verbose: _verbose = false, - runtime = defaultRuntime, - overrideBody, - dryRun = false, - } = opts; - const replyResolver = opts.replyResolver ?? getReplyFromConfig; - - if (overrideBody && overrideBody.trim().length === 0) { - throw new Error("Override body must be non-empty when provided."); - } - - try { - if (overrideBody) { - if (dryRun) { - logInfo( - `[dry-run] twilio send -> ${to}: ${overrideBody.trim()} (manual message)`, - runtime, - ); - return; - } - await sendMessage(to, overrideBody, undefined, runtime); - logInfo(success(`sent manual message to ${to} (twilio)`), runtime); - return; - } - - const replyResult = await replyResolver( - { - Body: HEARTBEAT_PROMPT, - From: to, - To: to, - MessageSid: undefined, - }, - { isHeartbeat: true }, - ); - - const replyPayload = Array.isArray(replyResult) - ? replyResult[0] - : replyResult; - - if ( - !replyPayload || - (!replyPayload.text && - !replyPayload.mediaUrl && - !replyPayload.mediaUrls?.length) - ) { - logInfo("heartbeat skipped: empty reply", runtime); - return; - } - - const hasMedia = Boolean( - replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0, - ); - const stripped = stripHeartbeatToken(replyPayload.text); - if (stripped.shouldSkip && !hasMedia) { - logInfo(success("heartbeat: ok (HEARTBEAT_OK)"), runtime); - return; - } - - const finalText = stripped.text || replyPayload.text || ""; - if (dryRun) { - logInfo( - `[dry-run] heartbeat -> ${to}: ${finalText.slice(0, 200)}`, - runtime, - ); - return; - } - - await sendMessage(to, finalText, undefined, runtime); - logInfo(success(`heartbeat sent to ${to} (twilio)`), runtime); - } catch (err) { - runtime.error(danger(`Heartbeat failed: ${String(err)}`)); - throw err; - } -} diff --git a/src/twilio/messages.ts b/src/twilio/messages.ts deleted file mode 100644 index 72c27ca85..000000000 --- a/src/twilio/messages.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { readEnv } from "../env.js"; -import { withWhatsAppPrefix } from "../utils.js"; -import { createClient } from "./client.js"; - -export type ListedMessage = { - sid: string; - status: string | null; - direction: string | null; - dateCreated: Date | undefined; - from?: string | null; - to?: string | null; - body?: string | null; - errorCode: number | null; - errorMessage: string | null; -}; - -// Remove duplicates by SID while preserving order. -export function uniqueBySid(messages: ListedMessage[]): ListedMessage[] { - const seen = new Set(); - const deduped: ListedMessage[] = []; - for (const m of messages) { - if (seen.has(m.sid)) continue; - seen.add(m.sid); - deduped.push(m); - } - return deduped; -} - -// Sort messages newest -> oldest by dateCreated. -export function sortByDateDesc(messages: ListedMessage[]): ListedMessage[] { - return [...messages].sort((a, b) => { - const da = a.dateCreated?.getTime() ?? 0; - const db = b.dateCreated?.getTime() ?? 0; - return db - da; - }); -} - -// Merge inbound/outbound messages (recent first) for status commands and tests. -export async function listRecentMessages( - lookbackMinutes: number, - limit: number, - clientOverride?: ReturnType, -): Promise { - const env = readEnv(); - const client = clientOverride ?? createClient(env); - const from = withWhatsAppPrefix(env.whatsappFrom); - const since = new Date(Date.now() - lookbackMinutes * 60_000); - - // Fetch inbound (to our WA number) and outbound (from our WA number), merge, sort, limit. - const fetchLimit = Math.min(Math.max(limit * 2, limit + 10), 100); - const inbound = await client.messages.list({ - to: from, - dateSentAfter: since, - limit: fetchLimit, - }); - const outbound = await client.messages.list({ - from, - dateSentAfter: since, - limit: fetchLimit, - }); - - const inboundArr = Array.isArray(inbound) ? inbound : []; - const outboundArr = Array.isArray(outbound) ? outbound : []; - const combined = uniqueBySid( - [...inboundArr, ...outboundArr].map((m) => ({ - sid: m.sid, - status: m.status ?? null, - direction: m.direction ?? null, - dateCreated: m.dateCreated, - from: m.from, - to: m.to, - body: m.body, - errorCode: m.errorCode ?? null, - errorMessage: m.errorMessage ?? null, - })), - ); - - return sortByDateDesc(combined).slice(0, limit); -} - -// Human-friendly single-line formatter for recent messages. -export function formatMessageLine(m: ListedMessage): string { - const ts = m.dateCreated?.toISOString() ?? "unknown-time"; - const dir = - m.direction === "inbound" - ? "⬅️ " - : m.direction === "outbound-api" || m.direction === "outbound-reply" - ? "➡️ " - : "↔️ "; - const status = m.status ?? "unknown"; - const err = - m.errorCode != null - ? ` error ${m.errorCode}${m.errorMessage ? ` (${m.errorMessage})` : ""}` - : ""; - const body = (m.body ?? "").replace(/\s+/g, " ").trim(); - const bodyPreview = - body.length > 140 ? `${body.slice(0, 137)}…` : body || ""; - return `[${ts}] ${dir}${m.from ?? "?"} -> ${m.to ?? "?"} | ${status}${err} | ${bodyPreview} (sid ${m.sid})`; -} diff --git a/src/twilio/monitor.test.ts b/src/twilio/monitor.test.ts deleted file mode 100644 index 76b6a8527..000000000 --- a/src/twilio/monitor.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { monitorTwilio } from "./monitor.js"; - -describe("monitorTwilio", () => { - it("processes inbound messages once with injected deps", async () => { - const listRecentMessages = vi.fn().mockResolvedValue([ - { - sid: "m1", - direction: "inbound", - dateCreated: new Date(), - from: "+1", - to: "+2", - body: "hi", - errorCode: null, - errorMessage: null, - status: null, - }, - ]); - const autoReplyIfConfigured = vi.fn().mockResolvedValue(undefined); - const readEnv = vi.fn(() => ({ - accountSid: "AC", - whatsappFrom: "whatsapp:+1", - auth: { accountSid: "AC", authToken: "t" }, - })); - const createClient = vi.fn( - () => ({ messages: { create: vi.fn() } }) as never, - ); - const sleep = vi.fn().mockResolvedValue(undefined); - - await monitorTwilio(0, 0, { - deps: { - autoReplyIfConfigured, - listRecentMessages, - readEnv, - createClient, - sleep, - }, - maxIterations: 1, - }); - - expect(listRecentMessages).toHaveBeenCalledTimes(1); - expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/twilio/monitor.ts b/src/twilio/monitor.ts deleted file mode 100644 index 58bae951f..000000000 --- a/src/twilio/monitor.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js"; -import { autoReplyIfConfigured } from "../auto-reply/reply.js"; -import { readEnv } from "../env.js"; -import { danger } from "../globals.js"; -import { logDebug, logInfo, logWarn } from "../logger.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { sleep, withWhatsAppPrefix } from "../utils.js"; -import { createClient } from "./client.js"; - -type MonitorDeps = { - autoReplyIfConfigured: typeof autoReplyIfConfigured; - listRecentMessages: ( - lookbackMinutes: number, - limit: number, - clientOverride?: ReturnType, - ) => Promise; - readEnv: typeof readEnv; - createClient: typeof createClient; - sleep: typeof sleep; -}; - -const DEFAULT_POLL_INTERVAL_SECONDS = 5; - -export type ListedMessage = { - sid: string; - status: string | null; - direction: string | null; - dateCreated: Date | undefined; - from?: string | null; - to?: string | null; - body?: string | null; - errorCode: number | null; - errorMessage: string | null; -}; - -type MonitorOptions = { - client?: ReturnType; - maxIterations?: number; - deps?: MonitorDeps; - runtime?: RuntimeEnv; -}; - -const defaultDeps: MonitorDeps = { - autoReplyIfConfigured, - listRecentMessages: () => Promise.resolve([]), - readEnv, - createClient, - sleep, -}; - -// Poll Twilio for inbound messages and auto-reply when configured. -export async function monitorTwilio( - pollSeconds: number, - lookbackMinutes: number, - opts?: MonitorOptions, -) { - const deps = opts?.deps ?? defaultDeps; - const runtime = opts?.runtime ?? defaultRuntime; - const maxIterations = opts?.maxIterations ?? Infinity; - let backoffMs = 1_000; - - const env = deps.readEnv(runtime); - const from = withWhatsAppPrefix(env.whatsappFrom); - const client = opts?.client ?? deps.createClient(env); - logInfo( - `📡 Monitoring inbound messages to ${from} (poll ${pollSeconds}s, lookback ${lookbackMinutes}m)`, - runtime, - ); - - let lastSeenSid: string | undefined; - let iterations = 0; - while (iterations < maxIterations) { - let messages: ListedMessage[] = []; - try { - messages = - (await deps.listRecentMessages(lookbackMinutes, 50, client)) ?? []; - backoffMs = 1_000; // reset after success - } catch (err) { - logWarn( - `Twilio polling failed (will retry in ${backoffMs}ms): ${String(err)}`, - runtime, - ); - await deps.sleep(backoffMs); - backoffMs = Math.min(backoffMs * 2, 10_000); - continue; - } - const inboundOnly = messages.filter((m) => m.direction === "inbound"); - // Sort newest -> oldest without relying on external helpers (avoids test mocks clobbering imports). - const newestFirst = [...inboundOnly].sort( - (a, b) => - (b.dateCreated?.getTime() ?? 0) - (a.dateCreated?.getTime() ?? 0), - ); - await handleMessages(messages, client, lastSeenSid, deps, runtime); - lastSeenSid = newestFirst.length ? newestFirst[0].sid : lastSeenSid; - iterations += 1; - if (iterations >= maxIterations) break; - await deps.sleep( - Math.max(pollSeconds, DEFAULT_POLL_INTERVAL_SECONDS) * 1000, - ); - } -} - -async function handleMessages( - messages: ListedMessage[], - client: ReturnType, - lastSeenSid: string | undefined, - deps: MonitorDeps, - runtime: RuntimeEnv, -) { - for (const m of messages) { - if (!m.sid) continue; - if (lastSeenSid && m.sid === lastSeenSid) break; // stop at previously seen - logDebug(`[${m.sid}] ${m.from ?? "?"} -> ${m.to ?? "?"}: ${m.body ?? ""}`); - if (m.direction !== "inbound") continue; - if (!m.from || !m.to) continue; - try { - await deps.autoReplyIfConfigured( - client as unknown as import("./types.js").TwilioRequester & { - messages: { create: (opts: unknown) => Promise }; - }, - m as unknown as MessageInstance, - undefined, - runtime, - ); - } catch (err) { - runtime.error(danger(`Auto-reply failed: ${String(err)}`)); - } - } -} diff --git a/src/twilio/send.test.ts b/src/twilio/send.test.ts deleted file mode 100644 index f6545b96e..000000000 --- a/src/twilio/send.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { waitForFinalStatus } from "./send.js"; - -describe("twilio send helpers", () => { - it("waitForFinalStatus resolves on delivered", async () => { - const fetch = vi - .fn() - .mockResolvedValueOnce({ status: "queued" }) - .mockResolvedValueOnce({ status: "delivered" }); - const client = { messages: vi.fn(() => ({ fetch })) } as never; - await waitForFinalStatus(client, "SM1", 2, 0.01, console as never); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("waitForFinalStatus exits on failure", async () => { - const fetch = vi - .fn() - .mockResolvedValue({ status: "failed", errorMessage: "boom" }); - const client = { messages: vi.fn(() => ({ fetch })) } as never; - const runtime = { - log: console.log, - error: () => {}, - exit: vi.fn(() => { - throw new Error("exit"); - }), - } as never; - await expect( - waitForFinalStatus(client, "SM1", 1, 0.01, runtime), - ).rejects.toBeInstanceOf(Error); - }); -}); diff --git a/src/twilio/send.ts b/src/twilio/send.ts deleted file mode 100644 index eb08e6567..000000000 --- a/src/twilio/send.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { readEnv } from "../env.js"; -import { logInfo } from "../logger.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { sleep, withWhatsAppPrefix } from "../utils.js"; -import { createClient } from "./client.js"; -import { logTwilioSendError } from "./utils.js"; - -const successTerminalStatuses = new Set(["delivered", "read"]); -const failureTerminalStatuses = new Set(["failed", "undelivered", "canceled"]); - -// Send outbound WhatsApp message; exit non-zero on API failure. -export async function sendMessage( - to: string, - body: string, - opts?: { mediaUrl?: string }, - runtime: RuntimeEnv = defaultRuntime, -) { - const env = readEnv(runtime); - const client = createClient(env); - const from = withWhatsAppPrefix(env.whatsappFrom); - const toNumber = withWhatsAppPrefix(to); - - try { - const message = await client.messages.create({ - from, - to: toNumber, - body, - mediaUrl: opts?.mediaUrl ? [opts.mediaUrl] : undefined, - }); - - logInfo( - `✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`, - runtime, - ); - return { client, sid: message.sid }; - } catch (err) { - logTwilioSendError(err, toNumber, runtime); - } -} - -// Poll message status until delivered/failed or timeout. -export async function waitForFinalStatus( - client: ReturnType, - sid: string, - timeoutSeconds: number, - pollSeconds: number, - runtime: RuntimeEnv = defaultRuntime, -) { - const deadline = Date.now() + timeoutSeconds * 1000; - while (Date.now() < deadline) { - const m = await client.messages(sid).fetch(); - const status = m.status ?? "unknown"; - if (successTerminalStatuses.has(status)) { - logInfo(`✅ Delivered (status: ${status})`, runtime); - return; - } - if (failureTerminalStatuses.has(status)) { - runtime.error( - `❌ Delivery failed (status: ${status}${m.errorCode ? `, code ${m.errorCode}` : ""})${m.errorMessage ? `: ${m.errorMessage}` : ""}`, - ); - runtime.exit(1); - } - await sleep(pollSeconds * 1000); - } - logInfo( - "ℹ️ Timed out waiting for final status; message may still be in flight.", - runtime, - ); -} diff --git a/src/twilio/senders.ts b/src/twilio/senders.ts deleted file mode 100644 index ab58912d8..000000000 --- a/src/twilio/senders.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { danger, info, isVerbose, logVerbose } from "../globals.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { withWhatsAppPrefix } from "../utils.js"; -import type { TwilioSenderListClient } from "./types.js"; - -export async function findWhatsappSenderSid( - client: TwilioSenderListClient, - from: string, - explicitSenderSid?: string, - runtime: RuntimeEnv = defaultRuntime, -) { - // Use explicit sender SID if provided, otherwise list and match by sender_id. - if (explicitSenderSid) { - logVerbose(`Using TWILIO_SENDER_SID from env: ${explicitSenderSid}`); - return explicitSenderSid; - } - try { - // Prefer official SDK list helper to avoid request-shape mismatches. - // Twilio helper types are broad; we narrow to expected shape. - const senderClient = client as unknown as TwilioSenderListClient; - const senders = await senderClient.messaging.v2.channelsSenders.list({ - channel: "whatsapp", - pageSize: 50, - }); - if (!senders) { - throw new Error('List senders response missing "senders" array'); - } - const match = senders.find( - (s) => - (typeof s.senderId === "string" && - s.senderId === withWhatsAppPrefix(from)) || - (typeof s.sender_id === "string" && - s.sender_id === withWhatsAppPrefix(from)), - ); - if (!match || typeof match.sid !== "string") { - throw new Error( - `Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`, - ); - } - return match.sid; - } catch (err) { - runtime.error(danger("Unable to list WhatsApp senders via Twilio API.")); - if (isVerbose()) { - runtime.error(err as Error); - } - runtime.error( - info( - "Set TWILIO_SENDER_SID in .env to skip discovery (Twilio Console → Messaging → Senders → WhatsApp).", - ), - ); - runtime.exit(1); - } -} diff --git a/src/twilio/types.ts b/src/twilio/types.ts deleted file mode 100644 index 9ea56ac7d..000000000 --- a/src/twilio/types.ts +++ /dev/null @@ -1,79 +0,0 @@ -export type TwilioRequestOptions = { - method: "get" | "post"; - uri: string; - params?: Record; - form?: Record; - body?: unknown; - contentType?: string; -}; - -export type TwilioSender = { sid: string; sender_id: string }; - -export type TwilioRequestResponse = { - data?: { - senders?: TwilioSender[]; - }; -}; - -export type IncomingNumber = { - sid: string; - phoneNumber: string; - smsUrl?: string; -}; - -export type TwilioChannelsSender = { - sid?: string; - senderId?: string; - sender_id?: string; - webhook?: { - callback_url?: string; - callback_method?: string; - fallback_url?: string; - fallback_method?: string; - }; -}; - -export type ChannelSenderUpdater = { - update: (params: Record) => Promise; -}; - -export type IncomingPhoneNumberUpdater = { - update: (params: Record) => Promise; -}; - -export type IncomingPhoneNumbersClient = { - list: (params: { - phoneNumber: string; - limit?: number; - }) => Promise; - get: (sid: string) => IncomingPhoneNumberUpdater; -} & ((sid: string) => IncomingPhoneNumberUpdater); - -export type TwilioSenderListClient = { - messaging: { - v2: { - channelsSenders: { - list: (params: { - channel: string; - pageSize: number; - }) => Promise; - ( - sid: string, - ): ChannelSenderUpdater & { - fetch: () => Promise; - }; - }; - }; - v1: { - services: (sid: string) => { - update: (params: Record) => Promise; - fetch: () => Promise<{ inboundRequestUrl?: string }>; - }; - }; - }; - incomingPhoneNumbers: IncomingPhoneNumbersClient; -}; - -export type TwilioRequester = { - request: (options: TwilioRequestOptions) => Promise; -}; diff --git a/src/twilio/typing.ts b/src/twilio/typing.ts deleted file mode 100644 index adf234fa7..000000000 --- a/src/twilio/typing.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { isVerbose, logVerbose, warn } from "../globals.js"; -import type { RuntimeEnv } from "../runtime.js"; - -type TwilioRequestOptions = { - method: "get" | "post"; - uri: string; - params?: Record; - form?: Record; - body?: unknown; - contentType?: string; -}; - -type TwilioRequester = { - request: (options: TwilioRequestOptions) => Promise; -}; - -export async function sendTypingIndicator( - client: TwilioRequester, - runtime: RuntimeEnv, - messageSid?: string, -) { - // Best-effort WhatsApp typing indicator (public beta as of Nov 2025). - if (!messageSid) { - logVerbose("Skipping typing indicator: missing MessageSid"); - return; - } - try { - await client.request({ - method: "post", - uri: "https://messaging.twilio.com/v2/Indicators/Typing.json", - form: { - messageId: messageSid, - channel: "whatsapp", - }, - }); - logVerbose(`Sent typing indicator for inbound ${messageSid}`); - } catch (err) { - if (isVerbose()) { - runtime.error(warn("Typing indicator failed (continuing without it)")); - runtime.error(err as Error); - } - } -} diff --git a/src/twilio/update-webhook.test.ts b/src/twilio/update-webhook.test.ts deleted file mode 100644 index d29c31f49..000000000 --- a/src/twilio/update-webhook.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { - findIncomingNumberSid, - findMessagingServiceSid, - setMessagingServiceWebhook, -} from "./update-webhook.js"; - -const envBackup = { ...process.env } as Record; - -describe("update-webhook helpers", () => { - beforeEach(() => { - process.env.TWILIO_ACCOUNT_SID = "AC"; - process.env.TWILIO_WHATSAPP_FROM = "whatsapp:+1555"; - process.env.TWILIO_AUTH_TOKEN = "dummy-token"; - }); - - afterEach(() => { - Object.entries(envBackup).forEach(([k, v]) => { - if (v === undefined) delete process.env[k]; - else process.env[k] = v; - }); - }); - - it("findIncomingNumberSid returns first match", async () => { - const client = { - incomingPhoneNumbers: { - list: async () => [{ sid: "PN1", phoneNumber: "+1555" }], - }, - } as never; - const sid = await findIncomingNumberSid(client); - expect(sid).toBe("PN1"); - }); - - it("findMessagingServiceSid reads messagingServiceSid", async () => { - const client = { - incomingPhoneNumbers: { - list: async () => [{ messagingServiceSid: "MG1" }], - }, - } as never; - const sid = await findMessagingServiceSid(client); - expect(sid).toBe("MG1"); - }); - - it("setMessagingServiceWebhook updates via service helper", async () => { - const update = async (_: unknown) => {}; - const fetch = async () => ({ inboundRequestUrl: "https://cb" }); - const client = { - messaging: { - v1: { - services: () => ({ update, fetch }), - }, - }, - incomingPhoneNumbers: { - list: async () => [{ messagingServiceSid: "MG1" }], - }, - } as never; - const ok = await setMessagingServiceWebhook(client, "https://cb", "POST"); - expect(ok).toBe(true); - }); -}); diff --git a/src/twilio/update-webhook.ts b/src/twilio/update-webhook.ts deleted file mode 100644 index 0d6dc2fc7..000000000 --- a/src/twilio/update-webhook.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { readEnv } from "../env.js"; -import { isVerbose } from "../globals.js"; -import { logError, logInfo } from "../logger.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import type { createClient } from "./client.js"; -import type { TwilioRequester, TwilioSenderListClient } from "./types.js"; - -export async function findIncomingNumberSid( - client: TwilioSenderListClient, -): Promise { - // Look up incoming phone number SID matching the configured WhatsApp number. - try { - const env = readEnv(); - const phone = env.whatsappFrom.replace("whatsapp:", ""); - const list = await client.incomingPhoneNumbers.list({ - phoneNumber: phone, - limit: 1, - }); - return list?.[0]?.sid ?? null; - } catch { - return null; - } -} - -export async function findMessagingServiceSid( - client: TwilioSenderListClient, -): Promise { - // Attempt to locate a messaging service tied to the WA phone number (webhook fallback). - type IncomingNumberWithService = { messagingServiceSid?: string }; - try { - const env = readEnv(); - const phone = env.whatsappFrom.replace("whatsapp:", ""); - const list = await client.incomingPhoneNumbers.list({ - phoneNumber: phone, - limit: 1, - }); - const msid = - (list?.[0] as IncomingNumberWithService | undefined) - ?.messagingServiceSid ?? null; - return msid; - } catch { - return null; - } -} - -export async function setMessagingServiceWebhook( - client: TwilioSenderListClient, - url: string, - method: "POST" | "GET", - runtime: RuntimeEnv = defaultRuntime, -): Promise { - const msid = await findMessagingServiceSid(client); - if (!msid) return false; - try { - await client.messaging.v1.services(msid).update({ - InboundRequestUrl: url, - InboundRequestMethod: method, - }); - const fetched = await client.messaging.v1.services(msid).fetch(); - const stored = fetched?.inboundRequestUrl; - logInfo( - `✅ Messaging Service webhook set to ${stored ?? url} (service ${msid})`, - runtime, - ); - return true; - } catch { - return false; - } -} - -// Update sender webhook URL with layered fallbacks (channels, form, helper, phone). -export async function updateWebhook( - client: ReturnType, - senderSid: string, - url: string, - method: "POST" | "GET" = "POST", - runtime: RuntimeEnv, -) { - // Point Twilio sender webhook at the provided URL. - const requester = client as unknown as TwilioRequester; - const clientTyped = client as unknown as TwilioSenderListClient; - - // 1) Raw request (Channels/Senders) with JSON webhook payload — most reliable for WA - try { - await requester.request({ - method: "post", - uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`, - body: { - webhook: { - callback_url: url, - callback_method: method, - }, - }, - contentType: "application/json", - }); - const fetched = await clientTyped.messaging.v2 - .channelsSenders(senderSid) - .fetch(); - const storedUrl = - fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url; - if (storedUrl) { - logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime); - return; - } - if (isVerbose()) - logError( - "Sender updated but webhook callback_url missing; will try fallbacks", - runtime, - ); - } catch (err) { - if (isVerbose()) - logError( - `channelsSenders request update failed, will try client helpers: ${String(err)}`, - runtime, - ); - } - - // 1b) Form-encoded fallback for older Twilio stacks - try { - await requester.request({ - method: "post", - uri: `https://messaging.twilio.com/v2/Channels/Senders/${senderSid}`, - form: { - "Webhook.CallbackUrl": url, - "Webhook.CallbackMethod": method, - }, - }); - const fetched = await clientTyped.messaging.v2 - .channelsSenders(senderSid) - .fetch(); - const storedUrl = - fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url; - if (storedUrl) { - logInfo(`✅ Twilio sender webhook set to ${storedUrl}`, runtime); - return; - } - if (isVerbose()) - logError( - "Form update succeeded but callback_url missing; will try helper fallback", - runtime, - ); - } catch (err) { - if (isVerbose()) - logError( - `Form channelsSenders update failed, will try helper fallback: ${String(err)}`, - runtime, - ); - } - - // 2) SDK helper fallback (if supported by this client) - try { - if (clientTyped.messaging?.v2?.channelsSenders) { - await clientTyped.messaging.v2.channelsSenders(senderSid).update({ - callbackUrl: url, - callbackMethod: method, - }); - const fetched = await clientTyped.messaging.v2 - .channelsSenders(senderSid) - .fetch(); - const storedUrl = - fetched?.webhook?.callback_url || fetched?.webhook?.fallback_url; - logInfo( - `✅ Twilio sender webhook set to ${storedUrl ?? url} (helper API)`, - runtime, - ); - return; - } - } catch (err) { - if (isVerbose()) - logError( - `channelsSenders helper update failed, will try phone number fallback: ${String(err)}`, - runtime, - ); - } - - // 3) Incoming phone number fallback (works for many WA senders) - try { - const phoneSid = await findIncomingNumberSid(clientTyped); - if (phoneSid) { - await clientTyped.incomingPhoneNumbers(phoneSid).update({ - smsUrl: url, - smsMethod: method, - }); - logInfo(`✅ Phone webhook set to ${url} (number ${phoneSid})`, runtime); - return; - } - } catch (err) { - if (isVerbose()) - logError( - `Incoming phone number webhook update failed; no more fallbacks: ${String(err)}`, - runtime, - ); - } - - runtime.error( - `❌ Failed to update Twilio webhook for sender ${senderSid} after multiple attempts`, - ); -} diff --git a/src/twilio/utils.ts b/src/twilio/utils.ts deleted file mode 100644 index 4f433a70a..000000000 --- a/src/twilio/utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { danger, info } from "../globals.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; - -type TwilioApiError = { - code?: number | string; - status?: number | string; - message?: string; - moreInfo?: string; - response?: { body?: unknown }; -}; - -export function formatTwilioError(err: unknown): string { - // Normalize Twilio error objects into a single readable string. - const e = err as TwilioApiError; - const pieces = []; - if (e.code != null) pieces.push(`code ${e.code}`); - if (e.status != null) pieces.push(`status ${e.status}`); - if (e.message) pieces.push(e.message); - if (e.moreInfo) pieces.push(`more: ${e.moreInfo}`); - return pieces.length ? pieces.join(" | ") : String(err); -} - -export function logTwilioSendError( - err: unknown, - destination?: string, - runtime: RuntimeEnv = defaultRuntime, -) { - // Friendly error logger for send failures, including response body when present. - const prefix = destination ? `to ${destination}: ` : ""; - runtime.error( - danger(`❌ Twilio send failed ${prefix}${formatTwilioError(err)}`), - ); - const body = (err as TwilioApiError)?.response?.body; - if (body) { - runtime.error(info("Response body:"), JSON.stringify(body, null, 2)); - } -} diff --git a/src/twilio/webhook.ts b/src/twilio/webhook.ts deleted file mode 100644 index 8198831b6..000000000 --- a/src/twilio/webhook.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { Server } from "node:http"; -import bodyParser from "body-parser"; -import chalk from "chalk"; -import express, { type Request, type Response } from "express"; -import { getReplyFromConfig, type ReplyPayload } from "../auto-reply/reply.js"; -import { type EnvConfig, readEnv } from "../env.js"; -import { danger, success } from "../globals.js"; -import * as mediaHost from "../media/host.js"; -import { attachMediaRoutes } from "../media/server.js"; -import { saveMediaSource } from "../media/store.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { normalizePath } from "../utils.js"; -import { createClient } from "./client.js"; -import { sendTypingIndicator } from "./typing.js"; -import { logTwilioSendError } from "./utils.js"; - -/** Start the inbound webhook HTTP server and wire optional auto-replies. */ -export async function startWebhook( - port: number, - path = "/webhook/whatsapp", - autoReply: string | undefined, - verbose: boolean, - runtime: RuntimeEnv = defaultRuntime, -): Promise { - const normalizedPath = normalizePath(path); - const env = readEnv(runtime); - const app = express(); - - attachMediaRoutes(app, undefined, runtime); - // Twilio sends application/x-www-form-urlencoded payloads. - app.use(bodyParser.urlencoded({ extended: false })); - app.use((req, _res, next) => { - runtime.log(chalk.gray(`REQ ${req.method} ${req.url}`)); - next(); - }); - - app.post(normalizedPath, async (req: Request, res: Response) => { - const { From, To, Body, MessageSid } = req.body ?? {}; - runtime.log(` -[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`); - if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`)); - - const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10); - let mediaPath: string | undefined; - let mediaUrlInbound: string | undefined; - let mediaType: string | undefined; - if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") { - mediaUrlInbound = req.body.MediaUrl0 as string; - mediaType = - typeof req.body?.MediaContentType0 === "string" - ? (req.body.MediaContentType0 as string) - : undefined; - try { - const creds = buildTwilioBasicAuth(env); - const saved = await saveMediaSource( - mediaUrlInbound, - { - Authorization: `Basic ${creds}`, - }, - "inbound", - ); - mediaPath = saved.path; - if (!mediaType && saved.contentType) mediaType = saved.contentType; - } catch (err) { - runtime.error( - danger(`Failed to download inbound media: ${String(err)}`), - ); - } - } - - const client = createClient(env); - let replyResult: ReplyPayload | ReplyPayload[] | undefined = - autoReply !== undefined ? { text: autoReply } : undefined; - if (!replyResult) { - replyResult = await getReplyFromConfig( - { - Body, - From, - To, - MessageSid, - MediaPath: mediaPath, - MediaUrl: mediaUrlInbound, - MediaType: mediaType, - }, - { - onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid), - }, - ); - } - - const replyPayload = Array.isArray(replyResult) - ? replyResult[0] - : replyResult; - - if (replyPayload && (replyPayload.text || replyPayload.mediaUrl)) { - try { - let mediaUrl = replyPayload.mediaUrl; - if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) { - const hosted = await mediaHost.ensureMediaHosted(mediaUrl); - mediaUrl = hosted.url; - } - await client.messages.create({ - from: To, - to: From, - body: replyPayload.text ?? "", - ...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}), - }); - if (verbose) - runtime.log( - success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`), - ); - } catch (err) { - logTwilioSendError(err, From ?? undefined, runtime); - } - } - - // Respond 200 OK to Twilio. - res.type("text/xml").send(""); - }); - - app.use((_req, res) => { - if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`)); - res.status(404).send("clawdis webhook: not found"); - }); - - // Start server and resolve once listening; reject on bind error. - return await new Promise((resolve, reject) => { - const server = app.listen(port); - - const onListening = () => { - cleanup(); - runtime.log( - `📥 Webhook listening on http://localhost:${port}${normalizedPath}`, - ); - resolve(server); - }; - - const onError = (err: NodeJS.ErrnoException) => { - cleanup(); - reject(err); - }; - - const cleanup = () => { - server.off("listening", onListening); - server.off("error", onError); - }; - - server.once("listening", onListening); - server.once("error", onError); - }); -} - -function buildTwilioBasicAuth(env: EnvConfig) { - if ("authToken" in env.auth) { - return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString( - "base64", - ); - } - return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString( - "base64", - ); -} diff --git a/src/utils.ts b/src/utils.ts index df6125d2c..38d526de0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,11 +7,11 @@ export async function ensureDir(dir: string) { await fs.promises.mkdir(dir, { recursive: true }); } -export type Provider = "twilio" | "web"; +export type Provider = "web"; export function assertProvider(input: string): asserts input is Provider { - if (input !== "twilio" && input !== "web") { - throw new Error("Provider must be 'twilio' or 'web'"); + if (input !== "web") { + throw new Error("Provider must be 'web'"); } } @@ -71,10 +71,5 @@ export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } -// Prefer new branding directory; fall back to legacy for compatibility. -export const CONFIG_DIR = (() => { - const clawdis = path.join(os.homedir(), ".clawdis"); - const legacy = path.join(os.homedir(), ".warelay"); - if (fs.existsSync(clawdis)) return clawdis; - return legacy; -})(); +// Fixed configuration root; legacy ~/.warelay is no longer used. +export const CONFIG_DIR = path.join(os.homedir(), ".clawdis"); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 11f3ad1e5..c2bfba319 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -301,8 +301,7 @@ export async function runWebHeartbeatOnce(opts: { const stripped = stripHeartbeatToken(replyPayload.text); if (stripped.shouldSkip && !hasMedia) { // Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works. - const sessionCfg = cfg.inbound?.reply?.session; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store); const store = loadSessionStore(storePath); if (sessionSnapshot.entry && store[sessionSnapshot.key]) { store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt; @@ -350,8 +349,7 @@ export async function runWebHeartbeatOnce(opts: { } function getFallbackRecipient(cfg: ReturnType) { - const sessionCfg = cfg.inbound?.reply?.session; - const storePath = resolveStorePath(sessionCfg?.store); + const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store); const store = loadSessionStore(storePath); const candidates = Object.entries(store).filter(([key]) => key !== "global"); if (candidates.length === 0) { @@ -372,7 +370,7 @@ 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 storePath = resolveStorePath(cfg.inbound?.reply?.session?.store); const store = loadSessionStore(storePath); return Object.entries(store) .filter(([key]) => key !== "global" && key !== "unknown") diff --git a/src/web/logout.test.ts b/src/web/logout.test.ts index a1d822cae..1a3c7928b 100644 --- a/src/web/logout.test.ts +++ b/src/web/logout.test.ts @@ -17,7 +17,7 @@ describe("web logout", () => { beforeEach(() => { vi.clearAllMocks(); - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "warelay-logout-")); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-logout-")); vi.spyOn(os, "homedir").mockReturnValue(tmpDir); }); @@ -32,10 +32,16 @@ describe("web logout", () => { }); it("deletes cached credentials when present", async () => { - const credsDir = path.join(tmpDir, ".warelay", "credentials"); + const credsDir = path.join(tmpDir, ".clawdis", "credentials"); fs.mkdirSync(credsDir, { recursive: true }); fs.writeFileSync(path.join(credsDir, "creds.json"), "{}"); - const sessionsPath = path.join(tmpDir, ".warelay", "sessions.json"); + const sessionsPath = path.join( + tmpDir, + ".clawdis", + "sessions", + "sessions.json", + ); + fs.mkdirSync(path.dirname(sessionsPath), { recursive: true }); fs.writeFileSync(sessionsPath, "{}"); const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js"); diff --git a/src/web/session.ts b/src/web/session.ts index 67c32cd95..37c7fa9dc 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -212,9 +212,12 @@ export function logWebSelfId( } export async function pickProvider(pref: Provider | "auto"): Promise { - // Auto-select web when logged in; otherwise fall back to twilio. - if (pref !== "auto") return pref; + const choice: Provider = pref === "auto" ? "web" : pref; const hasWeb = await webAuthExists(); - if (hasWeb) return "web"; - return "twilio"; + if (!hasWeb) { + throw new Error( + "No WhatsApp Web session found. Run `clawdis login --verbose` to link.", + ); + } + return choice; } diff --git a/src/webhook/server.test.ts b/src/webhook/server.test.ts deleted file mode 100644 index ad8b62966..000000000 --- a/src/webhook/server.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import * as impl from "../twilio/webhook.js"; -import * as entry from "./server.js"; - -describe("webhook server wrapper", () => { - it("re-exports startWebhook", () => { - expect(entry.startWebhook).toBe(impl.startWebhook); - }); -}); diff --git a/src/webhook/server.ts b/src/webhook/server.ts deleted file mode 100644 index 5cd0a31fa..000000000 --- a/src/webhook/server.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* istanbul ignore file */ -import { startWebhook } from "../twilio/webhook.js"; - -// Thin wrapper to keep webhook server co-located with other webhook helpers. -export { startWebhook }; - -export type WebhookServer = Awaited>; diff --git a/src/webhook/update.test.ts b/src/webhook/update.test.ts deleted file mode 100644 index a9766eeea..000000000 --- a/src/webhook/update.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import * as impl from "../twilio/update-webhook.js"; -import * as entry from "./update.js"; - -describe("webhook update wrappers", () => { - it("mirror the Twilio implementations", () => { - expect(entry.updateWebhook).toBe(impl.updateWebhook); - expect(entry.findIncomingNumberSid).toBe(impl.findIncomingNumberSid); - expect(entry.findMessagingServiceSid).toBe(impl.findMessagingServiceSid); - expect(entry.setMessagingServiceWebhook).toBe( - impl.setMessagingServiceWebhook, - ); - }); -}); diff --git a/src/webhook/update.ts b/src/webhook/update.ts deleted file mode 100644 index 047b8096b..000000000 --- a/src/webhook/update.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* istanbul ignore file */ -export { - findIncomingNumberSid, - findMessagingServiceSid, - setMessagingServiceWebhook, - updateWebhook, -} from "../twilio/update-webhook.js"; diff --git a/test/mocks/twilio.ts b/test/mocks/twilio.ts deleted file mode 100644 index 8335b7a98..000000000 --- a/test/mocks/twilio.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { vi } from "vitest"; - -type MockFn unknown> = ReturnType>; - -export type MockTwilioClient = { - messages: ((sid?: string) => { fetch: MockFn<() => unknown> }) & { - create: MockFn<() => unknown>; - list: MockFn<() => unknown>; - }; - request?: MockFn<() => unknown>; - messaging?: { - v2: { channelsSenders: ((sid?: string) => { fetch: MockFn<() => unknown>; update: MockFn<() => unknown> }) & { list: MockFn<() => unknown> } }; - v1: { services: MockFn<() => { update: MockFn<() => unknown>; fetch: MockFn<() => unknown> }> }; - }; - incomingPhoneNumbers?: ((sid?: string) => { update: MockFn<() => unknown> }) & { - list: MockFn<() => unknown>; - }; -}; - -export function createMockTwilio() { - const messages = Object.assign(vi.fn((sid?: string) => ({ fetch: vi.fn() })), { - create: vi.fn(), - list: vi.fn(), - }); - - const channelsSenders = Object.assign( - vi.fn((sid?: string) => ({ fetch: vi.fn(), update: vi.fn() })), - { list: vi.fn() }, - ); - - const services = vi.fn(() => ({ update: vi.fn(), fetch: vi.fn() })); - - const incomingPhoneNumbers = Object.assign( - vi.fn((sid?: string) => ({ update: vi.fn() })), - { list: vi.fn() }, - ); - - const client: MockTwilioClient = { - messages, - request: vi.fn(), - messaging: { - v2: { channelsSenders }, - v1: { services }, - }, - incomingPhoneNumbers, - }; - - const factory = Object.assign(vi.fn(() => client), { - _client: client, - _createClient: () => client, - }); - - return { client, factory }; -}