Compare commits

...

14 Commits

Author SHA1 Message Date
Peter Steinberger f81f432af5 Release 1.0.4
CI / build (push) Failing after 35s
2025-11-25 18:12:44 +01:00
Peter Steinberger dda017df23 Web relay: auto-reconnect Baileys and test 2025-11-25 18:09:57 +01:00
Peter Steinberger 46be5eac7d Auto-reply: send timeout fallback and tests 2025-11-25 17:52:57 +01:00
Peter Steinberger e4076d14c0 Docs: clarify release body should not repeat title 2025-11-25 17:11:24 +01:00
Peter Steinberger 20fc412765 Refactor: derive version from package.json 2025-11-25 17:10:53 +01:00
Peter Steinberger c251681a40 Chore: prep 0.1.4 unreleased placeholder and release guardrails 2025-11-25 17:08:13 +01:00
Peter Steinberger 9c25e15e92 Release 0.1.3
CI / build (push) Failing after 34s
2025-11-25 16:53:30 +01:00
Peter Steinberger bcbf0de240 Add cwd option for command replies 2025-11-25 16:19:24 +01:00
Peter Steinberger 1ef7f4dbad Update README.md 2025-11-25 14:52:43 +01:00
Peter Steinberger 9db969e94e docs: simplify FAQ heading 2025-11-25 14:51:20 +01:00
Peter Steinberger 4f27f4cf79 chore: trim 0.1.2 changelog entry 2025-11-25 14:50:34 +01:00
Peter Steinberger b7c46909cd chore: reorder changelog and add 0.1.3 stub 2025-11-25 14:47:48 +01:00
Peter Steinberger 953f9af419 docs: restyle README badges below header 2025-11-25 14:42:12 +01:00
Peter Steinberger 6ea32873df docs: add README header image 2025-11-25 14:29:21 +01:00
17 changed files with 689 additions and 108 deletions
+26 -5
View File
@@ -1,5 +1,31 @@
# Changelog
## [Unreleased] 1.0.5
### Pending
- (add entries here)
## 1.0.4 — 2025-11-25
### Changes
- Auto-replies now send a WhatsApp fallback message when a command/Claude run hits the timeout, including up to 800 chars of partial stdout so the user still sees progress.
- Added tests covering the new timeout fallback behavior and partial-output truncation.
- Web relay auto-reconnects after Baileys/WebSocket drops (with log-out detection) and exposes close events for monitoring; added tests for close propagation and reconnect loop.
## 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.
## 0.1.2 — 2025-11-25
### CI/build fix
- Fixed commander help configuration (`subcommandTerm`) so TypeScript builds pass in CI.
## 0.1.1 — 2025-11-25
### CLI polish
@@ -8,11 +34,6 @@
- `send` and `status` gained a `--verbose` flag for consistent noisy output when debugging.
- Lowercased branding in docs/UA; web provider UA is `warelay/cli/0.1.1`.
## 0.1.2 — 2025-11-25
### CI/build fix
- Fixed commander help configuration (`subcommandTerm`) so TypeScript builds pass in CI.
- Bumped version/UA to 0.1.2; no functional changes beyond the CI fix.
## 0.1.0 — 2025-11-25
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+26 -6
View File
@@ -1,7 +1,14 @@
# 📡 warelay — Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.
[![CI](https://github.com/steipete/warelay/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/steipete/warelay/actions/workflows/ci.yml)
[![npm version](https://img.shields.io/npm/v/warelay.svg)](https://www.npmjs.com/package/warelay)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
# 📡 warelay — Send, receive, and auto-reply on WhatsApp.
<p align="center">
<img src="README-header.png" alt="warelay header" width="640">
</p>
<p align="center">
<a href="https://github.com/steipete/warelay/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/warelay/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
<a href="https://www.npmjs.com/package/warelay"><img src="https://img.shields.io/npm/v/warelay.svg?style=for-the-badge" alt="npm version"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p>
Send, receive, auto-reply, and inspect WhatsApp messages over **Twilio** or your personal **WhatsApp Web** session. Ships with a one-command webhook setup (Tailscale Funnel + Twilio callback) and a configurable auto-reply engine (plain text or command/Claude driven).
@@ -84,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).
@@ -122,9 +142,9 @@ Templating tokens: `{{Body}}`, `{{BodyStripped}}`, `{{From}}`, `{{To}}`, `{{Mess
- Web provider dropped: rerun `pnpm warelay login`; credentials live in `~/.warelay/credentials/`.
- Tailscale Funnel errors: update tailscale/tailscaled; check admin console that Funnel is enabled for this device.
## FAQ & Safety (quick answers)
## 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 -1
View File
@@ -30,6 +30,6 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
6) **Post-publish**
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
- [ ] Create/refresh the GitHub release for `vX.Y.Z`; body should be the product-facing bullets from the changelog; attach the `npm pack` tarball + checksums if you generated them.
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `warelay X.Y.Z`** (not just the tag); body should inline the product-facing bullets from the changelog (no bare links) **and must not repeat the title inside the body**; attach the `npm pack` tarball + checksums if you generated them.
- [ ] From a clean temp directory (no `package.json`), run `npx -y warelay@X.Y.Z send --help` to confirm install/CLI entrypoints work.
- [ ] Announce/share release notes.
+3
View File
@@ -21,6 +21,8 @@ warelay reads `~/.warelay/warelay.json` (JSON5 accepted). Add a command-mode rep
allowFrom: ["+15551234567"],
reply: {
mode: "command",
// Working directory for command execution (useful for Claude Code project context).
cwd: "/Users/you/Projects/my-project",
// Prepended before the inbound body; good for system prompts.
bodyPrefix: "You are a concise WhatsApp assistant. Keep replies under 1500 characters.\n\n",
// Claude CLI argv; the final element is the prompt/body provided by warelay.
@@ -38,6 +40,7 @@ warelay reads `~/.warelay/warelay.json` (JSON5 accepted). Add a command-mode rep
```
Notes on this configuration:
- `cwd` sets the working directory where the command runs. This is essential for Claude Code to have the right project context—Claude will see the project's `CLAUDE.md`, have access to project files, and understand the codebase structure.
- warelay automatically injects a Claude identity prefix and the correct `--output-format`/`-p` flags when `command[0]` is `claude` and `claudeOutputFormat` is set.
- Sessions are stored in `~/.warelay/sessions.json`; `scope: per-sender` keeps separate threads for each contact.
- `bodyPrefix` is added before the inbound message body that reaches Claude. The string above mirrors the built-in 1500-character WhatsApp guardrail.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "warelay",
"version": "0.1.2",
"version": "1.0.4",
"description": "WhatsApp relay CLI (send, monitor, webhook, auto-reply) using Twilio",
"type": "module",
"main": "dist/index.js",
+12 -2
View File
@@ -288,11 +288,13 @@ export async function getReplyFromConfig(
[CLAUDE_IDENTITY_PREFIX, existingBody].filter(Boolean).join("\n\n"),
];
}
logVerbose(`Running command auto-reply: ${finalArgv.join(" ")}`);
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),
() => commandRunner(finalArgv, { timeoutMs, cwd: reply.cwd }),
{
onWait: (waitMs, queuedAhead) => {
if (isVerbose()) {
@@ -384,6 +386,14 @@ export async function getReplyFromConfig(
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;
return { text };
} else {
logError(
`Command auto-reply failed after ${elapsed}ms: ${String(err)}`,
+2 -1
View File
@@ -8,6 +8,7 @@ import { danger, info, setVerbose, setYes, warn } from "../globals.js";
import { loginWeb, monitorWebProvider, pickProvider } from "../provider-web.js";
import { defaultRuntime } from "../runtime.js";
import type { Provider } from "../utils.js";
import { VERSION } from "../version.js";
import {
createDefaultDeps,
logTwilioFrom,
@@ -18,7 +19,7 @@ import { spawnRelayTmux } from "./relay_tmux.js";
export function buildProgram() {
const program = new Command();
const PROGRAM_VERSION = "0.1.2";
const PROGRAM_VERSION = VERSION;
const TAGLINE =
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
+24
View File
@@ -19,13 +19,20 @@ 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?: {
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
@@ -43,6 +50,7 @@ const ReplySchema = z
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(),
@@ -78,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(),
+60
View File
@@ -300,6 +300,66 @@ describe("config and templating", () => {
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,
},
},
};
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);
});
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,
);
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",
+49 -1
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
}
}
+7 -1
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
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;
}
+13 -1
View File
@@ -43,14 +43,26 @@ export type SpawnResult = {
killed: boolean;
};
export type CommandOptions = {
timeoutMs: number;
cwd?: string;
};
export async function runCommandWithTimeout(
argv: string[],
timeoutMs: number,
optionsOrTimeout: number | CommandOptions,
): Promise<SpawnResult> {
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 = "";
+135
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,54 @@ describe("provider-web", () => {
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 });
@@ -263,6 +317,49 @@ describe("provider-web", () => {
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<void>((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);
@@ -310,6 +407,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")
+222 -89
View File
@@ -13,16 +13,17 @@ 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";
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`;
@@ -31,7 +32,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 +54,7 @@ export async function createWaSocket(printQr: boolean, verbose: boolean) {
version,
logger,
printQRInTerminal: false,
browser: ["warelay", "cli", "0.1.2"],
browser: ["warelay", "cli", VERSION],
syncFullHistory: false,
markOnlineOnConnect: false,
});
@@ -223,6 +229,12 @@ export function webAuthExists() {
.catch(() => false);
}
type WebListenerCloseReason = {
status?: number;
isLoggedOut: boolean;
error?: unknown;
};
export type WebInboundMessage = {
id?: string;
from: string;
@@ -246,8 +258,13 @@ 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);
let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null;
const onClose = new Promise<WebListenerCloseReason>((resolve) => {
onCloseResolve = resolve;
});
try {
// Advertise that the relay is online right after connecting.
await sock.sendPresenceUpdate("available");
@@ -333,6 +350,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,
@@ -355,6 +383,20 @@ export async function monitorWebInbox(options: {
}
});
sock.ev.on(
"connection.update",
(update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
if (update.connection === "close") {
const status = getStatusCode(update.lastDisconnect?.error);
onCloseResolve?.({
status,
isLoggedOut: status === DisconnectReason.loggedOut,
error: update.lastDisconnect?.error,
});
}
},
);
return {
close: async () => {
try {
@@ -363,6 +405,7 @@ export async function monitorWebInbox(options: {
logVerbose(`Socket close failed: ${String(err)}`);
}
},
onClose,
};
}
@@ -372,106 +415,196 @@ export async function monitorWebProvider(
keepAlive = true,
replyResolver: typeof getReplyFromConfig = getReplyFromConfig,
runtime: RuntimeEnv = defaultRuntime,
abortSignal?: AbortSignal,
) {
// Listen for inbound personal WhatsApp Web messages and auto-reply if configured.
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 replyLogger = getChildLogger({ module: "web-auto-reply" });
const stopRequested = () => abortSignal?.aborted === true;
const abortPromise =
abortSignal &&
new Promise<"aborted">((resolve) =>
abortSignal.addEventListener("abort", () => resolve("aborted"), {
once: true,
}),
);
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);
if (isVerbose()) {
logVerbose(
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
);
}
await msg.sendMedia({
image: media.buffer,
caption: replyResult.text || undefined,
mimetype: media.contentType,
});
logInfo(
`✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
runtime,
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
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 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}`,
);
} catch (err) {
console.error(
danger(`Failed sending web media to ${msg.from}: ${String(err)}`),
);
if (replyResult.text) {
await msg.reply(replyResult.text);
try {
const media = await loadWebMedia(replyResult.mediaUrl);
if (isVerbose()) {
logVerbose(
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
);
}
await msg.sendMedia({
image: media.buffer,
caption: replyResult.text || undefined,
mimetype: media.contentType,
});
logInfo(
`⚠️ Media skipped; sent text-only to ${msg.from}`,
`✅ 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)}`,
),
);
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 ?? "");
}
} 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)})`,
),
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 ?? "<media>"}${replyResult.mediaUrl ? " (media)" : ""}`,
),
);
}
replyLogger.info(
{
to: msg.from,
from: msg.to,
text: replyResult.text ?? null,
mediaUrl: replyResult.mediaUrl,
durationMs,
},
"auto-reply sent",
);
} else {
console.log(
success(
`↩️ ${replyResult.text ?? "<media>"}${replyResult.mediaUrl ? " (media)" : ""}`,
} catch (err) {
console.error(
danger(
`Failed sending web auto-reply to ${msg.from}: ${String(err)}`,
),
);
}
} catch (err) {
console.error(
danger(
`Failed sending web auto-reply to ${msg.from}: ${String(err)}`,
),
);
}
},
});
logInfo(
"📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.",
runtime,
);
process.on("SIGINT", () => {
void listener.close().finally(() => {
logInfo("👋 Web monitor stopped", runtime);
runtime.exit(0);
},
});
});
if (keepAlive) {
await 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 (!keepAlive) return;
const reason = await Promise.race([
listener.onClose ?? waitForever(),
abortPromise ?? waitForever(),
]);
if (stopRequested() || stop || reason === "aborted") {
await listener.close();
break;
}
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);
}
}
+7
View File
@@ -0,0 +1,7 @@
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const pkg = require("../package.json") as { version?: string };
// Single source of truth for the current warelay version (reads from package.json).
export const VERSION = pkg.version ?? "0.0.0";