Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f81f432af5 | |||
| dda017df23 | |||
| 46be5eac7d | |||
| e4076d14c0 | |||
| 20fc412765 | |||
| c251681a40 | |||
| 9c25e15e92 | |||
| bcbf0de240 | |||
| 1ef7f4dbad | |||
| 9db969e94e | |||
| 4f27f4cf79 | |||
| b7c46909cd | |||
| 953f9af419 | |||
| 6ea32873df |
+26
-5
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -1,7 +1,14 @@
|
||||
# 📡 warelay — Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.
|
||||
[](https://github.com/steipete/warelay/actions/workflows/ci.yml)
|
||||
[](https://www.npmjs.com/package/warelay)
|
||||
[](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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.";
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = "";
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user