diff --git a/CHANGELOG.md b/CHANGELOG.md index 7affc760f..06f8f27ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ # Changelog -## [Unreleased] 0.1.3 +## 0.1.3 — 2025-11-25 ### Features - Added `cwd` option to command reply config for setting the working directory where commands execute. Essential for Claude Code to have proper project context. +- Added configurable file-based logging (default `/tmp/warelay/warelay.log`) with log level set via `logging.level` in `~/.warelay/warelay.json`; verbose still forces debug. ### Developer notes - Command auto-replies now pass `{ timeoutMs, cwd }` into the command runner; custom runners/tests that stub `runCommandWithTimeout` should accept the options object as well as the legacy numeric timeout. diff --git a/README.md b/README.md index 703e4e19a..89783aa25 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,19 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a } ``` +### Logging (optional) +- File logs are written to `/tmp/warelay/warelay.log` by default. Levels: `silent | fatal | error | warn | info | debug | trace` (CLI `--verbose` forces `debug`). Web-provider inbound/outbound entries include message bodies and auto-reply text for easier auditing. +- Override in `~/.warelay/warelay.json`: + +```json5 +{ + logging: { + level: "warn", + file: "/tmp/warelay/custom.log" + } +} +``` + ### Claude CLI setup (how we run it) 1) Install the official Claude CLI (e.g., `brew install anthropic-ai/cli/claude` or follow the Anthropic docs) and run `claude login` so it can read your API key. 2) In `warelay.json`, set `reply.mode` to `"command"` and point `command[0]` to `"claude"`; set `claudeOutputFormat` to `"text"` (or `"json"`/`"stream-json"` if you want warelay to parse and trim the JSON output). @@ -131,7 +144,7 @@ Templating tokens: `{{Body}}`, `{{BodyStripped}}`, `{{From}}`, `{{To}}`, `{{Mess ## FAQ & Safety - Twilio errors: **63016 “permission to send an SMS has not been enabled”** → ensure your number is WhatsApp-enabled; **63007 template not approved** → send a free-form session message within 24h or use an approved template; **63112 policy violation** → adjust content, shorten to <1600 chars, avoid links that trigger spam filters. Re-run `pnpm warelay status` to see the exact Twilio response body. -- Does this store my messages? warelay only writes `~/.warelay/warelay.json` (config), `~/.warelay/credentials/` (WhatsApp Web auth), and `~/.warelay/sessions.json` (session IDs + timestamps). It does **not** persist message bodies beyond the session store. Logs print to stdout/stderr; redirect or rotate if needed. +- Does this store my messages? warelay only writes `~/.warelay/warelay.json` (config), `~/.warelay/credentials/` (WhatsApp Web auth), and `~/.warelay/sessions.json` (session IDs + timestamps). It does **not** persist message bodies beyond the session store. Logs stream to stdout/stderr and also `/tmp/warelay/warelay.log` (configurable via `logging.file`). - Personal WhatsApp safety: Automation on personal accounts can be rate-limited or logged out by WhatsApp. Use `--provider web` sparingly, keep messages human-like, and re-run `login` if the session is dropped. - Limits to remember: WhatsApp text limit ~1600 chars; avoid rapid bursts—space sends by a few seconds; keep webhook replies under a couple seconds for good UX; command auto-replies time out after 600s by default. - Deploy / keep running: Use `tmux` or `screen` for ad-hoc (`tmux new -s warelay -- pnpm warelay relay --provider twilio`). For long-running hosts, wrap `pnpm warelay relay ...` or `pnpm warelay webhook --ingress tailscale ...` in a systemd service or macOS LaunchAgent; ensure environment variables are loaded in that context. diff --git a/package.json b/package.json index 343a9dff1..71b562dec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "warelay", - "version": "0.1.2", + "version": "0.1.3", "description": "WhatsApp relay CLI (send, monitor, webhook, auto-reply) using Twilio", "type": "module", "main": "dist/index.js", diff --git a/src/cli/program.ts b/src/cli/program.ts index e52e4e75a..ff9ef9ed5 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -18,7 +18,7 @@ import { spawnRelayTmux } from "./relay_tmux.js"; export function buildProgram() { const program = new Command(); - const PROGRAM_VERSION = "0.1.2"; + const PROGRAM_VERSION = "0.1.3"; const TAGLINE = "Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked."; diff --git a/src/config/config.ts b/src/config/config.ts index 4ba46a362..96c8121a3 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -19,7 +19,13 @@ export type SessionConfig = { sessionArgBeforeBody?: boolean; }; +export type LoggingConfig = { + 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:) reply?: { @@ -80,6 +86,22 @@ const ReplySchema = z ); 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(), diff --git a/src/logger.test.ts b/src/logger.test.ts index 98a0561c1..bbafec4a5 100644 --- a/src/logger.test.ts +++ b/src/logger.test.ts @@ -1,10 +1,22 @@ -import { describe, expect, it, vi } from "vitest"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; import { setVerbose } from "./globals.js"; import { logDebug, logError, logInfo, logSuccess, logWarn } from "./logger.js"; +import { resetLogger, setLoggerOverride } from "./logging.js"; import type { RuntimeEnv } from "./runtime.js"; describe("logger helpers", () => { + afterEach(() => { + resetLogger(); + setLoggerOverride(null); + setVerbose(false); + }); + it("formats messages through runtime log/error", () => { const log = vi.fn(); const error = vi.fn(); @@ -31,4 +43,40 @@ describe("logger helpers", () => { 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("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`); +} + +function cleanup(file: string) { + try { + fs.rmSync(file, { force: true }); + } catch { + // ignore + } +} diff --git a/src/logger.ts b/src/logger.ts index 4c06bd25e..b0f027ba9 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -6,14 +6,17 @@ import { 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); } export function logWarn(message: string, runtime: RuntimeEnv = defaultRuntime) { runtime.log(warn(message)); + getLogger().warn(message); } export function logSuccess( @@ -21,6 +24,7 @@ export function logSuccess( runtime: RuntimeEnv = defaultRuntime, ) { runtime.log(success(message)); + getLogger().info(message); } export function logError( @@ -28,9 +32,11 @@ export function logError( runtime: RuntimeEnv = defaultRuntime, ) { runtime.error(danger(message)); + getLogger().error(message); } export function logDebug(message: string) { - // Verbose helper that respects global verbosity flag. + // 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 new file mode 100644 index 000000000..8bcc0b064 --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,101 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import pino, { type Bindings, type LevelWithSilent, type Logger } from "pino"; +import { loadConfig, type WarelayConfig } from "./config/config.js"; +import { isVerbose } from "./globals.js"; + +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", +]; + +export type LoggerSettings = { + level?: LevelWithSilent; + file?: string; +}; + +type ResolvedSettings = { + level: LevelWithSilent; + file: string; +}; + +let cachedLogger: Logger | null = null; +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"; +} + +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 }; +} + +function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) { + 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, + ); +} + +export function getLogger(): Logger { + const settings = resolveSettings(); + if (!cachedLogger || settingsChanged(cachedSettings, settings)) { + cachedLogger = buildLogger(settings); + cachedSettings = settings; + } + return cachedLogger; +} + +export function getChildLogger( + bindings?: Bindings, + opts?: { level?: LevelWithSilent }, +): Logger { + return getLogger().child(bindings ?? {}, opts); +} + +// Test helpers +export function setLoggerOverride(settings: LoggerSettings | null) { + overrideSettings = settings; + cachedLogger = null; + cachedSettings = null; +} + +export function resetLogger() { + cachedLogger = null; + cachedSettings = null; + overrideSettings = null; +} diff --git a/src/provider-web.test.ts b/src/provider-web.test.ts index d69448ab2..8f73db051 100644 --- a/src/provider-web.test.ts +++ b/src/provider-web.test.ts @@ -1,5 +1,8 @@ +import crypto from "node:crypto"; import { EventEmitter } from "node:events"; import fsSync from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { MockBaileysSocket } from "../test/mocks/baileys.js"; import { createMockBaileys } from "../test/mocks/baileys.js"; @@ -39,6 +42,7 @@ vi.mock("qrcode-terminal", () => ({ })); import { monitorWebProvider } from "./index.js"; +import { resetLogger, setLoggerOverride } from "./logging.js"; import { createWaSocket, loginWeb, @@ -78,6 +82,8 @@ describe("provider-web", () => { afterEach(() => { vi.useRealTimers(); + resetLogger(); + setLoggerOverride(null); }); it("creates WA socket with QR handler", async () => { @@ -230,6 +236,37 @@ describe("provider-web", () => { 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 }); @@ -310,6 +347,44 @@ describe("provider-web", () => { 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") diff --git a/src/provider-web.ts b/src/provider-web.ts index 66132d0e6..a8e290c7f 100644 --- a/src/provider-web.ts +++ b/src/provider-web.ts @@ -13,12 +13,12 @@ import { useMultiFileAuthState, type WAMessage, } from "@whiskeysockets/baileys"; -import pino from "pino"; import qrcode from "qrcode-terminal"; import { getReplyFromConfig } from "./auto-reply/reply.js"; import { waitForever } from "./cli/wait.js"; import { danger, info, isVerbose, logVerbose, success } from "./globals.js"; import { logInfo } from "./logger.js"; +import { getChildLogger } from "./logging.js"; import { saveMediaBuffer } from "./media/store.js"; import { defaultRuntime, type RuntimeEnv } from "./runtime.js"; import type { Provider } from "./utils.js"; @@ -31,7 +31,12 @@ function formatDuration(ms: number) { const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "credentials"); export async function createWaSocket(printQr: boolean, verbose: boolean) { - const logger = pino({ level: verbose ? "info" : "silent" }); + 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") { @@ -48,7 +53,7 @@ export async function createWaSocket(printQr: boolean, verbose: boolean) { version, logger, printQRInTerminal: false, - browser: ["warelay", "cli", "0.1.2"], + browser: ["warelay", "cli", "0.1.3"], syncFullHistory: false, markOnlineOnConnect: false, }); @@ -246,6 +251,7 @@ export async function monitorWebInbox(options: { verbose: boolean; onMessage: (msg: WebInboundMessage) => Promise; }) { + const inboundLogger = getChildLogger({ module: "web-inbound" }); const sock = await createWaSocket(false, options.verbose); await waitForWaConnection(sock); try { @@ -333,6 +339,17 @@ export async function monitorWebInbox(options: { 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, @@ -373,6 +390,7 @@ export async function monitorWebProvider( replyResolver: typeof getReplyFromConfig = getReplyFromConfig, runtime: RuntimeEnv = defaultRuntime, ) { + const replyLogger = getChildLogger({ module: "web-auto-reply" }); // Listen for inbound personal WhatsApp Web messages and auto-reply if configured. const listener = await listenerFactory({ verbose, @@ -420,6 +438,17 @@ export async function monitorWebProvider( `✅ 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, + durationMs: Date.now() - replyStarted, + }, + "auto-reply sent (media)", + ); } catch (err) { console.error( danger(`Failed sending web media to ${msg.from}: ${String(err)}`), @@ -430,6 +459,17 @@ export async function monitorWebProvider( `⚠️ 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 { @@ -449,6 +489,16 @@ export async function monitorWebProvider( ), ); } + replyLogger.info( + { + to: msg.from, + from: msg.to, + text: replyResult.text ?? null, + mediaUrl: replyResult.mediaUrl, + durationMs, + }, + "auto-reply sent", + ); } catch (err) { console.error( danger(