Release 0.1.3
This commit is contained in:
@@ -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.
|
||||
|
||||
15
README.md
15
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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.";
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
101
src/logging.ts
Normal 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;
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user