diff --git a/CHANGELOG.md b/CHANGELOG.md index 5567c0a4f..ee85b26b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ First Clawdis release after the Warelay rebrand. This is a semver-major because ### Docs - Added `docs/telegram.md` outlining the Telegram Bot API provider (grammY) and how it shares the `main` session. Default grammY throttler keeps Bot API calls under rate limits. -- CLI exposes `relay:telegram` (grammY) and text/media sends via `--provider telegram`; webhook/proxy options documented. +- CLI relay now auto-starts WhatsApp and Telegram when configured (single `relay` command with `--provider` selector); text/media sends still use `--provider telegram`; webhook/proxy options documented. ## 1.5.0 — 2025-12-05 @@ -148,7 +148,7 @@ First Clawdis release after the Warelay rebrand. This is a semver-major because ### Changes - Heartbeat interval default 10m for command mode; prompt `HEARTBEAT /think:high`; skips don’t refresh session; session `heartbeatIdleMinutes` support. -- Heartbeat tooling: `--session-id`, `--heartbeat-now`, and a relay helper `relay:heartbeat` for immediate startup probes. +- Heartbeat tooling: `--session-id`, `--heartbeat-now` (inline flag on `relay`) for immediate startup probes. - Prompt structure: `sessionIntro` plus per-message `/think:high`; session idle up to 7 days. - Thinking directives: `/think:`; Pi uses `--thinking`; others append cue; `/think:off` no-op. - Robustness: Baileys/WebSocket guards; global unhandled error handlers; WhatsApp LID mapping; hosted media MIME-sniffing and cleanup. diff --git a/README.md b/README.md index aa260168f..081418c50 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ clawdis relay # Start listening ``` ### Telegram (Bot API) -Bot-mode support (grammY only) 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). Webhook mode: `--webhook --port … --webhook-secret … --webhook-url …` (or register via BotFather). See `docs/telegram.md` for setup and limits. +Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebChat, with groups kept isolated. Text and media send work via `clawdis send --provider telegram`. The unified `clawdis relay` starts WhatsApp and, when `TELEGRAM_BOT_TOKEN` or `telegram.botToken` is set, Telegram too (use `--provider` to force web|telegram|all). Webhook mode: `--webhook --port … --webhook-secret … --webhook-url …` (or register via BotFather). See `docs/telegram.md` for setup and limits. ## Commands @@ -132,8 +132,7 @@ Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebCha | `clawdis login` | Link WhatsApp Web via QR | | `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 relay` | Start auto-reply loop | -| `clawdis relay:telegram` | Start Telegram bot long-poll relay (Bot API) | +| `clawdis relay` | Start auto-reply loop (WhatsApp + Telegram when configured) | | `clawdis status` | Web session health + session store summary | | `clawdis heartbeat` | Trigger a heartbeat | diff --git a/docs/clawd.md b/docs/clawd.md index 7acf708f3..f3ba4089c 100644 --- a/docs/clawd.md +++ b/docs/clawd.md @@ -241,7 +241,7 @@ Include `MEDIA:/path/to/file.png` in Claude's output to attach images. clawdis h clawdis relay --provider web --verbose # With immediate heartbeat on startup -clawdis relay:heartbeat +clawdis relay --heartbeat-now ``` For backgrounding, run the relay under your preferred supervisor (e.g., launchd/systemd) and point it at the same `clawdis relay --provider web --verbose` command. diff --git a/docs/heartbeat.md b/docs/heartbeat.md index c0815b5bd..1719197dc 100644 --- a/docs/heartbeat.md +++ b/docs/heartbeat.md @@ -39,6 +39,6 @@ Goal: add a simple heartbeat poll for command-based auto-replies (Pi/Tau) that o - Expose CLI triggers: - `clawdis heartbeat` (web provider, defaults to first `allowFrom`; optional `--to` override) - `--session-id ` forces resuming a specific session for that heartbeat - - `clawdis relay:heartbeat` to run the relay loop with an immediate heartbeat + - `clawdis relay --heartbeat-now` to run the relay loop with an immediate heartbeat - Relay supports `--heartbeat-now` to fire once at startup. - When multiple sessions are active or `allowFrom` is only `"*"`, require `--to ` or `--all` for manual heartbeats to avoid ambiguous targets. diff --git a/docs/telegram.md b/docs/telegram.md index 7c5a977db..c9eac79b0 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -12,7 +12,7 @@ Status: ready for bot-mode use with grammY (long-poll + webhook). Text + media s ## How it will work (Bot API) 1) Create a bot with @BotFather and grab the token. 2) Configure Clawdis with `TELEGRAM_BOT_TOKEN` (or `telegram.botToken` in `~/.clawdis/clawdis.json`). -3) Run the relay with provider `telegram` via `clawdis relay:telegram` (grammY long-poll). Webhook mode: `clawdis relay:telegram --webhook --port 8787 --webhook-secret ` (optionally `--webhook-url` when the public URL differs). +3) Run the relay; it auto-starts Telegram when the bot token is set. To force Telegram-only: `clawdis relay --provider telegram`. Webhook mode: `clawdis relay --provider telegram --webhook --port 8787 --webhook-secret ` (optionally `--webhook-url` when the public URL differs). 4) Direct chats: user sends the first message; all subsequent turns land in the shared `main` session (default, no extra config). 5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `group:` and require mention/command to trigger replies. 6) Optional allowlist: reuse `inbound.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`). diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts index 8048045c0..1e3fe5821 100644 --- a/src/cli/program.test.ts +++ b/src/cli/program.test.ts @@ -57,7 +57,7 @@ describe("cli program", () => { monitorWebProvider.mockResolvedValue(undefined); const program = buildProgram(); await program.parseAsync( - ["relay", "--web-heartbeat", "90", "--heartbeat-now"], + ["relay", "--web-heartbeat", "90", "--heartbeat-now", "--provider", "web"], { from: "user", }, @@ -69,28 +69,37 @@ describe("cli program", () => { true, undefined, runtime, - undefined, + expect.any(AbortSignal), { heartbeatSeconds: 90, replyHeartbeatNow: true }, ); - }); - - it("runs relay heartbeat command", async () => { - monitorWebProvider.mockResolvedValue(undefined); - const originalExit = runtime.exit; - runtime.exit = vi.fn(); - const program = buildProgram(); - await program.parseAsync(["relay:heartbeat"], { from: "user" }); - expect(logWebSelfId).toHaveBeenCalled(); - expect(runtime.exit).not.toHaveBeenCalled(); - runtime.exit = originalExit; + expect(monitorTelegramProvider).not.toHaveBeenCalled(); }); 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(); + await program.parseAsync(["relay", "--provider", "telegram"], { + from: "user", + }); + expect(monitorTelegramProvider).toHaveBeenCalledWith( + expect.objectContaining({ token: "token123" }), + ); + expect(monitorWebProvider).not.toHaveBeenCalled(); + process.env.TELEGRAM_BOT_TOKEN = prev; + }); + + it("errors when telegram provider requested without token", async () => { + const program = buildProgram(); + const prev = process.env.TELEGRAM_BOT_TOKEN; + process.env.TELEGRAM_BOT_TOKEN = ""; + await expect( + program.parseAsync(["relay", "--provider", "telegram"], { + from: "user", + }), + ).rejects.toThrow(); + expect(runtime.error).toHaveBeenCalled(); + expect(runtime.exit).toHaveBeenCalled(); process.env.TELEGRAM_BOT_TOKEN = prev; }); diff --git a/src/cli/program.ts b/src/cli/program.ts index c61907615..e6aa5cad4 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -403,7 +403,13 @@ Examples: program .command("relay") - .description("Auto-reply to inbound WhatsApp messages (web provider)") + .description( + "Auto-reply to inbound messages across configured providers (web, Telegram)", + ) + .option( + "--provider ", + "Which providers to start: auto (default), web, telegram, or all", + ) .option( "--web-heartbeat ", "Heartbeat interval for web relay health logs (seconds)", @@ -422,13 +428,30 @@ Examples: "Run a heartbeat immediately when relay starts", false, ) + .option("--webhook", "Run Telegram webhook server instead of long-poll", false) + .option( + "--webhook-path ", + "Telegram webhook path (default /telegram-webhook when webhook enabled)", + ) + .option( + "--webhook-secret ", + "Secret token to verify Telegram webhook requests", + ) + .option("--port ", "Port for Telegram webhook server (default 8787)") + .option( + "--webhook-url ", + "Public Telegram webhook URL to register (overrides localhost autodetect)", + ) .option("--verbose", "Verbose logging", false) .addHelpText( "after", ` Examples: - clawdis relay # uses your linked web session - clawdis relay --web-heartbeat 60 # override heartbeat interval + clawdis relay # starts WhatsApp; also Telegram if bot token set + clawdis relay --provider web # force WhatsApp-only + clawdis relay --provider telegram # Telegram-only (needs TELEGRAM_BOT_TOKEN) + clawdis relay --heartbeat-now # send immediate agent heartbeat on start (web) + clawdis relay --web-heartbeat 60 # override WhatsApp heartbeat interval # Troubleshooting: docs/refactor/web-relay-troubleshooting.md `, ) @@ -436,6 +459,50 @@ Examples: setVerbose(Boolean(opts.verbose)); const { file: logFile, level: logLevel } = getResolvedLoggerSettings(); defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`)); + + const providerOpt = (opts.provider ?? "auto").toLowerCase(); + const cfg = loadConfig(); + const telegramToken = + process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken; + + let startWeb = false; + let startTelegram = false; + switch (providerOpt) { + case "web": + startWeb = true; + break; + case "telegram": + startTelegram = true; + break; + case "all": + startWeb = true; + startTelegram = true; + break; + case "auto": + default: + startWeb = true; + startTelegram = Boolean(telegramToken); + break; + } + + if (startTelegram && !telegramToken) { + defaultRuntime.error( + danger( + "Telegram relay requires TELEGRAM_BOT_TOKEN or telegram.botToken in config", + ), + ); + defaultRuntime.exit(1); + return; + } + + if (!startWeb && !startTelegram) { + defaultRuntime.error( + danger("No providers selected. Use --provider web|telegram|all."), + ); + defaultRuntime.exit(1); + return; + } + const webHeartbeat = opts.webHeartbeat !== undefined ? Number.parseInt(String(opts.webHeartbeat), 10) @@ -490,30 +557,37 @@ Examples: defaultRuntime.exit(1); } - const webTuning: WebMonitorTuning = {}; - if (webHeartbeat !== undefined) webTuning.heartbeatSeconds = webHeartbeat; - if (heartbeatNow) webTuning.replyHeartbeatNow = true; - const reconnect: WebMonitorTuning["reconnect"] = {}; - if (webRetries !== undefined) reconnect.maxAttempts = webRetries; - if (webRetryInitial !== undefined) reconnect.initialMs = webRetryInitial; - if (webRetryMax !== undefined) reconnect.maxMs = webRetryMax; - if (Object.keys(reconnect).length > 0) { - webTuning.reconnect = reconnect; - } - logWebSelfId(defaultRuntime, true); - const cfg = loadConfig(); - const effectiveHeartbeat = resolveHeartbeatSeconds( - cfg, - webTuning.heartbeatSeconds, - ); - const effectivePolicy = resolveReconnectPolicy(cfg, webTuning.reconnect); - defaultRuntime.log( - info( - `Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}→${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`, - ), - ); - try { - // Start loopback web chat server unless disabled. + const controller = new AbortController(); + const stopAll = () => controller.abort(); + process.once("SIGINT", stopAll); + + const runners: Array> = []; + + if (startWeb) { + const webTuning: WebMonitorTuning = {}; + if (webHeartbeat !== undefined) + webTuning.heartbeatSeconds = webHeartbeat; + if (heartbeatNow) webTuning.replyHeartbeatNow = true; + const reconnect: WebMonitorTuning["reconnect"] = {}; + if (webRetries !== undefined) reconnect.maxAttempts = webRetries; + if (webRetryInitial !== undefined) + reconnect.initialMs = webRetryInitial; + if (webRetryMax !== undefined) reconnect.maxMs = webRetryMax; + if (Object.keys(reconnect).length > 0) { + webTuning.reconnect = reconnect; + } + logWebSelfId(defaultRuntime, true); + const effectiveHeartbeat = resolveHeartbeatSeconds( + cfg, + webTuning.heartbeatSeconds, + ); + const effectivePolicy = resolveReconnectPolicy(cfg, webTuning.reconnect); + defaultRuntime.log( + info( + `Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}→${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`, + ), + ); + const webchatServer = await ensureWebChatServerFromConfig(); if (webchatServer) { defaultRuntime.log( @@ -523,146 +597,59 @@ Examples: ); } - await monitorWebProvider( - Boolean(opts.verbose), - undefined, - true, - undefined, - defaultRuntime, - undefined, - webTuning, - ); - return; - } catch (err) { - defaultRuntime.error( - danger( - `Web relay failed: ${String(err)}. Re-link with 'clawdis login --verbose'.`, + runners.push( + monitorWebProvider( + Boolean(opts.verbose), + undefined, + true, + undefined, + defaultRuntime, + controller.signal, + webTuning, ), ); - defaultRuntime.exit(1); } - }); - program - .command("relay:heartbeat") - .description("Run relay with an immediate heartbeat; requires web provider") - .option("--verbose", "Verbose logging", false) - .action(async (opts) => { - setVerbose(Boolean(opts.verbose)); - const { file: logFile, level: logLevel } = getResolvedLoggerSettings(); - defaultRuntime.log(info(`logs: ${logFile} (level ${logLevel})`)); - - logWebSelfId(defaultRuntime, true); - const cfg = loadConfig(); - const effectiveHeartbeat = resolveHeartbeatSeconds(cfg, undefined); - const effectivePolicy = resolveReconnectPolicy(cfg, undefined); - defaultRuntime.log( - info( - `Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}→${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`, - ), - ); - - try { - await monitorWebProvider( - Boolean(opts.verbose), - undefined, - true, - undefined, - defaultRuntime, - undefined, - { replyHeartbeatNow: true }, - ); - } catch (err) { - defaultRuntime.error( - danger( - `Web relay failed: ${String(err)}. Re-link with 'clawdis login --provider web'.`, - ), - ); - defaultRuntime.exit(1); - } - }); - - program - .command("relay:telegram") - .description("Auto-reply to Telegram (Bot API via grammY)") - .option("--verbose", "Verbose logging", false) - .option("--webhook", "Run webhook server instead of long-poll", false) - .option( - "--webhook-path ", - "Webhook path (default /telegram-webhook when webhook enabled)", - ) - .option( - "--webhook-secret ", - "Secret token to verify Telegram webhook requests", - ) - .option("--port ", "Port for webhook server (default 8787)") - .option( - "--webhook-url ", - "Public webhook URL to register (overrides localhost autodetect)", - ) - .addHelpText( - "after", - ` -Examples: - clawdis relay:telegram # uses TELEGRAM_BOT_TOKEN env - TELEGRAM_BOT_TOKEN=xxx clawdis relay:telegram --verbose - TELEGRAM_BOT_TOKEN=xxx clawdis relay:telegram --webhook --port 9000 --webhook-secret secret -`, - ) - .action(async (opts) => { - setVerbose(Boolean(opts.verbose)); - const token = - process.env.TELEGRAM_BOT_TOKEN ?? loadConfig().telegram?.botToken; - if (!token) { - defaultRuntime.error( - danger( - "Set TELEGRAM_BOT_TOKEN or telegram.botToken to use telegram relay", - ), - ); - defaultRuntime.exit(1); - return; - } - const useWebhook = Boolean(opts.webhook); - if (useWebhook) { - const port = opts.port ? Number.parseInt(String(opts.port), 10) : 8787; - const path = opts.webhookPath ?? "/telegram-webhook"; - try { + if (startTelegram) { + const useWebhook = Boolean(opts.webhook); + const telegramRunner = (async () => { const { monitorTelegramProvider } = await import( "../telegram/monitor.js" ); - await monitorTelegramProvider({ - token, - useWebhook: true, - webhookPath: path, - webhookPort: port, - webhookSecret: - opts.webhookSecret ?? loadConfig().telegram?.webhookSecret, + const sharedOpts = { + token: telegramToken, runtime: defaultRuntime, - proxyFetch: undefined, - // register with provided public URL when given - webhookUrl: opts.webhookUrl, - }); - } catch (err) { - defaultRuntime.error( - danger(`Telegram webhook server failed: ${String(err)}`), - ); - defaultRuntime.exit(1); - } - return; + abortSignal: controller.signal, + } as const; + if (useWebhook) { + const port = opts.port + ? Number.parseInt(String(opts.port), 10) + : 8787; + const path = opts.webhookPath ?? "/telegram-webhook"; + return monitorTelegramProvider({ + ...sharedOpts, + useWebhook: true, + webhookPath: path, + webhookPort: port, + webhookSecret: opts.webhookSecret ?? cfg.telegram?.webhookSecret, + webhookUrl: opts.webhookUrl ?? cfg.telegram?.webhookUrl, + }); + } + return monitorTelegramProvider(sharedOpts); + })(); + runners.push(telegramRunner); } + try { - await import("../telegram/monitor.js").then((m) => - m.monitorTelegramProvider({ - token, - runtime: defaultRuntime, - }), - ); + await Promise.all(runners); } catch (err) { - defaultRuntime.error(danger(`Telegram relay failed: ${String(err)}`)); + defaultRuntime.error(danger(`Relay failed: ${String(err)}`)); defaultRuntime.exit(1); } }); + // relay is the single entry point; heartbeat/Telegram helpers removed. + program .command("status") .description("Show web session health and recent session recipients")