feat(cli): unify relay providers and heartbeat flag

This commit is contained in:
Peter Steinberger
2025-12-08 15:22:10 +01:00
parent 0e4379f075
commit 90a0bb5acb
7 changed files with 170 additions and 175 deletions

View File

@@ -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 dont 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:<level>`; 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.

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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 <uuid>` 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 <E.164>` or `--all` for manual heartbeats to avoid ambiguous targets.

View File

@@ -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 <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 <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:<chatId>` and require mention/command to trigger replies.
6) Optional allowlist: reuse `inbound.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`).

View File

@@ -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;
});

View File

@@ -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 <auto|web|telegram|all>",
"Which providers to start: auto (default), web, telegram, or all",
)
.option(
"--web-heartbeat <seconds>",
"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 <path>",
"Telegram webhook path (default /telegram-webhook when webhook enabled)",
)
.option(
"--webhook-secret <secret>",
"Secret token to verify Telegram webhook requests",
)
.option("--port <port>", "Port for Telegram webhook server (default 8787)")
.option(
"--webhook-url <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<Promise<unknown>> = [];
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 <path>",
"Webhook path (default /telegram-webhook when webhook enabled)",
)
.option(
"--webhook-secret <secret>",
"Secret token to verify Telegram webhook requests",
)
.option("--port <port>", "Port for webhook server (default 8787)")
.option(
"--webhook-url <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")