mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-09 05:19:32 +08:00
feat(cli): unify relay providers and heartbeat flag
This commit is contained in:
@@ -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:<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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user