Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7b13c4d0f | ||
|
|
45d2debd3f | ||
|
|
53c4bc106c | ||
|
|
0f7db91256 | ||
|
|
969510fc60 | ||
|
|
704af78a5d | ||
|
|
d4f60bf16a | ||
|
|
ede5145191 | ||
|
|
26d3fbb09f | ||
|
|
f7c89ba796 | ||
|
|
9afde64e26 | ||
|
|
8682524da3 | ||
|
|
5956dde459 |
No files matched your search
@@ -18,6 +18,7 @@
|
||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
||||
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
|
||||
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
||||
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||
|
||||
@@ -11,12 +11,15 @@ Docs: https://docs.clawd.bot
|
||||
### Changes
|
||||
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
|
||||
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
||||
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||
- Docs: add verbose installer troubleshooting guidance.
|
||||
- Docs: update Fly.io guide notes.
|
||||
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
||||
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
|
||||
|
||||
### Fixes
|
||||
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||
@@ -38,6 +41,9 @@ Docs: https://docs.clawd.bot
|
||||
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||
- Google Chat: normalize space targets without double `spaces/` prefix.
|
||||
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
||||
- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.
|
||||
- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||
- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||
|
||||
## 2026.1.23-1
|
||||
|
||||
|
||||
@@ -120,6 +120,13 @@ You can add custom commands to the menu via config:
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `setMyCommands failed` in logs usually means outbound HTTPS/DNS is blocked to `api.telegram.org`.
|
||||
- If you see `sendMessage` or `sendChatAction` failures, check IPv6 routing and DNS.
|
||||
|
||||
More help: [Channel troubleshooting](/channels/troubleshooting).
|
||||
|
||||
Notes:
|
||||
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
|
||||
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
|
||||
|
||||
@@ -22,3 +22,4 @@ clawdbot channels status --probe
|
||||
|
||||
## Telegram quick fixes
|
||||
- Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting).
|
||||
- Logs show `setMyCommands failed` → check outbound HTTPS and DNS reachability to `api.telegram.org` (common on locked-down VPS or proxies).
|
||||
@@ -31,6 +31,7 @@ These files live under the workspace (`agents.defaults.workspace`, default
|
||||
- Decisions, preferences, and durable facts go to `MEMORY.md`.
|
||||
- Day-to-day notes and running context go to `memory/YYYY-MM-DD.md`.
|
||||
- If someone says "remember this," write it down (do not keep it in RAM).
|
||||
- This area is still evolving. It helps to remind the model to store memories; it will know what to do.
|
||||
- If you want something to stick, **ask the bot to write it** into memory.
|
||||
|
||||
## Automatic memory flush (pre-compaction ping)
|
||||
|
||||
@@ -46,6 +46,9 @@ better forms without hard-coding config knowledge.
|
||||
Use `config.apply` to validate + write the full config and restart the Gateway in one step.
|
||||
It writes a restart sentinel and pings the last active session after the Gateway comes back.
|
||||
|
||||
Warning: `config.apply` replaces the **entire config**. If you want to change only a few keys,
|
||||
use `config.patch` or `clawdbot config set`. Keep a backup of `~/.clawdbot/clawdbot.json`.
|
||||
|
||||
Params:
|
||||
- `raw` (string) — JSON5 payload for the entire config
|
||||
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
|
||||
@@ -1506,7 +1509,7 @@ voice notes; other channels send MP3 audio.
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
auto: "always", // off | always | inbound | tagged
|
||||
mode: "final", // final | all (include tool/block replies)
|
||||
provider: "elevenlabs",
|
||||
summaryModel: "openai/gpt-4.1-mini",
|
||||
@@ -1543,8 +1546,10 @@ voice notes; other channels send MP3 audio.
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `messages.tts.enabled` can be overridden by local user prefs (see `/tts on`, `/tts off`).
|
||||
- `prefsPath` stores local overrides (enabled/provider/limit/summarize).
|
||||
- `messages.tts.auto` controls auto‑TTS (`off`, `always`, `inbound`, `tagged`).
|
||||
- `/tts off|always|inbound|tagged` sets the per‑session auto mode (overrides config).
|
||||
- `messages.tts.enabled` is legacy; doctor migrates it to `messages.tts.auto`.
|
||||
- `prefsPath` stores local overrides (provider/limit/summarize).
|
||||
- `maxTextLength` is a hard cap for TTS input; summaries are truncated to fit.
|
||||
- `summaryModel` overrides `agents.defaults.model.primary` for auto-summary.
|
||||
- Accepts `provider/model` or an alias from `agents.defaults.models`.
|
||||
|
||||
+276
-36
@@ -8,12 +8,14 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
## Table of contents
|
||||
|
||||
- [Quick start and first-run setup](#quick-start-and-first-run-setup)
|
||||
- [I'm stuck — what's the fastest way to get unstuck?](#im-stuck--whats-the-fastest-way-to-get-unstuck)
|
||||
- [Im stuck - whats the fastest way to get unstuck?](#im-stuck--whats-the-fastest-way-to-get-unstuck)
|
||||
- [What’s the recommended way to install and set up Clawdbot?](#whats-the-recommended-way-to-install-and-set-up-clawdbot)
|
||||
- [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding)
|
||||
- [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote)
|
||||
- [What runtime do I need?](#what-runtime-do-i-need)
|
||||
- [Does it run on Raspberry Pi?](#does-it-run-on-raspberry-pi)
|
||||
- [Any tips for Raspberry Pi installs?](#any-tips-for-raspberry-pi-installs)
|
||||
- [It is stuck on "wake up my friend" / onboarding will not hatch. What now?](#it-is-stuck-on-wake-up-my-friend--onboarding-will-not-hatch-what-now)
|
||||
- [Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?](#can-i-migrate-my-setup-to-a-new-machine-mac-mini-without-redoing-onboarding)
|
||||
- [Where do I see what’s new in the latest version?](#where-do-i-see-whats-new-in-the-latest-version)
|
||||
- [I can't access docs.clawd.bot (SSL error). What now?](#i-cant-access-docsclawdbot-ssl-error-what-now)
|
||||
@@ -21,7 +23,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [How do I install the beta version, and what’s the difference between beta and dev?](#how-do-i-install-the-beta-version-and-whats-the-difference-between-beta-and-dev)
|
||||
- [How do I try the latest bits?](#how-do-i-try-the-latest-bits)
|
||||
- [Installer stuck? How do I get more feedback?](#installer-stuck-how-do-i-get-more-feedback)
|
||||
- [The docs didn’t answer my question — how do I get a better answer?](#the-docs-didnt-answer-my-question--how-do-i-get-a-better-answer)
|
||||
- [The docs didn’t answer my question - how do I get a better answer?](#the-docs-didnt-answer-my-question--how-do-i-get-a-better-answer)
|
||||
- [How do I install Clawdbot on Linux?](#how-do-i-install-clawdbot-on-linux)
|
||||
- [How do I install Clawdbot on a VPS?](#how-do-i-install-clawdbot-on-a-vps)
|
||||
- [Where are the cloud/VPS install guides?](#where-are-the-cloudvps-install-guides)
|
||||
@@ -49,6 +51,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
|
||||
- [Should I run the Gateway on my laptop or a VPS?](#should-i-run-the-gateway-on-my-laptop-or-a-vps)
|
||||
- [How important is it to run Clawdbot on a dedicated machine?](#how-important-is-it-to-run-clawdbot-on-a-dedicated-machine)
|
||||
- [What are the minimum VPS requirements and recommended OS?](#what-are-the-minimum-vps-requirements-and-recommended-os)
|
||||
- [What is Clawdbot?](#what-is-clawdbot)
|
||||
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
|
||||
- [What’s the value proposition?](#whats-the-value-proposition)
|
||||
@@ -56,6 +59,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [How do I customize skills without keeping the repo dirty?](#how-do-i-customize-skills-without-keeping-the-repo-dirty)
|
||||
- [Can I load skills from a custom folder?](#can-i-load-skills-from-a-custom-folder)
|
||||
- [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks)
|
||||
- [The bot freezes while doing heavy work. How do I offload that?](#the-bot-freezes-while-doing-heavy-work-how-do-i-offload-that)
|
||||
- [Cron or reminders do not fire. What should I check?](#cron-or-reminders-do-not-fire-what-should-i-check)
|
||||
- [How do I install skills on Linux?](#how-do-i-install-skills-on-linux)
|
||||
- [Can Clawdbot run tasks on a schedule or continuously in the background?](#can-clawdbot-run-tasks-on-a-schedule-or-continuously-in-the-background)
|
||||
- [Can I run Apple/macOS-only skills from Linux?](#can-i-run-applemacos-only-skills-from-linux)
|
||||
@@ -65,6 +70,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [Is there a dedicated sandboxing doc?](#is-there-a-dedicated-sandboxing-doc)
|
||||
- [How do I bind a host folder into the sandbox?](#how-do-i-bind-a-host-folder-into-the-sandbox)
|
||||
- [How does memory work?](#how-does-memory-work)
|
||||
- [Memory keeps forgetting things. How do I make it stick?](#memory-keeps-forgetting-things-how-do-i-make-it-stick)
|
||||
- [Does semantic memory search require an OpenAI API key?](#does-semantic-memory-search-require-an-openai-api-key)
|
||||
- [Where things live on disk](#where-things-live-on-disk)
|
||||
- [Is all data used with Clawdbot saved locally?](#is-all-data-used-with-clawdbot-saved-locally)
|
||||
@@ -73,19 +79,21 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [What’s the recommended backup strategy?](#whats-the-recommended-backup-strategy)
|
||||
- [How do I completely uninstall Clawdbot?](#how-do-i-completely-uninstall-clawdbot)
|
||||
- [Can agents work outside the workspace?](#can-agents-work-outside-the-workspace)
|
||||
- [I’m in remote mode — where is the session store?](#im-in-remote-mode-where-is-the-session-store)
|
||||
- [I’m in remote mode - where is the session store?](#im-in-remote-mode-where-is-the-session-store)
|
||||
- [Config basics](#config-basics)
|
||||
- [What format is the config? Where is it?](#what-format-is-the-config-where-is-it)
|
||||
- [I set `gateway.bind: "lan"` (or `"tailnet"`) and now nothing listens / the UI says unauthorized](#i-set-gatewaybind-lan-or-tailnet-and-now-nothing-listens-the-ui-says-unauthorized)
|
||||
- [Why do I need a token on localhost now?](#why-do-i-need-a-token-on-localhost-now)
|
||||
- [Do I have to restart after changing config?](#do-i-have-to-restart-after-changing-config)
|
||||
- [How do I enable web search (and web fetch)?](#how-do-i-enable-web-search-and-web-fetch)
|
||||
- [config.apply wiped my config. How do I recover and avoid this?](#configapply-wiped-my-config-how-do-i-recover-and-avoid-this)
|
||||
- [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices)
|
||||
- [Can the Clawdbot browser run headless?](#can-the-clawdbot-browser-run-headless)
|
||||
- [How do I use Brave for browser control?](#how-do-i-use-brave-for-browser-control)
|
||||
- [Remote gateways + nodes](#remote-gateways-nodes)
|
||||
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
||||
- [How can my agent access my computer if the Gateway is hosted remotely?](#how-can-my-agent-access-my-computer-if-the-gateway-is-hosted-remotely)
|
||||
- [Tailscale is connected but I get no replies. What now?](#tailscale-is-connected-but-i-get-no-replies-what-now)
|
||||
- [Can two Clawdbots talk to each other (local + VPS)?](#can-two-clawdbots-talk-to-each-other-local--vps)
|
||||
- [Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?](#is-there-a-benefit-to-using-a-node-on-my-personal-laptop-instead-of-ssh-from-a-vps)
|
||||
- [Do nodes run a gateway service?](#do-nodes-run-a-gateway-service)
|
||||
@@ -93,6 +101,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
|
||||
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
|
||||
- [How do I connect a Mac node to a remote Gateway (Tailscale Serve)?](#how-do-i-connect-a-mac-node-to-a-remote-gateway-tailscale-serve)
|
||||
- [Should I install on a second laptop or just add a node?](#should-i-install-on-a-second-laptop-or-just-add-a-node)
|
||||
- [Env vars and .env loading](#env-vars-and-env-loading)
|
||||
- [How does Clawdbot load environment variables?](#how-does-clawdbot-load-environment-variables)
|
||||
- [“I started the Gateway via the service and my env vars disappeared.” What now?](#i-started-the-gateway-via-the-service-and-my-env-vars-disappeared-what-now)
|
||||
@@ -101,7 +110,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
|
||||
- [Do sessions reset automatically if I never send `/new`?](#do-sessions-reset-automatically-if-i-never-send-new)
|
||||
- [How do I completely reset Clawdbot but keep it installed?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
|
||||
- [I’m getting “context too large” errors — how do I reset or compact?](#im-getting-context-too-large-errors-how-do-i-reset-or-compact)
|
||||
- [I’m getting “context too large” errors - how do I reset or compact?](#im-getting-context-too-large-errors-how-do-i-reset-or-compact)
|
||||
- [Why am I seeing “LLM request rejected: messages.N.content.X.tool_use.input: Field required”?](#why-am-i-seeing-llm-request-rejected-messagesncontentxtool_useinput-field-required)
|
||||
- [Why am I getting heartbeat messages every 30 minutes?](#why-am-i-getting-heartbeat-messages-every-30-minutes)
|
||||
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
|
||||
@@ -113,6 +122,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching)
|
||||
- [What is the “default model”?](#what-is-the-default-model)
|
||||
- [What model do you recommend?](#what-model-do-you-recommend)
|
||||
- [How do I switch models without wiping my config?](#how-do-i-switch-models-without-wiping-my-config)
|
||||
- [Can I use self-hosted models (llama.cpp, vLLM, Ollama)?](#can-i-use-self-hosted-models-llamacpp-vllm-ollama)
|
||||
- [What do Clawd, Flawd, and Krill use for models?](#what-do-clawd-flawd-and-krill-use-for-models)
|
||||
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
||||
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
|
||||
@@ -144,7 +155,11 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [Logging and debugging](#logging-and-debugging)
|
||||
- [Where are logs?](#where-are-logs)
|
||||
- [How do I start/stop/restart the Gateway service?](#how-do-i-startstoprestart-the-gateway-service)
|
||||
- [I closed my terminal on Windows — how do I restart Clawdbot?](#i-closed-my-terminal-on-windows--how-do-i-restart-clawdbot)
|
||||
- [I closed my terminal on Windows - how do I restart Clawdbot?](#i-closed-my-terminal-on-windows--how-do-i-restart-clawdbot)
|
||||
- [The Gateway is up but replies never arrive. What should I check?](#the-gateway-is-up-but-replies-never-arrive-what-should-i-check)
|
||||
- ["Disconnected from gateway: no reason" - what now?](#disconnected-from-gateway-no-reason---what-now)
|
||||
- [Telegram setMyCommands fails with network errors. What should I check?](#telegram-setmycommands-fails-with-network-errors-what-should-i-check)
|
||||
- [TUI shows no output. What should I check?](#tui-shows-no-output-what-should-i-check)
|
||||
- [How do I completely stop then start the Gateway?](#how-do-i-completely-stop-then-start-the-gateway)
|
||||
- [ELI5: `clawdbot gateway restart` vs `clawdbot gateway`](#eli5-clawdbot-gateway-restart-vs-clawdbot-gateway)
|
||||
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
|
||||
@@ -212,7 +227,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
|
||||
## Quick start and first-run setup
|
||||
|
||||
### I'm stuck — what's the fastest way to get unstuck?
|
||||
### Im stuck - whats the fastest way to get unstuck?
|
||||
|
||||
Use a local AI agent that can **see your machine**. That is far more effective than asking
|
||||
in Discord, because most "I'm stuck" cases are **local config or environment issues** that
|
||||
@@ -285,7 +300,7 @@ If you don’t have a global install yet, run it via `pnpm clawdbot onboard`.
|
||||
|
||||
### How do I open the dashboard after onboarding?
|
||||
|
||||
The wizard now opens your browser with a tokenized dashboard URL right after onboarding and also prints the full link (with token) in the summary. Keep that tab open; if it didn’t launch, copy/paste the printed URL on the same machine. Tokens stay local to your host—nothing is fetched from the browser.
|
||||
The wizard now opens your browser with a tokenized dashboard URL right after onboarding and also prints the full link (with token) in the summary. Keep that tab open; if it didn’t launch, copy/paste the printed URL on the same machine. Tokens stay local to your host-nothing is fetched from the browser.
|
||||
|
||||
### How do I authenticate the dashboard (token) on localhost vs remote?
|
||||
|
||||
@@ -307,7 +322,7 @@ Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** fo
|
||||
|
||||
### Does it run on Raspberry Pi?
|
||||
|
||||
Yes. The Gateway is lightweight — docs list **512MB–1GB RAM**, **1 core**, and about **500MB**
|
||||
Yes. The Gateway is lightweight - docs list **512MB–1GB RAM**, **1 core**, and about **500MB**
|
||||
disk as enough for personal use, and note that a **Raspberry Pi 4 can run it**.
|
||||
|
||||
If you want extra headroom (logs, media, other services), **2GB is recommended**, but it’s
|
||||
@@ -316,6 +331,41 @@ not a hard minimum.
|
||||
Tip: a small Pi/VPS can host the Gateway, and you can pair **nodes** on your laptop/phone for
|
||||
local screen/camera/canvas or command execution. See [Nodes](/nodes).
|
||||
|
||||
### Any tips for Raspberry Pi installs?
|
||||
|
||||
Short version: it works, but expect rough edges.
|
||||
|
||||
- Use a **64-bit** OS and keep Node >= 22.
|
||||
- Prefer the **hackable (git) install** so you can see logs and update fast.
|
||||
- Start without channels/skills, then add them one by one.
|
||||
- If you hit weird binary issues, it is usually an **ARM compatibility** problem.
|
||||
|
||||
Docs: [Linux](/platforms/linux), [Install](/install).
|
||||
|
||||
### It is stuck on "wake up my friend" / onboarding will not hatch. What now?
|
||||
|
||||
That screen depends on the Gateway being reachable and authenticated. The TUI also sends
|
||||
"Wake up, my friend!" automatically on first hatch. If you see that line with **no reply**
|
||||
and tokens stay at 0, the agent never ran.
|
||||
|
||||
1) Restart the Gateway:
|
||||
```bash
|
||||
clawdbot gateway restart
|
||||
```
|
||||
2) Check status + auth:
|
||||
```bash
|
||||
clawdbot status
|
||||
clawdbot models status
|
||||
clawdbot logs --follow
|
||||
```
|
||||
3) If it still hangs, run:
|
||||
```bash
|
||||
clawdbot doctor
|
||||
```
|
||||
|
||||
If the Gateway is remote, ensure the tunnel/Tailscale connection is up and that the UI
|
||||
is pointed at the right Gateway. See [Remote access](/gateway/remote).
|
||||
|
||||
### Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?
|
||||
|
||||
Yes. Copy the **state directory** and **workspace**, then run Doctor once. This
|
||||
@@ -439,7 +489,7 @@ curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --verb
|
||||
|
||||
More options: [Installer flags](/install/installer).
|
||||
|
||||
### The docs didn’t answer my question — how do I get a better answer?
|
||||
### The docs didn’t answer my question - how do I get a better answer?
|
||||
|
||||
Use the **hackable (git) install** so you have the full source and docs locally, then ask
|
||||
your bot (or Claude/Codex) *from that folder* so it can read the repo and answer precisely.
|
||||
@@ -569,7 +619,7 @@ See [Models](/cli/models) and [OAuth](/concepts/oauth).
|
||||
|
||||
### Is AWS Bedrock supported?
|
||||
|
||||
Yes — via pi‑ai’s **Amazon Bedrock (Converse)** provider with **manual config**. You must supply AWS credentials/region on the gateway host and add a Bedrock provider entry in your models config. See [Amazon Bedrock](/bedrock) and [Model providers](/providers/models). If you prefer a managed key flow, an OpenAI‑compatible proxy in front of Bedrock is still a valid option.
|
||||
Yes - via pi‑ai’s **Amazon Bedrock (Converse)** provider with **manual config**. You must supply AWS credentials/region on the gateway host and add a Bedrock provider entry in your models config. See [Amazon Bedrock](/bedrock) and [Model providers](/providers/models). If you prefer a managed key flow, an OpenAI‑compatible proxy in front of Bedrock is still a valid option.
|
||||
|
||||
### How does Codex auth work?
|
||||
|
||||
@@ -585,7 +635,7 @@ See [OAuth](/concepts/oauth), [Model providers](/concepts/model-providers), and
|
||||
|
||||
### Is a local model OK for casual chats?
|
||||
|
||||
Usually no. Clawdbot needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.1 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk — see [Security](/gateway/security).
|
||||
Usually no. Clawdbot needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.1 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk - see [Security](/gateway/security).
|
||||
|
||||
### How do I keep hosted model traffic in a specific region?
|
||||
|
||||
@@ -593,7 +643,7 @@ Pick region-pinned endpoints. OpenRouter exposes US-hosted options for MiniMax,
|
||||
|
||||
### Do I have to buy a Mac Mini to install this?
|
||||
|
||||
No. Clawdbot runs on macOS or Linux (Windows via WSL2). A Mac mini is optional — some people
|
||||
No. Clawdbot runs on macOS or Linux (Windows via WSL2). A Mac mini is optional - some people
|
||||
buy one as an always‑on host, but a small VPS, home server, or Raspberry Pi‑class box works too.
|
||||
|
||||
You only need a Mac **for macOS‑only tools**. For iMessage, you can keep the Gateway on Linux
|
||||
@@ -604,7 +654,7 @@ Docs: [iMessage](/channels/imessage), [Nodes](/nodes), [Mac remote mode](/platfo
|
||||
|
||||
### Do I need a Mac mini for iMessage support?
|
||||
|
||||
You need **some macOS device** signed into Messages. It does **not** have to be a Mac mini —
|
||||
You need **some macOS device** signed into Messages. It does **not** have to be a Mac mini -
|
||||
any Mac works. Clawdbot’s iMessage integrations run on macOS (BlueBubbles or `imsg`), while
|
||||
the Gateway can run elsewhere.
|
||||
|
||||
@@ -619,7 +669,7 @@ Docs: [iMessage](/channels/imessage), [BlueBubbles](/channels/bluebubbles),
|
||||
### If I buy a Mac mini to run Clawdbot, can I connect it to my MacBook Pro?
|
||||
|
||||
Yes. The **Mac mini can run the Gateway**, and your MacBook Pro can connect as a
|
||||
**node** (companion device). Nodes don’t run the Gateway — they provide extra
|
||||
**node** (companion device). Nodes don’t run the Gateway - they provide extra
|
||||
capabilities like screen/camera/canvas and `system.run` on that device.
|
||||
|
||||
Common pattern:
|
||||
@@ -686,7 +736,7 @@ Docs: [Getting started](/start/getting-started), [Updating](/install/updating).
|
||||
### Can I switch between npm and git installs later?
|
||||
|
||||
Yes. Install the other flavor, then run Doctor so the gateway service points at the new entrypoint.
|
||||
This **does not delete your data** — it only changes the Clawdbot code install. Your state
|
||||
This **does not delete your data** - it only changes the Clawdbot code install. Your state
|
||||
(`~/.clawdbot`) and workspace (`~/clawd`) stay untouched.
|
||||
|
||||
From npm → git:
|
||||
@@ -737,6 +787,18 @@ Not required, but **recommended for reliability and isolation**.
|
||||
- **Shared laptop/desktop:** totally fine for testing and active use, but expect pauses when the machine sleeps or updates.
|
||||
|
||||
If you want the best of both worlds, keep the Gateway on a dedicated host and pair your laptop as a **node** for local screen/camera/exec tools. See [Nodes](/nodes).
|
||||
For security guidance, read [Security](/gateway/security).
|
||||
|
||||
### What are the minimum VPS requirements and recommended OS?
|
||||
|
||||
Clawdbot is lightweight. For a basic Gateway + one chat channel:
|
||||
|
||||
- **Absolute minimum:** 1 vCPU, 1GB RAM, ~500MB disk.
|
||||
- **Recommended:** 1-2 vCPU, 2GB RAM or more for headroom (logs, media, multiple channels). Node tools and browser automation can be resource hungry.
|
||||
|
||||
OS: use **Ubuntu LTS** (or any modern Debian/Ubuntu). The Linux install path is best tested there.
|
||||
|
||||
Docs: [Linux](/platforms/linux), [VPS hosting](/vps).
|
||||
|
||||
## What is Clawdbot?
|
||||
|
||||
@@ -748,7 +810,7 @@ Clawdbot is a personal AI assistant you run on your own devices. It replies on t
|
||||
|
||||
Clawdbot is not “just a Claude wrapper.” It’s a **local-first control plane** that lets you run a
|
||||
capable assistant on **your own hardware**, reachable from the chat apps you already use, with
|
||||
stateful sessions, memory, and tools — without handing control of your workflows to a hosted
|
||||
stateful sessions, memory, and tools - without handing control of your workflows to a hosted
|
||||
SaaS.
|
||||
|
||||
Highlights:
|
||||
@@ -785,6 +847,37 @@ Today the supported patterns are:
|
||||
|
||||
See [Cron jobs](/automation/cron-jobs), [Multi-Agent Routing](/concepts/multi-agent), and [Slash commands](/tools/slash-commands).
|
||||
|
||||
### The bot freezes while doing heavy work. How do I offload that?
|
||||
|
||||
Use **sub-agents** for long or parallel tasks. Sub-agents run in their own session,
|
||||
return a summary, and keep your main chat responsive.
|
||||
|
||||
Ask your bot to "spawn a sub-agent for this task" or use `/subagents`.
|
||||
Use `/status` in chat to see what the Gateway is doing right now (and whether it is busy).
|
||||
|
||||
Token tip: long tasks and sub-agents both consume tokens. If cost is a concern, set a
|
||||
cheaper model for sub-agents via `agents.defaults.subagents.model`.
|
||||
|
||||
Docs: [Sub-agents](/tools/subagents).
|
||||
|
||||
### Cron or reminders do not fire. What should I check?
|
||||
|
||||
Cron runs inside the Gateway process. If the Gateway is not running continuously,
|
||||
scheduled jobs will not run.
|
||||
|
||||
Checklist:
|
||||
- Confirm cron is enabled (`cron.enabled`) and `CLAWDBOT_SKIP_CRON` is not set.
|
||||
- Check the Gateway is running 24/7 (no sleep/restarts).
|
||||
- Verify timezone settings for the job (`--tz` vs host timezone).
|
||||
|
||||
Debug:
|
||||
```bash
|
||||
clawdbot cron run <jobId> --force
|
||||
clawdbot cron runs --id <jobId> --limit 50
|
||||
```
|
||||
|
||||
Docs: [Cron jobs](/automation/cron-jobs), [Cron vs Heartbeat](/automation/cron-vs-heartbeat).
|
||||
|
||||
### How do I install skills on Linux?
|
||||
|
||||
Use **ClawdHub** (CLI) or drop skills into your workspace. The macOS Skills UI isn’t available on Linux.
|
||||
@@ -893,7 +986,7 @@ Yes. See [Sandboxing](/gateway/sandboxing). For Docker-specific setup (full gate
|
||||
|
||||
### Can I keep DMs “personal” but make groups “public/sandboxed” with one agent?
|
||||
|
||||
Yes — if your private traffic is **DMs** and your public traffic is **groups**.
|
||||
Yes - if your private traffic is **DMs** and your public traffic is **groups**.
|
||||
|
||||
Use `agents.defaults.sandbox.mode: "non-main"` so group/channel sessions (non-main keys) run in Docker, while the main DM session stays on-host. Then restrict what tools are available in sandboxed sessions via `tools.sandbox.tools`.
|
||||
|
||||
@@ -915,6 +1008,17 @@ Clawdbot also runs a **silent pre-compaction memory flush** to remind the model
|
||||
to write durable notes before auto-compaction. This only runs when the workspace
|
||||
is writable (read-only sandboxes skip it). See [Memory](/concepts/memory).
|
||||
|
||||
### Memory keeps forgetting things. How do I make it stick?
|
||||
|
||||
Ask the bot to **write the fact to memory**. Long-term notes belong in `MEMORY.md`,
|
||||
short-term context goes into `memory/YYYY-MM-DD.md`.
|
||||
|
||||
This is still an area we are improving. It helps to remind the model to store memories;
|
||||
it will know what to do. If it keeps forgetting, verify the Gateway is using the same
|
||||
workspace on every run.
|
||||
|
||||
Docs: [Memory](/concepts/memory), [Agent workspace](/concepts/agent-workspace).
|
||||
|
||||
### Does semantic memory search require an OpenAI API key?
|
||||
|
||||
Only if you use **OpenAI embeddings**. Codex OAuth covers chat/completions and
|
||||
@@ -933,13 +1037,13 @@ If you’d rather stay local, set `memorySearch.provider = "local"` (and optiona
|
||||
`memorySearch.fallback = "none"`). If you want Gemini embeddings, set
|
||||
`memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or
|
||||
`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, or local** embedding
|
||||
models — see [Memory](/concepts/memory) for the setup details.
|
||||
models - see [Memory](/concepts/memory) for the setup details.
|
||||
|
||||
## Where things live on disk
|
||||
|
||||
### Is all data used with Clawdbot saved locally?
|
||||
|
||||
No — **Clawdbot’s state is local**, but **external services still see what you send them**.
|
||||
No - **Clawdbot’s state is local**, but **external services still see what you send them**.
|
||||
|
||||
- **Local by default:** sessions, memory files, config, and workspace live on the Gateway host
|
||||
(`~/.clawdbot` + your workspace directory).
|
||||
@@ -1034,7 +1138,7 @@ Example (repo as default cwd):
|
||||
}
|
||||
```
|
||||
|
||||
### I’m in remote mode — where is the session store?
|
||||
### I’m in remote mode - where is the session store?
|
||||
|
||||
Session state is owned by the **gateway host**. If you’re in remote mode, the session store you care about is on the remote machine, not your local laptop. See [Session management](/concepts/session).
|
||||
|
||||
@@ -1188,6 +1292,20 @@ pair devices you trust, and review [Security](/gateway/security).
|
||||
|
||||
Docs: [Nodes](/nodes), [Gateway protocol](/gateway/protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security).
|
||||
|
||||
### Tailscale is connected but I get no replies. What now?
|
||||
|
||||
Check the basics:
|
||||
- Gateway is running: `clawdbot gateway status`
|
||||
- Gateway health: `clawdbot status`
|
||||
- Channel health: `clawdbot channels status`
|
||||
|
||||
Then verify auth and routing:
|
||||
- If you use Tailscale Serve, make sure `gateway.auth.allowTailscale` is set correctly.
|
||||
- If you connect via SSH tunnel, confirm the local tunnel is up and points at the right port.
|
||||
- Confirm your allowlists (DM or group) include your account.
|
||||
|
||||
Docs: [Tailscale](/gateway/tailscale), [Remote access](/gateway/remote), [Channels](/channels).
|
||||
|
||||
### Can two Clawdbots talk to each other (local + VPS)?
|
||||
|
||||
Yes. There is no built-in "bot-to-bot" bridge, but you can wire it up in a few
|
||||
@@ -1213,7 +1331,7 @@ Docs: [Remote access](/gateway/remote), [Agent CLI](/cli/agent), [Agent send](/t
|
||||
|
||||
### Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?
|
||||
|
||||
Yes — nodes are the first‑class way to reach your laptop from a remote Gateway, and they
|
||||
Yes - nodes are the first‑class way to reach your laptop from a remote Gateway, and they
|
||||
unlock more than shell access. The Gateway runs on macOS/Linux (Windows via WSL2) and is
|
||||
lightweight (a small VPS or Raspberry Pi-class box is fine; 4 GB RAM is plenty), so a common
|
||||
setup is an always‑on host plus your laptop as a node.
|
||||
@@ -1229,6 +1347,16 @@ device automation.
|
||||
|
||||
Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes), [Chrome extension](/tools/chrome-extension).
|
||||
|
||||
### Should I install on a second laptop or just add a node?
|
||||
|
||||
If you only need **local tools** (screen/camera/exec) on the second laptop, add it as a
|
||||
**node**. That keeps a single Gateway and avoids duplicated config. Local node tools are
|
||||
currently macOS-only, but we plan to extend them to other OSes.
|
||||
|
||||
Install a second Gateway only when you need **hard isolation** or two fully separate bots.
|
||||
|
||||
Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes), [Multiple gateways](/gateway/multiple-gateways).
|
||||
|
||||
### Do nodes run a gateway service?
|
||||
|
||||
No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect
|
||||
@@ -1241,6 +1369,23 @@ A full restart is required for `gateway`, `discovery`, and `canvasHost` changes.
|
||||
|
||||
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
|
||||
|
||||
### config.apply wiped my config. How do I recover and avoid this?
|
||||
|
||||
`config.apply` replaces the **entire config**. If you send a partial object, everything
|
||||
else is removed.
|
||||
|
||||
Recover:
|
||||
- Restore from backup (git or a copied `~/.clawdbot/clawdbot.json`).
|
||||
- If you have no backup, re-run `clawdbot doctor` and reconfigure channels/models.
|
||||
- If this was unexpected, file a bug and include your last known config or any backup.
|
||||
- A local coding agent can often reconstruct a working config from logs or history.
|
||||
|
||||
Avoid it:
|
||||
- Use `clawdbot config set` for small changes.
|
||||
- Use `clawdbot configure` for interactive edits.
|
||||
|
||||
Docs: [Config](/cli/config), [Configure](/cli/configure), [Doctor](/gateway/doctor).
|
||||
|
||||
### What’s a minimal “sane” config for a first install?
|
||||
|
||||
```json5
|
||||
@@ -1339,7 +1484,7 @@ This runs your login shell and imports only missing expected keys (never overrid
|
||||
### I set `COPILOT_GITHUB_TOKEN`, but models status shows “Shell env: off.” Why?
|
||||
|
||||
`clawdbot models status` reports whether **shell env import** is enabled. “Shell env: off”
|
||||
does **not** mean your env vars are missing — it just means Clawdbot won’t load
|
||||
does **not** mean your env vars are missing - it just means Clawdbot won’t load
|
||||
your login shell automatically.
|
||||
|
||||
If the Gateway runs as a service (launchd/systemd), it won’t inherit your shell
|
||||
@@ -1370,7 +1515,7 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce
|
||||
|
||||
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next**
|
||||
message starts a fresh session id for that chat key. This does not delete
|
||||
transcripts — it just starts a new session.
|
||||
transcripts - it just starts a new session.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -1405,7 +1550,7 @@ Notes:
|
||||
- If you used profiles (`--profile` / `CLAWDBOT_PROFILE`), reset each state dir (defaults are `~/.clawdbot-<profile>`).
|
||||
- Dev reset: `clawdbot gateway --dev --reset` (dev-only; wipes dev config + credentials + sessions + workspace).
|
||||
|
||||
### I’m getting “context too large” errors — how do I reset or compact?
|
||||
### I’m getting “context too large” errors - how do I reset or compact?
|
||||
|
||||
Use one of these:
|
||||
|
||||
@@ -1524,7 +1669,7 @@ Tips:
|
||||
Yes. Use **Multi‑Agent Routing** to run multiple isolated agents and route inbound messages by
|
||||
channel/account/peer. Slack is supported as a channel and can be bound to specific agents.
|
||||
|
||||
Browser access is powerful but not “do anything a human can” — anti‑bot, CAPTCHAs, and MFA can
|
||||
Browser access is powerful but not “do anything a human can” - anti‑bot, CAPTCHAs, and MFA can
|
||||
still block automation. For the most reliable browser control, use the Chrome extension relay
|
||||
on the machine that runs the browser (and keep the Gateway anywhere).
|
||||
|
||||
@@ -1547,13 +1692,13 @@ Clawdbot’s default model is whatever you set as:
|
||||
agents.defaults.model.primary
|
||||
```
|
||||
|
||||
Models are referenced as `provider/model` (example: `anthropic/claude-opus-4-5`). If you omit the provider, Clawdbot currently assumes `anthropic` as a temporary deprecation fallback — but you should still **explicitly** set `provider/model`.
|
||||
Models are referenced as `provider/model` (example: `anthropic/claude-opus-4-5`). If you omit the provider, Clawdbot currently assumes `anthropic` as a temporary deprecation fallback - but you should still **explicitly** set `provider/model`.
|
||||
|
||||
### What model do you recommend?
|
||||
|
||||
**Recommended default:** `anthropic/claude-opus-4-5`.
|
||||
**Good alternative:** `anthropic/claude-sonnet-4-5`.
|
||||
**Reliable (less character):** `openai/gpt-5.2` — nearly as good as Opus, just less personality.
|
||||
**Reliable (less character):** `openai/gpt-5.2` - nearly as good as Opus, just less personality.
|
||||
**Budget:** `zai/glm-4.7`.
|
||||
|
||||
MiniMax M2.1 has its own docs: [MiniMax](/providers/minimax) and
|
||||
@@ -1569,10 +1714,38 @@ injection and unsafe behavior. See [Security](/gateway/security).
|
||||
|
||||
More context: [Models](/concepts/models).
|
||||
|
||||
### Can I use self-hosted models (llama.cpp, vLLM, Ollama)?
|
||||
|
||||
Yes. If your local server exposes an OpenAI-compatible API, you can point a
|
||||
custom provider at it. Ollama is supported directly and is the easiest path.
|
||||
|
||||
Security note: smaller or heavily quantized models are more vulnerable to prompt
|
||||
injection. We strongly recommend **large models** for any bot that can use tools.
|
||||
If you still want small models, enable sandboxing and strict tool allowlists.
|
||||
|
||||
Docs: [Ollama](/providers/ollama), [Local models](/gateway/local-models),
|
||||
[Model providers](/concepts/model-providers), [Security](/gateway/security),
|
||||
[Sandboxing](/gateway/sandboxing).
|
||||
|
||||
### How do I switch models without wiping my config?
|
||||
|
||||
Use **model commands** or edit only the **model** fields. Avoid full config replaces.
|
||||
|
||||
Safe options:
|
||||
- `/model` in chat (quick, per-session)
|
||||
- `clawdbot models set ...` (updates just model config)
|
||||
- `clawdbot configure --section models` (interactive)
|
||||
- edit `agents.defaults.model` in `~/.clawdbot/clawdbot.json`
|
||||
|
||||
Avoid `config.apply` with a partial object unless you intend to replace the whole config.
|
||||
If you did overwrite config, restore from backup or re-run `clawdbot doctor` to repair.
|
||||
|
||||
Docs: [Models](/concepts/models), [Configure](/cli/configure), [Config](/cli/config), [Doctor](/gateway/doctor).
|
||||
|
||||
### What do Clawd, Flawd, and Krill use for models?
|
||||
|
||||
- **Clawd + Flawd:** Anthropic Opus (`anthropic/claude-opus-4-5`) — see [Anthropic](/providers/anthropic).
|
||||
- **Krill:** MiniMax M2.1 (`minimax/MiniMax-M2.1`) — see [MiniMax](/providers/minimax).
|
||||
- **Clawd + Flawd:** Anthropic Opus (`anthropic/claude-opus-4-5`) - see [Anthropic](/providers/anthropic).
|
||||
- **Krill:** MiniMax M2.1 (`minimax/MiniMax-M2.1`) - see [MiniMax](/providers/minimax).
|
||||
|
||||
### How do I switch models on the fly (without restarting)?
|
||||
|
||||
@@ -1971,7 +2144,7 @@ Note: `tailnet` is explicit. `auto` prefers loopback; use `gateway.bind: "tailne
|
||||
|
||||
### Can I run multiple Gateways on the same host?
|
||||
|
||||
Usually no — one Gateway can run multiple messaging channels and agents. Use multiple Gateways only when you need redundancy (ex: rescue bot) or hard isolation.
|
||||
Usually no - one Gateway can run multiple messaging channels and agents. Use multiple Gateways only when you need redundancy (ex: rescue bot) or hard isolation.
|
||||
|
||||
Yes, but you must isolate:
|
||||
|
||||
@@ -2047,7 +2220,7 @@ clawdbot gateway restart
|
||||
|
||||
If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway).
|
||||
|
||||
### I closed my terminal on Windows — how do I restart Clawdbot?
|
||||
### I closed my terminal on Windows - how do I restart Clawdbot?
|
||||
|
||||
There are **two Windows install modes**:
|
||||
|
||||
@@ -2084,6 +2257,73 @@ clawdbot gateway run
|
||||
|
||||
Docs: [Windows (WSL2)](/platforms/windows), [Gateway service runbook](/gateway).
|
||||
|
||||
### The Gateway is up but replies never arrive. What should I check?
|
||||
|
||||
Start with a quick health sweep:
|
||||
|
||||
```bash
|
||||
clawdbot status
|
||||
clawdbot models status
|
||||
clawdbot channels status
|
||||
clawdbot logs --follow
|
||||
```
|
||||
|
||||
Common causes:
|
||||
- Model auth not loaded on the **gateway host** (check `models status`).
|
||||
- Channel pairing/allowlist blocking replies (check channel config + logs).
|
||||
- WebChat/Dashboard is open without the right token.
|
||||
|
||||
If you are remote, confirm the tunnel/Tailscale connection is up and that the
|
||||
Gateway WebSocket is reachable.
|
||||
|
||||
Docs: [Channels](/channels), [Troubleshooting](/gateway/troubleshooting), [Remote access](/gateway/remote).
|
||||
|
||||
### "Disconnected from gateway: no reason" - what now?
|
||||
|
||||
This usually means the UI lost the WebSocket connection. Check:
|
||||
|
||||
1) Is the Gateway running? `clawdbot gateway status`
|
||||
2) Is the Gateway healthy? `clawdbot status`
|
||||
3) Does the UI have the right token? `clawdbot dashboard`
|
||||
4) If remote, is the tunnel/Tailscale link up?
|
||||
|
||||
Then tail logs:
|
||||
|
||||
```bash
|
||||
clawdbot logs --follow
|
||||
```
|
||||
|
||||
Docs: [Dashboard](/web/dashboard), [Remote access](/gateway/remote), [Troubleshooting](/gateway/troubleshooting).
|
||||
|
||||
### Telegram setMyCommands fails with network errors. What should I check?
|
||||
|
||||
Start with logs and channel status:
|
||||
|
||||
```bash
|
||||
clawdbot channels status
|
||||
clawdbot channels logs --channel telegram
|
||||
```
|
||||
|
||||
If you are on a VPS or behind a proxy, confirm outbound HTTPS is allowed and DNS works.
|
||||
If the Gateway is remote, make sure you are looking at logs on the Gateway host.
|
||||
|
||||
Docs: [Telegram](/channels/telegram), [Channel troubleshooting](/channels/troubleshooting).
|
||||
|
||||
### TUI shows no output. What should I check?
|
||||
|
||||
First confirm the Gateway is reachable and the agent can run:
|
||||
|
||||
```bash
|
||||
clawdbot status
|
||||
clawdbot models status
|
||||
clawdbot logs --follow
|
||||
```
|
||||
|
||||
In the TUI, use `/status` to see the current state. If you expect replies in a chat
|
||||
channel, make sure delivery is enabled (`/deliver on`).
|
||||
|
||||
Docs: [TUI](/tui), [Slash commands](/tools/slash-commands).
|
||||
|
||||
### How do I completely stop then start the Gateway?
|
||||
|
||||
If you installed the service:
|
||||
@@ -2259,11 +2499,11 @@ agent, set it under `agents.list[].tools.message` instead.
|
||||
|
||||
Queue mode controls how new messages interact with an in‑flight run. Use `/queue` to change modes:
|
||||
|
||||
- `steer` — new messages redirect the current task
|
||||
- `followup` — run messages one at a time
|
||||
- `collect` — batch messages and reply once (default)
|
||||
- `steer-backlog` — steer now, then process backlog
|
||||
- `interrupt` — abort current run and start fresh
|
||||
- `steer` - new messages redirect the current task
|
||||
- `followup` - run messages one at a time
|
||||
- `collect` - batch messages and reply once (default)
|
||||
- `steer-backlog` - steer now, then process backlog
|
||||
- `interrupt` - abort current run and start fresh
|
||||
|
||||
You can add options like `debounce:2s cap:25 drop:summarize` for followup modes.
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ Text + native (when enabled):
|
||||
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
|
||||
- `/tts on|off|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
|
||||
- `/tts off|always|inbound|tagged|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
|
||||
- Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works.
|
||||
- `/stop`
|
||||
- `/restart`
|
||||
|
||||
+29
-10
@@ -53,8 +53,8 @@ so that provider must also be authenticated if you enable summaries.
|
||||
|
||||
## Is it enabled by default?
|
||||
|
||||
No. TTS is **disabled** by default. Enable it in config or with `/tts on`,
|
||||
which writes a local preference override.
|
||||
No. Auto‑TTS is **off** by default. Enable it in config with
|
||||
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
|
||||
|
||||
Edge TTS **is** enabled by default once TTS is on, and is used automatically
|
||||
when no OpenAI or ElevenLabs API keys are available.
|
||||
@@ -70,7 +70,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
auto: "always",
|
||||
provider: "elevenlabs"
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
auto: "always",
|
||||
provider: "openai",
|
||||
summaryModel: "openai/gpt-4.1-mini",
|
||||
modelOverrides: {
|
||||
@@ -121,7 +121,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
auto: "always",
|
||||
provider: "edge",
|
||||
edge: {
|
||||
enabled: true,
|
||||
@@ -156,7 +156,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
auto: "always",
|
||||
maxTextLength: 4000,
|
||||
timeoutMs: 30000,
|
||||
prefsPath: "~/.clawdbot/settings/tts.json"
|
||||
@@ -165,13 +165,25 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
}
|
||||
```
|
||||
|
||||
### Only reply with audio after an inbound voice note
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "inbound"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Disable auto-summary for long replies
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true
|
||||
auto: "always"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,7 +197,10 @@ Then run:
|
||||
|
||||
### Notes on fields
|
||||
|
||||
- `enabled`: master toggle (default `false`; local prefs can override).
|
||||
- `auto`: auto‑TTS mode (`off`, `always`, `inbound`, `tagged`).
|
||||
- `inbound` only sends audio after an inbound voice note.
|
||||
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
|
||||
- `enabled`: legacy toggle (doctor migrates this to `auto`).
|
||||
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
|
||||
- `provider`: `"elevenlabs"`, `"openai"`, or `"edge"` (fallback is automatic).
|
||||
- If `provider` is **unset**, Clawdbot prefers `openai` (if key), then `elevenlabs` (if key),
|
||||
@@ -195,7 +210,7 @@ Then run:
|
||||
- `modelOverrides`: allow the model to emit TTS directives (on by default).
|
||||
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
|
||||
- `timeoutMs`: request timeout (ms).
|
||||
- `prefsPath`: override the local prefs JSON path.
|
||||
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
|
||||
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
|
||||
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
|
||||
- `elevenlabs.voiceSettings`:
|
||||
@@ -218,6 +233,7 @@ Then run:
|
||||
## Model-driven overrides (default on)
|
||||
|
||||
By default, the model **can** emit TTS directives for a single reply.
|
||||
When `messages.tts.auto` is `tagged`, these directives are required to trigger audio.
|
||||
|
||||
When enabled, the model can emit `[[tts:...]]` directives to override the voice
|
||||
for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
|
||||
@@ -338,8 +354,10 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
|
||||
`/voice` as the native command there. Text `/tts ...` still works.
|
||||
|
||||
```
|
||||
/tts on
|
||||
/tts off
|
||||
/tts always
|
||||
/tts inbound
|
||||
/tts tagged
|
||||
/tts status
|
||||
/tts provider openai
|
||||
/tts limit 2000
|
||||
@@ -350,6 +368,7 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
|
||||
Notes:
|
||||
- Commands require an authorized sender (allowlist/owner rules still apply).
|
||||
- `commands.text` or native command registration must be enabled.
|
||||
- `off|always|inbound|tagged` are per‑session toggles (`/tts on` is an alias for `/tts always`).
|
||||
- `limit` and `summary` are stored in local prefs, not the main config.
|
||||
- `/tts audio` generates a one-off audio reply (does not toggle TTS on).
|
||||
|
||||
|
||||
@@ -118,6 +118,14 @@ Other Gateway slash commands (for example, `/context`) are forwarded to the Gate
|
||||
- `--deliver`: Deliver assistant replies to the provider (default off)
|
||||
- `--thinking <level>`: Override thinking level for sends
|
||||
- `--timeout-ms <ms>`: Agent timeout in ms (defaults to `agents.defaults.timeoutSeconds`)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
No output after sending a message:
|
||||
- Run `/status` in the TUI to confirm the Gateway is connected and idle/busy.
|
||||
- Check the Gateway logs: `clawdbot logs --follow`.
|
||||
- Confirm the agent can run: `clawdbot status` and `clawdbot models status`.
|
||||
- If you expect messages in a chat channel, enable delivery (`/deliver on` or `--deliver`).
|
||||
- `--history-limit <n>`: History entries to load (default 200)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -23,11 +23,14 @@ const serialRuns = runs.filter((entry) => entry.name === "gateway");
|
||||
|
||||
const children = new Set();
|
||||
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
|
||||
const isMacOS = process.platform === "darwin" || process.env.RUNNER_OS === "macOS";
|
||||
const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10);
|
||||
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
||||
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
||||
const maxWorkers = isCI ? null : resolvedOverride ?? perRunWorkers;
|
||||
// Keep worker counts predictable for local runs and for CI on macOS.
|
||||
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
|
||||
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : perRunWorkers);
|
||||
|
||||
const WARNING_SUPPRESSION_FLAGS = [
|
||||
"--disable-warning=ExperimentalWarning",
|
||||
|
||||
+20
@@ -121,6 +121,26 @@ describe("getDmHistoryLimitFromSessionKey", () => {
|
||||
} as ClawdbotConfig;
|
||||
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123", config)).toBe(10);
|
||||
});
|
||||
it("strips thread suffix from dm session keys", () => {
|
||||
const config = {
|
||||
channels: { telegram: { dmHistoryLimit: 10, dms: { "123": { historyLimit: 7 } } } },
|
||||
} as ClawdbotConfig;
|
||||
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123:thread:999", config)).toBe(
|
||||
7,
|
||||
);
|
||||
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123:topic:555", config)).toBe(7);
|
||||
expect(getDmHistoryLimitFromSessionKey("telegram:dm:123:thread:999", config)).toBe(7);
|
||||
});
|
||||
it("keeps non-numeric thread markers in dm ids", () => {
|
||||
const config = {
|
||||
channels: {
|
||||
telegram: { dms: { "user:thread:abc": { historyLimit: 9 } } },
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:user:thread:abc", config)).toBe(
|
||||
9,
|
||||
);
|
||||
});
|
||||
it("returns undefined for non-dm session kinds", () => {
|
||||
const config = {
|
||||
channels: {
|
||||
|
||||
@@ -70,7 +70,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
|
||||
},
|
||||
streamSimple: (model: { api: string; provider: string; id: string }) => {
|
||||
const stream = new actual.AssistantMessageEventStream();
|
||||
setTimeout(() => {
|
||||
queueMicrotask(() => {
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
@@ -80,7 +80,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
|
||||
: buildAssistantMessage(model),
|
||||
});
|
||||
stream.end();
|
||||
}, 0);
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
};
|
||||
@@ -213,7 +213,7 @@ describe("runEmbeddedPiAgent", () => {
|
||||
|
||||
itIfNotWin32(
|
||||
"persists the first user message before assistant output",
|
||||
{ timeout: 60_000 },
|
||||
{ timeout: 120_000 },
|
||||
async () => {
|
||||
const sessionFile = nextSessionFile();
|
||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||
|
||||
@@ -2,6 +2,13 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
|
||||
const THREAD_SUFFIX_REGEX = /^(.*)(?::(?:thread|topic):\d+)$/i;
|
||||
|
||||
function stripThreadSuffix(value: string): string {
|
||||
const match = value.match(THREAD_SUFFIX_REGEX);
|
||||
return match?.[1] ?? value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limits conversation history to the last N user turns (and their associated
|
||||
* assistant responses). This reduces token usage for long-running DM sessions.
|
||||
@@ -44,7 +51,8 @@ export function getDmHistoryLimitFromSessionKey(
|
||||
if (!provider) return undefined;
|
||||
|
||||
const kind = providerParts[1]?.toLowerCase();
|
||||
const userId = providerParts.slice(2).join(":");
|
||||
const userIdRaw = providerParts.slice(2).join(":");
|
||||
const userId = stripThreadSuffix(userIdRaw);
|
||||
if (kind !== "dm") return undefined;
|
||||
|
||||
const getLimit = (
|
||||
|
||||
@@ -252,9 +252,7 @@ function isValidIsoDate(value: string): boolean {
|
||||
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
return (
|
||||
date.getUTCFullYear() === year &&
|
||||
date.getUTCMonth() === month - 1 &&
|
||||
date.getUTCDate() === day
|
||||
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,19 +6,20 @@ 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;
|
||||
@@ -39,9 +40,9 @@ function ttsUsage(): ReplyPayload {
|
||||
// Keep usage in one place so help/validation stays consistent.
|
||||
return {
|
||||
text:
|
||||
"⚙️ Usage: /tts <on|off|status|provider|limit|summary|audio> [value]" +
|
||||
"⚙️ Usage: /tts <off|always|inbound|tagged|status|provider|limit|summary|audio> [value]" +
|
||||
"\nExamples:\n" +
|
||||
"/tts on\n" +
|
||||
"/tts always\n" +
|
||||
"/tts provider openai\n" +
|
||||
"/tts provider edge\n" +
|
||||
"/tts limit 2000\n" +
|
||||
@@ -71,14 +72,30 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
return { shouldContinue: false, reply: ttsUsage() };
|
||||
}
|
||||
|
||||
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." } };
|
||||
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 === "audio") {
|
||||
@@ -212,7 +229,9 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
}
|
||||
|
||||
if (action === "status") {
|
||||
const enabled = isTtsEnabled(config, prefsPath);
|
||||
const sessionAuto = params.sessionEntry?.ttsAuto;
|
||||
const autoMode = resolveTtsAutoMode({ config, prefsPath, sessionAuto });
|
||||
const enabled = autoMode !== "off";
|
||||
const provider = getTtsProvider(config, prefsPath);
|
||||
const hasKey = isTtsProviderConfigured(config, provider);
|
||||
const providerStatus =
|
||||
@@ -226,9 +245,10 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
const maxLength = getTtsMaxLength(prefsPath);
|
||||
const summarize = isSummarizationEnabled(prefsPath);
|
||||
const last = getLastTtsAttempt();
|
||||
const autoLabel = sessionAuto ? `${autoMode} (session)` : autoMode;
|
||||
const lines = [
|
||||
"📊 TTS status",
|
||||
`State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
|
||||
`Auto: ${enabled ? autoLabel : "off"}`,
|
||||
`Provider: ${provider} (${providerStatus})`,
|
||||
`Text limit: ${maxLength} chars`,
|
||||
`Auto-summary: ${summarize ? "on" : "off"}`,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||
import {
|
||||
@@ -14,7 +16,55 @@ 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 } from "../../tts/tts.js";
|
||||
import { maybeApplyTtsToPayload, normalizeTtsAutoMode } from "../../tts/tts.js";
|
||||
|
||||
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
|
||||
const AUDIO_HEADER_RE = /^\[Audio\b/i;
|
||||
|
||||
const normalizeMediaType = (value: string): string => value.split(";")[0]?.trim().toLowerCase();
|
||||
|
||||
const isInboundAudioContext = (ctx: FinalizedMsgContext): boolean => {
|
||||
const rawTypes = [
|
||||
typeof ctx.MediaType === "string" ? ctx.MediaType : undefined,
|
||||
...(Array.isArray(ctx.MediaTypes) ? ctx.MediaTypes : []),
|
||||
].filter(Boolean) as string[];
|
||||
const types = rawTypes.map((type) => normalizeMediaType(type));
|
||||
if (types.some((type) => type === "audio" || type.startsWith("audio/"))) return true;
|
||||
|
||||
const body =
|
||||
typeof ctx.BodyForCommands === "string"
|
||||
? ctx.BodyForCommands
|
||||
: typeof ctx.CommandBody === "string"
|
||||
? ctx.CommandBody
|
||||
: typeof ctx.RawBody === "string"
|
||||
? ctx.RawBody
|
||||
: typeof ctx.Body === "string"
|
||||
? ctx.Body
|
||||
: "";
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return false;
|
||||
if (AUDIO_PLACEHOLDER_RE.test(trimmed)) return true;
|
||||
return AUDIO_HEADER_RE.test(trimmed);
|
||||
};
|
||||
|
||||
const resolveSessionTtsAuto = (
|
||||
ctx: FinalizedMsgContext,
|
||||
cfg: ClawdbotConfig,
|
||||
): string | undefined => {
|
||||
const targetSessionKey =
|
||||
ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
|
||||
const sessionKey = (targetSessionKey ?? ctx.SessionKey)?.trim();
|
||||
if (!sessionKey) return undefined;
|
||||
const agentId = resolveSessionAgentId({ sessionKey, config: cfg });
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
try {
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[sessionKey.toLowerCase()] ?? store[sessionKey];
|
||||
return normalizeTtsAutoMode(entry?.ttsAuto);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export type DispatchFromConfigResult = {
|
||||
queuedFinal: boolean;
|
||||
@@ -81,6 +131,8 @@ export async function dispatchReplyFromConfig(params: {
|
||||
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
|
||||
}
|
||||
|
||||
const inboundAudio = isInboundAudioContext(ctx);
|
||||
const sessionTtsAuto = resolveSessionTtsAuto(ctx, cfg);
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
if (hookRunner?.hasHooks("message_received")) {
|
||||
const timestamp =
|
||||
@@ -223,6 +275,8 @@ export async function dispatchReplyFromConfig(params: {
|
||||
cfg,
|
||||
channel: ttsChannel,
|
||||
kind: "tool",
|
||||
inboundAudio,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
});
|
||||
if (shouldRouteToOriginating) {
|
||||
await sendPayloadAsync(ttsPayload);
|
||||
@@ -239,6 +293,8 @@ export async function dispatchReplyFromConfig(params: {
|
||||
cfg,
|
||||
channel: ttsChannel,
|
||||
kind: "block",
|
||||
inboundAudio,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
});
|
||||
if (shouldRouteToOriginating) {
|
||||
await sendPayloadAsync(ttsPayload, context?.abortSignal);
|
||||
@@ -262,6 +318,8 @@ export async function dispatchReplyFromConfig(params: {
|
||||
cfg,
|
||||
channel: ttsChannel,
|
||||
kind: "final",
|
||||
inboundAudio,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
});
|
||||
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
||||
// Route final reply to originating channel.
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { TtsAutoMode } from "../../config/types.tts.js";
|
||||
import {
|
||||
DEFAULT_RESET_TRIGGERS,
|
||||
deriveSessionMetaPatch,
|
||||
@@ -128,6 +129,7 @@ export async function initSessionState(params: {
|
||||
let persistedThinking: string | undefined;
|
||||
let persistedVerbose: string | undefined;
|
||||
let persistedReasoning: string | undefined;
|
||||
let persistedTtsAuto: TtsAutoMode | undefined;
|
||||
let persistedModelOverride: string | undefined;
|
||||
let persistedProviderOverride: string | undefined;
|
||||
|
||||
@@ -220,6 +222,7 @@ export async function initSessionState(params: {
|
||||
persistedThinking = entry.thinkingLevel;
|
||||
persistedVerbose = entry.verboseLevel;
|
||||
persistedReasoning = entry.reasoningLevel;
|
||||
persistedTtsAuto = entry.ttsAuto;
|
||||
persistedModelOverride = entry.modelOverride;
|
||||
persistedProviderOverride = entry.providerOverride;
|
||||
} else {
|
||||
@@ -258,6 +261,7 @@ export async function initSessionState(params: {
|
||||
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
|
||||
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
|
||||
reasoningLevel: persistedReasoning ?? baseEntry?.reasoningLevel,
|
||||
ttsAuto: persistedTtsAuto ?? baseEntry?.ttsAuto,
|
||||
responseUsage: baseEntry?.responseUsage,
|
||||
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
||||
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
getTtsMaxLength,
|
||||
getTtsProvider,
|
||||
isSummarizationEnabled,
|
||||
isTtsEnabled,
|
||||
resolveTtsAutoMode,
|
||||
resolveTtsConfig,
|
||||
resolveTtsPrefsPath,
|
||||
} from "../tts/tts.js";
|
||||
@@ -252,15 +252,23 @@ const formatMediaUnderstandingLine = (decisions?: MediaUnderstandingDecision[])
|
||||
return `📎 Media: ${parts.join(" · ")}`;
|
||||
};
|
||||
|
||||
const formatVoiceModeLine = (config?: ClawdbotConfig): string | null => {
|
||||
const formatVoiceModeLine = (
|
||||
config?: ClawdbotConfig,
|
||||
sessionEntry?: SessionEntry,
|
||||
): string | null => {
|
||||
if (!config) return null;
|
||||
const ttsConfig = resolveTtsConfig(config);
|
||||
const prefsPath = resolveTtsPrefsPath(ttsConfig);
|
||||
if (!isTtsEnabled(ttsConfig, prefsPath)) return null;
|
||||
const autoMode = resolveTtsAutoMode({
|
||||
config: ttsConfig,
|
||||
prefsPath,
|
||||
sessionAuto: sessionEntry?.ttsAuto,
|
||||
});
|
||||
if (autoMode === "off") return null;
|
||||
const provider = getTtsProvider(ttsConfig, prefsPath);
|
||||
const maxLength = getTtsMaxLength(prefsPath);
|
||||
const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
|
||||
return `🔊 Voice: on · provider=${provider} · limit=${maxLength} · summary=${summarize}`;
|
||||
return `🔊 Voice: ${autoMode} · provider=${provider} · limit=${maxLength} · summary=${summarize}`;
|
||||
};
|
||||
|
||||
export function buildStatusMessage(args: StatusArgs): string {
|
||||
@@ -398,7 +406,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
const usageCostLine =
|
||||
usagePair && costLine ? `${usagePair} · ${costLine}` : (usagePair ?? costLine);
|
||||
const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions);
|
||||
const voiceLine = formatVoiceModeLine(args.config);
|
||||
const voiceLine = formatVoiceModeLine(args.config, args.sessionEntry);
|
||||
|
||||
return [
|
||||
versionLine,
|
||||
|
||||
@@ -138,6 +138,16 @@ describe("legacy config detection", () => {
|
||||
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(res.config?.channels?.telegram?.requireMention).toBeUndefined();
|
||||
});
|
||||
it("migrates messages.tts.enabled to messages.tts.auto", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
messages: { tts: { enabled: true } },
|
||||
});
|
||||
expect(res.changes).toContain("Moved messages.tts.enabled → messages.tts.auto (always).");
|
||||
expect(res.config?.messages?.tts?.auto).toBe("always");
|
||||
expect(res.config?.messages?.tts?.enabled).toBeUndefined();
|
||||
});
|
||||
it("migrates legacy model config to agent.models + model lists", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
|
||||
@@ -40,6 +40,26 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
||||
delete tools.bash;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "messages.tts.enabled->auto",
|
||||
describe: "Move messages.tts.enabled to messages.tts.auto",
|
||||
apply: (raw, changes) => {
|
||||
const messages = getRecord(raw.messages);
|
||||
const tts = getRecord(messages?.tts);
|
||||
if (!tts) return;
|
||||
if (tts.auto !== undefined) {
|
||||
if ("enabled" in tts) {
|
||||
delete tts.enabled;
|
||||
changes.push("Removed messages.tts.enabled (messages.tts.auto already set).");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof tts.enabled !== "boolean") return;
|
||||
tts.auto = tts.enabled ? "always" : "off";
|
||||
delete tts.enabled;
|
||||
changes.push(`Moved messages.tts.enabled → messages.tts.auto (${String(tts.auto)}).`);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent.defaults-v2",
|
||||
describe: "Move agent config to agents.defaults and tools",
|
||||
|
||||
@@ -120,6 +120,10 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
||||
message:
|
||||
"agent.imageModelFallbacks was replaced by agents.defaults.imageModel.fallbacks (auto-migrated on load).",
|
||||
},
|
||||
{
|
||||
path: ["messages", "tts", "enabled"],
|
||||
message: "messages.tts.enabled was replaced by messages.tts.auto (auto-migrated on load).",
|
||||
},
|
||||
{
|
||||
path: ["gateway", "token"],
|
||||
message: "gateway.token is ignored; use gateway.auth.token instead (auto-migrated on load).",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
|
||||
import type { NormalizedChatType } from "../../channels/chat-type.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import type { DeliveryContext } from "../../utils/delivery-context.js";
|
||||
import type { TtsAutoMode } from "../types.tts.js";
|
||||
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
|
||||
@@ -42,6 +43,7 @@ export type SessionEntry = {
|
||||
verboseLevel?: string;
|
||||
reasoningLevel?: string;
|
||||
elevatedLevel?: string;
|
||||
ttsAuto?: TtsAutoMode;
|
||||
execHost?: string;
|
||||
execSecurity?: string;
|
||||
execAsk?: string;
|
||||
|
||||
@@ -2,6 +2,8 @@ export type TtsProvider = "elevenlabs" | "openai" | "edge";
|
||||
|
||||
export type TtsMode = "final" | "all";
|
||||
|
||||
export type TtsAutoMode = "off" | "always" | "inbound" | "tagged";
|
||||
|
||||
export type TtsModelOverrideConfig = {
|
||||
/** Enable model-provided overrides for TTS. */
|
||||
enabled?: boolean;
|
||||
@@ -22,7 +24,9 @@ export type TtsModelOverrideConfig = {
|
||||
};
|
||||
|
||||
export type TtsConfig = {
|
||||
/** Enable auto-TTS (can be overridden by local prefs). */
|
||||
/** Auto-TTS mode (preferred). */
|
||||
auto?: TtsAutoMode;
|
||||
/** Legacy: enable auto-TTS when `auto` is not set. */
|
||||
enabled?: boolean;
|
||||
/** Apply TTS to final replies only or to all replies (tool/block/final). */
|
||||
mode?: TtsMode;
|
||||
|
||||
@@ -158,8 +158,10 @@ export const MarkdownConfigSchema = z
|
||||
|
||||
export const TtsProviderSchema = z.enum(["elevenlabs", "openai", "edge"]);
|
||||
export const TtsModeSchema = z.enum(["final", "all"]);
|
||||
export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]);
|
||||
export const TtsConfigSchema = z
|
||||
.object({
|
||||
auto: TtsAutoSchema.optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
mode: TtsModeSchema.optional(),
|
||||
provider: TtsProviderSchema.optional(),
|
||||
|
||||
@@ -136,9 +136,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
const forumParentSlug =
|
||||
isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : "";
|
||||
const threadChannelId = threadChannel?.id;
|
||||
const isForumStarter = Boolean(
|
||||
threadChannelId && isForumParent && forumParentSlug && message.id === threadChannelId,
|
||||
);
|
||||
const isForumStarter =
|
||||
Boolean(threadChannelId && isForumParent && forumParentSlug) && message.id === threadChannelId;
|
||||
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
|
||||
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
|
||||
const groupSubject = isDirectMessage ? undefined : groupChannel;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getTtsProvider,
|
||||
isTtsEnabled,
|
||||
isTtsProviderConfigured,
|
||||
resolveTtsAutoMode,
|
||||
resolveTtsApiKey,
|
||||
resolveTtsConfig,
|
||||
resolveTtsPrefsPath,
|
||||
@@ -24,11 +25,13 @@ export const ttsHandlers: GatewayRequestHandlers = {
|
||||
const config = resolveTtsConfig(cfg);
|
||||
const prefsPath = resolveTtsPrefsPath(config);
|
||||
const provider = getTtsProvider(config, prefsPath);
|
||||
const autoMode = resolveTtsAutoMode({ config, prefsPath });
|
||||
const fallbackProviders = resolveTtsProviderOrder(provider)
|
||||
.slice(1)
|
||||
.filter((candidate) => isTtsProviderConfigured(config, candidate));
|
||||
respond(true, {
|
||||
enabled: isTtsEnabled(config, prefsPath),
|
||||
auto: autoMode,
|
||||
provider,
|
||||
fallbackProvider: fallbackProviders[0] ?? null,
|
||||
fallbackProviders,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { buildTelegramMessageContext } from "./bot-message-context.js";
|
||||
|
||||
describe("buildTelegramMessageContext dm thread sessions", () => {
|
||||
const baseConfig = {
|
||||
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } },
|
||||
channels: { telegram: {} },
|
||||
messages: { groupChat: { mentionPatterns: [] } },
|
||||
} as never;
|
||||
|
||||
const buildContext = async (message: Record<string, unknown>) =>
|
||||
await buildTelegramMessageContext({
|
||||
primaryCtx: {
|
||||
message,
|
||||
me: { id: 7, username: "bot" },
|
||||
} as never,
|
||||
allMedia: [],
|
||||
storeAllowFrom: [],
|
||||
options: {},
|
||||
bot: {
|
||||
api: {
|
||||
sendChatAction: vi.fn(),
|
||||
setMessageReaction: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
cfg: baseConfig,
|
||||
account: { accountId: "default" } as never,
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
dmPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
ackReactionScope: "off",
|
||||
logger: { info: vi.fn() },
|
||||
resolveGroupActivation: () => undefined,
|
||||
resolveGroupRequireMention: () => false,
|
||||
resolveTelegramGroupConfig: () => ({
|
||||
groupConfig: { requireMention: false },
|
||||
topicConfig: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
it("uses thread session key for dm topics", async () => {
|
||||
const ctx = await buildContext({
|
||||
message_id: 1,
|
||||
chat: { id: 1234, type: "private" },
|
||||
date: 1700000000,
|
||||
text: "hello",
|
||||
message_thread_id: 42,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.MessageThreadId).toBe(42);
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:42");
|
||||
});
|
||||
|
||||
it("keeps legacy dm session key when no thread id", async () => {
|
||||
const ctx = await buildContext({
|
||||
message_id: 2,
|
||||
chat: { id: 1234, type: "private" },
|
||||
date: 1700000001,
|
||||
text: "hello",
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined();
|
||||
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main");
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../conf
|
||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
||||
import { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reactions.js";
|
||||
import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
|
||||
import { resolveControlCommandGate } from "../channels/command-gating.js";
|
||||
@@ -136,6 +137,13 @@ export const buildTelegramMessageContext = async ({
|
||||
id: peerId,
|
||||
},
|
||||
});
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const dmThreadId = !isGroup ? resolvedThreadId : undefined;
|
||||
const threadKeys =
|
||||
dmThreadId != null
|
||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
|
||||
: null;
|
||||
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
||||
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
|
||||
const effectiveDmAllow = normalizeAllowFromWithStore({ allowFrom, storeAllowFrom });
|
||||
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||
@@ -325,7 +333,7 @@ export const buildTelegramMessageContext = async ({
|
||||
const activationOverride = resolveGroupActivation({
|
||||
chatId,
|
||||
messageThreadId: resolvedThreadId,
|
||||
sessionKey: route.sessionKey,
|
||||
sessionKey: sessionKey,
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const baseRequireMention = resolveGroupRequireMention(chatId);
|
||||
@@ -432,7 +440,7 @@ export const buildTelegramMessageContext = async ({
|
||||
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||
const previousTimestamp = readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
sessionKey: sessionKey,
|
||||
});
|
||||
const body = formatInboundEnvelope({
|
||||
channel: "Telegram",
|
||||
@@ -482,7 +490,7 @@ export const buildTelegramMessageContext = async ({
|
||||
CommandBody: commandBody,
|
||||
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
|
||||
To: `telegram:${chatId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
SessionKey: sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: conversationLabel,
|
||||
@@ -526,7 +534,7 @@ export const buildTelegramMessageContext = async ({
|
||||
|
||||
await recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
sessionKey: ctxPayload.SessionKey ?? sessionKey,
|
||||
ctx: ctxPayload,
|
||||
updateLastRoute: !isGroup
|
||||
? {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
||||
import { danger, logVerbose } from "../globals.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
|
||||
import type { ChannelGroupPolicy } from "../config/group-policy.js";
|
||||
import type {
|
||||
@@ -271,6 +272,13 @@ export const registerTelegramNativeCommands = ({
|
||||
id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId),
|
||||
},
|
||||
});
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const dmThreadId = !isGroup ? resolvedThreadId : undefined;
|
||||
const threadKeys =
|
||||
dmThreadId != null
|
||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
|
||||
: null;
|
||||
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
@@ -309,7 +317,7 @@ export const registerTelegramNativeCommands = ({
|
||||
CommandAuthorized: commandAuthorized,
|
||||
CommandSource: "native" as const,
|
||||
SessionKey: `telegram:slash:${senderId || chatId}`,
|
||||
CommandTargetSessionKey: route.sessionKey,
|
||||
CommandTargetSessionKey: sessionKey,
|
||||
MessageThreadId: resolvedThreadId,
|
||||
IsForum: isForum,
|
||||
// Originating context for sub-agent announce routing
|
||||
|
||||
@@ -2163,6 +2163,46 @@ describe("createTelegramBot", () => {
|
||||
expect.objectContaining({ message_thread_id: 99 }),
|
||||
);
|
||||
});
|
||||
it("sets command target session key for dm topic commands", async () => {
|
||||
onSpy.mockReset();
|
||||
sendMessageSpy.mockReset();
|
||||
commandSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
replySpy.mockResolvedValue({ text: "response" });
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
commands: { native: true },
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "pairing",
|
||||
},
|
||||
},
|
||||
});
|
||||
readTelegramAllowFromStore.mockResolvedValueOnce(["12345"]);
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as
|
||||
| ((ctx: Record<string, unknown>) => Promise<void>)
|
||||
| undefined;
|
||||
if (!handler) throw new Error("status command handler missing");
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 12345, type: "private" },
|
||||
from: { id: 12345, username: "testuser" },
|
||||
text: "/status",
|
||||
date: 1736380800,
|
||||
message_id: 42,
|
||||
message_thread_id: 99,
|
||||
},
|
||||
match: "",
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.CommandTargetSessionKey).toBe("agent:main:main:thread:99");
|
||||
});
|
||||
|
||||
it("allows native DM commands for paired users", async () => {
|
||||
onSpy.mockReset();
|
||||
@@ -2789,4 +2829,41 @@ describe("createTelegramBot", () => {
|
||||
const sessionKey = enqueueSystemEvent.mock.calls[0][1].sessionKey;
|
||||
expect(sessionKey).not.toContain(":topic:");
|
||||
});
|
||||
it("uses thread session key for dm reactions with topic id", async () => {
|
||||
onSpy.mockReset();
|
||||
enqueueSystemEvent.mockReset();
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: { dmPolicy: "open", reactionNotifications: "all" },
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message_reaction") as (
|
||||
ctx: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
update: { update_id: 508 },
|
||||
messageReaction: {
|
||||
chat: { id: 1234, type: "private" },
|
||||
message_id: 300,
|
||||
message_thread_id: 42,
|
||||
user: { id: 12, first_name: "Dana" },
|
||||
date: 1736380800,
|
||||
old_reaction: [],
|
||||
new_reaction: [{ type: "emoji", emoji: "🔥" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledTimes(1);
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
"Telegram reaction added: 🔥 by Dana on msg 300",
|
||||
expect.objectContaining({
|
||||
sessionKey: expect.stringContaining(":thread:42"),
|
||||
contextKey: expect.stringContaining("telegram:reaction:add:1234:300:12"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+45
-1
@@ -20,9 +20,11 @@ import {
|
||||
} from "../config/group-policy.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
import {
|
||||
@@ -161,7 +163,42 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
return skipped;
|
||||
};
|
||||
|
||||
const rawUpdateLogger = createSubsystemLogger("gateway/channels/telegram/raw-update");
|
||||
const MAX_RAW_UPDATE_CHARS = 8000;
|
||||
const MAX_RAW_UPDATE_STRING = 500;
|
||||
const MAX_RAW_UPDATE_ARRAY = 20;
|
||||
const stringifyUpdate = (update: unknown) => {
|
||||
const seen = new WeakSet<object>();
|
||||
return JSON.stringify(update ?? null, (key, value) => {
|
||||
if (typeof value === "string" && value.length > MAX_RAW_UPDATE_STRING) {
|
||||
return `${value.slice(0, MAX_RAW_UPDATE_STRING)}...`;
|
||||
}
|
||||
if (Array.isArray(value) && value.length > MAX_RAW_UPDATE_ARRAY) {
|
||||
return [
|
||||
...value.slice(0, MAX_RAW_UPDATE_ARRAY),
|
||||
`...(${value.length - MAX_RAW_UPDATE_ARRAY} more)`,
|
||||
];
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const obj = value as object;
|
||||
if (seen.has(obj)) return "[Circular]";
|
||||
seen.add(obj);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
};
|
||||
|
||||
bot.use(async (ctx, next) => {
|
||||
if (shouldLogVerbose()) {
|
||||
try {
|
||||
const raw = stringifyUpdate(ctx.update);
|
||||
const preview =
|
||||
raw.length > MAX_RAW_UPDATE_CHARS ? `${raw.slice(0, MAX_RAW_UPDATE_CHARS)}...` : raw;
|
||||
rawUpdateLogger.debug(`telegram update: ${preview}`);
|
||||
} catch (err) {
|
||||
rawUpdateLogger.debug(`telegram update log failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
recordUpdateId(ctx);
|
||||
});
|
||||
@@ -372,13 +409,20 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
accountId: account.accountId,
|
||||
peer: { kind: isGroup ? "group" : "dm", id: peerId },
|
||||
});
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const dmThreadId = !isGroup ? resolvedThreadId : undefined;
|
||||
const threadKeys =
|
||||
dmThreadId != null
|
||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
|
||||
: null;
|
||||
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
||||
|
||||
// Enqueue system event for each added reaction
|
||||
for (const r of addedReactions) {
|
||||
const emoji = r.emoji;
|
||||
const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`;
|
||||
enqueueSystemEvent(text, {
|
||||
sessionKey: route.sessionKey,
|
||||
sessionKey: sessionKey,
|
||||
contextKey: `telegram:reaction:add:${chatId}:${messageId}:${user?.id ?? "anon"}:${emoji}`,
|
||||
});
|
||||
logVerbose(`telegram: reaction event enqueued: ${text}`);
|
||||
|
||||
+128
-1
@@ -4,7 +4,7 @@ import { completeSimple } from "@mariozechner/pi-ai";
|
||||
|
||||
import { getApiKeyForModel } from "../agents/model-auth.js";
|
||||
import { resolveModel } from "../agents/pi-embedded-runner/model.js";
|
||||
import { _test, getTtsProvider, resolveTtsConfig } from "./tts.js";
|
||||
import * as tts from "./tts.js";
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", () => ({
|
||||
completeSimple: vi.fn(),
|
||||
@@ -37,6 +37,8 @@ vi.mock("../agents/model-auth.js", () => ({
|
||||
requireApiKey: vi.fn((auth: { apiKey?: string }) => auth.apiKey ?? ""),
|
||||
}));
|
||||
|
||||
const { _test, resolveTtsConfig, maybeApplyTtsToPayload, getTtsProvider } = tts;
|
||||
|
||||
const {
|
||||
isValidVoiceId,
|
||||
isValidOpenAIVoice,
|
||||
@@ -431,4 +433,129 @@ describe("tts", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeApplyTtsToPayload", () => {
|
||||
const baseCfg = {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } },
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "inbound",
|
||||
provider: "openai",
|
||||
openai: { apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("skips auto-TTS when inbound audio gating is on and the message is not audio", async () => {
|
||||
const prevPrefs = process.env.CLAWDBOT_TTS_PREFS;
|
||||
process.env.CLAWDBOT_TTS_PREFS = `/tmp/tts-test-${Date.now()}.json`;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(1),
|
||||
}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const payload = { text: "Hello world" };
|
||||
const result = await maybeApplyTtsToPayload({
|
||||
payload,
|
||||
cfg: baseCfg,
|
||||
kind: "final",
|
||||
inboundAudio: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(payload);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
process.env.CLAWDBOT_TTS_PREFS = prevPrefs;
|
||||
});
|
||||
|
||||
it("attempts auto-TTS when inbound audio gating is on and the message is audio", async () => {
|
||||
const prevPrefs = process.env.CLAWDBOT_TTS_PREFS;
|
||||
process.env.CLAWDBOT_TTS_PREFS = `/tmp/tts-test-${Date.now()}.json`;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(1),
|
||||
}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const result = await maybeApplyTtsToPayload({
|
||||
payload: { text: "Hello world" },
|
||||
cfg: baseCfg,
|
||||
kind: "final",
|
||||
inboundAudio: true,
|
||||
});
|
||||
|
||||
expect(result.mediaUrl).toBeDefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
process.env.CLAWDBOT_TTS_PREFS = prevPrefs;
|
||||
});
|
||||
|
||||
it("skips auto-TTS in tagged mode unless a tts tag is present", async () => {
|
||||
const prevPrefs = process.env.CLAWDBOT_TTS_PREFS;
|
||||
process.env.CLAWDBOT_TTS_PREFS = `/tmp/tts-test-${Date.now()}.json`;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(1),
|
||||
}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
messages: {
|
||||
...baseCfg.messages,
|
||||
tts: { ...baseCfg.messages.tts, auto: "tagged" },
|
||||
},
|
||||
};
|
||||
|
||||
const payload = { text: "Hello world" };
|
||||
const result = await maybeApplyTtsToPayload({
|
||||
payload,
|
||||
cfg,
|
||||
kind: "final",
|
||||
});
|
||||
|
||||
expect(result).toBe(payload);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
process.env.CLAWDBOT_TTS_PREFS = prevPrefs;
|
||||
});
|
||||
|
||||
it("runs auto-TTS in tagged mode when tags are present", async () => {
|
||||
const prevPrefs = process.env.CLAWDBOT_TTS_PREFS;
|
||||
process.env.CLAWDBOT_TTS_PREFS = `/tmp/tts-test-${Date.now()}.json`;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(1),
|
||||
}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
messages: {
|
||||
...baseCfg.messages,
|
||||
tts: { ...baseCfg.messages.tts, auto: "tagged" },
|
||||
},
|
||||
};
|
||||
|
||||
const result = await maybeApplyTtsToPayload({
|
||||
payload: { text: "[[tts:text]]Hello world[[/tts:text]]" },
|
||||
cfg,
|
||||
kind: "final",
|
||||
});
|
||||
|
||||
expect(result.mediaUrl).toBeDefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
process.env.CLAWDBOT_TTS_PREFS = prevPrefs;
|
||||
});
|
||||
});
|
||||
});
|
||||
+87
-18
@@ -20,6 +20,7 @@ import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type {
|
||||
TtsConfig,
|
||||
TtsAutoMode,
|
||||
TtsMode,
|
||||
TtsProvider,
|
||||
TtsModelOverrideConfig,
|
||||
@@ -75,8 +76,10 @@ const DEFAULT_OUTPUT = {
|
||||
voiceCompatible: false,
|
||||
};
|
||||
|
||||
const TTS_AUTO_MODES = new Set<TtsAutoMode>(["off", "always", "inbound", "tagged"]);
|
||||
|
||||
export type ResolvedTtsConfig = {
|
||||
enabled: boolean;
|
||||
auto: TtsAutoMode;
|
||||
mode: TtsMode;
|
||||
provider: TtsProvider;
|
||||
providerSource: "config" | "default";
|
||||
@@ -123,6 +126,7 @@ export type ResolvedTtsConfig = {
|
||||
|
||||
type TtsUserPrefs = {
|
||||
tts?: {
|
||||
auto?: TtsAutoMode;
|
||||
enabled?: boolean;
|
||||
provider?: TtsProvider;
|
||||
maxLength?: number;
|
||||
@@ -161,6 +165,7 @@ type TtsDirectiveOverrides = {
|
||||
type TtsDirectiveParseResult = {
|
||||
cleanedText: string;
|
||||
ttsText?: string;
|
||||
hasDirective: boolean;
|
||||
overrides: TtsDirectiveOverrides;
|
||||
warnings: string[];
|
||||
};
|
||||
@@ -187,6 +192,15 @@ type TtsStatusEntry = {
|
||||
|
||||
let lastTtsAttempt: TtsStatusEntry | undefined;
|
||||
|
||||
export function normalizeTtsAutoMode(value: unknown): TtsAutoMode | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (TTS_AUTO_MODES.has(normalized as TtsAutoMode)) {
|
||||
return normalized as TtsAutoMode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveModelOverridePolicy(
|
||||
overrides: TtsModelOverrideConfig | undefined,
|
||||
): ResolvedTtsModelOverrides {
|
||||
@@ -220,8 +234,9 @@ export function resolveTtsConfig(cfg: ClawdbotConfig): ResolvedTtsConfig {
|
||||
const raw: TtsConfig = cfg.messages?.tts ?? {};
|
||||
const providerSource = raw.provider ? "config" : "default";
|
||||
const edgeOutputFormat = raw.edge?.outputFormat?.trim();
|
||||
const auto = normalizeTtsAutoMode(raw.auto) ?? (raw.enabled ? "always" : "off");
|
||||
return {
|
||||
enabled: raw.enabled ?? false,
|
||||
auto,
|
||||
mode: raw.mode ?? "final",
|
||||
provider: raw.provider ?? "edge",
|
||||
providerSource,
|
||||
@@ -279,17 +294,48 @@ export function resolveTtsPrefsPath(config: ResolvedTtsConfig): string {
|
||||
return path.join(CONFIG_DIR, "settings", "tts.json");
|
||||
}
|
||||
|
||||
function resolveTtsAutoModeFromPrefs(prefs: TtsUserPrefs): TtsAutoMode | undefined {
|
||||
const auto = normalizeTtsAutoMode(prefs.tts?.auto);
|
||||
if (auto) return auto;
|
||||
if (typeof prefs.tts?.enabled === "boolean") {
|
||||
return prefs.tts.enabled ? "always" : "off";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveTtsAutoMode(params: {
|
||||
config: ResolvedTtsConfig;
|
||||
prefsPath: string;
|
||||
sessionAuto?: string;
|
||||
}): TtsAutoMode {
|
||||
const sessionAuto = normalizeTtsAutoMode(params.sessionAuto);
|
||||
if (sessionAuto) return sessionAuto;
|
||||
const prefsAuto = resolveTtsAutoModeFromPrefs(readPrefs(params.prefsPath));
|
||||
if (prefsAuto) return prefsAuto;
|
||||
return params.config.auto;
|
||||
}
|
||||
|
||||
export function buildTtsSystemPromptHint(cfg: ClawdbotConfig): string | undefined {
|
||||
const config = resolveTtsConfig(cfg);
|
||||
const prefsPath = resolveTtsPrefsPath(config);
|
||||
if (!isTtsEnabled(config, prefsPath)) return undefined;
|
||||
const autoMode = resolveTtsAutoMode({ config, prefsPath });
|
||||
if (autoMode === "off") return undefined;
|
||||
const maxLength = getTtsMaxLength(prefsPath);
|
||||
const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
|
||||
const autoHint =
|
||||
autoMode === "inbound"
|
||||
? "Only use TTS when the user's last message includes audio/voice."
|
||||
: autoMode === "tagged"
|
||||
? "Only use TTS when you include [[tts]] or [[tts:text]] tags."
|
||||
: undefined;
|
||||
return [
|
||||
"Voice (TTS) is enabled.",
|
||||
autoHint,
|
||||
`Keep spoken text ≤${maxLength} chars to avoid auto-summary (summary ${summarize}).`,
|
||||
"Use [[tts:...]] and optional [[tts:text]]...[[/tts:text]] to control voice/expressiveness.",
|
||||
].join("\n");
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function readPrefs(prefsPath: string): TtsUserPrefs {
|
||||
@@ -323,16 +369,25 @@ function updatePrefs(prefsPath: string, update: (prefs: TtsUserPrefs) => void):
|
||||
atomicWriteFileSync(prefsPath, JSON.stringify(prefs, null, 2));
|
||||
}
|
||||
|
||||
export function isTtsEnabled(config: ResolvedTtsConfig, prefsPath: string): boolean {
|
||||
const prefs = readPrefs(prefsPath);
|
||||
if (prefs.tts?.enabled !== undefined) return prefs.tts.enabled === true;
|
||||
return config.enabled;
|
||||
export function isTtsEnabled(
|
||||
config: ResolvedTtsConfig,
|
||||
prefsPath: string,
|
||||
sessionAuto?: string,
|
||||
): boolean {
|
||||
return resolveTtsAutoMode({ config, prefsPath, sessionAuto }) !== "off";
|
||||
}
|
||||
|
||||
export function setTtsAutoMode(prefsPath: string, mode: TtsAutoMode): void {
|
||||
updatePrefs(prefsPath, (prefs) => {
|
||||
const next = { ...prefs.tts };
|
||||
delete next.enabled;
|
||||
next.auto = mode;
|
||||
prefs.tts = next;
|
||||
});
|
||||
}
|
||||
|
||||
export function setTtsEnabled(prefsPath: string, enabled: boolean): void {
|
||||
updatePrefs(prefsPath, (prefs) => {
|
||||
prefs.tts = { ...prefs.tts, enabled };
|
||||
});
|
||||
setTtsAutoMode(prefsPath, enabled ? "always" : "off");
|
||||
}
|
||||
|
||||
export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): TtsProvider {
|
||||
@@ -485,15 +540,17 @@ function parseTtsDirectives(
|
||||
policy: ResolvedTtsModelOverrides,
|
||||
): TtsDirectiveParseResult {
|
||||
if (!policy.enabled) {
|
||||
return { cleanedText: text, overrides: {}, warnings: [] };
|
||||
return { cleanedText: text, overrides: {}, warnings: [], hasDirective: false };
|
||||
}
|
||||
|
||||
const overrides: TtsDirectiveOverrides = {};
|
||||
const warnings: string[] = [];
|
||||
let cleanedText = text;
|
||||
let hasDirective = false;
|
||||
|
||||
const blockRegex = /\[\[tts:text\]\]([\s\S]*?)\[\[\/tts:text\]\]/gi;
|
||||
cleanedText = cleanedText.replace(blockRegex, (_match, inner: string) => {
|
||||
hasDirective = true;
|
||||
if (policy.allowText && overrides.ttsText == null) {
|
||||
overrides.ttsText = inner.trim();
|
||||
}
|
||||
@@ -502,6 +559,7 @@ function parseTtsDirectives(
|
||||
|
||||
const directiveRegex = /\[\[tts:([^\]]+)\]\]/gi;
|
||||
cleanedText = cleanedText.replace(directiveRegex, (_match, body: string) => {
|
||||
hasDirective = true;
|
||||
const tokens = body.split(/\s+/).filter(Boolean);
|
||||
for (const token of tokens) {
|
||||
const eqIndex = token.indexOf("=");
|
||||
@@ -672,6 +730,7 @@ function parseTtsDirectives(
|
||||
return {
|
||||
cleanedText,
|
||||
ttsText: overrides.ttsText,
|
||||
hasDirective,
|
||||
overrides,
|
||||
warnings,
|
||||
};
|
||||
@@ -1156,13 +1215,17 @@ export async function maybeApplyTtsToPayload(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
channel?: string;
|
||||
kind?: "tool" | "block" | "final";
|
||||
inboundAudio?: boolean;
|
||||
ttsAuto?: string;
|
||||
}): Promise<ReplyPayload> {
|
||||
const config = resolveTtsConfig(params.cfg);
|
||||
const prefsPath = resolveTtsPrefsPath(config);
|
||||
if (!isTtsEnabled(config, prefsPath)) return params.payload;
|
||||
|
||||
const mode = config.mode ?? "final";
|
||||
if (mode === "final" && params.kind && params.kind !== "final") return params.payload;
|
||||
const autoMode = resolveTtsAutoMode({
|
||||
config,
|
||||
prefsPath,
|
||||
sessionAuto: params.ttsAuto,
|
||||
});
|
||||
if (autoMode === "off") return params.payload;
|
||||
|
||||
const text = params.payload.text ?? "";
|
||||
const directives = parseTtsDirectives(text, config.modelOverrides);
|
||||
@@ -1183,6 +1246,12 @@ export async function maybeApplyTtsToPayload(params: {
|
||||
text: visibleText.length > 0 ? visibleText : undefined,
|
||||
};
|
||||
|
||||
if (autoMode === "tagged" && !directives.hasDirective) return nextPayload;
|
||||
if (autoMode === "inbound" && params.inboundAudio !== true) return nextPayload;
|
||||
|
||||
const mode = config.mode ?? "final";
|
||||
if (mode === "final" && params.kind && params.kind !== "final") return nextPayload;
|
||||
|
||||
if (!ttsText.trim()) return nextPayload;
|
||||
if (params.payload.mediaUrl || (params.payload.mediaUrls?.length ?? 0) > 0) return nextPayload;
|
||||
if (text.includes("MEDIA:")) return nextPayload;
|
||||
@@ -1197,7 +1266,7 @@ export async function maybeApplyTtsToPayload(params: {
|
||||
logVerbose(
|
||||
`TTS: skipping long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
|
||||
);
|
||||
return params.payload;
|
||||
return nextPayload;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1219,7 +1288,7 @@ export async function maybeApplyTtsToPayload(params: {
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logVerbose(`TTS: summarization failed: ${error.message}`);
|
||||
return params.payload;
|
||||
return nextPayload;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
declare module "node-edge-tts" {
|
||||
export type EdgeTTSOptions = {
|
||||
voice?: string;
|
||||
lang?: string;
|
||||
outputFormat?: string;
|
||||
saveSubtitles?: boolean;
|
||||
proxy?: string;
|
||||
rate?: string;
|
||||
pitch?: string;
|
||||
volume?: string;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export class EdgeTTS {
|
||||
constructor(options?: EdgeTTSOptions);
|
||||
ttsPromise(text: string, outputPath: string): Promise<void>;
|
||||
}
|
||||
}
|
||||
@@ -127,9 +127,9 @@ describe("web inbound media saves with extension", () => {
|
||||
realSock.ev.emit("messages.upsert", upsert);
|
||||
|
||||
// Allow a brief window for the async handler to fire on slower hosts.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (onMessage.mock.calls.length > 0) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
@@ -178,9 +178,9 @@ describe("web inbound media saves with extension", () => {
|
||||
|
||||
realSock.ev.emit("messages.upsert", upsert);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (onMessage.mock.calls.length > 0) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
@@ -218,9 +218,9 @@ describe("web inbound media saves with extension", () => {
|
||||
|
||||
realSock.ev.emit("messages.upsert", upsert);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (onMessage.mock.calls.length > 0) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
Reference in new issue
Block a user