Merge branch 'main' into fix/media-replies
This commit is contained in:
commit
04ea9e582a
13
CHANGELOG.md
13
CHANGELOG.md
@ -1,9 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## 1.3.0 — Unreleased
|
||||
## Unreleased
|
||||
|
||||
### Security
|
||||
- Hardened the relay IPC socket: now lives under `~/.warelay/ipc`, enforces 0700 dir / 0600 socket perms, rejects symlink or foreign-owned paths, and includes unit tests to lock in the behavior.
|
||||
- `warelay logout` now also prunes the shared session store (`~/.warelay/sessions.json`) alongside WhatsApp Web credentials, reducing leftover state after unlinking.
|
||||
- Logging now rolls daily to `/tmp/warelay/warelay-YYYY-MM-DD.log` (or custom dir) and prunes files older than 24h to reduce data retention.
|
||||
- Media server now rejects symlinked files and ensures resolved paths stay inside the media directory, closing traversal via symlinks; added regression test. (Thanks @joaohlisboa)
|
||||
|
||||
## 1.3.0 — 2025-12-02
|
||||
|
||||
### Highlights
|
||||
- **Pluggable agents (Claude, Pi, Codex, Opencode):** New `inbound.reply.agent` block chooses the CLI and parser per command reply; per-agent argv builders inject the right flags/identity/prompt handling and parse NDJSON streams, enabling Pi/Codex swaps without changing templates.
|
||||
- **Safety stop words for agents:** If an inbound message is exactly `stop`, `esc`, `abort`, `wait`, or `exit`, warelay immediately replies “Agent was aborted.”, kills the pending agent run, and marks the session so the next prompt is prefixed with a reminder that the previous run was aborted.
|
||||
- **Agent session reliability:** Only Claude currently returns a `session_id` that warelay persists; other agents (Gemini, Opencode, Codex, Pi) don’t emit stable session identifiers, so multi-turn continuity may reset between runs for those harnesses.
|
||||
|
||||
### Bug Fixes
|
||||
- **Empty result field handling:** Fixed bug where Claude CLI returning `result: ""` (empty string) would cause raw JSON to be sent to WhatsApp instead of being treated as valid empty output. Changed truthy check to explicit type check in `command-reply.ts`.
|
||||
@ -11,6 +21,7 @@
|
||||
- **User-visible error messages:** Command failures (non-zero exit, killed processes, exceptions) now return user-friendly error messages to WhatsApp instead of silently failing with empty responses.
|
||||
- **Test session isolation:** Fixed tests corrupting production `sessions.json` by mocking session persistence in all test files.
|
||||
- **Signal session corruption prevention:** Added IPC mechanism so `warelay send` and `warelay heartbeat` reuse the running relay's WhatsApp connection instead of creating new Baileys sockets. Previously, using these commands while the relay was running could corrupt the Signal session ratchet (both connections wrote to the same auth state), causing the relay's subsequent sends to fail silently.
|
||||
- **Web send media kinds:** `sendMessageWeb` now honors media kind when sending via WhatsApp Web: audio → PTT with correct opus mimetype, video → video, image → image, other → document with filename. Previously all media were sent as images, breaking audio/video/doc sends.
|
||||
|
||||
### Changes
|
||||
- **IPC server for relay:** The web relay now starts a Unix socket server at `~/.warelay/relay.sock`. Commands like `warelay send --provider web` automatically connect via IPC when the relay is running, falling back to direct connection otherwise.
|
||||
|
||||
14
README.md
14
README.md
@ -137,6 +137,16 @@ warelay supports running on the same phone number you message from—you chat wi
|
||||
}
|
||||
```
|
||||
|
||||
#### Abort trigger words
|
||||
- If an inbound body is exactly `stop`, `esc`, `abort`, `wait`, or `exit`, the command/agent run is skipped and the user immediately gets `Agent was aborted.`.
|
||||
- The session is tagged so the *next* prompt sent to the agent is prefixed with a short reminder that the previous run was aborted; the hint clears after that turn.
|
||||
|
||||
#### Agent choices
|
||||
- `inbound.reply.agent.kind` can be `claude`, `opencode`, `pi`, `codex`, or `gemini`.
|
||||
- Gemini CLI supports `--output-format text|json|stream-json`; warelay auto-adds it when you set `agent.format`.
|
||||
- Session defaults: Claude uses `--session-id/--resume`, Codex/Opencode/Pi use `--session`, and Gemini defaults to `--resume` for session resumes (new sessions need no flag). Override via `sessionArgNew/sessionArgResume` if you prefer custom flags.
|
||||
- Reliability note: only Claude reliably returns a `session_id` that warelay can persist and reuse. Other harnesses currently don’t emit a stable session identifier, so multi-turn continuity may reset between runs for those agents (Pi does not auto-compact, but still doesn’t expose a session id).
|
||||
|
||||
#### Heartbeat pings (command mode)
|
||||
- When `heartbeatMinutes` is set (default 10 for `mode: "command"`), the relay periodically runs your command/Claude session with a heartbeat prompt.
|
||||
- Heartbeat body is `HEARTBEAT ultrathink` (so the model can recognize the probe); if Claude replies exactly `HEARTBEAT_OK`, the message is suppressed; otherwise the reply (or media) is forwarded. Suppressions are still logged so you know the heartbeat ran.
|
||||
@ -145,7 +155,7 @@ warelay supports running on the same phone number you message from—you chat wi
|
||||
- When multiple active sessions exist, `warelay heartbeat` requires `--to <E.164>` or `--all`; if `allowFrom` is just `"*"`, you must choose a target with one of those flags.
|
||||
|
||||
### 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.
|
||||
- File logs are written to `/tmp/warelay/warelay-YYYY-MM-DD.log` by default (rotated daily; files older than 24h are pruned). 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
|
||||
@ -208,7 +218,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 stream to stdout/stderr and also `/tmp/warelay/warelay.log` (configurable via `logging.file`).
|
||||
- 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-YYYY-MM-DD.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.
|
||||
|
||||
@ -7,7 +7,11 @@ 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 {
|
||||
DEFAULT_LOG_DIR,
|
||||
resetLogger,
|
||||
setLoggerOverride,
|
||||
} from "./logging.js";
|
||||
import type { RuntimeEnv } from "./runtime.js";
|
||||
|
||||
describe("logger helpers", () => {
|
||||
@ -67,6 +71,28 @@ describe("logger helpers", () => {
|
||||
expect(content).toContain("warn-only");
|
||||
cleanup(logPath);
|
||||
});
|
||||
|
||||
it("uses daily rolling default log file and prunes old ones", () => {
|
||||
resetLogger();
|
||||
setLoggerOverride({}); // force defaults regardless of user config
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const todayPath = path.join(DEFAULT_LOG_DIR, `warelay-${today}.log`);
|
||||
|
||||
// create an old file to be pruned
|
||||
const oldPath = path.join(DEFAULT_LOG_DIR, "warelay-2000-01-01.log");
|
||||
fs.mkdirSync(DEFAULT_LOG_DIR, { recursive: true });
|
||||
fs.writeFileSync(oldPath, "old");
|
||||
fs.utimesSync(oldPath, new Date(0), new Date(0));
|
||||
cleanup(todayPath);
|
||||
|
||||
logInfo("roll-me");
|
||||
|
||||
expect(fs.existsSync(todayPath)).toBe(true);
|
||||
expect(fs.readFileSync(todayPath, "utf-8")).toContain("roll-me");
|
||||
expect(fs.existsSync(oldPath)).toBe(false);
|
||||
|
||||
cleanup(todayPath);
|
||||
});
|
||||
});
|
||||
|
||||
function pathForTest() {
|
||||
|
||||
@ -6,8 +6,12 @@ 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");
|
||||
export const DEFAULT_LOG_DIR = path.join(os.tmpdir(), "warelay");
|
||||
export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "warelay.log"); // legacy single-file path
|
||||
|
||||
const LOG_PREFIX = "warelay";
|
||||
const LOG_SUFFIX = ".log";
|
||||
const MAX_LOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
||||
|
||||
const ALLOWED_LEVELS: readonly LevelWithSilent[] = [
|
||||
"silent",
|
||||
@ -46,7 +50,7 @@ function resolveSettings(): ResolvedSettings {
|
||||
const cfg: WarelayConfig["logging"] | undefined =
|
||||
overrideSettings ?? loadConfig().logging;
|
||||
const level = normalizeLevel(cfg?.level);
|
||||
const file = cfg?.file ?? DEFAULT_LOG_FILE;
|
||||
const file = cfg?.file ?? defaultRollingPathForToday();
|
||||
return { level, file };
|
||||
}
|
||||
|
||||
@ -57,6 +61,10 @@ function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) {
|
||||
|
||||
function buildLogger(settings: ResolvedSettings): Logger {
|
||||
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
|
||||
// Clean up stale rolling logs when using a dated log filename.
|
||||
if (isRollingPath(settings.file)) {
|
||||
pruneOldRollingLogs(path.dirname(settings.file));
|
||||
}
|
||||
const destination = pino.destination({
|
||||
dest: settings.file,
|
||||
mkdir: true,
|
||||
@ -104,3 +112,39 @@ export function resetLogger() {
|
||||
cachedSettings = null;
|
||||
overrideSettings = null;
|
||||
}
|
||||
|
||||
function defaultRollingPathForToday(): string {
|
||||
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
return path.join(DEFAULT_LOG_DIR, `${LOG_PREFIX}-${today}${LOG_SUFFIX}`);
|
||||
}
|
||||
|
||||
function isRollingPath(file: string): boolean {
|
||||
const base = path.basename(file);
|
||||
return (
|
||||
base.startsWith(`${LOG_PREFIX}-`) &&
|
||||
base.endsWith(LOG_SUFFIX) &&
|
||||
base.length === `${LOG_PREFIX}-YYYY-MM-DD${LOG_SUFFIX}`.length
|
||||
);
|
||||
}
|
||||
|
||||
function pruneOldRollingLogs(dir: string): void {
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const cutoff = Date.now() - MAX_LOG_AGE_MS;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.startsWith(`${LOG_PREFIX}-`) || !entry.name.endsWith(LOG_SUFFIX)) continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(fullPath, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore errors during pruning
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore missing dir or read errors
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,4 +49,27 @@ describe("media server", () => {
|
||||
await expect(fs.stat(file)).rejects.toThrow();
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
|
||||
it("blocks path traversal attempts", async () => {
|
||||
const server = await startMediaServer(0, 5_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
// URL-encoded "../" to bypass client-side path normalization
|
||||
const res = await fetch(`http://localhost:${port}/media/%2e%2e%2fpackage.json`);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.text()).toBe("invalid path");
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
|
||||
it("blocks symlink escaping outside media dir", async () => {
|
||||
const target = path.join(process.cwd(), "package.json"); // outside MEDIA_DIR
|
||||
const link = path.join(MEDIA_DIR, "link-out");
|
||||
await fs.symlink(target, link);
|
||||
|
||||
const server = await startMediaServer(0, 5_000);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const res = await fetch(`http://localhost:${port}/media/link-out`);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.text()).toBe("invalid path");
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
});
|
||||
|
||||
@ -18,11 +18,23 @@ export function attachMediaRoutes(
|
||||
|
||||
app.get("/media/:id", async (req, res) => {
|
||||
const id = req.params.id;
|
||||
const file = path.join(mediaDir, id);
|
||||
const mediaRoot = (await fs.realpath(mediaDir)) + path.sep;
|
||||
const file = path.resolve(mediaRoot, id);
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(file);
|
||||
const lstat = await fs.lstat(file);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
res.status(400).send("invalid path");
|
||||
return;
|
||||
}
|
||||
const realPath = await fs.realpath(file);
|
||||
if (!realPath.startsWith(mediaRoot)) {
|
||||
res.status(400).send("invalid path");
|
||||
return;
|
||||
}
|
||||
const stat = await fs.stat(realPath);
|
||||
if (Date.now() - stat.mtimeMs > ttlMs) {
|
||||
await fs.rm(file).catch(() => {});
|
||||
await fs.rm(realPath).catch(() => {});
|
||||
res.status(410).send("expired");
|
||||
return;
|
||||
}
|
||||
|
||||
63
src/web/ipc.test.ts
Normal file
63
src/web/ipc.test.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../logging.js", () => ({
|
||||
getChildLogger: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = originalHome;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("ipc hardening", () => {
|
||||
it("creates private socket dir and socket with tight perms", async () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "warelay-home-"));
|
||||
process.env.HOME = tmpHome;
|
||||
vi.resetModules();
|
||||
|
||||
const ipc = await import("./ipc.js");
|
||||
|
||||
const sendHandler = vi.fn().mockResolvedValue({ messageId: "msg1" });
|
||||
ipc.startIpcServer(sendHandler);
|
||||
|
||||
const dirStat = fs.lstatSync(path.join(tmpHome, ".warelay", "ipc"));
|
||||
expect(dirStat.mode & 0o777).toBe(0o700);
|
||||
|
||||
expect(ipc.isRelayRunning()).toBe(true);
|
||||
|
||||
const socketStat = fs.lstatSync(ipc.getSocketPath());
|
||||
expect(socketStat.isSocket()).toBe(true);
|
||||
if (typeof process.getuid === "function") {
|
||||
expect(socketStat.uid).toBe(process.getuid());
|
||||
}
|
||||
|
||||
ipc.stopIpcServer();
|
||||
expect(ipc.isRelayRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses to start when IPC dir is a symlink", async () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "warelay-home-"));
|
||||
const warelayDir = path.join(tmpHome, ".warelay");
|
||||
fs.mkdirSync(warelayDir, { recursive: true });
|
||||
fs.symlinkSync("/tmp", path.join(warelayDir, "ipc"));
|
||||
|
||||
process.env.HOME = tmpHome;
|
||||
vi.resetModules();
|
||||
|
||||
const ipc = await import("./ipc.js");
|
||||
const sendHandler = vi.fn().mockResolvedValue({ messageId: "msg1" });
|
||||
|
||||
expect(() => ipc.startIpcServer(sendHandler)).toThrow(/symlink/i);
|
||||
});
|
||||
});
|
||||
@ -15,7 +15,8 @@ import path from "node:path";
|
||||
|
||||
import { getChildLogger } from "../logging.js";
|
||||
|
||||
const SOCKET_PATH = path.join(os.homedir(), ".warelay", "relay.sock");
|
||||
const SOCKET_DIR = path.join(os.homedir(), ".warelay", "ipc");
|
||||
const SOCKET_PATH = path.join(SOCKET_DIR, "relay.sock");
|
||||
|
||||
export interface IpcSendRequest {
|
||||
type: "send";
|
||||
@ -44,11 +45,21 @@ let server: net.Server | null = null;
|
||||
export function startIpcServer(sendHandler: SendHandler): void {
|
||||
const logger = getChildLogger({ module: "ipc-server" });
|
||||
|
||||
// Clean up stale socket file
|
||||
ensureSocketDir();
|
||||
try {
|
||||
assertSafeSocketPath(SOCKET_PATH);
|
||||
} catch (err) {
|
||||
logger.error({ error: String(err) }, "Refusing to start IPC server");
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Clean up stale socket file (only if safe to do so)
|
||||
try {
|
||||
fs.unlinkSync(SOCKET_PATH);
|
||||
} catch {
|
||||
// Ignore if doesn't exist
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
server = net.createServer((conn) => {
|
||||
@ -134,6 +145,7 @@ export function stopIpcServer(): void {
|
||||
*/
|
||||
export function isRelayRunning(): boolean {
|
||||
try {
|
||||
assertSafeSocketPath(SOCKET_PATH);
|
||||
fs.accessSync(SOCKET_PATH);
|
||||
return true;
|
||||
} catch {
|
||||
@ -223,3 +235,43 @@ export async function sendViaIpc(
|
||||
export function getSocketPath(): string {
|
||||
return SOCKET_PATH;
|
||||
}
|
||||
|
||||
function ensureSocketDir(): void {
|
||||
try {
|
||||
const stat = fs.lstatSync(SOCKET_DIR);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`IPC dir is a symlink: ${SOCKET_DIR}`);
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`IPC dir is not a directory: ${SOCKET_DIR}`);
|
||||
}
|
||||
// Enforce private permissions
|
||||
fs.chmodSync(SOCKET_DIR, 0o700);
|
||||
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
||||
throw new Error(`IPC dir owned by different user: ${SOCKET_DIR}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
fs.mkdirSync(SOCKET_DIR, { recursive: true, mode: 0o700 });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeSocketPath(socketPath: string): void {
|
||||
try {
|
||||
const stat = fs.lstatSync(socketPath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`Refusing IPC socket symlink: ${socketPath}`);
|
||||
}
|
||||
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
||||
throw new Error(`IPC socket owned by different user: ${socketPath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return; // Missing is fine; creation will happen next.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,6 +35,8 @@ describe("web logout", () => {
|
||||
const credsDir = path.join(tmpDir, ".warelay", "credentials");
|
||||
fs.mkdirSync(credsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(credsDir, "creds.json"), "{}");
|
||||
const sessionsPath = path.join(tmpDir, ".warelay", "sessions.json");
|
||||
fs.writeFileSync(sessionsPath, "{}");
|
||||
const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js");
|
||||
|
||||
expect(WA_WEB_AUTH_DIR.startsWith(tmpDir)).toBe(true);
|
||||
@ -42,6 +44,7 @@ describe("web logout", () => {
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.existsSync(credsDir)).toBe(false);
|
||||
expect(fs.existsSync(sessionsPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("no-ops when nothing to delete", async () => {
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
} from "@whiskeysockets/baileys";
|
||||
import qrcode from "qrcode-terminal";
|
||||
|
||||
import { SESSION_STORE_DEFAULT } from "../config/sessions.js";
|
||||
import { danger, info, success } from "../globals.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
@ -160,6 +161,8 @@ export async function logoutWeb(runtime: RuntimeEnv = defaultRuntime) {
|
||||
return false;
|
||||
}
|
||||
await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true });
|
||||
// Also drop session store to clear lingering per-sender state after logout.
|
||||
await fs.rm(SESSION_STORE_DEFAULT, { force: true });
|
||||
runtime.log(
|
||||
success(
|
||||
"Cleared WhatsApp Web credentials. Run `warelay login --provider web` to relink.",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user