diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f11a2b58..060c5c6bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] 1.0.5 +## 1.1.0 — 2025-11-25 ### Pending - Web auto-replies now resize/recompress media and honor `inbound.reply.mediaMaxMb` in `~/.warelay/warelay.json` (default 5 MB) to avoid provider/API limits. diff --git a/biome.json b/biome.json index dbb36c4fb..ac36d2c9d 100644 --- a/biome.json +++ b/biome.json @@ -2,7 +2,8 @@ "$schema": "https://biomejs.dev/schemas/biome.json", "formatter": { "enabled": true, - "indentWidth": 2 + "indentWidth": 2, + "indentStyle": "space" }, "linter": { "enabled": true, diff --git a/src/auto-reply/claude.test.ts b/src/auto-reply/claude.test.ts index 4d7b582f5..f6a894c96 100644 --- a/src/auto-reply/claude.test.ts +++ b/src/auto-reply/claude.test.ts @@ -3,37 +3,37 @@ import { describe, expect, it } from "vitest"; import { parseClaudeJson, parseClaudeJsonText } from "./claude.js"; describe("claude JSON parsing", () => { - it("extracts text from single JSON object", () => { - const out = parseClaudeJsonText('{"text":"hello"}'); - expect(out).toBe("hello"); - }); + it("extracts text from single JSON object", () => { + const out = parseClaudeJsonText('{"text":"hello"}'); + expect(out).toBe("hello"); + }); - it("extracts from newline-delimited JSON", () => { - const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}'); - expect(out).toBe("there"); - }); + it("extracts from newline-delimited JSON", () => { + const out = parseClaudeJsonText('{"irrelevant":1}\n{"text":"there"}'); + expect(out).toBe("there"); + }); - it("returns undefined on invalid JSON", () => { - expect(parseClaudeJsonText("not json")).toBeUndefined(); - }); + it("returns undefined on invalid JSON", () => { + expect(parseClaudeJsonText("not json")).toBeUndefined(); + }); - it("extracts text from Claude CLI result field and preserves metadata", () => { - const sample = { - type: "result", - subtype: "success", - result: "hello from result field", - duration_ms: 1234, - usage: { server_tool_use: { tool_a: 2 } }, - }; - const parsed = parseClaudeJson(JSON.stringify(sample)); - expect(parsed?.text).toBe("hello from result field"); - expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 }); - expect(parsed?.valid).toBe(true); - }); + it("extracts text from Claude CLI result field and preserves metadata", () => { + const sample = { + type: "result", + subtype: "success", + result: "hello from result field", + duration_ms: 1234, + usage: { server_tool_use: { tool_a: 2 } }, + }; + const parsed = parseClaudeJson(JSON.stringify(sample)); + expect(parsed?.text).toBe("hello from result field"); + expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 }); + expect(parsed?.valid).toBe(true); + }); - it("marks invalid Claude JSON as invalid but still attempts text extraction", () => { - const parsed = parseClaudeJson('{"unexpected":1}'); - expect(parsed?.valid).toBe(false); - expect(parsed?.text).toBeUndefined(); - }); + it("marks invalid Claude JSON as invalid but still attempts text extraction", () => { + const parsed = parseClaudeJson('{"unexpected":1}'); + expect(parsed?.valid).toBe(false); + expect(parsed?.text).toBeUndefined(); + }); }); diff --git a/src/auto-reply/claude.ts b/src/auto-reply/claude.ts index 6cbb754c5..11b28bb84 100644 --- a/src/auto-reply/claude.ts +++ b/src/auto-reply/claude.ts @@ -4,159 +4,159 @@ import { z } from "zod"; // Preferred binary name for Claude CLI invocations. export const CLAUDE_BIN = "claude"; export const CLAUDE_IDENTITY_PREFIX = - "You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present."; + "You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present."; function extractClaudeText(payload: unknown): string | undefined { - // Best-effort walker to find the primary text field in Claude JSON outputs. - if (payload == null) return undefined; - if (typeof payload === "string") return payload; - if (Array.isArray(payload)) { - for (const item of payload) { - const found = extractClaudeText(item); - if (found) return found; - } - return undefined; - } - if (typeof payload === "object") { - const obj = payload as Record; - if (typeof obj.result === "string") return obj.result; - if (typeof obj.text === "string") return obj.text; - if (typeof obj.completion === "string") return obj.completion; - if (typeof obj.output === "string") return obj.output; - if (obj.message) { - const inner = extractClaudeText(obj.message); - if (inner) return inner; - } - if (Array.isArray(obj.messages)) { - const inner = extractClaudeText(obj.messages); - if (inner) return inner; - } - if (Array.isArray(obj.content)) { - for (const block of obj.content) { - if ( - block && - typeof block === "object" && - (block as { type?: string }).type === "text" && - typeof (block as { text?: unknown }).text === "string" - ) { - return (block as { text: string }).text; - } - const inner = extractClaudeText(block); - if (inner) return inner; - } - } - } - return undefined; + // Best-effort walker to find the primary text field in Claude JSON outputs. + if (payload == null) return undefined; + if (typeof payload === "string") return payload; + if (Array.isArray(payload)) { + for (const item of payload) { + const found = extractClaudeText(item); + if (found) return found; + } + return undefined; + } + if (typeof payload === "object") { + const obj = payload as Record; + if (typeof obj.result === "string") return obj.result; + if (typeof obj.text === "string") return obj.text; + if (typeof obj.completion === "string") return obj.completion; + if (typeof obj.output === "string") return obj.output; + if (obj.message) { + const inner = extractClaudeText(obj.message); + if (inner) return inner; + } + if (Array.isArray(obj.messages)) { + const inner = extractClaudeText(obj.messages); + if (inner) return inner; + } + if (Array.isArray(obj.content)) { + for (const block of obj.content) { + if ( + block && + typeof block === "object" && + (block as { type?: string }).type === "text" && + typeof (block as { text?: unknown }).text === "string" + ) { + return (block as { text: string }).text; + } + const inner = extractClaudeText(block); + if (inner) return inner; + } + } + } + return undefined; } export type ClaudeJsonParseResult = { - text?: string; - parsed: unknown; - valid: boolean; + text?: string; + parsed: unknown; + valid: boolean; }; const ClaudeJsonSchema = z - .object({ - type: z.string().optional(), - subtype: z.string().optional(), - is_error: z.boolean().optional(), - result: z.string().optional(), - text: z.string().optional(), - completion: z.string().optional(), - output: z.string().optional(), - message: z.any().optional(), - messages: z.any().optional(), - content: z.any().optional(), - duration_ms: z.number().optional(), - duration_api_ms: z.number().optional(), - num_turns: z.number().optional(), - session_id: z.string().optional(), - total_cost_usd: z.number().optional(), - usage: z.record(z.string(), z.any()).optional(), - modelUsage: z.record(z.string(), z.any()).optional(), - }) - .passthrough() - .refine( - (obj) => - typeof obj.result === "string" || - typeof obj.text === "string" || - typeof obj.completion === "string" || - typeof obj.output === "string" || - obj.message !== undefined || - obj.messages !== undefined || - obj.content !== undefined, - { message: "Not a Claude JSON payload" }, - ); + .object({ + type: z.string().optional(), + subtype: z.string().optional(), + is_error: z.boolean().optional(), + result: z.string().optional(), + text: z.string().optional(), + completion: z.string().optional(), + output: z.string().optional(), + message: z.any().optional(), + messages: z.any().optional(), + content: z.any().optional(), + duration_ms: z.number().optional(), + duration_api_ms: z.number().optional(), + num_turns: z.number().optional(), + session_id: z.string().optional(), + total_cost_usd: z.number().optional(), + usage: z.record(z.string(), z.any()).optional(), + modelUsage: z.record(z.string(), z.any()).optional(), + }) + .passthrough() + .refine( + (obj) => + typeof obj.result === "string" || + typeof obj.text === "string" || + typeof obj.completion === "string" || + typeof obj.output === "string" || + obj.message !== undefined || + obj.messages !== undefined || + obj.content !== undefined, + { message: "Not a Claude JSON payload" }, + ); type ClaudeSafeParse = ReturnType; export function parseClaudeJson( - raw: string, + raw: string, ): ClaudeJsonParseResult | undefined { - // Handle a single JSON blob or newline-delimited JSON; return the first parsed payload. - let firstParsed: unknown; - const candidates = [ - raw, - ...raw - .split(/\n+/) - .map((s) => s.trim()) - .filter(Boolean), - ]; - for (const candidate of candidates) { - try { - const parsed = JSON.parse(candidate); - if (firstParsed === undefined) firstParsed = parsed; - let validation: ClaudeSafeParse | { success: false }; - try { - validation = ClaudeJsonSchema.safeParse(parsed); - } catch { - validation = { success: false } as const; - } - const validated = validation.success ? validation.data : parsed; - const isLikelyClaude = - typeof validated === "object" && - validated !== null && - ("result" in validated || - "text" in validated || - "completion" in validated || - "output" in validated); - const text = extractClaudeText(validated); - if (text) - return { - parsed: validated, - text, - // Treat parse as valid when schema passes or we still see Claude-like shape. - valid: Boolean(validation?.success || isLikelyClaude), - }; - } catch { - // ignore parse errors; try next candidate - } - } - if (firstParsed !== undefined) { - let validation: ClaudeSafeParse | { success: false }; - try { - validation = ClaudeJsonSchema.safeParse(firstParsed); - } catch { - validation = { success: false } as const; - } - const validated = validation.success ? validation.data : firstParsed; - const isLikelyClaude = - typeof validated === "object" && - validated !== null && - ("result" in validated || - "text" in validated || - "completion" in validated || - "output" in validated); - return { - parsed: validated, - text: extractClaudeText(validated), - valid: Boolean(validation?.success || isLikelyClaude), - }; - } - return undefined; + // Handle a single JSON blob or newline-delimited JSON; return the first parsed payload. + let firstParsed: unknown; + const candidates = [ + raw, + ...raw + .split(/\n+/) + .map((s) => s.trim()) + .filter(Boolean), + ]; + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate); + if (firstParsed === undefined) firstParsed = parsed; + let validation: ClaudeSafeParse | { success: false }; + try { + validation = ClaudeJsonSchema.safeParse(parsed); + } catch { + validation = { success: false } as const; + } + const validated = validation.success ? validation.data : parsed; + const isLikelyClaude = + typeof validated === "object" && + validated !== null && + ("result" in validated || + "text" in validated || + "completion" in validated || + "output" in validated); + const text = extractClaudeText(validated); + if (text) + return { + parsed: validated, + text, + // Treat parse as valid when schema passes or we still see Claude-like shape. + valid: Boolean(validation?.success || isLikelyClaude), + }; + } catch { + // ignore parse errors; try next candidate + } + } + if (firstParsed !== undefined) { + let validation: ClaudeSafeParse | { success: false }; + try { + validation = ClaudeJsonSchema.safeParse(firstParsed); + } catch { + validation = { success: false } as const; + } + const validated = validation.success ? validation.data : firstParsed; + const isLikelyClaude = + typeof validated === "object" && + validated !== null && + ("result" in validated || + "text" in validated || + "completion" in validated || + "output" in validated); + return { + parsed: validated, + text: extractClaudeText(validated), + valid: Boolean(validation?.success || isLikelyClaude), + }; + } + return undefined; } export function parseClaudeJsonText(raw: string): string | undefined { - const parsed = parseClaudeJson(raw); - return parsed?.text; + const parsed = parseClaudeJson(raw); + return parsed?.text; } diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 5656b1bc4..ae574e115 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -6,12 +6,12 @@ import path from "node:path"; import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message.js"; import { loadConfig, type WarelayConfig } from "../config/config.js"; import { - DEFAULT_IDLE_MINUTES, - DEFAULT_RESET_TRIGGER, - deriveSessionKey, - loadSessionStore, - resolveStorePath, - saveSessionStore, + DEFAULT_IDLE_MINUTES, + DEFAULT_RESET_TRIGGER, + deriveSessionKey, + loadSessionStore, + resolveStorePath, + saveSessionStore, } from "../config/sessions.js"; import { info, isVerbose, logVerbose } from "../globals.js"; import { logError } from "../logger.js"; @@ -23,664 +23,694 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import type { TwilioRequester } from "../twilio/types.js"; import { sendTypingIndicator } from "../twilio/typing.js"; import { - CLAUDE_BIN, - CLAUDE_IDENTITY_PREFIX, - type ClaudeJsonParseResult, - parseClaudeJson, + CLAUDE_BIN, + CLAUDE_IDENTITY_PREFIX, + type ClaudeJsonParseResult, + parseClaudeJson, } from "./claude.js"; import { - applyTemplate, - type MsgContext, - type TemplateContext, + applyTemplate, + type MsgContext, + type TemplateContext, } from "./templating.js"; type GetReplyOptions = { - onReplyStart?: () => Promise | void; + onReplyStart?: () => Promise | void; }; function summarizeClaudeMetadata(payload: unknown): string | undefined { - if (!payload || typeof payload !== "object") return undefined; - const obj = payload as Record; - const parts: string[] = []; + if (!payload || typeof payload !== "object") return undefined; + const obj = payload as Record; + const parts: string[] = []; - if (typeof obj.duration_ms === "number") { - parts.push(`duration=${obj.duration_ms}ms`); - } - if (typeof obj.duration_api_ms === "number") { - parts.push(`api=${obj.duration_api_ms}ms`); - } - if (typeof obj.num_turns === "number") { - parts.push(`turns=${obj.num_turns}`); - } - if (typeof obj.total_cost_usd === "number") { - parts.push(`cost=$${obj.total_cost_usd.toFixed(4)}`); - } + if (typeof obj.duration_ms === "number") { + parts.push(`duration=${obj.duration_ms}ms`); + } + if (typeof obj.duration_api_ms === "number") { + parts.push(`api=${obj.duration_api_ms}ms`); + } + if (typeof obj.num_turns === "number") { + parts.push(`turns=${obj.num_turns}`); + } + if (typeof obj.total_cost_usd === "number") { + parts.push(`cost=$${obj.total_cost_usd.toFixed(4)}`); + } - const usage = obj.usage; - if (usage && typeof usage === "object") { - const serverToolUse = ( - usage as { server_tool_use?: Record } - ).server_tool_use; - if (serverToolUse && typeof serverToolUse === "object") { - const toolCalls = Object.values(serverToolUse).reduce( - (sum, val) => { - if (typeof val === "number") return sum + val; - return sum; - }, - 0, - ); - if (toolCalls > 0) parts.push(`tool_calls=${toolCalls}`); - } - } + const usage = obj.usage; + if (usage && typeof usage === "object") { + const serverToolUse = ( + usage as { server_tool_use?: Record } + ).server_tool_use; + if (serverToolUse && typeof serverToolUse === "object") { + const toolCalls = Object.values(serverToolUse).reduce( + (sum, val) => { + if (typeof val === "number") return sum + val; + return sum; + }, + 0, + ); + if (toolCalls > 0) parts.push(`tool_calls=${toolCalls}`); + } + } - const modelUsage = obj.modelUsage; - if (modelUsage && typeof modelUsage === "object") { - const models = Object.keys(modelUsage as Record); - if (models.length) { - const display = - models.length > 2 - ? `${models.slice(0, 2).join(",")}+${models.length - 2}` - : models.join(","); - parts.push(`models=${display}`); - } - } + const modelUsage = obj.modelUsage; + if (modelUsage && typeof modelUsage === "object") { + const models = Object.keys(modelUsage as Record); + if (models.length) { + const display = + models.length > 2 + ? `${models.slice(0, 2).join(",")}+${models.length - 2}` + : models.join(","); + parts.push(`models=${display}`); + } + } - return parts.length ? parts.join(", ") : undefined; + return parts.length ? parts.join(", ") : undefined; } export type ReplyPayload = { - text?: string; - mediaUrl?: string; + text?: string; + mediaUrl?: string; + mediaUrls?: string[]; }; export async function getReplyFromConfig( - ctx: MsgContext, - opts?: GetReplyOptions, - configOverride?: WarelayConfig, - commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout, + ctx: MsgContext, + opts?: GetReplyOptions, + configOverride?: WarelayConfig, + commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout, ): Promise { - // Choose reply from config: static text or external command stdout. - const cfg = configOverride ?? loadConfig(); - const reply = cfg.inbound?.reply; - const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1); - const timeoutMs = timeoutSeconds * 1000; - let started = false; - const triggerTyping = async () => { - await opts?.onReplyStart?.(); - }; - const onReplyStart = async () => { - if (started) return; - started = true; - await triggerTyping(); - }; - let typingTimer: NodeJS.Timeout | undefined; - const typingIntervalMs = - reply?.mode === "command" - ? (reply.typingIntervalSeconds ?? - reply?.session?.typingIntervalSeconds ?? - 30) * 1000 - : 0; - const cleanupTyping = () => { - if (typingTimer) { - clearInterval(typingTimer); - typingTimer = undefined; - } - }; - const startTypingLoop = async () => { - if (!opts?.onReplyStart) return; - if (typingIntervalMs <= 0) return; - if (typingTimer) return; - await triggerTyping(); - typingTimer = setInterval(() => { - void triggerTyping(); - }, typingIntervalMs); - }; - let transcribedText: string | undefined; + // Choose reply from config: static text or external command stdout. + const cfg = configOverride ?? loadConfig(); + const reply = cfg.inbound?.reply; + const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1); + const timeoutMs = timeoutSeconds * 1000; + let started = false; + const triggerTyping = async () => { + await opts?.onReplyStart?.(); + }; + const onReplyStart = async () => { + if (started) return; + started = true; + await triggerTyping(); + }; + let typingTimer: NodeJS.Timeout | undefined; + const typingIntervalMs = + reply?.mode === "command" + ? (reply.typingIntervalSeconds ?? + reply?.session?.typingIntervalSeconds ?? + 30) * 1000 + : 0; + const cleanupTyping = () => { + if (typingTimer) { + clearInterval(typingTimer); + typingTimer = undefined; + } + }; + const startTypingLoop = async () => { + if (!opts?.onReplyStart) return; + if (typingIntervalMs <= 0) return; + if (typingTimer) return; + await triggerTyping(); + typingTimer = setInterval(() => { + void triggerTyping(); + }, typingIntervalMs); + }; + let transcribedText: string | undefined; - // Optional audio transcription before templating/session handling. - if (cfg.inbound?.transcribeAudio && isAudio(ctx.MediaType)) { - const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime); - if (transcribed?.text) { - transcribedText = transcribed.text; - ctx.Body = transcribed.text; - ctx.Transcript = transcribed.text; - logVerbose("Replaced Body with audio transcript for reply flow"); - } - } + // Optional audio transcription before templating/session handling. + if (cfg.inbound?.transcribeAudio && isAudio(ctx.MediaType)) { + const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime); + if (transcribed?.text) { + transcribedText = transcribed.text; + ctx.Body = transcribed.text; + ctx.Transcript = transcribed.text; + logVerbose("Replaced Body with audio transcript for reply flow"); + } + } - // Optional session handling (conversation reuse + /new resets) - const sessionCfg = reply?.session; - const resetTriggers = sessionCfg?.resetTriggers?.length - ? sessionCfg.resetTriggers - : [DEFAULT_RESET_TRIGGER]; - const idleMinutes = Math.max( - sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, - 1, - ); - const sessionScope = sessionCfg?.scope ?? "per-sender"; - const storePath = resolveStorePath(sessionCfg?.store); - let sessionStore: ReturnType | undefined; - let sessionKey: string | undefined; + // Optional session handling (conversation reuse + /new resets) + const sessionCfg = reply?.session; + const resetTriggers = sessionCfg?.resetTriggers?.length + ? sessionCfg.resetTriggers + : [DEFAULT_RESET_TRIGGER]; + const idleMinutes = Math.max( + sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, + 1, + ); + const sessionScope = sessionCfg?.scope ?? "per-sender"; + const storePath = resolveStorePath(sessionCfg?.store); + let sessionStore: ReturnType | undefined; + let sessionKey: string | undefined; - let sessionId: string | undefined; - let isNewSession = false; - let bodyStripped: string | undefined; - let systemSent = false; + let sessionId: string | undefined; + let isNewSession = false; + let bodyStripped: string | undefined; + let systemSent = false; - if (sessionCfg) { - const trimmedBody = (ctx.Body ?? "").trim(); - for (const trigger of resetTriggers) { - if (!trigger) continue; - if (trimmedBody === trigger) { - isNewSession = true; - bodyStripped = ""; - break; - } - const triggerPrefix = `${trigger} `; - if (trimmedBody.startsWith(triggerPrefix)) { - isNewSession = true; - bodyStripped = trimmedBody.slice(trigger.length).trimStart(); - break; - } - } + if (sessionCfg) { + const trimmedBody = (ctx.Body ?? "").trim(); + for (const trigger of resetTriggers) { + if (!trigger) continue; + if (trimmedBody === trigger) { + isNewSession = true; + bodyStripped = ""; + break; + } + const triggerPrefix = `${trigger} `; + if (trimmedBody.startsWith(triggerPrefix)) { + isNewSession = true; + bodyStripped = trimmedBody.slice(trigger.length).trimStart(); + break; + } + } - sessionKey = deriveSessionKey(sessionScope, ctx); - sessionStore = loadSessionStore(storePath); - const entry = sessionStore[sessionKey]; - const idleMs = idleMinutes * 60_000; - const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs; + sessionKey = deriveSessionKey(sessionScope, ctx); + sessionStore = loadSessionStore(storePath); + const entry = sessionStore[sessionKey]; + const idleMs = idleMinutes * 60_000; + const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs; - if (!isNewSession && freshEntry) { - sessionId = entry.sessionId; - systemSent = entry.systemSent ?? false; - } else { - sessionId = crypto.randomUUID(); - isNewSession = true; - systemSent = false; - } + if (!isNewSession && freshEntry) { + sessionId = entry.sessionId; + systemSent = entry.systemSent ?? false; + } else { + sessionId = crypto.randomUUID(); + isNewSession = true; + systemSent = false; + } - sessionStore[sessionKey] = { sessionId, updatedAt: Date.now(), systemSent }; - await saveSessionStore(storePath, sessionStore); - } + sessionStore[sessionKey] = { sessionId, updatedAt: Date.now(), systemSent }; + await saveSessionStore(storePath, sessionStore); + } - const sessionCtx: TemplateContext = { - ...ctx, - BodyStripped: bodyStripped ?? ctx.Body, - SessionId: sessionId, - IsNewSession: isNewSession ? "true" : "false", - }; + const sessionCtx: TemplateContext = { + ...ctx, + BodyStripped: bodyStripped ?? ctx.Body, + SessionId: sessionId, + IsNewSession: isNewSession ? "true" : "false", + }; - // Optional allowlist by origin number (E.164 without whatsapp: prefix) - const allowFrom = cfg.inbound?.allowFrom; - if (Array.isArray(allowFrom) && allowFrom.length > 0) { - const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); - if (!allowFrom.includes(from)) { - logVerbose( - `Skipping auto-reply: sender ${from || ""} not in allowFrom list`, - ); - cleanupTyping(); - return undefined; - } - } + // Optional allowlist by origin number (E.164 without whatsapp: prefix) + const allowFrom = cfg.inbound?.allowFrom; + if (Array.isArray(allowFrom) && allowFrom.length > 0) { + const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); + if (!allowFrom.includes(from)) { + logVerbose( + `Skipping auto-reply: sender ${from || ""} not in allowFrom list`, + ); + cleanupTyping(); + return undefined; + } + } - await startTypingLoop(); + await startTypingLoop(); - // Optional prefix injected before Body for templating/command prompts. - const sendSystemOnce = sessionCfg?.sendSystemOnce === true; - const isFirstTurnInSession = isNewSession || !systemSent; - const sessionIntro = - isFirstTurnInSession && sessionCfg?.sessionIntro - ? applyTemplate(sessionCfg.sessionIntro, sessionCtx) - : ""; - const bodyPrefix = reply?.bodyPrefix - ? applyTemplate(reply.bodyPrefix, sessionCtx) - : ""; - const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; - const prefixedBodyBase = (() => { - let body = baseBody; - if (!sendSystemOnce || isFirstTurnInSession) { - body = bodyPrefix ? `${bodyPrefix}${body}` : body; - } - if (sessionIntro) { - body = `${sessionIntro}\n\n${body}`; - } - return body; - })(); - if ( - sessionCfg && - sendSystemOnce && - isFirstTurnInSession && - sessionStore && - sessionKey - ) { - sessionStore[sessionKey] = { - ...(sessionStore[sessionKey] ?? {}), - sessionId: sessionId ?? crypto.randomUUID(), - updatedAt: Date.now(), - systemSent: true, - }; - await saveSessionStore(storePath, sessionStore); - systemSent = true; - } + // Optional prefix injected before Body for templating/command prompts. + const sendSystemOnce = sessionCfg?.sendSystemOnce === true; + const isFirstTurnInSession = isNewSession || !systemSent; + const sessionIntro = + isFirstTurnInSession && sessionCfg?.sessionIntro + ? applyTemplate(sessionCfg.sessionIntro, sessionCtx) + : ""; + const bodyPrefix = reply?.bodyPrefix + ? applyTemplate(reply.bodyPrefix, sessionCtx) + : ""; + const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; + const prefixedBodyBase = (() => { + let body = baseBody; + if (!sendSystemOnce || isFirstTurnInSession) { + body = bodyPrefix ? `${bodyPrefix}${body}` : body; + } + if (sessionIntro) { + body = `${sessionIntro}\n\n${body}`; + } + return body; + })(); + if ( + sessionCfg && + sendSystemOnce && + isFirstTurnInSession && + sessionStore && + sessionKey + ) { + sessionStore[sessionKey] = { + ...(sessionStore[sessionKey] ?? {}), + sessionId: sessionId ?? crypto.randomUUID(), + updatedAt: Date.now(), + systemSent: true, + }; + await saveSessionStore(storePath, sessionStore); + systemSent = true; + } - const prefixedBody = - transcribedText && reply?.mode === "command" - ? [prefixedBodyBase, `Transcript:\n${transcribedText}`] - .filter(Boolean) - .join("\n\n") - : prefixedBodyBase; - const mediaNote = ctx.MediaPath?.length - ? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]` - : undefined; - // For command prompts we prepend the media note so Claude et al. see it; text replies stay clean. - const mediaReplyHint = - mediaNote && reply?.mode === "command" - ? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body." - : undefined; - const commandBody = mediaNote - ? [mediaNote, mediaReplyHint, prefixedBody ?? ""] - .filter(Boolean) - .join("\n") - .trim() - : prefixedBody; - const templatingCtx: TemplateContext = { - ...sessionCtx, - Body: commandBody, - BodyStripped: commandBody, - }; - if (!reply) { - logVerbose("No inbound.reply configured; skipping auto-reply"); - cleanupTyping(); - return undefined; - } + const prefixedBody = + transcribedText && reply?.mode === "command" + ? [prefixedBodyBase, `Transcript:\n${transcribedText}`] + .filter(Boolean) + .join("\n\n") + : prefixedBodyBase; + const mediaNote = ctx.MediaPath?.length + ? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]` + : undefined; + // For command prompts we prepend the media note so Claude et al. see it; text replies stay clean. + const mediaReplyHint = + mediaNote && reply?.mode === "command" + ? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body." + : undefined; + const commandBody = mediaNote + ? [mediaNote, mediaReplyHint, prefixedBody ?? ""] + .filter(Boolean) + .join("\n") + .trim() + : prefixedBody; + const templatingCtx: TemplateContext = { + ...sessionCtx, + Body: commandBody, + BodyStripped: commandBody, + }; + if (!reply) { + logVerbose("No inbound.reply configured; skipping auto-reply"); + cleanupTyping(); + return undefined; + } - if (reply.mode === "text" && reply.text) { - await onReplyStart(); - logVerbose("Using text auto-reply from config"); - const result = { - text: applyTemplate(reply.text, templatingCtx), - mediaUrl: reply.mediaUrl, - }; - cleanupTyping(); - return result; - } + if (reply.mode === "text" && reply.text) { + await onReplyStart(); + logVerbose("Using text auto-reply from config"); + const result = { + text: applyTemplate(reply.text, templatingCtx), + mediaUrl: reply.mediaUrl, + }; + cleanupTyping(); + return result; + } - if (reply.mode === "command" && reply.command?.length) { - await onReplyStart(); - let argv = reply.command.map((part) => applyTemplate(part, templatingCtx)); - const templatePrefix = - reply.template && (!sendSystemOnce || isFirstTurnInSession || !systemSent) - ? applyTemplate(reply.template, templatingCtx) - : ""; - if (templatePrefix && argv.length > 0) { - argv = [argv[0], templatePrefix, ...argv.slice(1)]; - } + if (reply.mode === "command" && reply.command?.length) { + await onReplyStart(); + let argv = reply.command.map((part) => applyTemplate(part, templatingCtx)); + const templatePrefix = + reply.template && (!sendSystemOnce || isFirstTurnInSession || !systemSent) + ? applyTemplate(reply.template, templatingCtx) + : ""; + if (templatePrefix && argv.length > 0) { + argv = [argv[0], templatePrefix, ...argv.slice(1)]; + } - // Ensure Claude commands can emit plain text by forcing --output-format when configured. - // We inject the flags only when the user points at the `claude` binary and has opted in via config, - // so existing custom argv or non-Claude commands remain untouched. - if ( - reply.claudeOutputFormat && - argv.length > 0 && - path.basename(argv[0]) === CLAUDE_BIN - ) { - const hasOutputFormat = argv.some( - (part) => - part === "--output-format" || part.startsWith("--output-format="), - ); - // Keep the final argument as the prompt/body; insert options just before it. - const insertBeforeBody = Math.max(argv.length - 1, 0); - if (!hasOutputFormat) { - argv = [ - ...argv.slice(0, insertBeforeBody), - "--output-format", - reply.claudeOutputFormat, - ...argv.slice(insertBeforeBody), - ]; - } - const hasPrintFlag = argv.some( - (part) => part === "-p" || part === "--print", - ); - if (!hasPrintFlag) { - const insertIdx = Math.max(argv.length - 1, 0); - argv = [...argv.slice(0, insertIdx), "-p", ...argv.slice(insertIdx)]; - } - } + // Ensure Claude commands can emit plain text by forcing --output-format when configured. + // We inject the flags only when the user points at the `claude` binary and has opted in via config, + // so existing custom argv or non-Claude commands remain untouched. + if ( + reply.claudeOutputFormat && + argv.length > 0 && + path.basename(argv[0]) === CLAUDE_BIN + ) { + const hasOutputFormat = argv.some( + (part) => + part === "--output-format" || part.startsWith("--output-format="), + ); + // Keep the final argument as the prompt/body; insert options just before it. + const insertBeforeBody = Math.max(argv.length - 1, 0); + if (!hasOutputFormat) { + argv = [ + ...argv.slice(0, insertBeforeBody), + "--output-format", + reply.claudeOutputFormat, + ...argv.slice(insertBeforeBody), + ]; + } + const hasPrintFlag = argv.some( + (part) => part === "-p" || part === "--print", + ); + if (!hasPrintFlag) { + const insertIdx = Math.max(argv.length - 1, 0); + argv = [...argv.slice(0, insertIdx), "-p", ...argv.slice(insertIdx)]; + } + } - // Inject session args if configured (use resume for existing, session-id for new) - if (reply.session) { - const sessionArgList = ( - isNewSession - ? (reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"]) - : (reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"]) - ).map((part) => applyTemplate(part, templatingCtx)); - if (sessionArgList.length) { - const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true; - const insertAt = - insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length; - argv = [ - ...argv.slice(0, insertAt), - ...sessionArgList, - ...argv.slice(insertAt), - ]; - } - } - let finalArgv = argv; - const isClaudeInvocation = - finalArgv.length > 0 && path.basename(finalArgv[0]) === CLAUDE_BIN; - if (isClaudeInvocation && finalArgv.length > 0) { - const bodyIdx = finalArgv.length - 1; - const existingBody = finalArgv[bodyIdx] ?? ""; - finalArgv = [ - ...finalArgv.slice(0, bodyIdx), - [CLAUDE_IDENTITY_PREFIX, existingBody].filter(Boolean).join("\n\n"), - ]; - } - logVerbose( - `Running command auto-reply: ${finalArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`, - ); - const started = Date.now(); - try { - const { stdout, stderr, code, signal, killed } = await enqueueCommand( - () => commandRunner(finalArgv, { timeoutMs, cwd: reply.cwd }), - { - onWait: (waitMs, queuedAhead) => { - if (isVerbose()) { - logVerbose( - `Command auto-reply queued for ${waitMs}ms (${queuedAhead} ahead)`, - ); - } - }, - }, - ); - const rawStdout = stdout.trim(); - let mediaFromCommand: string | undefined; - let trimmed = rawStdout; - if (stderr?.trim()) { - logVerbose(`Command auto-reply stderr: ${stderr.trim()}`); - } - let parsed: ClaudeJsonParseResult | undefined; - if ( - trimmed && - (reply.claudeOutputFormat === "json" || isClaudeInvocation) - ) { - // Claude JSON mode: extract the human text for both logging and reply while keeping metadata. - parsed = parseClaudeJson(trimmed); - if (parsed?.parsed && isVerbose()) { - const summary = summarizeClaudeMetadata(parsed.parsed); - if (summary) logVerbose(`Claude JSON meta: ${summary}`); - logVerbose( - `Claude JSON raw: ${JSON.stringify(parsed.parsed, null, 2)}`, - ); - } - if (parsed?.text) { - logVerbose( - `Claude JSON parsed -> ${parsed.text.slice(0, 120)}${parsed.text.length > 120 ? "…" : ""}`, - ); - trimmed = parsed.text.trim(); - } else { - logVerbose("Claude JSON parse failed; returning raw stdout"); - } - } - // Run media extraction once on the final human text (post-JSON parse if available). - const { text: cleanedText, mediaUrl: mediaFound } = - splitMediaFromOutput(trimmed); - trimmed = cleanedText; - if (mediaFound) { - mediaFromCommand = mediaFound; - if (isVerbose()) logVerbose(`MEDIA token extracted: ${mediaFound}`); - } else if (isVerbose()) { - logVerbose("No MEDIA token extracted from final text"); - } - if (!trimmed && !mediaFromCommand) { - const meta = parsed - ? summarizeClaudeMetadata(parsed.parsed) - : undefined; - trimmed = `(command produced no output${meta ? `; ${meta}` : ""})`; - logVerbose("No text/media produced; injecting fallback notice to user"); - } - logVerbose( - `Command auto-reply stdout (trimmed): ${trimmed || ""}`, - ); - logVerbose(`Command auto-reply finished in ${Date.now() - started}ms`); - if ((code ?? 0) !== 0) { - console.error( - `Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`, - ); - return undefined; - } - if (killed && !signal) { - console.error( - `Command auto-reply process killed before completion (exit code ${code ?? "unknown"})`, - ); - return undefined; - } - const mediaUrl = mediaFromCommand ?? reply.mediaUrl; - const result = - trimmed || mediaUrl - ? { text: trimmed || undefined, mediaUrl } - : undefined; - cleanupTyping(); - return result; - } catch (err) { - const elapsed = Date.now() - started; - const anyErr = err as { killed?: boolean; signal?: string }; - const timeoutHit = anyErr.killed === true || anyErr.signal === "SIGKILL"; - const errorObj = err as { - stdout?: string; - stderr?: string; - }; - if (errorObj.stderr?.trim()) { - logVerbose(`Command auto-reply stderr: ${errorObj.stderr.trim()}`); - } - if (timeoutHit) { - console.error( - `Command auto-reply timed out after ${elapsed}ms (limit ${timeoutMs}ms)`, - ); - const baseMsg = `Command timed out after ${timeoutSeconds}s. Try a shorter prompt or split the request.`; - const partial = errorObj.stdout?.trim(); - const partialSnippet = - partial && partial.length > 800 - ? `${partial.slice(0, 800)}...` - : partial; - const text = partialSnippet - ? `${baseMsg}\n\nPartial output before timeout:\n${partialSnippet}` - : baseMsg; - const result = { text }; - cleanupTyping(); - return result; - } else { - logError( - `Command auto-reply failed after ${elapsed}ms: ${String(err)}`, - ); - } - cleanupTyping(); - return undefined; - } - } + // Inject session args if configured (use resume for existing, session-id for new) + if (reply.session) { + const sessionArgList = ( + isNewSession + ? (reply.session.sessionArgNew ?? ["--session-id", "{{SessionId}}"]) + : (reply.session.sessionArgResume ?? ["--resume", "{{SessionId}}"]) + ).map((part) => applyTemplate(part, templatingCtx)); + if (sessionArgList.length) { + const insertBeforeBody = reply.session.sessionArgBeforeBody ?? true; + const insertAt = + insertBeforeBody && argv.length > 1 ? argv.length - 1 : argv.length; + argv = [ + ...argv.slice(0, insertAt), + ...sessionArgList, + ...argv.slice(insertAt), + ]; + } + } + let finalArgv = argv; + const isClaudeInvocation = + finalArgv.length > 0 && path.basename(finalArgv[0]) === CLAUDE_BIN; + if (isClaudeInvocation && finalArgv.length > 0) { + const bodyIdx = finalArgv.length - 1; + const existingBody = finalArgv[bodyIdx] ?? ""; + finalArgv = [ + ...finalArgv.slice(0, bodyIdx), + [CLAUDE_IDENTITY_PREFIX, existingBody].filter(Boolean).join("\n\n"), + ]; + } + logVerbose( + `Running command auto-reply: ${finalArgv.join(" ")}${reply.cwd ? ` (cwd: ${reply.cwd})` : ""}`, + ); + const started = Date.now(); + try { + const { stdout, stderr, code, signal, killed } = await enqueueCommand( + () => commandRunner(finalArgv, { timeoutMs, cwd: reply.cwd }), + { + onWait: (waitMs, queuedAhead) => { + if (isVerbose()) { + logVerbose( + `Command auto-reply queued for ${waitMs}ms (${queuedAhead} ahead)`, + ); + } + }, + }, + ); + const rawStdout = stdout.trim(); + let mediaFromCommand: string | undefined; + let trimmed = rawStdout; + if (stderr?.trim()) { + logVerbose(`Command auto-reply stderr: ${stderr.trim()}`); + } + let parsed: ClaudeJsonParseResult | undefined; + if ( + trimmed && + (reply.claudeOutputFormat === "json" || isClaudeInvocation) + ) { + // Claude JSON mode: extract the human text for both logging and reply while keeping metadata. + parsed = parseClaudeJson(trimmed); + if (parsed?.parsed && isVerbose()) { + const summary = summarizeClaudeMetadata(parsed.parsed); + if (summary) logVerbose(`Claude JSON meta: ${summary}`); + logVerbose( + `Claude JSON raw: ${JSON.stringify(parsed.parsed, null, 2)}`, + ); + } + if (parsed?.text) { + logVerbose( + `Claude JSON parsed -> ${parsed.text.slice(0, 120)}${parsed.text.length > 120 ? "…" : ""}`, + ); + trimmed = parsed.text.trim(); + } else { + logVerbose("Claude JSON parse failed; returning raw stdout"); + } + } + // Run media extraction once on the final human text (post-JSON parse if available). + const { text: cleanedText, mediaUrls: mediaFound } = + splitMediaFromOutput(trimmed); + trimmed = cleanedText; + if (mediaFound?.length) { + mediaFromCommand = mediaFound; + if (isVerbose()) logVerbose(`MEDIA token extracted: ${mediaFound}`); + } else if (isVerbose()) { + logVerbose("No MEDIA token extracted from final text"); + } + if (!trimmed && !mediaFromCommand) { + const meta = parsed + ? summarizeClaudeMetadata(parsed.parsed) + : undefined; + trimmed = `(command produced no output${meta ? `; ${meta}` : ""})`; + logVerbose("No text/media produced; injecting fallback notice to user"); + } + logVerbose( + `Command auto-reply stdout (trimmed): ${trimmed || ""}`, + ); + logVerbose(`Command auto-reply finished in ${Date.now() - started}ms`); + if ((code ?? 0) !== 0) { + console.error( + `Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`, + ); + return undefined; + } + if (killed && !signal) { + console.error( + `Command auto-reply process killed before completion (exit code ${code ?? "unknown"})`, + ); + return undefined; + } + const mediaUrls = + mediaFromCommand ?? (reply.mediaUrl ? [reply.mediaUrl] : undefined); + const result = + trimmed || mediaUrls?.length + ? { + text: trimmed || undefined, + mediaUrl: mediaUrls?.[0], + mediaUrls, + } + : undefined; + cleanupTyping(); + return result; + } catch (err) { + const elapsed = Date.now() - started; + const anyErr = err as { killed?: boolean; signal?: string }; + const timeoutHit = anyErr.killed === true || anyErr.signal === "SIGKILL"; + const errorObj = err as { + stdout?: string; + stderr?: string; + }; + if (errorObj.stderr?.trim()) { + logVerbose(`Command auto-reply stderr: ${errorObj.stderr.trim()}`); + } + if (timeoutHit) { + console.error( + `Command auto-reply timed out after ${elapsed}ms (limit ${timeoutMs}ms)`, + ); + const baseMsg = `Command timed out after ${timeoutSeconds}s. Try a shorter prompt or split the request.`; + const partial = errorObj.stdout?.trim(); + const partialSnippet = + partial && partial.length > 800 + ? `${partial.slice(0, 800)}...` + : partial; + const text = partialSnippet + ? `${baseMsg}\n\nPartial output before timeout:\n${partialSnippet}` + : baseMsg; + const result = { text }; + cleanupTyping(); + return result; + } else { + logError( + `Command auto-reply failed after ${elapsed}ms: ${String(err)}`, + ); + } + cleanupTyping(); + return undefined; + } + } - cleanupTyping(); - return undefined; + cleanupTyping(); + return undefined; } type TwilioLikeClient = TwilioRequester & { - messages: { - create: (opts: { - from?: string; - to?: string; - body: string; - }) => Promise; - }; + messages: { + create: (opts: { + from?: string; + to?: string; + body: string; + }) => Promise; + }; }; export async function autoReplyIfConfigured( - client: TwilioLikeClient, - message: MessageInstance, - configOverride?: WarelayConfig, - runtime: RuntimeEnv = defaultRuntime, + 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 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; + // 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 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. - if (cfg.inbound?.transcribeAudio && message.media?.length) { - const media = message.media[0]; - const contentType = (media as { contentType?: string }).contentType; - if (contentType?.startsWith("audio")) { - const transcribed = await transcribeInboundAudio( - cfg, - { - mediaUrl: mediaUrl ?? undefined, - contentType, - }, - runtime, - ); - if (transcribed?.text) { - ctx.Body = transcribed.text; - ctx.MediaType = contentType; - logVerbose("Replaced Body with audio transcript for reply flow"); - } - } - } + // Optional audio transcription before building reply. + if (cfg.inbound?.transcribeAudio && message.media?.length) { + const media = message.media[0]; + const contentType = (media as { contentType?: string }).contentType; + if (contentType?.startsWith("audio")) { + const transcribed = await transcribeInboundAudio( + cfg, + { + mediaUrl: mediaUrl ?? undefined, + contentType, + }, + runtime, + ); + if (transcribed?.text) { + ctx.Body = transcribed.text; + ctx.MediaType = contentType; + logVerbose("Replaced Body with audio transcript for reply flow"); + } + } + } - const replyResult = await getReplyFromConfig( - ctx, - { - onReplyStart: () => sendTypingIndicator(client, runtime, message.sid), - }, - cfg, - ); - if (!replyResult || (!replyResult.text && !replyResult.mediaUrl)) return; + const replyResult = await getReplyFromConfig( + ctx, + { + onReplyStart: () => sendTypingIndicator(client, runtime, message.sid), + }, + cfg, + ); + if ( + !replyResult || + (!replyResult.text && + !replyResult.mediaUrl && + !replyResult.mediaUrls?.length) + ) + return; - 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 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; + } - if (replyResult.text) { - logVerbose( - `Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyResult.text.length}`, - ); - } else { - logVerbose( - `Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`, - ); - } + if (replyResult.text) { + logVerbose( + `Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyResult.text.length}`, + ); + } else { + logVerbose( + `Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`, + ); + } - try { - let mediaUrl = replyResult.mediaUrl; - if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) { - const hosted = await ensureMediaHosted(mediaUrl); - mediaUrl = hosted.url; - } - await client.messages.create({ - from: replyFrom, - to: replyTo, - body: replyResult.text ?? "", - ...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}), - }); - if (isVerbose()) { - console.log( - info( - `↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${replyResult.mediaUrl ? ", media" : ""})`, - ), - ); - } - } 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)); - } - } + try { + const mediaList = replyResult.mediaUrls?.length + ? replyResult.mediaUrls + : replyResult.mediaUrl + ? [replyResult.mediaUrl] + : []; + + 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] } : {}), + }); + }; + + if (mediaList.length === 0) { + await sendTwilio(replyResult.text ?? ""); + } else { + // First media with body (if any), then remaining as separate media-only sends. + await sendTwilio(replyResult.text ?? "", mediaList[0]); + for (const extra of mediaList.slice(1)) { + await sendTwilio("", extra); + } + } + if (isVerbose()) { + console.log( + info( + `↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${replyResult.mediaUrl ? ", media" : ""})`, + ), + ); + } + } 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)); + } + } } function isAudio(mediaType?: string | null) { - return Boolean(mediaType?.startsWith("audio")); + return Boolean(mediaType?.startsWith("audio")); } async function transcribeInboundAudio( - cfg: WarelayConfig, - ctx: MsgContext, - runtime: RuntimeEnv, + cfg: WarelayConfig, + ctx: MsgContext, + runtime: RuntimeEnv, ): Promise<{ text: string } | undefined> { - const transcriber = cfg.inbound?.transcribeAudio; - if (!transcriber?.command?.length) return undefined; + const transcriber = cfg.inbound?.transcribeAudio; + if (!transcriber?.command?.length) return undefined; - const timeoutMs = Math.max((transcriber.timeoutSeconds ?? 45) * 1000, 1_000); - let tmpPath: string | undefined; - let mediaPath = ctx.MediaPath; - try { - if (!mediaPath && ctx.MediaUrl) { - const res = await fetch(ctx.MediaUrl); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const arrayBuf = await res.arrayBuffer(); - const buffer = Buffer.from(arrayBuf); - tmpPath = path.join( - os.tmpdir(), - `warelay-audio-${crypto.randomUUID()}.ogg`, - ); - await fs.writeFile(tmpPath, buffer); - mediaPath = tmpPath; - if (isVerbose()) { - logVerbose( - `Downloaded audio for transcription (${(buffer.length / (1024 * 1024)).toFixed(2)}MB) -> ${tmpPath}`, - ); - } - } - if (!mediaPath) return undefined; + const timeoutMs = Math.max((transcriber.timeoutSeconds ?? 45) * 1000, 1_000); + let tmpPath: string | undefined; + let mediaPath = ctx.MediaPath; + try { + if (!mediaPath && ctx.MediaUrl) { + const res = await fetch(ctx.MediaUrl); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const arrayBuf = await res.arrayBuffer(); + const buffer = Buffer.from(arrayBuf); + tmpPath = path.join( + os.tmpdir(), + `warelay-audio-${crypto.randomUUID()}.ogg`, + ); + await fs.writeFile(tmpPath, buffer); + mediaPath = tmpPath; + if (isVerbose()) { + logVerbose( + `Downloaded audio for transcription (${(buffer.length / (1024 * 1024)).toFixed(2)}MB) -> ${tmpPath}`, + ); + } + } + if (!mediaPath) return undefined; - const templCtx: MsgContext = { ...ctx, MediaPath: mediaPath }; - const argv = transcriber.command.map((part) => - applyTemplate(part, templCtx), - ); - if (isVerbose()) { - logVerbose(`Transcribing audio via command: ${argv.join(" ")}`); - } - const { stdout } = await runExec(argv[0], argv.slice(1), { - timeoutMs, - maxBuffer: 5 * 1024 * 1024, - }); - const text = stdout.trim(); - if (!text) return undefined; - return { text }; - } catch (err) { - runtime.error?.(`Audio transcription failed: ${String(err)}`); - return undefined; - } finally { - if (tmpPath) { - void fs.unlink(tmpPath).catch(() => {}); - } - } + const templCtx: MsgContext = { ...ctx, MediaPath: mediaPath }; + const argv = transcriber.command.map((part) => + applyTemplate(part, templCtx), + ); + if (isVerbose()) { + logVerbose(`Transcribing audio via command: ${argv.join(" ")}`); + } + const { stdout } = await runExec(argv[0], argv.slice(1), { + timeoutMs, + maxBuffer: 5 * 1024 * 1024, + }); + const text = stdout.trim(); + if (!text) return undefined; + return { text }; + } catch (err) { + runtime.error?.(`Audio transcription failed: ${String(err)}`); + return undefined; + } finally { + if (tmpPath) { + void fs.unlink(tmpPath).catch(() => {}); + } + } } diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 3aa244ea8..434f651fb 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -1,24 +1,24 @@ export type MsgContext = { - Body?: string; - From?: string; - To?: string; - MessageSid?: string; - MediaPath?: string; - MediaUrl?: string; - MediaType?: string; - Transcript?: string; + Body?: string; + From?: string; + To?: string; + MessageSid?: string; + MediaPath?: string; + MediaUrl?: string; + MediaType?: string; + Transcript?: string; }; export type TemplateContext = MsgContext & { - BodyStripped?: string; - SessionId?: string; - IsNewSession?: string; + BodyStripped?: string; + SessionId?: string; + IsNewSession?: string; }; // Simple {{Placeholder}} interpolation using inbound message context. export function applyTemplate(str: string, ctx: TemplateContext) { - return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => { - const value = (ctx as Record)[key]; - return value == null ? "" : String(value); - }); + return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => { + const value = (ctx as Record)[key]; + return value == null ? "" : String(value); + }); } diff --git a/src/cli/deps.ts b/src/cli/deps.ts index eded217da..51c428175 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -6,9 +6,9 @@ import { ensurePortAvailable, handlePortError } from "../infra/ports.js"; import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js"; import { ensureMediaHosted } from "../media/host.js"; import { - logWebSelfId, - monitorWebProvider, - sendMessageWeb, + logWebSelfId, + monitorWebProvider, + sendMessageWeb, } from "../providers/web/index.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { createClient } from "../twilio/client.js"; @@ -22,89 +22,89 @@ import { updateWebhook } from "../webhook/update.js"; import { waitForever } from "./wait.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; + 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, + 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, - }); + // 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; - }, - }; + // 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}`), - ); + // 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 b31f531d2..a30a19f25 100644 --- a/src/cli/program.test.ts +++ b/src/cli/program.test.ts @@ -14,11 +14,11 @@ const waitForever = vi.fn(); const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay"); const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(() => { - throw new Error("exit"); - }), + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), }; vi.mock("../commands/send.js", () => ({ sendCommand })); @@ -27,63 +27,63 @@ 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, + loginWeb, + monitorWebProvider, + pickProvider, })); vi.mock("./deps.js", () => ({ - createDefaultDeps: () => ({ waitForever }), - logTwilioFrom, - logWebSelfId, - monitorTwilio, + createDefaultDeps: () => ({ waitForever }), + logTwilioFrom, + logWebSelfId, + monitorTwilio, })); vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux })); const { buildProgram } = await import("./program.js"); describe("cli program", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - it("runs send with required options", async () => { - const program = buildProgram(); - await program.parseAsync(["send", "--to", "+1", "--message", "hi"], { - from: "user", - }); - expect(sendCommand).toHaveBeenCalled(); - }); + it("runs send with required options", async () => { + const program = buildProgram(); + await program.parseAsync(["send", "--to", "+1", "--message", "hi"], { + from: "user", + }); + 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("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 program.parseAsync( - ["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"], - { from: "user" }, - ); - expect(logWebSelfId).toHaveBeenCalled(); - expect(ensureTwilioEnv).toHaveBeenCalled(); - expect(monitorTwilio).toHaveBeenCalledWith(2, 1); - }); + it("falls back to twilio when web relay fails", async () => { + pickProvider.mockResolvedValue("web"); + monitorWebProvider.mockRejectedValue(new Error("no web")); + const program = buildProgram(); + await program.parseAsync( + ["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"], + { from: "user" }, + ); + expect(logWebSelfId).toHaveBeenCalled(); + expect(ensureTwilioEnv).toHaveBeenCalled(); + expect(monitorTwilio).toHaveBeenCalledWith(2, 1); + }); - it("runs relay tmux attach command", async () => { - const program = buildProgram(); - await program.parseAsync(["relay:tmux:attach"], { from: "user" }); - expect(spawnRelayTmux).toHaveBeenCalledWith( - "pnpm warelay relay --verbose", - true, - false, - ); - }); + it("runs relay tmux attach command", async () => { + const program = buildProgram(); + await program.parseAsync(["relay:tmux:attach"], { from: "user" }); + expect(spawnRelayTmux).toHaveBeenCalledWith( + "pnpm warelay relay --verbose", + true, + false, + ); + }); }); diff --git a/src/cli/program.ts b/src/cli/program.ts index 4a78611e5..eea03f8a4 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -10,327 +10,327 @@ import { defaultRuntime } from "../runtime.js"; import type { Provider } from "../utils.js"; import { VERSION } from "../version.js"; import { - createDefaultDeps, - logTwilioFrom, - logWebSelfId, - monitorTwilio, + createDefaultDeps, + logTwilioFrom, + logWebSelfId, + monitorTwilio, } 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."; + const program = new Command(); + const PROGRAM_VERSION = VERSION; + const TAGLINE = + "Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked."; - program - .name("warelay") - .description("WhatsApp relay CLI (Twilio or WhatsApp Web session)") - .version(PROGRAM_VERSION); + program + .name("warelay") + .description("WhatsApp relay CLI (Twilio or WhatsApp Web session)") + .version(PROGRAM_VERSION); - const formatIntroLine = (version: string, rich = true) => { - const base = `📡 warelay ${version} — ${TAGLINE}`; - return rich && chalk.level > 0 - ? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}` - : base; - }; + const formatIntroLine = (version: string, rich = true) => { + const base = `📡 warelay ${version} — ${TAGLINE}`; + return rich && chalk.level > 0 + ? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}` + : base; + }; - program.configureHelp({ - optionTerm: (option) => chalk.yellow(option.flags), - subcommandTerm: (cmd) => chalk.green(cmd.name()), - }); + program.configureHelp({ + optionTerm: (option) => chalk.yellow(option.flags), + subcommandTerm: (cmd) => chalk.green(cmd.name()), + }); - program.configureOutput({ - writeOut: (str) => { - const colored = str - .replace(/^Usage:/gm, chalk.bold.cyan("Usage:")) - .replace(/^Options:/gm, chalk.bold.cyan("Options:")) - .replace(/^Commands:/gm, chalk.bold.cyan("Commands:")); - process.stdout.write(colored); - }, - writeErr: (str) => process.stderr.write(str), - outputError: (str, write) => write(chalk.red(str)), - }); + program.configureOutput({ + writeOut: (str) => { + const colored = str + .replace(/^Usage:/gm, chalk.bold.cyan("Usage:")) + .replace(/^Options:/gm, chalk.bold.cyan("Options:")) + .replace(/^Commands:/gm, chalk.bold.cyan("Commands:")); + process.stdout.write(colored); + }, + writeErr: (str) => process.stderr.write(str), + outputError: (str, write) => write(chalk.red(str)), + }); - if (process.argv.includes("-V") || process.argv.includes("--version")) { - console.log(formatIntroLine(PROGRAM_VERSION)); - process.exit(0); - } + if (process.argv.includes("-V") || process.argv.includes("--version")) { + console.log(formatIntroLine(PROGRAM_VERSION)); + process.exit(0); + } - program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`); - const examples = [ - [ - "warelay login --verbose", - "Link personal WhatsApp Web and show QR + connection logs.", - ], - [ - 'warelay send --to +15551234567 --message "Hi" --provider web --json', - "Send via your web session and print JSON result.", - ], - [ - "warelay relay --provider auto --interval 5 --lookback 15 --verbose", - "Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.", - ], - [ - "warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose", - "Start webhook + Tailscale Funnel and update Twilio callbacks.", - ], - [ - "warelay status --limit 10 --lookback 60 --json", - "Show last 10 messages from the past hour as JSON.", - ], - ] as const; + program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`); + const examples = [ + [ + "warelay login --verbose", + "Link personal WhatsApp Web and show QR + connection logs.", + ], + [ + 'warelay send --to +15551234567 --message "Hi" --provider web --json', + "Send via your web session and print JSON result.", + ], + [ + "warelay relay --provider auto --interval 5 --lookback 15 --verbose", + "Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.", + ], + [ + "warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose", + "Start webhook + Tailscale Funnel and update Twilio callbacks.", + ], + [ + "warelay status --limit 10 --lookback 60 --json", + "Show last 10 messages from the past hour as JSON.", + ], + ] as const; - const fmtExamples = examples - .map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`) - .join("\n"); + const fmtExamples = examples + .map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`) + .join("\n"); - program.addHelpText( - "afterAll", - `\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`, - ); + program.addHelpText( + "afterAll", + `\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`, + ); - program - .command("login") - .description("Link your personal WhatsApp via QR (web provider)") - .option("--verbose", "Verbose connection logs", false) - .action(async (opts) => { - setVerbose(Boolean(opts.verbose)); - try { - await loginWeb(Boolean(opts.verbose)); - } catch (err) { - defaultRuntime.error(danger(`Web login failed: ${String(err)}`)); - defaultRuntime.exit(1); - } - }); + program + .command("login") + .description("Link your personal WhatsApp via QR (web provider)") + .option("--verbose", "Verbose connection logs", false) + .action(async (opts) => { + setVerbose(Boolean(opts.verbose)); + try { + await loginWeb(Boolean(opts.verbose)); + } catch (err) { + defaultRuntime.error(danger(`Web login failed: ${String(err)}`)); + defaultRuntime.exit(1); + } + }); - program - .command("send") - .description("Send a WhatsApp message") - .requiredOption( - "-t, --to ", - "Recipient number in E.164 (e.g. +15551234567)", - ) - .requiredOption("-m, --message ", "Message body") - .option( - "--media ", - "Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.", - ) - .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) - .addHelpText( - "after", - ` + program + .command("send") + .description("Send a WhatsApp message") + .requiredOption( + "-t, --to ", + "Recipient number in E.164 (e.g. +15551234567)", + ) + .requiredOption("-m, --message ", "Message body") + .option( + "--media ", + "Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.", + ) + .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) + .addHelpText( + "after", + ` Examples: warelay send --to +15551234567 --message "Hi" # wait 20s for delivery (default) warelay send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget warelay send --to +15551234567 --message "Hi" --dry-run # print payload only warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`, - ) - .action(async (opts) => { - setVerbose(Boolean(opts.verbose)); - const deps = createDefaultDeps(); - try { - await sendCommand(opts, deps, defaultRuntime); - } catch (err) { - defaultRuntime.error(String(err)); - defaultRuntime.exit(1); - } - }); + ) + .action(async (opts) => { + setVerbose(Boolean(opts.verbose)); + const deps = createDefaultDeps(); + try { + await sendCommand(opts, deps, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); - 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", - ) - .option("--verbose", "Verbose logging", false) - .addHelpText( - "after", - ` + 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", + ) + .option("--verbose", "Verbose logging", false) + .addHelpText( + "after", + ` Examples: warelay relay # auto: web if logged-in, else twilio poll warelay relay --provider web # force personal web session warelay relay --provider twilio # force twilio poll warelay relay --provider twilio --interval 2 --lookback 30 `, - ) - .action(async (opts) => { - setVerbose(Boolean(opts.verbose)); - 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); - 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); - } + ) + .action(async (opts) => { + setVerbose(Boolean(opts.verbose)); + 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); + 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); + } - const provider = await pickProvider(providerPref as Provider | "auto"); + const provider = await pickProvider(providerPref as Provider | "auto"); - if (provider === "web") { - logWebSelfId(defaultRuntime, true); - try { - await monitorWebProvider(Boolean(opts.verbose)); - return; - } catch (err) { - if (providerPref === "auto") { - defaultRuntime.error( - warn("Web session unavailable; falling back to twilio."), - ); - } else { - defaultRuntime.error(danger(`Web relay failed: ${String(err)}`)); - defaultRuntime.exit(1); - } - } - } + if (provider === "web") { + logWebSelfId(defaultRuntime, true); + try { + await monitorWebProvider(Boolean(opts.verbose)); + return; + } catch (err) { + if (providerPref === "auto") { + defaultRuntime.error( + warn("Web session unavailable; falling back to twilio."), + ); + } else { + defaultRuntime.error(danger(`Web relay failed: ${String(err)}`)); + defaultRuntime.exit(1); + } + } + } - ensureTwilioEnv(); - logTwilioFrom(); - await monitorTwilio(intervalSeconds, lookbackMinutes); - }); + ensureTwilioEnv(); + logTwilioFrom(); + await monitorTwilio(intervalSeconds, lookbackMinutes); + }); - 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") - .option("--json", "Output JSON instead of text", false) - .option("--verbose", "Verbose logging", false) - .addHelpText( - "after", - ` + 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") + .option("--json", "Output JSON instead of text", false) + .option("--verbose", "Verbose logging", false) + .addHelpText( + "after", + ` Examples: warelay status # last 20 msgs in past 4h warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m warelay status --json --limit 50 # 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); - } - }); + ) + .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", - ` + 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: warelay webhook # ingress=tailscale (funnel + Twilio update) warelay webhook --ingress none # local-only server (no funnel / no Twilio update) warelay webhook --port 45000 # pick a high, less-colliding port warelay 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(); - } catch (err) { - defaultRuntime.error(String(err)); - defaultRuntime.exit(1); - } - }); + ) + // 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(); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); - program - .command("relay:tmux") - .description( - "Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach", - ) - .action(async () => { - try { - const session = await spawnRelayTmux( - "pnpm warelay relay --verbose", - true, - ); - defaultRuntime.log( - info( - `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`, - ), - ); - } catch (err) { - defaultRuntime.error( - danger(`Failed to start relay tmux session: ${String(err)}`), - ); - defaultRuntime.exit(1); - } - }); + program + .command("relay:tmux") + .description( + "Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach", + ) + .action(async () => { + try { + const session = await spawnRelayTmux( + "pnpm warelay relay --verbose", + true, + ); + defaultRuntime.log( + info( + `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`, + ), + ); + } catch (err) { + defaultRuntime.error( + danger(`Failed to start relay tmux session: ${String(err)}`), + ); + defaultRuntime.exit(1); + } + }); - program - .command("relay:tmux:attach") - .description( - "Attach to the existing warelay-relay tmux session (no restart)", - ) - .action(async () => { - try { - await spawnRelayTmux("pnpm warelay relay --verbose", true, false); - defaultRuntime.log(info("Attached to warelay-relay session.")); - } catch (err) { - defaultRuntime.error( - danger(`Failed to attach to warelay-relay: ${String(err)}`), - ); - defaultRuntime.exit(1); - } - }); + program + .command("relay:tmux:attach") + .description( + "Attach to the existing warelay-relay tmux session (no restart)", + ) + .action(async () => { + try { + await spawnRelayTmux("pnpm warelay relay --verbose", true, false); + defaultRuntime.log(info("Attached to warelay-relay session.")); + } catch (err) { + defaultRuntime.error( + danger(`Failed to attach to warelay-relay: ${String(err)}`), + ); + defaultRuntime.exit(1); + } + }); - return program; + return program; } diff --git a/src/cli/prompt.test.ts b/src/cli/prompt.test.ts index 0c1a2d8bc..808140497 100644 --- a/src/cli/prompt.test.ts +++ b/src/cli/prompt.test.ts @@ -3,47 +3,47 @@ import { describe, expect, it, vi } from "vitest"; import { isYes, setVerbose, setYes } from "../globals.js"; vi.mock("node:readline/promises", () => { - const question = vi.fn<[], Promise>(); - const close = vi.fn(); - const createInterface = vi.fn(() => ({ question, close })); - return { default: { createInterface } }; + const question = vi.fn<[], Promise>(); + const close = vi.fn(); + const createInterface = vi.fn(() => ({ question, close })); + return { default: { createInterface } }; }); type ReadlineMock = { - default: { - createInterface: () => { - question: ReturnType>>; - close: ReturnType; - }; - }; + default: { + createInterface: () => { + question: ReturnType>>; + close: ReturnType; + }; + }; }; const { promptYesNo } = await import("./prompt.js"); const readline = (await import("node:readline/promises")) as ReadlineMock; describe("promptYesNo", () => { - it("returns true when global --yes is set", async () => { - setYes(true); - setVerbose(false); - const result = await promptYesNo("Continue?"); - expect(result).toBe(true); - expect(isYes()).toBe(true); - }); + it("returns true when global --yes is set", async () => { + setYes(true); + setVerbose(false); + const result = await promptYesNo("Continue?"); + expect(result).toBe(true); + expect(isYes()).toBe(true); + }); - it("asks the question and respects default", async () => { - setYes(false); - setVerbose(false); - const { question: questionMock } = readline.default.createInterface(); - questionMock.mockResolvedValueOnce(""); - const resultDefaultYes = await promptYesNo("Continue?", true); - expect(resultDefaultYes).toBe(true); + it("asks the question and respects default", async () => { + setYes(false); + setVerbose(false); + const { question: questionMock } = readline.default.createInterface(); + questionMock.mockResolvedValueOnce(""); + const resultDefaultYes = await promptYesNo("Continue?", true); + expect(resultDefaultYes).toBe(true); - questionMock.mockResolvedValueOnce("n"); - const resultNo = await promptYesNo("Continue?", true); - expect(resultNo).toBe(false); + questionMock.mockResolvedValueOnce("n"); + const resultNo = await promptYesNo("Continue?", true); + expect(resultNo).toBe(false); - questionMock.mockResolvedValueOnce("y"); - const resultYes = await promptYesNo("Continue?", false); - expect(resultYes).toBe(true); - }); + questionMock.mockResolvedValueOnce("y"); + const resultYes = await promptYesNo("Continue?", false); + expect(resultYes).toBe(true); + }); }); diff --git a/src/cli/prompt.ts b/src/cli/prompt.ts index 63f778a9e..606cd176a 100644 --- a/src/cli/prompt.ts +++ b/src/cli/prompt.ts @@ -4,18 +4,18 @@ import readline from "node:readline/promises"; import { isVerbose, isYes } from "../globals.js"; export async function promptYesNo( - question: string, - defaultYes = false, + question: string, + defaultYes = false, ): Promise { - // Simple Y/N prompt honoring global --yes and verbosity flags. - if (isVerbose() && isYes()) return true; // redundant guard when both flags set - if (isYes()) return true; - const rl = readline.createInterface({ input, output }); - const suffix = defaultYes ? " [Y/n] " : " [y/N] "; - const answer = (await rl.question(`${question}${suffix}`)) - .trim() - .toLowerCase(); - rl.close(); - if (!answer) return defaultYes; - return answer.startsWith("y"); + // Simple Y/N prompt honoring global --yes and verbosity flags. + if (isVerbose() && isYes()) return true; // redundant guard when both flags set + if (isYes()) return true; + const rl = readline.createInterface({ input, output }); + const suffix = defaultYes ? " [Y/n] " : " [y/N] "; + const answer = (await rl.question(`${question}${suffix}`)) + .trim() + .toLowerCase(); + rl.close(); + if (!answer) return defaultYes; + return answer.startsWith("y"); } diff --git a/src/cli/relay.e2e.test.ts b/src/cli/relay.e2e.test.ts index a0390a291..07cd981c9 100644 --- a/src/cli/relay.e2e.test.ts +++ b/src/cli/relay.e2e.test.ts @@ -2,43 +2,43 @@ 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.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, + monitorWebProvider, + pickProvider, + logWebSelfId, })); vi.mock("../twilio/monitor.js", () => ({ - monitorTwilio, + monitorTwilio, })); import { buildProgram } from "./program.js"; describe("CLI relay command (e2e-ish)", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - it("runs relay in web mode without crashing", async () => { - const program = buildProgram(); - program.exitOverride(); // throw instead of exiting process on error + 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); + await expect( + program.parseAsync(["relay", "--provider", "web"], { from: "user" }), + ).resolves.toBeInstanceOf(Object); - expect(pickProvider).toHaveBeenCalledWith("web"); - expect(logWebSelfId).toHaveBeenCalledTimes(1); - expect(monitorWebProvider).toHaveBeenCalledWith(false); - expect(monitorTwilio).not.toHaveBeenCalled(); - }); + expect(pickProvider).toHaveBeenCalledWith("web"); + expect(logWebSelfId).toHaveBeenCalledTimes(1); + expect(monitorWebProvider).toHaveBeenCalledWith(false); + expect(monitorTwilio).not.toHaveBeenCalled(); + }); }); diff --git a/src/cli/relay_tmux.test.ts b/src/cli/relay_tmux.test.ts index 6daac19d4..69b9f95ae 100644 --- a/src/cli/relay_tmux.test.ts +++ b/src/cli/relay_tmux.test.ts @@ -3,45 +3,45 @@ import { EventEmitter } from "node:events"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("node:child_process", () => { - const spawn = vi.fn((_cmd: string, _args: string[]) => { - const proc = new EventEmitter() as EventEmitter & { - kill: ReturnType; - }; - queueMicrotask(() => { - proc.emit("exit", 0); - }); - proc.kill = vi.fn(); - return proc; - }); - return { spawn }; + const spawn = vi.fn((_cmd: string, _args: string[]) => { + const proc = new EventEmitter() as EventEmitter & { + kill: ReturnType; + }; + queueMicrotask(() => { + proc.emit("exit", 0); + }); + proc.kill = vi.fn(); + return proc; + }); + return { spawn }; }); const { spawnRelayTmux } = await import("./relay_tmux.js"); const { spawn } = await import("node:child_process"); describe("spawnRelayTmux", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - it("kills old session, starts new one, and attaches", async () => { - const session = await spawnRelayTmux("echo hi", true, true); - expect(session).toBe("warelay-relay"); - const spawnMock = spawn as unknown as vi.Mock; - expect(spawnMock.mock.calls.length).toBe(3); - const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>; - expect(calls[0][0]).toBe("tmux"); // kill-session - expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session - expect(calls[2][1][0]).toBe("attach-session"); - }); + it("kills old session, starts new one, and attaches", async () => { + const session = await spawnRelayTmux("echo hi", true, true); + expect(session).toBe("warelay-relay"); + const spawnMock = spawn as unknown as vi.Mock; + expect(spawnMock.mock.calls.length).toBe(3); + const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>; + expect(calls[0][0]).toBe("tmux"); // kill-session + expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session + expect(calls[2][1][0]).toBe("attach-session"); + }); - it("can skip attach", async () => { - await spawnRelayTmux("echo hi", false, true); - const spawnMock = spawn as unknown as vi.Mock; - const hasAttach = spawnMock.mock.calls.some( - (c) => - Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"), - ); - expect(hasAttach).toBe(false); - }); + it("can skip attach", async () => { + await spawnRelayTmux("echo hi", false, true); + const spawnMock = spawn as unknown as vi.Mock; + const hasAttach = spawnMock.mock.calls.some( + (c) => + Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"), + ); + expect(hasAttach).toBe(false); + }); }); diff --git a/src/cli/relay_tmux.ts b/src/cli/relay_tmux.ts index e4c3366d3..cf78cb1af 100644 --- a/src/cli/relay_tmux.ts +++ b/src/cli/relay_tmux.ts @@ -3,48 +3,48 @@ import { spawn } from "node:child_process"; const SESSION = "warelay-relay"; export async function spawnRelayTmux( - cmd = "pnpm warelay relay --verbose", - attach = true, - restart = true, + cmd = "pnpm warelay relay --verbose", + attach = true, + restart = true, ) { - if (restart) { - await killSession(SESSION); - await new Promise((resolve, reject) => { - const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], { - stdio: "inherit", - shell: false, - }); - child.on("error", reject); - child.on("exit", (code) => { - if (code === 0) resolve(); - else reject(new Error(`tmux exited with code ${code}`)); - }); - }); - } + if (restart) { + await killSession(SESSION); + await new Promise((resolve, reject) => { + const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], { + stdio: "inherit", + shell: false, + }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`tmux exited with code ${code}`)); + }); + }); + } - if (attach) { - await new Promise((resolve, reject) => { - const child = spawn("tmux", ["attach-session", "-t", SESSION], { - stdio: "inherit", - shell: false, - }); - child.on("error", reject); - child.on("exit", (code) => { - if (code === 0) resolve(); - else reject(new Error(`tmux attach exited with code ${code}`)); - }); - }); - } + if (attach) { + await new Promise((resolve, reject) => { + const child = spawn("tmux", ["attach-session", "-t", SESSION], { + stdio: "inherit", + shell: false, + }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`tmux attach exited with code ${code}`)); + }); + }); + } - return SESSION; + return SESSION; } async function killSession(name: string) { - await new Promise((resolve) => { - const child = spawn("tmux", ["kill-session", "-t", name], { - stdio: "ignore", - }); - child.on("exit", () => resolve()); - child.on("error", () => resolve()); - }); + await new Promise((resolve) => { + const child = spawn("tmux", ["kill-session", "-t", name], { + stdio: "ignore", + }); + child.on("exit", () => resolve()); + child.on("error", () => resolve()); + }); } diff --git a/src/cli/wait.test.ts b/src/cli/wait.test.ts index 480e600bf..bd4accecd 100644 --- a/src/cli/wait.test.ts +++ b/src/cli/wait.test.ts @@ -3,14 +3,14 @@ import { describe, expect, it, vi } from "vitest"; import { waitForever } from "./wait.js"; describe("waitForever", () => { - it("creates an unref'ed interval and returns a pending promise", () => { - const setIntervalSpy = vi.spyOn(global, "setInterval"); - const promise = waitForever(); - expect(setIntervalSpy).toHaveBeenCalledWith( - expect.any(Function), - 1_000_000, - ); - expect(promise).toBeInstanceOf(Promise); - setIntervalSpy.mockRestore(); - }); + it("creates an unref'ed interval and returns a pending promise", () => { + const setIntervalSpy = vi.spyOn(global, "setInterval"); + const promise = waitForever(); + expect(setIntervalSpy).toHaveBeenCalledWith( + expect.any(Function), + 1_000_000, + ); + expect(promise).toBeInstanceOf(Promise); + setIntervalSpy.mockRestore(); + }); }); diff --git a/src/cli/wait.ts b/src/cli/wait.ts index 9057a3d1a..51221b842 100644 --- a/src/cli/wait.ts +++ b/src/cli/wait.ts @@ -1,8 +1,8 @@ export function waitForever() { - // Keep event loop alive via an unref'ed interval plus a pending promise. - const interval = setInterval(() => {}, 1_000_000); - interval.unref(); - return new Promise(() => { - /* never resolve */ - }); + // Keep event loop alive via an unref'ed interval plus a pending promise. + const interval = setInterval(() => {}, 1_000_000); + interval.unref(); + return new Promise(() => { + /* never resolve */ + }); } diff --git a/src/commands/send.test.ts b/src/commands/send.test.ts index 158575f91..7e7fb9dd8 100644 --- a/src/commands/send.test.ts +++ b/src/commands/send.test.ts @@ -5,141 +5,141 @@ import type { RuntimeEnv } from "../runtime.js"; import { sendCommand } from "./send.js"; const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(() => { - throw new Error("exit"); - }), + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), }; const baseDeps = { - assertProvider: vi.fn(), - sendMessageWeb: vi.fn(), - resolveTwilioMediaUrl: vi.fn(), - sendMessage: vi.fn(), - waitForFinalStatus: vi.fn(), + assertProvider: vi.fn(), + sendMessageWeb: vi.fn(), + resolveTwilioMediaUrl: vi.fn(), + sendMessage: vi.fn(), + waitForFinalStatus: vi.fn(), } as unknown as CliDeps; 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"); + 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"); - }); + 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; - await sendCommand( - { - to: "+1", - message: "hi", - wait: "5", - poll: "2", - provider: "web", - dryRun: true, - media: "pic.jpg", - }, - deps, - runtime, - ); - expect(deps.sendMessageWeb).not.toHaveBeenCalled(); - }); + it("handles web dry-run and warns on wait", async () => { + const deps = { + ...baseDeps, + sendMessageWeb: vi.fn(), + } as CliDeps; + await sendCommand( + { + to: "+1", + message: "hi", + wait: "5", + poll: "2", + provider: "web", + dryRun: true, + media: "pic.jpg", + }, + deps, + runtime, + ); + expect(deps.sendMessageWeb).not.toHaveBeenCalled(); + }); - it("sends via web and outputs JSON", async () => { - const deps = { - ...baseDeps, - sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }), - } as CliDeps; - await sendCommand( - { - to: "+1", - message: "hi", - wait: "1", - poll: "2", - provider: "web", - json: true, - }, - deps, - runtime, - ); - expect(deps.sendMessageWeb).toHaveBeenCalled(); - expect(runtime.log).toHaveBeenCalledWith( - expect.stringContaining('"provider": "web"'), - ); - }); + it("sends via web and outputs JSON", async () => { + const deps = { + ...baseDeps, + sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }), + } as CliDeps; + await sendCommand( + { + to: "+1", + message: "hi", + wait: "1", + poll: "2", + provider: "web", + json: true, + }, + 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; - await sendCommand( - { - to: "+1", - message: "hi", - wait: "0", - poll: "2", - provider: "twilio", - dryRun: true, - }, - deps, - runtime, - ); - expect(deps.sendMessage).not.toHaveBeenCalled(); - }); + it("supports twilio dry-run", async () => { + const deps = { ...baseDeps } as CliDeps; + 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"'), - ); - }); + 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"'), + ); + }); }); diff --git a/src/commands/send.ts b/src/commands/send.ts index 6c39e71c0..193c407cf 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -4,109 +4,109 @@ import type { RuntimeEnv } from "../runtime.js"; import type { Provider } from "../utils.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, + 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); + 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 (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.")); - } - 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", - to: opts.to, - messageId: res.messageId, - mediaUrl: opts.media ?? null, - }, - null, - 2, - ), - ); - } - return; - } + 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.")); + } + 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", + 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})` : ""}`, - ); - return; - } + if (opts.dryRun) { + runtime.log( + `[dry-run] would send via twilio -> ${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, - }); - } + let mediaUrl: string | undefined; + if (opts.media) { + mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, { + serveMedia: Boolean(opts.serveMedia), + runtime, + }); + } - const result = await deps.sendMessage( - opts.to, - opts.message, - { mediaUrl }, - runtime, - ); - if (opts.json) { - runtime.log( - JSON.stringify( - { - provider: "twilio", - to: opts.to, - sid: result?.sid ?? null, - mediaUrl: mediaUrl ?? null, - }, - null, - 2, - ), - ); - } - if (!result) return; - if (waitSeconds === 0) return; - await deps.waitForFinalStatus( - result.client, - result.sid, - waitSeconds, - pollSeconds, - runtime, - ); + const result = await deps.sendMessage( + opts.to, + opts.message, + { mediaUrl }, + runtime, + ); + if (opts.json) { + runtime.log( + JSON.stringify( + { + provider: "twilio", + to: opts.to, + sid: result?.sid ?? null, + mediaUrl: mediaUrl ?? 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 4d5450ac1..e12e27c1a 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -5,46 +5,46 @@ import type { RuntimeEnv } from "../runtime.js"; import { statusCommand } from "./status.js"; vi.mock("../twilio/messages.js", () => ({ - formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`, + formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`, })); const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(() => { - throw new Error("exit"); - }), + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), }; const deps: CliDeps = { - listRecentMessages: vi.fn(), + 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("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), - ); - }); + 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), + ); + }); - 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"); - }); + 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"); + }); }); diff --git a/src/commands/status.ts b/src/commands/status.ts index 39fba78bd..74b233a36 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -3,29 +3,29 @@ import type { RuntimeEnv } from "../runtime.js"; import { formatMessageLine } from "../twilio/messages.js"; export async function statusCommand( - opts: { limit: string; lookback: string; json?: boolean }, - deps: CliDeps, - runtime: RuntimeEnv, + opts: { limit: string; lookback: string; json?: boolean }, + deps: CliDeps, + 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 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 messages = await deps.listRecentMessages(lookbackMinutes, limit); - if (opts.json) { - runtime.log(JSON.stringify(messages, null, 2)); - return; - } - if (messages.length === 0) { - runtime.log("No messages found in the requested window."); - return; - } - for (const m of messages) { - runtime.log(formatMessageLine(m)); - } + const messages = await deps.listRecentMessages(lookbackMinutes, limit); + if (opts.json) { + runtime.log(JSON.stringify(messages, null, 2)); + return; + } + if (messages.length === 0) { + runtime.log("No messages found in the requested window."); + return; + } + for (const m of messages) { + runtime.log(formatMessageLine(m)); + } } diff --git a/src/commands/up.test.ts b/src/commands/up.test.ts index c75486839..0b2d5eb25 100644 --- a/src/commands/up.test.ts +++ b/src/commands/up.test.ts @@ -5,72 +5,72 @@ 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"); - }), + 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), + 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("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("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"); - }); + 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 index 6466a0b22..3003f98fd 100644 --- a/src/commands/up.ts +++ b/src/commands/up.ts @@ -4,65 +4,65 @@ 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, + 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"); - } + 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}`); + 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, - ); + 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); + 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.", - ); + runtime.log( + "\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.", + ); - return { server, publicUrl, senderSid, waiter }; + return { server, publicUrl, senderSid, waiter }; } diff --git a/src/commands/webhook.test.ts b/src/commands/webhook.test.ts index 2458f08df..e1b040ae6 100644 --- a/src/commands/webhook.test.ts +++ b/src/commands/webhook.test.ts @@ -6,57 +6,57 @@ 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"); - }), + 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 }), + 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("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("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 }); - }); + 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 index 9b8c807c2..cb134e391 100644 --- a/src/commands/webhook.ts +++ b/src/commands/webhook.ts @@ -4,60 +4,60 @@ 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, + 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 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"; + 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; - } + // 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; + // 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 0faee0139..ca40a5149 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -10,145 +10,145 @@ export type ClaudeOutputFormat = "text" | "json" | "stream-json"; export type SessionScope = "per-sender" | "global"; export type SessionConfig = { - scope?: SessionScope; - resetTriggers?: string[]; - idleMinutes?: number; - store?: string; - sessionArgNew?: string[]; - sessionArgResume?: string[]; - sessionArgBeforeBody?: boolean; - sendSystemOnce?: boolean; - sessionIntro?: string; - typingIntervalSeconds?: number; + scope?: SessionScope; + resetTriggers?: string[]; + idleMinutes?: number; + store?: string; + sessionArgNew?: string[]; + sessionArgResume?: string[]; + sessionArgBeforeBody?: boolean; + sendSystemOnce?: boolean; + sessionIntro?: string; + typingIntervalSeconds?: number; }; export type LoggingConfig = { - level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace"; - file?: string; + level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace"; + file?: string; }; export type WarelayConfig = { - logging?: LoggingConfig; - inbound?: { - allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) - transcribeAudio?: { - // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. - command: string[]; - timeoutSeconds?: number; - }; - reply?: { - mode: ReplyMode; - text?: string; // for mode=text, can contain {{Body}} - command?: string[]; // for mode=command, argv with templates - cwd?: string; // working directory for command execution - template?: string; // prepend template string when building command/prompt - timeoutSeconds?: number; // optional command timeout; defaults to 600s - bodyPrefix?: string; // optional string prepended to Body before templating - mediaUrl?: string; // optional media attachment (path or URL) - session?: SessionConfig; - claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format - mediaMaxMb?: number; // optional cap for outbound media (default 5MB) - typingIntervalSeconds?: number; // how often to refresh typing indicator while command runs - }; - }; + logging?: LoggingConfig; + inbound?: { + allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) + transcribeAudio?: { + // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. + command: string[]; + timeoutSeconds?: number; + }; + reply?: { + mode: ReplyMode; + text?: string; // for mode=text, can contain {{Body}} + command?: string[]; // for mode=command, argv with templates + cwd?: string; // working directory for command execution + template?: string; // prepend template string when building command/prompt + timeoutSeconds?: number; // optional command timeout; defaults to 600s + bodyPrefix?: string; // optional string prepended to Body before templating + mediaUrl?: string; // optional media attachment (path or URL) + session?: SessionConfig; + claudeOutputFormat?: ClaudeOutputFormat; // when command starts with `claude`, force an output format + mediaMaxMb?: number; // optional cap for outbound media (default 5MB) + typingIntervalSeconds?: number; // how often to refresh typing indicator while command runs + }; + }; }; export const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json"); const ReplySchema = z - .object({ - mode: z.union([z.literal("text"), z.literal("command")]), - text: z.string().optional(), - command: z.array(z.string()).optional(), - cwd: z.string().optional(), - template: z.string().optional(), - timeoutSeconds: z.number().int().positive().optional(), - bodyPrefix: z.string().optional(), - mediaUrl: z.string().optional(), - mediaMaxMb: z.number().positive().optional(), - typingIntervalSeconds: z.number().int().positive().optional(), - session: z - .object({ - scope: z - .union([z.literal("per-sender"), z.literal("global")]) - .optional(), - resetTriggers: z.array(z.string()).optional(), - idleMinutes: z.number().int().positive().optional(), - store: z.string().optional(), - sessionArgNew: z.array(z.string()).optional(), - sessionArgResume: z.array(z.string()).optional(), - sessionArgBeforeBody: z.boolean().optional(), - sendSystemOnce: z.boolean().optional(), - sessionIntro: z.string().optional(), - typingIntervalSeconds: z.number().int().positive().optional(), - }) - .optional(), - claudeOutputFormat: z - .union([ - z.literal("text"), - z.literal("json"), - z.literal("stream-json"), - z.undefined(), - ]) - .optional(), - }) - .refine( - (val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)), - { - message: - "reply.text is required for mode=text; reply.command is required for mode=command", - }, - ); + .object({ + mode: z.union([z.literal("text"), z.literal("command")]), + text: z.string().optional(), + command: z.array(z.string()).optional(), + cwd: z.string().optional(), + template: z.string().optional(), + timeoutSeconds: z.number().int().positive().optional(), + bodyPrefix: z.string().optional(), + mediaUrl: z.string().optional(), + mediaMaxMb: z.number().positive().optional(), + typingIntervalSeconds: z.number().int().positive().optional(), + session: z + .object({ + scope: z + .union([z.literal("per-sender"), z.literal("global")]) + .optional(), + resetTriggers: z.array(z.string()).optional(), + idleMinutes: z.number().int().positive().optional(), + store: z.string().optional(), + sessionArgNew: z.array(z.string()).optional(), + sessionArgResume: z.array(z.string()).optional(), + sessionArgBeforeBody: z.boolean().optional(), + sendSystemOnce: z.boolean().optional(), + sessionIntro: z.string().optional(), + typingIntervalSeconds: z.number().int().positive().optional(), + }) + .optional(), + claudeOutputFormat: z + .union([ + z.literal("text"), + z.literal("json"), + z.literal("stream-json"), + z.undefined(), + ]) + .optional(), + }) + .refine( + (val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)), + { + message: + "reply.text is required for mode=text; reply.command is required for mode=command", + }, + ); const WarelaySchema = z.object({ - logging: z - .object({ - level: z - .union([ - z.literal("silent"), - z.literal("fatal"), - z.literal("error"), - z.literal("warn"), - z.literal("info"), - z.literal("debug"), - z.literal("trace"), - ]) - .optional(), - file: z.string().optional(), - }) - .optional(), - inbound: z - .object({ - allowFrom: z.array(z.string()).optional(), - transcribeAudio: z - .object({ - command: z.array(z.string()), - timeoutSeconds: z.number().int().positive().optional(), - }) - .optional(), - reply: ReplySchema.optional(), - }) - .optional(), + logging: z + .object({ + level: z + .union([ + z.literal("silent"), + z.literal("fatal"), + z.literal("error"), + z.literal("warn"), + z.literal("info"), + z.literal("debug"), + z.literal("trace"), + ]) + .optional(), + file: z.string().optional(), + }) + .optional(), + inbound: z + .object({ + allowFrom: z.array(z.string()).optional(), + transcribeAudio: z + .object({ + command: z.array(z.string()), + timeoutSeconds: z.number().int().positive().optional(), + }) + .optional(), + reply: ReplySchema.optional(), + }) + .optional(), }); export function loadConfig(): WarelayConfig { - // Read ~/.warelay/warelay.json (JSON5) if present. - try { - if (!fs.existsSync(CONFIG_PATH)) return {}; - const raw = fs.readFileSync(CONFIG_PATH, "utf-8"); - const parsed = JSON5.parse(raw); - if (typeof parsed !== "object" || parsed === null) return {}; - const validated = WarelaySchema.safeParse(parsed); - if (!validated.success) { - console.error("Invalid warelay config:"); - for (const iss of validated.error.issues) { - console.error(`- ${iss.path.join(".")}: ${iss.message}`); - } - return {}; - } - return validated.data as WarelayConfig; - } catch (err) { - console.error(`Failed to read config at ${CONFIG_PATH}`, err); - return {}; - } + // Read ~/.warelay/warelay.json (JSON5) if present. + try { + if (!fs.existsSync(CONFIG_PATH)) return {}; + const raw = fs.readFileSync(CONFIG_PATH, "utf-8"); + const parsed = JSON5.parse(raw); + if (typeof parsed !== "object" || parsed === null) return {}; + const validated = WarelaySchema.safeParse(parsed); + if (!validated.success) { + console.error("Invalid warelay config:"); + for (const iss of validated.error.issues) { + console.error(`- ${iss.path.join(".")}: ${iss.message}`); + } + return {}; + } + return validated.data as WarelayConfig; + } catch (err) { + console.error(`Failed to read config at ${CONFIG_PATH}`, err); + return {}; + } } diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index c8ad01c26..91e6189f1 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -3,17 +3,17 @@ import { describe, expect, it } from "vitest"; import { deriveSessionKey } from "./sessions.js"; describe("sessions", () => { - it("returns normalized per-sender key", () => { - expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe( - "+1555", - ); - }); + it("returns normalized per-sender key", () => { + expect(deriveSessionKey("per-sender", { From: "whatsapp:+1555" })).toBe( + "+1555", + ); + }); - it("falls back to unknown when sender missing", () => { - expect(deriveSessionKey("per-sender", {})).toBe("unknown"); - }); + it("falls back to unknown when sender missing", () => { + expect(deriveSessionKey("per-sender", {})).toBe("unknown"); + }); - it("global scope returns global", () => { - expect(deriveSessionKey("global", { From: "+1" })).toBe("global"); - }); + it("global scope returns global", () => { + expect(deriveSessionKey("global", { From: "+1" })).toBe("global"); + }); }); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 60265d95f..9c5d10096 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -9,9 +9,9 @@ import { CONFIG_DIR, normalizeE164 } from "../utils.js"; export type SessionScope = "per-sender" | "global"; export type SessionEntry = { - sessionId: string; - updatedAt: number; - systemSent?: boolean; + sessionId: string; + updatedAt: number; + systemSent?: boolean; }; export const SESSION_STORE_DEFAULT = path.join(CONFIG_DIR, "sessions.json"); @@ -19,42 +19,42 @@ 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())); - return path.resolve(store); + if (!store) return SESSION_STORE_DEFAULT; + if (store.startsWith("~")) + return path.resolve(store.replace("~", os.homedir())); + return path.resolve(store); } export function loadSessionStore( - storePath: string, + storePath: string, ): Record { - try { - const raw = fs.readFileSync(storePath, "utf-8"); - const parsed = JSON5.parse(raw); - if (parsed && typeof parsed === "object") { - return parsed as Record; - } - } catch { - // ignore missing/invalid store; we'll recreate it - } - return {}; + try { + const raw = fs.readFileSync(storePath, "utf-8"); + const parsed = JSON5.parse(raw); + if (parsed && typeof parsed === "object") { + return parsed as Record; + } + } catch { + // ignore missing/invalid store; we'll recreate it + } + return {}; } export async function saveSessionStore( - storePath: string, - store: Record, + storePath: string, + store: Record, ) { - await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); - await fs.promises.writeFile( - storePath, - JSON.stringify(store, null, 2), - "utf-8", - ); + await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); + await fs.promises.writeFile( + storePath, + JSON.stringify(store, null, 2), + "utf-8", + ); } // Decide which session bucket to use (per-sender vs global). export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) { - if (scope === "global") return "global"; - const from = ctx.From ? normalizeE164(ctx.From) : ""; - return from || "unknown"; + if (scope === "global") return "global"; + const from = ctx.From ? normalizeE164(ctx.From) : ""; + return from || "unknown"; } diff --git a/src/env.test.ts b/src/env.test.ts index d70d66d44..7c1502096 100644 --- a/src/env.test.ts +++ b/src/env.test.ts @@ -4,94 +4,94 @@ import { ensureTwilioEnv, readEnv } from "./env.js"; import type { RuntimeEnv } from "./runtime.js"; const baseEnv = { - TWILIO_ACCOUNT_SID: "AC123", - TWILIO_WHATSAPP_FROM: "whatsapp:+1555", + 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"); - }), - }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), + }; - beforeEach(() => { - vi.clearAllMocks(); - process.env = {}; - }); + 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; - } - } + 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 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("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("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 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"); - }); + 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 index f8a8bfd7b..9061ff948 100644 --- a/src/env.ts +++ b/src/env.ts @@ -4,103 +4,103 @@ 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 }; + | { accountSid: string; authToken: string } + | { accountSid: string; apiKey: string; apiSecret: string }; export type EnvConfig = { - accountSid: string; - whatsappFrom: string; - whatsappSenderSid?: string; - auth: AuthMode; + 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", - }); - } - }); + .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); - } + // 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; + 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"); - } + 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, - }; + 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); - } + // 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/globals.test.ts b/src/globals.test.ts index b2d4f7e2f..9b6e309b2 100644 --- a/src/globals.test.ts +++ b/src/globals.test.ts @@ -2,28 +2,28 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { isVerbose, isYes, logVerbose, setVerbose, setYes } from "./globals.js"; describe("globals", () => { - afterEach(() => { - setVerbose(false); - setYes(false); - vi.restoreAllMocks(); - }); + afterEach(() => { + setVerbose(false); + setYes(false); + vi.restoreAllMocks(); + }); - it("toggles verbose flag and logs when enabled", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - setVerbose(false); - logVerbose("hidden"); - expect(logSpy).not.toHaveBeenCalled(); + it("toggles verbose flag and logs when enabled", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + setVerbose(false); + logVerbose("hidden"); + expect(logSpy).not.toHaveBeenCalled(); - setVerbose(true); - logVerbose("shown"); - expect(isVerbose()).toBe(true); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("shown")); - }); + setVerbose(true); + logVerbose("shown"); + expect(isVerbose()).toBe(true); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("shown")); + }); - it("stores yes flag", () => { - setYes(true); - expect(isYes()).toBe(true); - setYes(false); - expect(isYes()).toBe(false); - }); + it("stores yes flag", () => { + setYes(true); + expect(isYes()).toBe(true); + setYes(false); + expect(isYes()).toBe(false); + }); }); diff --git a/src/globals.ts b/src/globals.ts index 4daa800ae..5efe15ec5 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -4,23 +4,23 @@ let globalVerbose = false; let globalYes = false; export function setVerbose(v: boolean) { - globalVerbose = v; + globalVerbose = v; } export function isVerbose() { - return globalVerbose; + return globalVerbose; } export function logVerbose(message: string) { - if (globalVerbose) console.log(chalk.gray(message)); + if (globalVerbose) console.log(chalk.gray(message)); } export function setYes(v: boolean) { - globalYes = v; + globalYes = v; } export function isYes() { - return globalYes; + return globalYes; } export const success = chalk.green; diff --git a/src/index.commands.test.ts b/src/index.commands.test.ts index 4172bdc8d..4cf8e5091 100644 --- a/src/index.commands.test.ts +++ b/src/index.commands.test.ts @@ -6,134 +6,134 @@ import * as providerWeb from "./provider-web.js"; import { defaultRuntime } from "./runtime.js"; vi.mock("twilio", () => { - const { factory } = createMockTwilio(); - return { default: factory }; + 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(); + 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(); + vi.restoreAllMocks(); }); describe("CLI commands", () => { - it("exposes login command", () => { - const names = index.program.commands.map((c) => c.name()); - expect(names).toContain("login"); - }); + 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 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 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 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("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("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(); - }); + 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 index 2cf46f91a..2a228c0fa 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -11,8 +11,8 @@ import { withWhatsAppPrefix } from "./utils.js"; // Twilio mock factory shared across tests vi.mock("twilio", () => { - const { factory } = createMockTwilio(); - return { default: factory }; + const { factory } = createMockTwilio(); + return { default: factory }; }); type TwilioFactoryMock = ReturnType["factory"]; @@ -24,1249 +24,1249 @@ 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(); + 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(); + 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"); + 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); - }); + 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(); - }); + 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] ", - }, - }, - }; + 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(); - }); + 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 templating includes media fields", async () => { - 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, - ); - expect(result?.text).toContain("/tmp/a.jpg"); - expect(result?.text).toContain("image/jpeg"); - expect(result?.text).toContain("http://example.com/a.jpg"); - }); + it("getReplyFromConfig templating includes media fields", async () => { + 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, + ); + 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}}"], - }, - }, - }; + 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 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, - ); + 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"); - }); + 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}}", - }, - }, - }; + 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, - ); + 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(""); - }); + 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("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 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("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("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 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("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("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("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("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); - const runSpy = vi.fn().mockRejectedValue({ - killed: true, - signal: "SIGKILL", - stdout: partial, - stderr: "", - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - timeoutSeconds: 42, - }, - }, - }; + it("returns timeout reply with partial stdout snippet", async () => { + const partial = "x".repeat(900); + const runSpy = vi.fn().mockRejectedValue({ + killed: true, + signal: "SIGKILL", + stdout: partial, + stderr: "", + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{Body}}"], + timeoutSeconds: 42, + }, + }, + }; - const result = await index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); + const result = await index.getReplyFromConfig( + { Body: "hi", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); - 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); - }); + 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 () => { - const runSpy = vi.fn().mockRejectedValue({ - killed: true, - signal: "SIGKILL", - stdout: "", - stderr: "", - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - timeoutSeconds: 5, - }, - }, - }; + it("returns timeout reply without partial output when none is available", async () => { + const runSpy = vi.fn().mockRejectedValue({ + killed: true, + signal: "SIGKILL", + stdout: "", + stderr: "", + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{Body}}"], + timeoutSeconds: 5, + }, + }, + }; - const result = await index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); + const result = await index.getReplyFromConfig( + { Body: "hi", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); - expect(result?.text).toBe( - "Command timed out after 5s. Try a shorter prompt or split the request.", - ); - }); + 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("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 runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: "cmd output\n", - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - template: "[tmpl]", - session: { - scope: "per-sender" as const, - resetTriggers: ["/new"], - store: tmpStore, - sessionArgNew: ["--sid", "{{SessionId}}"], - sessionArgResume: ["--resume", "{{SessionId}}"], - }, - }, - }, - }; + 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 runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ + stdout: "cmd output\n", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{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, - runSpy, - ); - expect(first?.text).toBe("cmd output"); - const argvFirst = runSpy.mock.calls[0][0]; - expect(argvFirst).toEqual([ - "echo", - "[tmpl]", - "--sid", - "session-123", - "hello", - ]); + const first = await index.getReplyFromConfig( + { Body: "/new hello", From: "+1555", To: "+1666" }, + undefined, + cfg, + runSpy, + ); + expect(first?.text).toBe("cmd output"); + const argvFirst = runSpy.mock.calls[0][0]; + expect(argvFirst).toEqual([ + "echo", + "[tmpl]", + "--sid", + "session-123", + "hello", + ]); - const second = await index.getReplyFromConfig( - { Body: "next", From: "+1555", To: "+1666" }, - undefined, - cfg, - runSpy, - ); - expect(second?.text).toBe("cmd output"); - const argvSecond = runSpy.mock.calls[1][0]; - expect(argvSecond[2]).toBe("--resume"); - }); + const second = await index.getReplyFromConfig( + { Body: "next", From: "+1555", To: "+1666" }, + undefined, + cfg, + runSpy, + ); + expect(second?.text).toBe("cmd output"); + const argvSecond = runSpy.mock.calls[1][0]; + expect(argvSecond[2]).toBe("--resume"); + }); - 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 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}}"], - template: "[tmpl]", - bodyPrefix: "[pfx] ", - session: { - sendSystemOnce: true, - sessionIntro: "SYS", - store: tmpStore, - sessionArgNew: ["--sid", "{{SessionId}}"], - sessionArgResume: ["--resume", "{{SessionId}}"], - }, - }, - }, - }; + 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 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}}"], + 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, - runSpy, - ); - await index.getReplyFromConfig( - { Body: "next", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); + await index.getReplyFromConfig( + { Body: "/new hi", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); + await index.getReplyFromConfig( + { Body: "next", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); - const firstArgv = runSpy.mock.calls[0][0]; - expect(firstArgv).toEqual([ - "echo", - "[tmpl]", - "--sid", - "sid-1", - "SYS\n\n[pfx] hi", - ]); + const firstArgv = runSpy.mock.calls[0][0]; + expect(firstArgv).toEqual([ + "echo", + "[tmpl]", + "--sid", + "sid-1", + "SYS\n\n[pfx] hi", + ]); - const secondArgv = runSpy.mock.calls[1][0]; - expect(secondArgv).toEqual(["echo", "--resume", "sid-1", "next"]); + const secondArgv = runSpy.mock.calls[1][0]; + expect(secondArgv).toEqual(["echo", "--resume", "sid-1", "next"]); - const persisted = JSON.parse(fs.readFileSync(tmpStore, "utf-8")); - const firstEntry = Object.values(persisted)[0] as { systemSent?: boolean }; - expect(firstEntry.systemSent).toBe(true); - }); + const persisted = JSON.parse(fs.readFileSync(tmpStore, "utf-8")); + const firstEntry = Object.values(persisted)[0] as { systemSent?: boolean }; + expect(firstEntry.systemSent).toBe(true); + }); - it("keeps sending system prompt when sendSystemOnce is disabled (default)", async () => { - 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}}"], - bodyPrefix: "[sys] ", - session: { - scope: "per-sender" as const, - resetTriggers: ["/new"], - idleMinutes: 60, - }, - }, - }, - }; + it("keeps sending system prompt when sendSystemOnce is disabled (default)", async () => { + 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}}"], + bodyPrefix: "[sys] ", + session: { + scope: "per-sender" as const, + resetTriggers: ["/new"], + idleMinutes: 60, + }, + }, + }, + }; - await index.getReplyFromConfig( - { Body: "/new hi", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); - await index.getReplyFromConfig( - { Body: "next", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); + await index.getReplyFromConfig( + { Body: "/new hi", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); + await index.getReplyFromConfig( + { Body: "next", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); - const firstArgv = runSpy.mock.calls[0][0]; - expect(firstArgv[firstArgv.length - 1]).toBe("[sys] hi"); + const firstArgv = runSpy.mock.calls[0][0]; + expect(firstArgv[firstArgv.length - 1]).toBe("[sys] hi"); - const secondArgv = runSpy.mock.calls[1][0]; - expect(secondArgv[secondArgv.length - 1]).toBe("[sys] next"); - }); + const secondArgv = runSpy.mock.calls[1][0]; + expect(secondArgv[secondArgv.length - 1]).toBe("[sys] next"); + }); - it("refreshes typing indicator while command runs", async () => { - const onReplyStart = vi.fn(); - const runSpy = vi.spyOn(index, "runCommandWithTimeout").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: ["echo", "{{Body}}"], - typingIntervalSeconds: 0.02, - }, - }, - }; + it("refreshes typing indicator while command runs", async () => { + const onReplyStart = vi.fn(); + const runSpy = vi.spyOn(index, "runCommandWithTimeout").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: ["echo", "{{Body}}"], + typingIntervalSeconds: 0.02, + }, + }, + }; - const promise = index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - { onReplyStart }, - cfg, - runSpy, - ); - await new Promise((r) => setTimeout(r, 200)); - await promise; - expect(onReplyStart.mock.calls.length).toBeGreaterThanOrEqual(3); - }); + const promise = index.getReplyFromConfig( + { Body: "hi", From: "+1", To: "+2" }, + { onReplyStart }, + cfg, + runSpy, + ); + await new Promise((r) => setTimeout(r, 200)); + await promise; + expect(onReplyStart.mock.calls.length).toBeGreaterThanOrEqual(3); + }); - it("uses session typing interval override", async () => { - const onReplyStart = vi.fn(); - const runSpy = vi.spyOn(index, "runCommandWithTimeout").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: ["echo", "{{Body}}"], - session: { typingIntervalSeconds: 0.02 }, - }, - }, - }; + it("uses session typing interval override", async () => { + const onReplyStart = vi.fn(); + const runSpy = vi.spyOn(index, "runCommandWithTimeout").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: ["echo", "{{Body}}"], + session: { typingIntervalSeconds: 0.02 }, + }, + }, + }; - const promise = index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - { onReplyStart }, - cfg, - runSpy, - ); - await new Promise((r) => setTimeout(r, 200)); - await promise; - expect(onReplyStart.mock.calls.length).toBeGreaterThanOrEqual(3); - }); + const promise = index.getReplyFromConfig( + { Body: "hi", From: "+1", To: "+2" }, + { onReplyStart }, + cfg, + runSpy, + ); + await new Promise((r) => setTimeout(r, 200)); + await promise; + expect(onReplyStart.mock.calls.length).toBeGreaterThanOrEqual(3); + }); - it("injects Claude output format + print flag when configured", 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: ["claude", "{{Body}}"], - claudeOutputFormat: "text" as const, - }, - }, - }; + it("injects Claude output format + print flag when configured", 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: ["claude", "{{Body}}"], + claudeOutputFormat: "text" as const, + }, + }, + }; - await index.getReplyFromConfig( - { Body: "hi", From: "+1555", To: "+1666" }, - undefined, - cfg, - runSpy, - ); + await index.getReplyFromConfig( + { Body: "hi", From: "+1555", To: "+1666" }, + undefined, + cfg, + runSpy, + ); - const argv = runSpy.mock.calls[0][0]; - expect(argv[0]).toBe("claude"); - expect(argv.at(-1)).toContain("You are Clawd (Claude)"); - expect(argv.at(-1)).toContain("/Users/steipete/clawd"); - expect(argv.at(-1)).toMatch(/hi$/); - // The helper should auto-add print and output format flags without disturbing the prompt position. - expect(argv.includes("-p") || argv.includes("--print")).toBe(true); - const outputIdx = argv.findIndex( - (part) => - part === "--output-format" || part.startsWith("--output-format="), - ); - expect(outputIdx).toBeGreaterThan(-1); - expect(argv[outputIdx + 1]).toBe("text"); - }); + const argv = runSpy.mock.calls[0][0]; + expect(argv[0]).toBe("claude"); + expect(argv.at(-1)).toContain("You are Clawd (Claude)"); + expect(argv.at(-1)).toContain("/Users/steipete/clawd"); + expect(argv.at(-1)).toMatch(/hi$/); + // The helper should auto-add print and output format flags without disturbing the prompt position. + expect(argv.includes("-p") || argv.includes("--print")).toBe(true); + const outputIdx = argv.findIndex( + (part) => + part === "--output-format" || part.startsWith("--output-format="), + ); + expect(outputIdx).toBeGreaterThan(-1); + expect(argv[outputIdx + 1]).toBe("text"); + }); - it("parses Claude JSON output and returns text content", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: '{"text":"hello world"}\n', - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["claude", "{{Body}}"], - claudeOutputFormat: "json" as const, - }, - }, - }; + it("parses Claude JSON output and returns text content", async () => { + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ + stdout: '{"text":"hello world"}\n', + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["claude", "{{Body}}"], + claudeOutputFormat: "json" as const, + }, + }, + }; - const result = await index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); + const result = await index.getReplyFromConfig( + { Body: "hi", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); - expect(result?.text).toBe("hello world"); - }); + expect(result?.text).toBe("hello world"); + }); - it("parses Claude JSON output even without explicit claudeOutputFormat when using claude bin", async () => { - const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ - stdout: '{"result":"Sure! What\'s up?"}\n', - stderr: "", - code: 0, - signal: null, - killed: false, - }); - const cfg = { - inbound: { - reply: { - mode: "command" as const, - command: ["claude", "{{Body}}"], - // No claudeOutputFormat set on purpose - }, - }, - }; + it("parses Claude JSON output even without explicit claudeOutputFormat when using claude bin", async () => { + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ + stdout: '{"result":"Sure! What\'s up?"}\n', + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["claude", "{{Body}}"], + // No claudeOutputFormat set on purpose + }, + }, + }; - const result = await index.getReplyFromConfig( - { Body: "hi", From: "+1", To: "+2" }, - undefined, - cfg, - runSpy, - ); + const result = await index.getReplyFromConfig( + { Body: "hi", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); - expect(result?.text).toBe("Sure! What's up?"); - const argv = runSpy.mock.calls[0][0]; - expect(argv.at(-1)).toContain("You are Clawd (Claude)"); - expect(argv.at(-1)).toContain("/Users/steipete/clawd"); - }); + expect(result?.text).toBe("Sure! What's up?"); + const argv = runSpy.mock.calls[0][0]; + expect(argv.at(-1)).toContain("You are Clawd (Claude)"); + expect(argv.at(-1)).toContain("/Users/steipete/clawd"); + }); - it("serializes command auto-replies via the queue", async () => { - let active = 0; - let maxActive = 0; - const runSpy = vi.fn(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, - }; - }); + it("serializes command auto-replies via the queue", async () => { + let active = 0; + let maxActive = 0; + const runSpy = vi.fn(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}}"], - }, - }, - }; + 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, - ), - ]); + 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); - }); + 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" }, - }, - }, - ); + 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", - }); - }); + 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(); + 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" }), - ); - }); + 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); + 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"); - }); + 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 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); - }); + 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" }); + 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)); - }); + 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", - }); + 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", - }); + 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)); - }); + 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); + 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); - }); + 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"); - }); + 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 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("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; + 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(); - }); + 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("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("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("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("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); - }); + 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("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("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("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("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(); - }); + 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("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("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: Parameters[1] extends undefined - ? never - : NonNullable[1]>, - ) => { - 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"); - }); + it("monitorWebProvider triggers replies and stops when asked", async () => { + const replySpy = vi.fn(); + const sendMediaSpy = vi.fn(); + const listenerFactory = vi.fn( + async ( + opts: Parameters[1] extends undefined + ? never + : NonNullable[1]>, + ) => { + 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 7152c3ae9..e846f3868 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -2,28 +2,28 @@ import { describe, expect, it } from "vitest"; import { assertProvider, normalizeE164, toWhatsappJid } from "./index.js"; describe("normalizeE164", () => { - it("strips whatsapp prefix and whitespace", () => { - expect(normalizeE164("whatsapp:+1 555 123 4567")).toBe("+15551234567"); - }); + it("strips whatsapp prefix and whitespace", () => { + expect(normalizeE164("whatsapp:+1 555 123 4567")).toBe("+15551234567"); + }); - it("adds plus when missing", () => { - expect(normalizeE164("1555123")).toBe("+1555123"); - }); + it("adds plus when missing", () => { + expect(normalizeE164("1555123")).toBe("+1555123"); + }); }); describe("toWhatsappJid", () => { - it("converts E164 to jid", () => { - expect(toWhatsappJid("+1 555 123 4567")).toBe("15551234567@s.whatsapp.net"); - }); + it("converts E164 to jid", () => { + expect(toWhatsappJid("+1 555 123 4567")).toBe("15551234567@s.whatsapp.net"); + }); }); describe("assertProvider", () => { - it("accepts valid providers", () => { - expect(() => assertProvider("twilio")).not.toThrow(); - expect(() => assertProvider("web")).not.toThrow(); - }); + it("accepts valid providers", () => { + expect(() => assertProvider("twilio")).not.toThrow(); + expect(() => assertProvider("web")).not.toThrow(); + }); - it("throws on invalid provider", () => { - expect(() => assertProvider("invalid" as string)).toThrow(); - }); + it("throws on invalid provider", () => { + expect(() => assertProvider("invalid" as string)).toThrow(); + }); }); diff --git a/src/index.ts b/src/index.ts index 0f355f93c..a74f07e1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,8 @@ import { fileURLToPath } from "node:url"; import dotenv from "dotenv"; import { - autoReplyIfConfigured, - getReplyFromConfig, + autoReplyIfConfigured, + getReplyFromConfig, } from "./auto-reply/reply.js"; import { applyTemplate } from "./auto-reply/templating.js"; import { createDefaultDeps, monitorTwilio } from "./cli/deps.js"; @@ -13,42 +13,42 @@ import { promptYesNo } from "./cli/prompt.js"; import { waitForever } from "./cli/wait.js"; import { loadConfig } from "./config/config.js"; import { - deriveSessionKey, - loadSessionStore, - resolveStorePath, - saveSessionStore, + deriveSessionKey, + loadSessionStore, + resolveStorePath, + saveSessionStore, } from "./config/sessions.js"; import { readEnv } from "./env.js"; import { ensureBinary } from "./infra/binaries.js"; import { - describePortOwner, - ensurePortAvailable, - handlePortError, - PortInUseError, + describePortOwner, + ensurePortAvailable, + handlePortError, + PortInUseError, } from "./infra/ports.js"; import { - ensureFunnel, - ensureGoInstalled, - ensureTailscaledInstalled, - getTailnetHostname, + ensureFunnel, + ensureGoInstalled, + ensureTailscaledInstalled, + getTailnetHostname, } from "./infra/tailscale.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, + 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, + 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"; @@ -66,56 +66,56 @@ const setMessagingServiceWebhook = setMessagingServiceWebhookImpl; const updateWebhook = updateWebhookImpl; export { - assertProvider, - autoReplyIfConfigured, - applyTemplate, - createClient, - 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, + assertProvider, + autoReplyIfConfigured, + applyTemplate, + createClient, + 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, }; const isMain = - process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; + process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isMain) { - program.parseAsync(process.argv); + program.parseAsync(process.argv); } diff --git a/src/infra/binaries.test.ts b/src/infra/binaries.test.ts index df707313f..4fd7fa520 100644 --- a/src/infra/binaries.test.ts +++ b/src/infra/binaries.test.ts @@ -5,34 +5,34 @@ import type { RuntimeEnv } from "../runtime.js"; import { ensureBinary } from "./binaries.js"; describe("ensureBinary", () => { - it("passes through when binary exists", async () => { - const exec: typeof runExec = vi.fn().mockResolvedValue({ - stdout: "", - stderr: "", - }); - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; - await ensureBinary("node", exec, runtime); - expect(exec).toHaveBeenCalledWith("which", ["node"]); - }); + it("passes through when binary exists", async () => { + const exec: typeof runExec = vi.fn().mockResolvedValue({ + stdout: "", + stderr: "", + }); + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + await ensureBinary("node", exec, runtime); + expect(exec).toHaveBeenCalledWith("which", ["node"]); + }); - it("logs and exits when missing", async () => { - const exec: typeof runExec = vi - .fn() - .mockRejectedValue(new Error("missing")); - const error = vi.fn(); - const exit = vi.fn(() => { - throw new Error("exit"); - }); - await expect( - ensureBinary("ghost", exec, { log: vi.fn(), error, exit }), - ).rejects.toThrow("exit"); - expect(error).toHaveBeenCalledWith( - "Missing required binary: ghost. Please install it.", - ); - expect(exit).toHaveBeenCalledWith(1); - }); + it("logs and exits when missing", async () => { + const exec: typeof runExec = vi + .fn() + .mockRejectedValue(new Error("missing")); + const error = vi.fn(); + const exit = vi.fn(() => { + throw new Error("exit"); + }); + await expect( + ensureBinary("ghost", exec, { log: vi.fn(), error, exit }), + ).rejects.toThrow("exit"); + expect(error).toHaveBeenCalledWith( + "Missing required binary: ghost. Please install it.", + ); + expect(exit).toHaveBeenCalledWith(1); + }); }); diff --git a/src/infra/binaries.ts b/src/infra/binaries.ts index 73af82273..492cf5a9c 100644 --- a/src/infra/binaries.ts +++ b/src/infra/binaries.ts @@ -2,13 +2,13 @@ import { runExec } from "../process/exec.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; export async function ensureBinary( - name: string, - exec: typeof runExec = runExec, - runtime: RuntimeEnv = defaultRuntime, + name: string, + exec: typeof runExec = runExec, + runtime: RuntimeEnv = defaultRuntime, ): Promise { - // Abort early if a required CLI tool is missing. - await exec("which", [name]).catch(() => { - runtime.error(`Missing required binary: ${name}. Please install it.`); - runtime.exit(1); - }); + // Abort early if a required CLI tool is missing. + await exec("which", [name]).catch(() => { + runtime.error(`Missing required binary: ${name}. Please install it.`); + runtime.exit(1); + }); } diff --git a/src/infra/ports.test.ts b/src/infra/ports.test.ts index c0560ad60..9f72739c8 100644 --- a/src/infra/ports.test.ts +++ b/src/infra/ports.test.ts @@ -2,35 +2,35 @@ import net from "node:net"; import { describe, expect, it, vi } from "vitest"; import { - ensurePortAvailable, - handlePortError, - PortInUseError, + ensurePortAvailable, + handlePortError, + PortInUseError, } from "./ports.js"; describe("ports helpers", () => { - it("ensurePortAvailable rejects when port busy", async () => { - const server = net.createServer(); - await new Promise((resolve) => server.listen(0, resolve)); - const port = (server.address() as net.AddressInfo).port; - await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf( - PortInUseError, - ); - server.close(); - }); + it("ensurePortAvailable rejects when port busy", async () => { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, resolve)); + const port = (server.address() as net.AddressInfo).port; + await expect(ensurePortAvailable(port)).rejects.toBeInstanceOf( + PortInUseError, + ); + server.close(); + }); - it("handlePortError exits nicely on EADDRINUSE", async () => { - const runtime = { - error: vi.fn(), - log: vi.fn(), - exit: vi.fn() as unknown as (code: number) => never, - }; - await handlePortError( - { code: "EADDRINUSE" }, - 1234, - "context", - runtime, - ).catch(() => {}); - expect(runtime.error).toHaveBeenCalled(); - expect(runtime.exit).toHaveBeenCalledWith(1); - }); + it("handlePortError exits nicely on EADDRINUSE", async () => { + const runtime = { + error: vi.fn(), + log: vi.fn(), + exit: vi.fn() as unknown as (code: number) => never, + }; + await handlePortError( + { code: "EADDRINUSE" }, + 1234, + "context", + runtime, + ).catch(() => {}); + expect(runtime.error).toHaveBeenCalled(); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); }); diff --git a/src/infra/ports.ts b/src/infra/ports.ts index 458653e01..35fce8177 100644 --- a/src/infra/ports.ts +++ b/src/infra/ports.ts @@ -5,103 +5,103 @@ import { runExec } from "../process/exec.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; class PortInUseError extends Error { - port: number; - details?: string; + port: number; + details?: string; - constructor(port: number, details?: string) { - super(`Port ${port} is already in use.`); - this.name = "PortInUseError"; - this.port = port; - this.details = details; - } + constructor(port: number, details?: string) { + super(`Port ${port} is already in use.`); + this.name = "PortInUseError"; + this.port = port; + this.details = details; + } } function isErrno(err: unknown): err is NodeJS.ErrnoException { - return Boolean(err && typeof err === "object" && "code" in err); + return Boolean(err && typeof err === "object" && "code" in err); } export async function describePortOwner( - port: number, + port: number, ): Promise { - // Best-effort process info for a listening port (macOS/Linux). - try { - const { stdout } = await runExec("lsof", [ - "-i", - `tcp:${port}`, - "-sTCP:LISTEN", - "-nP", - ]); - const trimmed = stdout.trim(); - if (trimmed) return trimmed; - } catch (err) { - logVerbose(`lsof unavailable: ${String(err)}`); - } - return undefined; + // Best-effort process info for a listening port (macOS/Linux). + try { + const { stdout } = await runExec("lsof", [ + "-i", + `tcp:${port}`, + "-sTCP:LISTEN", + "-nP", + ]); + const trimmed = stdout.trim(); + if (trimmed) return trimmed; + } catch (err) { + logVerbose(`lsof unavailable: ${String(err)}`); + } + return undefined; } export async function ensurePortAvailable(port: number): Promise { - // Detect EADDRINUSE early with a friendly message. - try { - await new Promise((resolve, reject) => { - const tester = net - .createServer() - .once("error", (err) => reject(err)) - .once("listening", () => { - tester.close(() => resolve()); - }) - .listen(port); - }); - } catch (err) { - if (isErrno(err) && err.code === "EADDRINUSE") { - const details = await describePortOwner(port); - throw new PortInUseError(port, details); - } - throw err; - } + // Detect EADDRINUSE early with a friendly message. + try { + await new Promise((resolve, reject) => { + const tester = net + .createServer() + .once("error", (err) => reject(err)) + .once("listening", () => { + tester.close(() => resolve()); + }) + .listen(port); + }); + } catch (err) { + if (isErrno(err) && err.code === "EADDRINUSE") { + const details = await describePortOwner(port); + throw new PortInUseError(port, details); + } + throw err; + } } export async function handlePortError( - err: unknown, - port: number, - context: string, - runtime: RuntimeEnv = defaultRuntime, + err: unknown, + port: number, + context: string, + runtime: RuntimeEnv = defaultRuntime, ): Promise { - // Uniform messaging for EADDRINUSE with optional owner details. - if ( - err instanceof PortInUseError || - (isErrno(err) && err.code === "EADDRINUSE") - ) { - const details = - err instanceof PortInUseError - ? err.details - : await describePortOwner(port); - runtime.error(danger(`${context} failed: port ${port} is already in use.`)); - if (details) { - runtime.error(info("Port listener details:")); - runtime.error(details); - if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) { - runtime.error( - warn( - "It looks like another warelay instance is already running. Stop it or pick a different port.", - ), - ); - } - } - runtime.error( - info( - "Resolve by stopping the process using the port or passing --port .", - ), - ); - runtime.exit(1); - } - runtime.error(danger(`${context} failed: ${String(err)}`)); - if (isVerbose()) { - const stdout = (err as { stdout?: string })?.stdout; - const stderr = (err as { stderr?: string })?.stderr; - if (stdout?.trim()) logDebug(`stdout: ${stdout.trim()}`); - if (stderr?.trim()) logDebug(`stderr: ${stderr.trim()}`); - } - return runtime.exit(1); + // Uniform messaging for EADDRINUSE with optional owner details. + if ( + err instanceof PortInUseError || + (isErrno(err) && err.code === "EADDRINUSE") + ) { + const details = + err instanceof PortInUseError + ? err.details + : await describePortOwner(port); + runtime.error(danger(`${context} failed: port ${port} is already in use.`)); + if (details) { + runtime.error(info("Port listener details:")); + runtime.error(details); + if (/warelay|src\/index\.ts|dist\/index\.js/.test(details)) { + runtime.error( + warn( + "It looks like another warelay instance is already running. Stop it or pick a different port.", + ), + ); + } + } + runtime.error( + info( + "Resolve by stopping the process using the port or passing --port .", + ), + ); + runtime.exit(1); + } + runtime.error(danger(`${context} failed: ${String(err)}`)); + if (isVerbose()) { + const stdout = (err as { stdout?: string })?.stdout; + const stderr = (err as { stderr?: string })?.stderr; + if (stdout?.trim()) logDebug(`stdout: ${stdout.trim()}`); + if (stderr?.trim()) logDebug(`stderr: ${stderr.trim()}`); + } + return runtime.exit(1); } export { PortInUseError }; diff --git a/src/infra/retry.test.ts b/src/infra/retry.test.ts index cd25bdb1f..7099f5239 100644 --- a/src/infra/retry.test.ts +++ b/src/infra/retry.test.ts @@ -3,26 +3,26 @@ import { describe, expect, it, vi } from "vitest"; import { retryAsync } from "./retry.js"; describe("retryAsync", () => { - it("returns on first success", async () => { - const fn = vi.fn().mockResolvedValue("ok"); - const result = await retryAsync(fn, 3, 10); - expect(result).toBe("ok"); - expect(fn).toHaveBeenCalledTimes(1); - }); + it("returns on first success", async () => { + const fn = vi.fn().mockResolvedValue("ok"); + const result = await retryAsync(fn, 3, 10); + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(1); + }); - it("retries then succeeds", async () => { - const fn = vi - .fn() - .mockRejectedValueOnce(new Error("fail1")) - .mockResolvedValueOnce("ok"); - const result = await retryAsync(fn, 3, 1); - expect(result).toBe("ok"); - expect(fn).toHaveBeenCalledTimes(2); - }); + it("retries then succeeds", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error("fail1")) + .mockResolvedValueOnce("ok"); + const result = await retryAsync(fn, 3, 1); + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); - it("propagates after exhausting retries", async () => { - const fn = vi.fn().mockRejectedValue(new Error("boom")); - await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom"); - expect(fn).toHaveBeenCalledTimes(2); - }); + it("propagates after exhausting retries", async () => { + const fn = vi.fn().mockRejectedValue(new Error("boom")); + await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom"); + expect(fn).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/infra/retry.ts b/src/infra/retry.ts index b32a5fc80..234ab539c 100644 --- a/src/infra/retry.ts +++ b/src/infra/retry.ts @@ -1,18 +1,18 @@ export async function retryAsync( - fn: () => Promise, - attempts = 3, - initialDelayMs = 300, + fn: () => Promise, + attempts = 3, + initialDelayMs = 300, ): Promise { - let lastErr: unknown; - for (let i = 0; i < attempts; i += 1) { - try { - return await fn(); - } catch (err) { - lastErr = err; - if (i === attempts - 1) break; - const delay = initialDelayMs * 2 ** i; - await new Promise((r) => setTimeout(r, delay)); - } - } - throw lastErr; + let lastErr: unknown; + for (let i = 0; i < attempts; i += 1) { + try { + return await fn(); + } catch (err) { + lastErr = err; + if (i === attempts - 1) break; + const delay = initialDelayMs * 2 ** i; + await new Promise((r) => setTimeout(r, delay)); + } + } + throw lastErr; } diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index e123c6a06..769997653 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -1,61 +1,61 @@ import { describe, expect, it, vi } from "vitest"; import { - ensureGoInstalled, - ensureTailscaledInstalled, - getTailnetHostname, + ensureGoInstalled, + ensureTailscaledInstalled, + getTailnetHostname, } from "./tailscale.js"; describe("tailscale helpers", () => { - it("parses DNS name from tailscale status", async () => { - const exec = vi.fn().mockResolvedValue({ - stdout: JSON.stringify({ - Self: { DNSName: "host.tailnet.ts.net.", TailscaleIPs: ["100.1.1.1"] }, - }), - }); - const host = await getTailnetHostname(exec); - expect(host).toBe("host.tailnet.ts.net"); - }); + it("parses DNS name from tailscale status", async () => { + const exec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + Self: { DNSName: "host.tailnet.ts.net.", TailscaleIPs: ["100.1.1.1"] }, + }), + }); + const host = await getTailnetHostname(exec); + expect(host).toBe("host.tailnet.ts.net"); + }); - it("falls back to IP when DNS missing", async () => { - const exec = vi.fn().mockResolvedValue({ - stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2"] } }), - }); - const host = await getTailnetHostname(exec); - expect(host).toBe("100.2.2.2"); - }); + it("falls back to IP when DNS missing", async () => { + const exec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2"] } }), + }); + const host = await getTailnetHostname(exec); + expect(host).toBe("100.2.2.2"); + }); - it("ensureGoInstalled installs when missing and user agrees", async () => { - const exec = vi - .fn() - .mockRejectedValueOnce(new Error("no go")) - .mockResolvedValue({}); // brew install go - const prompt = vi.fn().mockResolvedValue(true); - const runtime = { - error: vi.fn(), - log: vi.fn(), - exit: ((code: number) => { - throw new Error(`exit ${code}`); - }) as (code: number) => never, - }; - await ensureGoInstalled(exec as never, prompt, runtime); - expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]); - }); + it("ensureGoInstalled installs when missing and user agrees", async () => { + const exec = vi + .fn() + .mockRejectedValueOnce(new Error("no go")) + .mockResolvedValue({}); // brew install go + const prompt = vi.fn().mockResolvedValue(true); + const runtime = { + error: vi.fn(), + log: vi.fn(), + exit: ((code: number) => { + throw new Error(`exit ${code}`); + }) as (code: number) => never, + }; + await ensureGoInstalled(exec as never, prompt, runtime); + expect(exec).toHaveBeenCalledWith("brew", ["install", "go"]); + }); - it("ensureTailscaledInstalled installs when missing and user agrees", async () => { - const exec = vi - .fn() - .mockRejectedValueOnce(new Error("missing")) - .mockResolvedValue({}); - const prompt = vi.fn().mockResolvedValue(true); - const runtime = { - error: vi.fn(), - log: vi.fn(), - exit: ((code: number) => { - throw new Error(`exit ${code}`); - }) as (code: number) => never, - }; - await ensureTailscaledInstalled(exec as never, prompt, runtime); - expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]); - }); + it("ensureTailscaledInstalled installs when missing and user agrees", async () => { + const exec = vi + .fn() + .mockRejectedValueOnce(new Error("missing")) + .mockResolvedValue({}); + const prompt = vi.fn().mockResolvedValue(true); + const runtime = { + error: vi.fn(), + log: vi.fn(), + exit: ((code: number) => { + throw new Error(`exit ${code}`); + }) as (code: number) => never, + }; + await ensureTailscaledInstalled(exec as never, prompt, runtime); + expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]); + }); }); diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index c3d7e94db..ebf100e40 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -6,158 +6,158 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { ensureBinary } from "./binaries.js"; export async function getTailnetHostname(exec: typeof runExec = runExec) { - // Derive tailnet hostname (or IP fallback) from tailscale status JSON. - const { stdout } = await exec("tailscale", ["status", "--json"]); - const parsed = stdout ? (JSON.parse(stdout) as Record) : {}; - const self = - typeof parsed.Self === "object" && parsed.Self !== null - ? (parsed.Self as Record) - : undefined; - const dns = - typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined; - const ips = Array.isArray(self?.TailscaleIPs) - ? (self.TailscaleIPs as string[]) - : []; - if (dns && dns.length > 0) return dns.replace(/\.$/, ""); - if (ips.length > 0) return ips[0]; - throw new Error("Could not determine Tailscale DNS or IP"); + // Derive tailnet hostname (or IP fallback) from tailscale status JSON. + const { stdout } = await exec("tailscale", ["status", "--json"]); + const parsed = stdout ? (JSON.parse(stdout) as Record) : {}; + const self = + typeof parsed.Self === "object" && parsed.Self !== null + ? (parsed.Self as Record) + : undefined; + const dns = + typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined; + const ips = Array.isArray(self?.TailscaleIPs) + ? (self.TailscaleIPs as string[]) + : []; + if (dns && dns.length > 0) return dns.replace(/\.$/, ""); + if (ips.length > 0) return ips[0]; + throw new Error("Could not determine Tailscale DNS or IP"); } export async function ensureGoInstalled( - exec: typeof runExec = runExec, - prompt: typeof promptYesNo = promptYesNo, - runtime: RuntimeEnv = defaultRuntime, + exec: typeof runExec = runExec, + prompt: typeof promptYesNo = promptYesNo, + runtime: RuntimeEnv = defaultRuntime, ) { - // Ensure Go toolchain is present; offer Homebrew install if missing. - const hasGo = await exec("go", ["version"]).then( - () => true, - () => false, - ); - if (hasGo) return; - const install = await prompt( - "Go is not installed. Install via Homebrew (brew install go)?", - true, - ); - if (!install) { - runtime.error("Go is required to build tailscaled from source. Aborting."); - runtime.exit(1); - } - logVerbose("Installing Go via Homebrew…"); - await exec("brew", ["install", "go"]); + // Ensure Go toolchain is present; offer Homebrew install if missing. + const hasGo = await exec("go", ["version"]).then( + () => true, + () => false, + ); + if (hasGo) return; + const install = await prompt( + "Go is not installed. Install via Homebrew (brew install go)?", + true, + ); + if (!install) { + runtime.error("Go is required to build tailscaled from source. Aborting."); + runtime.exit(1); + } + logVerbose("Installing Go via Homebrew…"); + await exec("brew", ["install", "go"]); } export async function ensureTailscaledInstalled( - exec: typeof runExec = runExec, - prompt: typeof promptYesNo = promptYesNo, - runtime: RuntimeEnv = defaultRuntime, + exec: typeof runExec = runExec, + prompt: typeof promptYesNo = promptYesNo, + runtime: RuntimeEnv = defaultRuntime, ) { - // Ensure tailscaled binary exists; install via Homebrew tailscale if missing. - const hasTailscaled = await exec("tailscaled", ["--version"]).then( - () => true, - () => false, - ); - if (hasTailscaled) return; + // Ensure tailscaled binary exists; install via Homebrew tailscale if missing. + const hasTailscaled = await exec("tailscaled", ["--version"]).then( + () => true, + () => false, + ); + if (hasTailscaled) return; - const install = await prompt( - "tailscaled not found. Install via Homebrew (tailscale package)?", - true, - ); - if (!install) { - runtime.error("tailscaled is required for user-space funnel. Aborting."); - runtime.exit(1); - } - logVerbose("Installing tailscaled via Homebrew…"); - await exec("brew", ["install", "tailscale"]); + const install = await prompt( + "tailscaled not found. Install via Homebrew (tailscale package)?", + true, + ); + if (!install) { + runtime.error("tailscaled is required for user-space funnel. Aborting."); + runtime.exit(1); + } + logVerbose("Installing tailscaled via Homebrew…"); + await exec("brew", ["install", "tailscale"]); } export async function ensureFunnel( - port: number, - exec: typeof runExec = runExec, - runtime: RuntimeEnv = defaultRuntime, - prompt: typeof promptYesNo = promptYesNo, + port: number, + exec: typeof runExec = runExec, + runtime: RuntimeEnv = defaultRuntime, + prompt: typeof promptYesNo = promptYesNo, ) { - // Ensure Funnel is enabled and publish the webhook port. - try { - const statusOut = ( - await exec("tailscale", ["funnel", "status", "--json"]) - ).stdout.trim(); - const parsed = statusOut - ? (JSON.parse(statusOut) as Record) - : {}; - if (!parsed || Object.keys(parsed).length === 0) { - runtime.error( - danger("Tailscale Funnel is not enabled on this tailnet/device."), - ); - runtime.error( - info( - "Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)", - ), - ); - runtime.error( - info( - "macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS", - ), - ); - const proceed = await prompt( - "Attempt local setup with user-space tailscaled?", - true, - ); - if (!proceed) runtime.exit(1); - await ensureBinary("brew", exec, runtime); - await ensureGoInstalled(exec, prompt, runtime); - await ensureTailscaledInstalled(exec, prompt, runtime); - } + // Ensure Funnel is enabled and publish the webhook port. + try { + const statusOut = ( + await exec("tailscale", ["funnel", "status", "--json"]) + ).stdout.trim(); + const parsed = statusOut + ? (JSON.parse(statusOut) as Record) + : {}; + if (!parsed || Object.keys(parsed).length === 0) { + runtime.error( + danger("Tailscale Funnel is not enabled on this tailnet/device."), + ); + runtime.error( + info( + "Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)", + ), + ); + runtime.error( + info( + "macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS", + ), + ); + const proceed = await prompt( + "Attempt local setup with user-space tailscaled?", + true, + ); + if (!proceed) runtime.exit(1); + await ensureBinary("brew", exec, runtime); + await ensureGoInstalled(exec, prompt, runtime); + await ensureTailscaledInstalled(exec, prompt, runtime); + } - logVerbose(`Enabling funnel on port ${port}…`); - const { stdout } = await exec( - "tailscale", - ["funnel", "--yes", "--bg", `${port}`], - { - maxBuffer: 200_000, - timeoutMs: 15_000, - }, - ); - if (stdout.trim()) console.log(stdout.trim()); - } catch (err) { - const errOutput = err as { stdout?: unknown; stderr?: unknown }; - const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : ""; - const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : ""; - if (stdout.includes("Funnel is not enabled")) { - console.error(danger("Funnel is not enabled on this tailnet/device.")); - const linkMatch = stdout.match(/https?:\/\/\S+/); - if (linkMatch) { - console.error(info(`Enable it here: ${linkMatch[0]}`)); - } else { - console.error( - info( - "Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)", - ), - ); - } - } - if ( - stderr.includes("client version") || - stdout.includes("client version") - ) { - console.error( - warn( - "Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.", - ), - ); - } - runtime.error( - "Failed to enable Tailscale Funnel. Is it allowed on your tailnet?", - ); - runtime.error( - info( - "Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`", - ), - ); - if (isVerbose()) { - if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`)); - if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`)); - runtime.error(err as Error); - } - runtime.exit(1); - } + logVerbose(`Enabling funnel on port ${port}…`); + const { stdout } = await exec( + "tailscale", + ["funnel", "--yes", "--bg", `${port}`], + { + maxBuffer: 200_000, + timeoutMs: 15_000, + }, + ); + if (stdout.trim()) console.log(stdout.trim()); + } catch (err) { + const errOutput = err as { stdout?: unknown; stderr?: unknown }; + const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : ""; + const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : ""; + if (stdout.includes("Funnel is not enabled")) { + console.error(danger("Funnel is not enabled on this tailnet/device.")); + const linkMatch = stdout.match(/https?:\/\/\S+/); + if (linkMatch) { + console.error(info(`Enable it here: ${linkMatch[0]}`)); + } else { + console.error( + info( + "Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)", + ), + ); + } + } + if ( + stderr.includes("client version") || + stdout.includes("client version") + ) { + console.error( + warn( + "Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.", + ), + ); + } + runtime.error( + "Failed to enable Tailscale Funnel. Is it allowed on your tailnet?", + ); + runtime.error( + info( + "Tip: you can fall back to polling (no webhooks needed): `pnpm warelay relay --provider twilio --interval 5 --lookback 10`", + ), + ); + if (isVerbose()) { + if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`)); + if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`)); + runtime.error(err as Error); + } + runtime.exit(1); + } } diff --git a/src/logger.test.ts b/src/logger.test.ts index bbafec4a5..1e7d6aa63 100644 --- a/src/logger.test.ts +++ b/src/logger.test.ts @@ -11,72 +11,72 @@ import { resetLogger, setLoggerOverride } from "./logging.js"; import type { RuntimeEnv } from "./runtime.js"; describe("logger helpers", () => { - afterEach(() => { - resetLogger(); - setLoggerOverride(null); - setVerbose(false); - }); + afterEach(() => { + resetLogger(); + setLoggerOverride(null); + setVerbose(false); + }); - it("formats messages through runtime log/error", () => { - const log = vi.fn(); - const error = vi.fn(); - const runtime: RuntimeEnv = { log, error, exit: vi.fn() }; + it("formats messages through runtime log/error", () => { + const log = vi.fn(); + const error = vi.fn(); + const runtime: RuntimeEnv = { log, error, exit: vi.fn() }; - logInfo("info", runtime); - logWarn("warn", runtime); - logSuccess("ok", runtime); - logError("bad", runtime); + logInfo("info", runtime); + logWarn("warn", runtime); + logSuccess("ok", runtime); + logError("bad", runtime); - expect(log).toHaveBeenCalledTimes(3); - expect(error).toHaveBeenCalledTimes(1); - }); + expect(log).toHaveBeenCalledTimes(3); + expect(error).toHaveBeenCalledTimes(1); + }); - it("only logs debug when verbose is enabled", () => { - const logVerbose = vi.spyOn(console, "log"); - setVerbose(false); - logDebug("quiet"); - expect(logVerbose).not.toHaveBeenCalled(); + it("only logs debug when verbose is enabled", () => { + const logVerbose = vi.spyOn(console, "log"); + setVerbose(false); + logDebug("quiet"); + expect(logVerbose).not.toHaveBeenCalled(); - setVerbose(true); - logVerbose.mockClear(); - logDebug("loud"); - expect(logVerbose).toHaveBeenCalled(); - logVerbose.mockRestore(); - }); + setVerbose(true); + logVerbose.mockClear(); + logDebug("loud"); + expect(logVerbose).toHaveBeenCalled(); + logVerbose.mockRestore(); + }); - it("writes to configured log file at configured level", () => { - const logPath = pathForTest(); - cleanup(logPath); - setLoggerOverride({ level: "debug", file: logPath }); - logInfo("hello"); - logDebug("debug-only"); - const content = fs.readFileSync(logPath, "utf-8"); - expect(content).toContain("hello"); - expect(content).toContain("debug-only"); - cleanup(logPath); - }); + it("writes to configured log file at configured level", () => { + const logPath = pathForTest(); + cleanup(logPath); + setLoggerOverride({ level: "debug", file: logPath }); + logInfo("hello"); + logDebug("debug-only"); + const content = fs.readFileSync(logPath, "utf-8"); + expect(content).toContain("hello"); + expect(content).toContain("debug-only"); + cleanup(logPath); + }); - it("filters messages below configured level", () => { - const logPath = pathForTest(); - cleanup(logPath); - setLoggerOverride({ level: "warn", file: logPath }); - logInfo("info-only"); - logWarn("warn-only"); - const content = fs.readFileSync(logPath, "utf-8"); - expect(content).not.toContain("info-only"); - expect(content).toContain("warn-only"); - cleanup(logPath); - }); + it("filters messages below configured level", () => { + const logPath = pathForTest(); + cleanup(logPath); + setLoggerOverride({ level: "warn", file: logPath }); + logInfo("info-only"); + logWarn("warn-only"); + const content = fs.readFileSync(logPath, "utf-8"); + expect(content).not.toContain("info-only"); + expect(content).toContain("warn-only"); + cleanup(logPath); + }); }); function pathForTest() { - return path.join(os.tmpdir(), `warelay-log-${crypto.randomUUID()}.log`); + return path.join(os.tmpdir(), `warelay-log-${crypto.randomUUID()}.log`); } function cleanup(file: string) { - try { - fs.rmSync(file, { force: true }); - } catch { - // ignore - } + try { + fs.rmSync(file, { force: true }); + } catch { + // ignore + } } diff --git a/src/logger.ts b/src/logger.ts index b0f027ba9..20d31452f 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,42 +1,42 @@ import { - danger, - info, - isVerbose, - logVerbose, - success, - warn, + danger, + info, + isVerbose, + logVerbose, + success, + warn, } from "./globals.js"; import { getLogger } from "./logging.js"; import { defaultRuntime, type RuntimeEnv } from "./runtime.js"; export function logInfo(message: string, runtime: RuntimeEnv = defaultRuntime) { - runtime.log(info(message)); - getLogger().info(message); + runtime.log(info(message)); + getLogger().info(message); } export function logWarn(message: string, runtime: RuntimeEnv = defaultRuntime) { - runtime.log(warn(message)); - getLogger().warn(message); + runtime.log(warn(message)); + getLogger().warn(message); } export function logSuccess( - message: string, - runtime: RuntimeEnv = defaultRuntime, + message: string, + runtime: RuntimeEnv = defaultRuntime, ) { - runtime.log(success(message)); - getLogger().info(message); + runtime.log(success(message)); + getLogger().info(message); } export function logError( - message: string, - runtime: RuntimeEnv = defaultRuntime, + message: string, + runtime: RuntimeEnv = defaultRuntime, ) { - runtime.error(danger(message)); - getLogger().error(message); + runtime.error(danger(message)); + getLogger().error(message); } export function logDebug(message: string) { - // Always emit to file logger (level-filtered); console only when verbose. - getLogger().debug(message); - if (isVerbose()) logVerbose(message); + // Always emit to file logger (level-filtered); console only when verbose. + getLogger().debug(message); + if (isVerbose()) logVerbose(message); } diff --git a/src/logging.ts b/src/logging.ts index 8bcc0b064..858a185a3 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -10,23 +10,23 @@ const DEFAULT_LOG_DIR = path.join(os.tmpdir(), "warelay"); export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "warelay.log"); const ALLOWED_LEVELS: readonly LevelWithSilent[] = [ - "silent", - "fatal", - "error", - "warn", - "info", - "debug", - "trace", + "silent", + "fatal", + "error", + "warn", + "info", + "debug", + "trace", ]; export type LoggerSettings = { - level?: LevelWithSilent; - file?: string; + level?: LevelWithSilent; + file?: string; }; type ResolvedSettings = { - level: LevelWithSilent; - file: string; + level: LevelWithSilent; + file: string; }; let cachedLogger: Logger | null = null; @@ -34,68 +34,68 @@ let cachedSettings: ResolvedSettings | null = null; let overrideSettings: LoggerSettings | null = null; function normalizeLevel(level?: string): LevelWithSilent { - if (isVerbose()) return "debug"; - const candidate = level ?? "info"; - return ALLOWED_LEVELS.includes(candidate as LevelWithSilent) - ? (candidate as LevelWithSilent) - : "info"; + if (isVerbose()) return "debug"; + const candidate = level ?? "info"; + return ALLOWED_LEVELS.includes(candidate as LevelWithSilent) + ? (candidate as LevelWithSilent) + : "info"; } function resolveSettings(): ResolvedSettings { - const cfg: WarelayConfig["logging"] | undefined = - overrideSettings ?? loadConfig().logging; - const level = normalizeLevel(cfg?.level); - const file = cfg?.file ?? DEFAULT_LOG_FILE; - return { level, file }; + const cfg: WarelayConfig["logging"] | undefined = + overrideSettings ?? loadConfig().logging; + const level = normalizeLevel(cfg?.level); + const file = cfg?.file ?? DEFAULT_LOG_FILE; + return { level, file }; } function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) { - if (!a) return true; - return a.level !== b.level || a.file !== b.file; + if (!a) return true; + return a.level !== b.level || a.file !== b.file; } function buildLogger(settings: ResolvedSettings): Logger { - fs.mkdirSync(path.dirname(settings.file), { recursive: true }); - const destination = pino.destination({ - dest: settings.file, - mkdir: true, - sync: true, // deterministic for tests; log volume is modest. - }); - return pino( - { - level: settings.level, - base: undefined, - timestamp: pino.stdTimeFunctions.isoTime, - }, - destination, - ); + fs.mkdirSync(path.dirname(settings.file), { recursive: true }); + const destination = pino.destination({ + dest: settings.file, + mkdir: true, + sync: true, // deterministic for tests; log volume is modest. + }); + return pino( + { + level: settings.level, + base: undefined, + timestamp: pino.stdTimeFunctions.isoTime, + }, + destination, + ); } export function getLogger(): Logger { - const settings = resolveSettings(); - if (!cachedLogger || settingsChanged(cachedSettings, settings)) { - cachedLogger = buildLogger(settings); - cachedSettings = settings; - } - return cachedLogger; + const settings = resolveSettings(); + if (!cachedLogger || settingsChanged(cachedSettings, settings)) { + cachedLogger = buildLogger(settings); + cachedSettings = settings; + } + return cachedLogger; } export function getChildLogger( - bindings?: Bindings, - opts?: { level?: LevelWithSilent }, + bindings?: Bindings, + opts?: { level?: LevelWithSilent }, ): Logger { - return getLogger().child(bindings ?? {}, opts); + return getLogger().child(bindings ?? {}, opts); } // Test helpers export function setLoggerOverride(settings: LoggerSettings | null) { - overrideSettings = settings; - cachedLogger = null; - cachedSettings = null; + overrideSettings = settings; + cachedLogger = null; + cachedSettings = null; } export function resetLogger() { - cachedLogger = null; - cachedSettings = null; - overrideSettings = null; + cachedLogger = null; + cachedSettings = null; + overrideSettings = null; } diff --git a/src/media/constants.ts b/src/media/constants.ts index 27aff351f..e74ac6934 100644 --- a/src/media/constants.ts +++ b/src/media/constants.ts @@ -6,26 +6,26 @@ export const MAX_DOCUMENT_BYTES = 100 * 1024 * 1024; // 100MB export type MediaKind = "image" | "audio" | "video" | "document" | "unknown"; export function mediaKindFromMime(mime?: string | null): MediaKind { - if (!mime) return "unknown"; - if (mime.startsWith("image/")) return "image"; - if (mime.startsWith("audio/")) return "audio"; - if (mime.startsWith("video/")) return "video"; - if (mime === "application/pdf") return "document"; - if (mime.startsWith("application/")) return "document"; - return "unknown"; + if (!mime) return "unknown"; + if (mime.startsWith("image/")) return "image"; + if (mime.startsWith("audio/")) return "audio"; + if (mime.startsWith("video/")) return "video"; + if (mime === "application/pdf") return "document"; + if (mime.startsWith("application/")) return "document"; + return "unknown"; } export function maxBytesForKind(kind: MediaKind): number { - switch (kind) { - case "image": - return MAX_IMAGE_BYTES; - case "audio": - return MAX_AUDIO_BYTES; - case "video": - return MAX_VIDEO_BYTES; - case "document": - return MAX_DOCUMENT_BYTES; - default: - return MAX_DOCUMENT_BYTES; - } + switch (kind) { + case "image": + return MAX_IMAGE_BYTES; + case "audio": + return MAX_AUDIO_BYTES; + case "video": + return MAX_VIDEO_BYTES; + case "document": + return MAX_DOCUMENT_BYTES; + default: + return MAX_DOCUMENT_BYTES; + } } diff --git a/src/media/host.test.ts b/src/media/host.test.ts index 3c6d587d6..1703e53cd 100644 --- a/src/media/host.test.ts +++ b/src/media/host.test.ts @@ -12,11 +12,11 @@ const logInfo = vi.fn(); vi.mock("./store.js", () => ({ saveMediaSource })); vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname })); vi.mock("../infra/ports.js", async () => { - const actual = - await vi.importActual( - "../infra/ports.js", - ); - return { ensurePortAvailable, PortInUseError: actual.PortInUseError }; + const actual = + await vi.importActual( + "../infra/ports.js", + ); + return { ensurePortAvailable, PortInUseError: actual.PortInUseError }; }); vi.mock("./server.js", () => ({ startMediaServer })); vi.mock("../logger.js", () => ({ logInfo })); @@ -25,69 +25,69 @@ const { ensureMediaHosted } = await import("./host.js"); const { PortInUseError } = await import("../infra/ports.js"); describe("ensureMediaHosted", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - it("throws and cleans up when server not allowed to start", async () => { - saveMediaSource.mockResolvedValue({ - id: "id1", - path: "/tmp/file1", - size: 5, - }); - getTailnetHostname.mockResolvedValue("tailnet-host"); - ensurePortAvailable.mockResolvedValue(undefined); - const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined); + it("throws and cleans up when server not allowed to start", async () => { + saveMediaSource.mockResolvedValue({ + id: "id1", + path: "/tmp/file1", + size: 5, + }); + getTailnetHostname.mockResolvedValue("tailnet-host"); + ensurePortAvailable.mockResolvedValue(undefined); + const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined); - await expect( - ensureMediaHosted("/tmp/file1", { startServer: false }), - ).rejects.toThrow("requires the webhook/Funnel server"); - expect(rmSpy).toHaveBeenCalledWith("/tmp/file1"); - rmSpy.mockRestore(); - }); + await expect( + ensureMediaHosted("/tmp/file1", { startServer: false }), + ).rejects.toThrow("requires the webhook/Funnel server"); + expect(rmSpy).toHaveBeenCalledWith("/tmp/file1"); + rmSpy.mockRestore(); + }); - it("starts media server when allowed", async () => { - saveMediaSource.mockResolvedValue({ - id: "id2", - path: "/tmp/file2", - size: 9, - }); - getTailnetHostname.mockResolvedValue("tail.net"); - ensurePortAvailable.mockResolvedValue(undefined); - const fakeServer = { unref: vi.fn() } as unknown as Server; - startMediaServer.mockResolvedValue(fakeServer); + it("starts media server when allowed", async () => { + saveMediaSource.mockResolvedValue({ + id: "id2", + path: "/tmp/file2", + size: 9, + }); + getTailnetHostname.mockResolvedValue("tail.net"); + ensurePortAvailable.mockResolvedValue(undefined); + const fakeServer = { unref: vi.fn() } as unknown as Server; + startMediaServer.mockResolvedValue(fakeServer); - const result = await ensureMediaHosted("/tmp/file2", { - startServer: true, - port: 1234, - }); - expect(startMediaServer).toHaveBeenCalledWith( - 1234, - expect.any(Number), - expect.anything(), - ); - expect(logInfo).toHaveBeenCalled(); - expect(result).toEqual({ - url: "https://tail.net/media/id2", - id: "id2", - size: 9, - }); - }); + const result = await ensureMediaHosted("/tmp/file2", { + startServer: true, + port: 1234, + }); + expect(startMediaServer).toHaveBeenCalledWith( + 1234, + expect.any(Number), + expect.anything(), + ); + expect(logInfo).toHaveBeenCalled(); + expect(result).toEqual({ + url: "https://tail.net/media/id2", + id: "id2", + size: 9, + }); + }); - it("skips server start when port already in use", async () => { - saveMediaSource.mockResolvedValue({ - id: "id3", - path: "/tmp/file3", - size: 7, - }); - getTailnetHostname.mockResolvedValue("tail.net"); - ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc")); + it("skips server start when port already in use", async () => { + saveMediaSource.mockResolvedValue({ + id: "id3", + path: "/tmp/file3", + size: 7, + }); + getTailnetHostname.mockResolvedValue("tail.net"); + ensurePortAvailable.mockRejectedValue(new PortInUseError(3000, "proc")); - const result = await ensureMediaHosted("/tmp/file3", { - startServer: false, - port: 3000, - }); - expect(startMediaServer).not.toHaveBeenCalled(); - expect(result.url).toBe("https://tail.net/media/id3"); - }); + const result = await ensureMediaHosted("/tmp/file3", { + startServer: false, + port: 3000, + }); + expect(startMediaServer).not.toHaveBeenCalled(); + expect(result.url).toBe("https://tail.net/media/id3"); + }); }); diff --git a/src/media/host.ts b/src/media/host.ts index bb7a1e3bd..83d1d8e74 100644 --- a/src/media/host.ts +++ b/src/media/host.ts @@ -12,54 +12,54 @@ const TTL_MS = 2 * 60 * 1000; let mediaServer: import("http").Server | null = null; export type HostedMedia = { - url: string; - id: string; - size: number; + url: string; + id: string; + size: number; }; export async function ensureMediaHosted( - source: string, - opts: { - port?: number; - startServer?: boolean; - runtime?: RuntimeEnv; - } = {}, + source: string, + opts: { + port?: number; + startServer?: boolean; + runtime?: RuntimeEnv; + } = {}, ): Promise { - const port = opts.port ?? DEFAULT_PORT; - const runtime = opts.runtime ?? defaultRuntime; + const port = opts.port ?? DEFAULT_PORT; + const runtime = opts.runtime ?? defaultRuntime; - const saved = await saveMediaSource(source); - const hostname = await getTailnetHostname(); + const saved = await saveMediaSource(source); + const hostname = await getTailnetHostname(); - // Decide whether we must start a media server. - const needsServerStart = await isPortFree(port); - if (needsServerStart && !opts.startServer) { - await fs.rm(saved.path).catch(() => {}); - throw new Error( - "Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.", - ); - } - if (needsServerStart && opts.startServer) { - if (!mediaServer) { - mediaServer = await startMediaServer(port, TTL_MS, runtime); - logInfo( - `📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`, - runtime, - ); - mediaServer.unref?.(); - } - } + // Decide whether we must start a media server. + const needsServerStart = await isPortFree(port); + if (needsServerStart && !opts.startServer) { + await fs.rm(saved.path).catch(() => {}); + throw new Error( + "Media hosting requires the webhook/Funnel server. Start `warelay webhook`/`warelay up` or re-run with --serve-media.", + ); + } + if (needsServerStart && opts.startServer) { + if (!mediaServer) { + mediaServer = await startMediaServer(port, TTL_MS, runtime); + logInfo( + `📡 Started temporary media host on http://localhost:${port}/media/:id (TTL ${TTL_MS / 1000}s)`, + runtime, + ); + mediaServer.unref?.(); + } + } - const url = `https://${hostname}/media/${saved.id}`; - return { url, id: saved.id, size: saved.size }; + const url = `https://${hostname}/media/${saved.id}`; + return { url, id: saved.id, size: saved.size }; } async function isPortFree(port: number) { - try { - await ensurePortAvailable(port); - return true; - } catch (err) { - if (err instanceof PortInUseError) return false; - throw err; - } + try { + await ensurePortAvailable(port); + return true; + } catch (err) { + if (err instanceof PortInUseError) return false; + throw err; + } } diff --git a/src/media/parse.ts b/src/media/parse.ts index abb31408b..d6d76334b 100644 --- a/src/media/parse.ts +++ b/src/media/parse.ts @@ -1,53 +1,101 @@ // Shared helpers for parsing MEDIA tokens from command/stdout text. // Allow optional wrapping backticks and punctuation after the token; capture the core token. -export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\s`]+)`?/i; +export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi; export function normalizeMediaSource(src: string) { - return src.startsWith("file://") ? src.replace("file://", "") : src; + return src.startsWith("file://") ? src.replace("file://", "") : src; } function cleanCandidate(raw: string) { - return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, ""); + return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, ""); } function isValidMedia(candidate: string) { - if (!candidate) return false; - if (candidate.length > 1024) return false; - if (/\s/.test(candidate)) return false; - return ( - /^https?:\/\//i.test(candidate) || - candidate.startsWith("/") || - candidate.startsWith("./") - ); + if (!candidate) return false; + if (candidate.length > 1024) return false; + if (/\s/.test(candidate)) return false; + return ( + /^https?:\/\//i.test(candidate) || + candidate.startsWith("/") || + candidate.startsWith("./") + ); } export function splitMediaFromOutput(raw: string): { - text: string; - mediaUrl?: string; + text: string; + mediaUrls?: string[]; + mediaUrl?: string; // legacy first item for backward compatibility } { - const trimmedRaw = raw.trim(); - const match = MEDIA_TOKEN_RE.exec(trimmedRaw); - if (!match?.[1]) return { text: trimmedRaw }; + const trimmedRaw = raw.trim(); + if (!trimmedRaw) return { text: "" }; - const candidate = normalizeMediaSource(cleanCandidate(match[1])); - const mediaUrl = isValidMedia(candidate) ? candidate : undefined; + const media: string[] = []; + let foundMediaToken = false; - const cleanedText = mediaUrl - ? trimmedRaw - .replace(match[0], "") - .replace(/[ \t]+\n/g, "\n") - .replace(/[ \t]{2,}/g, " ") - .replace(/\n{2,}/g, "\n") - .trim() - : trimmedRaw - .split("\n") - .filter((line) => !MEDIA_TOKEN_RE.test(line)) - .join("\n") - .replace(/[ \t]+\n/g, "\n") - .replace(/[ \t]{2,}/g, " ") - .replace(/\n{2,}/g, "\n") - .trim(); + // Collect tokens line by line so we can strip them cleanly. + const lines = trimmedRaw.split("\n"); + const keptLines: string[] = []; - return mediaUrl ? { text: cleanedText, mediaUrl } : { text: cleanedText }; + for (const line of lines) { + const matches = Array.from(line.matchAll(MEDIA_TOKEN_RE)); + if (matches.length === 0) { + keptLines.push(line); + continue; + } + + foundMediaToken = true; + const pieces: string[] = []; + let cursor = 0; + let hasValidMedia = false; + + for (const match of matches) { + const start = match.index ?? 0; + pieces.push(line.slice(cursor, start)); + + const payload = match[1]; + const parts = payload.split(/\s+/).filter(Boolean); + const invalidParts: string[] = []; + for (const part of parts) { + const candidate = normalizeMediaSource(cleanCandidate(part)); + if (isValidMedia(candidate)) { + media.push(candidate); + hasValidMedia = true; + } else { + invalidParts.push(part); + } + } + + if (hasValidMedia && invalidParts.length > 0) { + pieces.push(invalidParts.join(" ")); + } + + cursor = start + match[0].length; + } + + pieces.push(line.slice(cursor)); + + const cleanedLine = pieces + .join("") + .replace(/[ \t]{2,}/g, " ") + .trim(); + + // If the line becomes empty, drop it. + if (cleanedLine) { + keptLines.push(cleanedLine); + } + } + + const cleanedText = keptLines + .join("\n") + .replace(/[ \t]+\n/g, "\n") + .replace(/[ \t]{2,}/g, " ") + .replace(/\n{2,}/g, "\n") + .trim(); + + if (media.length === 0) { + return { text: foundMediaToken ? cleanedText : trimmedRaw }; + } + + return { text: cleanedText, mediaUrls: media, mediaUrl: media[0] }; } diff --git a/src/media/server.test.ts b/src/media/server.test.ts index aa5620de6..876051dcd 100644 --- a/src/media/server.test.ts +++ b/src/media/server.test.ts @@ -8,45 +8,45 @@ const MEDIA_DIR = path.join(process.cwd(), "tmp-media-test"); const cleanOldMedia = vi.fn().mockResolvedValue(undefined); vi.mock("./store.js", () => ({ - getMediaDir: () => MEDIA_DIR, - cleanOldMedia, + getMediaDir: () => MEDIA_DIR, + cleanOldMedia, })); const { startMediaServer } = await import("./server.js"); describe("media server", () => { - beforeAll(async () => { - await fs.rm(MEDIA_DIR, { recursive: true, force: true }); - await fs.mkdir(MEDIA_DIR, { recursive: true }); - }); + beforeAll(async () => { + await fs.rm(MEDIA_DIR, { recursive: true, force: true }); + await fs.mkdir(MEDIA_DIR, { recursive: true }); + }); - afterAll(async () => { - await fs.rm(MEDIA_DIR, { recursive: true, force: true }); - }); + afterAll(async () => { + await fs.rm(MEDIA_DIR, { recursive: true, force: true }); + }); - it("serves media and cleans up after send", async () => { - const file = path.join(MEDIA_DIR, "file1"); - await fs.writeFile(file, "hello"); - const server = await startMediaServer(0, 5_000); - const port = (server.address() as AddressInfo).port; - const res = await fetch(`http://localhost:${port}/media/file1`); - expect(res.status).toBe(200); - expect(await res.text()).toBe("hello"); - await new Promise((r) => setTimeout(r, 600)); - await expect(fs.stat(file)).rejects.toThrow(); - await new Promise((r) => server.close(r)); - }); + it("serves media and cleans up after send", async () => { + const file = path.join(MEDIA_DIR, "file1"); + await fs.writeFile(file, "hello"); + const server = await startMediaServer(0, 5_000); + const port = (server.address() as AddressInfo).port; + const res = await fetch(`http://localhost:${port}/media/file1`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("hello"); + await new Promise((r) => setTimeout(r, 600)); + await expect(fs.stat(file)).rejects.toThrow(); + await new Promise((r) => server.close(r)); + }); - it("expires old media", async () => { - const file = path.join(MEDIA_DIR, "old"); - await fs.writeFile(file, "stale"); - const past = Date.now() - 10_000; - await fs.utimes(file, past / 1000, past / 1000); - const server = await startMediaServer(0, 1_000); - const port = (server.address() as AddressInfo).port; - const res = await fetch(`http://localhost:${port}/media/old`); - expect(res.status).toBe(410); - await expect(fs.stat(file)).rejects.toThrow(); - await new Promise((r) => server.close(r)); - }); + it("expires old media", async () => { + const file = path.join(MEDIA_DIR, "old"); + await fs.writeFile(file, "stale"); + const past = Date.now() - 10_000; + await fs.utimes(file, past / 1000, past / 1000); + const server = await startMediaServer(0, 1_000); + const port = (server.address() as AddressInfo).port; + const res = await fetch(`http://localhost:${port}/media/old`); + expect(res.status).toBe(410); + await expect(fs.stat(file)).rejects.toThrow(); + await new Promise((r) => server.close(r)); + }); }); diff --git a/src/media/server.ts b/src/media/server.ts index bd813be80..52c5a1ec3 100644 --- a/src/media/server.ts +++ b/src/media/server.ts @@ -9,53 +9,53 @@ import { cleanOldMedia, getMediaDir } from "./store.js"; const DEFAULT_TTL_MS = 2 * 60 * 1000; export function attachMediaRoutes( - app: Express, - ttlMs = DEFAULT_TTL_MS, - _runtime: RuntimeEnv = defaultRuntime, + app: Express, + ttlMs = DEFAULT_TTL_MS, + _runtime: RuntimeEnv = defaultRuntime, ) { - const mediaDir = getMediaDir(); + const mediaDir = getMediaDir(); - app.get("/media/:id", async (req, res) => { - const id = req.params.id; - const file = path.join(mediaDir, id); - try { - const stat = await fs.stat(file); - if (Date.now() - stat.mtimeMs > ttlMs) { - await fs.rm(file).catch(() => {}); - res.status(410).send("expired"); - return; - } - res.sendFile(file); - // best-effort single-use cleanup after response ends - res.on("finish", () => { - setTimeout(() => { - fs.rm(file).catch(() => {}); - }, 500); - }); - } catch { - res.status(404).send("not found"); - } - }); + app.get("/media/:id", async (req, res) => { + const id = req.params.id; + const file = path.join(mediaDir, id); + try { + const stat = await fs.stat(file); + if (Date.now() - stat.mtimeMs > ttlMs) { + await fs.rm(file).catch(() => {}); + res.status(410).send("expired"); + return; + } + res.sendFile(file); + // best-effort single-use cleanup after response ends + res.on("finish", () => { + setTimeout(() => { + fs.rm(file).catch(() => {}); + }, 500); + }); + } catch { + res.status(404).send("not found"); + } + }); - // periodic cleanup - setInterval(() => { - void cleanOldMedia(ttlMs); - }, ttlMs).unref(); + // periodic cleanup + setInterval(() => { + void cleanOldMedia(ttlMs); + }, ttlMs).unref(); } export async function startMediaServer( - port: number, - ttlMs = DEFAULT_TTL_MS, - runtime: RuntimeEnv = defaultRuntime, + port: number, + ttlMs = DEFAULT_TTL_MS, + runtime: RuntimeEnv = defaultRuntime, ): Promise { - const app = express(); - attachMediaRoutes(app, ttlMs, runtime); - return await new Promise((resolve, reject) => { - const server = app.listen(port); - server.once("listening", () => resolve(server)); - server.once("error", (err) => { - runtime.error(danger(`Media server failed: ${String(err)}`)); - reject(err); - }); - }); + const app = express(); + attachMediaRoutes(app, ttlMs, runtime); + return await new Promise((resolve, reject) => { + const server = app.listen(port); + server.once("listening", () => resolve(server)); + server.once("error", (err) => { + runtime.error(danger(`Media server failed: ${String(err)}`)); + reject(err); + }); + }); } diff --git a/src/media/store.test.ts b/src/media/store.test.ts index 7db7b832f..b3c2dd1d3 100644 --- a/src/media/store.test.ts +++ b/src/media/store.test.ts @@ -7,54 +7,54 @@ const realOs = await vi.importActual("node:os"); const HOME = path.join(realOs.tmpdir(), "warelay-home-test"); vi.mock("node:os", () => ({ - default: { homedir: () => HOME }, - homedir: () => HOME, + default: { homedir: () => HOME }, + homedir: () => HOME, })); const store = await import("./store.js"); describe("media store", () => { - beforeAll(async () => { - await fs.rm(HOME, { recursive: true, force: true }); - }); + beforeAll(async () => { + await fs.rm(HOME, { recursive: true, force: true }); + }); - afterAll(async () => { - await fs.rm(HOME, { recursive: true, force: true }); - }); + afterAll(async () => { + await fs.rm(HOME, { recursive: true, force: true }); + }); - it("creates and returns media directory", async () => { - const dir = await store.ensureMediaDir(); - expect(dir).toContain("warelay-home-test"); - const stat = await fs.stat(dir); - expect(stat.isDirectory()).toBe(true); - }); + it("creates and returns media directory", async () => { + const dir = await store.ensureMediaDir(); + expect(dir).toContain("warelay-home-test"); + const stat = await fs.stat(dir); + expect(stat.isDirectory()).toBe(true); + }); - it("saves buffers and enforces size limit", async () => { - const buf = Buffer.from("hello"); - const saved = await store.saveMediaBuffer(buf, "text/plain"); - const savedStat = await fs.stat(saved.path); - expect(savedStat.size).toBe(buf.length); - expect(saved.contentType).toBe("text/plain"); + it("saves buffers and enforces size limit", async () => { + const buf = Buffer.from("hello"); + const saved = await store.saveMediaBuffer(buf, "text/plain"); + const savedStat = await fs.stat(saved.path); + expect(savedStat.size).toBe(buf.length); + expect(saved.contentType).toBe("text/plain"); - const huge = Buffer.alloc(5 * 1024 * 1024 + 1); - await expect(store.saveMediaBuffer(huge)).rejects.toThrow( - "Media exceeds 5MB limit", - ); - }); + const huge = Buffer.alloc(5 * 1024 * 1024 + 1); + await expect(store.saveMediaBuffer(huge)).rejects.toThrow( + "Media exceeds 5MB limit", + ); + }); - it("copies local files and cleans old media", async () => { - const srcFile = path.join(HOME, "tmp-src.txt"); - await fs.mkdir(HOME, { recursive: true }); - await fs.writeFile(srcFile, "local file"); - const saved = await store.saveMediaSource(srcFile); - expect(saved.size).toBe(10); - const savedStat = await fs.stat(saved.path); - expect(savedStat.isFile()).toBe(true); + it("copies local files and cleans old media", async () => { + const srcFile = path.join(HOME, "tmp-src.txt"); + await fs.mkdir(HOME, { recursive: true }); + await fs.writeFile(srcFile, "local file"); + const saved = await store.saveMediaSource(srcFile); + expect(saved.size).toBe(10); + const savedStat = await fs.stat(saved.path); + expect(savedStat.isFile()).toBe(true); - // make the file look old and ensure cleanOldMedia removes it - const past = Date.now() - 10_000; - await fs.utimes(saved.path, past / 1000, past / 1000); - await store.cleanOldMedia(1); - await expect(fs.stat(saved.path)).rejects.toThrow(); - }); + // make the file look old and ensure cleanOldMedia removes it + const past = Date.now() - 10_000; + await fs.utimes(saved.path, past / 1000, past / 1000); + await store.cleanOldMedia(1); + await expect(fs.stat(saved.path)).rejects.toThrow(); + }); }); diff --git a/src/media/store.ts b/src/media/store.ts index 1a6bc67e4..410db0047 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -11,108 +11,108 @@ const MAX_BYTES = 5 * 1024 * 1024; // 5MB const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes export function getMediaDir() { - return MEDIA_DIR; + return MEDIA_DIR; } export async function ensureMediaDir() { - await fs.mkdir(MEDIA_DIR, { recursive: true }); - return MEDIA_DIR; + await fs.mkdir(MEDIA_DIR, { recursive: true }); + return MEDIA_DIR; } export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) { - await ensureMediaDir(); - const entries = await fs.readdir(MEDIA_DIR).catch(() => []); - const now = Date.now(); - await Promise.all( - entries.map(async (file) => { - const full = path.join(MEDIA_DIR, file); - const stat = await fs.stat(full).catch(() => null); - if (!stat) return; - if (now - stat.mtimeMs > ttlMs) { - await fs.rm(full).catch(() => {}); - } - }), - ); + await ensureMediaDir(); + const entries = await fs.readdir(MEDIA_DIR).catch(() => []); + const now = Date.now(); + await Promise.all( + entries.map(async (file) => { + const full = path.join(MEDIA_DIR, file); + const stat = await fs.stat(full).catch(() => null); + if (!stat) return; + if (now - stat.mtimeMs > ttlMs) { + await fs.rm(full).catch(() => {}); + } + }), + ); } function looksLikeUrl(src: string) { - return /^https?:\/\//i.test(src); + return /^https?:\/\//i.test(src); } async function downloadToFile( - url: string, - dest: string, - headers?: Record, + url: string, + dest: string, + headers?: Record, ) { - await new Promise((resolve, reject) => { - const req = request(url, { headers }, (res) => { - if (!res.statusCode || res.statusCode >= 400) { - reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`)); - return; - } - let total = 0; - const out = createWriteStream(dest); - res.on("data", (chunk) => { - total += chunk.length; - if (total > MAX_BYTES) { - req.destroy(new Error("Media exceeds 5MB limit")); - } - }); - pipeline(res, out) - .then(() => resolve()) - .catch(reject); - }); - req.on("error", reject); - req.end(); - }); + await new Promise((resolve, reject) => { + const req = request(url, { headers }, (res) => { + if (!res.statusCode || res.statusCode >= 400) { + reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`)); + return; + } + let total = 0; + const out = createWriteStream(dest); + res.on("data", (chunk) => { + total += chunk.length; + if (total > MAX_BYTES) { + req.destroy(new Error("Media exceeds 5MB limit")); + } + }); + pipeline(res, out) + .then(() => resolve()) + .catch(reject); + }); + req.on("error", reject); + req.end(); + }); } export type SavedMedia = { - id: string; - path: string; - size: number; - contentType?: string; + id: string; + path: string; + size: number; + contentType?: string; }; export async function saveMediaSource( - source: string, - headers?: Record, - subdir = "", + source: string, + headers?: Record, + subdir = "", ): Promise { - const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR; - await fs.mkdir(dir, { recursive: true }); - await cleanOldMedia(); - const id = crypto.randomUUID(); - const dest = path.join(dir, id); - if (looksLikeUrl(source)) { - await downloadToFile(source, dest, headers); - const stat = await fs.stat(dest); - return { id, path: dest, size: stat.size }; - } - // local path - const stat = await fs.stat(source); - if (!stat.isFile()) { - throw new Error("Media path is not a file"); - } - if (stat.size > MAX_BYTES) { - throw new Error("Media exceeds 5MB limit"); - } - await fs.copyFile(source, dest); - return { id, path: dest, size: stat.size }; + const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR; + await fs.mkdir(dir, { recursive: true }); + await cleanOldMedia(); + const id = crypto.randomUUID(); + const dest = path.join(dir, id); + if (looksLikeUrl(source)) { + await downloadToFile(source, dest, headers); + const stat = await fs.stat(dest); + return { id, path: dest, size: stat.size }; + } + // local path + const stat = await fs.stat(source); + if (!stat.isFile()) { + throw new Error("Media path is not a file"); + } + if (stat.size > MAX_BYTES) { + throw new Error("Media exceeds 5MB limit"); + } + await fs.copyFile(source, dest); + return { id, path: dest, size: stat.size }; } export async function saveMediaBuffer( - buffer: Buffer, - contentType?: string, - subdir = "inbound", + buffer: Buffer, + contentType?: string, + subdir = "inbound", ): Promise { - if (buffer.byteLength > MAX_BYTES) { - throw new Error("Media exceeds 5MB limit"); - } - const dir = path.join(MEDIA_DIR, subdir); - await fs.mkdir(dir, { recursive: true }); - const id = crypto.randomUUID(); - const dest = path.join(dir, id); - await fs.writeFile(dest, buffer); - return { id, path: dest, size: buffer.byteLength, contentType }; + if (buffer.byteLength > MAX_BYTES) { + throw new Error("Media exceeds 5MB limit"); + } + const dir = path.join(MEDIA_DIR, subdir); + await fs.mkdir(dir, { recursive: true }); + const id = crypto.randomUUID(); + const dest = path.join(dir, id); + await fs.writeFile(dest, buffer); + return { id, path: dest, size: buffer.byteLength, contentType }; } diff --git a/src/process/command-queue.test.ts b/src/process/command-queue.test.ts index a90de942c..d71619aed 100644 --- a/src/process/command-queue.test.ts +++ b/src/process/command-queue.test.ts @@ -3,53 +3,53 @@ import { describe, expect, it } from "vitest"; import { enqueueCommand, getQueueSize } from "./command-queue.js"; describe("command queue", () => { - it("runs tasks one at a time in order", async () => { - let active = 0; - let maxActive = 0; - const calls: number[] = []; + it("runs tasks one at a time in order", async () => { + let active = 0; + let maxActive = 0; + const calls: number[] = []; - const makeTask = (id: number) => async () => { - active += 1; - maxActive = Math.max(maxActive, active); - calls.push(id); - await new Promise((resolve) => setTimeout(resolve, 15)); - active -= 1; - return id; - }; + const makeTask = (id: number) => async () => { + active += 1; + maxActive = Math.max(maxActive, active); + calls.push(id); + await new Promise((resolve) => setTimeout(resolve, 15)); + active -= 1; + return id; + }; - const results = await Promise.all([ - enqueueCommand(makeTask(1)), - enqueueCommand(makeTask(2)), - enqueueCommand(makeTask(3)), - ]); + const results = await Promise.all([ + enqueueCommand(makeTask(1)), + enqueueCommand(makeTask(2)), + enqueueCommand(makeTask(3)), + ]); - expect(results).toEqual([1, 2, 3]); - expect(calls).toEqual([1, 2, 3]); - expect(maxActive).toBe(1); - expect(getQueueSize()).toBe(0); - }); + expect(results).toEqual([1, 2, 3]); + expect(calls).toEqual([1, 2, 3]); + expect(maxActive).toBe(1); + expect(getQueueSize()).toBe(0); + }); - it("invokes onWait callback when a task waits past the threshold", async () => { - let waited: number | null = null; - let queuedAhead: number | null = null; + it("invokes onWait callback when a task waits past the threshold", async () => { + let waited: number | null = null; + let queuedAhead: number | null = null; - // First task holds the queue long enough to trigger wait notice. - const first = enqueueCommand(async () => { - await new Promise((resolve) => setTimeout(resolve, 30)); - }); + // First task holds the queue long enough to trigger wait notice. + const first = enqueueCommand(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + }); - const second = enqueueCommand(async () => {}, { - warnAfterMs: 5, - onWait: (ms, ahead) => { - waited = ms; - queuedAhead = ahead; - }, - }); + const second = enqueueCommand(async () => {}, { + warnAfterMs: 5, + onWait: (ms, ahead) => { + waited = ms; + queuedAhead = ahead; + }, + }); - await Promise.all([first, second]); + await Promise.all([first, second]); - expect(waited).not.toBeNull(); - expect(waited as number).toBeGreaterThanOrEqual(5); - expect(queuedAhead).toBe(0); - }); + expect(waited).not.toBeNull(); + expect(waited as number).toBeGreaterThanOrEqual(5); + expect(queuedAhead).toBe(0); + }); }); diff --git a/src/process/command-queue.ts b/src/process/command-queue.ts index 07a0f1f92..4119d6e3e 100644 --- a/src/process/command-queue.ts +++ b/src/process/command-queue.ts @@ -2,57 +2,57 @@ // Ensures only one command runs at a time across webhook, poller, and web inbox flows. type QueueEntry = { - task: () => Promise; - resolve: (value: unknown) => void; - reject: (reason?: unknown) => void; - enqueuedAt: number; - warnAfterMs: number; - onWait?: (waitMs: number, queuedAhead: number) => void; + task: () => Promise; + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; + enqueuedAt: number; + warnAfterMs: number; + onWait?: (waitMs: number, queuedAhead: number) => void; }; const queue: QueueEntry[] = []; let draining = false; async function drainQueue() { - if (draining) return; - draining = true; - while (queue.length) { - const entry = queue.shift() as QueueEntry; - const waitedMs = Date.now() - entry.enqueuedAt; - if (waitedMs >= entry.warnAfterMs) { - entry.onWait?.(waitedMs, queue.length); - } - try { - const result = await entry.task(); - entry.resolve(result); - } catch (err) { - entry.reject(err); - } - } - draining = false; + if (draining) return; + draining = true; + while (queue.length) { + const entry = queue.shift() as QueueEntry; + const waitedMs = Date.now() - entry.enqueuedAt; + if (waitedMs >= entry.warnAfterMs) { + entry.onWait?.(waitedMs, queue.length); + } + try { + const result = await entry.task(); + entry.resolve(result); + } catch (err) { + entry.reject(err); + } + } + draining = false; } export function enqueueCommand( - task: () => Promise, - opts?: { - warnAfterMs?: number; - onWait?: (waitMs: number, queuedAhead: number) => void; - }, + task: () => Promise, + opts?: { + warnAfterMs?: number; + onWait?: (waitMs: number, queuedAhead: number) => void; + }, ): Promise { - const warnAfterMs = opts?.warnAfterMs ?? 2_000; - return new Promise((resolve, reject) => { - queue.push({ - task: () => task(), - resolve: (value) => resolve(value as T), - reject, - enqueuedAt: Date.now(), - warnAfterMs, - onWait: opts?.onWait, - }); - void drainQueue(); - }); + const warnAfterMs = opts?.warnAfterMs ?? 2_000; + return new Promise((resolve, reject) => { + queue.push({ + task: () => task(), + resolve: (value) => resolve(value as T), + reject, + enqueuedAt: Date.now(), + warnAfterMs, + onWait: opts?.onWait, + }); + void drainQueue(); + }); } export function getQueueSize() { - return queue.length + (draining ? 1 : 0); + return queue.length + (draining ? 1 : 0); } diff --git a/src/process/exec.ts b/src/process/exec.ts index db8fd59c8..45a7744ae 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -8,86 +8,86 @@ const execFileAsync = promisify(execFile); // Simple promise-wrapped execFile with optional verbosity logging. export async function runExec( - command: string, - args: string[], - opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000, + command: string, + args: string[], + opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000, ): Promise<{ stdout: string; stderr: string }> { - const options = - typeof opts === "number" - ? { timeout: opts, encoding: "utf8" as const } - : { - timeout: opts.timeoutMs, - maxBuffer: opts.maxBuffer, - encoding: "utf8" as const, - }; - try { - const { stdout, stderr } = await execFileAsync(command, args, options); - if (isVerbose()) { - if (stdout.trim()) logDebug(stdout.trim()); - if (stderr.trim()) logError(stderr.trim()); - } - return { stdout, stderr }; - } catch (err) { - if (isVerbose()) { - logError(danger(`Command failed: ${command} ${args.join(" ")}`)); - } - throw err; - } + const options = + typeof opts === "number" + ? { timeout: opts, encoding: "utf8" as const } + : { + timeout: opts.timeoutMs, + maxBuffer: opts.maxBuffer, + encoding: "utf8" as const, + }; + try { + const { stdout, stderr } = await execFileAsync(command, args, options); + if (isVerbose()) { + if (stdout.trim()) logDebug(stdout.trim()); + if (stderr.trim()) logError(stderr.trim()); + } + return { stdout, stderr }; + } catch (err) { + if (isVerbose()) { + logError(danger(`Command failed: ${command} ${args.join(" ")}`)); + } + throw err; + } } export type SpawnResult = { - stdout: string; - stderr: string; - code: number | null; - signal: NodeJS.Signals | null; - killed: boolean; + stdout: string; + stderr: string; + code: number | null; + signal: NodeJS.Signals | null; + killed: boolean; }; export type CommandOptions = { - timeoutMs: number; - cwd?: string; + timeoutMs: number; + cwd?: string; }; export async function runCommandWithTimeout( - argv: string[], - optionsOrTimeout: number | CommandOptions, + argv: string[], + optionsOrTimeout: number | CommandOptions, ): Promise { - const options: CommandOptions = - typeof optionsOrTimeout === "number" - ? { timeoutMs: optionsOrTimeout } - : optionsOrTimeout; - const { timeoutMs, cwd } = options; + const options: CommandOptions = + typeof optionsOrTimeout === "number" + ? { timeoutMs: optionsOrTimeout } + : optionsOrTimeout; + const { timeoutMs, cwd } = options; - // Spawn with inherited stdin (TTY) so tools like `claude` don't hang. - return await new Promise((resolve, reject) => { - const child = spawn(argv[0], argv.slice(1), { - stdio: ["inherit", "pipe", "pipe"], - cwd, - }); - let stdout = ""; - let stderr = ""; - let settled = false; - const timer = setTimeout(() => { - child.kill("SIGKILL"); - }, timeoutMs); + // Spawn with inherited stdin (TTY) so tools like `claude` don't hang. + return await new Promise((resolve, reject) => { + const child = spawn(argv[0], argv.slice(1), { + stdio: ["inherit", "pipe", "pipe"], + cwd, + }); + let stdout = ""; + let stderr = ""; + let settled = false; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + }, timeoutMs); - child.stdout?.on("data", (d) => { - stdout += d.toString(); - }); - child.stderr?.on("data", (d) => { - stderr += d.toString(); - }); - child.on("error", (err) => { - if (settled) return; - settled = true; - clearTimeout(timer); - reject(err); - }); - child.on("close", (code, signal) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve({ stdout, stderr, code, signal, killed: child.killed }); - }); - }); + child.stdout?.on("data", (d) => { + stdout += d.toString(); + }); + child.stderr?.on("data", (d) => { + stderr += d.toString(); + }); + child.on("error", (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(err); + }); + child.on("close", (code, signal) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ stdout, stderr, code, signal, killed: child.killed }); + }); + }); } diff --git a/src/provider-web.test.ts b/src/provider-web.test.ts index c86ffccb8..73601f237 100644 --- a/src/provider-web.test.ts +++ b/src/provider-web.test.ts @@ -9,787 +9,787 @@ import type { MockBaileysSocket } from "../test/mocks/baileys.js"; import { createMockBaileys } from "../test/mocks/baileys.js"; vi.mock("@whiskeysockets/baileys", () => { - const created = createMockBaileys(); - (globalThis as Record)[ - Symbol.for("warelay:lastSocket") - ] = created.lastSocket; - return created.mod; + const created = createMockBaileys(); + (globalThis as Record)[ + Symbol.for("warelay:lastSocket") + ] = created.lastSocket; + return created.mod; }); vi.mock("./media/store.js", () => ({ - saveMediaBuffer: vi - .fn() - .mockImplementation(async (_buf: Buffer, contentType?: string) => ({ - id: "mid", - path: "/tmp/mid", - size: _buf.length, - contentType, - })), + saveMediaBuffer: vi + .fn() + .mockImplementation(async (_buf: Buffer, contentType?: string) => ({ + id: "mid", + path: "/tmp/mid", + size: _buf.length, + contentType, + })), })); let loadConfigMock: () => unknown = () => ({}); vi.mock("./config/config.js", () => ({ - loadConfig: () => loadConfigMock(), + loadConfig: () => loadConfigMock(), })); function getLastSocket(): MockBaileysSocket { - const getter = (globalThis as Record)[ - Symbol.for("warelay:lastSocket") - ]; - if (typeof getter === "function") - return (getter as () => MockBaileysSocket)(); - if (!getter) throw new Error("Baileys mock not initialized"); - throw new Error("Invalid Baileys socket getter"); + const getter = (globalThis as Record)[ + Symbol.for("warelay:lastSocket") + ]; + if (typeof getter === "function") + return (getter as () => MockBaileysSocket)(); + if (!getter) throw new Error("Baileys mock not initialized"); + throw new Error("Invalid Baileys socket getter"); } vi.mock("qrcode-terminal", () => ({ - default: { generate: vi.fn() }, - generate: vi.fn(), + default: { generate: vi.fn() }, + generate: vi.fn(), })); import { monitorWebProvider } from "./index.js"; import { resetLogger, setLoggerOverride } from "./logging.js"; import { - createWaSocket, - loginWeb, - logWebSelfId, - monitorWebInbox, - sendMessageWeb, - waitForWaConnection, + createWaSocket, + loginWeb, + logWebSelfId, + monitorWebInbox, + sendMessageWeb, + waitForWaConnection, } from "./provider-web.js"; const baileys = (await import( - "@whiskeysockets/baileys" + "@whiskeysockets/baileys" )) as unknown as typeof import("@whiskeysockets/baileys") & { - makeWASocket: ReturnType; - useMultiFileAuthState: ReturnType; - fetchLatestBaileysVersion: ReturnType; - makeCacheableSignalKeyStore: ReturnType; + makeWASocket: ReturnType; + useMultiFileAuthState: ReturnType; + fetchLatestBaileysVersion: ReturnType; + makeCacheableSignalKeyStore: ReturnType; }; describe("provider-web", () => { - beforeEach(() => { - vi.clearAllMocks(); - loadConfigMock = () => ({}); - const recreated = createMockBaileys(); - (globalThis as Record)[ - Symbol.for("warelay:lastSocket") - ] = recreated.lastSocket; - baileys.makeWASocket.mockImplementation(recreated.mod.makeWASocket); - baileys.useMultiFileAuthState.mockImplementation( - recreated.mod.useMultiFileAuthState, - ); - baileys.fetchLatestBaileysVersion.mockImplementation( - recreated.mod.fetchLatestBaileysVersion, - ); - baileys.makeCacheableSignalKeyStore.mockImplementation( - recreated.mod.makeCacheableSignalKeyStore, - ); - }); - - afterEach(() => { - vi.useRealTimers(); - resetLogger(); - setLoggerOverride(null); - }); - - it("creates WA socket with QR handler", async () => { - await createWaSocket(true, false); - const makeWASocket = baileys.makeWASocket as ReturnType; - expect(makeWASocket).toHaveBeenCalledWith( - expect.objectContaining({ printQRInTerminal: false }), - ); - const passed = makeWASocket.mock.calls[0][0]; - const passedLogger = ( - passed as { logger?: { level?: string; trace?: unknown } } - ).logger; - expect(passedLogger?.level).toBe("silent"); - expect(typeof passedLogger?.trace).toBe("function"); - const sock = getLastSocket(); - const saveCreds = ( - await baileys.useMultiFileAuthState.mock.results[0].value - ).saveCreds; - // trigger creds.update listener - sock.ev.emit("creds.update", {}); - expect(saveCreds).toHaveBeenCalled(); - }); - - it("waits for connection open", async () => { - const ev = new EventEmitter(); - const promise = waitForWaConnection({ ev } as unknown as ReturnType< - typeof baileys.makeWASocket - >); - ev.emit("connection.update", { connection: "open" }); - await expect(promise).resolves.toBeUndefined(); - }); - - it("rejects when connection closes", async () => { - const ev = new EventEmitter(); - const promise = waitForWaConnection({ ev } as unknown as ReturnType< - typeof baileys.makeWASocket - >); - ev.emit("connection.update", { - connection: "close", - lastDisconnect: new Error("bye"), - }); - await expect(promise).rejects.toBeInstanceOf(Error); - }); - - it("sends message via web and closes socket", async () => { - await sendMessageWeb("+1555", "hi", { verbose: false }); - const sock = getLastSocket(); - expect(sock.sendMessage).toHaveBeenCalled(); - expect(sock.ws.close).toHaveBeenCalled(); - }); - - it("loginWeb waits for connection and closes", async () => { - const closeSpy = vi.fn(); - const ev = new EventEmitter(); - baileys.makeWASocket.mockImplementation(() => ({ - ev, - ws: { close: closeSpy }, - sendPresenceUpdate: vi.fn(), - sendMessage: vi.fn(), - })); - const waiter: typeof waitForWaConnection = vi - .fn() - .mockResolvedValue(undefined); - await loginWeb(false, waiter); - await new Promise((resolve) => setTimeout(resolve, 550)); - expect(closeSpy).toHaveBeenCalled(); - }); - - it("monitorWebInbox streams inbound messages", async () => { - const onMessage = vi.fn(async (msg) => { - await msg.sendComposing(); - await msg.reply("pong"); - }); - - const listener = await monitorWebInbox({ verbose: false, onMessage }); - const sock = getLastSocket(); - expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); - const upsert = { - type: "notify", - messages: [ - { - key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" }, - message: { conversation: "ping" }, - messageTimestamp: 1_700_000_000, - pushName: "Tester", - }, - ], - }; - - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); - - expect(onMessage).toHaveBeenCalledWith( - expect.objectContaining({ body: "ping", from: "+999", to: "+123" }), - ); - expect(sock.readMessages).toHaveBeenCalledWith([ - { - remoteJid: "999@s.whatsapp.net", - id: "abc", - participant: undefined, - fromMe: false, - }, - ]); - expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); - expect(sock.sendPresenceUpdate).toHaveBeenCalledWith( - "composing", - "999@s.whatsapp.net", - ); - expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { - text: "pong", - }); - - await listener.close(); - }); - - it("monitorWebInbox captures media path for image messages", async () => { - const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); - const sock = getLastSocket(); - const upsert = { - type: "notify", - messages: [ - { - key: { id: "med1", fromMe: false, remoteJid: "888@s.whatsapp.net" }, - message: { imageMessage: { mimetype: "image/jpeg" } }, - messageTimestamp: 1_700_000_100, - }, - ], - }; - - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); - - expect(onMessage).toHaveBeenCalledWith( - expect.objectContaining({ - body: "", - mediaPath: "/tmp/mid", - mediaType: "image/jpeg", - }), - ); - expect(sock.readMessages).toHaveBeenCalledWith([ - { - remoteJid: "888@s.whatsapp.net", - id: "med1", - participant: undefined, - fromMe: false, - }, - ]); - expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); - await listener.close(); - }); - - it("monitorWebInbox resolves onClose when the socket closes", async () => { - const listener = await monitorWebInbox({ - verbose: false, - onMessage: vi.fn(), - }); - const sock = getLastSocket(); - const reasonPromise = listener.onClose; - sock.ev.emit("connection.update", { - connection: "close", - lastDisconnect: { error: { output: { statusCode: 500 } } }, - }); - await expect(reasonPromise).resolves.toEqual( - expect.objectContaining({ status: 500, isLoggedOut: false }), - ); - await listener.close(); - }); - - it("monitorWebInbox logs inbound bodies to file", async () => { - const logPath = path.join( - os.tmpdir(), - `warelay-log-test-${crypto.randomUUID()}.log`, - ); - setLoggerOverride({ level: "trace", file: logPath }); - - const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); - const sock = getLastSocket(); - const upsert = { - type: "notify", - messages: [ - { - key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" }, - message: { conversation: "ping" }, - messageTimestamp: 1_700_000_000, - pushName: "Tester", - }, - ], - }; - - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); - - const content = fsSync.readFileSync(logPath, "utf-8"); - expect(content).toContain('"module":"web-inbound"'); - expect(content).toContain('"body":"ping"'); - await listener.close(); - }); - - it("monitorWebInbox includes participant when marking group messages read", async () => { - const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); - const sock = getLastSocket(); - const upsert = { - type: "notify", - messages: [ - { - key: { - id: "grp1", - fromMe: false, - remoteJid: "12345-67890@g.us", - participant: "111@s.whatsapp.net", - }, - message: { conversation: "group ping" }, - }, - ], - }; - - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); - - expect(sock.readMessages).toHaveBeenCalledWith([ - { - remoteJid: "12345-67890@g.us", - id: "grp1", - participant: "111@s.whatsapp.net", - fromMe: false, - }, - ]); - await listener.close(); - }); - - it("monitorWebProvider reconnects after a connection close", async () => { - vi.useFakeTimers(); - const closeResolvers: Array<() => void> = []; - const listenerFactory = vi.fn(async () => { - let _resolve!: () => void; - const onClose = new Promise((res) => { - _resolve = res; - closeResolvers.push(res); - }); - return { close: vi.fn(), onClose }; - }); - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; - const controller = new AbortController(); - const run = monitorWebProvider( - false, - listenerFactory, - true, - async () => ({ text: "ok" }), - runtime as never, - controller.signal, - ); - - await Promise.resolve(); - expect(listenerFactory).toHaveBeenCalledTimes(1); - - closeResolvers[0]?.(); - await Promise.resolve(); - await vi.runOnlyPendingTimersAsync(); - expect(listenerFactory).toHaveBeenCalledTimes(2); - expect(runtime.error).toHaveBeenCalledWith( - expect.stringContaining("Reconnecting"), - ); - - controller.abort(); - closeResolvers[1]?.(); - await vi.runAllTimersAsync(); - await run; - }); - - it("monitorWebProvider falls back to text when media send fails", async () => { - const sendMedia = vi.fn().mockRejectedValue(new Error("boom")); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ - text: "hi", - mediaUrl: "https://example.com/img.png", - }); - - let capturedOnMessage: - | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: ( - msg: import("./provider-web.js").WebInboundMessage, - ) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const smallPng = await sharp({ - create: { - width: 200, - height: 200, - channels: 3, - background: { r: 0, g: 255, b: 0 }, - }, - }) - .png() - .toBuffer(); - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - body: true, - arrayBuffer: async () => - smallPng.buffer.slice( - smallPng.byteOffset, - smallPng.byteOffset + smallPng.byteLength, - ), - headers: { get: () => "image/png" }, - status: 200, - } as Response); - - await monitorWebProvider(false, listenerFactory, false, resolver); - - expect(capturedOnMessage).toBeDefined(); - await capturedOnMessage?.({ - body: "hello", - from: "+1", - to: "+2", - id: "msg1", - sendComposing, - reply, - sendMedia, - }); - - expect(sendMedia).toHaveBeenCalledTimes(1); - expect(reply).toHaveBeenCalledWith("hi"); - fetchMock.mockRestore(); - }); - - it("compresses media over 5MB and still sends it", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ - text: "hi", - mediaUrl: "https://example.com/big.png", - }); - - let capturedOnMessage: - | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: ( - msg: import("./provider-web.js").WebInboundMessage, - ) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - // Create a large ( >5MB ) PNG to trigger compression. - const bigPng = await sharp({ - create: { - width: 3200, - height: 3200, - channels: 3, - background: { r: 255, g: 0, b: 0 }, - }, - }) - .png({ compressionLevel: 0 }) - .toBuffer(); - expect(bigPng.length).toBeGreaterThan(5 * 1024 * 1024); - - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - body: true, - arrayBuffer: async () => - bigPng.buffer.slice( - bigPng.byteOffset, - bigPng.byteOffset + bigPng.byteLength, - ), - headers: { get: () => "image/png" }, - status: 200, - } as Response); - - await monitorWebProvider(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hello", - from: "+1", - to: "+2", - id: "msg1", - sendComposing, - reply, - sendMedia, - }); - - expect(sendMedia).toHaveBeenCalledTimes(1); - const payload = sendMedia.mock.calls[0][0] as { - image: Buffer; - caption?: string; - mimetype?: string; - }; - expect(payload.image.length).toBeLessThanOrEqual(5 * 1024 * 1024); - expect(payload.mimetype).toBe("image/jpeg"); - // Should not fall back to separate text reply because caption is used. - expect(reply).not.toHaveBeenCalled(); - - fetchMock.mockRestore(); - }); - - it( - "compresses common formats to jpeg under the cap", - { timeout: 15_000 }, - async () => { - const formats = [ - { - name: "png", - mime: "image/png", - make: (buf: Buffer, opts: { width: number; height: number }) => - sharp(buf, { - raw: { width: opts.width, height: opts.height, channels: 3 }, - }) - .png({ compressionLevel: 0 }) - .toBuffer(), - }, - { - name: "jpeg", - mime: "image/jpeg", - make: (buf: Buffer, opts: { width: number; height: number }) => - sharp(buf, { - raw: { width: opts.width, height: opts.height, channels: 3 }, - }) - .jpeg({ quality: 100, chromaSubsampling: "4:4:4" }) - .toBuffer(), - }, - { - name: "webp", - mime: "image/webp", - make: (buf: Buffer, opts: { width: number; height: number }) => - sharp(buf, { - raw: { width: opts.width, height: opts.height, channels: 3 }, - }) - .webp({ quality: 100 }) - .toBuffer(), - }, - ] as const; - - for (const fmt of formats) { - // Force a small cap to ensure compression is exercised for every format. - loadConfigMock = () => ({ inbound: { reply: { mediaMaxMb: 1 } } }); - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ - text: "hi", - mediaUrl: `https://example.com/big.${fmt.name}`, - }); - - let capturedOnMessage: - | (( - msg: import("./provider-web.js").WebInboundMessage, - ) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: ( - msg: import("./provider-web.js").WebInboundMessage, - ) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const width = 2000; - const height = 2000; - const raw = crypto.randomBytes(width * height * 3); - const big = await fmt.make(raw, { width, height }); - expect(big.length).toBeGreaterThan(1 * 1024 * 1024); - - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - body: true, - arrayBuffer: async () => - big.buffer.slice(big.byteOffset, big.byteOffset + big.byteLength), - headers: { get: () => fmt.mime }, - status: 200, - } as Response); - - await monitorWebProvider(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hello", - from: "+1", - to: "+2", - id: `msg-${fmt.name}`, - sendComposing, - reply, - sendMedia, - }); - - expect(sendMedia).toHaveBeenCalledTimes(1); - const payload = sendMedia.mock.calls[0][0] as { - image: Buffer; - mimetype?: string; - }; - expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024); - expect(payload.mimetype).toBe("image/jpeg"); - expect(reply).not.toHaveBeenCalled(); - - fetchMock.mockRestore(); - } - }, - ); - - it("honors mediaMaxMb from config", async () => { - loadConfigMock = () => ({ inbound: { reply: { mediaMaxMb: 1 } } }); - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ - text: "hi", - mediaUrl: "https://example.com/big.png", - }); - - let capturedOnMessage: - | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: ( - msg: import("./provider-web.js").WebInboundMessage, - ) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const bigPng = await sharp({ - create: { - width: 2600, - height: 2600, - channels: 3, - background: { r: 0, g: 0, b: 255 }, - }, - }) - .png({ compressionLevel: 0 }) - .toBuffer(); - expect(bigPng.length).toBeGreaterThan(1 * 1024 * 1024); - - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - body: true, - arrayBuffer: async () => - bigPng.buffer.slice( - bigPng.byteOffset, - bigPng.byteOffset + bigPng.byteLength, - ), - headers: { get: () => "image/png" }, - status: 200, - } as Response); - - await monitorWebProvider(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hello", - from: "+1", - to: "+2", - id: "msg1", - sendComposing, - reply, - sendMedia, - }); - - expect(sendMedia).toHaveBeenCalledTimes(1); - const payload = sendMedia.mock.calls[0][0] as { - image: Buffer; - caption?: string; - mimetype?: string; - }; - expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024); - expect(payload.mimetype).toBe("image/jpeg"); - expect(reply).not.toHaveBeenCalled(); - - fetchMock.mockRestore(); - }); - - it("falls back to text when media is unsupported", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ - text: "hi", - mediaUrl: "https://example.com/file.pdf", - }); - - let capturedOnMessage: - | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: ( - msg: import("./provider-web.js").WebInboundMessage, - ) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - body: true, - arrayBuffer: async () => Buffer.from("%PDF-1.4").buffer, - headers: { get: () => "application/pdf" }, - status: 200, - } as Response); - - await monitorWebProvider(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hello", - from: "+1", - to: "+2", - id: "msg-pdf", - sendComposing, - reply, - sendMedia, - }); - - expect(sendMedia).toHaveBeenCalledTimes(1); - const payload = sendMedia.mock.calls[0][0] as { - document?: Buffer; - caption?: string; - fileName?: string; - }; - expect(payload.document).toBeInstanceOf(Buffer); - expect(payload.fileName).toBe("file.pdf"); - expect(payload.caption).toBe("hi"); - expect(reply).not.toHaveBeenCalled(); - - fetchMock.mockRestore(); - }); - - it("logs outbound replies to file", async () => { - const logPath = path.join( - os.tmpdir(), - `warelay-log-test-${crypto.randomUUID()}.log`, - ); - setLoggerOverride({ level: "trace", file: logPath }); - - let capturedOnMessage: - | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: ( - msg: import("./provider-web.js").WebInboundMessage, - ) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const resolver = vi.fn().mockResolvedValue({ text: "auto" }); - await monitorWebProvider(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hello", - from: "+1", - to: "+2", - id: "msg1", - sendComposing: vi.fn(), - reply: vi.fn(), - sendMedia: vi.fn(), - }); - - const content = fsSync.readFileSync(logPath, "utf-8"); - expect(content).toContain('"module":"web-auto-reply"'); - expect(content).toContain('"text":"auto"'); - }); - - it("logWebSelfId prints cached E.164 when creds exist", () => { - const existsSpy = vi - .spyOn(fsSync, "existsSync") - .mockReturnValue(true as never); - const readSpy = vi - .spyOn(fsSync, "readFileSync") - .mockReturnValue(JSON.stringify({ me: { id: "12345@s.whatsapp.net" } })); - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; - - logWebSelfId(runtime as never, true); - - expect(runtime.log).toHaveBeenCalledWith( - "Web Provider: +12345 (jid 12345@s.whatsapp.net)", - ); - existsSpy.mockRestore(); - readSpy.mockRestore(); - }); + beforeEach(() => { + vi.clearAllMocks(); + loadConfigMock = () => ({}); + const recreated = createMockBaileys(); + (globalThis as Record)[ + Symbol.for("warelay:lastSocket") + ] = recreated.lastSocket; + baileys.makeWASocket.mockImplementation(recreated.mod.makeWASocket); + baileys.useMultiFileAuthState.mockImplementation( + recreated.mod.useMultiFileAuthState, + ); + baileys.fetchLatestBaileysVersion.mockImplementation( + recreated.mod.fetchLatestBaileysVersion, + ); + baileys.makeCacheableSignalKeyStore.mockImplementation( + recreated.mod.makeCacheableSignalKeyStore, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + resetLogger(); + setLoggerOverride(null); + }); + + it("creates WA socket with QR handler", async () => { + await createWaSocket(true, false); + const makeWASocket = baileys.makeWASocket as ReturnType; + expect(makeWASocket).toHaveBeenCalledWith( + expect.objectContaining({ printQRInTerminal: false }), + ); + const passed = makeWASocket.mock.calls[0][0]; + const passedLogger = ( + passed as { logger?: { level?: string; trace?: unknown } } + ).logger; + expect(passedLogger?.level).toBe("silent"); + expect(typeof passedLogger?.trace).toBe("function"); + const sock = getLastSocket(); + const saveCreds = ( + await baileys.useMultiFileAuthState.mock.results[0].value + ).saveCreds; + // trigger creds.update listener + sock.ev.emit("creds.update", {}); + expect(saveCreds).toHaveBeenCalled(); + }); + + it("waits for connection open", async () => { + const ev = new EventEmitter(); + const promise = waitForWaConnection({ ev } as unknown as ReturnType< + typeof baileys.makeWASocket + >); + ev.emit("connection.update", { connection: "open" }); + await expect(promise).resolves.toBeUndefined(); + }); + + it("rejects when connection closes", async () => { + const ev = new EventEmitter(); + const promise = waitForWaConnection({ ev } as unknown as ReturnType< + typeof baileys.makeWASocket + >); + ev.emit("connection.update", { + connection: "close", + lastDisconnect: new Error("bye"), + }); + await expect(promise).rejects.toBeInstanceOf(Error); + }); + + it("sends message via web and closes socket", async () => { + await sendMessageWeb("+1555", "hi", { verbose: false }); + const sock = getLastSocket(); + expect(sock.sendMessage).toHaveBeenCalled(); + expect(sock.ws.close).toHaveBeenCalled(); + }); + + it("loginWeb waits for connection and closes", async () => { + const closeSpy = vi.fn(); + const ev = new EventEmitter(); + baileys.makeWASocket.mockImplementation(() => ({ + ev, + ws: { close: closeSpy }, + sendPresenceUpdate: vi.fn(), + sendMessage: vi.fn(), + })); + const waiter: typeof waitForWaConnection = vi + .fn() + .mockResolvedValue(undefined); + await loginWeb(false, waiter); + await new Promise((resolve) => setTimeout(resolve, 550)); + expect(closeSpy).toHaveBeenCalled(); + }); + + it("monitorWebInbox streams inbound messages", async () => { + const onMessage = vi.fn(async (msg) => { + await msg.sendComposing(); + await msg.reply("pong"); + }); + + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = getLastSocket(); + expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); + const upsert = { + type: "notify", + messages: [ + { + key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" }, + message: { conversation: "ping" }, + messageTimestamp: 1_700_000_000, + pushName: "Tester", + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ body: "ping", from: "+999", to: "+123" }), + ); + expect(sock.readMessages).toHaveBeenCalledWith([ + { + remoteJid: "999@s.whatsapp.net", + id: "abc", + participant: undefined, + fromMe: false, + }, + ]); + expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); + expect(sock.sendPresenceUpdate).toHaveBeenCalledWith( + "composing", + "999@s.whatsapp.net", + ); + expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { + text: "pong", + }); + + await listener.close(); + }); + + it("monitorWebInbox captures media path for image messages", async () => { + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = getLastSocket(); + const upsert = { + type: "notify", + messages: [ + { + key: { id: "med1", fromMe: false, remoteJid: "888@s.whatsapp.net" }, + message: { imageMessage: { mimetype: "image/jpeg" } }, + messageTimestamp: 1_700_000_100, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ + body: "", + mediaPath: "/tmp/mid", + mediaType: "image/jpeg", + }), + ); + expect(sock.readMessages).toHaveBeenCalledWith([ + { + remoteJid: "888@s.whatsapp.net", + id: "med1", + participant: undefined, + fromMe: false, + }, + ]); + expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); + await listener.close(); + }); + + it("monitorWebInbox resolves onClose when the socket closes", async () => { + const listener = await monitorWebInbox({ + verbose: false, + onMessage: vi.fn(), + }); + const sock = getLastSocket(); + const reasonPromise = listener.onClose; + sock.ev.emit("connection.update", { + connection: "close", + lastDisconnect: { error: { output: { statusCode: 500 } } }, + }); + await expect(reasonPromise).resolves.toEqual( + expect.objectContaining({ status: 500, isLoggedOut: false }), + ); + await listener.close(); + }); + + it("monitorWebInbox logs inbound bodies to file", async () => { + const logPath = path.join( + os.tmpdir(), + `warelay-log-test-${crypto.randomUUID()}.log`, + ); + setLoggerOverride({ level: "trace", file: logPath }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = getLastSocket(); + const upsert = { + type: "notify", + messages: [ + { + key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" }, + message: { conversation: "ping" }, + messageTimestamp: 1_700_000_000, + pushName: "Tester", + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + const content = fsSync.readFileSync(logPath, "utf-8"); + expect(content).toContain('"module":"web-inbound"'); + expect(content).toContain('"body":"ping"'); + await listener.close(); + }); + + it("monitorWebInbox includes participant when marking group messages read", async () => { + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = getLastSocket(); + const upsert = { + type: "notify", + messages: [ + { + key: { + id: "grp1", + fromMe: false, + remoteJid: "12345-67890@g.us", + participant: "111@s.whatsapp.net", + }, + message: { conversation: "group ping" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + expect(sock.readMessages).toHaveBeenCalledWith([ + { + remoteJid: "12345-67890@g.us", + id: "grp1", + participant: "111@s.whatsapp.net", + fromMe: false, + }, + ]); + await listener.close(); + }); + + it("monitorWebProvider reconnects after a connection close", async () => { + vi.useFakeTimers(); + const closeResolvers: Array<() => void> = []; + const listenerFactory = vi.fn(async () => { + let _resolve!: () => void; + const onClose = new Promise((res) => { + _resolve = res; + closeResolvers.push(res); + }); + return { close: vi.fn(), onClose }; + }); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const controller = new AbortController(); + const run = monitorWebProvider( + false, + listenerFactory, + true, + async () => ({ text: "ok" }), + runtime as never, + controller.signal, + ); + + await Promise.resolve(); + expect(listenerFactory).toHaveBeenCalledTimes(1); + + closeResolvers[0]?.(); + await Promise.resolve(); + await vi.runOnlyPendingTimersAsync(); + expect(listenerFactory).toHaveBeenCalledTimes(2); + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("Reconnecting"), + ); + + controller.abort(); + closeResolvers[1]?.(); + await vi.runAllTimersAsync(); + await run; + }); + + it("monitorWebProvider falls back to text when media send fails", async () => { + const sendMedia = vi.fn().mockRejectedValue(new Error("boom")); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ + text: "hi", + mediaUrl: "https://example.com/img.png", + }); + + let capturedOnMessage: + | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./provider-web.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const smallPng = await sharp({ + create: { + width: 200, + height: 200, + channels: 3, + background: { r: 0, g: 255, b: 0 }, + }, + }) + .png() + .toBuffer(); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + body: true, + arrayBuffer: async () => + smallPng.buffer.slice( + smallPng.byteOffset, + smallPng.byteOffset + smallPng.byteLength, + ), + headers: { get: () => "image/png" }, + status: 200, + } as Response); + + await monitorWebProvider(false, listenerFactory, false, resolver); + + expect(capturedOnMessage).toBeDefined(); + await capturedOnMessage?.({ + body: "hello", + from: "+1", + to: "+2", + id: "msg1", + sendComposing, + reply, + sendMedia, + }); + + expect(sendMedia).toHaveBeenCalledTimes(1); + expect(reply).toHaveBeenCalledWith("hi"); + fetchMock.mockRestore(); + }); + + it("compresses media over 5MB and still sends it", async () => { + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ + text: "hi", + mediaUrl: "https://example.com/big.png", + }); + + let capturedOnMessage: + | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./provider-web.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + // Create a large ( >5MB ) PNG to trigger compression. + const bigPng = await sharp({ + create: { + width: 3200, + height: 3200, + channels: 3, + background: { r: 255, g: 0, b: 0 }, + }, + }) + .png({ compressionLevel: 0 }) + .toBuffer(); + expect(bigPng.length).toBeGreaterThan(5 * 1024 * 1024); + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + body: true, + arrayBuffer: async () => + bigPng.buffer.slice( + bigPng.byteOffset, + bigPng.byteOffset + bigPng.byteLength, + ), + headers: { get: () => "image/png" }, + status: 200, + } as Response); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hello", + from: "+1", + to: "+2", + id: "msg1", + sendComposing, + reply, + sendMedia, + }); + + expect(sendMedia).toHaveBeenCalledTimes(1); + const payload = sendMedia.mock.calls[0][0] as { + image: Buffer; + caption?: string; + mimetype?: string; + }; + expect(payload.image.length).toBeLessThanOrEqual(5 * 1024 * 1024); + expect(payload.mimetype).toBe("image/jpeg"); + // Should not fall back to separate text reply because caption is used. + expect(reply).not.toHaveBeenCalled(); + + fetchMock.mockRestore(); + }); + + it( + "compresses common formats to jpeg under the cap", + { timeout: 15_000 }, + async () => { + const formats = [ + { + name: "png", + mime: "image/png", + make: (buf: Buffer, opts: { width: number; height: number }) => + sharp(buf, { + raw: { width: opts.width, height: opts.height, channels: 3 }, + }) + .png({ compressionLevel: 0 }) + .toBuffer(), + }, + { + name: "jpeg", + mime: "image/jpeg", + make: (buf: Buffer, opts: { width: number; height: number }) => + sharp(buf, { + raw: { width: opts.width, height: opts.height, channels: 3 }, + }) + .jpeg({ quality: 100, chromaSubsampling: "4:4:4" }) + .toBuffer(), + }, + { + name: "webp", + mime: "image/webp", + make: (buf: Buffer, opts: { width: number; height: number }) => + sharp(buf, { + raw: { width: opts.width, height: opts.height, channels: 3 }, + }) + .webp({ quality: 100 }) + .toBuffer(), + }, + ] as const; + + for (const fmt of formats) { + // Force a small cap to ensure compression is exercised for every format. + loadConfigMock = () => ({ inbound: { reply: { mediaMaxMb: 1 } } }); + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ + text: "hi", + mediaUrl: `https://example.com/big.${fmt.name}`, + }); + + let capturedOnMessage: + | (( + msg: import("./provider-web.js").WebInboundMessage, + ) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./provider-web.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const width = 2000; + const height = 2000; + const raw = crypto.randomBytes(width * height * 3); + const big = await fmt.make(raw, { width, height }); + expect(big.length).toBeGreaterThan(1 * 1024 * 1024); + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + body: true, + arrayBuffer: async () => + big.buffer.slice(big.byteOffset, big.byteOffset + big.byteLength), + headers: { get: () => fmt.mime }, + status: 200, + } as Response); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hello", + from: "+1", + to: "+2", + id: `msg-${fmt.name}`, + sendComposing, + reply, + sendMedia, + }); + + expect(sendMedia).toHaveBeenCalledTimes(1); + const payload = sendMedia.mock.calls[0][0] as { + image: Buffer; + mimetype?: string; + }; + expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024); + expect(payload.mimetype).toBe("image/jpeg"); + expect(reply).not.toHaveBeenCalled(); + + fetchMock.mockRestore(); + } + }, + ); + + it("honors mediaMaxMb from config", async () => { + loadConfigMock = () => ({ inbound: { reply: { mediaMaxMb: 1 } } }); + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ + text: "hi", + mediaUrl: "https://example.com/big.png", + }); + + let capturedOnMessage: + | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./provider-web.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const bigPng = await sharp({ + create: { + width: 2600, + height: 2600, + channels: 3, + background: { r: 0, g: 0, b: 255 }, + }, + }) + .png({ compressionLevel: 0 }) + .toBuffer(); + expect(bigPng.length).toBeGreaterThan(1 * 1024 * 1024); + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + body: true, + arrayBuffer: async () => + bigPng.buffer.slice( + bigPng.byteOffset, + bigPng.byteOffset + bigPng.byteLength, + ), + headers: { get: () => "image/png" }, + status: 200, + } as Response); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hello", + from: "+1", + to: "+2", + id: "msg1", + sendComposing, + reply, + sendMedia, + }); + + expect(sendMedia).toHaveBeenCalledTimes(1); + const payload = sendMedia.mock.calls[0][0] as { + image: Buffer; + caption?: string; + mimetype?: string; + }; + expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024); + expect(payload.mimetype).toBe("image/jpeg"); + expect(reply).not.toHaveBeenCalled(); + + fetchMock.mockRestore(); + }); + + it("falls back to text when media is unsupported", async () => { + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ + text: "hi", + mediaUrl: "https://example.com/file.pdf", + }); + + let capturedOnMessage: + | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./provider-web.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + body: true, + arrayBuffer: async () => Buffer.from("%PDF-1.4").buffer, + headers: { get: () => "application/pdf" }, + status: 200, + } as Response); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hello", + from: "+1", + to: "+2", + id: "msg-pdf", + sendComposing, + reply, + sendMedia, + }); + + expect(sendMedia).toHaveBeenCalledTimes(1); + const payload = sendMedia.mock.calls[0][0] as { + document?: Buffer; + caption?: string; + fileName?: string; + }; + expect(payload.document).toBeInstanceOf(Buffer); + expect(payload.fileName).toBe("file.pdf"); + expect(payload.caption).toBe("hi"); + expect(reply).not.toHaveBeenCalled(); + + fetchMock.mockRestore(); + }); + + it("logs outbound replies to file", async () => { + const logPath = path.join( + os.tmpdir(), + `warelay-log-test-${crypto.randomUUID()}.log`, + ); + setLoggerOverride({ level: "trace", file: logPath }); + + let capturedOnMessage: + | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./provider-web.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const resolver = vi.fn().mockResolvedValue({ text: "auto" }); + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hello", + from: "+1", + to: "+2", + id: "msg1", + sendComposing: vi.fn(), + reply: vi.fn(), + sendMedia: vi.fn(), + }); + + const content = fsSync.readFileSync(logPath, "utf-8"); + expect(content).toContain('"module":"web-auto-reply"'); + expect(content).toContain('"text":"auto"'); + }); + + it("logWebSelfId prints cached E.164 when creds exist", () => { + const existsSpy = vi + .spyOn(fsSync, "existsSync") + .mockReturnValue(true as never); + const readSpy = vi + .spyOn(fsSync, "readFileSync") + .mockReturnValue(JSON.stringify({ me: { id: "12345@s.whatsapp.net" } })); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + logWebSelfId(runtime as never, true); + + expect(runtime.log).toHaveBeenCalledWith( + "Web Provider: +12345 (jid 12345@s.whatsapp.net)", + ); + existsSpy.mockRestore(); + readSpy.mockRestore(); + }); }); diff --git a/src/provider-web.ts b/src/provider-web.ts index e00e68e14..294313429 100644 --- a/src/provider-web.ts +++ b/src/provider-web.ts @@ -4,21 +4,28 @@ import os from "node:os"; import path from "node:path"; import type { proto } from "@whiskeysockets/baileys"; import { - type AnyMessageContent, - DisconnectReason, - downloadMediaMessage, - fetchLatestBaileysVersion, - makeCacheableSignalKeyStore, - makeWASocket, - useMultiFileAuthState, - type WAMessage, + type AnyMessageContent, + DisconnectReason, + downloadMediaMessage, + fetchLatestBaileysVersion, + makeCacheableSignalKeyStore, + makeWASocket, + useMultiFileAuthState, + type WAMessage, } from "@whiskeysockets/baileys"; import qrcode from "qrcode-terminal"; import sharp from "sharp"; import { getReplyFromConfig } from "./auto-reply/reply.js"; import { waitForever } from "./cli/wait.js"; import { loadConfig } from "./config/config.js"; -import { danger, info, isVerbose, logVerbose, success } from "./globals.js"; +import { + danger, + info, + isVerbose, + logVerbose, + success, + warn, +} from "./globals.js"; import { logInfo } from "./logger.js"; import { getChildLogger } from "./logging.js"; import { maxBytesForKind, mediaKindFromMime } from "./media/constants.js"; @@ -29,893 +36,894 @@ import { ensureDir, jidToE164, toWhatsappJid } from "./utils.js"; import { VERSION } from "./version.js"; function formatDuration(ms: number) { - return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`; + return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`; } const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "credentials"); const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024; export async function createWaSocket(printQr: boolean, verbose: boolean) { - const logger = getChildLogger( - { module: "baileys" }, - { - level: verbose ? "info" : "silent", - }, - ); - // Some Baileys internals call logger.trace even when silent; ensure it's present. - const loggerAny = logger as unknown as Record; - if (typeof loggerAny.trace !== "function") { - loggerAny.trace = () => {}; - } - await ensureDir(WA_WEB_AUTH_DIR); - const { state, saveCreds } = await useMultiFileAuthState(WA_WEB_AUTH_DIR); - const { version } = await fetchLatestBaileysVersion(); - const sock = makeWASocket({ - auth: { - creds: state.creds, - keys: makeCacheableSignalKeyStore(state.keys, logger), - }, - version, - logger, - printQRInTerminal: false, - browser: ["warelay", "cli", VERSION], - syncFullHistory: false, - markOnlineOnConnect: false, - }); + const logger = getChildLogger( + { module: "baileys" }, + { + level: verbose ? "info" : "silent", + }, + ); + // Some Baileys internals call logger.trace even when silent; ensure it's present. + const loggerAny = logger as unknown as Record; + if (typeof loggerAny.trace !== "function") { + loggerAny.trace = () => {}; + } + await ensureDir(WA_WEB_AUTH_DIR); + const { state, saveCreds } = await useMultiFileAuthState(WA_WEB_AUTH_DIR); + const { version } = await fetchLatestBaileysVersion(); + const sock = makeWASocket({ + auth: { + creds: state.creds, + keys: makeCacheableSignalKeyStore(state.keys, logger), + }, + version, + logger, + printQRInTerminal: false, + browser: ["warelay", "cli", VERSION], + syncFullHistory: false, + markOnlineOnConnect: false, + }); - sock.ev.on("creds.update", saveCreds); - sock.ev.on( - "connection.update", - (update: Partial) => { - const { connection, lastDisconnect, qr } = update; - if (qr && printQr) { - console.log("Scan this QR in WhatsApp (Linked Devices):"); - qrcode.generate(qr, { small: true }); - } - if (connection === "close") { - const status = getStatusCode(lastDisconnect?.error); - if (status === DisconnectReason.loggedOut) { - console.error( - danger("WhatsApp session logged out. Run: warelay login"), - ); - } - } - if (connection === "open" && verbose) { - console.log(success("WhatsApp Web connected.")); - } - }, - ); + sock.ev.on("creds.update", saveCreds); + sock.ev.on( + "connection.update", + (update: Partial) => { + const { connection, lastDisconnect, qr } = update; + if (qr && printQr) { + console.log("Scan this QR in WhatsApp (Linked Devices):"); + qrcode.generate(qr, { small: true }); + } + if (connection === "close") { + const status = getStatusCode(lastDisconnect?.error); + if (status === DisconnectReason.loggedOut) { + console.error( + danger("WhatsApp session logged out. Run: warelay login"), + ); + } + } + if (connection === "open" && verbose) { + console.log(success("WhatsApp Web connected.")); + } + }, + ); - return sock; + return sock; } export async function waitForWaConnection( - sock: ReturnType, + sock: ReturnType, ) { - return new Promise((resolve, reject) => { - type OffCapable = { - off?: (event: string, listener: (...args: unknown[]) => void) => void; - }; - const evWithOff = sock.ev as unknown as OffCapable; + return new Promise((resolve, reject) => { + type OffCapable = { + off?: (event: string, listener: (...args: unknown[]) => void) => void; + }; + const evWithOff = sock.ev as unknown as OffCapable; - const handler = (...args: unknown[]) => { - const update = (args[0] ?? {}) as Partial< - import("@whiskeysockets/baileys").ConnectionState - >; - if (update.connection === "open") { - evWithOff.off?.("connection.update", handler); - resolve(); - } - if (update.connection === "close") { - evWithOff.off?.("connection.update", handler); - reject(update.lastDisconnect ?? new Error("Connection closed")); - } - }; + const handler = (...args: unknown[]) => { + const update = (args[0] ?? {}) as Partial< + import("@whiskeysockets/baileys").ConnectionState + >; + if (update.connection === "open") { + evWithOff.off?.("connection.update", handler); + resolve(); + } + if (update.connection === "close") { + evWithOff.off?.("connection.update", handler); + reject(update.lastDisconnect ?? new Error("Connection closed")); + } + }; - sock.ev.on("connection.update", handler); - }); + sock.ev.on("connection.update", handler); + }); } export async function sendMessageWeb( - to: string, - body: string, - options: { verbose: boolean; mediaUrl?: string }, + to: string, + body: string, + options: { verbose: boolean; mediaUrl?: string }, ): Promise<{ messageId: string; toJid: string }> { - const sock = await createWaSocket(false, options.verbose); - try { - logInfo("🔌 Connecting to WhatsApp Web…"); - await waitForWaConnection(sock); - const jid = toWhatsappJid(to); - try { - await sock.sendPresenceUpdate("composing", jid); - } catch (err) { - logVerbose(`Presence update skipped: ${String(err)}`); - } - let payload: AnyMessageContent = { text: body }; - if (options.mediaUrl) { - const media = await loadWebMedia(options.mediaUrl); - payload = { - image: media.buffer, - caption: body || undefined, - mimetype: media.contentType, - }; - } - logInfo( - `📤 Sending via web session -> ${jid}${options.mediaUrl ? " (media)" : ""}`, - ); - const result = await sock.sendMessage(jid, payload); - const messageId = result?.key?.id ?? "unknown"; - logInfo( - `✅ Sent via web session. Message ID: ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""}`, - ); - return { messageId, toJid: jid }; - } finally { - try { - sock.ws?.close(); - } catch (err) { - logVerbose(`Socket close failed: ${String(err)}`); - } - } + const sock = await createWaSocket(false, options.verbose); + try { + logInfo("🔌 Connecting to WhatsApp Web…"); + await waitForWaConnection(sock); + const jid = toWhatsappJid(to); + try { + await sock.sendPresenceUpdate("composing", jid); + } catch (err) { + logVerbose(`Presence update skipped: ${String(err)}`); + } + let payload: AnyMessageContent = { text: body }; + if (options.mediaUrl) { + const media = await loadWebMedia(options.mediaUrl); + payload = { + image: media.buffer, + caption: body || undefined, + mimetype: media.contentType, + }; + } + logInfo( + `📤 Sending via web session -> ${jid}${options.mediaUrl ? " (media)" : ""}`, + ); + const result = await sock.sendMessage(jid, payload); + const messageId = result?.key?.id ?? "unknown"; + logInfo( + `✅ Sent via web session. Message ID: ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""}`, + ); + return { messageId, toJid: jid }; + } finally { + try { + sock.ws?.close(); + } catch (err) { + logVerbose(`Socket close failed: ${String(err)}`); + } + } } export async function loginWeb( - verbose: boolean, - waitForConnection: typeof waitForWaConnection = waitForWaConnection, - runtime: RuntimeEnv = defaultRuntime, + verbose: boolean, + waitForConnection: typeof waitForWaConnection = waitForWaConnection, + runtime: RuntimeEnv = defaultRuntime, ) { - const sock = await createWaSocket(true, verbose); - logInfo("Waiting for WhatsApp connection...", runtime); - try { - await waitForConnection(sock); - console.log(success("✅ Linked! Credentials saved for future sends.")); - } catch (err) { - const code = - (err as { error?: { output?: { statusCode?: number } } })?.error?.output - ?.statusCode ?? - (err as { output?: { statusCode?: number } })?.output?.statusCode; - if (code === 515) { - console.log( - info( - "WhatsApp asked for a restart after pairing (code 515); creds are saved. Restarting connection once…", - ), - ); - try { - sock.ws?.close(); - } catch { - // ignore - } - const retry = await createWaSocket(false, verbose); - try { - await waitForConnection(retry); - console.log( - success( - "✅ Linked after restart; web session ready. You can now send with provider=web.", - ), - ); - return; - } finally { - setTimeout(() => retry.ws?.close(), 500); - } - } - if (code === DisconnectReason.loggedOut) { - await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true }); - console.error( - danger( - "WhatsApp reported the session is logged out. Cleared cached web session; please rerun warelay login and scan the QR again.", - ), - ); - throw new Error("Session logged out; cache cleared. Re-run login."); - } - const formatted = formatError(err); - console.error( - danger( - `WhatsApp Web connection ended before fully opening. ${formatted}`, - ), - ); - throw new Error(formatted); - } finally { - setTimeout(() => { - try { - sock.ws?.close(); - } catch { - // ignore - } - }, 500); - } + const sock = await createWaSocket(true, verbose); + logInfo("Waiting for WhatsApp connection...", runtime); + try { + await waitForConnection(sock); + console.log(success("✅ Linked! Credentials saved for future sends.")); + } catch (err) { + const code = + (err as { error?: { output?: { statusCode?: number } } })?.error?.output + ?.statusCode ?? + (err as { output?: { statusCode?: number } })?.output?.statusCode; + if (code === 515) { + console.log( + info( + "WhatsApp asked for a restart after pairing (code 515); creds are saved. Restarting connection once…", + ), + ); + try { + sock.ws?.close(); + } catch { + // ignore + } + const retry = await createWaSocket(false, verbose); + try { + await waitForConnection(retry); + console.log( + success( + "✅ Linked after restart; web session ready. You can now send with provider=web.", + ), + ); + return; + } finally { + setTimeout(() => retry.ws?.close(), 500); + } + } + if (code === DisconnectReason.loggedOut) { + await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true }); + console.error( + danger( + "WhatsApp reported the session is logged out. Cleared cached web session; please rerun warelay login and scan the QR again.", + ), + ); + throw new Error("Session logged out; cache cleared. Re-run login."); + } + const formatted = formatError(err); + console.error( + danger( + `WhatsApp Web connection ended before fully opening. ${formatted}`, + ), + ); + throw new Error(formatted); + } finally { + setTimeout(() => { + try { + sock.ws?.close(); + } catch { + // ignore + } + }, 500); + } } export { WA_WEB_AUTH_DIR }; export function webAuthExists() { - return fs - .access(WA_WEB_AUTH_DIR) - .then(() => true) - .catch(() => false); + return fs + .access(WA_WEB_AUTH_DIR) + .then(() => true) + .catch(() => false); } type WebListenerCloseReason = { - status?: number; - isLoggedOut: boolean; - error?: unknown; + status?: number; + isLoggedOut: boolean; + error?: unknown; }; export type WebInboundMessage = { - id?: string; - from: string; - to: string; - body: string; - pushName?: string; - timestamp?: number; - sendComposing: () => Promise; - reply: (text: string) => Promise; - sendMedia: (payload: { - image: Buffer; - caption?: string; - mimetype?: string; - }) => Promise; - mediaPath?: string; - mediaType?: string; - mediaUrl?: string; + id?: string; + from: string; + to: string; + body: string; + pushName?: string; + timestamp?: number; + sendComposing: () => Promise; + reply: (text: string) => Promise; + sendMedia: (payload: { + image: Buffer; + caption?: string; + mimetype?: string; + }) => Promise; + mediaPath?: string; + mediaType?: string; + mediaUrl?: string; }; export async function monitorWebInbox(options: { - verbose: boolean; - onMessage: (msg: WebInboundMessage) => Promise; + verbose: boolean; + onMessage: (msg: WebInboundMessage) => Promise; }) { - const inboundLogger = getChildLogger({ module: "web-inbound" }); - const sock = await createWaSocket(false, options.verbose); - await waitForWaConnection(sock); - let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null; - const onClose = new Promise((resolve) => { - onCloseResolve = resolve; - }); - try { - // Advertise that the relay is online right after connecting. - await sock.sendPresenceUpdate("available"); - if (isVerbose()) logVerbose("Sent global 'available' presence on connect"); - } catch (err) { - logVerbose( - `Failed to send 'available' presence on connect: ${String(err)}`, - ); - } - const selfJid = sock.user?.id; - const selfE164 = selfJid ? jidToE164(selfJid) : null; - const seen = new Set(); + const inboundLogger = getChildLogger({ module: "web-inbound" }); + const sock = await createWaSocket(false, options.verbose); + await waitForWaConnection(sock); + let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null; + const onClose = new Promise((resolve) => { + onCloseResolve = resolve; + }); + try { + // Advertise that the relay is online right after connecting. + await sock.sendPresenceUpdate("available"); + if (isVerbose()) logVerbose("Sent global 'available' presence on connect"); + } catch (err) { + logVerbose( + `Failed to send 'available' presence on connect: ${String(err)}`, + ); + } + const selfJid = sock.user?.id; + const selfE164 = selfJid ? jidToE164(selfJid) : null; + const seen = new Set(); - sock.ev.on("messages.upsert", async (upsert) => { - if (upsert.type !== "notify") return; - for (const msg of upsert.messages) { - const id = msg.key?.id ?? undefined; - // De-dupe on message id; Baileys can emit retries. - if (id && seen.has(id)) continue; - if (id) seen.add(id); - if (msg.key?.fromMe) continue; - const remoteJid = msg.key?.remoteJid; - if (!remoteJid) continue; - // Ignore status/broadcast traffic; we only care about direct chats. - if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast")) - continue; - if (id) { - const participant = msg.key?.participant; - try { - await sock.readMessages([ - { remoteJid, id, participant, fromMe: false }, - ]); - if (isVerbose()) { - const suffix = participant ? ` (participant ${participant})` : ""; - logVerbose( - `Marked message ${id} as read for ${remoteJid}${suffix}`, - ); - } - } catch (err) { - logVerbose(`Failed to mark message ${id} read: ${String(err)}`); - } - } - const from = jidToE164(remoteJid); - if (!from) continue; - let body = extractText(msg.message ?? undefined); - if (!body) { - body = extractMediaPlaceholder(msg.message ?? undefined); - if (!body) continue; - } - let mediaPath: string | undefined; - let mediaType: string | undefined; - try { - const inboundMedia = await downloadInboundMedia(msg, sock); - if (inboundMedia) { - const saved = await saveMediaBuffer( - inboundMedia.buffer, - inboundMedia.mimetype, - ); - mediaPath = saved.path; - mediaType = inboundMedia.mimetype; - } - } catch (err) { - logVerbose(`Inbound media download failed: ${String(err)}`); - } - const chatJid = remoteJid; - const sendComposing = async () => { - try { - await sock.sendPresenceUpdate("composing", chatJid); - } catch (err) { - logVerbose(`Presence update failed: ${String(err)}`); - } - }; - const reply = async (text: string) => { - await sock.sendMessage(chatJid, { text }); - }; - const sendMedia = async (payload: { - image: Buffer; - caption?: string; - mimetype?: string; - }) => { - await sock.sendMessage(chatJid, payload); - }; - const timestamp = msg.messageTimestamp - ? Number(msg.messageTimestamp) * 1000 - : undefined; - inboundLogger.info( - { - from, - to: selfE164 ?? "me", - body, - mediaPath, - mediaType, - timestamp, - }, - "inbound message", - ); - try { - await options.onMessage({ - id, - from, - to: selfE164 ?? "me", - body, - pushName: msg.pushName ?? undefined, - timestamp, - sendComposing, - reply, - sendMedia, - mediaPath, - mediaType, - }); - } catch (err) { - console.error( - danger(`Failed handling inbound web message: ${String(err)}`), - ); - } - } - }); + sock.ev.on("messages.upsert", async (upsert) => { + if (upsert.type !== "notify") return; + for (const msg of upsert.messages) { + const id = msg.key?.id ?? undefined; + // De-dupe on message id; Baileys can emit retries. + if (id && seen.has(id)) continue; + if (id) seen.add(id); + if (msg.key?.fromMe) continue; + const remoteJid = msg.key?.remoteJid; + if (!remoteJid) continue; + // Ignore status/broadcast traffic; we only care about direct chats. + if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast")) + continue; + if (id) { + const participant = msg.key?.participant; + try { + await sock.readMessages([ + { remoteJid, id, participant, fromMe: false }, + ]); + if (isVerbose()) { + const suffix = participant ? ` (participant ${participant})` : ""; + logVerbose( + `Marked message ${id} as read for ${remoteJid}${suffix}`, + ); + } + } catch (err) { + logVerbose(`Failed to mark message ${id} read: ${String(err)}`); + } + } + const from = jidToE164(remoteJid); + if (!from) continue; + let body = extractText(msg.message ?? undefined); + if (!body) { + body = extractMediaPlaceholder(msg.message ?? undefined); + if (!body) continue; + } + let mediaPath: string | undefined; + let mediaType: string | undefined; + try { + const inboundMedia = await downloadInboundMedia(msg, sock); + if (inboundMedia) { + const saved = await saveMediaBuffer( + inboundMedia.buffer, + inboundMedia.mimetype, + ); + mediaPath = saved.path; + mediaType = inboundMedia.mimetype; + } + } catch (err) { + logVerbose(`Inbound media download failed: ${String(err)}`); + } + const chatJid = remoteJid; + const sendComposing = async () => { + try { + await sock.sendPresenceUpdate("composing", chatJid); + } catch (err) { + logVerbose(`Presence update failed: ${String(err)}`); + } + }; + const reply = async (text: string) => { + await sock.sendMessage(chatJid, { text }); + }; + const sendMedia = async (payload: { + image: Buffer; + caption?: string; + mimetype?: string; + }) => { + await sock.sendMessage(chatJid, payload); + }; + const timestamp = msg.messageTimestamp + ? Number(msg.messageTimestamp) * 1000 + : undefined; + inboundLogger.info( + { + from, + to: selfE164 ?? "me", + body, + mediaPath, + mediaType, + timestamp, + }, + "inbound message", + ); + try { + await options.onMessage({ + id, + from, + to: selfE164 ?? "me", + body, + pushName: msg.pushName ?? undefined, + timestamp, + sendComposing, + reply, + sendMedia, + mediaPath, + mediaType, + }); + } catch (err) { + console.error( + danger(`Failed handling inbound web message: ${String(err)}`), + ); + } + } + }); - sock.ev.on( - "connection.update", - (update: Partial) => { - if (update.connection === "close") { - const status = getStatusCode(update.lastDisconnect?.error); - onCloseResolve?.({ - status, - isLoggedOut: status === DisconnectReason.loggedOut, - error: update.lastDisconnect?.error, - }); - } - }, - ); + sock.ev.on( + "connection.update", + (update: Partial) => { + if (update.connection === "close") { + const status = getStatusCode(update.lastDisconnect?.error); + onCloseResolve?.({ + status, + isLoggedOut: status === DisconnectReason.loggedOut, + error: update.lastDisconnect?.error, + }); + } + }, + ); - return { - close: async () => { - try { - sock.ws?.close(); - } catch (err) { - logVerbose(`Socket close failed: ${String(err)}`); - } - }, - onClose, - }; + return { + close: async () => { + try { + sock.ws?.close(); + } catch (err) { + logVerbose(`Socket close failed: ${String(err)}`); + } + }, + onClose, + }; } export async function monitorWebProvider( - verbose: boolean, - listenerFactory = monitorWebInbox, - keepAlive = true, - replyResolver: typeof getReplyFromConfig = getReplyFromConfig, - runtime: RuntimeEnv = defaultRuntime, - abortSignal?: AbortSignal, + verbose: boolean, + listenerFactory = monitorWebInbox, + keepAlive = true, + replyResolver: typeof getReplyFromConfig = getReplyFromConfig, + runtime: RuntimeEnv = defaultRuntime, + abortSignal?: AbortSignal, ) { - const replyLogger = getChildLogger({ module: "web-auto-reply" }); - const cfg = loadConfig(); - const configuredMaxMb = cfg.inbound?.reply?.mediaMaxMb; - const maxMediaBytes = - typeof configuredMaxMb === "number" && configuredMaxMb > 0 - ? configuredMaxMb * 1024 * 1024 - : DEFAULT_WEB_MEDIA_BYTES; - const stopRequested = () => abortSignal?.aborted === true; - const abortPromise = - abortSignal && - new Promise<"aborted">((resolve) => - abortSignal.addEventListener("abort", () => resolve("aborted"), { - once: true, - }), - ); + const replyLogger = getChildLogger({ module: "web-auto-reply" }); + const cfg = loadConfig(); + const configuredMaxMb = cfg.inbound?.reply?.mediaMaxMb; + const maxMediaBytes = + typeof configuredMaxMb === "number" && configuredMaxMb > 0 + ? configuredMaxMb * 1024 * 1024 + : DEFAULT_WEB_MEDIA_BYTES; + const stopRequested = () => abortSignal?.aborted === true; + const abortPromise = + abortSignal && + new Promise<"aborted">((resolve) => + abortSignal.addEventListener("abort", () => resolve("aborted"), { + once: true, + }), + ); - const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); + const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); - while (true) { - if (stopRequested()) break; + while (true) { + if (stopRequested()) break; - const listener = await listenerFactory({ - verbose, - onMessage: async (msg) => { - const ts = msg.timestamp - ? new Date(msg.timestamp).toISOString() - : new Date().toISOString(); - console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`); + const listener = await listenerFactory({ + verbose, + onMessage: async (msg) => { + const ts = msg.timestamp + ? new Date(msg.timestamp).toISOString() + : new Date().toISOString(); + console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`); - const replyStarted = Date.now(); - const replyResult = await replyResolver( - { - Body: msg.body, - From: msg.from, - To: msg.to, - MessageSid: msg.id, - MediaPath: msg.mediaPath, - MediaUrl: msg.mediaUrl, - MediaType: msg.mediaType, - }, - { - onReplyStart: msg.sendComposing, - }, - ); - if (!replyResult || (!replyResult.text && !replyResult.mediaUrl)) { - logVerbose( - "Skipping auto-reply: no text/media returned from resolver", - ); - return; - } - try { - if (replyResult.mediaUrl) { - logVerbose( - `Web auto-reply media detected: ${replyResult.mediaUrl}`, - ); - try { - const media = await loadWebMedia( - replyResult.mediaUrl, - maxMediaBytes, - ); - if (isVerbose()) { - logVerbose( - `Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`, - ); - logVerbose( - `Web auto-reply media source: ${replyResult.mediaUrl} (kind ${media.kind})`, - ); - } - if (media.kind === "image") { - await msg.sendMedia({ - image: media.buffer, - caption: replyResult.text || undefined, - mimetype: media.contentType, - }); - } else if (media.kind === "audio") { - await msg.sendMedia({ - audio: media.buffer, - ptt: true, - mimetype: media.contentType, - caption: replyResult.text || undefined, - } as AnyMessageContent); - } else if (media.kind === "video") { - await msg.sendMedia({ - video: media.buffer, - caption: replyResult.text || undefined, - mimetype: media.contentType, - }); - } else { - const fileName = - replyResult.mediaUrl.split("/").pop() ?? "file"; - await msg.sendMedia({ - document: media.buffer, - fileName, - caption: replyResult.text || undefined, - mimetype: media.contentType, - } as AnyMessageContent); - } - logInfo( - `✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`, - runtime, - ); - replyLogger.info( - { - to: msg.from, - from: msg.to, - text: replyResult.text ?? null, - mediaUrl: replyResult.mediaUrl, - mediaSizeBytes: media.buffer.length, - mediaKind: media.kind, - durationMs: Date.now() - replyStarted, - }, - "auto-reply sent (media)", - ); - } catch (err) { - console.error( - danger( - `Failed sending web media to ${msg.from}: ${String(err)}`, - ), - ); - if (replyResult.text) { - await msg.reply(replyResult.text); - logInfo( - `⚠️ Media skipped; sent text-only to ${msg.from}`, - runtime, - ); - replyLogger.info( - { - to: msg.from, - from: msg.to, - text: replyResult.text, - mediaUrl: replyResult.mediaUrl, - durationMs: Date.now() - replyStarted, - mediaSendFailed: true, - }, - "auto-reply sent (text fallback)", - ); - } - } - } else { - await msg.reply(replyResult.text ?? ""); - } - const durationMs = Date.now() - replyStarted; - if (isVerbose()) { - console.log( - success( - `↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${replyResult.mediaUrl ? ", media" : ""}, ${formatDuration(durationMs)})`, - ), - ); - } else { - console.log( - success( - `↩️ ${replyResult.text ?? ""}${replyResult.mediaUrl ? " (media)" : ""}`, - ), - ); - } - replyLogger.info( - { - to: msg.from, - from: msg.to, - text: replyResult.text ?? null, - mediaUrl: replyResult.mediaUrl, - durationMs, - }, - "auto-reply sent", - ); - } catch (err) { - console.error( - danger( - `Failed sending web auto-reply to ${msg.from}: ${String(err)}`, - ), - ); - } - }, - }); + const replyStarted = Date.now(); + const replyResult = await replyResolver( + { + Body: msg.body, + From: msg.from, + To: msg.to, + MessageSid: msg.id, + MediaPath: msg.mediaPath, + MediaUrl: msg.mediaUrl, + MediaType: msg.mediaType, + }, + { + onReplyStart: msg.sendComposing, + }, + ); + if ( + !replyResult || + (!replyResult.text && + !replyResult.mediaUrl && + !replyResult.mediaUrls?.length) + ) { + logVerbose( + "Skipping auto-reply: no text/media returned from resolver", + ); + return; + } + try { + const mediaList = replyResult.mediaUrls?.length + ? replyResult.mediaUrls + : replyResult.mediaUrl + ? [replyResult.mediaUrl] + : []; - logInfo( - "📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.", - runtime, - ); - let stop = false; - process.on("SIGINT", () => { - stop = true; - void listener.close().finally(() => { - logInfo("👋 Web monitor stopped", runtime); - runtime.exit(0); - }); - }); + if (mediaList.length > 0) { + logVerbose( + `Web auto-reply media detected: ${mediaList.filter(Boolean).join(", ")}`, + ); + for (const [index, mediaUrl] of mediaList.entries()) { + try { + const media = await loadWebMedia(mediaUrl, maxMediaBytes); + if (isVerbose()) { + logVerbose( + `Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`, + ); + logVerbose( + `Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`, + ); + } + const caption = + index === 0 ? replyResult.text || undefined : undefined; + if (media.kind === "image") { + await msg.sendMedia({ + image: media.buffer, + caption, + mimetype: media.contentType, + }); + } else if (media.kind === "audio") { + await msg.sendMedia({ + audio: media.buffer, + ptt: true, + mimetype: media.contentType, + caption, + } as AnyMessageContent); + } else if (media.kind === "video") { + await msg.sendMedia({ + video: media.buffer, + caption, + mimetype: media.contentType, + }); + } else { + const fileName = mediaUrl.split("/").pop() ?? "file"; + await msg.sendMedia({ + document: media.buffer, + fileName, + caption, + mimetype: media.contentType, + } as AnyMessageContent); + } + logInfo( + `✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`, + runtime, + ); + replyLogger.info( + { + to: msg.from, + from: msg.to, + text: index === 0 ? (replyResult.text ?? null) : null, + mediaUrl, + mediaSizeBytes: media.buffer.length, + mediaKind: media.kind, + durationMs: Date.now() - replyStarted, + }, + "auto-reply sent (media)", + ); + } catch (err) { + console.error( + danger( + `Failed sending web media to ${msg.from}: ${String(err)}`, + ), + ); + if (index === 0 && replyResult.text) { + console.log( + warn(`⚠️ Media skipped; sent text-only to ${msg.from}`), + ); + await msg.reply(replyResult.text || ""); + } + } + } + } else if (replyResult.text) { + await msg.reply(replyResult.text); + } - if (!keepAlive) return; + const durationMs = Date.now() - replyStarted; + const hasMedia = mediaList.length > 0; + if (isVerbose()) { + console.log( + success( + `↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${hasMedia ? ", media" : ""}, ${formatDuration(durationMs)})`, + ), + ); + } else { + console.log( + success( + `↩️ ${replyResult.text ?? ""}${hasMedia ? " (media)" : ""}`, + ), + ); + } + replyLogger.info( + { + to: msg.from, + from: msg.to, + text: replyResult.text ?? null, + mediaUrl: mediaList[0] ?? null, + durationMs, + }, + "auto-reply sent", + ); + } catch (err) { + console.error( + danger( + `Failed sending web auto-reply to ${msg.from}: ${String(err)}`, + ), + ); + } + }, + }); - const reason = await Promise.race([ - listener.onClose ?? waitForever(), - abortPromise ?? waitForever(), - ]); + logInfo( + "📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.", + runtime, + ); + let stop = false; + process.on("SIGINT", () => { + stop = true; + void listener.close().finally(() => { + logInfo("👋 Web monitor stopped", runtime); + runtime.exit(0); + }); + }); - if (stopRequested() || stop || reason === "aborted") { - await listener.close(); - break; - } + if (!keepAlive) return; - const status = - (typeof reason === "object" && reason && "status" in reason - ? (reason as WebListenerCloseReason).status - : undefined) ?? "unknown"; - const loggedOut = - typeof reason === "object" && - reason && - "isLoggedOut" in reason && - (reason as WebListenerCloseReason).isLoggedOut; + const reason = await Promise.race([ + listener.onClose ?? waitForever(), + abortPromise ?? waitForever(), + ]); - if (loggedOut) { - runtime.error( - danger( - "WhatsApp session logged out. Run `warelay login --provider web` to relink.", - ), - ); - break; - } + if (stopRequested() || stop || reason === "aborted") { + await listener.close(); + break; + } - runtime.error( - danger( - `WhatsApp Web connection closed (status ${status}). Reconnecting in 2s…`, - ), - ); - await listener.close(); - await sleep(2_000); - } + const status = + (typeof reason === "object" && reason && "status" in reason + ? (reason as WebListenerCloseReason).status + : undefined) ?? "unknown"; + const loggedOut = + typeof reason === "object" && + reason && + "isLoggedOut" in reason && + (reason as WebListenerCloseReason).isLoggedOut; + + if (loggedOut) { + runtime.error( + danger( + "WhatsApp session logged out. Run `warelay login --provider web` to relink.", + ), + ); + break; + } + + runtime.error( + danger( + `WhatsApp Web connection closed (status ${status}). Reconnecting in 2s…`, + ), + ); + await listener.close(); + await sleep(2_000); + } } function readWebSelfId() { - // Read the cached WhatsApp Web identity (jid + E.164) from disk if present. - const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json"); - try { - if (!fsSync.existsSync(credsPath)) { - return { e164: null, jid: null }; - } - const raw = fsSync.readFileSync(credsPath, "utf-8"); - const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined; - const jid = parsed?.me?.id ?? null; - const e164 = jid ? jidToE164(jid) : null; - return { e164, jid }; - } catch { - return { e164: null, jid: null }; - } + // Read the cached WhatsApp Web identity (jid + E.164) from disk if present. + const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json"); + try { + if (!fsSync.existsSync(credsPath)) { + return { e164: null, jid: null }; + } + const raw = fsSync.readFileSync(credsPath, "utf-8"); + const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined; + const jid = parsed?.me?.id ?? null; + const e164 = jid ? jidToE164(jid) : null; + return { e164, jid }; + } catch { + return { e164: null, jid: null }; + } } export function logWebSelfId( - runtime: RuntimeEnv = defaultRuntime, - includeProviderPrefix = false, + runtime: RuntimeEnv = defaultRuntime, + includeProviderPrefix = false, ) { - // Human-friendly log of the currently linked personal web session. - const { e164, jid } = readWebSelfId(); - const details = - e164 || jid - ? `${e164 ?? "unknown"}${jid ? ` (jid ${jid})` : ""}` - : "unknown"; - const prefix = includeProviderPrefix ? "Web Provider: " : ""; - runtime.log(info(`${prefix}${details}`)); + // Human-friendly log of the currently linked personal web session. + const { e164, jid } = readWebSelfId(); + const details = + e164 || jid + ? `${e164 ?? "unknown"}${jid ? ` (jid ${jid})` : ""}` + : "unknown"; + const prefix = includeProviderPrefix ? "Web Provider: " : ""; + runtime.log(info(`${prefix}${details}`)); } 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 hasWeb = await webAuthExists(); - if (hasWeb) return "web"; - return "twilio"; + // Auto-select web when logged in; otherwise fall back to twilio. + if (pref !== "auto") return pref; + const hasWeb = await webAuthExists(); + if (hasWeb) return "web"; + return "twilio"; } function extractText(message: proto.IMessage | undefined): string | undefined { - if (!message) return undefined; - if (typeof message.conversation === "string" && message.conversation.trim()) { - return message.conversation.trim(); - } - const extended = message.extendedTextMessage?.text; - if (extended?.trim()) return extended.trim(); - const caption = - message.imageMessage?.caption ?? message.videoMessage?.caption; - if (caption?.trim()) return caption.trim(); - return undefined; + if (!message) return undefined; + if (typeof message.conversation === "string" && message.conversation.trim()) { + return message.conversation.trim(); + } + const extended = message.extendedTextMessage?.text; + if (extended?.trim()) return extended.trim(); + const caption = + message.imageMessage?.caption ?? message.videoMessage?.caption; + if (caption?.trim()) return caption.trim(); + return undefined; } function extractMediaPlaceholder( - message: proto.IMessage | undefined, + message: proto.IMessage | undefined, ): string | undefined { - if (!message) return undefined; - if (message.imageMessage) return ""; - if (message.videoMessage) return ""; - if (message.audioMessage) return ""; - if (message.documentMessage) return ""; - if (message.stickerMessage) return ""; - return undefined; + if (!message) return undefined; + if (message.imageMessage) return ""; + if (message.videoMessage) return ""; + if (message.audioMessage) return ""; + if (message.documentMessage) return ""; + if (message.stickerMessage) return ""; + return undefined; } async function downloadInboundMedia( - msg: proto.IWebMessageInfo, - sock: ReturnType, + msg: proto.IWebMessageInfo, + sock: ReturnType, ): Promise<{ buffer: Buffer; mimetype?: string } | undefined> { - const message = msg.message; - if (!message) return undefined; - const mimetype = - message.imageMessage?.mimetype ?? - message.videoMessage?.mimetype ?? - message.documentMessage?.mimetype ?? - message.audioMessage?.mimetype ?? - message.stickerMessage?.mimetype ?? - undefined; - if ( - !message.imageMessage && - !message.videoMessage && - !message.documentMessage && - !message.audioMessage && - !message.stickerMessage - ) { - return undefined; - } - try { - const buffer = (await downloadMediaMessage( - msg as WAMessage, - "buffer", - {}, - { - reuploadRequest: sock.updateMediaMessage, - logger: sock.logger, - }, - )) as Buffer; - return { buffer, mimetype }; - } catch (err) { - logVerbose(`downloadMediaMessage failed: ${String(err)}`); - return undefined; - } + const message = msg.message; + if (!message) return undefined; + const mimetype = + message.imageMessage?.mimetype ?? + message.videoMessage?.mimetype ?? + message.documentMessage?.mimetype ?? + message.audioMessage?.mimetype ?? + message.stickerMessage?.mimetype ?? + undefined; + if ( + !message.imageMessage && + !message.videoMessage && + !message.documentMessage && + !message.audioMessage && + !message.stickerMessage + ) { + return undefined; + } + try { + const buffer = (await downloadMediaMessage( + msg as WAMessage, + "buffer", + {}, + { + reuploadRequest: sock.updateMediaMessage, + logger: sock.logger, + }, + )) as Buffer; + return { buffer, mimetype }; + } catch (err) { + logVerbose(`downloadMediaMessage failed: ${String(err)}`); + return undefined; + } } async function loadWebMedia( - mediaUrl: string, - maxBytes?: number, + mediaUrl: string, + maxBytes?: number, ): Promise<{ buffer: Buffer; contentType?: string; kind: MediaKind }> { - if (mediaUrl.startsWith("file://")) { - mediaUrl = mediaUrl.replace("file://", ""); - } + if (mediaUrl.startsWith("file://")) { + mediaUrl = mediaUrl.replace("file://", ""); + } - const optimizeAndClampImage = async (buffer: Buffer, cap: number) => { - const originalSize = buffer.length; - const optimized = await optimizeImageToJpeg(buffer, cap); - if (optimized.optimizedSize < originalSize && isVerbose()) { - logVerbose( - `Optimized media from ${(originalSize / (1024 * 1024)).toFixed(2)}MB to ${(optimized.optimizedSize / (1024 * 1024)).toFixed(2)}MB (side≤${optimized.resizeSide}px, q=${optimized.quality})`, - ); - } - if (optimized.buffer.length > cap) { - throw new Error( - `Media could not be reduced below ${(maxBytes / (1024 * 1024)).toFixed(0)}MB (got ${( - optimized.buffer.length / (1024 * 1024) - ).toFixed(2)}MB)`, - ); - } - return { - buffer: optimized.buffer, - contentType: "image/jpeg", - kind: "image" as const, - }; - }; + const optimizeAndClampImage = async (buffer: Buffer, cap: number) => { + const originalSize = buffer.length; + const optimized = await optimizeImageToJpeg(buffer, cap); + if (optimized.optimizedSize < originalSize && isVerbose()) { + logVerbose( + `Optimized media from ${(originalSize / (1024 * 1024)).toFixed(2)}MB to ${(optimized.optimizedSize / (1024 * 1024)).toFixed(2)}MB (side≤${optimized.resizeSide}px, q=${optimized.quality})`, + ); + } + if (optimized.buffer.length > cap) { + throw new Error( + `Media could not be reduced below ${(maxBytes / (1024 * 1024)).toFixed(0)}MB (got ${( + optimized.buffer.length / (1024 * 1024) + ).toFixed(2)}MB)`, + ); + } + return { + buffer: optimized.buffer, + contentType: "image/jpeg", + kind: "image" as const, + }; + }; - if (/^https?:\/\//i.test(mediaUrl)) { - const res = await fetch(mediaUrl); - if (!res.ok || !res.body) { - throw new Error(`Failed to fetch media: HTTP ${res.status}`); - } - const array = Buffer.from(await res.arrayBuffer()); - const contentType = res.headers.get("content-type"); - const kind = mediaKindFromMime(contentType); - const cap = Math.min( - maxBytes ?? maxBytesForKind(kind), - maxBytesForKind(kind), - ); - if (kind === "image") { - return optimizeAndClampImage(array, cap); - } - if (array.length > cap) { - throw new Error( - `Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( - array.length / (1024 * 1024) - ).toFixed(2)}MB)`, - ); - } - return { buffer: array, contentType: contentType ?? undefined, kind }; - } - // Local path - const data = await fs.readFile(mediaUrl); - const ext = path.extname(mediaUrl); - const mime = - (ext && - ( - { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".webp": "image/webp", - ".gif": "image/gif", - ".ogg": "audio/ogg", - ".opus": "audio/ogg", - ".mp3": "audio/mpeg", - ".mp4": "video/mp4", - ".pdf": "application/pdf", - } as Record - )[ext.toLowerCase()]) ?? - undefined; - const kind = mediaKindFromMime(mime); - const cap = Math.min( - maxBytes ?? maxBytesForKind(kind), - maxBytesForKind(kind), - ); - if (kind === "image") { - return optimizeAndClampImage(data, cap); - } - if (data.length > cap) { - throw new Error( - `Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( - data.length / (1024 * 1024) - ).toFixed(2)}MB)`, - ); - } - return { buffer: data, contentType: mime, kind }; + if (/^https?:\/\//i.test(mediaUrl)) { + const res = await fetch(mediaUrl); + if (!res.ok || !res.body) { + throw new Error(`Failed to fetch media: HTTP ${res.status}`); + } + const array = Buffer.from(await res.arrayBuffer()); + const contentType = res.headers.get("content-type"); + const kind = mediaKindFromMime(contentType); + const cap = Math.min( + maxBytes ?? maxBytesForKind(kind), + maxBytesForKind(kind), + ); + if (kind === "image") { + return optimizeAndClampImage(array, cap); + } + if (array.length > cap) { + throw new Error( + `Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( + array.length / (1024 * 1024) + ).toFixed(2)}MB)`, + ); + } + return { buffer: array, contentType: contentType ?? undefined, kind }; + } + // Local path + const data = await fs.readFile(mediaUrl); + const ext = path.extname(mediaUrl); + const mime = + (ext && + ( + { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".gif": "image/gif", + ".ogg": "audio/ogg", + ".opus": "audio/ogg", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".pdf": "application/pdf", + } as Record + )[ext.toLowerCase()]) ?? + undefined; + const kind = mediaKindFromMime(mime); + const cap = Math.min( + maxBytes ?? maxBytesForKind(kind), + maxBytesForKind(kind), + ); + if (kind === "image") { + return optimizeAndClampImage(data, cap); + } + if (data.length > cap) { + throw new Error( + `Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( + data.length / (1024 * 1024) + ).toFixed(2)}MB)`, + ); + } + return { buffer: data, contentType: mime, kind }; } function getStatusCode(err: unknown) { - return ( - (err as { output?: { statusCode?: number } })?.output?.statusCode ?? - (err as { status?: number })?.status - ); + return ( + (err as { output?: { statusCode?: number } })?.output?.statusCode ?? + (err as { status?: number })?.status + ); } function formatError(err: unknown): string { - if (err instanceof Error) return err.message; - if (typeof err === "string") return err; - const status = getStatusCode(err); - const code = (err as { code?: unknown })?.code; - if (status || code) - return `status=${status ?? "unknown"} code=${code ?? "unknown"}`; - return String(err); + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + const status = getStatusCode(err); + const code = (err as { code?: unknown })?.code; + if (status || code) + return `status=${status ?? "unknown"} code=${code ?? "unknown"}`; + return String(err); } async function optimizeImageToJpeg( - buffer: Buffer, - maxBytes: number, + buffer: Buffer, + maxBytes: number, ): Promise<{ - buffer: Buffer; - optimizedSize: number; - resizeSide: number; - quality: number; + buffer: Buffer; + optimizedSize: number; + resizeSide: number; + quality: number; }> { - // Try a grid of sizes/qualities until under the limit. - const sides = [2048, 1536, 1280, 1024, 800]; - const qualities = [80, 70, 60, 50, 40]; - let smallest: { - buffer: Buffer; - size: number; - resizeSide: number; - quality: number; - } | null = null; + // Try a grid of sizes/qualities until under the limit. + const sides = [2048, 1536, 1280, 1024, 800]; + const qualities = [80, 70, 60, 50, 40]; + let smallest: { + buffer: Buffer; + size: number; + resizeSide: number; + quality: number; + } | null = null; - for (const side of sides) { - for (const quality of qualities) { - const out = await sharp(buffer) - .resize({ - width: side, - height: side, - fit: "inside", - withoutEnlargement: true, - }) - .jpeg({ quality, mozjpeg: true }) - .toBuffer(); - const size = out.length; - if (!smallest || size < smallest.size) { - smallest = { buffer: out, size, resizeSide: side, quality }; - } - if (size <= maxBytes) { - return { - buffer: out, - optimizedSize: size, - resizeSide: side, - quality, - }; - } - } - } + for (const side of sides) { + for (const quality of qualities) { + const out = await sharp(buffer) + .resize({ + width: side, + height: side, + fit: "inside", + withoutEnlargement: true, + }) + .jpeg({ quality, mozjpeg: true }) + .toBuffer(); + const size = out.length; + if (!smallest || size < smallest.size) { + smallest = { buffer: out, size, resizeSide: side, quality }; + } + if (size <= maxBytes) { + return { + buffer: out, + optimizedSize: size, + resizeSide: side, + quality, + }; + } + } + } - if (smallest) { - return { - buffer: smallest.buffer, - optimizedSize: smallest.size, - resizeSide: smallest.resizeSide, - quality: smallest.quality, - }; - } + if (smallest) { + return { + buffer: smallest.buffer, + optimizedSize: smallest.size, + resizeSide: smallest.resizeSide, + quality: smallest.quality, + }; + } - throw new Error("Failed to optimize image"); + throw new Error("Failed to optimize image"); } diff --git a/src/providers/twilio/index.ts b/src/providers/twilio/index.ts index de6089aa8..7d1cd4f54 100644 --- a/src/providers/twilio/index.ts +++ b/src/providers/twilio/index.ts @@ -1,16 +1,16 @@ export { createClient } from "../../twilio/client.js"; export { - formatMessageLine, - listRecentMessages, + 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, + findIncomingNumberSid, + findMessagingServiceSid, + setMessagingServiceWebhook, + updateWebhook, } from "../../twilio/update-webhook.js"; export { formatTwilioError, logTwilioSendError } from "../../twilio/utils.js"; diff --git a/src/providers/web/index.test.ts b/src/providers/web/index.test.ts index d6e08e5d3..7ea078330 100644 --- a/src/providers/web/index.test.ts +++ b/src/providers/web/index.test.ts @@ -4,16 +4,16 @@ import * as impl from "../../provider-web.js"; import * as entry from "./index.js"; describe("providers/web entrypoint", () => { - it("re-exports web provider helpers", () => { - expect(entry.createWaSocket).toBe(impl.createWaSocket); - expect(entry.loginWeb).toBe(impl.loginWeb); - expect(entry.logWebSelfId).toBe(impl.logWebSelfId); - expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox); - expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider); - expect(entry.pickProvider).toBe(impl.pickProvider); - expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb); - expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR); - expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection); - expect(entry.webAuthExists).toBe(impl.webAuthExists); - }); + it("re-exports web provider helpers", () => { + expect(entry.createWaSocket).toBe(impl.createWaSocket); + expect(entry.loginWeb).toBe(impl.loginWeb); + expect(entry.logWebSelfId).toBe(impl.logWebSelfId); + expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox); + expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider); + expect(entry.pickProvider).toBe(impl.pickProvider); + expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb); + expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR); + expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection); + expect(entry.webAuthExists).toBe(impl.webAuthExists); + }); }); diff --git a/src/providers/web/index.ts b/src/providers/web/index.ts index 0b824568b..8605726a8 100644 --- a/src/providers/web/index.ts +++ b/src/providers/web/index.ts @@ -1,13 +1,13 @@ /* istanbul ignore file */ export { - createWaSocket, - loginWeb, - logWebSelfId, - monitorWebInbox, - monitorWebProvider, - pickProvider, - sendMessageWeb, - WA_WEB_AUTH_DIR, - waitForWaConnection, - webAuthExists, + createWaSocket, + loginWeb, + logWebSelfId, + monitorWebInbox, + monitorWebProvider, + pickProvider, + sendMessageWeb, + WA_WEB_AUTH_DIR, + waitForWaConnection, + webAuthExists, } from "../../provider-web.js"; diff --git a/src/runtime.ts b/src/runtime.ts index 56e2d138a..48ec47bfa 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,14 +1,14 @@ export type RuntimeEnv = { - log: typeof console.log; - error: typeof console.error; - exit: (code: number) => never; + log: typeof console.log; + error: typeof console.error; + exit: (code: number) => never; }; export const defaultRuntime: RuntimeEnv = { - log: console.log, - error: console.error, - exit: (code) => { - process.exit(code); - throw new Error("unreachable"); // satisfies tests when mocked - }, + log: console.log, + error: console.error, + exit: (code) => { + process.exit(code); + throw new Error("unreachable"); // satisfies tests when mocked + }, }; diff --git a/src/twilio/client.ts b/src/twilio/client.ts index 2324cefe0..fbb5565b6 100644 --- a/src/twilio/client.ts +++ b/src/twilio/client.ts @@ -2,13 +2,13 @@ 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, - }); + // 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/messages.ts b/src/twilio/messages.ts index 5c2bd96e9..72c27ca85 100644 --- a/src/twilio/messages.ts +++ b/src/twilio/messages.ts @@ -3,97 +3,97 @@ 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; + 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; + 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; - }); + 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, + 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); + 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, - }); + // 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, - })), - ); + 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); + 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})`; + 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 index 92d36ca4b..76b6a8527 100644 --- a/src/twilio/monitor.test.ts +++ b/src/twilio/monitor.test.ts @@ -3,43 +3,43 @@ 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); + 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, - }); + await monitorTwilio(0, 0, { + deps: { + autoReplyIfConfigured, + listRecentMessages, + readEnv, + createClient, + sleep, + }, + maxIterations: 1, + }); - expect(listRecentMessages).toHaveBeenCalledTimes(1); - expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1); - }); + expect(listRecentMessages).toHaveBeenCalledTimes(1); + expect(autoReplyIfConfigured).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/twilio/monitor.ts b/src/twilio/monitor.ts index b7c25ebf6..58bae951f 100644 --- a/src/twilio/monitor.ts +++ b/src/twilio/monitor.ts @@ -8,122 +8,122 @@ 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; + 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; + 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; + client?: ReturnType; + maxIterations?: number; + deps?: MonitorDeps; + runtime?: RuntimeEnv; }; const defaultDeps: MonitorDeps = { - autoReplyIfConfigured, - listRecentMessages: () => Promise.resolve([]), - readEnv, - createClient, - sleep, + 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, + 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 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, - ); + 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, - ); - } + 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, + 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)}`)); - } - } + 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 index 0ee674a2a..f6545b96e 100644 --- a/src/twilio/send.test.ts +++ b/src/twilio/send.test.ts @@ -3,30 +3,30 @@ 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 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); - }); + 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 index 3eaedc8e5..eb08e6567 100644 --- a/src/twilio/send.ts +++ b/src/twilio/send.ts @@ -10,60 +10,60 @@ 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, + 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); + 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, - }); + 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); - } + 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, + 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, - ); + 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 index 1653a7793..ab58912d8 100644 --- a/src/twilio/senders.ts +++ b/src/twilio/senders.ts @@ -4,50 +4,50 @@ import { withWhatsAppPrefix } from "../utils.js"; import type { TwilioSenderListClient } from "./types.js"; export async function findWhatsappSenderSid( - client: TwilioSenderListClient, - from: string, - explicitSenderSid?: string, - runtime: RuntimeEnv = defaultRuntime, + 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); - } + // 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 index c11eea69c..9ea56ac7d 100644 --- a/src/twilio/types.ts +++ b/src/twilio/types.ts @@ -1,79 +1,79 @@ export type TwilioRequestOptions = { - method: "get" | "post"; - uri: string; - params?: Record; - form?: Record; - body?: unknown; - contentType?: string; + 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[]; - }; + data?: { + senders?: TwilioSender[]; + }; }; export type IncomingNumber = { - sid: string; - phoneNumber: string; - smsUrl?: string; + 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; - }; + 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; + update: (params: Record) => Promise; }; export type IncomingPhoneNumberUpdater = { - update: (params: Record) => Promise; + update: (params: Record) => Promise; }; export type IncomingPhoneNumbersClient = { - list: (params: { - phoneNumber: string; - limit?: number; - }) => Promise; - get: (sid: string) => IncomingPhoneNumberUpdater; + 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; + 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; + request: (options: TwilioRequestOptions) => Promise; }; diff --git a/src/twilio/typing.ts b/src/twilio/typing.ts index ea4b50975..adf234fa7 100644 --- a/src/twilio/typing.ts +++ b/src/twilio/typing.ts @@ -2,42 +2,42 @@ 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; + method: "get" | "post"; + uri: string; + params?: Record; + form?: Record; + body?: unknown; + contentType?: string; }; type TwilioRequester = { - request: (options: TwilioRequestOptions) => Promise; + request: (options: TwilioRequestOptions) => Promise; }; export async function sendTypingIndicator( - client: TwilioRequester, - runtime: RuntimeEnv, - messageSid?: string, + 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); - } - } + // 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 index 4f04123dd..d29c31f49 100644 --- a/src/twilio/update-webhook.test.ts +++ b/src/twilio/update-webhook.test.ts @@ -1,61 +1,61 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - findIncomingNumberSid, - findMessagingServiceSid, - setMessagingServiceWebhook, + 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"; - }); + 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; - }); - }); + 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("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("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); - }); + 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 index 96031e479..0d6dc2fc7 100644 --- a/src/twilio/update-webhook.ts +++ b/src/twilio/update-webhook.ts @@ -6,193 +6,193 @@ import type { createClient } from "./client.js"; import type { TwilioRequester, TwilioSenderListClient } from "./types.js"; export async function findIncomingNumberSid( - client: TwilioSenderListClient, + 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; - } + // 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, + 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; - } + // 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, + 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; - } + 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, + 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; + // 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, - ); - } + // 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, - ); - } + // 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, - ); - } + // 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, - ); - } + // 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`, - ); + 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 index 7ea648c88..4f433a70a 100644 --- a/src/twilio/utils.ts +++ b/src/twilio/utils.ts @@ -2,36 +2,36 @@ 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 }; + 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); + // 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, + 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)); - } + // 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 index daa9f1ca8..6bc2093ba 100644 --- a/src/twilio/webhook.ts +++ b/src/twilio/webhook.ts @@ -16,143 +16,143 @@ 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, + 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(); + 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(); - }); + 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(` + 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 ?? ""}`)); + 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 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 | 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 client = createClient(env); + let replyResult: 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), + }, + ); + } - if (replyResult && (replyResult.text || replyResult.mediaUrl)) { - try { - let mediaUrl = replyResult.mediaUrl; - if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) { - const hosted = await ensureMediaHosted(mediaUrl); - mediaUrl = hosted.url; - } - await client.messages.create({ - from: To, - to: From, - body: replyResult.text ?? "", - ...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}), - }); - if (verbose) - runtime.log( - success(`↩️ Auto-replied to ${From}${mediaUrl ? " (media)" : ""}`), - ); - } catch (err) { - logTwilioSendError(err, From ?? undefined, runtime); - } - } + if (replyResult && (replyResult.text || replyResult.mediaUrl)) { + try { + let mediaUrl = replyResult.mediaUrl; + if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) { + const hosted = await ensureMediaHosted(mediaUrl); + mediaUrl = hosted.url; + } + await client.messages.create({ + from: To, + to: From, + body: replyResult.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(""); - }); + // 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("warelay webhook: not found"); - }); + app.use((_req, res) => { + if (verbose) runtime.log(chalk.yellow(`404 ${_req.method} ${_req.url}`)); + res.status(404).send("warelay webhook: not found"); + }); - // Start server and resolve once listening; reject on bind error. - return await new Promise((resolve, reject) => { - const server = app.listen(port); + // 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 onListening = () => { + cleanup(); + runtime.log( + `📥 Webhook listening on http://localhost:${port}${normalizedPath}`, + ); + resolve(server); + }; - const onError = (err: NodeJS.ErrnoException) => { - cleanup(); - reject(err); - }; + const onError = (err: NodeJS.ErrnoException) => { + cleanup(); + reject(err); + }; - const cleanup = () => { - server.off("listening", onListening); - server.off("error", onError); - }; + const cleanup = () => { + server.off("listening", onListening); + server.off("error", onError); + }; - server.once("listening", onListening); - server.once("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", - ); + 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.test.ts b/src/utils.test.ts index d00aca83f..4d105bbee 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -3,67 +3,67 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { - assertProvider, - ensureDir, - normalizeE164, - normalizePath, - sleep, - toWhatsappJid, - withWhatsAppPrefix, + assertProvider, + ensureDir, + normalizeE164, + normalizePath, + sleep, + toWhatsappJid, + withWhatsAppPrefix, } from "./utils.js"; describe("normalizePath", () => { - it("adds leading slash when missing", () => { - expect(normalizePath("foo")).toBe("/foo"); - }); + it("adds leading slash when missing", () => { + expect(normalizePath("foo")).toBe("/foo"); + }); - it("keeps existing slash", () => { - expect(normalizePath("/bar")).toBe("/bar"); - }); + it("keeps existing slash", () => { + expect(normalizePath("/bar")).toBe("/bar"); + }); }); describe("withWhatsAppPrefix", () => { - it("adds whatsapp prefix", () => { - expect(withWhatsAppPrefix("+1555")).toBe("whatsapp:+1555"); - }); + it("adds whatsapp prefix", () => { + expect(withWhatsAppPrefix("+1555")).toBe("whatsapp:+1555"); + }); - it("leaves prefixed intact", () => { - expect(withWhatsAppPrefix("whatsapp:+1555")).toBe("whatsapp:+1555"); - }); + it("leaves prefixed intact", () => { + expect(withWhatsAppPrefix("whatsapp:+1555")).toBe("whatsapp:+1555"); + }); }); describe("ensureDir", () => { - it("creates nested directory", async () => { - const tmp = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "warelay-test-"), - ); - const target = path.join(tmp, "nested", "dir"); - await ensureDir(target); - expect(fs.existsSync(target)).toBe(true); - }); + it("creates nested directory", async () => { + const tmp = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "warelay-test-"), + ); + const target = path.join(tmp, "nested", "dir"); + await ensureDir(target); + expect(fs.existsSync(target)).toBe(true); + }); }); describe("sleep", () => { - it("resolves after delay using fake timers", async () => { - vi.useFakeTimers(); - const promise = sleep(1000); - vi.advanceTimersByTime(1000); - await expect(promise).resolves.toBeUndefined(); - vi.useRealTimers(); - }); + it("resolves after delay using fake timers", async () => { + vi.useFakeTimers(); + const promise = sleep(1000); + vi.advanceTimersByTime(1000); + await expect(promise).resolves.toBeUndefined(); + vi.useRealTimers(); + }); }); describe("assertProvider", () => { - it("throws for invalid provider", () => { - expect(() => assertProvider("bad" as string)).toThrow(); - }); + it("throws for invalid provider", () => { + expect(() => assertProvider("bad" as string)).toThrow(); + }); }); describe("normalizeE164 & toWhatsappJid", () => { - it("strips formatting and prefixes", () => { - expect(normalizeE164("whatsapp:(555) 123-4567")).toBe("+5551234567"); - expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe( - "5551234567@s.whatsapp.net", - ); - }); + it("strips formatting and prefixes", () => { + expect(normalizeE164("whatsapp:(555) 123-4567")).toBe("+5551234567"); + expect(toWhatsappJid("whatsapp:+555 123 4567")).toBe( + "5551234567@s.whatsapp.net", + ); + }); }); diff --git a/src/utils.ts b/src/utils.ts index 72c8c227e..eba16782b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,49 +2,49 @@ import fs from "node:fs"; import os from "node:os"; export async function ensureDir(dir: string) { - await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.mkdir(dir, { recursive: true }); } export type Provider = "twilio" | "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 !== "twilio" && input !== "web") { + throw new Error("Provider must be 'twilio' or 'web'"); + } } export function normalizePath(p: string): string { - if (!p.startsWith("/")) return `/${p}`; - return p; + if (!p.startsWith("/")) return `/${p}`; + return p; } export function withWhatsAppPrefix(number: string): string { - return number.startsWith("whatsapp:") ? number : `whatsapp:${number}`; + return number.startsWith("whatsapp:") ? number : `whatsapp:${number}`; } export function normalizeE164(number: string): string { - const withoutPrefix = number.replace(/^whatsapp:/, "").trim(); - const digits = withoutPrefix.replace(/[^\d+]/g, ""); - if (digits.startsWith("+")) return `+${digits.slice(1)}`; - return `+${digits}`; + const withoutPrefix = number.replace(/^whatsapp:/, "").trim(); + const digits = withoutPrefix.replace(/[^\d+]/g, ""); + if (digits.startsWith("+")) return `+${digits.slice(1)}`; + return `+${digits}`; } export function toWhatsappJid(number: string): string { - const e164 = normalizeE164(number); - const digits = e164.replace(/\D/g, ""); - return `${digits}@s.whatsapp.net`; + const e164 = normalizeE164(number); + const digits = e164.replace(/\D/g, ""); + return `${digits}@s.whatsapp.net`; } export function jidToE164(jid: string): string | null { - // Convert a WhatsApp JID (with optional device suffix, e.g. 1234:1@s.whatsapp.net) back to +1234. - const match = jid.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/); - if (!match) return null; - const digits = match[1]; - return `+${digits}`; + // Convert a WhatsApp JID (with optional device suffix, e.g. 1234:1@s.whatsapp.net) back to +1234. + const match = jid.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/); + if (!match) return null; + const digits = match[1]; + return `+${digits}`; } export function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } export const CONFIG_DIR = `${os.homedir()}/.warelay`; diff --git a/src/webhook/server.test.ts b/src/webhook/server.test.ts index cf27da0f4..ad8b62966 100644 --- a/src/webhook/server.test.ts +++ b/src/webhook/server.test.ts @@ -4,7 +4,7 @@ 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); - }); + it("re-exports startWebhook", () => { + expect(entry.startWebhook).toBe(impl.startWebhook); + }); }); diff --git a/src/webhook/update.test.ts b/src/webhook/update.test.ts index 621bac32f..a9766eeea 100644 --- a/src/webhook/update.test.ts +++ b/src/webhook/update.test.ts @@ -4,12 +4,12 @@ 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, - ); - }); + 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 index 59911ab17..047b8096b 100644 --- a/src/webhook/update.ts +++ b/src/webhook/update.ts @@ -1,7 +1,7 @@ /* istanbul ignore file */ export { - findIncomingNumberSid, - findMessagingServiceSid, - setMessagingServiceWebhook, - updateWebhook, + findIncomingNumberSid, + findMessagingServiceSid, + setMessagingServiceWebhook, + updateWebhook, } from "../twilio/update-webhook.js";