Release 0.1.3

This commit is contained in:
Peter Steinberger
2025-11-25 16:53:30 +01:00
parent bcbf0de240
commit 9c25e15e92
10 changed files with 325 additions and 9 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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",

View File

@@ -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.";

View File

@@ -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(),

View File

@@ -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
}
}

View File

@@ -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);
}

101
src/logging.ts Normal file
View File

@@ -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;
}

View File

@@ -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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (
msg: import("./provider-web.js").WebInboundMessage,
) => Promise<void>;
}) => {
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")

View File

@@ -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<string, unknown>;
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<void>;
}) {
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(