build: add local node bin to restart script PATH
This commit is contained in:
@@ -8,6 +8,7 @@ First Clawdis release after the Warelay rebrand. This is a semver-major because
|
|||||||
- Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run.
|
- Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run.
|
||||||
- Pi/Tau only: `inbound.reply.agent.kind` accepts only `"pi"`, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
|
- Pi/Tau only: `inbound.reply.agent.kind` accepts only `"pi"`, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
|
||||||
- WhatsApp Web is the only transport; Twilio support and related CLI flags/tests were removed.
|
- WhatsApp Web is the only transport; Twilio support and related CLI flags/tests were removed.
|
||||||
|
- Direct chats now collapse into a single `main` session by default (no config needed); groups stay isolated as `group:<jid>`.
|
||||||
|
|
||||||
### macOS companion app
|
### macOS companion app
|
||||||
- **Clawdis.app menu bar companion**: packaged, signed bundle with relay start/stop, launchd toggle, project-root and pnpm/node auto-resolution, live log shortcut, restart button, and status/recipient table plus badges/dimming for attention and paused states.
|
- **Clawdis.app menu bar companion**: packaged, signed bundle with relay start/stop, launchd toggle, project-root and pnpm/node auto-resolution, live log shortcut, restart button, and status/recipient table plus badges/dimming for attention and paused states.
|
||||||
@@ -32,6 +33,10 @@ First Clawdis release after the Warelay rebrand. This is a semver-major because
|
|||||||
- Session store purged on logout; IPC socket directory permissions tightened (0700/0600).
|
- Session store purged on logout; IPC socket directory permissions tightened (0700/0600).
|
||||||
- Launchd PATH and helper lookup hardened for packaged macOS builds; health probes surface missing binaries quickly.
|
- Launchd PATH and helper lookup hardened for packaged macOS builds; health probes surface missing binaries quickly.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- Added `docs/telegram.md` outlining the upcoming Telegram Bot API provider (grammY-based) and how it will share the `main` session.
|
||||||
|
- CLI now exposes `relay:telegram` and text/media sends via `--provider telegram`; typing/webhook still pending.
|
||||||
|
|
||||||
## 1.5.0 — 2025-12-05
|
## 1.5.0 — 2025-12-05
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ Create `~/.clawdis/clawdis.json`:
|
|||||||
- [Security](./docs/security.md)
|
- [Security](./docs/security.md)
|
||||||
- [Troubleshooting](./docs/troubleshooting.md)
|
- [Troubleshooting](./docs/troubleshooting.md)
|
||||||
- [The Lore](./docs/lore.md) 🦞
|
- [The Lore](./docs/lore.md) 🦞
|
||||||
|
- [Telegram (Bot API) — WIP](./docs/telegram.md)
|
||||||
|
|
||||||
## Clawd
|
## Clawd
|
||||||
|
|
||||||
@@ -119,14 +120,18 @@ clawdis login # Scan QR code
|
|||||||
clawdis relay # Start listening
|
clawdis relay # Start listening
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Telegram (Bot API) — WIP
|
||||||
|
Bot-mode support (long-poll) shares the same `main` session as WhatsApp/WebChat, with groups kept isolated. Text and media send work via `clawdis send --provider telegram`; a relay is available via `clawdis relay:telegram` (TELEGRAM_BOT_TOKEN or telegram.botToken in config). See `docs/telegram.md` for current limits and setup.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `clawdis login` | Link WhatsApp Web via QR |
|
| `clawdis login` | Link WhatsApp Web via QR |
|
||||||
| `clawdis send` | Send a message |
|
| `clawdis send` | Send a message (WhatsApp default; `--provider telegram` for bot mode, text + media) |
|
||||||
| `clawdis agent` | Talk directly to the agent (no WhatsApp send) |
|
| `clawdis agent` | Talk directly to the agent (no WhatsApp send) |
|
||||||
| `clawdis relay` | Start auto-reply loop |
|
| `clawdis relay` | Start auto-reply loop |
|
||||||
|
| `clawdis relay:telegram` | Start Telegram bot long-poll relay (Bot API) |
|
||||||
| `clawdis status` | Web session health + session store summary |
|
| `clawdis status` | Web session health + session store summary |
|
||||||
| `clawdis heartbeat` | Trigger a heartbeat |
|
| `clawdis heartbeat` | Trigger a heartbeat |
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
# Session Management
|
# Session Management
|
||||||
|
|
||||||
Clawdis treats **one session as primary**. By default the canonical key is `main`; set `inbound.reply.session.mainKey` to change it. Older/local sessions can stay on disk, but only the primary key is used for desktop/web chat and direct agent calls.
|
Clawdis treats **one session as primary**. By default the canonical key is `main` for every direct chat; no configuration is required. You can rename it via `inbound.reply.session.mainKey` if you really want, but there is still only a single primary session. Older/local sessions can stay on disk, but only the primary key is used for desktop/web chat and direct agent calls.
|
||||||
|
|
||||||
## Where state lives
|
## Where state lives
|
||||||
- Store file: `~/.clawdis/sessions/sessions.json` (legacy: `~/.clawdis/sessions.json`).
|
- Store file: `~/.clawdis/sessions/sessions.json` (legacy: `~/.clawdis/sessions.json`).
|
||||||
@@ -17,7 +17,7 @@ Clawdis treats **one session as primary**. By default the canonical key is `main
|
|||||||
- Reset triggers: exact `/new` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through.
|
- Reset triggers: exact `/new` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through.
|
||||||
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
||||||
|
|
||||||
## Configuration (primary-only example)
|
## Configuration (optional rename example)
|
||||||
```json5
|
```json5
|
||||||
// ~/.clawdis/clawdis.json
|
// ~/.clawdis/clawdis.json
|
||||||
{
|
{
|
||||||
@@ -28,7 +28,7 @@ Clawdis treats **one session as primary**. By default the canonical key is `main
|
|||||||
idleMinutes: 120,
|
idleMinutes: 120,
|
||||||
resetTriggers: ["/new"],
|
resetTriggers: ["/new"],
|
||||||
store: "~/.clawdis/sessions/sessions.json",
|
store: "~/.clawdis/sessions/sessions.json",
|
||||||
mainKey: "main" // marks the primary session
|
mainKey: "main" // optional rename; still a single primary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# Surfaces & Routing
|
# Surfaces & Routing
|
||||||
|
|
||||||
Updated: 2025-12-06
|
Updated: 2025-12-07
|
||||||
|
|
||||||
Goal: make replies deterministic per channel while keeping one shared context for direct chats.
|
Goal: make replies deterministic per channel while keeping one shared context for direct chats.
|
||||||
|
|
||||||
- **Surfaces** (channel labels): `whatsapp`, `webchat`, `telegram`, `voice`, etc. Add `Surface` to inbound `MsgContext` so templates/agents can log which channel a turn came from. Routing is fixed: replies go back to the origin surface; the model doesn’t choose.
|
- **Surfaces** (channel labels): `whatsapp`, `webchat`, `telegram`, `voice`, etc. Add `Surface` to inbound `MsgContext` so templates/agents can log which channel a turn came from. Routing is fixed: replies go back to the origin surface; the model doesn’t choose.
|
||||||
- **Canonical direct session:** Direct chats collapse into `main` by default via `inbound.reply.session.mainKey` (configurable). Groups stay `group:<jid>`. This keeps context unified across WhatsApp/WebChat/Telegram while preserving group isolation.
|
- **Canonical direct session:** All direct chats collapse into the single `main` session by default (no config needed). Groups stay `group:<jid>`, so they remain isolated.
|
||||||
- **Session store:** Keys are resolved via `resolveSessionKey(scope, ctx, mainKey)`; the Tau JSONL path still lives under `~/.clawdis/sessions/<SessionId>.jsonl`.
|
- **Session store:** Keys are resolved via `resolveSessionKey(scope, ctx, mainKey)`; the Tau JSONL path still lives under `~/.clawdis/sessions/<SessionId>.jsonl`.
|
||||||
- **WebChat:** Always attaches to `main`, loads the full Tau transcript so desktop reflects cross-surface history, and writes new turns back to the same session.
|
- **WebChat:** Always attaches to `main`, loads the full Tau transcript so desktop reflects cross-surface history, and writes new turns back to the same session.
|
||||||
- **Implementation hints:**
|
- **Implementation hints:**
|
||||||
|
|||||||
21
package.json
21
package.json
@@ -29,37 +29,38 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.23.0",
|
"packageManager": "pnpm@10.23.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-ai": "^0.12.11",
|
"@mariozechner/pi-ai": "^0.13.2",
|
||||||
"@mariozechner/pi-coding-agent": "^0.12.11",
|
"@mariozechner/pi-coding-agent": "^0.13.2",
|
||||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.2.1",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"pino": "^10.1.0",
|
"detect-libc": "^2.0.2",
|
||||||
|
"tslog": "^4.9.3",
|
||||||
"qrcode-terminal": "^0.12.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.34.5",
|
||||||
"zod": "^4.1.13"
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.7",
|
"@biomejs/biome": "^2.3.8",
|
||||||
"@mariozechner/mini-lit": "0.2.1",
|
"@mariozechner/mini-lit": "0.2.1",
|
||||||
"@types/body-parser": "^1.19.6",
|
"@types/body-parser": "^1.19.6",
|
||||||
"@types/express": "^5.0.5",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/qrcode-terminal": "^0.12.2",
|
"@types/qrcode-terminal": "^0.12.2",
|
||||||
"@vitest/coverage-v8": "^4.0.13",
|
"@vitest/coverage-v8": "^4.0.15",
|
||||||
"docx-preview": "^0.3.7",
|
"docx-preview": "^0.3.7",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lit": "^3.3.1",
|
"lit": "^3.3.1",
|
||||||
"lucide": "^0.556.0",
|
"lucide": "^0.556.0",
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
"rolldown": "1.0.0-beta.53",
|
"rolldown": "1.0.0-beta.53",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.0.13"
|
"vitest": "^4.0.15"
|
||||||
},
|
},
|
||||||
"vitest": {
|
"vitest": {
|
||||||
"coverage": {
|
"coverage": {
|
||||||
|
|||||||
@@ -119,7 +119,10 @@ pnpm install \
|
|||||||
--config.shared-workspace-lockfile=false \
|
--config.shared-workspace-lockfile=false \
|
||||||
--lockfile-dir "$ROOT_DIR" \
|
--lockfile-dir "$ROOT_DIR" \
|
||||||
--dir "$TMP_DEPLOY"
|
--dir "$TMP_DEPLOY"
|
||||||
rsync -aL "$TMP_DEPLOY/node_modules" "$RELAY_DIR/"
|
PNPM_STORE_DIR="$TMP_DEPLOY/.pnpm-store" \
|
||||||
|
PNPM_HOME="$HOME/Library/pnpm" \
|
||||||
|
pnpm rebuild sharp --config.ignore-workspace-root-check=true --dir "$TMP_DEPLOY"
|
||||||
|
rsync -aL "$TMP_DEPLOY/node_modules/" "$RELAY_DIR/node_modules/"
|
||||||
rm -rf "$TMP_DEPLOY"
|
rm -rf "$TMP_DEPLOY"
|
||||||
|
|
||||||
if [ -f "$CLI_BIN" ]; then
|
if [ -f "$CLI_BIN" ]; then
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ LAUNCH_AGENT="${HOME}/Library/LaunchAgents/com.steipete.clawdis.plist"
|
|||||||
log() { printf '%s\n' "$*"; }
|
log() { printf '%s\n' "$*"; }
|
||||||
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Ensure local node binaries (rolldown, tsc, pnpm) are discoverable for the steps below.
|
||||||
|
export PATH="${ROOT_DIR}/node_modules/.bin:${PATH}"
|
||||||
|
|
||||||
run_step() {
|
run_step() {
|
||||||
local label="$1"; shift
|
local label="$1"; shift
|
||||||
log "==> ${label}"
|
log "==> ${label}"
|
||||||
|
|||||||
@@ -16,15 +16,15 @@ import { isVerbose, logVerbose } from "../globals.js";
|
|||||||
import { triggerWarelayRestart } from "../infra/restart.js";
|
import { triggerWarelayRestart } from "../infra/restart.js";
|
||||||
import { runCommandWithTimeout } from "../process/exec.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||||
|
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
|
||||||
import { runCommandReply } from "./command-reply.js";
|
import { runCommandReply } from "./command-reply.js";
|
||||||
|
import { buildStatusMessage } from "./status.js";
|
||||||
import {
|
import {
|
||||||
applyTemplate,
|
applyTemplate,
|
||||||
type MsgContext,
|
type MsgContext,
|
||||||
type TemplateContext,
|
type TemplateContext,
|
||||||
} from "./templating.js";
|
} from "./templating.js";
|
||||||
import { buildStatusMessage } from "./status.js";
|
|
||||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
|
||||||
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
|
|
||||||
import {
|
import {
|
||||||
normalizeThinkLevel,
|
normalizeThinkLevel,
|
||||||
normalizeVerboseLevel,
|
normalizeVerboseLevel,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
|
|
||||||
import { lookupContextTokens } from "../agents/context.js";
|
import { lookupContextTokens } from "../agents/context.js";
|
||||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
|
||||||
@@ -79,9 +78,8 @@ const probeAgentCommand = (command?: string[]): AgentProbe => {
|
|||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
timeout: 1500,
|
timeout: 1500,
|
||||||
});
|
});
|
||||||
const found = res.status === 0 && res.stdout
|
const found =
|
||||||
? res.stdout.split("\n")[0]?.trim()
|
res.status === 0 && res.stdout ? res.stdout.split("\n")[0]?.trim() : "";
|
||||||
: "";
|
|
||||||
return {
|
return {
|
||||||
ok: Boolean(found),
|
ok: Boolean(found),
|
||||||
detail: found || "not in PATH",
|
detail: found || "not in PATH",
|
||||||
@@ -115,7 +113,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
DEFAULT_CONTEXT_TOKENS;
|
DEFAULT_CONTEXT_TOKENS;
|
||||||
const totalTokens =
|
const totalTokens =
|
||||||
entry?.totalTokens ??
|
entry?.totalTokens ??
|
||||||
((entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0));
|
(entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0);
|
||||||
const agentProbe = probeAgentCommand(args.reply?.command);
|
const agentProbe = probeAgentCommand(args.reply?.command);
|
||||||
|
|
||||||
const thinkLevel =
|
const thinkLevel =
|
||||||
@@ -138,7 +136,9 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
const sessionLine = [
|
const sessionLine = [
|
||||||
`Session: ${args.sessionKey ?? "unknown"}`,
|
`Session: ${args.sessionKey ?? "unknown"}`,
|
||||||
`scope ${args.sessionScope ?? "per-sender"}`,
|
`scope ${args.sessionScope ?? "per-sender"}`,
|
||||||
entry?.updatedAt ? `updated ${formatAge(now - entry.updatedAt)}` : "no activity",
|
entry?.updatedAt
|
||||||
|
? `updated ${formatAge(now - entry.updatedAt)}`
|
||||||
|
: "no activity",
|
||||||
args.storePath ? `store ${abbreviatePath(args.storePath)}` : undefined,
|
args.storePath ? `store ${abbreviatePath(args.storePath)}` : undefined,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -155,7 +155,13 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
|
|
||||||
const helpersLine = "Shortcuts: /new reset | /restart relink";
|
const helpersLine = "Shortcuts: /new reset | /restart relink";
|
||||||
|
|
||||||
return [ "⚙️ Status", webLine, agentLine, contextLine, sessionLine, optionsLine, helpersLine ].join(
|
return [
|
||||||
"\n",
|
"⚙️ Status",
|
||||||
);
|
webLine,
|
||||||
|
agentLine,
|
||||||
|
contextLine,
|
||||||
|
sessionLine,
|
||||||
|
optionsLine,
|
||||||
|
helpersLine,
|
||||||
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { logWebSelfId, sendMessageWeb } from "../providers/web/index.js";
|
import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js";
|
||||||
|
import { sendMessageTelegram } from "../telegram/send.js";
|
||||||
|
|
||||||
export type CliDeps = {
|
export type CliDeps = {
|
||||||
sendMessageWeb: typeof sendMessageWeb;
|
sendMessageWhatsApp: typeof sendMessageWhatsApp;
|
||||||
|
sendMessageTelegram: typeof sendMessageTelegram;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createDefaultDeps(): CliDeps {
|
export function createDefaultDeps(): CliDeps {
|
||||||
return {
|
return {
|
||||||
sendMessageWeb,
|
sendMessageWhatsApp,
|
||||||
|
sendMessageTelegram,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const monitorWebProvider = vi.fn();
|
|||||||
const logWebSelfId = vi.fn();
|
const logWebSelfId = vi.fn();
|
||||||
const waitForever = vi.fn();
|
const waitForever = vi.fn();
|
||||||
const spawnRelayTmux = vi.fn().mockResolvedValue("clawdis-relay");
|
const spawnRelayTmux = vi.fn().mockResolvedValue("clawdis-relay");
|
||||||
|
const monitorTelegramProvider = vi.fn();
|
||||||
|
|
||||||
const runtime = {
|
const runtime = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
@@ -23,6 +24,9 @@ vi.mock("../provider-web.js", () => ({
|
|||||||
loginWeb,
|
loginWeb,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../telegram/monitor.js", () => ({
|
||||||
|
monitorTelegramProvider,
|
||||||
|
}));
|
||||||
vi.mock("./deps.js", () => ({
|
vi.mock("./deps.js", () => ({
|
||||||
createDefaultDeps: () => ({ waitForever }),
|
createDefaultDeps: () => ({ waitForever }),
|
||||||
logWebSelfId,
|
logWebSelfId,
|
||||||
@@ -86,6 +90,15 @@ describe("cli program", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs telegram relay when token set", async () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const prev = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
process.env.TELEGRAM_BOT_TOKEN = "token123";
|
||||||
|
await program.parseAsync(["relay:telegram"], { from: "user" });
|
||||||
|
expect(monitorTelegramProvider).toHaveBeenCalled();
|
||||||
|
process.env.TELEGRAM_BOT_TOKEN = prev;
|
||||||
|
});
|
||||||
|
|
||||||
it("runs status command", async () => {
|
it("runs status command", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(["status"], { from: "user" });
|
await program.parseAsync(["status"], { from: "user" });
|
||||||
|
|||||||
@@ -135,16 +135,20 @@ export function buildProgram() {
|
|||||||
|
|
||||||
program
|
program
|
||||||
.command("send")
|
.command("send")
|
||||||
.description("Send a WhatsApp message (web provider)")
|
.description("Send a message (WhatsApp web or Telegram bot)")
|
||||||
.requiredOption(
|
.requiredOption(
|
||||||
"-t, --to <number>",
|
"-t, --to <number>",
|
||||||
"Recipient number in E.164 (e.g. +15555550123)",
|
"Recipient: E.164 for WhatsApp (e.g. +15555550123) or Telegram chat id/@username",
|
||||||
)
|
)
|
||||||
.requiredOption("-m, --message <text>", "Message body")
|
.requiredOption("-m, --message <text>", "Message body")
|
||||||
.option(
|
.option(
|
||||||
"--media <path-or-url>",
|
"--media <path-or-url>",
|
||||||
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
|
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
|
||||||
)
|
)
|
||||||
|
.option(
|
||||||
|
"--provider <provider>",
|
||||||
|
"Delivery provider: whatsapp|telegram (default: whatsapp)",
|
||||||
|
)
|
||||||
.option("--dry-run", "Print payload and skip sending", false)
|
.option("--dry-run", "Print payload and skip sending", false)
|
||||||
.option("--json", "Output result as JSON", false)
|
.option("--json", "Output result as JSON", false)
|
||||||
.option("--verbose", "Verbose logging", false)
|
.option("--verbose", "Verbose logging", false)
|
||||||
@@ -562,6 +566,42 @@ Examples:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("relay:telegram")
|
||||||
|
.description("Auto-reply to Telegram (Bot API, long-poll)")
|
||||||
|
.option("--verbose", "Verbose logging", false)
|
||||||
|
.addHelpText(
|
||||||
|
"after",
|
||||||
|
`
|
||||||
|
Examples:
|
||||||
|
clawdis relay:telegram # uses TELEGRAM_BOT_TOKEN env
|
||||||
|
TELEGRAM_BOT_TOKEN=xxx clawdis relay:telegram --verbose
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.action(async (opts) => {
|
||||||
|
setVerbose(Boolean(opts.verbose));
|
||||||
|
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
danger("Set TELEGRAM_BOT_TOKEN to use telegram relay"),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await import("../telegram/monitor.js").then((m) =>
|
||||||
|
m.monitorTelegramProvider({
|
||||||
|
verbose: Boolean(opts.verbose),
|
||||||
|
token,
|
||||||
|
runtime: defaultRuntime,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(danger(`Telegram relay failed: ${String(err)}`));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("status")
|
.command("status")
|
||||||
.description("Show web session health and recent session recipients")
|
.description("Show web session health and recent session recipients")
|
||||||
|
|||||||
@@ -378,13 +378,13 @@ export async function agentCommand(
|
|||||||
}
|
}
|
||||||
if (!sentViaIpc) {
|
if (!sentViaIpc) {
|
||||||
if (text || media.length === 0) {
|
if (text || media.length === 0) {
|
||||||
await deps.sendMessageWeb(targetTo, text, {
|
await deps.sendMessageWhatsApp(targetTo, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: media[0],
|
mediaUrl: media[0],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const extra of media.slice(1)) {
|
for (const extra of media.slice(1)) {
|
||||||
await deps.sendMessageWeb(targetTo, "", {
|
await deps.sendMessageWhatsApp(targetTo, "", {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: extra,
|
mediaUrl: extra,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ const runtime: RuntimeEnv = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
|
const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
|
||||||
sendMessageWeb: vi.fn(),
|
sendMessageWhatsApp: vi.fn(),
|
||||||
|
sendMessageTelegram: vi.fn(),
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ describe("sendCommand", () => {
|
|||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses IPC when available", async () => {
|
it("uses IPC when available", async () => {
|
||||||
@@ -48,14 +49,16 @@ describe("sendCommand", () => {
|
|||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||||
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("ipc1"));
|
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("ipc1"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to direct send when IPC fails", async () => {
|
it("falls back to direct send when IPC fails", async () => {
|
||||||
sendViaIpcMock.mockResolvedValueOnce({ success: false, error: "nope" });
|
sendViaIpcMock.mockResolvedValueOnce({ success: false, error: "nope" });
|
||||||
const deps = makeDeps({
|
const deps = makeDeps({
|
||||||
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "direct1" }),
|
sendMessageWhatsApp: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ messageId: "direct1" }),
|
||||||
});
|
});
|
||||||
await sendCommand(
|
await sendCommand(
|
||||||
{
|
{
|
||||||
@@ -66,13 +69,34 @@ describe("sendCommand", () => {
|
|||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.sendMessageWeb).toHaveBeenCalled();
|
expect(deps.sendMessageWhatsApp).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes to telegram provider", async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
sendMessageTelegram: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ messageId: "t1", chatId: "123" }),
|
||||||
|
});
|
||||||
|
await sendCommand(
|
||||||
|
{ to: "123", message: "hi", provider: "telegram" },
|
||||||
|
deps,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||||
|
"123",
|
||||||
|
"hi",
|
||||||
|
expect.objectContaining({ token: expect.any(String) }),
|
||||||
|
);
|
||||||
|
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits json output", async () => {
|
it("emits json output", async () => {
|
||||||
sendViaIpcMock.mockResolvedValueOnce(null);
|
sendViaIpcMock.mockResolvedValueOnce(null);
|
||||||
const deps = makeDeps({
|
const deps = makeDeps({
|
||||||
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "direct2" }),
|
sendMessageWhatsApp: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ messageId: "direct2" }),
|
||||||
});
|
});
|
||||||
await sendCommand(
|
await sendCommand(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export async function sendCommand(
|
|||||||
opts: {
|
opts: {
|
||||||
to: string;
|
to: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
provider?: string;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
media?: string;
|
media?: string;
|
||||||
@@ -14,13 +15,44 @@ export async function sendCommand(
|
|||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
|
const provider = (opts.provider ?? "whatsapp").toLowerCase();
|
||||||
|
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
`[dry-run] would send via ${provider} -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === "telegram") {
|
||||||
|
const result = await deps.sendMessageTelegram(opts.to, opts.message, {
|
||||||
|
token: process.env.TELEGRAM_BOT_TOKEN,
|
||||||
|
mediaUrl: opts.media,
|
||||||
|
});
|
||||||
|
runtime.log(
|
||||||
|
success(
|
||||||
|
`✅ Sent via telegram. Message ID: ${result.messageId} (chat ${result.chatId})`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (opts.json) {
|
||||||
|
runtime.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
provider: "telegram",
|
||||||
|
via: "direct",
|
||||||
|
to: opts.to,
|
||||||
|
chatId: result.chatId,
|
||||||
|
messageId: result.messageId,
|
||||||
|
mediaUrl: opts.media ?? null,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Try to send via IPC to running relay first (avoids Signal session corruption)
|
// Try to send via IPC to running relay first (avoids Signal session corruption)
|
||||||
const ipcResult = await sendViaIpc(opts.to, opts.message, opts.media);
|
const ipcResult = await sendViaIpc(opts.to, opts.message, opts.media);
|
||||||
if (ipcResult) {
|
if (ipcResult) {
|
||||||
@@ -55,7 +87,7 @@ export async function sendCommand(
|
|||||||
|
|
||||||
// Fall back to direct connection (creates new Baileys socket)
|
// Fall back to direct connection (creates new Baileys socket)
|
||||||
const res = await deps
|
const res = await deps
|
||||||
.sendMessageWeb(opts.to, opts.message, {
|
.sendMessageWhatsApp(opts.to, opts.message, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: opts.media,
|
mediaUrl: opts.media,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ export type WebConfig = {
|
|||||||
reconnect?: WebReconnectConfig;
|
reconnect?: WebReconnectConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TelegramConfig = {
|
||||||
|
botToken?: string;
|
||||||
|
requireMention?: boolean;
|
||||||
|
allowFrom?: Array<string | number>;
|
||||||
|
mediaMaxMb?: number;
|
||||||
|
proxy?: string;
|
||||||
|
webhookUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type GroupChatConfig = {
|
export type GroupChatConfig = {
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
mentionPatterns?: string[];
|
mentionPatterns?: string[];
|
||||||
@@ -89,6 +98,7 @@ export type WarelayConfig = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
web?: WebConfig;
|
web?: WebConfig;
|
||||||
|
telegram?: TelegramConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
// New branding path (preferred)
|
// New branding path (preferred)
|
||||||
@@ -214,6 +224,16 @@ const WarelaySchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
telegram: z
|
||||||
|
.object({
|
||||||
|
botToken: z.string().optional(),
|
||||||
|
requireMention: z.boolean().optional(),
|
||||||
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
|
proxy: z.string().optional(),
|
||||||
|
webhookUrl: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function loadConfig(): WarelayConfig {
|
export function loadConfig(): WarelayConfig {
|
||||||
|
|||||||
@@ -23,12 +23,30 @@ describe("sessions", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("collapses direct chats to main by default", () => {
|
||||||
|
expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("main");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses direct chats to main even when sender missing", () => {
|
||||||
|
expect(resolveSessionKey("per-sender", {})).toBe("main");
|
||||||
|
});
|
||||||
|
|
||||||
it("maps direct chats to main key when provided", () => {
|
it("maps direct chats to main key when provided", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveSessionKey("per-sender", { From: "whatsapp:+1555" }, "main"),
|
resolveSessionKey("per-sender", { From: "whatsapp:+1555" }, "main"),
|
||||||
).toBe("main");
|
).toBe("main");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses custom main key when provided", () => {
|
||||||
|
expect(resolveSessionKey("per-sender", { From: "+1555" }, "primary")).toBe(
|
||||||
|
"primary",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps global scope untouched", () => {
|
||||||
|
expect(resolveSessionKey("global", { From: "+1555" })).toBe("global");
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves groups untouched even with main key", () => {
|
it("leaves groups untouched even with main key", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveSessionKey("per-sender", { From: "12345-678@g.us" }, "main"),
|
resolveSessionKey("per-sender", { From: "12345-678@g.us" }, "main"),
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the session key with an optional canonical direct-chat key (e.g., "main").
|
* Resolve the session key with a canonical direct-chat bucket (default: "main").
|
||||||
* All non-group direct chats collapse to `mainKey` when provided, keeping group isolation.
|
* All non-group direct chats collapse to this bucket; groups stay isolated.
|
||||||
*/
|
*/
|
||||||
export function resolveSessionKey(
|
export function resolveSessionKey(
|
||||||
scope: SessionScope,
|
scope: SessionScope,
|
||||||
@@ -89,8 +89,9 @@ export function resolveSessionKey(
|
|||||||
) {
|
) {
|
||||||
const raw = deriveSessionKey(scope, ctx);
|
const raw = deriveSessionKey(scope, ctx);
|
||||||
if (scope === "global") return raw;
|
if (scope === "global") return raw;
|
||||||
const canonical = (mainKey ?? "").trim();
|
// Default to a single shared direct-chat session called "main"; groups stay isolated.
|
||||||
|
const canonical = (mainKey ?? "main").trim() || "main";
|
||||||
const isGroup = raw.startsWith("group:") || raw.includes("@g.us");
|
const isGroup = raw.startsWith("group:") || raw.includes("@g.us");
|
||||||
if (!isGroup && canonical) return canonical;
|
if (!isGroup) return canonical;
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { assertProvider, normalizeE164, toWhatsappJid } from "./utils.js";
|
|||||||
|
|
||||||
dotenv.config({ quiet: true });
|
dotenv.config({ quiet: true });
|
||||||
|
|
||||||
// Capture all console output into pino logs while keeping stdout/stderr behavior.
|
// Capture all console output into structured logs while keeping stdout/stderr behavior.
|
||||||
enableConsoleCapture();
|
enableConsoleCapture();
|
||||||
|
|
||||||
import { buildProgram } from "./cli/program.js";
|
import { buildProgram } from "./cli/program.js";
|
||||||
|
|||||||
121
src/logging.ts
121
src/logging.ts
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import util from "node:util";
|
import util from "node:util";
|
||||||
|
|
||||||
import pino, { type Bindings, type LevelWithSilent, type Logger } from "pino";
|
import { Logger as TsLogger } from "tslog";
|
||||||
import { loadConfig, type WarelayConfig } from "./config/config.js";
|
import { loadConfig, type WarelayConfig } from "./config/config.js";
|
||||||
import { isVerbose } from "./globals.js";
|
import { isVerbose } from "./globals.js";
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ const LOG_PREFIX = "clawdis";
|
|||||||
const LOG_SUFFIX = ".log";
|
const LOG_SUFFIX = ".log";
|
||||||
const MAX_LOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
const MAX_LOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
||||||
|
|
||||||
const ALLOWED_LEVELS: readonly LevelWithSilent[] = [
|
const ALLOWED_LEVELS = [
|
||||||
"silent",
|
"silent",
|
||||||
"fatal",
|
"fatal",
|
||||||
"error",
|
"error",
|
||||||
@@ -23,30 +23,32 @@ const ALLOWED_LEVELS: readonly LevelWithSilent[] = [
|
|||||||
"info",
|
"info",
|
||||||
"debug",
|
"debug",
|
||||||
"trace",
|
"trace",
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
|
type Level = (typeof ALLOWED_LEVELS)[number];
|
||||||
|
|
||||||
export type LoggerSettings = {
|
export type LoggerSettings = {
|
||||||
level?: LevelWithSilent;
|
level?: Level;
|
||||||
file?: string;
|
file?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LogObj = Record<string, unknown>;
|
||||||
|
|
||||||
type ResolvedSettings = {
|
type ResolvedSettings = {
|
||||||
level: LevelWithSilent;
|
level: Level;
|
||||||
file: string;
|
file: string;
|
||||||
};
|
};
|
||||||
export type LoggerResolvedSettings = ResolvedSettings;
|
export type LoggerResolvedSettings = ResolvedSettings;
|
||||||
|
|
||||||
let cachedLogger: Logger | null = null;
|
let cachedLogger: TsLogger<LogObj> | null = null;
|
||||||
let cachedSettings: ResolvedSettings | null = null;
|
let cachedSettings: ResolvedSettings | null = null;
|
||||||
let overrideSettings: LoggerSettings | null = null;
|
let overrideSettings: LoggerSettings | null = null;
|
||||||
let consolePatched = false;
|
let consolePatched = false;
|
||||||
|
|
||||||
function normalizeLevel(level?: string): LevelWithSilent {
|
function normalizeLevel(level?: string): Level {
|
||||||
if (isVerbose()) return "trace";
|
if (isVerbose()) return "trace";
|
||||||
const candidate = level ?? "info";
|
const candidate = level ?? "info";
|
||||||
return ALLOWED_LEVELS.includes(candidate as LevelWithSilent)
|
return ALLOWED_LEVELS.includes(candidate as Level) ? (candidate as Level) : "info";
|
||||||
? (candidate as LevelWithSilent)
|
|
||||||
: "info";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSettings(): ResolvedSettings {
|
function resolveSettings(): ResolvedSettings {
|
||||||
@@ -62,28 +64,48 @@ function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) {
|
|||||||
return a.level !== b.level || a.file !== b.file;
|
return a.level !== b.level || a.file !== b.file;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLogger(settings: ResolvedSettings): Logger {
|
function levelToMinLevel(level: Level): number {
|
||||||
|
// tslog level ordering: fatal=0, error=1, warn=2, info=3, debug=4, trace=5
|
||||||
|
const map: Record<Level, number> = {
|
||||||
|
fatal: 0,
|
||||||
|
error: 1,
|
||||||
|
warn: 2,
|
||||||
|
info: 3,
|
||||||
|
debug: 4,
|
||||||
|
trace: 5,
|
||||||
|
silent: Number.POSITIVE_INFINITY,
|
||||||
|
};
|
||||||
|
return map[level];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLogger(settings: ResolvedSettings): TsLogger<LogObj> {
|
||||||
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
|
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
|
||||||
// Clean up stale rolling logs when using a dated log filename.
|
// Clean up stale rolling logs when using a dated log filename.
|
||||||
if (isRollingPath(settings.file)) {
|
if (isRollingPath(settings.file)) {
|
||||||
pruneOldRollingLogs(path.dirname(settings.file));
|
pruneOldRollingLogs(path.dirname(settings.file));
|
||||||
}
|
}
|
||||||
const destination = pino.destination({
|
const logger = new TsLogger<LogObj>({
|
||||||
dest: settings.file,
|
name: "clawdis",
|
||||||
mkdir: true,
|
minLevel: levelToMinLevel(settings.level),
|
||||||
sync: true, // deterministic for tests; log volume is modest.
|
type: "hidden", // no ansi formatting
|
||||||
});
|
});
|
||||||
return pino(
|
|
||||||
{
|
logger.attachTransport(
|
||||||
level: settings.level,
|
(logObj) => {
|
||||||
base: undefined,
|
try {
|
||||||
timestamp: pino.stdTimeFunctions.isoTime,
|
const time = (logObj as any)?.date?.toISOString?.() ?? new Date().toISOString();
|
||||||
},
|
const line = JSON.stringify({ ...logObj, time });
|
||||||
destination,
|
fs.appendFileSync(settings.file, line + "\n", { encoding: "utf8" });
|
||||||
|
} catch {
|
||||||
|
// never block on logging failures
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLogger(): Logger {
|
export function getLogger(): TsLogger<LogObj> {
|
||||||
const settings = resolveSettings();
|
const settings = resolveSettings();
|
||||||
if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
|
if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
|
||||||
cachedLogger = buildLogger(settings);
|
cachedLogger = buildLogger(settings);
|
||||||
@@ -93,12 +115,55 @@ export function getLogger(): Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getChildLogger(
|
export function getChildLogger(
|
||||||
bindings?: Bindings,
|
bindings?: Record<string, unknown>,
|
||||||
opts?: { level?: LevelWithSilent },
|
opts?: { level?: Level },
|
||||||
): Logger {
|
): TsLogger<LogObj> {
|
||||||
return getLogger().child(bindings ?? {}, opts);
|
const base = getLogger();
|
||||||
|
const minLevel = opts?.level ? levelToMinLevel(opts.level) : undefined;
|
||||||
|
const name = bindings ? JSON.stringify(bindings) : undefined;
|
||||||
|
return base.getSubLogger({
|
||||||
|
name,
|
||||||
|
minLevel,
|
||||||
|
prefix: bindings ? [name ?? ""] : [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LogLevel = Level;
|
||||||
|
|
||||||
|
// Baileys expects a pino-like logger shape. Provide a lightweight adapter.
|
||||||
|
export function toPinoLikeLogger(
|
||||||
|
logger: TsLogger<LogObj>,
|
||||||
|
level: Level,
|
||||||
|
): PinoLikeLogger {
|
||||||
|
const buildChild = (bindings?: Record<string, unknown>) =>
|
||||||
|
toPinoLikeLogger(
|
||||||
|
logger.getSubLogger({ name: bindings ? JSON.stringify(bindings) : undefined }),
|
||||||
|
level,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
level,
|
||||||
|
child: buildChild,
|
||||||
|
trace: (...args: unknown[]) => logger.trace(...args),
|
||||||
|
debug: (...args: unknown[]) => logger.debug(...args),
|
||||||
|
info: (...args: unknown[]) => logger.info(...args),
|
||||||
|
warn: (...args: unknown[]) => logger.warn(...args),
|
||||||
|
error: (...args: unknown[]) => logger.error(...args),
|
||||||
|
fatal: (...args: unknown[]) => logger.fatal(...args),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PinoLikeLogger = {
|
||||||
|
level: string;
|
||||||
|
child: (bindings?: Record<string, unknown>) => PinoLikeLogger;
|
||||||
|
trace: (...args: unknown[]) => void;
|
||||||
|
debug: (...args: unknown[]) => void;
|
||||||
|
info: (...args: unknown[]) => void;
|
||||||
|
warn: (...args: unknown[]) => void;
|
||||||
|
error: (...args: unknown[]) => void;
|
||||||
|
fatal: (...args: unknown[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export function getResolvedLoggerSettings(): LoggerResolvedSettings {
|
export function getResolvedLoggerSettings(): LoggerResolvedSettings {
|
||||||
return resolveSettings();
|
return resolveSettings();
|
||||||
}
|
}
|
||||||
@@ -136,7 +201,7 @@ export function enableConsoleCapture(): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const forward =
|
const forward =
|
||||||
(level: LevelWithSilent, orig: (...args: unknown[]) => void) =>
|
(level: Level, orig: (...args: unknown[]) => void) =>
|
||||||
(...args: unknown[]) => {
|
(...args: unknown[]) => {
|
||||||
const formatted = util.format(...args);
|
const formatted = util.format(...args);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { CONFIG_DIR } from "../utils.js";
|
|||||||
import { detectMime, extensionForMime } from "./mime.js";
|
import { detectMime, extensionForMime } from "./mime.js";
|
||||||
|
|
||||||
const MEDIA_DIR = path.join(CONFIG_DIR, "media");
|
const MEDIA_DIR = path.join(CONFIG_DIR, "media");
|
||||||
const MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
const MAX_BYTES = 5 * 1024 * 1024; // 5MB default
|
||||||
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
||||||
|
|
||||||
export function getMediaDir() {
|
export function getMediaDir() {
|
||||||
@@ -159,9 +159,12 @@ export async function saveMediaBuffer(
|
|||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
contentType?: string,
|
contentType?: string,
|
||||||
subdir = "inbound",
|
subdir = "inbound",
|
||||||
|
maxBytes = MAX_BYTES,
|
||||||
): Promise<SavedMedia> {
|
): Promise<SavedMedia> {
|
||||||
if (buffer.byteLength > MAX_BYTES) {
|
if (buffer.byteLength > maxBytes) {
|
||||||
throw new Error("Media exceeds 5MB limit");
|
throw new Error(
|
||||||
|
`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const dir = path.join(MEDIA_DIR, subdir);
|
const dir = path.join(MEDIA_DIR, subdir);
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ describe("provider-web barrel", () => {
|
|||||||
expect(mod.createWaSocket).toBeTypeOf("function");
|
expect(mod.createWaSocket).toBeTypeOf("function");
|
||||||
expect(mod.loginWeb).toBeTypeOf("function");
|
expect(mod.loginWeb).toBeTypeOf("function");
|
||||||
expect(mod.monitorWebProvider).toBeTypeOf("function");
|
expect(mod.monitorWebProvider).toBeTypeOf("function");
|
||||||
expect(mod.sendMessageWeb).toBeTypeOf("function");
|
expect(mod.sendMessageWhatsApp).toBeTypeOf("function");
|
||||||
expect(mod.monitorWebInbox).toBeTypeOf("function");
|
expect(mod.monitorWebInbox).toBeTypeOf("function");
|
||||||
expect(mod.pickProvider).toBeTypeOf("function");
|
expect(mod.pickProvider).toBeTypeOf("function");
|
||||||
expect(mod.WA_WEB_AUTH_DIR).toBeTruthy();
|
expect(mod.WA_WEB_AUTH_DIR).toBeTruthy();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export {
|
|||||||
} from "./web/inbound.js";
|
} from "./web/inbound.js";
|
||||||
export { loginWeb } from "./web/login.js";
|
export { loginWeb } from "./web/login.js";
|
||||||
export { loadWebMedia, optimizeImageToJpeg } from "./web/media.js";
|
export { loadWebMedia, optimizeImageToJpeg } from "./web/media.js";
|
||||||
export { sendMessageWeb } from "./web/outbound.js";
|
export { sendMessageWhatsApp } from "./web/outbound.js";
|
||||||
export {
|
export {
|
||||||
createWaSocket,
|
createWaSocket,
|
||||||
formatError,
|
formatError,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe("providers/web entrypoint", () => {
|
|||||||
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
|
expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox);
|
||||||
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
|
expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider);
|
||||||
expect(entry.pickProvider).toBe(impl.pickProvider);
|
expect(entry.pickProvider).toBe(impl.pickProvider);
|
||||||
expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb);
|
expect(entry.sendMessageWhatsApp).toBe(impl.sendMessageWhatsApp);
|
||||||
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
|
expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR);
|
||||||
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
|
expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection);
|
||||||
expect(entry.webAuthExists).toBe(impl.webAuthExists);
|
expect(entry.webAuthExists).toBe(impl.webAuthExists);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export {
|
|||||||
monitorWebInbox,
|
monitorWebInbox,
|
||||||
monitorWebProvider,
|
monitorWebProvider,
|
||||||
pickProvider,
|
pickProvider,
|
||||||
sendMessageWeb,
|
sendMessageWhatsApp,
|
||||||
WA_WEB_AUTH_DIR,
|
WA_WEB_AUTH_DIR,
|
||||||
waitForWaConnection,
|
waitForWaConnection,
|
||||||
webAuthExists,
|
webAuthExists,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
runWebHeartbeatOnce,
|
runWebHeartbeatOnce,
|
||||||
stripHeartbeatToken,
|
stripHeartbeatToken,
|
||||||
} from "./auto-reply.js";
|
} from "./auto-reply.js";
|
||||||
import type { sendMessageWeb } from "./outbound.js";
|
import type { sendMessageWhatsApp } from "./outbound.js";
|
||||||
import {
|
import {
|
||||||
resetBaileysMocks,
|
resetBaileysMocks,
|
||||||
resetLoadConfigMock,
|
resetLoadConfigMock,
|
||||||
@@ -157,7 +157,7 @@ describe("resolveHeartbeatRecipients", () => {
|
|||||||
describe("runWebHeartbeatOnce", () => {
|
describe("runWebHeartbeatOnce", () => {
|
||||||
it("skips when heartbeat token returned", async () => {
|
it("skips when heartbeat token returned", async () => {
|
||||||
const store = await makeSessionStore();
|
const store = await makeSessionStore();
|
||||||
const sender: typeof sendMessageWeb = vi.fn();
|
const sender: typeof sendMessageWhatsApp = vi.fn();
|
||||||
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
||||||
await runWebHeartbeatOnce({
|
await runWebHeartbeatOnce({
|
||||||
cfg: {
|
cfg: {
|
||||||
@@ -178,7 +178,7 @@ describe("runWebHeartbeatOnce", () => {
|
|||||||
|
|
||||||
it("sends when alert text present", async () => {
|
it("sends when alert text present", async () => {
|
||||||
const store = await makeSessionStore();
|
const store = await makeSessionStore();
|
||||||
const sender: typeof sendMessageWeb = vi
|
const sender: typeof sendMessageWhatsApp = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
||||||
const resolver = vi.fn(async () => ({ text: "ALERT" }));
|
const resolver = vi.fn(async () => ({ text: "ALERT" }));
|
||||||
@@ -201,7 +201,7 @@ describe("runWebHeartbeatOnce", () => {
|
|||||||
it("falls back to most recent session when no to is provided", async () => {
|
it("falls back to most recent session when no to is provided", async () => {
|
||||||
const store = await makeSessionStore();
|
const store = await makeSessionStore();
|
||||||
const storePath = store.storePath;
|
const storePath = store.storePath;
|
||||||
const sender: typeof sendMessageWeb = vi
|
const sender: typeof sendMessageWhatsApp = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
||||||
const resolver = vi.fn(async () => ({ text: "ALERT" }));
|
const resolver = vi.fn(async () => ({ text: "ALERT" }));
|
||||||
@@ -239,7 +239,7 @@ describe("runWebHeartbeatOnce", () => {
|
|||||||
};
|
};
|
||||||
await fs.writeFile(storePath, JSON.stringify(store));
|
await fs.writeFile(storePath, JSON.stringify(store));
|
||||||
|
|
||||||
const sender: typeof sendMessageWeb = vi.fn();
|
const sender: typeof sendMessageWhatsApp = vi.fn();
|
||||||
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
const resolver = vi.fn(async () => ({ text: HEARTBEAT_TOKEN }));
|
||||||
setLoadConfigMock({
|
setLoadConfigMock({
|
||||||
inbound: {
|
inbound: {
|
||||||
@@ -365,7 +365,7 @@ describe("runWebHeartbeatOnce", () => {
|
|||||||
|
|
||||||
it("sends overrideBody directly and skips resolver", async () => {
|
it("sends overrideBody directly and skips resolver", async () => {
|
||||||
const store = await makeSessionStore();
|
const store = await makeSessionStore();
|
||||||
const sender: typeof sendMessageWeb = vi
|
const sender: typeof sendMessageWhatsApp = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
|
||||||
const resolver = vi.fn();
|
const resolver = vi.fn();
|
||||||
@@ -391,7 +391,7 @@ describe("runWebHeartbeatOnce", () => {
|
|||||||
|
|
||||||
it("dry-run overrideBody prints and skips send", async () => {
|
it("dry-run overrideBody prints and skips send", async () => {
|
||||||
const store = await makeSessionStore();
|
const store = await makeSessionStore();
|
||||||
const sender: typeof sendMessageWeb = vi.fn();
|
const sender: typeof sendMessageWhatsApp = vi.fn();
|
||||||
const resolver = vi.fn();
|
const resolver = vi.fn();
|
||||||
await runWebHeartbeatOnce({
|
await runWebHeartbeatOnce({
|
||||||
cfg: {
|
cfg: {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { jidToE164, normalizeE164 } from "../utils.js";
|
|||||||
import { monitorWebInbox } from "./inbound.js";
|
import { monitorWebInbox } from "./inbound.js";
|
||||||
import { sendViaIpc, startIpcServer, stopIpcServer } from "./ipc.js";
|
import { sendViaIpc, startIpcServer, stopIpcServer } from "./ipc.js";
|
||||||
import { loadWebMedia } from "./media.js";
|
import { loadWebMedia } from "./media.js";
|
||||||
import { sendMessageWeb } from "./outbound.js";
|
import { sendMessageWhatsApp } from "./outbound.js";
|
||||||
import {
|
import {
|
||||||
computeBackoff,
|
computeBackoff,
|
||||||
newConnectionId,
|
newConnectionId,
|
||||||
@@ -55,7 +55,7 @@ async function sendWithIpcFallback(
|
|||||||
return { messageId: ipcResult.messageId, toJid: `${to}@s.whatsapp.net` };
|
return { messageId: ipcResult.messageId, toJid: `${to}@s.whatsapp.net` };
|
||||||
}
|
}
|
||||||
// Fall back to direct send
|
// Fall back to direct send
|
||||||
return sendMessageWeb(to, message, opts);
|
return sendMessageWhatsApp(to, message, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;
|
const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;
|
||||||
@@ -194,7 +194,7 @@ export async function runWebHeartbeatOnce(opts: {
|
|||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
replyResolver?: typeof getReplyFromConfig;
|
replyResolver?: typeof getReplyFromConfig;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
sender?: typeof sendMessageWeb;
|
sender?: typeof sendMessageWhatsApp;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
overrideBody?: string;
|
overrideBody?: string;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ vi.mock("./media.js", () => ({
|
|||||||
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { sendMessageWeb } from "./outbound.js";
|
import { sendMessageWhatsApp } from "./outbound.js";
|
||||||
|
|
||||||
const { createWaSocket } = await import("./session.js");
|
const { createWaSocket } = await import("./session.js");
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ describe("web outbound", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sends message via web and closes socket", async () => {
|
it("sends message via web and closes socket", async () => {
|
||||||
await sendMessageWeb("+1555", "hi", { verbose: false });
|
await sendMessageWhatsApp("+1555", "hi", { verbose: false });
|
||||||
const sock = await createWaSocket();
|
const sock = await createWaSocket();
|
||||||
expect(sock.sendMessage).toHaveBeenCalled();
|
expect(sock.sendMessage).toHaveBeenCalled();
|
||||||
expect(sock.ws.close).toHaveBeenCalled();
|
expect(sock.ws.close).toHaveBeenCalled();
|
||||||
@@ -51,7 +51,7 @@ describe("web outbound", () => {
|
|||||||
contentType: "audio/ogg",
|
contentType: "audio/ogg",
|
||||||
kind: "audio",
|
kind: "audio",
|
||||||
});
|
});
|
||||||
await sendMessageWeb("+1555", "voice note", {
|
await sendMessageWhatsApp("+1555", "voice note", {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: "/tmp/voice.ogg",
|
mediaUrl: "/tmp/voice.ogg",
|
||||||
});
|
});
|
||||||
@@ -74,7 +74,7 @@ describe("web outbound", () => {
|
|||||||
contentType: "video/mp4",
|
contentType: "video/mp4",
|
||||||
kind: "video",
|
kind: "video",
|
||||||
});
|
});
|
||||||
await sendMessageWeb("+1555", "clip", {
|
await sendMessageWhatsApp("+1555", "clip", {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: "/tmp/video.mp4",
|
mediaUrl: "/tmp/video.mp4",
|
||||||
});
|
});
|
||||||
@@ -97,7 +97,7 @@ describe("web outbound", () => {
|
|||||||
contentType: "image/jpeg",
|
contentType: "image/jpeg",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
});
|
});
|
||||||
await sendMessageWeb("+1555", "pic", {
|
await sendMessageWhatsApp("+1555", "pic", {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: "/tmp/pic.jpg",
|
mediaUrl: "/tmp/pic.jpg",
|
||||||
});
|
});
|
||||||
@@ -121,7 +121,7 @@ describe("web outbound", () => {
|
|||||||
kind: "document",
|
kind: "document",
|
||||||
fileName: "file.pdf",
|
fileName: "file.pdf",
|
||||||
});
|
});
|
||||||
await sendMessageWeb("+1555", "doc", {
|
await sendMessageWhatsApp("+1555", "doc", {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl: "/tmp/file.pdf",
|
mediaUrl: "/tmp/file.pdf",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { toWhatsappJid } from "../utils.js";
|
|||||||
import { loadWebMedia } from "./media.js";
|
import { loadWebMedia } from "./media.js";
|
||||||
import { createWaSocket, waitForWaConnection } from "./session.js";
|
import { createWaSocket, waitForWaConnection } from "./session.js";
|
||||||
|
|
||||||
export async function sendMessageWeb(
|
export async function sendMessageWhatsApp(
|
||||||
to: string,
|
to: string,
|
||||||
body: string,
|
body: string,
|
||||||
options: { verbose: boolean; mediaUrl?: string },
|
options: { verbose: boolean; mediaUrl?: string },
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import qrcode from "qrcode-terminal";
|
|||||||
|
|
||||||
import { SESSION_STORE_DEFAULT } from "../config/sessions.js";
|
import { SESSION_STORE_DEFAULT } from "../config/sessions.js";
|
||||||
import { danger, info, success } from "../globals.js";
|
import { danger, info, success } from "../globals.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger, toPinoLikeLogger } from "../logging.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import type { Provider } from "../utils.js";
|
import type { Provider } from "../utils.js";
|
||||||
import { CONFIG_DIR, ensureDir, jidToE164 } from "../utils.js";
|
import { CONFIG_DIR, ensureDir, jidToE164 } from "../utils.js";
|
||||||
@@ -26,17 +26,13 @@ export const WA_WEB_AUTH_DIR = path.join(CONFIG_DIR, "credentials");
|
|||||||
* Consumers can opt into QR printing for interactive login flows.
|
* Consumers can opt into QR printing for interactive login flows.
|
||||||
*/
|
*/
|
||||||
export async function createWaSocket(printQr: boolean, verbose: boolean) {
|
export async function createWaSocket(printQr: boolean, verbose: boolean) {
|
||||||
const logger = getChildLogger(
|
const baseLogger = getChildLogger(
|
||||||
{ module: "baileys" },
|
{ module: "baileys" },
|
||||||
{
|
{
|
||||||
level: verbose ? "info" : "silent",
|
level: verbose ? "info" : "silent",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Some Baileys internals call logger.trace even when silent; ensure it's present.
|
const logger = toPinoLikeLogger(baseLogger, verbose ? "info" : "silent");
|
||||||
const loggerAny = logger as unknown as Record<string, unknown>;
|
|
||||||
if (typeof loggerAny.trace !== "function") {
|
|
||||||
loggerAny.trace = () => {};
|
|
||||||
}
|
|
||||||
await ensureDir(WA_WEB_AUTH_DIR);
|
await ensureDir(WA_WEB_AUTH_DIR);
|
||||||
const { state, saveCreds } = await useMultiFileAuthState(WA_WEB_AUTH_DIR);
|
const { state, saveCreds } = await useMultiFileAuthState(WA_WEB_AUTH_DIR);
|
||||||
const { version } = await fetchLatestBaileysVersion();
|
const { version } = await fetchLatestBaileysVersion();
|
||||||
|
|||||||
Reference in New Issue
Block a user