diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml
index 8d078774b..2b2f80130 100644
--- a/.github/workflows/labeler.yml
+++ b/.github/workflows/labeler.yml
@@ -21,3 +21,4 @@ jobs:
with:
configuration-path: .github/labeler.yml
repo-token: ${{ steps.app-token.outputs.token }}
+ sync-labels: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f1330931..791dbcd7d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,9 +6,12 @@ Docs: https://docs.clawd.bot
Status: unreleased.
### Changes
+- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
+- Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk.
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
- Docs: add migration guide for moving to a new machine. (#2381)
+- Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN.
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
@@ -36,12 +39,14 @@ Status: unreleased.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
+- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99.
- Security: use Windows ACLs for permission audits and fixes on Windows. (#1957)
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
+- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21.
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
@@ -50,8 +55,19 @@ Status: unreleased.
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes
+- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg.
+- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg.
+- Security: pin npm overrides to keep tar@7.5.4 for install toolchains.
+- Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez.
+- CLI: recognize versioned Node executables when parsing argv. (#2490) Thanks @David-Marsh-Photo.
+- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204.
+- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein.
+- CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481.
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
+- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
+- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
+- Telegram: log fetch/API errors in delivery to avoid unhandled rejections. (#2492) Thanks @altryne.
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
- Build: align memory-core peer dependency with lockfile.
diff --git a/README.md b/README.md
index 535cd1c75..a5daba163 100644
--- a/README.md
+++ b/README.md
@@ -477,35 +477,36 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
Thanks to all clawtributors:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/docs/assets/terminal.css b/docs/assets/terminal.css
index 23283d651..e5e51af9e 100644
--- a/docs/assets/terminal.css
+++ b/docs/assets/terminal.css
@@ -115,6 +115,9 @@ body::after {
}
.shell {
+ position: sticky;
+ top: 0;
+ z-index: 100;
padding: 22px 16px 10px;
}
diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md
index 2d9025f51..8151bfed1 100644
--- a/docs/channels/matrix.md
+++ b/docs/channels/matrix.md
@@ -10,7 +10,7 @@ on any homeserver, so you need a Matrix account for the bot. Once it is logged i
the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too,
but it requires E2EE to be enabled.
-Status: supported via plugin (matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
+Status: supported via plugin (@vector-im/matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
polls (send + poll-start as text), location, and E2EE (with crypto support).
## Plugin required
diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md
index e708e2e64..39f3a2ec3 100644
--- a/docs/channels/telegram.md
+++ b/docs/channels/telegram.md
@@ -529,6 +529,7 @@ Provider options:
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
+- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts.
- `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
- `channels.telegram.webhookUrl`: enable webhook mode.
- `channels.telegram.webhookSecret`: webhook secret (optional).
diff --git a/docs/docs.json b/docs/docs.json
index 2cc5ae78b..01a338a18 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -345,10 +345,6 @@
"source": "/auth-monitoring",
"destination": "/automation/auth-monitoring"
},
- {
- "source": "/scripts",
- "destination": "/scripts"
- },
{
"source": "/camera",
"destination": "/nodes/camera"
@@ -805,6 +801,10 @@
"source": "/install/railway/",
"destination": "/railway"
},
+ {
+ "source": "/install/northflank/",
+ "destination": "/northflank"
+ },
{
"source": "/gcp",
"destination": "/platforms/gcp"
@@ -852,6 +852,7 @@
"install/docker",
"railway",
"render",
+ "northflank",
"install/bun"
]
},
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index eaba866b1..9c850e070 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -954,6 +954,8 @@ Notes:
- `commands.debug: true` enables `/debug` (runtime-only overrides).
- `commands.restart: true` enables `/restart` and the gateway tool restart action.
- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies.
+- Slash commands and directives are only honored for **authorized senders**. Authorization is derived from
+ channel allowlists/pairing plus `commands.useAccessGroups`.
### `web` (WhatsApp web channel runtime)
@@ -1027,6 +1029,9 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
maxDelayMs: 30000,
jitter: 0.1
},
+ network: { // transport overrides
+ autoSelectFamily: false
+ },
proxy: "socks5://localhost:9050",
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: "secret",
diff --git a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md
index d28481ebb..d7fd921e7 100644
--- a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md
+++ b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md
@@ -59,6 +59,8 @@ Two layers matter:
Rules of thumb:
- `deny` always wins.
- If `allow` is non-empty, everything else is treated as blocked.
+- Tool policy is the hard stop: `/exec` cannot override a denied `exec` tool.
+- `/exec` only changes session defaults for authorized senders; it does not grant tool access.
Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.2`).
### Tool groups (shorthands)
@@ -95,6 +97,7 @@ Elevated does **not** grant extra tools; it only affects `exec`.
- Use `/elevated full` to skip exec approvals for the session.
- If you’re already running direct, elevated is effectively a no-op (still gated).
- Elevated is **not** skill-scoped and does **not** override tool allow/deny.
+- `/exec` is separate from elevated. It only adjusts per-session exec defaults for authorized senders.
Gates:
- Enablement: `tools.elevated.enabled` (and optionally `agents.list[].tools.elevated.enabled`)
diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md
index b9b1bd8fe..fcbc46b9b 100644
--- a/docs/gateway/sandboxing.md
+++ b/docs/gateway/sandboxing.md
@@ -142,6 +142,8 @@ Tool allow/deny policies still apply before sandbox rules. If a tool is denied
globally or per-agent, sandboxing doesn’t bring it back.
`tools.elevated` is an explicit escape hatch that runs `exec` on the host.
+`/exec` directives only apply for authorized senders and persist per session; to hard-disable
+`exec`, use tool policy deny (see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)).
Debugging:
- Use `clawdbot sandbox explain` to inspect effective sandbox mode, tool policy, and fix-it config keys.
diff --git a/docs/gateway/security.md b/docs/gateway/security.md
index 700e6fdaf..52671d864 100644
--- a/docs/gateway/security.md
+++ b/docs/gateway/security.md
@@ -142,6 +142,16 @@ Clawdbot’s stance:
- **Scope next:** decide where the bot is allowed to act (group allowlists + mention gating, tools, sandboxing, device permissions).
- **Model last:** assume the model can be manipulated; design so manipulation has limited blast radius.
+## Command authorization model
+
+Slash commands and directives are only honored for **authorized senders**. Authorization is derived from
+channel allowlists/pairing plus `commands.useAccessGroups` (see [Configuration](/gateway/configuration)
+and [Slash commands](/tools/slash-commands)). If a channel allowlist is empty or includes `"*"`,
+commands are effectively open for that channel.
+
+`/exec` is a session-only convenience for authorized operators. It does **not** write config or
+change other sessions.
+
## Plugins/extensions
Plugins run **in-process** with the Gateway. Treat them as trusted code:
diff --git a/docs/install/node.md b/docs/install/node.md
index 6a622e198..3075b6207 100644
--- a/docs/install/node.md
+++ b/docs/install/node.md
@@ -1,9 +1,10 @@
---
+title: "Node.js + npm (PATH sanity)"
summary: "Node.js + npm install sanity: versions, PATH, and global installs"
read_when:
- - You installed Clawdbot but `clawdbot` is “command not found”
- - You’re setting up Node.js/npm on a new machine
- - `npm install -g ...` fails with permissions or PATH issues
+ - "You installed Clawdbot but `clawdbot` is “command not found”"
+ - "You’re setting up Node.js/npm on a new machine"
+ - "npm install -g ... fails with permissions or PATH issues"
---
# Node.js + npm (PATH sanity)
diff --git a/docs/northflank.mdx b/docs/northflank.mdx
new file mode 100644
index 000000000..aae9c6a22
--- /dev/null
+++ b/docs/northflank.mdx
@@ -0,0 +1,53 @@
+---
+title: Deploy on Northflank
+---
+
+Deploy Clawdbot on Northflank with a one-click template and finish setup in your browser.
+This is the easiest “no terminal on the server” path: Northflank runs the Gateway for you,
+and you configure everything via the `/setup` web wizard.
+
+## How to get started
+
+1. Click [Deploy Clawdbot](https://northflank.com/stacks/deploy-clawdbot) to open the template.
+2. Create an [account on Northflank](https://app.northflank.com/signup) if you don’t already have one.
+3. Click **Deploy Clawdbot now**.
+4. Set the required environment variable: `SETUP_PASSWORD`.
+5. Click **Deploy stack** to build and run the Clawdbot template.
+6. Wait for the deployment to complete, then click **View resources**.
+7. Open the Clawdbot service.
+8. Open the public Clawdbot URL and complete setup at `/setup`.
+9. Open the Control UI at `/clawdbot`.
+
+## What you get
+
+- Hosted Clawdbot Gateway + Control UI
+- Web setup wizard at `/setup` (no terminal commands)
+- Persistent storage via Northflank Volume (`/data`) so config/credentials/workspace survive redeploys
+
+## Setup flow
+
+1) Visit `https:///setup` and enter your `SETUP_PASSWORD`.
+2) Choose a model/auth provider and paste your key.
+3) (Optional) Add Telegram/Discord/Slack tokens.
+4) Click **Run setup**.
+5) Open the Control UI at `https:///clawdbot`
+
+If Telegram DMs are set to pairing, the setup wizard can approve the pairing code.
+
+## Getting chat tokens
+
+### Telegram bot token
+
+1) Message `@BotFather` in Telegram
+2) Run `/newbot`
+3) Copy the token (looks like `123456789:AA...`)
+4) Paste it into `/setup`
+
+### Discord bot token
+
+1) Go to https://discord.com/developers/applications
+2) **New Application** → choose a name
+3) **Bot** → **Add Bot**
+4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup)
+5) Copy the **Bot Token** and paste into `/setup`
+6) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md
index cd574b26e..46713c939 100644
--- a/docs/plugins/voice-call.md
+++ b/docs/plugins/voice-call.md
@@ -104,6 +104,7 @@ Notes:
- `mock` is a local dev provider (no network calls).
- `skipSignatureVerification` is for local testing only.
- If you use ngrok free tier, set `publicUrl` to the exact ngrok URL; signature verification is always enforced.
+- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
- Ngrok free tier URLs can change or add interstitial behavior; if `publicUrl` drifts, Twilio signatures will fail. For production, prefer a stable domain or Tailscale funnel.
## TTS for calls
diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md
index 863c53a1f..7635bbbee 100644
--- a/docs/tools/elevated.md
+++ b/docs/tools/elevated.md
@@ -23,6 +23,7 @@ read_when:
- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require.
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
+- **Separate from `/exec`**: `/exec` adjusts per-session defaults for authorized senders and does not require elevated.
## Resolution order
1. Inline directive on the message (applies only to that message).
diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md
index ec350f9d9..2ec8ec191 100644
--- a/docs/tools/exec-approvals.md
+++ b/docs/tools/exec-approvals.md
@@ -216,6 +216,9 @@ Approval-gated execs reuse the approval id as the `runId` in these messages for
- **full** is powerful; prefer allowlists when possible.
- **ask** keeps you in the loop while still allowing fast approvals.
- Per-agent allowlists prevent one agent’s approvals from leaking into others.
+- Approvals only apply to host exec requests from **authorized senders**. Unauthorized senders cannot issue `/exec`.
+- `/exec security=full` is a session-level convenience for authorized operators and skips approvals by design.
+ To hard-block host exec, set approvals security to `deny` or deny the `exec` tool via tool policy.
Related:
- [Exec tool](/tools/exec)
diff --git a/docs/tools/exec.md b/docs/tools/exec.md
index 9579a5c27..2524c3665 100644
--- a/docs/tools/exec.md
+++ b/docs/tools/exec.md
@@ -91,6 +91,13 @@ Example:
/exec host=gateway security=allowlist ask=on-miss node=mac-1
```
+## Authorization model
+
+`/exec` is only honored for **authorized senders** (channel allowlists/pairing plus `commands.useAccessGroups`).
+It updates **session state only** and does not write config. To hard-disable exec, deny it via tool
+policy (`tools.deny: ["exec"]` or per-agent). Host approvals still apply unless you explicitly set
+`security=full` and `ask=off`.
+
## Exec approvals (companion app / node host)
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md
index 93b51d5ae..138ede9d0 100644
--- a/docs/tools/slash-commands.md
+++ b/docs/tools/slash-commands.md
@@ -16,6 +16,8 @@ There are two related systems:
- Directives are stripped from the message before the model sees it.
- In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings.
- In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement.
+ - Directives are only applied for **authorized senders** (channel allowlists/pairing plus `commands.useAccessGroups`).
+ Unauthorized senders see directives treated as plain text.
There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status`, `/whoami` (`/id`).
They run immediately, are stripped before the model sees the message, and the remaining text continues through the normal flow.
diff --git a/docs/vps.md b/docs/vps.md
index 192ab830e..08910733f 100644
--- a/docs/vps.md
+++ b/docs/vps.md
@@ -11,6 +11,8 @@ deployments work at a high level.
## Pick a provider
+- **Railway** (one‑click + browser setup): [Railway](/railway)
+- **Northflank** (one‑click + browser setup): [Northflank](/northflank)
- **Oracle Cloud (Always Free)**: [Oracle](/platforms/oracle) — $0/month (Always Free, ARM; capacity/signup can be finicky)
- **Fly.io**: [Fly.io](/platforms/fly)
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts
index 12aef679c..76c9eebf6 100644
--- a/extensions/bluebubbles/src/monitor.test.ts
+++ b/extensions/bluebubbles/src/monitor.test.ts
@@ -146,8 +146,14 @@ function createMockRuntime(): PluginRuntime {
resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
},
debounce: {
- createInboundDebouncer: vi.fn() as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
- resolveInboundDebounceMs: vi.fn() as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
+ // Create a pass-through debouncer that immediately calls onFlush
+ createInboundDebouncer: vi.fn((params: { onFlush: (items: unknown[]) => Promise }) => ({
+ enqueue: async (item: unknown) => {
+ await params.onFlush([item]);
+ },
+ flushKey: vi.fn(),
+ })) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
+ resolveInboundDebounceMs: vi.fn(() => 0) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
},
commands: {
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts
index 8635b183e..98431775a 100644
--- a/extensions/bluebubbles/src/monitor.ts
+++ b/extensions/bluebubbles/src/monitor.ts
@@ -250,8 +250,178 @@ type WebhookTarget = {
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
+/**
+ * Entry type for debouncing inbound messages.
+ * Captures the normalized message and its target for later combined processing.
+ */
+type BlueBubblesDebounceEntry = {
+ message: NormalizedWebhookMessage;
+ target: WebhookTarget;
+};
+
+/**
+ * Default debounce window for inbound message coalescing (ms).
+ * This helps combine URL text + link preview balloon messages that BlueBubbles
+ * sends as separate webhook events when no explicit inbound debounce config exists.
+ */
+const DEFAULT_INBOUND_DEBOUNCE_MS = 350;
+
+/**
+ * Combines multiple debounced messages into a single message for processing.
+ * Used when multiple webhook events arrive within the debounce window.
+ */
+function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage {
+ if (entries.length === 0) {
+ throw new Error("Cannot combine empty entries");
+ }
+ if (entries.length === 1) {
+ return entries[0].message;
+ }
+
+ // Use the first message as the base (typically the text message)
+ const first = entries[0].message;
+
+ // Combine text from all entries, filtering out duplicates and empty strings
+ const seenTexts = new Set();
+ const textParts: string[] = [];
+
+ for (const entry of entries) {
+ const text = entry.message.text.trim();
+ if (!text) continue;
+ // Skip duplicate text (URL might be in both text message and balloon)
+ const normalizedText = text.toLowerCase();
+ if (seenTexts.has(normalizedText)) continue;
+ seenTexts.add(normalizedText);
+ textParts.push(text);
+ }
+
+ // Merge attachments from all entries
+ const allAttachments = entries.flatMap((e) => e.message.attachments ?? []);
+
+ // Use the latest timestamp
+ const timestamps = entries
+ .map((e) => e.message.timestamp)
+ .filter((t): t is number => typeof t === "number");
+ const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp;
+
+ // Collect all message IDs for reference
+ const messageIds = entries
+ .map((e) => e.message.messageId)
+ .filter((id): id is string => Boolean(id));
+
+ // Prefer reply context from any entry that has it
+ const entryWithReply = entries.find((e) => e.message.replyToId);
+
+ return {
+ ...first,
+ text: textParts.join(" "),
+ attachments: allAttachments.length > 0 ? allAttachments : first.attachments,
+ timestamp: latestTimestamp,
+ // Use first message's ID as primary (for reply reference), but we've coalesced others
+ messageId: messageIds[0] ?? first.messageId,
+ // Preserve reply context if present
+ replyToId: entryWithReply?.message.replyToId ?? first.replyToId,
+ replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody,
+ replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender,
+ // Clear balloonBundleId since we've combined (the combined message is no longer just a balloon)
+ balloonBundleId: undefined,
+ };
+}
+
const webhookTargets = new Map();
+/**
+ * Maps webhook targets to their inbound debouncers.
+ * Each target gets its own debouncer keyed by a unique identifier.
+ */
+const targetDebouncers = new Map<
+ WebhookTarget,
+ ReturnType
+>();
+
+function resolveBlueBubblesDebounceMs(
+ config: ClawdbotConfig,
+ core: BlueBubblesCoreRuntime,
+): number {
+ const inbound = config.messages?.inbound;
+ const hasExplicitDebounce =
+ typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
+ if (!hasExplicitDebounce) return DEFAULT_INBOUND_DEBOUNCE_MS;
+ return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
+}
+
+/**
+ * Creates or retrieves a debouncer for a webhook target.
+ */
+function getOrCreateDebouncer(target: WebhookTarget) {
+ const existing = targetDebouncers.get(target);
+ if (existing) return existing;
+
+ const { account, config, runtime, core } = target;
+
+ const debouncer = core.channel.debounce.createInboundDebouncer({
+ debounceMs: resolveBlueBubblesDebounceMs(config, core),
+ buildKey: (entry) => {
+ const msg = entry.message;
+ // Build key from account + chat + sender to coalesce messages from same source
+ const chatKey =
+ msg.chatGuid?.trim() ??
+ msg.chatIdentifier?.trim() ??
+ (msg.chatId ? String(msg.chatId) : "dm");
+ return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`;
+ },
+ shouldDebounce: (entry) => {
+ const msg = entry.message;
+ // Skip debouncing for messages with attachments - process immediately
+ if (msg.attachments && msg.attachments.length > 0) return false;
+ // Skip debouncing for from-me messages (they're just cached, not processed)
+ if (msg.fromMe) return false;
+ // Skip debouncing for control commands - process immediately
+ if (core.channel.text.hasControlCommand(msg.text, config)) return false;
+ // Debounce normal text messages and URL balloon messages
+ return true;
+ },
+ onFlush: async (entries) => {
+ if (entries.length === 0) return;
+
+ // Use target from first entry (all entries have same target due to key structure)
+ const flushTarget = entries[0].target;
+
+ if (entries.length === 1) {
+ // Single message - process normally
+ await processMessage(entries[0].message, flushTarget);
+ return;
+ }
+
+ // Multiple messages - combine and process
+ const combined = combineDebounceEntries(entries);
+
+ if (core.logging.shouldLogVerbose()) {
+ const count = entries.length;
+ const preview = combined.text.slice(0, 50);
+ runtime.log?.(
+ `[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
+ );
+ }
+
+ await processMessage(combined, flushTarget);
+ },
+ onError: (err) => {
+ runtime.error?.(`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`);
+ },
+ });
+
+ targetDebouncers.set(target, debouncer);
+ return debouncer;
+}
+
+/**
+ * Removes a debouncer for a target (called during unregistration).
+ */
+function removeDebouncer(target: WebhookTarget): void {
+ targetDebouncers.delete(target);
+}
+
function normalizeWebhookPath(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "/";
@@ -275,6 +445,8 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v
} else {
webhookTargets.delete(key);
}
+ // Clean up debouncer when target is unregistered
+ removeDebouncer(normalizedTarget);
};
}
@@ -1205,7 +1377,10 @@ export async function handleBlueBubblesWebhookRequest(
);
});
} else if (message) {
- processMessage(message, target).catch((err) => {
+ // Route messages through debouncer to coalesce rapid-fire events
+ // (e.g., text message + URL balloon arriving as separate webhooks)
+ const debouncer = getOrCreateDebouncer(target);
+ debouncer.enqueue({ message, target }).catch((err) => {
target.runtime.error?.(
`[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
);
diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json
index 7fa12bc74..625c92df0 100644
--- a/extensions/matrix/package.json
+++ b/extensions/matrix/package.json
@@ -26,7 +26,7 @@
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"markdown-it": "14.1.0",
- "matrix-bot-sdk": "0.8.0",
+ "@vector-im/matrix-bot-sdk": "0.8.0-element.3",
"music-metadata": "^11.10.6",
"zod": "^4.3.6"
},
diff --git a/extensions/matrix/src/matrix/actions/messages.ts b/extensions/matrix/src/matrix/actions/messages.ts
index dae1a0f20..60f69e219 100644
--- a/extensions/matrix/src/matrix/actions/messages.ts
+++ b/extensions/matrix/src/matrix/actions/messages.ts
@@ -95,7 +95,7 @@ export async function readMatrixMessages(
: 20;
const token = opts.before?.trim() || opts.after?.trim() || undefined;
const dir = opts.after ? "f" : "b";
- // matrix-bot-sdk uses doRequest for room messages
+ // @vector-im/matrix-bot-sdk uses doRequest for room messages
const res = await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
diff --git a/extensions/matrix/src/matrix/actions/reactions.ts b/extensions/matrix/src/matrix/actions/reactions.ts
index 5c3f65305..044ef46c5 100644
--- a/extensions/matrix/src/matrix/actions/reactions.ts
+++ b/extensions/matrix/src/matrix/actions/reactions.ts
@@ -21,7 +21,7 @@ export async function listMatrixReactions(
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 100;
- // matrix-bot-sdk uses doRequest for relations
+ // @vector-im/matrix-bot-sdk uses doRequest for relations
const res = await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
diff --git a/extensions/matrix/src/matrix/actions/room.ts b/extensions/matrix/src/matrix/actions/room.ts
index 1b52404dc..68cf9b0a0 100644
--- a/extensions/matrix/src/matrix/actions/room.ts
+++ b/extensions/matrix/src/matrix/actions/room.ts
@@ -9,9 +9,9 @@ export async function getMatrixMemberInfo(
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
- // matrix-bot-sdk uses getUserProfile
+ // @vector-im/matrix-bot-sdk uses getUserProfile
const profile = await client.getUserProfile(userId);
- // Note: matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
+ // Note: @vector-im/matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
// We'd need to fetch room state separately if needed
return {
userId,
@@ -36,7 +36,7 @@ export async function getMatrixRoomInfo(
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
- // matrix-bot-sdk uses getRoomState for state events
+ // @vector-im/matrix-bot-sdk uses getRoomState for state events
let name: string | null = null;
let topic: string | null = null;
let canonicalAlias: string | null = null;
diff --git a/extensions/matrix/src/matrix/actions/summary.ts b/extensions/matrix/src/matrix/actions/summary.ts
index f58d6a9b8..2fa2d27b3 100644
--- a/extensions/matrix/src/matrix/actions/summary.ts
+++ b/extensions/matrix/src/matrix/actions/summary.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import {
EventType,
diff --git a/extensions/matrix/src/matrix/actions/types.ts b/extensions/matrix/src/matrix/actions/types.ts
index 506e00783..75fddbd9c 100644
--- a/extensions/matrix/src/matrix/actions/types.ts
+++ b/extensions/matrix/src/matrix/actions/types.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
export const MsgType = {
Text: "m.text",
diff --git a/extensions/matrix/src/matrix/active-client.ts b/extensions/matrix/src/matrix/active-client.ts
index 9aa0ffdde..5ff540926 100644
--- a/extensions/matrix/src/matrix/active-client.ts
+++ b/extensions/matrix/src/matrix/active-client.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
let activeClient: MatrixClient | null = null;
diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts
index bc0729ddb..048c3bef9 100644
--- a/extensions/matrix/src/matrix/client/config.ts
+++ b/extensions/matrix/src/matrix/client/config.ts
@@ -1,4 +1,4 @@
-import { MatrixClient } from "matrix-bot-sdk";
+import { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../../runtime.js";
diff --git a/extensions/matrix/src/matrix/client/create-client.ts b/extensions/matrix/src/matrix/client/create-client.ts
index 01dc2e7ad..874da7e92 100644
--- a/extensions/matrix/src/matrix/client/create-client.ts
+++ b/extensions/matrix/src/matrix/client/create-client.ts
@@ -5,8 +5,8 @@ import {
MatrixClient,
SimpleFsStorageProvider,
RustSdkCryptoStorageProvider,
-} from "matrix-bot-sdk";
-import type { IStorageProvider, ICryptoStorageProvider } from "matrix-bot-sdk";
+} from "@vector-im/matrix-bot-sdk";
+import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
diff --git a/extensions/matrix/src/matrix/client/logging.ts b/extensions/matrix/src/matrix/client/logging.ts
index 7c4011fc5..5a7180597 100644
--- a/extensions/matrix/src/matrix/client/logging.ts
+++ b/extensions/matrix/src/matrix/client/logging.ts
@@ -1,4 +1,4 @@
-import { ConsoleLogger, LogService } from "matrix-bot-sdk";
+import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk";
let matrixSdkLoggingConfigured = false;
const matrixSdkBaseLogger = new ConsoleLogger();
diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts
index fcde28268..da10fc360 100644
--- a/extensions/matrix/src/matrix/client/shared.ts
+++ b/extensions/matrix/src/matrix/client/shared.ts
@@ -1,5 +1,5 @@
-import { LogService } from "matrix-bot-sdk";
-import type { MatrixClient } from "matrix-bot-sdk";
+import { LogService } from "@vector-im/matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { CoreConfig } from "../types.js";
import { createMatrixClient } from "./create-client.js";
@@ -157,7 +157,7 @@ export async function waitForMatrixSync(_params: {
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise {
- // matrix-bot-sdk handles sync internally in start()
+ // @vector-im/matrix-bot-sdk handles sync internally in start()
// This is kept for API compatibility but is essentially a no-op now
}
diff --git a/extensions/matrix/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts
index df2f58706..5777e43a7 100644
--- a/extensions/matrix/src/matrix/deps.ts
+++ b/extensions/matrix/src/matrix/deps.ts
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
-const MATRIX_SDK_PACKAGE = "matrix-bot-sdk";
+const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
export function isMatrixSdkAvailable(): boolean {
try {
@@ -30,9 +30,9 @@ export async function ensureMatrixSdkInstalled(params: {
if (isMatrixSdkAvailable()) return;
const confirm = params.confirm;
if (confirm) {
- const ok = await confirm("Matrix requires matrix-bot-sdk. Install now?");
+ const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?");
if (!ok) {
- throw new Error("Matrix requires matrix-bot-sdk (install dependencies first).");
+ throw new Error("Matrix requires @vector-im/matrix-bot-sdk (install dependencies first).");
}
}
@@ -52,6 +52,6 @@ export async function ensureMatrixSdkInstalled(params: {
);
}
if (!isMatrixSdkAvailable()) {
- throw new Error("Matrix dependency install completed but matrix-bot-sdk is still missing.");
+ throw new Error("Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing.");
}
}
diff --git a/extensions/matrix/src/matrix/monitor/auto-join.ts b/extensions/matrix/src/matrix/monitor/auto-join.ts
index 564c78995..5feb5bc3a 100644
--- a/extensions/matrix/src/matrix/monitor/auto-join.ts
+++ b/extensions/matrix/src/matrix/monitor/auto-join.ts
@@ -1,5 +1,5 @@
-import type { MatrixClient } from "matrix-bot-sdk";
-import { AutojoinRoomsMixin } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
+import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "../../types.js";
diff --git a/extensions/matrix/src/matrix/monitor/direct.ts b/extensions/matrix/src/matrix/monitor/direct.ts
index fff8383ca..cd2234fdd 100644
--- a/extensions/matrix/src/matrix/monitor/direct.ts
+++ b/extensions/matrix/src/matrix/monitor/direct.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
type DirectMessageCheck = {
roomId: string;
diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts
index af49693ff..3705eb356 100644
--- a/extensions/matrix/src/matrix/monitor/events.ts
+++ b/extensions/matrix/src/matrix/monitor/events.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import type { MatrixAuth } from "../client.js";
diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts
index 4542e113a..19f9be38d 100644
--- a/extensions/matrix/src/matrix/monitor/handler.ts
+++ b/extensions/matrix/src/matrix/monitor/handler.ts
@@ -1,4 +1,4 @@
-import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk";
+import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
import {
createReplyPrefixContext,
@@ -110,7 +110,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
try {
const eventType = event.type;
if (eventType === EventType.RoomMessageEncrypted) {
- // Encrypted messages are decrypted automatically by matrix-bot-sdk with crypto enabled
+ // Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled
return;
}
@@ -436,7 +436,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
threadReplies,
messageId,
threadRootId,
- isThreadRoot: false, // matrix-bot-sdk doesn't have this info readily available
+ isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available
});
const route = core.channel.routing.resolveAgentRoute({
diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts
index 35e75c4ed..0a203be41 100644
--- a/extensions/matrix/src/matrix/monitor/index.ts
+++ b/extensions/matrix/src/matrix/monitor/index.ts
@@ -244,7 +244,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
logVerboseMessage("matrix: client started");
- // matrix-bot-sdk client is already started via resolveSharedMatrixClient
+ // @vector-im/matrix-bot-sdk client is already started via resolveSharedMatrixClient
logger.info(`matrix: logged in as ${auth.userId}`);
// If E2EE is enabled, trigger device verification
diff --git a/extensions/matrix/src/matrix/monitor/location.ts b/extensions/matrix/src/matrix/monitor/location.ts
index 22374cad8..0054b6c6b 100644
--- a/extensions/matrix/src/matrix/monitor/location.ts
+++ b/extensions/matrix/src/matrix/monitor/location.ts
@@ -1,4 +1,4 @@
-import type { LocationMessageEventContent } from "matrix-bot-sdk";
+import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk";
import {
formatLocationText,
diff --git a/extensions/matrix/src/matrix/monitor/media.test.ts b/extensions/matrix/src/matrix/monitor/media.test.ts
index 10cbd8b47..28ed5046a 100644
--- a/extensions/matrix/src/matrix/monitor/media.test.ts
+++ b/extensions/matrix/src/matrix/monitor/media.test.ts
@@ -29,7 +29,7 @@ describe("downloadMatrixMedia", () => {
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
- } as unknown as import("matrix-bot-sdk").MatrixClient;
+ } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
const file = {
url: "mxc://example/file",
@@ -70,7 +70,7 @@ describe("downloadMatrixMedia", () => {
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
- } as unknown as import("matrix-bot-sdk").MatrixClient;
+ } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
const file = {
url: "mxc://example/file",
diff --git a/extensions/matrix/src/matrix/monitor/media.ts b/extensions/matrix/src/matrix/monitor/media.ts
index 1ade1d19c..0b33cca53 100644
--- a/extensions/matrix/src/matrix/monitor/media.ts
+++ b/extensions/matrix/src/matrix/monitor/media.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { getMatrixRuntime } from "../../runtime.js";
@@ -22,7 +22,7 @@ async function fetchMatrixMediaBuffer(params: {
mxcUrl: string;
maxBytes: number;
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
- // matrix-bot-sdk provides mxcToHttp helper
+ // @vector-im/matrix-bot-sdk provides mxcToHttp helper
const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) return null;
@@ -40,7 +40,7 @@ async function fetchMatrixMediaBuffer(params: {
/**
* Download and decrypt encrypted media from a Matrix room.
- * Uses matrix-bot-sdk's decryptMedia which handles both download and decryption.
+ * Uses @vector-im/matrix-bot-sdk's decryptMedia which handles both download and decryption.
*/
async function fetchEncryptedMediaBuffer(params: {
client: MatrixClient;
diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts
index f79ef5926..70ac9bacc 100644
--- a/extensions/matrix/src/matrix/monitor/replies.ts
+++ b/extensions/matrix/src/matrix/monitor/replies.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
import { sendMessageMatrix } from "../send.js";
diff --git a/extensions/matrix/src/matrix/monitor/room-info.ts b/extensions/matrix/src/matrix/monitor/room-info.ts
index e32b5b37a..cad377e1a 100644
--- a/extensions/matrix/src/matrix/monitor/room-info.ts
+++ b/extensions/matrix/src/matrix/monitor/room-info.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
export type MatrixRoomInfo = {
name?: string;
diff --git a/extensions/matrix/src/matrix/monitor/threads.ts b/extensions/matrix/src/matrix/monitor/threads.ts
index 3378d3b2b..4d618f329 100644
--- a/extensions/matrix/src/matrix/monitor/threads.ts
+++ b/extensions/matrix/src/matrix/monitor/threads.ts
@@ -1,4 +1,4 @@
-// Type for raw Matrix event from matrix-bot-sdk
+// Type for raw Matrix event from @vector-im/matrix-bot-sdk
type MatrixRawEvent = {
event_id: string;
sender: string;
diff --git a/extensions/matrix/src/matrix/monitor/types.ts b/extensions/matrix/src/matrix/monitor/types.ts
index c77cf0282..c910f931f 100644
--- a/extensions/matrix/src/matrix/monitor/types.ts
+++ b/extensions/matrix/src/matrix/monitor/types.ts
@@ -1,4 +1,4 @@
-import type { EncryptedFile, MessageEventContent } from "matrix-bot-sdk";
+import type { EncryptedFile, MessageEventContent } from "@vector-im/matrix-bot-sdk";
export const EventType = {
RoomMessage: "m.room.message",
diff --git a/extensions/matrix/src/matrix/probe.ts b/extensions/matrix/src/matrix/probe.ts
index 3bfdd1728..7bd54bdc4 100644
--- a/extensions/matrix/src/matrix/probe.ts
+++ b/extensions/matrix/src/matrix/probe.ts
@@ -49,7 +49,7 @@ export async function probeMatrix(params: {
accessToken: params.accessToken,
localTimeoutMs: params.timeoutMs,
});
- // matrix-bot-sdk uses getUserId() which calls whoami internally
+ // @vector-im/matrix-bot-sdk uses getUserId() which calls whoami internally
const userId = await client.getUserId();
result.ok = true;
result.userId = userId ?? null;
diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts
index c647eedb9..e82e18fb0 100644
--- a/extensions/matrix/src/matrix/send.test.ts
+++ b/extensions/matrix/src/matrix/send.test.ts
@@ -3,7 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import { setMatrixRuntime } from "../runtime.js";
-vi.mock("matrix-bot-sdk", () => ({
+vi.mock("@vector-im/matrix-bot-sdk", () => ({
ConsoleLogger: class {
trace = vi.fn();
debug = vi.fn();
@@ -60,7 +60,7 @@ const makeClient = () => {
sendMessage,
uploadContent,
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
- } as unknown as import("matrix-bot-sdk").MatrixClient;
+ } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
return { client, sendMessage, uploadContent };
};
diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts
index 264bd6429..1fed4198a 100644
--- a/extensions/matrix/src/matrix/send.ts
+++ b/extensions/matrix/src/matrix/send.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { PollInput } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
@@ -72,7 +72,7 @@ export async function sendMessageMatrix(
? buildThreadRelation(threadId, opts.replyToId)
: buildReplyRelation(opts.replyToId);
const sendContent = async (content: MatrixOutboundContent) => {
- // matrix-bot-sdk uses sendMessage differently
+ // @vector-im/matrix-bot-sdk uses sendMessage differently
const eventId = await client.sendMessage(roomId, content);
return eventId;
};
@@ -172,7 +172,7 @@ export async function sendPollMatrix(
const pollPayload = threadId
? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) }
: pollContent;
- // matrix-bot-sdk sendEvent returns eventId string directly
+ // @vector-im/matrix-bot-sdk sendEvent returns eventId string directly
const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload);
return {
diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts
index 2faa19091..5b9338054 100644
--- a/extensions/matrix/src/matrix/send/client.ts
+++ b/extensions/matrix/src/matrix/send/client.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient } from "../active-client.js";
@@ -57,7 +57,7 @@ export async function resolveMatrixClient(opts: {
// Ignore crypto prep failures for one-off sends; normal sync will retry.
}
}
- // matrix-bot-sdk uses start() instead of startClient()
+ // @vector-im/matrix-bot-sdk uses start() instead of startClient()
await client.start();
return { client, stopOnDone: true };
}
diff --git a/extensions/matrix/src/matrix/send/media.ts b/extensions/matrix/src/matrix/send/media.ts
index d4cf29805..8c564bddb 100644
--- a/extensions/matrix/src/matrix/send/media.ts
+++ b/extensions/matrix/src/matrix/send/media.ts
@@ -5,7 +5,7 @@ import type {
MatrixClient,
TimedFileInfo,
VideoFileInfo,
-} from "matrix-bot-sdk";
+} from "@vector-im/matrix-bot-sdk";
import { parseBuffer, type IFileInfo } from "music-metadata";
import { getMatrixRuntime } from "../../runtime.js";
diff --git a/extensions/matrix/src/matrix/send/targets.test.ts b/extensions/matrix/src/matrix/send/targets.test.ts
index 18499f895..7173b1cf6 100644
--- a/extensions/matrix/src/matrix/send/targets.test.ts
+++ b/extensions/matrix/src/matrix/send/targets.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { EventType } from "./types.js";
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
diff --git a/extensions/matrix/src/matrix/send/targets.ts b/extensions/matrix/src/matrix/send/targets.ts
index dde734ba2..6ec6ad6d7 100644
--- a/extensions/matrix/src/matrix/send/targets.ts
+++ b/extensions/matrix/src/matrix/send/targets.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { EventType, type MatrixDirectAccountData } from "./types.js";
diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts
index eb59f8a62..2b91327aa 100644
--- a/extensions/matrix/src/matrix/send/types.ts
+++ b/extensions/matrix/src/matrix/send/types.ts
@@ -6,7 +6,7 @@ import type {
TextualMessageEventContent,
TimedFileInfo,
VideoFileInfo,
-} from "matrix-bot-sdk";
+} from "@vector-im/matrix-bot-sdk";
// Message types
export const MsgType = {
@@ -85,7 +85,7 @@ export type MatrixSendResult = {
};
export type MatrixSendOpts = {
- client?: import("matrix-bot-sdk").MatrixClient;
+ client?: import("@vector-im/matrix-bot-sdk").MatrixClient;
mediaUrl?: string;
accountId?: string;
replyToId?: string;
diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts
index 28f24b788..80c034d44 100644
--- a/extensions/matrix/src/onboarding.ts
+++ b/extensions/matrix/src/onboarding.ts
@@ -185,7 +185,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
`Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`,
],
selectionHint: !sdkReady
- ? "install matrix-bot-sdk"
+ ? "install @vector-im/matrix-bot-sdk"
: configured
? "configured"
: "needs auth",
diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts
index f44f1074d..f03734130 100644
--- a/extensions/matrix/src/types.ts
+++ b/extensions/matrix/src/types.ts
@@ -53,7 +53,7 @@ export type MatrixConfig = {
password?: string;
/** Optional device name when logging in via password. */
deviceName?: string;
- /** Initial sync limit for startup (default: matrix-bot-sdk default). */
+ /** Initial sync limit for startup (default: @vector-im/matrix-bot-sdk default). */
initialSyncLimit?: number;
/** Enable end-to-end encryption (E2EE). Default: false. */
encryption?: boolean;
diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json
index c70da1395..af6a3f9cd 100644
--- a/extensions/memory-core/package.json
+++ b/extensions/memory-core/package.json
@@ -9,6 +9,6 @@
]
},
"peerDependencies": {
- "clawdbot": ">=2026.1.25"
+ "clawdbot": ">=2026.1.24-3"
}
}
diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts
index c83867a65..f54422d33 100644
--- a/extensions/msteams/src/reply-dispatcher.ts
+++ b/extensions/msteams/src/reply-dispatcher.ts
@@ -42,7 +42,7 @@ export function createMSTeamsReplyDispatcher(params: {
}) {
const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => {
- await params.context.sendActivities([{ type: "typing" }]);
+ await params.context.sendActivity({ type: "typing" });
};
const typingCallbacks = createTypingCallbacks({
start: sendTypingIndicator,
@@ -70,38 +70,38 @@ export function createMSTeamsReplyDispatcher(params: {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: params.cfg,
channel: "msteams",
- });
- const messages = renderReplyPayloadsToMessages([payload], {
- textChunkLimit: params.textLimit,
- chunkText: true,
- mediaMode: "split",
- tableMode,
- chunkMode,
- });
- const mediaMaxBytes = resolveChannelMediaMaxBytes({
- cfg: params.cfg,
- resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
- });
- const ids = await sendMSTeamsMessages({
- replyStyle: params.replyStyle,
- adapter: params.adapter,
- appId: params.appId,
- conversationRef: params.conversationRef,
- context: params.context,
- messages,
- // Enable default retry/backoff for throttling/transient failures.
- retry: {},
- onRetry: (event) => {
- params.log.debug("retrying send", {
- replyStyle: params.replyStyle,
- ...event,
- });
- },
- tokenProvider: params.tokenProvider,
- sharePointSiteId: params.sharePointSiteId,
- mediaMaxBytes,
- });
- if (ids.length > 0) params.onSentMessageIds?.(ids);
+ });
+ const messages = renderReplyPayloadsToMessages([payload], {
+ textChunkLimit: params.textLimit,
+ chunkText: true,
+ mediaMode: "split",
+ tableMode,
+ chunkMode,
+ });
+ const mediaMaxBytes = resolveChannelMediaMaxBytes({
+ cfg: params.cfg,
+ resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
+ });
+ const ids = await sendMSTeamsMessages({
+ replyStyle: params.replyStyle,
+ adapter: params.adapter,
+ appId: params.appId,
+ conversationRef: params.conversationRef,
+ context: params.context,
+ messages,
+ // Enable default retry/backoff for throttling/transient failures.
+ retry: {},
+ onRetry: (event) => {
+ params.log.debug("retrying send", {
+ replyStyle: params.replyStyle,
+ ...event,
+ });
+ },
+ tokenProvider: params.tokenProvider,
+ sharePointSiteId: params.sharePointSiteId,
+ mediaMaxBytes,
+ });
+ if (ids.length > 0) params.onSentMessageIds?.(ids);
},
onError: (err, info) => {
const errMsg = formatUnknownError(err);
diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md
index a8721d47d..588817858 100644
--- a/extensions/voice-call/CHANGELOG.md
+++ b/extensions/voice-call/CHANGELOG.md
@@ -6,6 +6,7 @@
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
- Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls.
- Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields.
+- Ngrok free-tier bypass renamed to `tunnel.allowNgrokFreeTierLoopbackBypass` and gated to loopback + `tunnel.provider="ngrok"`.
## 2026.1.23
diff --git a/extensions/voice-call/README.md b/extensions/voice-call/README.md
index d96f90392..5f009aa28 100644
--- a/extensions/voice-call/README.md
+++ b/extensions/voice-call/README.md
@@ -74,6 +74,7 @@ Put under `plugins.entries.voice-call.config`:
Notes:
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
- `mock` is a local dev provider (no network calls).
+- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
## TTS for calls
diff --git a/extensions/voice-call/clawdbot.plugin.json b/extensions/voice-call/clawdbot.plugin.json
index 2a4f04466..cfac7ad9d 100644
--- a/extensions/voice-call/clawdbot.plugin.json
+++ b/extensions/voice-call/clawdbot.plugin.json
@@ -78,8 +78,8 @@
"label": "ngrok Domain",
"advanced": true
},
- "tunnel.allowNgrokFreeTier": {
- "label": "Allow ngrok Free Tier",
+ "tunnel.allowNgrokFreeTierLoopbackBypass": {
+ "label": "Allow ngrok Free Tier (Loopback Bypass)",
"advanced": true
},
"streaming.enabled": {
@@ -330,7 +330,7 @@
"ngrokDomain": {
"type": "string"
},
- "allowNgrokFreeTier": {
+ "allowNgrokFreeTierLoopbackBypass": {
"type": "boolean"
}
}
diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts
index 60076bbe2..60cb64eb2 100644
--- a/extensions/voice-call/index.ts
+++ b/extensions/voice-call/index.ts
@@ -62,8 +62,8 @@ const voiceCallConfigSchema = {
advanced: true,
},
"tunnel.ngrokDomain": { label: "ngrok Domain", advanced: true },
- "tunnel.allowNgrokFreeTier": {
- label: "Allow ngrok Free Tier",
+ "tunnel.allowNgrokFreeTierLoopbackBypass": {
+ label: "Allow ngrok Free Tier (Loopback Bypass)",
advanced: true,
},
"streaming.enabled": { label: "Enable Streaming", advanced: true },
diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts
index aac9fe44c..dde17e122 100644
--- a/extensions/voice-call/src/config.test.ts
+++ b/extensions/voice-call/src/config.test.ts
@@ -19,7 +19,7 @@ function createBaseConfig(
maxConcurrentCalls: 1,
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
tailscale: { mode: "off", path: "/voice/webhook" },
- tunnel: { provider: "none", allowNgrokFreeTier: false },
+ tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false },
streaming: {
enabled: false,
sttProvider: "openai-realtime",
diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts
index 99916e49d..7784406e7 100644
--- a/extensions/voice-call/src/config.ts
+++ b/extensions/voice-call/src/config.ts
@@ -217,12 +217,17 @@ export const VoiceCallTunnelConfigSchema = z
/**
* Allow ngrok free tier compatibility mode.
* When true, signature verification failures on ngrok-free.app URLs
- * will include extra diagnostics. Signature verification is still required.
+ * will be allowed only for loopback requests (ngrok local agent).
*/
- allowNgrokFreeTier: z.boolean().default(false),
+ allowNgrokFreeTierLoopbackBypass: z.boolean().default(false),
+ /**
+ * Legacy ngrok free tier compatibility mode (deprecated).
+ * Use allowNgrokFreeTierLoopbackBypass instead.
+ */
+ allowNgrokFreeTier: z.boolean().optional(),
})
.strict()
- .default({ provider: "none", allowNgrokFreeTier: false });
+ .default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false });
export type VoiceCallTunnelConfig = z.infer;
// -----------------------------------------------------------------------------
@@ -419,8 +424,12 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
// Tunnel Config
resolved.tunnel = resolved.tunnel ?? {
provider: "none",
- allowNgrokFreeTier: false,
+ allowNgrokFreeTierLoopbackBypass: false,
};
+ resolved.tunnel.allowNgrokFreeTierLoopbackBypass =
+ resolved.tunnel.allowNgrokFreeTierLoopbackBypass ||
+ resolved.tunnel.allowNgrokFreeTier ||
+ false;
resolved.tunnel.ngrokAuthToken =
resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
resolved.tunnel.ngrokDomain =
diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts
index be9dd6eda..87c0f244d 100644
--- a/extensions/voice-call/src/providers/twilio.ts
+++ b/extensions/voice-call/src/providers/twilio.ts
@@ -31,8 +31,8 @@ import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
* @see https://www.twilio.com/docs/voice/media-streams
*/
export interface TwilioProviderOptions {
- /** Allow ngrok free tier compatibility mode (less secure) */
- allowNgrokFreeTier?: boolean;
+ /** Allow ngrok free tier compatibility mode (loopback only, less secure) */
+ allowNgrokFreeTierLoopbackBypass?: boolean;
/** Override public URL for signature verification */
publicUrl?: string;
/** Path for media stream WebSocket (e.g., /voice/stream) */
diff --git a/extensions/voice-call/src/providers/twilio/webhook.ts b/extensions/voice-call/src/providers/twilio/webhook.ts
index 1cddcb164..d5c3abb95 100644
--- a/extensions/voice-call/src/providers/twilio/webhook.ts
+++ b/extensions/voice-call/src/providers/twilio/webhook.ts
@@ -11,7 +11,8 @@ export function verifyTwilioProviderWebhook(params: {
}): WebhookVerificationResult {
const result = verifyTwilioWebhook(params.ctx, params.authToken, {
publicUrl: params.currentPublicUrl || undefined,
- allowNgrokFreeTier: params.options.allowNgrokFreeTier ?? false,
+ allowNgrokFreeTierLoopbackBypass:
+ params.options.allowNgrokFreeTierLoopbackBypass ?? false,
skipVerification: params.options.skipVerification,
});
diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts
index ffa95ddff..6f638ab5b 100644
--- a/extensions/voice-call/src/runtime.ts
+++ b/extensions/voice-call/src/runtime.ts
@@ -33,7 +33,19 @@ type Logger = {
debug: (message: string) => void;
};
+function isLoopbackBind(bind: string | undefined): boolean {
+ if (!bind) return false;
+ return bind === "127.0.0.1" || bind === "::1" || bind === "localhost";
+}
+
function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
+ const allowNgrokFreeTierLoopbackBypass =
+ config.tunnel?.provider === "ngrok" &&
+ isLoopbackBind(config.serve?.bind) &&
+ (config.tunnel?.allowNgrokFreeTierLoopbackBypass ||
+ config.tunnel?.allowNgrokFreeTier ||
+ false);
+
switch (config.provider) {
case "telnyx":
return new TelnyxProvider({
@@ -48,7 +60,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
authToken: config.twilio?.authToken,
},
{
- allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? false,
+ allowNgrokFreeTierLoopbackBypass,
publicUrl: config.publicUrl,
skipVerification: config.skipSignatureVerification,
streamPath: config.streaming?.enabled
diff --git a/extensions/voice-call/src/types.ts b/extensions/voice-call/src/types.ts
index 7f3928778..68cca11e6 100644
--- a/extensions/voice-call/src/types.ts
+++ b/extensions/voice-call/src/types.ts
@@ -180,6 +180,7 @@ export type WebhookContext = {
url: string;
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
query?: Record;
+ remoteAddress?: string;
};
export type ProviderWebhookParseResult = {
diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts
index 98d8a451c..3db2983ec 100644
--- a/extensions/voice-call/src/webhook-security.test.ts
+++ b/extensions/voice-call/src/webhook-security.test.ts
@@ -221,13 +221,40 @@ describe("verifyTwilioWebhook", () => {
rawBody: postBody,
url: "http://127.0.0.1:3334/voice/webhook",
method: "POST",
+ remoteAddress: "203.0.113.10",
},
authToken,
- { allowNgrokFreeTier: true },
+ { allowNgrokFreeTierLoopbackBypass: true },
);
expect(result.ok).toBe(false);
expect(result.isNgrokFreeTier).toBe(true);
expect(result.reason).toMatch(/Invalid signature/);
});
+
+ it("allows invalid signatures for ngrok free tier only on loopback", () => {
+ const authToken = "test-auth-token";
+ const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
+
+ const result = verifyTwilioWebhook(
+ {
+ headers: {
+ host: "127.0.0.1:3334",
+ "x-forwarded-proto": "https",
+ "x-forwarded-host": "local.ngrok-free.app",
+ "x-twilio-signature": "invalid",
+ },
+ rawBody: postBody,
+ url: "http://127.0.0.1:3334/voice/webhook",
+ method: "POST",
+ remoteAddress: "127.0.0.1",
+ },
+ authToken,
+ { allowNgrokFreeTierLoopbackBypass: true },
+ );
+
+ expect(result.ok).toBe(true);
+ expect(result.isNgrokFreeTier).toBe(true);
+ expect(result.reason).toMatch(/compatibility mode/);
+ });
});
diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts
index 98b1d9837..6c7d4d9ab 100644
--- a/extensions/voice-call/src/webhook-security.ts
+++ b/extensions/voice-call/src/webhook-security.ts
@@ -131,6 +131,13 @@ function getHeader(
return value;
}
+function isLoopbackAddress(address?: string): boolean {
+ if (!address) return false;
+ if (address === "127.0.0.1" || address === "::1") return true;
+ if (address.startsWith("::ffff:127.")) return true;
+ return false;
+}
+
/**
* Result of Twilio webhook verification with detailed info.
*/
@@ -155,8 +162,8 @@ export function verifyTwilioWebhook(
options?: {
/** Override the public URL (e.g., from config) */
publicUrl?: string;
- /** Allow ngrok free tier compatibility mode (less secure) */
- allowNgrokFreeTier?: boolean;
+ /** Allow ngrok free tier compatibility mode (loopback only, less secure) */
+ allowNgrokFreeTierLoopbackBypass?: boolean;
/** Skip verification entirely (only for development) */
skipVerification?: boolean;
},
@@ -195,6 +202,22 @@ export function verifyTwilioWebhook(
verificationUrl.includes(".ngrok-free.app") ||
verificationUrl.includes(".ngrok.io");
+ if (
+ isNgrokFreeTier &&
+ options?.allowNgrokFreeTierLoopbackBypass &&
+ isLoopbackAddress(ctx.remoteAddress)
+ ) {
+ console.warn(
+ "[voice-call] Twilio signature validation failed (ngrok free tier compatibility, loopback only)",
+ );
+ return {
+ ok: true,
+ reason: "ngrok free tier compatibility mode (loopback only)",
+ verificationUrl,
+ isNgrokFreeTier: true,
+ };
+ }
+
return {
ok: false,
reason: `Invalid signature for URL: ${verificationUrl}`,
diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts
index 6ab4d0eed..09e96ffed 100644
--- a/extensions/voice-call/src/webhook.ts
+++ b/extensions/voice-call/src/webhook.ts
@@ -252,6 +252,7 @@ export class VoiceCallWebhookServer {
url: `http://${req.headers.host}${req.url}`,
method: "POST",
query: Object.fromEntries(url.searchParams),
+ remoteAddress: req.socket.remoteAddress ?? undefined,
};
// Verify signature
diff --git a/package.json b/package.json
index 0c63d5d69..1299d72d5 100644
--- a/package.json
+++ b/package.json
@@ -237,6 +237,9 @@
"vitest": "^4.0.18",
"wireit": "^0.14.12"
},
+ "overrides": {
+ "tar": "7.5.4"
+ },
"pnpm": {
"minimumReleaseAge": 2880,
"overrides": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 223537e85..d1c55dd8d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -172,13 +172,6 @@ importers:
zod:
specifier: ^4.3.6
version: 4.3.6
- optionalDependencies:
- '@napi-rs/canvas':
- specifier: ^0.1.88
- version: 0.1.88
- node-llama-cpp:
- specifier: 3.15.0
- version: 3.15.0(typescript@5.9.3)
devDependencies:
'@grammyjs/types':
specifier: ^3.23.0
@@ -261,6 +254,13 @@ importers:
wireit:
specifier: ^0.14.12
version: 0.14.12
+ optionalDependencies:
+ '@napi-rs/canvas':
+ specifier: ^0.1.88
+ version: 0.1.88
+ node-llama-cpp:
+ specifier: 3.15.0
+ version: 3.15.0(typescript@5.9.3)
extensions/bluebubbles: {}
@@ -335,12 +335,12 @@ importers:
'@matrix-org/matrix-sdk-crypto-nodejs':
specifier: ^0.4.0
version: 0.4.0
+ '@vector-im/matrix-bot-sdk':
+ specifier: 0.8.0-element.3
+ version: 0.8.0-element.3
markdown-it:
specifier: 14.1.0
version: 14.1.0
- matrix-bot-sdk:
- specifier: 0.8.0
- version: 0.8.0
music-metadata:
specifier: ^11.10.6
version: 11.10.6
@@ -357,8 +357,8 @@ importers:
extensions/memory-core:
dependencies:
clawdbot:
- specifier: '>=2026.1.25'
- version: link:../..
+ specifier: '>=2026.1.24-3'
+ version: 2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3)
extensions/memory-lancedb:
dependencies:
@@ -1316,6 +1316,7 @@ packages:
'@lancedb/lancedb@0.23.0':
resolution: {integrity: sha512-aYrIoEG24AC+wILCL57Ius/Y4yU+xFHDPKLvmjzzN4byAjzeIGF0TC86S5RBt4Ji+dxS7yIWV5Q/gE5/fybIFQ==}
engines: {node: '>= 18'}
+ cpu: [x64, arm64]
os: [darwin, linux, win32]
peerDependencies:
apache-arrow: '>=15.0.0 <=18.1.0'
@@ -2667,6 +2668,9 @@ packages:
'@types/bun@1.3.6':
resolution: {integrity: sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==}
+ '@types/caseless@0.12.5':
+ resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==}
+
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -2748,6 +2752,9 @@ packages:
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
+ '@types/request@2.48.13':
+ resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==}
+
'@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
@@ -2766,6 +2773,9 @@ packages:
'@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
+ '@types/tough-cookie@4.0.5':
+ resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
+
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -2822,6 +2832,10 @@ packages:
'@urbit/http-api@3.0.0':
resolution: {integrity: sha512-EmyPbWHWXhfYQ/9wWFcLT53VvCn8ct9ljd6QEe+UBjNPEhUPOFBLpDsDp3iPLQgg8ykSU8JMMHxp95LHCorExA==}
+ '@vector-im/matrix-bot-sdk@0.8.0-element.3':
+ resolution: {integrity: sha512-2FFo/Kz2vTnOZDv59Q0s803LHf7KzuQ2EwOYYAtO0zUKJ8pV5CPsVC/IHyFb+Fsxl3R9XWFiX529yhslb4v9cQ==}
+ engines: {node: '>=22.0.0'}
+
'@vitest/browser-playwright@4.0.18':
resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==}
peerDependencies:
@@ -3194,6 +3208,11 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+ clawdbot@2026.1.24-3:
+ resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==}
+ engines: {node: '>=22.12.0'}
+ hasBin: true
+
cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
@@ -3611,6 +3630,10 @@ packages:
resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
engines: {node: '>= 0.12'}
+ form-data@2.5.5:
+ resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==}
+ engines: {node: '>= 0.12'}
+
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
@@ -4235,10 +4258,6 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
- matrix-bot-sdk@0.8.0:
- resolution: {integrity: sha512-sCY5UvZfsZhJdCjSc8wZhGhIHOe5cSFSILxx9Zp5a/NEXtmQ6W/bIhefIk4zFAZXetFwXsgvKh1960k1hG5WDw==}
- engines: {node: '>=22.0.0'}
-
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
@@ -8419,6 +8438,8 @@ snapshots:
bun-types: 1.3.6
optional: true
+ '@types/caseless@0.12.5': {}
+
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -8511,6 +8532,13 @@ snapshots:
'@types/range-parser@1.2.7': {}
+ '@types/request@2.48.13':
+ dependencies:
+ '@types/caseless': 0.12.5
+ '@types/node': 25.0.10
+ '@types/tough-cookie': 4.0.5
+ form-data: 2.5.5
+
'@types/retry@0.12.0': {}
'@types/retry@0.12.5': {}
@@ -8535,6 +8563,8 @@ snapshots:
'@types/http-errors': 2.0.5
'@types/node': 25.0.10
+ '@types/tough-cookie@4.0.5': {}
+
'@types/trusted-types@2.0.7': {}
'@types/ws@8.18.1':
@@ -8588,6 +8618,30 @@ snapshots:
browser-or-node: 1.3.0
core-js: 3.48.0
+ '@vector-im/matrix-bot-sdk@0.8.0-element.3':
+ dependencies:
+ '@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0
+ '@types/express': 4.17.25
+ '@types/request': 2.48.13
+ another-json: 0.2.0
+ async-lock: 1.4.1
+ chalk: 4.1.2
+ express: 4.22.1
+ glob-to-regexp: 0.4.1
+ hash.js: 1.1.7
+ html-to-text: 9.0.5
+ htmlencode: 0.0.4
+ lowdb: 1.0.0
+ lru-cache: 10.4.3
+ mkdirp: 3.0.1
+ morgan: 1.10.1
+ postgres: 3.4.8
+ request: 2.88.2
+ request-promise: 4.2.6(request@2.88.2)
+ sanitize-html: 2.17.0
+ transitivePeerDependencies:
+ - supports-color
+
'@vitest/browser-playwright@4.0.18(playwright@1.58.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)':
dependencies:
'@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
@@ -9038,6 +9092,84 @@ snapshots:
dependencies:
clsx: 2.1.1
+ clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3):
+ dependencies:
+ '@agentclientprotocol/sdk': 0.13.1(zod@4.3.6)
+ '@aws-sdk/client-bedrock': 3.975.0
+ '@buape/carbon': 0.14.0(hono@4.11.4)
+ '@clack/prompts': 0.11.0
+ '@grammyjs/runner': 2.0.3(grammy@1.39.3)
+ '@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3)
+ '@homebridge/ciao': 1.3.4
+ '@line/bot-sdk': 10.6.0
+ '@lydell/node-pty': 1.2.0-beta.3
+ '@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6)
+ '@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
+ '@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6)
+ '@mariozechner/pi-tui': 0.49.3
+ '@mozilla/readability': 0.6.0
+ '@sinclair/typebox': 0.34.47
+ '@slack/bolt': 4.6.0(@types/express@5.0.6)
+ '@slack/web-api': 7.13.0
+ '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
+ ajv: 8.17.1
+ body-parser: 2.2.2
+ chalk: 5.6.2
+ chokidar: 5.0.0
+ chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482)
+ cli-highlight: 2.1.11
+ commander: 14.0.2
+ croner: 9.1.0
+ detect-libc: 2.1.2
+ discord-api-types: 0.38.37
+ dotenv: 17.2.3
+ express: 5.2.1
+ file-type: 21.3.0
+ grammy: 1.39.3
+ hono: 4.11.4
+ jiti: 2.6.1
+ json5: 2.2.3
+ jszip: 3.10.1
+ linkedom: 0.18.12
+ long: 5.3.2
+ markdown-it: 14.1.0
+ node-edge-tts: 1.2.9
+ osc-progress: 0.3.0
+ pdfjs-dist: 5.4.530
+ playwright-core: 1.58.0
+ proper-lockfile: 4.1.2
+ qrcode-terminal: 0.12.0
+ sharp: 0.34.5
+ sqlite-vec: 0.1.7-alpha.2
+ tar: 7.5.4
+ tslog: 4.10.2
+ undici: 7.19.0
+ ws: 8.19.0
+ yaml: 2.8.2
+ zod: 4.3.6
+ optionalDependencies:
+ '@napi-rs/canvas': 0.1.88
+ node-llama-cpp: 3.15.0(typescript@5.9.3)
+ transitivePeerDependencies:
+ - '@discordjs/opus'
+ - '@modelcontextprotocol/sdk'
+ - '@types/express'
+ - audio-decode
+ - aws-crt
+ - bufferutil
+ - canvas
+ - debug
+ - devtools-protocol
+ - encoding
+ - ffmpeg-static
+ - jimp
+ - link-preview-js
+ - node-opus
+ - opusscript
+ - supports-color
+ - typescript
+ - utf-8-validate
+
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
@@ -9518,6 +9650,15 @@ snapshots:
combined-stream: 1.0.8
mime-types: 2.1.35
+ form-data@2.5.5:
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ es-set-tostringtag: 2.1.0
+ hasown: 2.0.2
+ mime-types: 2.1.35
+ safe-buffer: 5.2.1
+
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
@@ -10197,29 +10338,6 @@ snapshots:
math-intrinsics@1.1.0: {}
- matrix-bot-sdk@0.8.0:
- dependencies:
- '@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0
- '@types/express': 4.17.25
- another-json: 0.2.0
- async-lock: 1.4.1
- chalk: 4.1.2
- express: 4.22.1
- glob-to-regexp: 0.4.1
- hash.js: 1.1.7
- html-to-text: 9.0.5
- htmlencode: 0.0.4
- lowdb: 1.0.0
- lru-cache: 10.4.3
- mkdirp: 3.0.1
- morgan: 1.10.1
- postgres: 3.4.8
- request: 2.88.2
- request-promise: 4.2.6(request@2.88.2)
- sanitize-html: 2.17.0
- transitivePeerDependencies:
- - supports-color
-
mdurl@2.0.0: {}
media-typer@0.3.0: {}
diff --git a/skills/nano-banana-pro/SKILL.md b/skills/nano-banana-pro/SKILL.md
index a36c21f64..469576ec7 100644
--- a/skills/nano-banana-pro/SKILL.md
+++ b/skills/nano-banana-pro/SKILL.md
@@ -14,9 +14,14 @@ Generate
uv run {baseDir}/scripts/generate_image.py --prompt "your image description" --filename "output.png" --resolution 1K
```
-Edit
+Edit (single image)
```bash
-uv run {baseDir}/scripts/generate_image.py --prompt "edit instructions" --filename "output.png" --input-image "/path/in.png" --resolution 2K
+uv run {baseDir}/scripts/generate_image.py --prompt "edit instructions" --filename "output.png" -i "/path/in.png" --resolution 2K
+```
+
+Multi-image composition (up to 14 images)
+```bash
+uv run {baseDir}/scripts/generate_image.py --prompt "combine these into one scene" --filename "output.png" -i img1.png -i img2.png -i img3.png
```
API key
diff --git a/skills/nano-banana-pro/scripts/generate_image.py b/skills/nano-banana-pro/scripts/generate_image.py
index 48dd9e9e5..32fc1fc32 100755
--- a/skills/nano-banana-pro/scripts/generate_image.py
+++ b/skills/nano-banana-pro/scripts/generate_image.py
@@ -11,6 +11,9 @@ Generate images using Google's Nano Banana Pro (Gemini 3 Pro Image) API.
Usage:
uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY]
+
+Multi-image editing (up to 14 images):
+ uv run generate_image.py --prompt "combine these images" --filename "output.png" -i img1.png -i img2.png -i img3.png
"""
import argparse
@@ -42,7 +45,10 @@ def main():
)
parser.add_argument(
"--input-image", "-i",
- help="Optional input image path for editing/modification"
+ action="append",
+ dest="input_images",
+ metavar="IMAGE",
+ help="Input image path(s) for editing/composition. Can be specified multiple times (up to 14 images)."
)
parser.add_argument(
"--resolution", "-r",
@@ -78,34 +84,43 @@ def main():
output_path = Path(args.filename)
output_path.parent.mkdir(parents=True, exist_ok=True)
- # Load input image if provided
- input_image = None
+ # Load input images if provided (up to 14 supported by Nano Banana Pro)
+ input_images = []
output_resolution = args.resolution
- if args.input_image:
- try:
- input_image = PILImage.open(args.input_image)
- print(f"Loaded input image: {args.input_image}")
-
- # Auto-detect resolution if not explicitly set by user
- if args.resolution == "1K": # Default value
- # Map input image size to resolution
- width, height = input_image.size
- max_dim = max(width, height)
- if max_dim >= 3000:
- output_resolution = "4K"
- elif max_dim >= 1500:
- output_resolution = "2K"
- else:
- output_resolution = "1K"
- print(f"Auto-detected resolution: {output_resolution} (from input {width}x{height})")
- except Exception as e:
- print(f"Error loading input image: {e}", file=sys.stderr)
+ if args.input_images:
+ if len(args.input_images) > 14:
+ print(f"Error: Too many input images ({len(args.input_images)}). Maximum is 14.", file=sys.stderr)
sys.exit(1)
- # Build contents (image first if editing, prompt only if generating)
- if input_image:
- contents = [input_image, args.prompt]
- print(f"Editing image with resolution {output_resolution}...")
+ max_input_dim = 0
+ for img_path in args.input_images:
+ try:
+ img = PILImage.open(img_path)
+ input_images.append(img)
+ print(f"Loaded input image: {img_path}")
+
+ # Track largest dimension for auto-resolution
+ width, height = img.size
+ max_input_dim = max(max_input_dim, width, height)
+ except Exception as e:
+ print(f"Error loading input image '{img_path}': {e}", file=sys.stderr)
+ sys.exit(1)
+
+ # Auto-detect resolution from largest input if not explicitly set
+ if args.resolution == "1K" and max_input_dim > 0: # Default value
+ if max_input_dim >= 3000:
+ output_resolution = "4K"
+ elif max_input_dim >= 1500:
+ output_resolution = "2K"
+ else:
+ output_resolution = "1K"
+ print(f"Auto-detected resolution: {output_resolution} (from max input dimension {max_input_dim})")
+
+ # Build contents (images first if editing, prompt only if generating)
+ if input_images:
+ contents = [*input_images, args.prompt]
+ img_count = len(input_images)
+ print(f"Processing {img_count} image{'s' if img_count > 1 else ''} with resolution {output_resolution}...")
else:
contents = args.prompt
print(f"Generating image with resolution {output_resolution}...")
diff --git a/src/agents/session-write-lock.test.ts b/src/agents/session-write-lock.test.ts
index 8f93bface..072eca364 100644
--- a/src/agents/session-write-lock.test.ts
+++ b/src/agents/session-write-lock.test.ts
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
-import { acquireSessionWriteLock } from "./session-write-lock.js";
+import { __testing, acquireSessionWriteLock } from "./session-write-lock.js";
describe("acquireSessionWriteLock", () => {
it("reuses locks across symlinked session paths", async () => {
@@ -31,4 +31,132 @@ describe("acquireSessionWriteLock", () => {
await fs.rm(root, { recursive: true, force: true });
}
});
+
+ it("keeps the lock file until the last release", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
+ try {
+ const sessionFile = path.join(root, "sessions.json");
+ const lockPath = `${sessionFile}.lock`;
+
+ const lockA = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
+ const lockB = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
+
+ await expect(fs.access(lockPath)).resolves.toBeUndefined();
+ await lockA.release();
+ await expect(fs.access(lockPath)).resolves.toBeUndefined();
+ await lockB.release();
+ await expect(fs.access(lockPath)).rejects.toThrow();
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
+ });
+
+ it("reclaims stale lock files", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
+ try {
+ const sessionFile = path.join(root, "sessions.json");
+ const lockPath = `${sessionFile}.lock`;
+ await fs.writeFile(
+ lockPath,
+ JSON.stringify({ pid: 123456, createdAt: new Date(Date.now() - 60_000).toISOString() }),
+ "utf8",
+ );
+
+ const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, staleMs: 10 });
+ const raw = await fs.readFile(lockPath, "utf8");
+ const payload = JSON.parse(raw) as { pid: number };
+
+ expect(payload.pid).toBe(process.pid);
+ await lock.release();
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
+ });
+
+ it("removes held locks on termination signals", async () => {
+ const signals = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const;
+ for (const signal of signals) {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-cleanup-"));
+ try {
+ const sessionFile = path.join(root, "sessions.json");
+ const lockPath = `${sessionFile}.lock`;
+ await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
+ const keepAlive = () => {};
+ if (signal === "SIGINT") {
+ process.on(signal, keepAlive);
+ }
+
+ __testing.handleTerminationSignal(signal);
+
+ await expect(fs.stat(lockPath)).rejects.toThrow();
+ if (signal === "SIGINT") {
+ process.off(signal, keepAlive);
+ }
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
+ }
+ });
+
+ it("registers cleanup for SIGQUIT and SIGABRT", () => {
+ expect(__testing.cleanupSignals).toContain("SIGQUIT");
+ expect(__testing.cleanupSignals).toContain("SIGABRT");
+ });
+ it("cleans up locks on SIGINT without removing other handlers", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
+ const originalKill = process.kill.bind(process);
+ const killCalls: Array = [];
+ let otherHandlerCalled = false;
+
+ process.kill = ((pid: number, signal?: NodeJS.Signals) => {
+ killCalls.push(signal);
+ return true;
+ }) as typeof process.kill;
+
+ const otherHandler = () => {
+ otherHandlerCalled = true;
+ };
+
+ process.on("SIGINT", otherHandler);
+
+ try {
+ const sessionFile = path.join(root, "sessions.json");
+ const lockPath = `${sessionFile}.lock`;
+ await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
+
+ process.emit("SIGINT");
+
+ await expect(fs.access(lockPath)).rejects.toThrow();
+ expect(otherHandlerCalled).toBe(true);
+ expect(killCalls).toEqual([]);
+ } finally {
+ process.off("SIGINT", otherHandler);
+ process.kill = originalKill;
+ await fs.rm(root, { recursive: true, force: true });
+ }
+ });
+
+ it("cleans up locks on exit", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
+ try {
+ const sessionFile = path.join(root, "sessions.json");
+ const lockPath = `${sessionFile}.lock`;
+ await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
+
+ process.emit("exit", 0);
+
+ await expect(fs.access(lockPath)).rejects.toThrow();
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
+ });
+ it("keeps other signal listeners registered", () => {
+ const keepAlive = () => {};
+ process.on("SIGINT", keepAlive);
+
+ __testing.handleTerminationSignal("SIGINT");
+
+ expect(process.listeners("SIGINT")).toContain(keepAlive);
+ process.off("SIGINT", keepAlive);
+ });
});
diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts
index 54e61d965..832d368a6 100644
--- a/src/agents/session-write-lock.ts
+++ b/src/agents/session-write-lock.ts
@@ -1,3 +1,4 @@
+import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
@@ -13,6 +14,9 @@ type HeldLock = {
};
const HELD_LOCKS = new Map();
+const CLEANUP_SIGNALS = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const;
+type CleanupSignal = (typeof CLEANUP_SIGNALS)[number];
+const cleanupHandlers = new Map void>();
function isAlive(pid: number): boolean {
if (!Number.isFinite(pid) || pid <= 0) return false;
@@ -24,6 +28,65 @@ function isAlive(pid: number): boolean {
}
}
+/**
+ * Synchronously release all held locks.
+ * Used during process exit when async operations aren't reliable.
+ */
+function releaseAllLocksSync(): void {
+ for (const [sessionFile, held] of HELD_LOCKS) {
+ try {
+ if (typeof held.handle.fd === "number") {
+ fsSync.closeSync(held.handle.fd);
+ }
+ } catch {
+ // Ignore errors during cleanup - best effort
+ }
+ try {
+ fsSync.rmSync(held.lockPath, { force: true });
+ } catch {
+ // Ignore errors during cleanup - best effort
+ }
+ HELD_LOCKS.delete(sessionFile);
+ }
+}
+
+let cleanupRegistered = false;
+
+function handleTerminationSignal(signal: CleanupSignal): void {
+ releaseAllLocksSync();
+ const shouldReraise = process.listenerCount(signal) === 1;
+ if (shouldReraise) {
+ const handler = cleanupHandlers.get(signal);
+ if (handler) process.off(signal, handler);
+ try {
+ process.kill(process.pid, signal);
+ } catch {
+ // Ignore errors during shutdown
+ }
+ }
+}
+
+function registerCleanupHandlers(): void {
+ if (cleanupRegistered) return;
+ cleanupRegistered = true;
+
+ // Cleanup on normal exit and process.exit() calls
+ process.on("exit", () => {
+ releaseAllLocksSync();
+ });
+
+ // Handle termination signals
+ for (const signal of CLEANUP_SIGNALS) {
+ try {
+ const handler = () => handleTerminationSignal(signal);
+ cleanupHandlers.set(signal, handler);
+ process.on(signal, handler);
+ } catch {
+ // Ignore unsupported signals on this platform.
+ }
+ }
+}
+
async function readLockPayload(lockPath: string): Promise {
try {
const raw = await fs.readFile(lockPath, "utf8");
@@ -43,6 +106,7 @@ export async function acquireSessionWriteLock(params: {
}): Promise<{
release: () => Promise;
}> {
+ registerCleanupHandlers();
const timeoutMs = params.timeoutMs ?? 10_000;
const staleMs = params.staleMs ?? 30 * 60 * 1000;
const sessionFile = path.resolve(params.sessionFile);
@@ -116,3 +180,9 @@ export async function acquireSessionWriteLock(params: {
const owner = payload?.pid ? `pid=${payload.pid}` : "unknown";
throw new Error(`session file locked (timeout ${timeoutMs}ms): ${owner} ${lockPath}`);
}
+
+export const __testing = {
+ cleanupSignals: [...CLEANUP_SIGNALS],
+ handleTerminationSignal,
+ releaseAllLocksSync,
+};
diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts
index c167ac32a..891ab2b45 100644
--- a/src/agents/tools/telegram-actions.ts
+++ b/src/agents/tools/telegram-actions.ts
@@ -3,6 +3,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
import {
deleteMessageTelegram,
+ editMessageTelegram,
reactMessageTelegram,
sendMessageTelegram,
} from "../../telegram/send.js";
@@ -209,5 +210,50 @@ export async function handleTelegramAction(
return jsonResult({ ok: true, deleted: true });
}
+ if (action === "editMessage") {
+ if (!isActionEnabled("editMessage")) {
+ throw new Error("Telegram editMessage is disabled.");
+ }
+ const chatId = readStringOrNumberParam(params, "chatId", {
+ required: true,
+ });
+ const messageId = readNumberParam(params, "messageId", {
+ required: true,
+ integer: true,
+ });
+ const content = readStringParam(params, "content", {
+ required: true,
+ allowEmpty: false,
+ });
+ const buttons = readTelegramButtons(params);
+ if (buttons) {
+ const inlineButtonsScope = resolveTelegramInlineButtonsScope({
+ cfg,
+ accountId: accountId ?? undefined,
+ });
+ if (inlineButtonsScope === "off") {
+ throw new Error(
+ 'Telegram inline buttons are disabled. Set channels.telegram.capabilities.inlineButtons to "dm", "group", "all", or "allowlist".',
+ );
+ }
+ }
+ const token = resolveTelegramToken(cfg, { accountId }).token;
+ if (!token) {
+ throw new Error(
+ "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
+ );
+ }
+ const result = await editMessageTelegram(chatId ?? "", messageId ?? 0, content, {
+ token,
+ accountId: accountId ?? undefined,
+ buttons,
+ });
+ return jsonResult({
+ ok: true,
+ messageId: result.messageId,
+ chatId: result.chatId,
+ });
+ }
+
throw new Error(`Unsupported Telegram action: ${action}`);
}
diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts
index 12fec300b..5ba6826fe 100644
--- a/src/auto-reply/commands-registry.data.ts
+++ b/src/auto-reply/commands-registry.data.ts
@@ -181,9 +181,44 @@ function buildChatCommands(): ChatCommandDefinition[] {
defineChatCommand({
key: "tts",
nativeName: "tts",
- description: "Configure text-to-speech.",
+ description: "Control text-to-speech (TTS).",
textAlias: "/tts",
- acceptsArgs: true,
+ args: [
+ {
+ name: "action",
+ description: "TTS action",
+ type: "string",
+ choices: [
+ { value: "on", label: "On" },
+ { value: "off", label: "Off" },
+ { value: "status", label: "Status" },
+ { value: "provider", label: "Provider" },
+ { value: "limit", label: "Limit" },
+ { value: "summary", label: "Summary" },
+ { value: "audio", label: "Audio" },
+ { value: "help", label: "Help" },
+ ],
+ },
+ {
+ name: "value",
+ description: "Provider, limit, or text",
+ type: "string",
+ captureRemaining: true,
+ },
+ ],
+ argsMenu: {
+ arg: "action",
+ title:
+ "TTS Actions:\n" +
+ "• On – Enable TTS for responses\n" +
+ "• Off – Disable TTS\n" +
+ "• Status – Show current settings\n" +
+ "• Provider – Set voice provider (edge, elevenlabs, openai)\n" +
+ "• Limit – Set max characters for TTS\n" +
+ "• Summary – Toggle AI summary for long texts\n" +
+ "• Audio – Generate TTS from custom text\n" +
+ "• Help – Show usage guide",
+ },
}),
defineChatCommand({
key: "whoami",
diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts
index 6a6efbced..69f3ac1ae 100644
--- a/src/auto-reply/commands-registry.test.ts
+++ b/src/auto-reply/commands-registry.test.ts
@@ -229,7 +229,12 @@ describe("commands registry args", () => {
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
- expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
+ expect(menu?.choices).toEqual([
+ { label: "off", value: "off" },
+ { label: "tokens", value: "tokens" },
+ { label: "full", value: "full" },
+ { label: "cost", value: "cost" },
+ ]);
});
it("does not show menus when arg already provided", () => {
@@ -284,7 +289,10 @@ describe("commands registry args", () => {
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("level");
- expect(menu?.choices).toEqual(["low", "high"]);
+ expect(menu?.choices).toEqual([
+ { label: "low", value: "low" },
+ { label: "high", value: "high" },
+ ]);
expect(seen?.commandKey).toBe("think");
expect(seen?.argName).toBe("level");
expect(seen?.provider).toBeTruthy();
diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts
index 5bca565f0..f772ac7fc 100644
--- a/src/auto-reply/commands-registry.ts
+++ b/src/auto-reply/commands-registry.ts
@@ -255,33 +255,41 @@ function resolveDefaultCommandContext(cfg?: ClawdbotConfig): {
};
}
+export type ResolvedCommandArgChoice = { value: string; label: string };
+
export function resolveCommandArgChoices(params: {
command: ChatCommandDefinition;
arg: CommandArgDefinition;
cfg?: ClawdbotConfig;
provider?: string;
model?: string;
-}): string[] {
+}): ResolvedCommandArgChoice[] {
const { command, arg, cfg } = params;
if (!arg.choices) return [];
const provided = arg.choices;
- if (Array.isArray(provided)) return provided;
- const defaults = resolveDefaultCommandContext(cfg);
- const context: CommandArgChoiceContext = {
- cfg,
- provider: params.provider ?? defaults.provider,
- model: params.model ?? defaults.model,
- command,
- arg,
- };
- return provided(context);
+ const raw = Array.isArray(provided)
+ ? provided
+ : (() => {
+ const defaults = resolveDefaultCommandContext(cfg);
+ const context: CommandArgChoiceContext = {
+ cfg,
+ provider: params.provider ?? defaults.provider,
+ model: params.model ?? defaults.model,
+ command,
+ arg,
+ };
+ return provided(context);
+ })();
+ return raw.map((choice) =>
+ typeof choice === "string" ? { value: choice, label: choice } : choice,
+ );
}
export function resolveCommandArgMenu(params: {
command: ChatCommandDefinition;
args?: CommandArgs;
cfg?: ClawdbotConfig;
-}): { arg: CommandArgDefinition; choices: string[]; title?: string } | null {
+}): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string } | null {
const { command, args, cfg } = params;
if (!command.args || !command.argsMenu) return null;
if (command.argsParsing === "none") return null;
diff --git a/src/auto-reply/commands-registry.types.ts b/src/auto-reply/commands-registry.types.ts
index c19c9d9a7..5e5bdd8cb 100644
--- a/src/auto-reply/commands-registry.types.ts
+++ b/src/auto-reply/commands-registry.types.ts
@@ -12,14 +12,16 @@ export type CommandArgChoiceContext = {
arg: CommandArgDefinition;
};
-export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => string[];
+export type CommandArgChoice = string | { value: string; label: string };
+
+export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => CommandArgChoice[];
export type CommandArgDefinition = {
name: string;
description: string;
type: CommandArgType;
required?: boolean;
- choices?: string[] | CommandArgChoicesProvider;
+ choices?: CommandArgChoice[] | CommandArgChoicesProvider;
captureRemaining?: boolean;
};
diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts
index 939fa92f0..3537972e4 100644
--- a/src/auto-reply/reply/agent-runner-execution.ts
+++ b/src/auto-reply/reply/agent-runner-execution.ts
@@ -89,6 +89,7 @@ export async function runAgentTurnWithFallback(params: {
registerAgentRunContext(runId, {
sessionKey: params.sessionKey,
verboseLevel: params.resolvedVerboseLevel,
+ isHeartbeat: params.isHeartbeat,
});
}
let runResult: Awaited>;
diff --git a/src/auto-reply/reply/commands-tts.ts b/src/auto-reply/reply/commands-tts.ts
index 5c65fb94c..04b60a4e9 100644
--- a/src/auto-reply/reply/commands-tts.ts
+++ b/src/auto-reply/reply/commands-tts.ts
@@ -6,20 +6,18 @@ import {
getTtsMaxLength,
getTtsProvider,
isSummarizationEnabled,
+ isTtsEnabled,
isTtsProviderConfigured,
- normalizeTtsAutoMode,
- resolveTtsAutoMode,
resolveTtsApiKey,
resolveTtsConfig,
resolveTtsPrefsPath,
- resolveTtsProviderOrder,
setLastTtsAttempt,
setSummarizationEnabled,
+ setTtsEnabled,
setTtsMaxLength,
setTtsProvider,
textToSpeech,
} from "../../tts/tts.js";
-import { updateSessionStore } from "../../config/sessions.js";
type ParsedTtsCommand = {
action: string;
@@ -40,14 +38,27 @@ function ttsUsage(): ReplyPayload {
// Keep usage in one place so help/validation stays consistent.
return {
text:
- "⚙️ Usage: /tts [value]" +
- "\nExamples:\n" +
- "/tts always\n" +
- "/tts provider openai\n" +
- "/tts provider edge\n" +
- "/tts limit 2000\n" +
- "/tts summary off\n" +
- "/tts audio Hello from Clawdbot",
+ `🔊 **TTS (Text-to-Speech) Help**\n\n` +
+ `**Commands:**\n` +
+ `• /tts on — Enable automatic TTS for replies\n` +
+ `• /tts off — Disable TTS\n` +
+ `• /tts status — Show current settings\n` +
+ `• /tts provider [name] — View/change provider\n` +
+ `• /tts limit [number] — View/change text limit\n` +
+ `• /tts summary [on|off] — View/change auto-summary\n` +
+ `• /tts audio — Generate audio from text\n\n` +
+ `**Providers:**\n` +
+ `• edge — Free, fast (default)\n` +
+ `• openai — High quality (requires API key)\n` +
+ `• elevenlabs — Premium voices (requires API key)\n\n` +
+ `**Text Limit (default: 1500, max: 4096):**\n` +
+ `When text exceeds the limit:\n` +
+ `• Summary ON: AI summarizes, then generates audio\n` +
+ `• Summary OFF: Truncates text, then generates audio\n\n` +
+ `**Examples:**\n` +
+ `/tts provider edge\n` +
+ `/tts limit 2000\n` +
+ `/tts audio Hello, this is a test!`,
};
}
@@ -72,35 +83,27 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
return { shouldContinue: false, reply: ttsUsage() };
}
- const requestedAuto = normalizeTtsAutoMode(
- action === "on" ? "always" : action === "off" ? "off" : action,
- );
- if (requestedAuto) {
- const entry = params.sessionEntry;
- const sessionKey = params.sessionKey;
- const store = params.sessionStore;
- if (entry && store && sessionKey) {
- entry.ttsAuto = requestedAuto;
- entry.updatedAt = Date.now();
- store[sessionKey] = entry;
- if (params.storePath) {
- await updateSessionStore(params.storePath, (store) => {
- store[sessionKey] = entry;
- });
- }
- }
- const label = requestedAuto === "always" ? "enabled (always)" : requestedAuto;
- return {
- shouldContinue: false,
- reply: {
- text: requestedAuto === "off" ? "🔇 TTS disabled." : `🔊 TTS ${label}.`,
- },
- };
+ if (action === "on") {
+ setTtsEnabled(prefsPath, true);
+ return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
+ }
+
+ if (action === "off") {
+ setTtsEnabled(prefsPath, false);
+ return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
}
if (action === "audio") {
if (!args.trim()) {
- return { shouldContinue: false, reply: ttsUsage() };
+ return {
+ shouldContinue: false,
+ reply: {
+ text:
+ `🎤 Generate audio from text.\n\n` +
+ `Usage: /tts audio \n` +
+ `Example: /tts audio Hello, this is a test!`,
+ },
+ };
}
const start = Date.now();
@@ -146,9 +149,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (action === "provider") {
const currentProvider = getTtsProvider(config, prefsPath);
if (!args.trim()) {
- const fallback = resolveTtsProviderOrder(currentProvider)
- .slice(1)
- .filter((provider) => isTtsProviderConfigured(config, provider));
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
const hasEdge = isTtsProviderConfigured(config, "edge");
@@ -158,7 +158,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
text:
`🎙️ TTS provider\n` +
`Primary: ${currentProvider}\n` +
- `Fallbacks: ${fallback.join(", ") || "none"}\n` +
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
`Edge enabled: ${hasEdge ? "✅" : "❌"}\n` +
@@ -173,18 +172,9 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
}
setTtsProvider(prefsPath, requested);
- const fallback = resolveTtsProviderOrder(requested)
- .slice(1)
- .filter((provider) => isTtsProviderConfigured(config, provider));
return {
shouldContinue: false,
- reply: {
- text:
- `✅ TTS provider set to ${requested} (fallbacks: ${fallback.join(", ") || "none"}).` +
- (requested === "edge"
- ? "\nEnable Edge TTS in config: messages.tts.edge.enabled = true."
- : ""),
- },
+ reply: { text: `✅ TTS provider set to ${requested}.` },
};
}
@@ -193,12 +183,22 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
const currentLimit = getTtsMaxLength(prefsPath);
return {
shouldContinue: false,
- reply: { text: `📏 TTS limit: ${currentLimit} characters.` },
+ reply: {
+ text:
+ `📏 TTS limit: ${currentLimit} characters.\n\n` +
+ `Text longer than this triggers summary (if enabled).\n` +
+ `Range: 100-4096 chars (Telegram max).\n\n` +
+ `To change: /tts limit \n` +
+ `Example: /tts limit 2000`,
+ },
};
}
const next = Number.parseInt(args.trim(), 10);
- if (!Number.isFinite(next) || next < 100 || next > 10_000) {
- return { shouldContinue: false, reply: ttsUsage() };
+ if (!Number.isFinite(next) || next < 100 || next > 4096) {
+ return {
+ shouldContinue: false,
+ reply: { text: "❌ Limit must be between 100 and 4096 characters." },
+ };
}
setTtsMaxLength(prefsPath, next);
return {
@@ -210,9 +210,17 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (action === "summary") {
if (!args.trim()) {
const enabled = isSummarizationEnabled(prefsPath);
+ const maxLen = getTtsMaxLength(prefsPath);
return {
shouldContinue: false,
- reply: { text: `📝 TTS auto-summary: ${enabled ? "on" : "off"}.` },
+ reply: {
+ text:
+ `📝 TTS auto-summary: ${enabled ? "on" : "off"}.\n\n` +
+ `When text exceeds ${maxLen} chars:\n` +
+ `• ON: summarizes text, then generates audio\n` +
+ `• OFF: truncates text, then generates audio\n\n` +
+ `To change: /tts summary on | off`,
+ },
};
}
const requested = args.trim().toLowerCase();
@@ -229,27 +237,16 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
}
if (action === "status") {
- const sessionAuto = params.sessionEntry?.ttsAuto;
- const autoMode = resolveTtsAutoMode({ config, prefsPath, sessionAuto });
- const enabled = autoMode !== "off";
+ const enabled = isTtsEnabled(config, prefsPath);
const provider = getTtsProvider(config, prefsPath);
const hasKey = isTtsProviderConfigured(config, provider);
- const providerStatus =
- provider === "edge"
- ? hasKey
- ? "✅ enabled"
- : "❌ disabled"
- : hasKey
- ? "✅ key"
- : "❌ no key";
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath);
const last = getLastTtsAttempt();
- const autoLabel = sessionAuto ? `${autoMode} (session)` : autoMode;
const lines = [
"📊 TTS status",
- `Auto: ${enabled ? autoLabel : "off"}`,
- `Provider: ${provider} (${providerStatus})`,
+ `State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
+ `Provider: ${provider} (${hasKey ? "✅ configured" : "❌ not configured"})`,
`Text limit: ${maxLength} chars`,
`Auto-summary: ${summarize ? "on" : "off"}`,
];
diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts
index 7078c15dc..fd8236c95 100644
--- a/src/auto-reply/reply/commands.test.ts
+++ b/src/auto-reply/reply/commands.test.ts
@@ -420,3 +420,17 @@ describe("handleCommands subagents", () => {
expect(result.reply?.text).toContain("Status: done");
});
});
+
+describe("handleCommands /tts", () => {
+ it("returns status for bare /tts on text command surfaces", async () => {
+ const cfg = {
+ commands: { text: true },
+ channels: { whatsapp: { allowFrom: ["*"] } },
+ messages: { tts: { prefsPath: path.join(testWorkspaceDir, "tts.json") } },
+ } as ClawdbotConfig;
+ const params = buildParams("/tts", cfg);
+ const result = await handleCommands(params);
+ expect(result.shouldContinue).toBe(false);
+ expect(result.reply?.text).toContain("TTS status");
+ });
+});
diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts
index f946c05f9..1dcd770bc 100644
--- a/src/auto-reply/reply/dispatch-from-config.ts
+++ b/src/auto-reply/reply/dispatch-from-config.ts
@@ -16,7 +16,7 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
import { isRoutableChannel, routeReply } from "./route-reply.js";
-import { maybeApplyTtsToPayload, normalizeTtsAutoMode } from "../../tts/tts.js";
+import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js";
const AUDIO_PLACEHOLDER_RE = /^(\s*\([^)]*\))?$/i;
const AUDIO_HEADER_RE = /^\[Audio\b/i;
@@ -266,12 +266,26 @@ export async function dispatchReplyFromConfig(params: {
return { queuedFinal, counts };
}
+ // Track accumulated block text for TTS generation after streaming completes.
+ // When block streaming succeeds, there's no final reply, so we need to generate
+ // TTS audio separately from the accumulated block content.
+ let accumulatedBlockText = "";
+ let blockCount = 0;
+
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
ctx,
{
...params.replyOptions,
onBlockReply: (payload: ReplyPayload, context) => {
const run = async () => {
+ // Accumulate block text for TTS generation after streaming
+ if (payload.text) {
+ if (accumulatedBlockText.length > 0) {
+ accumulatedBlockText += "\n";
+ }
+ accumulatedBlockText += payload.text;
+ blockCount++;
+ }
const ttsPayload = await maybeApplyTtsToPayload({
payload,
cfg,
@@ -327,6 +341,62 @@ export async function dispatchReplyFromConfig(params: {
queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal;
}
}
+
+ const ttsMode = resolveTtsConfig(cfg).mode ?? "final";
+ // Generate TTS-only reply after block streaming completes (when there's no final reply).
+ // This handles the case where block streaming succeeds and drops final payloads,
+ // but we still want TTS audio to be generated from the accumulated block content.
+ if (
+ ttsMode === "final" &&
+ replies.length === 0 &&
+ blockCount > 0 &&
+ accumulatedBlockText.trim()
+ ) {
+ try {
+ const ttsSyntheticReply = await maybeApplyTtsToPayload({
+ payload: { text: accumulatedBlockText },
+ cfg,
+ channel: ttsChannel,
+ kind: "final",
+ inboundAudio,
+ ttsAuto: sessionTtsAuto,
+ });
+ // Only send if TTS was actually applied (mediaUrl exists)
+ if (ttsSyntheticReply.mediaUrl) {
+ // Send TTS-only payload (no text, just audio) so it doesn't duplicate the block content
+ const ttsOnlyPayload: ReplyPayload = {
+ mediaUrl: ttsSyntheticReply.mediaUrl,
+ audioAsVoice: ttsSyntheticReply.audioAsVoice,
+ };
+ if (shouldRouteToOriginating && originatingChannel && originatingTo) {
+ const result = await routeReply({
+ payload: ttsOnlyPayload,
+ channel: originatingChannel,
+ to: originatingTo,
+ sessionKey: ctx.SessionKey,
+ accountId: ctx.AccountId,
+ threadId: ctx.MessageThreadId,
+ cfg,
+ });
+ queuedFinal = result.ok || queuedFinal;
+ if (result.ok) routedFinalCount += 1;
+ if (!result.ok) {
+ logVerbose(
+ `dispatch-from-config: route-reply (tts-only) failed: ${result.error ?? "unknown error"}`,
+ );
+ }
+ } else {
+ const didQueue = dispatcher.sendFinalReply(ttsOnlyPayload);
+ queuedFinal = didQueue || queuedFinal;
+ }
+ }
+ } catch (err) {
+ logVerbose(
+ `dispatch-from-config: accumulated block TTS failed: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ }
+
await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts();
diff --git a/src/auto-reply/reply/history.ts b/src/auto-reply/reply/history.ts
index bc59b4f2e..45ad44d5a 100644
--- a/src/auto-reply/reply/history.ts
+++ b/src/auto-reply/reply/history.ts
@@ -3,6 +3,26 @@ import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
export const HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]";
export const DEFAULT_GROUP_HISTORY_LIMIT = 50;
+/** Maximum number of group history keys to retain (LRU eviction when exceeded). */
+export const MAX_HISTORY_KEYS = 1000;
+
+/**
+ * Evict oldest keys from a history map when it exceeds MAX_HISTORY_KEYS.
+ * Uses Map's insertion order for LRU-like behavior.
+ */
+export function evictOldHistoryKeys(
+ historyMap: Map,
+ maxKeys: number = MAX_HISTORY_KEYS,
+): void {
+ if (historyMap.size <= maxKeys) return;
+ const keysToDelete = historyMap.size - maxKeys;
+ const iterator = historyMap.keys();
+ for (let i = 0; i < keysToDelete; i++) {
+ const key = iterator.next().value;
+ if (key !== undefined) historyMap.delete(key);
+ }
+}
+
export type HistoryEntry = {
sender: string;
body: string;
@@ -34,7 +54,13 @@ export function appendHistoryEntry(params: {
const history = historyMap.get(historyKey) ?? [];
history.push(entry);
while (history.length > params.limit) history.shift();
+ if (historyMap.has(historyKey)) {
+ // Refresh insertion order so eviction keeps recently used histories.
+ historyMap.delete(historyKey);
+ }
historyMap.set(historyKey, history);
+ // Evict oldest keys if map exceeds max size to prevent unbounded memory growth
+ evictOldHistoryKeys(historyMap);
return history;
}
diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts
index 970a714d0..0fea27708 100644
--- a/src/auto-reply/reply/session-updates.ts
+++ b/src/auto-reply/reply/session-updates.ts
@@ -21,7 +21,11 @@ export async function prependSystemEvents(params: {
if (!trimmed) return null;
const lower = trimmed.toLowerCase();
if (lower.includes("reason periodic")) return null;
- if (lower.includes("heartbeat")) return null;
+ // Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat"
+ // The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this
+ if (lower.startsWith("read heartbeat.md")) return null;
+ // Also filter heartbeat poll/wake noise
+ if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) return null;
if (trimmed.startsWith("Node:")) {
return trimmed.replace(/ · last input [^·]+/i, "").trim();
}
diff --git a/src/channels/plugins/actions/telegram.test.ts b/src/channels/plugins/actions/telegram.test.ts
index 6b79bf5ba..b2673134d 100644
--- a/src/channels/plugins/actions/telegram.test.ts
+++ b/src/channels/plugins/actions/telegram.test.ts
@@ -62,4 +62,53 @@ describe("telegramMessageActions", () => {
cfg,
);
});
+
+ it("maps edit action params into editMessage", async () => {
+ handleTelegramAction.mockClear();
+ const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
+
+ await telegramMessageActions.handleAction({
+ action: "edit",
+ params: {
+ chatId: "123",
+ messageId: 42,
+ message: "Updated",
+ buttons: [],
+ },
+ cfg,
+ accountId: undefined,
+ });
+
+ expect(handleTelegramAction).toHaveBeenCalledWith(
+ {
+ action: "editMessage",
+ chatId: "123",
+ messageId: 42,
+ content: "Updated",
+ buttons: [],
+ accountId: undefined,
+ },
+ cfg,
+ );
+ });
+
+ it("rejects non-integer messageId for edit before reaching telegram-actions", async () => {
+ handleTelegramAction.mockClear();
+ const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
+
+ await expect(
+ telegramMessageActions.handleAction({
+ action: "edit",
+ params: {
+ chatId: "123",
+ messageId: "nope",
+ message: "Updated",
+ },
+ cfg,
+ accountId: undefined,
+ }),
+ ).rejects.toThrow();
+
+ expect(handleTelegramAction).not.toHaveBeenCalled();
+ });
});
diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts
index e281772bd..364707e0a 100644
--- a/src/channels/plugins/actions/telegram.ts
+++ b/src/channels/plugins/actions/telegram.ts
@@ -1,5 +1,6 @@
import {
createActionGate,
+ readNumberParam,
readStringOrNumberParam,
readStringParam,
} from "../../../agents/tools/common.js";
@@ -43,6 +44,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
const actions = new Set(["send"]);
if (gate("reactions")) actions.add("react");
if (gate("deleteMessage")) actions.add("delete");
+ if (gate("editMessage")) actions.add("edit");
return Array.from(actions);
},
supportsButtons: ({ cfg }) => {
@@ -100,14 +102,39 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "channelId") ??
readStringParam(params, "to", { required: true });
- const messageId = readStringParam(params, "messageId", {
+ const messageId = readNumberParam(params, "messageId", {
required: true,
+ integer: true,
});
return await handleTelegramAction(
{
action: "deleteMessage",
chatId,
- messageId: Number(messageId),
+ messageId,
+ accountId: accountId ?? undefined,
+ },
+ cfg,
+ );
+ }
+
+ if (action === "edit") {
+ const chatId =
+ readStringOrNumberParam(params, "chatId") ??
+ readStringOrNumberParam(params, "channelId") ??
+ readStringParam(params, "to", { required: true });
+ const messageId = readNumberParam(params, "messageId", {
+ required: true,
+ integer: true,
+ });
+ const message = readStringParam(params, "message", { required: true, allowEmpty: false });
+ const buttons = params.buttons;
+ return await handleTelegramAction(
+ {
+ action: "editMessage",
+ chatId,
+ messageId,
+ content: message,
+ buttons,
accountId: accountId ?? undefined,
},
cfg,
diff --git a/src/channels/plugins/onboarding/telegram.ts b/src/channels/plugins/onboarding/telegram.ts
index 0356acd33..fdbc044c5 100644
--- a/src/channels/plugins/onboarding/telegram.ts
+++ b/src/channels/plugins/onboarding/telegram.ts
@@ -80,14 +80,20 @@ async function promptTelegramAllowFrom(params: {
if (!token) return null;
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`;
- const res = await fetch(url);
- const data = (await res.json().catch(() => null)) as {
- ok?: boolean;
- result?: { id?: number | string };
- } | null;
- const id = data?.ok ? data?.result?.id : undefined;
- if (typeof id === "number" || typeof id === "string") return String(id);
- return null;
+ try {
+ const res = await fetch(url);
+ if (!res.ok) return null;
+ const data = (await res.json().catch(() => null)) as {
+ ok?: boolean;
+ result?: { id?: number | string };
+ } | null;
+ const id = data?.ok ? data?.result?.id : undefined;
+ if (typeof id === "number" || typeof id === "string") return String(id);
+ return null;
+ } catch {
+ // Network error during username lookup - return null to prompt user for numeric ID
+ return null;
+ }
};
const parseInput = (value: string) =>
diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts
index 244e72241..54b93fcc7 100644
--- a/src/cli/argv.test.ts
+++ b/src/cli/argv.test.ts
@@ -78,6 +78,48 @@ describe("argv helpers", () => {
});
expect(nodeArgv).toEqual(["node", "clawdbot", "status"]);
+ const versionedNodeArgv = buildParseArgv({
+ programName: "clawdbot",
+ rawArgs: ["node-22", "clawdbot", "status"],
+ });
+ expect(versionedNodeArgv).toEqual(["node-22", "clawdbot", "status"]);
+
+ const versionedNodeWindowsArgv = buildParseArgv({
+ programName: "clawdbot",
+ rawArgs: ["node-22.2.0.exe", "clawdbot", "status"],
+ });
+ expect(versionedNodeWindowsArgv).toEqual(["node-22.2.0.exe", "clawdbot", "status"]);
+
+ const versionedNodePatchlessArgv = buildParseArgv({
+ programName: "clawdbot",
+ rawArgs: ["node-22.2", "clawdbot", "status"],
+ });
+ expect(versionedNodePatchlessArgv).toEqual(["node-22.2", "clawdbot", "status"]);
+
+ const versionedNodeWindowsPatchlessArgv = buildParseArgv({
+ programName: "clawdbot",
+ rawArgs: ["node-22.2.exe", "clawdbot", "status"],
+ });
+ expect(versionedNodeWindowsPatchlessArgv).toEqual(["node-22.2.exe", "clawdbot", "status"]);
+
+ const versionedNodeWithPathArgv = buildParseArgv({
+ programName: "clawdbot",
+ rawArgs: ["/usr/bin/node-22.2.0", "clawdbot", "status"],
+ });
+ expect(versionedNodeWithPathArgv).toEqual(["/usr/bin/node-22.2.0", "clawdbot", "status"]);
+
+ const nodejsArgv = buildParseArgv({
+ programName: "clawdbot",
+ rawArgs: ["nodejs", "clawdbot", "status"],
+ });
+ expect(nodejsArgv).toEqual(["nodejs", "clawdbot", "status"]);
+
+ const nonVersionedNodeArgv = buildParseArgv({
+ programName: "clawdbot",
+ rawArgs: ["node-dev", "clawdbot", "status"],
+ });
+ expect(nonVersionedNodeArgv).toEqual(["node", "clawdbot", "node-dev", "clawdbot", "status"]);
+
const directArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["clawdbot", "status"],
diff --git a/src/cli/argv.ts b/src/cli/argv.ts
index bc7b60ac9..4b403c92e 100644
--- a/src/cli/argv.ts
+++ b/src/cli/argv.ts
@@ -96,15 +96,27 @@ export function buildParseArgv(params: {
: baseArgv;
const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase();
const looksLikeNode =
- normalizedArgv.length >= 2 &&
- (executable === "node" ||
- executable === "node.exe" ||
- executable === "bun" ||
- executable === "bun.exe");
+ normalizedArgv.length >= 2 && (isNodeExecutable(executable) || isBunExecutable(executable));
if (looksLikeNode) return normalizedArgv;
return ["node", programName || "clawdbot", ...normalizedArgv];
}
+const nodeExecutablePattern = /^node-\d+(?:\.\d+)*(?:\.exe)?$/;
+
+function isNodeExecutable(executable: string): boolean {
+ return (
+ executable === "node" ||
+ executable === "node.exe" ||
+ executable === "nodejs" ||
+ executable === "nodejs.exe" ||
+ nodeExecutablePattern.test(executable)
+ );
+}
+
+function isBunExecutable(executable: string): boolean {
+ return executable === "bun" || executable === "bun.exe";
+}
+
export function shouldMigrateStateFromPath(path: string[]): boolean {
if (path.length === 0) return true;
const [primary, secondary] = path;
diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts
index b24e5b456..bb029ae31 100644
--- a/src/cli/run-main.ts
+++ b/src/cli/run-main.ts
@@ -11,7 +11,7 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { formatUncaughtError } from "../infra/errors.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js";
-import { getPrimaryCommand } from "./argv.js";
+import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
import { tryRouteCli } from "./route.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
@@ -56,6 +56,15 @@ export async function runCli(argv: string[] = process.argv) {
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
+
+ const shouldSkipPluginRegistration = !primary && hasHelpOrVersion(parseArgv);
+ if (!shouldSkipPluginRegistration) {
+ // Register plugin CLI commands before parsing
+ const { registerPluginCliCommands } = await import("../plugins/cli.js");
+ const { loadConfig } = await import("../config/config.js");
+ registerPluginCliCommands(program, loadConfig());
+ }
+
await program.parseAsync(parseArgv);
}
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 9627d64f3..3261b5170 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -310,6 +310,7 @@ const FIELD_LABELS: Record = {
"channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
"channels.telegram.retry.jitter": "Telegram Retry Jitter",
+ "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily",
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
"channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons",
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
@@ -643,6 +644,8 @@ const FIELD_HELP: Record = {
"channels.telegram.retry.maxDelayMs":
"Maximum retry delay cap in ms for Telegram outbound calls.",
"channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.",
+ "channels.telegram.network.autoSelectFamily":
+ "Override Node autoSelectFamily for Telegram (true=enable, false=disable).",
"channels.telegram.timeoutSeconds":
"Max seconds before Telegram API requests are aborted (default: 500 per grammY).",
"channels.whatsapp.dmPolicy":
diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts
index 5d0b80e25..fa9e2890a 100644
--- a/src/config/types.telegram.ts
+++ b/src/config/types.telegram.ts
@@ -15,6 +15,12 @@ export type TelegramActionConfig = {
reactions?: boolean;
sendMessage?: boolean;
deleteMessage?: boolean;
+ editMessage?: boolean;
+};
+
+export type TelegramNetworkConfig = {
+ /** Override Node's autoSelectFamily behavior (true = enable, false = disable). */
+ autoSelectFamily?: boolean;
};
export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist";
@@ -95,6 +101,8 @@ export type TelegramAccountConfig = {
timeoutSeconds?: number;
/** Retry policy for outbound Telegram API calls. */
retry?: OutboundRetryConfig;
+ /** Network transport overrides for Telegram. */
+ network?: TelegramNetworkConfig;
proxy?: string;
webhookUrl?: string;
webhookSecret?: string;
diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts
index 374e6e8aa..26e279faf 100644
--- a/src/config/zod-schema.providers-core.ts
+++ b/src/config/zod-schema.providers-core.ts
@@ -110,6 +110,12 @@ export const TelegramAccountSchemaBase = z
mediaMaxMb: z.number().positive().optional(),
timeoutSeconds: z.number().int().positive().optional(),
retry: RetryConfigSchema,
+ network: z
+ .object({
+ autoSelectFamily: z.boolean().optional(),
+ })
+ .strict()
+ .optional(),
proxy: z.string().optional(),
webhookUrl: z.string().optional(),
webhookSecret: z.string().optional(),
diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts
index 75c9b3b2b..2340da2da 100644
--- a/src/discord/monitor/native-command.ts
+++ b/src/discord/monitor/native-command.ts
@@ -93,16 +93,18 @@ function buildDiscordCommandOptions(params: {
typeof focused?.value === "string" ? focused.value.trim().toLowerCase() : "";
const choices = resolveCommandArgChoices({ command, arg, cfg });
const filtered = focusValue
- ? choices.filter((choice) => choice.toLowerCase().includes(focusValue))
+ ? choices.filter((choice) => choice.label.toLowerCase().includes(focusValue))
: choices;
await interaction.respond(
- filtered.slice(0, 25).map((choice) => ({ name: choice, value: choice })),
+ filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })),
);
}
: undefined;
const choices =
resolvedChoices.length > 0 && !autocomplete
- ? resolvedChoices.slice(0, 25).map((choice) => ({ name: choice, value: choice }))
+ ? resolvedChoices
+ .slice(0, 25)
+ .map((choice) => ({ name: choice.label, value: choice.value }))
: undefined;
return {
name: arg.name,
@@ -351,7 +353,11 @@ export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgC
function buildDiscordCommandArgMenu(params: {
command: ChatCommandDefinition;
- menu: { arg: CommandArgDefinition; choices: string[]; title?: string };
+ menu: {
+ arg: CommandArgDefinition;
+ choices: Array<{ value: string; label: string }>;
+ title?: string;
+ };
interaction: CommandInteraction;
cfg: ReturnType;
discordConfig: DiscordConfig;
@@ -365,11 +371,11 @@ function buildDiscordCommandArgMenu(params: {
const buttons = choices.map(
(choice) =>
new DiscordCommandArgButton({
- label: choice,
+ label: choice.label,
customId: buildDiscordCommandArgCustomId({
command: commandLabel,
arg: menu.arg.name,
- value: choice,
+ value: choice.value,
userId,
}),
cfg: params.cfg,
diff --git a/src/docs/terminal-css.test.ts b/src/docs/terminal-css.test.ts
new file mode 100644
index 000000000..838d387a3
--- /dev/null
+++ b/src/docs/terminal-css.test.ts
@@ -0,0 +1,28 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, test } from "vitest";
+
+function readTerminalCss() {
+ // This test is intentionally simple: it guards against regressions where the
+ // docs header stops being sticky because sticky elements live inside an
+ // overflow-clipped container.
+ const path = join(process.cwd(), "docs", "assets", "terminal.css");
+ return readFileSync(path, "utf8");
+}
+
+describe("docs terminal.css", () => {
+ test("keeps the docs header sticky (shell is sticky)", () => {
+ const css = readTerminalCss();
+ expect(css).toMatch(/\.shell\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*\}/s);
+ });
+
+ test("does not rely on making body overflow visible", () => {
+ const css = readTerminalCss();
+ expect(css).not.toMatch(/body\s*\{[^}]*overflow-x:\s*visible;[^}]*\}/s);
+ });
+
+ test("does not make the terminal frame overflow visible (can break layout)", () => {
+ const css = readTerminalCss();
+ expect(css).not.toMatch(/\.shell__frame\s*\{[^}]*overflow:\s*visible;[^}]*\}/s);
+ });
+});
diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts
index 9ef62e688..8c67767a6 100644
--- a/src/gateway/server-chat.ts
+++ b/src/gateway/server-chat.ts
@@ -1,8 +1,28 @@
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
+import { loadConfig } from "../config/config.js";
import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js";
+import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
import { loadSessionEntry } from "./session-utils.js";
import { formatForLog } from "./ws-log.js";
+/**
+ * Check if webchat broadcasts should be suppressed for heartbeat runs.
+ * Returns true if the run is a heartbeat and showOk is false.
+ */
+function shouldSuppressHeartbeatBroadcast(runId: string): boolean {
+ const runContext = getAgentRunContext(runId);
+ if (!runContext?.isHeartbeat) return false;
+
+ try {
+ const cfg = loadConfig();
+ const visibility = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
+ return !visibility.showOk;
+ } catch {
+ // Default to suppressing if we can't load config
+ return true;
+ }
+}
+
export type ChatRunEntry = {
sessionKey: string;
clientRunId: string;
@@ -130,7 +150,10 @@ export function createAgentEventHandler({
timestamp: now,
},
};
- broadcast("chat", payload, { dropIfSlow: true });
+ // Suppress webchat broadcast for heartbeat runs when showOk is false
+ if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
+ broadcast("chat", payload, { dropIfSlow: true });
+ }
nodeSendToSession(sessionKey, "chat", payload);
};
@@ -158,7 +181,10 @@ export function createAgentEventHandler({
}
: undefined,
};
- broadcast("chat", payload);
+ // Suppress webchat broadcast for heartbeat runs when showOk is false
+ if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
+ broadcast("chat", payload);
+ }
nodeSendToSession(sessionKey, "chat", payload);
return;
}
diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts
index 08415f346..b72939c6a 100644
--- a/src/gateway/server-http.ts
+++ b/src/gateway/server-http.ts
@@ -291,10 +291,10 @@ export function createGatewayHttpServer(opts: {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
- } catch (err) {
+ } catch {
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
- res.end(String(err));
+ res.end("Internal Server Error");
}
}
diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts
index c11dff8ab..5c41c3c95 100644
--- a/src/infra/agent-events.ts
+++ b/src/infra/agent-events.ts
@@ -14,6 +14,7 @@ export type AgentEventPayload = {
export type AgentRunContext = {
sessionKey?: string;
verboseLevel?: VerboseLevel;
+ isHeartbeat?: boolean;
};
// Keep per-run counters so streams stay strictly monotonic per runId.
@@ -34,6 +35,9 @@ export function registerAgentRunContext(runId: string, context: AgentRunContext)
if (context.verboseLevel && existing.verboseLevel !== context.verboseLevel) {
existing.verboseLevel = context.verboseLevel;
}
+ if (context.isHeartbeat !== undefined && existing.isHeartbeat !== context.isHeartbeat) {
+ existing.isHeartbeat = context.isHeartbeat;
+ }
}
export function getAgentRunContext(runId: string) {
diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts
index 621f895fa..595cbaed7 100644
--- a/src/infra/heartbeat-runner.returns-default-unset.test.ts
+++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts
@@ -333,6 +333,7 @@ describe("runHeartbeatOnce", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
+ workspace: tmpDir,
heartbeat: { every: "5m", target: "whatsapp" },
},
},
@@ -461,6 +462,7 @@ describe("runHeartbeatOnce", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
+ workspace: tmpDir,
heartbeat: {
every: "5m",
target: "last",
@@ -542,6 +544,7 @@ describe("runHeartbeatOnce", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
+ workspace: tmpDir,
heartbeat: { every: "5m", target: "whatsapp" },
},
},
@@ -597,6 +600,7 @@ describe("runHeartbeatOnce", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
+ workspace: tmpDir,
heartbeat: {
every: "5m",
target: "whatsapp",
@@ -668,6 +672,7 @@ describe("runHeartbeatOnce", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
+ workspace: tmpDir,
heartbeat: {
every: "5m",
target: "whatsapp",
@@ -737,7 +742,7 @@ describe("runHeartbeatOnce", () => {
try {
const cfg: ClawdbotConfig = {
agents: {
- defaults: { heartbeat: { every: "5m" } },
+ defaults: { workspace: tmpDir, heartbeat: { every: "5m" } },
list: [{ id: "work", default: true }],
},
channels: { whatsapp: { allowFrom: ["*"] } },
diff --git a/src/infra/heartbeat-visibility.test.ts b/src/infra/heartbeat-visibility.test.ts
index 17a7dc128..e98054bbb 100644
--- a/src/infra/heartbeat-visibility.test.ts
+++ b/src/infra/heartbeat-visibility.test.ts
@@ -247,4 +247,58 @@ describe("resolveHeartbeatVisibility", () => {
useIndicator: true,
});
});
+
+ it("webchat uses channel defaults only (no per-channel config)", () => {
+ const cfg = {
+ channels: {
+ defaults: {
+ heartbeat: {
+ showOk: true,
+ showAlerts: false,
+ useIndicator: false,
+ },
+ },
+ },
+ } as ClawdbotConfig;
+
+ const result = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
+
+ expect(result).toEqual({
+ showOk: true,
+ showAlerts: false,
+ useIndicator: false,
+ });
+ });
+
+ it("webchat returns defaults when no channel defaults configured", () => {
+ const cfg = {} as ClawdbotConfig;
+
+ const result = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
+
+ expect(result).toEqual({
+ showOk: false,
+ showAlerts: true,
+ useIndicator: true,
+ });
+ });
+
+ it("webchat ignores accountId (only uses defaults)", () => {
+ const cfg = {
+ channels: {
+ defaults: {
+ heartbeat: {
+ showOk: true,
+ },
+ },
+ },
+ } as ClawdbotConfig;
+
+ const result = resolveHeartbeatVisibility({
+ cfg,
+ channel: "webchat",
+ accountId: "some-account",
+ });
+
+ expect(result.showOk).toBe(true);
+ });
});
diff --git a/src/infra/heartbeat-visibility.ts b/src/infra/heartbeat-visibility.ts
index 75555b878..c24b10417 100644
--- a/src/infra/heartbeat-visibility.ts
+++ b/src/infra/heartbeat-visibility.ts
@@ -1,6 +1,6 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { ChannelHeartbeatVisibilityConfig } from "../config/types.channels.js";
-import type { DeliverableMessageChannel } from "../utils/message-channel.js";
+import type { GatewayMessageChannel } from "../utils/message-channel.js";
export type ResolvedHeartbeatVisibility = {
showOk: boolean;
@@ -14,13 +14,28 @@ const DEFAULT_VISIBILITY: ResolvedHeartbeatVisibility = {
useIndicator: true, // Emit indicator events
};
+/**
+ * Resolve heartbeat visibility settings for a channel.
+ * Supports both deliverable channels (telegram, signal, etc.) and webchat.
+ * For webchat, uses channels.defaults.heartbeat since webchat doesn't have per-channel config.
+ */
export function resolveHeartbeatVisibility(params: {
cfg: ClawdbotConfig;
- channel: DeliverableMessageChannel;
+ channel: GatewayMessageChannel;
accountId?: string;
}): ResolvedHeartbeatVisibility {
const { cfg, channel, accountId } = params;
+ // Webchat uses channel defaults only (no per-channel or per-account config)
+ if (channel === "webchat") {
+ const channelDefaults = cfg.channels?.defaults?.heartbeat;
+ return {
+ showOk: channelDefaults?.showOk ?? DEFAULT_VISIBILITY.showOk,
+ showAlerts: channelDefaults?.showAlerts ?? DEFAULT_VISIBILITY.showAlerts,
+ useIndicator: channelDefaults?.useIndicator ?? DEFAULT_VISIBILITY.useIndicator,
+ };
+ }
+
// Layer 1: Global channel defaults
const channelDefaults = cfg.channels?.defaults?.heartbeat;
diff --git a/src/infra/heartbeat-wake.ts b/src/infra/heartbeat-wake.ts
index 9d5a4c4ce..eb26bf499 100644
--- a/src/infra/heartbeat-wake.ts
+++ b/src/infra/heartbeat-wake.ts
@@ -37,10 +37,10 @@ function schedule(coalesceMs: number) {
pendingReason = reason ?? "retry";
schedule(DEFAULT_RETRY_MS);
}
- } catch (err) {
+ } catch {
+ // Error is already logged by the heartbeat runner; schedule a retry.
pendingReason = reason ?? "retry";
schedule(DEFAULT_RETRY_MS);
- throw err;
} finally {
running = false;
if (pendingReason || scheduled) schedule(coalesceMs);
diff --git a/src/infra/retry-policy.test.ts b/src/infra/retry-policy.test.ts
new file mode 100644
index 000000000..02aedb087
--- /dev/null
+++ b/src/infra/retry-policy.test.ts
@@ -0,0 +1,27 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { createTelegramRetryRunner } from "./retry-policy.js";
+
+describe("createTelegramRetryRunner", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("retries when custom shouldRetry matches non-telegram error", async () => {
+ vi.useFakeTimers();
+ const runner = createTelegramRetryRunner({
+ retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
+ shouldRetry: (err) => err instanceof Error && err.message === "boom",
+ });
+ const fn = vi
+ .fn<[], Promise>()
+ .mockRejectedValueOnce(new Error("boom"))
+ .mockResolvedValue("ok");
+
+ const promise = runner(fn, "request");
+ await vi.runAllTimersAsync();
+
+ await expect(promise).resolves.toBe("ok");
+ expect(fn).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/infra/retry-policy.ts b/src/infra/retry-policy.ts
index f5a3c4b33..6d647aa5e 100644
--- a/src/infra/retry-policy.ts
+++ b/src/infra/retry-policy.ts
@@ -72,16 +72,21 @@ export function createTelegramRetryRunner(params: {
retry?: RetryConfig;
configRetry?: RetryConfig;
verbose?: boolean;
+ shouldRetry?: (err: unknown) => boolean;
}): RetryRunner {
const retryConfig = resolveRetryConfig(TELEGRAM_RETRY_DEFAULTS, {
...params.configRetry,
...params.retry,
});
+ const shouldRetry = params.shouldRetry
+ ? (err: unknown) => params.shouldRetry?.(err) || TELEGRAM_RETRY_RE.test(formatErrorMessage(err))
+ : (err: unknown) => TELEGRAM_RETRY_RE.test(formatErrorMessage(err));
+
return (fn: () => Promise, label?: string) =>
retryAsync(fn, {
...retryConfig,
label,
- shouldRetry: (err) => TELEGRAM_RETRY_RE.test(formatErrorMessage(err)),
+ shouldRetry,
retryAfterMs: getTelegramRetryAfterMs,
onRetry: params.verbose
? (info) => {
diff --git a/src/infra/unhandled-rejections.test.ts b/src/infra/unhandled-rejections.test.ts
new file mode 100644
index 000000000..1ec144ba1
--- /dev/null
+++ b/src/infra/unhandled-rejections.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, it } from "vitest";
+
+import { isAbortError, isTransientNetworkError } from "./unhandled-rejections.js";
+
+describe("isAbortError", () => {
+ it("returns true for error with name AbortError", () => {
+ const error = new Error("aborted");
+ error.name = "AbortError";
+ expect(isAbortError(error)).toBe(true);
+ });
+
+ it('returns true for error with "This operation was aborted" message', () => {
+ const error = new Error("This operation was aborted");
+ expect(isAbortError(error)).toBe(true);
+ });
+
+ it("returns true for undici-style AbortError", () => {
+ // Node's undici throws errors with this exact message
+ const error = Object.assign(new Error("This operation was aborted"), { name: "AbortError" });
+ expect(isAbortError(error)).toBe(true);
+ });
+
+ it("returns true for object with AbortError name", () => {
+ expect(isAbortError({ name: "AbortError", message: "test" })).toBe(true);
+ });
+
+ it("returns false for regular errors", () => {
+ expect(isAbortError(new Error("Something went wrong"))).toBe(false);
+ expect(isAbortError(new TypeError("Cannot read property"))).toBe(false);
+ expect(isAbortError(new RangeError("Invalid array length"))).toBe(false);
+ });
+
+ it("returns false for errors with similar but different messages", () => {
+ expect(isAbortError(new Error("Operation aborted"))).toBe(false);
+ expect(isAbortError(new Error("aborted"))).toBe(false);
+ expect(isAbortError(new Error("Request was aborted"))).toBe(false);
+ });
+
+ it("returns false for null and undefined", () => {
+ expect(isAbortError(null)).toBe(false);
+ expect(isAbortError(undefined)).toBe(false);
+ });
+
+ it("returns false for non-error values", () => {
+ expect(isAbortError("string error")).toBe(false);
+ expect(isAbortError(42)).toBe(false);
+ });
+
+ it("returns false for plain objects without AbortError name", () => {
+ expect(isAbortError({ message: "plain object" })).toBe(false);
+ });
+});
+
+describe("isTransientNetworkError", () => {
+ it("returns true for errors with transient network codes", () => {
+ const codes = [
+ "ECONNRESET",
+ "ECONNREFUSED",
+ "ENOTFOUND",
+ "ETIMEDOUT",
+ "ESOCKETTIMEDOUT",
+ "ECONNABORTED",
+ "EPIPE",
+ "EHOSTUNREACH",
+ "ENETUNREACH",
+ "EAI_AGAIN",
+ "UND_ERR_CONNECT_TIMEOUT",
+ "UND_ERR_SOCKET",
+ "UND_ERR_HEADERS_TIMEOUT",
+ "UND_ERR_BODY_TIMEOUT",
+ ];
+
+ for (const code of codes) {
+ const error = Object.assign(new Error("test"), { code });
+ expect(isTransientNetworkError(error), `code: ${code}`).toBe(true);
+ }
+ });
+
+ it('returns true for TypeError with "fetch failed" message', () => {
+ const error = new TypeError("fetch failed");
+ expect(isTransientNetworkError(error)).toBe(true);
+ });
+
+ it("returns true for fetch failed with network cause", () => {
+ const cause = Object.assign(new Error("getaddrinfo ENOTFOUND"), { code: "ENOTFOUND" });
+ const error = Object.assign(new TypeError("fetch failed"), { cause });
+ expect(isTransientNetworkError(error)).toBe(true);
+ });
+
+ it("returns true for nested cause chain with network error", () => {
+ const innerCause = Object.assign(new Error("connection reset"), { code: "ECONNRESET" });
+ const outerCause = Object.assign(new Error("wrapper"), { cause: innerCause });
+ const error = Object.assign(new TypeError("fetch failed"), { cause: outerCause });
+ expect(isTransientNetworkError(error)).toBe(true);
+ });
+
+ it("returns true for AggregateError containing network errors", () => {
+ const networkError = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" });
+ const error = new AggregateError([networkError], "Multiple errors");
+ expect(isTransientNetworkError(error)).toBe(true);
+ });
+
+ it("returns false for regular errors without network codes", () => {
+ expect(isTransientNetworkError(new Error("Something went wrong"))).toBe(false);
+ expect(isTransientNetworkError(new TypeError("Cannot read property"))).toBe(false);
+ expect(isTransientNetworkError(new RangeError("Invalid array length"))).toBe(false);
+ });
+
+ it("returns false for errors with non-network codes", () => {
+ const error = Object.assign(new Error("test"), { code: "INVALID_CONFIG" });
+ expect(isTransientNetworkError(error)).toBe(false);
+ });
+
+ it("returns false for null and undefined", () => {
+ expect(isTransientNetworkError(null)).toBe(false);
+ expect(isTransientNetworkError(undefined)).toBe(false);
+ });
+
+ it("returns false for non-error values", () => {
+ expect(isTransientNetworkError("string error")).toBe(false);
+ expect(isTransientNetworkError(42)).toBe(false);
+ expect(isTransientNetworkError({ message: "plain object" })).toBe(false);
+ });
+
+ it("returns false for AggregateError with only non-network errors", () => {
+ const error = new AggregateError([new Error("regular error")], "Multiple errors");
+ expect(isTransientNetworkError(error)).toBe(false);
+ });
+});
diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts
index c444baaa2..86e80e9a3 100644
--- a/src/infra/unhandled-rejections.ts
+++ b/src/infra/unhandled-rejections.ts
@@ -6,6 +6,83 @@ type UnhandledRejectionHandler = (reason: unknown) => boolean;
const handlers = new Set();
+/**
+ * Checks if an error is an AbortError.
+ * These are typically intentional cancellations (e.g., during shutdown) and shouldn't crash.
+ */
+export function isAbortError(err: unknown): boolean {
+ if (!err || typeof err !== "object") return false;
+ const name = "name" in err ? String(err.name) : "";
+ if (name === "AbortError") return true;
+ // Check for "This operation was aborted" message from Node's undici
+ const message = "message" in err && typeof err.message === "string" ? err.message : "";
+ if (message === "This operation was aborted") return true;
+ return false;
+}
+
+// Network error codes that indicate transient failures (shouldn't crash the gateway)
+const TRANSIENT_NETWORK_CODES = new Set([
+ "ECONNRESET",
+ "ECONNREFUSED",
+ "ENOTFOUND",
+ "ETIMEDOUT",
+ "ESOCKETTIMEDOUT",
+ "ECONNABORTED",
+ "EPIPE",
+ "EHOSTUNREACH",
+ "ENETUNREACH",
+ "EAI_AGAIN",
+ "UND_ERR_CONNECT_TIMEOUT",
+ "UND_ERR_SOCKET",
+ "UND_ERR_HEADERS_TIMEOUT",
+ "UND_ERR_BODY_TIMEOUT",
+]);
+
+function getErrorCode(err: unknown): string | undefined {
+ if (!err || typeof err !== "object") return undefined;
+ const code = (err as { code?: unknown }).code;
+ return typeof code === "string" ? code : undefined;
+}
+
+function getErrorCause(err: unknown): unknown {
+ if (!err || typeof err !== "object") return undefined;
+ return (err as { cause?: unknown }).cause;
+}
+
+/**
+ * Checks if an error is a transient network error that shouldn't crash the gateway.
+ * These are typically temporary connectivity issues that will resolve on their own.
+ */
+export function isTransientNetworkError(err: unknown): boolean {
+ if (!err) return false;
+
+ // Check the error itself
+ const code = getErrorCode(err);
+ if (code && TRANSIENT_NETWORK_CODES.has(code)) return true;
+
+ // "fetch failed" TypeError from undici (Node's native fetch)
+ if (err instanceof TypeError && err.message === "fetch failed") {
+ const cause = getErrorCause(err);
+ // The cause often contains the actual network error
+ if (cause) return isTransientNetworkError(cause);
+ // Even without a cause, "fetch failed" is typically a network issue
+ return true;
+ }
+
+ // Check the cause chain recursively
+ const cause = getErrorCause(err);
+ if (cause && cause !== err) {
+ return isTransientNetworkError(cause);
+ }
+
+ // AggregateError may wrap multiple causes
+ if (err instanceof AggregateError && err.errors?.length) {
+ return err.errors.some((e) => isTransientNetworkError(e));
+ }
+
+ return false;
+}
+
export function registerUnhandledRejectionHandler(handler: UnhandledRejectionHandler): () => void {
handlers.add(handler);
return () => {
@@ -30,6 +107,21 @@ export function isUnhandledRejectionHandled(reason: unknown): boolean {
export function installUnhandledRejectionHandler(): void {
process.on("unhandledRejection", (reason, _promise) => {
if (isUnhandledRejectionHandled(reason)) return;
+
+ // AbortError is typically an intentional cancellation (e.g., during shutdown)
+ // Log it but don't crash - these are expected during graceful shutdown
+ if (isAbortError(reason)) {
+ console.warn("[clawdbot] Suppressed AbortError:", formatUncaughtError(reason));
+ return;
+ }
+
+ // Transient network errors (fetch failed, connection reset, etc.) shouldn't crash
+ // These are temporary connectivity issues that will resolve on their own
+ if (isTransientNetworkError(reason)) {
+ console.error("[clawdbot] Network error (non-fatal):", formatUncaughtError(reason));
+ return;
+ }
+
console.error("[clawdbot] Unhandled promise rejection:", formatUncaughtError(reason));
process.exit(1);
});
diff --git a/src/line/monitor.ts b/src/line/monitor.ts
index 9b40e4460..c6241d97d 100644
--- a/src/line/monitor.ts
+++ b/src/line/monitor.ts
@@ -1,10 +1,10 @@
import type { WebhookRequestBody } from "@line/bot-sdk";
import type { IncomingMessage, ServerResponse } from "node:http";
-import crypto from "node:crypto";
import type { ClawdbotConfig } from "../config/config.js";
import { danger, logVerbose } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { createLineBot } from "./bot.js";
+import { validateLineSignature } from "./signature.js";
import { normalizePluginHttpPath } from "../plugins/http-path.js";
import { registerPluginHttpRoute } from "../plugins/http-registry.js";
import {
@@ -85,11 +85,6 @@ export function getLineRuntimeState(accountId: string) {
return runtimeState.get(`line:${accountId}`);
}
-function validateLineSignature(body: string, signature: string, channelSecret: string): boolean {
- const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
- return hash === signature;
-}
-
async function readRequestBody(req: IncomingMessage): Promise {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
diff --git a/src/line/signature.test.ts b/src/line/signature.test.ts
new file mode 100644
index 000000000..8bd9b1f3f
--- /dev/null
+++ b/src/line/signature.test.ts
@@ -0,0 +1,27 @@
+import crypto from "node:crypto";
+import { describe, expect, it } from "vitest";
+import { validateLineSignature } from "./signature.js";
+
+const sign = (body: string, secret: string) =>
+ crypto.createHmac("SHA256", secret).update(body).digest("base64");
+
+describe("validateLineSignature", () => {
+ it("accepts valid signatures", () => {
+ const secret = "secret";
+ const rawBody = JSON.stringify({ events: [{ type: "message" }] });
+
+ expect(validateLineSignature(rawBody, sign(rawBody, secret), secret)).toBe(true);
+ });
+
+ it("rejects signatures computed with the wrong secret", () => {
+ const rawBody = JSON.stringify({ events: [{ type: "message" }] });
+
+ expect(validateLineSignature(rawBody, sign(rawBody, "wrong-secret"), "secret")).toBe(false);
+ });
+
+ it("rejects signatures with a different length", () => {
+ const rawBody = JSON.stringify({ events: [{ type: "message" }] });
+
+ expect(validateLineSignature(rawBody, "short", "secret")).toBe(false);
+ });
+});
diff --git a/src/line/signature.ts b/src/line/signature.ts
new file mode 100644
index 000000000..771a950ff
--- /dev/null
+++ b/src/line/signature.ts
@@ -0,0 +1,18 @@
+import crypto from "node:crypto";
+
+export function validateLineSignature(
+ body: string,
+ signature: string,
+ channelSecret: string,
+): boolean {
+ const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
+ const hashBuffer = Buffer.from(hash);
+ const signatureBuffer = Buffer.from(signature);
+
+ // Use constant-time comparison to prevent timing attacks.
+ if (hashBuffer.length !== signatureBuffer.length) {
+ return false;
+ }
+
+ return crypto.timingSafeEqual(hashBuffer, signatureBuffer);
+}
diff --git a/src/line/webhook.test.ts b/src/line/webhook.test.ts
index af30040b4..731653a09 100644
--- a/src/line/webhook.test.ts
+++ b/src/line/webhook.test.ts
@@ -70,4 +70,41 @@ describe("createLineWebhookMiddleware", () => {
expect(res.status).toHaveBeenCalledWith(400);
expect(onEvents).not.toHaveBeenCalled();
});
+
+ it("rejects webhooks with invalid signatures", async () => {
+ const onEvents = vi.fn(async () => {});
+ const secret = "secret";
+ const rawBody = JSON.stringify({ events: [{ type: "message" }] });
+ const middleware = createLineWebhookMiddleware({ channelSecret: secret, onEvents });
+
+ const req = {
+ headers: { "x-line-signature": "invalid-signature" },
+ body: rawBody,
+ } as any;
+ const res = createRes();
+
+ await middleware(req, res, {} as any);
+
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(onEvents).not.toHaveBeenCalled();
+ });
+
+ it("rejects webhooks with signatures computed using wrong secret", async () => {
+ const onEvents = vi.fn(async () => {});
+ const correctSecret = "correct-secret";
+ const wrongSecret = "wrong-secret";
+ const rawBody = JSON.stringify({ events: [{ type: "message" }] });
+ const middleware = createLineWebhookMiddleware({ channelSecret: correctSecret, onEvents });
+
+ const req = {
+ headers: { "x-line-signature": sign(rawBody, wrongSecret) },
+ body: rawBody,
+ } as any;
+ const res = createRes();
+
+ await middleware(req, res, {} as any);
+
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(onEvents).not.toHaveBeenCalled();
+ });
});
diff --git a/src/line/webhook.ts b/src/line/webhook.ts
index 5f5e12441..9986617f9 100644
--- a/src/line/webhook.ts
+++ b/src/line/webhook.ts
@@ -1,8 +1,8 @@
import type { Request, Response, NextFunction } from "express";
-import crypto from "node:crypto";
import type { WebhookRequestBody } from "@line/bot-sdk";
import { logVerbose, danger } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
+import { validateLineSignature } from "./signature.js";
export interface LineWebhookOptions {
channelSecret: string;
@@ -10,11 +10,6 @@ export interface LineWebhookOptions {
runtime?: RuntimeEnv;
}
-function validateSignature(body: string, signature: string, channelSecret: string): boolean {
- const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
- return hash === signature;
-}
-
function readRawBody(req: Request): string | null {
const rawBody =
(req as { rawBody?: string | Buffer }).rawBody ??
@@ -52,7 +47,7 @@ export function createLineWebhookMiddleware(options: LineWebhookOptions) {
return;
}
- if (!validateSignature(rawBody, signature, channelSecret)) {
+ if (!validateLineSignature(rawBody, signature, channelSecret)) {
logVerbose("line: webhook signature validation failed");
res.status(401).json({ error: "Invalid signature" });
return;
diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts
index 1006934d3..3a43ff4cc 100644
--- a/src/security/audit.test.ts
+++ b/src/security/audit.test.ts
@@ -44,6 +44,7 @@ describe("security audit", () => {
const res = await runSecurityAudit({
config: cfg,
+ env: {},
includeFilesystem: false,
includeChannelSecurity: false,
});
@@ -88,6 +89,7 @@ describe("security audit", () => {
const res = await runSecurityAudit({
config: cfg,
+ env: {},
includeFilesystem: false,
includeChannelSecurity: false,
});
@@ -855,51 +857,62 @@ describe("security audit", () => {
const includePath = path.join(stateDir, "extra.json5");
await fs.writeFile(includePath, "{ logging: { redactSensitive: 'off' } }\n", "utf-8");
- await fs.chmod(includePath, 0o644);
+ if (isWindows) {
+ // Grant "Everyone" write access to trigger the perms_writable check on Windows
+ const { execSync } = await import("node:child_process");
+ execSync(`icacls "${includePath}" /grant Everyone:W`, { stdio: "ignore" });
+ } else {
+ await fs.chmod(includePath, 0o644);
+ }
const configPath = path.join(stateDir, "clawdbot.json");
await fs.writeFile(configPath, `{ "$include": "./extra.json5" }\n`, "utf-8");
await fs.chmod(configPath, 0o600);
- const cfg: ClawdbotConfig = { logging: { redactSensitive: "off" } };
- const user = "DESKTOP-TEST\\Tester";
- const execIcacls = isWindows
- ? async (_cmd: string, args: string[]) => {
- const target = args[0];
- if (target === includePath) {
+ try {
+ const cfg: ClawdbotConfig = { logging: { redactSensitive: "off" } };
+ const user = "DESKTOP-TEST\\Tester";
+ const execIcacls = isWindows
+ ? async (_cmd: string, args: string[]) => {
+ const target = args[0];
+ if (target === includePath) {
+ return {
+ stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(W)\n ${user}:(F)\n`,
+ stderr: "",
+ };
+ }
return {
- stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(W)\n ${user}:(F)\n`,
+ stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
stderr: "",
};
}
- return {
- stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
- stderr: "",
- };
- }
- : undefined;
- const res = await runSecurityAudit({
- config: cfg,
- includeFilesystem: true,
- includeChannelSecurity: false,
- stateDir,
- configPath,
- platform: isWindows ? "win32" : undefined,
- env: isWindows
- ? { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" }
- : undefined,
- execIcacls,
- });
+ : undefined;
+ const res = await runSecurityAudit({
+ config: cfg,
+ includeFilesystem: true,
+ includeChannelSecurity: false,
+ stateDir,
+ configPath,
+ platform: isWindows ? "win32" : undefined,
+ env: isWindows
+ ? { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" }
+ : undefined,
+ execIcacls,
+ });
- const expectedCheckId = isWindows
- ? "fs.config_include.perms_writable"
- : "fs.config_include.perms_world_readable";
+ const expectedCheckId = isWindows
+ ? "fs.config_include.perms_writable"
+ : "fs.config_include.perms_world_readable";
- expect(res.findings).toEqual(
- expect.arrayContaining([
- expect.objectContaining({ checkId: expectedCheckId, severity: "critical" }),
- ]),
- );
+ expect(res.findings).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ checkId: expectedCheckId, severity: "critical" }),
+ ]),
+ );
+ } finally {
+ // Clean up temp directory with world-writable file
+ await fs.rm(tmp, { recursive: true, force: true });
+ }
});
it("flags extensions without plugins.allow", async () => {
diff --git a/src/security/audit.ts b/src/security/audit.ts
index 2169f197d..6cac2c37c 100644
--- a/src/security/audit.ts
+++ b/src/security/audit.ts
@@ -247,12 +247,15 @@ async function collectFilesystemFindings(params: {
return findings;
}
-function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] {
+function collectGatewayConfigFindings(
+ cfg: ClawdbotConfig,
+ env: NodeJS.ProcessEnv,
+): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback";
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
- const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode });
+ const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env });
const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false;
const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies)
? cfg.gateway.trustedProxies
@@ -905,7 +908,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise;
userId: string;
}) {
const rows = chunkItems(params.choices, 5).map((choices) => ({
@@ -111,11 +111,11 @@ function buildSlackCommandArgMenuBlocks(params: {
elements: choices.map((choice) => ({
type: "button",
action_id: SLACK_COMMAND_ARG_ACTION_ID,
- text: { type: "plain_text", text: choice },
+ text: { type: "plain_text", text: choice.label },
value: encodeSlackCommandArgValue({
command: params.command,
arg: params.arg,
- value: choice,
+ value: choice.value,
userId: params.userId,
}),
})),
diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts
index c33f1e18e..e9d287d0d 100644
--- a/src/telegram/bot-native-commands.ts
+++ b/src/telegram/bot-native-commands.ts
@@ -366,10 +366,10 @@ export const registerTelegramNativeCommands = ({
rows.push(
slice.map((choice) => {
const args: CommandArgs = {
- values: { [menu.arg.name]: choice },
+ values: { [menu.arg.name]: choice.value },
};
return {
- text: choice,
+ text: choice.label,
callback_data: buildCommandTextFromArgs(commandDefinition, args),
};
}),
diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts
index 66e60ecca..0b2f9c9af 100644
--- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts
+++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts
@@ -89,6 +89,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
+ catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },
diff --git a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts
index 1a7a9d40c..b5d154c42 100644
--- a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts
+++ b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts
@@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
+ catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },
diff --git a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts
index 0aa431d1b..d6c22256b 100644
--- a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts
+++ b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts
@@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
+ catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },
diff --git a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts
index 8ed8e189f..6e04be767 100644
--- a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts
+++ b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts
@@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
+ catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },
diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts
index c30b5e33a..4c7a93529 100644
--- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts
+++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts
@@ -90,6 +90,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
+ catch = vi.fn();
constructor(
public token: string,
public options?: {
diff --git a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts
index 805aa34da..4ddb83c02 100644
--- a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts
+++ b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts
@@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
+ catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },
diff --git a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts
index ec81283bb..ba3d802e2 100644
--- a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts
+++ b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts
@@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
+ catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },
diff --git a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts
index 63ddd9bec..514ff1452 100644
--- a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts
+++ b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts
@@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
+ catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },
diff --git a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts
index dffe8ee88..1aff63ed3 100644
--- a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts
+++ b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts
@@ -93,6 +93,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
+ catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },
diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts
index 2ea914874..b6c1ca419 100644
--- a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts
+++ b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts
@@ -32,6 +32,7 @@ vi.mock("grammy", () => ({
on = onSpy;
command = vi.fn();
stop = stopSpy;
+ catch = vi.fn();
constructor(public token: string) {}
},
InputFile: class {},
diff --git a/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts b/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts
index 2242941ce..f5ac0a268 100644
--- a/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts
+++ b/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts
@@ -30,6 +30,7 @@ vi.mock("grammy", () => ({
on = onSpy;
command = vi.fn();
stop = stopSpy;
+ catch = vi.fn();
constructor(public token: string) {}
},
InputFile: class {},
diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts
index 8dc52ab57..274f7c6a9 100644
--- a/src/telegram/bot.test.ts
+++ b/src/telegram/bot.test.ts
@@ -126,6 +126,7 @@ vi.mock("grammy", () => ({
on = onSpy;
stop = stopSpy;
command = commandSpy;
+ catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },
diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts
index d958d5616..6705d359f 100644
--- a/src/telegram/bot.ts
+++ b/src/telegram/bot.ts
@@ -21,6 +21,7 @@ import {
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
+import { formatUncaughtError } from "../infra/errors.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
@@ -118,7 +119,9 @@ export function createTelegramBot(opts: TelegramBotOptions) {
});
const telegramCfg = account.config;
- const fetchImpl = resolveTelegramFetch(opts.proxyFetch);
+ const fetchImpl = resolveTelegramFetch(opts.proxyFetch, {
+ network: telegramCfg.network,
+ });
const shouldProvideFetch = Boolean(fetchImpl);
const timeoutSeconds =
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
@@ -137,6 +140,15 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const bot = new Bot(opts.token, client ? { client } : undefined);
bot.api.config.use(apiThrottler());
bot.use(sequentialize(getTelegramSequentialKey));
+ bot.catch((err) => {
+ runtime.error?.(danger(`telegram bot error: ${formatUncaughtError(err)}`));
+ });
+
+ // Catch all errors from bot middleware to prevent unhandled rejections
+ bot.catch((err) => {
+ const message = err instanceof Error ? err.message : String(err);
+ runtime.error?.(danger(`telegram bot error: ${message}`));
+ });
const recentUpdates = createTelegramUpdateDedupe();
let lastUpdateId =
diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts
index 36a680227..7a3748e5b 100644
--- a/src/telegram/bot/delivery.ts
+++ b/src/telegram/bot/delivery.ts
@@ -25,6 +25,24 @@ import type { TelegramContext } from "./types.js";
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
+/**
+ * Wraps a Telegram API call with error logging. Ensures network failures are
+ * logged with context before propagating, preventing silent unhandled rejections.
+ */
+async function withMediaErrorHandler(
+ operation: string,
+ runtime: RuntimeEnv,
+ fn: () => Promise,
+): Promise {
+ try {
+ return await fn();
+ } catch (err) {
+ const errText = formatErrorMessage(err);
+ runtime.error?.(danger(`telegram ${operation} failed: ${errText}`));
+ throw err;
+ }
+}
+
export async function deliverReplies(params: {
replies: ReplyPayload[];
chatId: string;
@@ -146,17 +164,17 @@ export async function deliverReplies(params: {
mediaParams.message_thread_id = threadParams.message_thread_id;
}
if (isGif) {
- await bot.api.sendAnimation(chatId, file, {
- ...mediaParams,
- });
+ await withMediaErrorHandler("sendAnimation", runtime, () =>
+ bot.api.sendAnimation(chatId, file, { ...mediaParams }),
+ );
} else if (kind === "image") {
- await bot.api.sendPhoto(chatId, file, {
- ...mediaParams,
- });
+ await withMediaErrorHandler("sendPhoto", runtime, () =>
+ bot.api.sendPhoto(chatId, file, { ...mediaParams }),
+ );
} else if (kind === "video") {
- await bot.api.sendVideo(chatId, file, {
- ...mediaParams,
- });
+ await withMediaErrorHandler("sendVideo", runtime, () =>
+ bot.api.sendVideo(chatId, file, { ...mediaParams }),
+ );
} else if (kind === "audio") {
const { useVoice } = resolveTelegramVoiceSend({
wantsVoice: reply.audioAsVoice === true, // default false (backward compatible)
@@ -169,9 +187,9 @@ export async function deliverReplies(params: {
// Switch typing indicator to record_voice before sending.
await params.onVoiceRecording?.();
try {
- await bot.api.sendVoice(chatId, file, {
- ...mediaParams,
- });
+ await withMediaErrorHandler("sendVoice", runtime, () =>
+ bot.api.sendVoice(chatId, file, { ...mediaParams }),
+ );
} catch (voiceErr) {
// Fall back to text if voice messages are forbidden in this chat.
// This happens when the recipient has Telegram Premium privacy settings
@@ -204,14 +222,14 @@ export async function deliverReplies(params: {
}
} else {
// Audio file - displays with metadata (title, duration) - DEFAULT
- await bot.api.sendAudio(chatId, file, {
- ...mediaParams,
- });
+ await withMediaErrorHandler("sendAudio", runtime, () =>
+ bot.api.sendAudio(chatId, file, { ...mediaParams }),
+ );
}
} else {
- await bot.api.sendDocument(chatId, file, {
- ...mediaParams,
- });
+ await withMediaErrorHandler("sendDocument", runtime, () =>
+ bot.api.sendDocument(chatId, file, { ...mediaParams }),
+ );
}
if (replyToId && !hasReplied) {
hasReplied = true;
diff --git a/src/telegram/fetch.test.ts b/src/telegram/fetch.test.ts
index 4042be60d..17cda1d00 100644
--- a/src/telegram/fetch.test.ts
+++ b/src/telegram/fetch.test.ts
@@ -1,11 +1,21 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import { resolveTelegramFetch } from "./fetch.js";
-
describe("resolveTelegramFetch", () => {
const originalFetch = globalThis.fetch;
+ const loadModule = async () => {
+ const setDefaultAutoSelectFamily = vi.fn();
+ vi.resetModules();
+ vi.doMock("node:net", () => ({
+ setDefaultAutoSelectFamily,
+ }));
+ const mod = await import("./fetch.js");
+ return { resolveTelegramFetch: mod.resolveTelegramFetch, setDefaultAutoSelectFamily };
+ };
+
afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.clearAllMocks();
if (originalFetch) {
globalThis.fetch = originalFetch;
} else {
@@ -13,16 +23,41 @@ describe("resolveTelegramFetch", () => {
}
});
- it("returns wrapped global fetch when available", () => {
+ it("returns wrapped global fetch when available", async () => {
const fetchMock = vi.fn(async () => ({}));
globalThis.fetch = fetchMock as unknown as typeof fetch;
+ const { resolveTelegramFetch } = await loadModule();
const resolved = resolveTelegramFetch();
expect(resolved).toBeTypeOf("function");
});
- it("prefers proxy fetch when provided", () => {
+ it("prefers proxy fetch when provided", async () => {
const fetchMock = vi.fn(async () => ({}));
+ const { resolveTelegramFetch } = await loadModule();
const resolved = resolveTelegramFetch(fetchMock as unknown as typeof fetch);
expect(resolved).toBeTypeOf("function");
});
+
+ it("honors env enable override", async () => {
+ vi.stubEnv("CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY", "1");
+ globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch;
+ const { resolveTelegramFetch, setDefaultAutoSelectFamily } = await loadModule();
+ resolveTelegramFetch();
+ expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(true);
+ });
+
+ it("uses config override when provided", async () => {
+ globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch;
+ const { resolveTelegramFetch, setDefaultAutoSelectFamily } = await loadModule();
+ resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } });
+ expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(true);
+ });
+
+ it("env disable override wins over config", async () => {
+ vi.stubEnv("CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY", "1");
+ globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch;
+ const { resolveTelegramFetch, setDefaultAutoSelectFamily } = await loadModule();
+ resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } });
+ expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(false);
+ });
});
diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts
index 7fdaef301..ebed468c9 100644
--- a/src/telegram/fetch.ts
+++ b/src/telegram/fetch.ts
@@ -1,7 +1,36 @@
+import * as net from "node:net";
import { resolveFetch } from "../infra/fetch.js";
+import type { TelegramNetworkConfig } from "../config/types.telegram.js";
+import { createSubsystemLogger } from "../logging/subsystem.js";
+import { resolveTelegramAutoSelectFamilyDecision } from "./network-config.js";
+
+let appliedAutoSelectFamily: boolean | null = null;
+const log = createSubsystemLogger("telegram/network");
+
+// Node 22 workaround: disable autoSelectFamily to avoid Happy Eyeballs timeouts.
+// See: https://github.com/nodejs/node/issues/54359
+function applyTelegramNetworkWorkarounds(network?: TelegramNetworkConfig): void {
+ const decision = resolveTelegramAutoSelectFamilyDecision({ network });
+ if (decision.value === null || decision.value === appliedAutoSelectFamily) return;
+ appliedAutoSelectFamily = decision.value;
+
+ if (typeof net.setDefaultAutoSelectFamily === "function") {
+ try {
+ net.setDefaultAutoSelectFamily(decision.value);
+ const label = decision.source ? ` (${decision.source})` : "";
+ log.info(`telegram: autoSelectFamily=${decision.value}${label}`);
+ } catch {
+ // ignore if unsupported by the runtime
+ }
+ }
+}
// Prefer wrapped fetch when available to normalize AbortSignal across runtimes.
-export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined {
+export function resolveTelegramFetch(
+ proxyFetch?: typeof fetch,
+ options?: { network?: TelegramNetworkConfig },
+): typeof fetch | undefined {
+ applyTelegramNetworkWorkarounds(options?.network);
if (proxyFetch) return resolveFetch(proxyFetch);
const fetchImpl = resolveFetch();
if (!fetchImpl) {
diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts
index bfd8c83ac..2fc46827b 100644
--- a/src/telegram/monitor.test.ts
+++ b/src/telegram/monitor.test.ts
@@ -35,6 +35,11 @@ const { initSpy, runSpy, loadConfig } = vi.hoisted(() => ({
})),
}));
+const { computeBackoff, sleepWithAbort } = vi.hoisted(() => ({
+ computeBackoff: vi.fn(() => 0),
+ sleepWithAbort: vi.fn(async () => undefined),
+}));
+
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -70,6 +75,11 @@ vi.mock("@grammyjs/runner", () => ({
run: runSpy,
}));
+vi.mock("../infra/backoff.js", () => ({
+ computeBackoff,
+ sleepWithAbort,
+}));
+
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: async (ctx: { Body?: string }) => ({
text: `echo:${ctx.Body}`,
@@ -84,6 +94,8 @@ describe("monitorTelegramProvider (grammY)", () => {
});
initSpy.mockClear();
runSpy.mockClear();
+ computeBackoff.mockClear();
+ sleepWithAbort.mockClear();
});
it("processes a DM and sends reply", async () => {
@@ -119,7 +131,11 @@ describe("monitorTelegramProvider (grammY)", () => {
expect.anything(),
expect.objectContaining({
sink: { concurrency: 3 },
- runner: expect.objectContaining({ silent: true }),
+ runner: expect.objectContaining({
+ silent: true,
+ maxRetryTime: 5 * 60 * 1000,
+ retryInterval: "exponential",
+ }),
}),
);
});
@@ -140,4 +156,32 @@ describe("monitorTelegramProvider (grammY)", () => {
});
expect(api.sendMessage).not.toHaveBeenCalled();
});
+
+ it("retries on recoverable network errors", async () => {
+ const networkError = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" });
+ runSpy
+ .mockImplementationOnce(() => ({
+ task: () => Promise.reject(networkError),
+ stop: vi.fn(),
+ }))
+ .mockImplementationOnce(() => ({
+ task: () => Promise.resolve(),
+ stop: vi.fn(),
+ }));
+
+ await monitorTelegramProvider({ token: "tok" });
+
+ expect(computeBackoff).toHaveBeenCalled();
+ expect(sleepWithAbort).toHaveBeenCalled();
+ expect(runSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it("surfaces non-recoverable errors", async () => {
+ runSpy.mockImplementationOnce(() => ({
+ task: () => Promise.reject(new Error("bad token")),
+ stop: vi.fn(),
+ }));
+
+ await expect(monitorTelegramProvider({ token: "tok" })).rejects.toThrow("bad token");
+ });
});
diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts
index 24c8743df..5247c2af3 100644
--- a/src/telegram/monitor.ts
+++ b/src/telegram/monitor.ts
@@ -3,11 +3,13 @@ import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { resolveAgentMaxConcurrent } from "../config/agent-limits.js";
import { computeBackoff, sleepWithAbort } from "../infra/backoff.js";
+import { formatErrorMessage } from "../infra/errors.js";
import { formatDurationMs } from "../infra/format-duration.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
import { createTelegramBot } from "./bot.js";
+import { isRecoverableTelegramNetworkError } from "./network-errors.js";
import { makeProxyFetch } from "./proxy.js";
import { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js";
import { startTelegramWebhook } from "./webhook.js";
@@ -40,6 +42,9 @@ export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions {
+ it("prefers env enable over env disable", () => {
+ const decision = resolveTelegramAutoSelectFamilyDecision({
+ env: {
+ CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY: "1",
+ CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY: "1",
+ },
+ nodeMajor: 22,
+ });
+ expect(decision).toEqual({
+ value: true,
+ source: "env:CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY",
+ });
+ });
+
+ it("uses env disable when set", () => {
+ const decision = resolveTelegramAutoSelectFamilyDecision({
+ env: { CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY: "1" },
+ nodeMajor: 22,
+ });
+ expect(decision).toEqual({
+ value: false,
+ source: "env:CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY",
+ });
+ });
+
+ it("uses config override when provided", () => {
+ const decision = resolveTelegramAutoSelectFamilyDecision({
+ network: { autoSelectFamily: true },
+ nodeMajor: 22,
+ });
+ expect(decision).toEqual({ value: true, source: "config" });
+ });
+
+ it("defaults to disable on Node 22", () => {
+ const decision = resolveTelegramAutoSelectFamilyDecision({ nodeMajor: 22 });
+ expect(decision).toEqual({ value: false, source: "default-node22" });
+ });
+
+ it("returns null when no decision applies", () => {
+ const decision = resolveTelegramAutoSelectFamilyDecision({ nodeMajor: 20 });
+ expect(decision).toEqual({ value: null });
+ });
+});
diff --git a/src/telegram/network-config.ts b/src/telegram/network-config.ts
new file mode 100644
index 000000000..ac5dd05a7
--- /dev/null
+++ b/src/telegram/network-config.ts
@@ -0,0 +1,39 @@
+import process from "node:process";
+
+import { isTruthyEnvValue } from "../infra/env.js";
+import type { TelegramNetworkConfig } from "../config/types.telegram.js";
+
+export const TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV =
+ "CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY";
+export const TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV = "CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY";
+
+export type TelegramAutoSelectFamilyDecision = {
+ value: boolean | null;
+ source?: string;
+};
+
+export function resolveTelegramAutoSelectFamilyDecision(params?: {
+ network?: TelegramNetworkConfig;
+ env?: NodeJS.ProcessEnv;
+ nodeMajor?: number;
+}): TelegramAutoSelectFamilyDecision {
+ const env = params?.env ?? process.env;
+ const nodeMajor =
+ typeof params?.nodeMajor === "number"
+ ? params.nodeMajor
+ : Number(process.versions.node.split(".")[0]);
+
+ if (isTruthyEnvValue(env[TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV])) {
+ return { value: true, source: `env:${TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV}` };
+ }
+ if (isTruthyEnvValue(env[TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV])) {
+ return { value: false, source: `env:${TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV}` };
+ }
+ if (typeof params?.network?.autoSelectFamily === "boolean") {
+ return { value: params.network.autoSelectFamily, source: "config" };
+ }
+ if (Number.isFinite(nodeMajor) && nodeMajor >= 22) {
+ return { value: false, source: "default-node22" };
+ }
+ return { value: null };
+}
diff --git a/src/telegram/network-errors.test.ts b/src/telegram/network-errors.test.ts
new file mode 100644
index 000000000..ae42cbb97
--- /dev/null
+++ b/src/telegram/network-errors.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+
+import { isRecoverableTelegramNetworkError } from "./network-errors.js";
+
+describe("isRecoverableTelegramNetworkError", () => {
+ it("detects recoverable error codes", () => {
+ const err = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" });
+ expect(isRecoverableTelegramNetworkError(err)).toBe(true);
+ });
+
+ it("detects AbortError names", () => {
+ const err = Object.assign(new Error("The operation was aborted"), { name: "AbortError" });
+ expect(isRecoverableTelegramNetworkError(err)).toBe(true);
+ });
+
+ it("detects nested causes", () => {
+ const cause = Object.assign(new Error("socket hang up"), { code: "ECONNRESET" });
+ const err = Object.assign(new TypeError("fetch failed"), { cause });
+ expect(isRecoverableTelegramNetworkError(err)).toBe(true);
+ });
+
+ it("skips message matches for send context", () => {
+ const err = new TypeError("fetch failed");
+ expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(false);
+ expect(isRecoverableTelegramNetworkError(err, { context: "polling" })).toBe(true);
+ });
+
+ it("returns false for unrelated errors", () => {
+ expect(isRecoverableTelegramNetworkError(new Error("invalid token"))).toBe(false);
+ });
+});
diff --git a/src/telegram/network-errors.ts b/src/telegram/network-errors.ts
new file mode 100644
index 000000000..70cd81994
--- /dev/null
+++ b/src/telegram/network-errors.ts
@@ -0,0 +1,112 @@
+import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
+
+const RECOVERABLE_ERROR_CODES = new Set([
+ "ECONNRESET",
+ "ECONNREFUSED",
+ "EPIPE",
+ "ETIMEDOUT",
+ "ESOCKETTIMEDOUT",
+ "ENETUNREACH",
+ "EHOSTUNREACH",
+ "ENOTFOUND",
+ "EAI_AGAIN",
+ "UND_ERR_CONNECT_TIMEOUT",
+ "UND_ERR_HEADERS_TIMEOUT",
+ "UND_ERR_BODY_TIMEOUT",
+ "UND_ERR_SOCKET",
+ "UND_ERR_ABORTED",
+]);
+
+const RECOVERABLE_ERROR_NAMES = new Set([
+ "AbortError",
+ "TimeoutError",
+ "ConnectTimeoutError",
+ "HeadersTimeoutError",
+ "BodyTimeoutError",
+]);
+
+const RECOVERABLE_MESSAGE_SNIPPETS = [
+ "fetch failed",
+ "network error",
+ "network request",
+ "client network socket disconnected",
+ "socket hang up",
+ "getaddrinfo",
+];
+
+function normalizeCode(code?: string): string {
+ return code?.trim().toUpperCase() ?? "";
+}
+
+function getErrorName(err: unknown): string {
+ if (!err || typeof err !== "object") return "";
+ return "name" in err ? String(err.name) : "";
+}
+
+function getErrorCode(err: unknown): string | undefined {
+ const direct = extractErrorCode(err);
+ if (direct) return direct;
+ if (!err || typeof err !== "object") return undefined;
+ const errno = (err as { errno?: unknown }).errno;
+ if (typeof errno === "string") return errno;
+ if (typeof errno === "number") return String(errno);
+ return undefined;
+}
+
+function collectErrorCandidates(err: unknown): unknown[] {
+ const queue = [err];
+ const seen = new Set();
+ const candidates: unknown[] = [];
+
+ while (queue.length > 0) {
+ const current = queue.shift();
+ if (current == null || seen.has(current)) continue;
+ seen.add(current);
+ candidates.push(current);
+
+ if (typeof current === "object") {
+ const cause = (current as { cause?: unknown }).cause;
+ if (cause && !seen.has(cause)) queue.push(cause);
+ const reason = (current as { reason?: unknown }).reason;
+ if (reason && !seen.has(reason)) queue.push(reason);
+ const errors = (current as { errors?: unknown }).errors;
+ if (Array.isArray(errors)) {
+ for (const nested of errors) {
+ if (nested && !seen.has(nested)) queue.push(nested);
+ }
+ }
+ }
+ }
+
+ return candidates;
+}
+
+export type TelegramNetworkErrorContext = "polling" | "send" | "webhook" | "unknown";
+
+export function isRecoverableTelegramNetworkError(
+ err: unknown,
+ options: { context?: TelegramNetworkErrorContext; allowMessageMatch?: boolean } = {},
+): boolean {
+ if (!err) return false;
+ const allowMessageMatch =
+ typeof options.allowMessageMatch === "boolean"
+ ? options.allowMessageMatch
+ : options.context !== "send";
+
+ for (const candidate of collectErrorCandidates(err)) {
+ const code = normalizeCode(getErrorCode(candidate));
+ if (code && RECOVERABLE_ERROR_CODES.has(code)) return true;
+
+ const name = getErrorName(candidate);
+ if (name && RECOVERABLE_ERROR_NAMES.has(name)) return true;
+
+ if (allowMessageMatch) {
+ const message = formatErrorMessage(candidate).toLowerCase();
+ if (message && RECOVERABLE_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
diff --git a/src/telegram/send.caption-split.test.ts b/src/telegram/send.caption-split.test.ts
index 58e0a921a..7911e2890 100644
--- a/src/telegram/send.caption-split.test.ts
+++ b/src/telegram/send.caption-split.test.ts
@@ -19,6 +19,7 @@ vi.mock("../web/media.js", () => ({
vi.mock("grammy", () => ({
Bot: class {
api = botApi;
+ catch = vi.fn();
constructor(
public token: string,
public options?: {
diff --git a/src/telegram/send.edit-message.test.ts b/src/telegram/send.edit-message.test.ts
new file mode 100644
index 000000000..d495dfff5
--- /dev/null
+++ b/src/telegram/send.edit-message.test.ts
@@ -0,0 +1,91 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { botApi, botCtorSpy } = vi.hoisted(() => ({
+ botApi: {
+ editMessageText: vi.fn(),
+ },
+ botCtorSpy: vi.fn(),
+}));
+
+vi.mock("grammy", () => ({
+ Bot: class {
+ api = botApi;
+ constructor(public token: string) {
+ botCtorSpy(token);
+ }
+ },
+ InputFile: class {},
+}));
+
+import { editMessageTelegram } from "./send.js";
+
+describe("editMessageTelegram", () => {
+ beforeEach(() => {
+ botApi.editMessageText.mockReset();
+ botCtorSpy.mockReset();
+ });
+
+ it("keeps existing buttons when buttons is undefined (no reply_markup)", async () => {
+ botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
+
+ await editMessageTelegram("123", 1, "hi", {
+ token: "tok",
+ cfg: {},
+ });
+
+ expect(botCtorSpy).toHaveBeenCalledWith("tok");
+ expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
+ const call = botApi.editMessageText.mock.calls[0] ?? [];
+ const params = call[3] as Record;
+ expect(params).toEqual(expect.objectContaining({ parse_mode: "HTML" }));
+ expect(params).not.toHaveProperty("reply_markup");
+ });
+
+ it("removes buttons when buttons is empty (reply_markup.inline_keyboard = [])", async () => {
+ botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
+
+ await editMessageTelegram("123", 1, "hi", {
+ token: "tok",
+ cfg: {},
+ buttons: [],
+ });
+
+ expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
+ const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record;
+ expect(params).toEqual(
+ expect.objectContaining({
+ parse_mode: "HTML",
+ reply_markup: { inline_keyboard: [] },
+ }),
+ );
+ });
+
+ it("falls back to plain text when Telegram HTML parse fails (and preserves reply_markup)", async () => {
+ botApi.editMessageText
+ .mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities"))
+ .mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } });
+
+ await editMessageTelegram("123", 1, " html", {
+ token: "tok",
+ cfg: {},
+ buttons: [],
+ });
+
+ expect(botApi.editMessageText).toHaveBeenCalledTimes(2);
+
+ const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record;
+ expect(firstParams).toEqual(
+ expect.objectContaining({
+ parse_mode: "HTML",
+ reply_markup: { inline_keyboard: [] },
+ }),
+ );
+
+ const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record;
+ expect(secondParams).toEqual(
+ expect.objectContaining({
+ reply_markup: { inline_keyboard: [] },
+ }),
+ );
+ });
+});
diff --git a/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts b/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts
index 18176d259..2f9e7d057 100644
--- a/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts
+++ b/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts
@@ -19,6 +19,7 @@ vi.mock("../web/media.js", () => ({
vi.mock("grammy", () => ({
Bot: class {
api = botApi;
+ catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch } },
diff --git a/src/telegram/send.proxy.test.ts b/src/telegram/send.proxy.test.ts
index b395662e4..39ef9e2d0 100644
--- a/src/telegram/send.proxy.test.ts
+++ b/src/telegram/send.proxy.test.ts
@@ -40,6 +40,7 @@ vi.mock("./fetch.js", () => ({
vi.mock("grammy", () => ({
Bot: class {
api = botApi;
+ catch = vi.fn();
constructor(
public token: string,
public options?: { client?: { fetch?: typeof fetch; timeoutSeconds?: number } },
@@ -76,7 +77,7 @@ describe("telegram proxy client", () => {
await sendMessageTelegram("123", "hi", { token: "tok", accountId: "foo" });
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
- expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch);
+ expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined });
expect(botCtorSpy).toHaveBeenCalledWith(
"tok",
expect.objectContaining({
@@ -94,7 +95,7 @@ describe("telegram proxy client", () => {
await reactMessageTelegram("123", "456", "✅", { token: "tok", accountId: "foo" });
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
- expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch);
+ expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined });
expect(botCtorSpy).toHaveBeenCalledWith(
"tok",
expect.objectContaining({
@@ -112,7 +113,7 @@ describe("telegram proxy client", () => {
await deleteMessageTelegram("123", "456", { token: "tok", accountId: "foo" });
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
- expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch);
+ expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined });
expect(botCtorSpy).toHaveBeenCalledWith(
"tok",
expect.objectContaining({
diff --git a/src/telegram/send.returns-undefined-empty-input.test.ts b/src/telegram/send.returns-undefined-empty-input.test.ts
index 6e2ea85d0..d086fe2a3 100644
--- a/src/telegram/send.returns-undefined-empty-input.test.ts
+++ b/src/telegram/send.returns-undefined-empty-input.test.ts
@@ -19,6 +19,7 @@ vi.mock("../web/media.js", () => ({
vi.mock("grammy", () => ({
Bot: class {
api = botApi;
+ catch = vi.fn();
constructor(
public token: string,
public options?: {
diff --git a/src/telegram/send.ts b/src/telegram/send.ts
index f9557bf1e..d28cff55e 100644
--- a/src/telegram/send.ts
+++ b/src/telegram/send.ts
@@ -22,6 +22,7 @@ import { resolveTelegramFetch } from "./fetch.js";
import { makeProxyFetch } from "./proxy.js";
import { renderTelegramHtmlText } from "./format.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
+import { isRecoverableTelegramNetworkError } from "./network-errors.js";
import { splitTelegramCaption } from "./caption.js";
import { recordSentMessage } from "./sent-message-cache.js";
import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js";
@@ -84,7 +85,9 @@ function resolveTelegramClientOptions(
): ApiClientOptions | undefined {
const proxyUrl = account.config.proxy?.trim();
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
- const fetchImpl = resolveTelegramFetch(proxyFetch);
+ const fetchImpl = resolveTelegramFetch(proxyFetch, {
+ network: account.config.network,
+ });
const timeoutSeconds =
typeof account.config.timeoutSeconds === "number" &&
Number.isFinite(account.config.timeoutSeconds)
@@ -203,6 +206,7 @@ export async function sendMessageTelegram(
retry: opts.retry,
configRetry: account.config.retry,
verbose: opts.verbose,
+ shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
});
const logHttpError = createTelegramHttpLogger(cfg);
const requestWithDiag = (fn: () => Promise, label?: string) =>
@@ -434,6 +438,7 @@ export async function reactMessageTelegram(
retry: opts.retry,
configRetry: account.config.retry,
verbose: opts.verbose,
+ shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
});
const logHttpError = createTelegramHttpLogger(cfg);
const requestWithDiag = (fn: () => Promise, label?: string) =>
@@ -483,6 +488,7 @@ export async function deleteMessageTelegram(
retry: opts.retry,
configRetry: account.config.retry,
verbose: opts.verbose,
+ shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
});
const logHttpError = createTelegramHttpLogger(cfg);
const requestWithDiag = (fn: () => Promise, label?: string) =>
@@ -495,6 +501,99 @@ export async function deleteMessageTelegram(
return { ok: true };
}
+type TelegramEditOpts = {
+ token?: string;
+ accountId?: string;
+ verbose?: boolean;
+ api?: Bot["api"];
+ retry?: RetryConfig;
+ textMode?: "markdown" | "html";
+ /** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */
+ buttons?: Array>;
+ /** Optional config injection to avoid global loadConfig() (improves testability). */
+ cfg?: ReturnType;
+};
+
+export async function editMessageTelegram(
+ chatIdInput: string | number,
+ messageIdInput: string | number,
+ text: string,
+ opts: TelegramEditOpts = {},
+): Promise<{ ok: true; messageId: string; chatId: string }> {
+ const cfg = opts.cfg ?? loadConfig();
+ const account = resolveTelegramAccount({
+ cfg,
+ accountId: opts.accountId,
+ });
+ const token = resolveToken(opts.token, account);
+ const chatId = normalizeChatId(String(chatIdInput));
+ const messageId = normalizeMessageId(messageIdInput);
+ const client = resolveTelegramClientOptions(account);
+ const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
+ const request = createTelegramRetryRunner({
+ retry: opts.retry,
+ configRetry: account.config.retry,
+ verbose: opts.verbose,
+ });
+ const logHttpError = createTelegramHttpLogger(cfg);
+ const requestWithDiag = (fn: () => Promise, label?: string) =>
+ request(fn, label).catch((err) => {
+ logHttpError(label ?? "request", err);
+ throw err;
+ });
+
+ const textMode = opts.textMode ?? "markdown";
+ const tableMode = resolveMarkdownTableMode({
+ cfg,
+ channel: "telegram",
+ accountId: account.accountId,
+ });
+ const htmlText = renderTelegramHtmlText(text, { textMode, tableMode });
+
+ // Reply markup semantics:
+ // - buttons === undefined → don't send reply_markup (keep existing)
+ // - buttons is [] (or filters to empty) → send { inline_keyboard: [] } (remove)
+ // - otherwise → send built inline keyboard
+ const shouldTouchButtons = opts.buttons !== undefined;
+ const builtKeyboard = shouldTouchButtons ? buildInlineKeyboard(opts.buttons) : undefined;
+ const replyMarkup = shouldTouchButtons ? (builtKeyboard ?? { inline_keyboard: [] }) : undefined;
+
+ const editParams: Record = {
+ parse_mode: "HTML",
+ };
+ if (replyMarkup !== undefined) {
+ editParams.reply_markup = replyMarkup;
+ }
+
+ await requestWithDiag(
+ () => api.editMessageText(chatId, messageId, htmlText, editParams),
+ "editMessage",
+ ).catch(async (err) => {
+ // Telegram rejects malformed HTML. Fall back to plain text.
+ const errText = formatErrorMessage(err);
+ if (PARSE_ERR_RE.test(errText)) {
+ if (opts.verbose) {
+ console.warn(`telegram HTML parse failed, retrying as plain text: ${errText}`);
+ }
+ const plainParams: Record = {};
+ if (replyMarkup !== undefined) {
+ plainParams.reply_markup = replyMarkup;
+ }
+ return await requestWithDiag(
+ () =>
+ Object.keys(plainParams).length > 0
+ ? api.editMessageText(chatId, messageId, text, plainParams)
+ : api.editMessageText(chatId, messageId, text),
+ "editMessage-plain",
+ );
+ }
+ throw err;
+ });
+
+ logVerbose(`[telegram] Edited message ${messageId} in chat ${chatId}`);
+ return { ok: true, messageId: String(messageId), chatId };
+}
+
function inferFilename(kind: ReturnType) {
switch (kind) {
case "image":
diff --git a/src/telegram/webhook-set.ts b/src/telegram/webhook-set.ts
index eced660e6..2880c8254 100644
--- a/src/telegram/webhook-set.ts
+++ b/src/telegram/webhook-set.ts
@@ -1,4 +1,5 @@
import { type ApiClientOptions, Bot } from "grammy";
+import type { TelegramNetworkConfig } from "../config/types.telegram.js";
import { resolveTelegramFetch } from "./fetch.js";
export async function setTelegramWebhook(opts: {
@@ -6,8 +7,9 @@ export async function setTelegramWebhook(opts: {
url: string;
secret?: string;
dropPendingUpdates?: boolean;
+ network?: TelegramNetworkConfig;
}) {
- const fetchImpl = resolveTelegramFetch();
+ const fetchImpl = resolveTelegramFetch(undefined, { network: opts.network });
const client: ApiClientOptions | undefined = fetchImpl
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
: undefined;
@@ -18,8 +20,11 @@ export async function setTelegramWebhook(opts: {
});
}
-export async function deleteTelegramWebhook(opts: { token: string }) {
- const fetchImpl = resolveTelegramFetch();
+export async function deleteTelegramWebhook(opts: {
+ token: string;
+ network?: TelegramNetworkConfig;
+}) {
+ const fetchImpl = resolveTelegramFetch(undefined, { network: opts.network });
const client: ApiClientOptions | undefined = fetchImpl
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
: undefined;
diff --git a/src/tts/tts.ts b/src/tts/tts.ts
index 847876d04..9507c5535 100644
--- a/src/tts/tts.ts
+++ b/src/tts/tts.ts
@@ -40,7 +40,7 @@ import { resolveModel } from "../agents/pi-embedded-runner/model.js";
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_TTS_MAX_LENGTH = 1500;
const DEFAULT_TTS_SUMMARIZE = true;
-const DEFAULT_MAX_TEXT_LENGTH = 4000;
+const DEFAULT_MAX_TEXT_LENGTH = 4096;
const TEMP_FILE_CLEANUP_DELAY_MS = 5 * 60 * 1000; // 5 minutes
const DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io";
@@ -1386,32 +1386,34 @@ export async function maybeApplyTtsToPayload(params: {
if (textForAudio.length > maxLength) {
if (!isSummarizationEnabled(prefsPath)) {
+ // Truncate text when summarization is disabled
logVerbose(
- `TTS: skipping long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
+ `TTS: truncating long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
);
- return nextPayload;
- }
-
- try {
- const summary = await summarizeText({
- text: textForAudio,
- targetLength: maxLength,
- cfg: params.cfg,
- config,
- timeoutMs: config.timeoutMs,
- });
- textForAudio = summary.summary;
- wasSummarized = true;
- if (textForAudio.length > config.maxTextLength) {
- logVerbose(
- `TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`,
- );
- textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`;
+ textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`;
+ } else {
+ // Summarize text when enabled
+ try {
+ const summary = await summarizeText({
+ text: textForAudio,
+ targetLength: maxLength,
+ cfg: params.cfg,
+ config,
+ timeoutMs: config.timeoutMs,
+ });
+ textForAudio = summary.summary;
+ wasSummarized = true;
+ if (textForAudio.length > config.maxTextLength) {
+ logVerbose(
+ `TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`,
+ );
+ textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`;
+ }
+ } catch (err) {
+ const error = err as Error;
+ logVerbose(`TTS: summarization failed, truncating instead: ${error.message}`);
+ textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`;
}
- } catch (err) {
- const error = err as Error;
- logVerbose(`TTS: summarization failed: ${error.message}`);
- return nextPayload;
}
}
@@ -1436,12 +1438,12 @@ export async function maybeApplyTtsToPayload(params: {
const channelId = resolveChannelId(params.channel);
const shouldVoice = channelId === "telegram" && result.voiceCompatible === true;
-
- return {
+ const finalPayload = {
...nextPayload,
mediaUrl: result.audioPath,
audioAsVoice: shouldVoice || params.payload.audioAsVoice,
};
+ return finalPayload;
}
lastTtsAttempt = {