Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d649710627 | ||
|
|
498c15555e | ||
|
|
25345a909a | ||
|
|
bfd8321b42 | ||
|
|
fbbed439bd | ||
|
|
653401774d | ||
|
|
c6cdbb630c | ||
|
|
da2439f2cc | ||
|
|
92ab3f22dc | ||
|
|
43a6c5b77f | ||
|
|
495616d13e | ||
|
|
bac80f0886 | ||
|
|
ef078fec70 | ||
|
|
8e3ac01db6 | ||
|
|
c3f90dd4e2 | ||
|
|
00c4556d7b | ||
|
|
a4bc69dbec | ||
|
|
3f1457de2a | ||
|
|
8507ea08bd | ||
|
|
7ae2548fc6 | ||
|
|
f06f83ddd0 | ||
|
|
c78297d80f | ||
|
|
69f6e1a20b | ||
|
|
5f6409a73d | ||
|
|
06a7e1e8ce | ||
|
|
9eaaadf8ee | ||
|
|
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.
|
||||
|
||||
@@ -9,34 +9,48 @@ Docs: https://docs.clawd.bot
|
||||
- Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. https://docs.clawd.bot/providers/venice
|
||||
|
||||
### Changes
|
||||
- TTS: allow custom OpenAI-compatible TTS endpoints without API keys. (#1701) Thanks @louzhixian. https://docs.clawd.bot/tts
|
||||
- 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: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
||||
- 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: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||
- 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.
|
||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
|
||||
- Heartbeat: normalize target identifiers for consistent routing.
|
||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
||||
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
|
||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||
- Agents: use the active auth profile for auto-compaction recovery.
|
||||
- Models: default missing custom provider fields so minimal configs are accepted.
|
||||
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
||||
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
|
||||
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
|
||||
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||
- 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
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ read_when:
|
||||
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
||||
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls).
|
||||
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
|
||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
|
||||
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
|
||||
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||
|
||||
|
||||
@@ -74,6 +74,22 @@ Example:
|
||||
|
||||
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||
|
||||
## External daemon mode (httpUrl)
|
||||
If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point Clawdbot at it:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
signal: {
|
||||
httpUrl: "http://127.0.0.1:8080",
|
||||
autoStart: false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This skips auto-spawn and the startup wait inside Clawdbot. For slow starts when auto-spawning, set `channels.signal.startupTimeoutMs`.
|
||||
|
||||
## Access control (DMs + groups)
|
||||
DMs:
|
||||
- Default: `channels.signal.dmPolicy = "pairing"`.
|
||||
@@ -142,6 +158,7 @@ Provider options:
|
||||
- `channels.signal.httpUrl`: full daemon URL (overrides host/port).
|
||||
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
|
||||
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).
|
||||
- `channels.signal.startupTimeoutMs`: startup wait timeout in ms (cap 120000).
|
||||
- `channels.signal.receiveMode`: `on-start | manual`.
|
||||
- `channels.signal.ignoreAttachments`: skip attachment downloads.
|
||||
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
||||
|
||||
@@ -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).
|
||||
@@ -518,6 +525,7 @@ Provider options:
|
||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -89,6 +89,8 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
|
||||
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
|
||||
- Enable: `clawdbot plugins enable google-gemini-cli-auth`
|
||||
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
|
||||
- Note: you do **not** paste a client id or secret into `clawdbot.json`. The CLI login flow stores
|
||||
tokens in auth profiles on the gateway host.
|
||||
|
||||
### Z.AI (GLM)
|
||||
|
||||
|
||||
@@ -1048,6 +1048,7 @@
|
||||
"pages": [
|
||||
"platforms",
|
||||
"platforms/macos",
|
||||
"platforms/macos-vm",
|
||||
"platforms/ios",
|
||||
"platforms/android",
|
||||
"platforms/windows",
|
||||
|
||||
@@ -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)
|
||||
@@ -1018,6 +1021,7 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
|
||||
],
|
||||
historyLimit: 50, // include last N group messages as context (0 disables)
|
||||
replyToMode: "first", // off | first | all
|
||||
linkPreview: true, // toggle outbound link previews
|
||||
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
|
||||
draftChunk: { // optional; only for streamMode=block
|
||||
minChars: 200,
|
||||
@@ -1506,7 +1510,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",
|
||||
@@ -1534,6 +1538,7 @@ voice notes; other channels send MP3 audio.
|
||||
},
|
||||
openai: {
|
||||
apiKey: "openai_api_key",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
model: "gpt-4o-mini-tts",
|
||||
voice: "alloy"
|
||||
}
|
||||
@@ -1543,8 +1548,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`.
|
||||
@@ -1552,6 +1559,8 @@ Notes:
|
||||
- `/tts limit` and `/tts summary` control per-user summarization settings.
|
||||
- `apiKey` values fall back to `ELEVENLABS_API_KEY`/`XI_API_KEY` and `OPENAI_API_KEY`.
|
||||
- `elevenlabs.baseUrl` overrides the ElevenLabs API base URL.
|
||||
- `openai.baseUrl` overrides the OpenAI TTS base URL (defaults to `https://api.openai.com/v1`,
|
||||
or `OPENAI_TTS_BASE_URL` when set). Custom endpoints do not require an API key.
|
||||
- `elevenlabs.voiceSettings` supports `stability`/`similarityBoost`/`style` (0..1),
|
||||
`useSpeakerBoost`, and `speed` (0.5..2.0).
|
||||
|
||||
|
||||
+620
-185
File diff suppressed because it is too large.
Load diff
@@ -114,3 +114,9 @@ Git requirement:
|
||||
|
||||
If you choose `-InstallMethod git` and Git is missing, the installer will print the
|
||||
Git for Windows link (`https://git-scm.com/download/win`) and exit.
|
||||
|
||||
Common Windows issues:
|
||||
|
||||
- **npm error spawn git / ENOENT**: install Git for Windows and reopen PowerShell, then rerun the installer.
|
||||
- **"clawdbot" is not recognized**: your npm global bin folder is not on PATH. Most systems use
|
||||
`%AppData%\\npm`. You can also run `npm config get prefix` and add `\\bin` to PATH, then reopen PowerShell.
|
||||
@@ -0,0 +1,275 @@
|
||||
---
|
||||
summary: "Run Clawdbot in a sandboxed macOS VM (local or hosted) when you need isolation or iMessage"
|
||||
read_when:
|
||||
- You want Clawdbot isolated from your main macOS environment
|
||||
- You want iMessage integration (BlueBubbles) in a sandbox
|
||||
- You want a resettable macOS environment you can clone
|
||||
- You want to compare local vs hosted macOS VM options
|
||||
---
|
||||
|
||||
# Clawdbot on macOS VMs (Sandboxing)
|
||||
|
||||
## Recommended default (most users)
|
||||
|
||||
- **Small Linux VPS** for an always-on Gateway and low cost. See [VPS hosting](/vps).
|
||||
- **Dedicated hardware** (Mac mini or Linux box) if you want full control and a **residential IP** for browser automation. Many sites block data center IPs, so local browsing often works better.
|
||||
- **Hybrid:** keep the Gateway on a cheap VPS, and connect your Mac as a **node** when you need browser/UI automation. See [Nodes](/nodes) and [Gateway remote](/gateway/remote).
|
||||
|
||||
Use a macOS VM when you specifically need macOS-only capabilities (iMessage/BlueBubbles) or want strict isolation from your daily Mac.
|
||||
|
||||
## macOS VM options
|
||||
|
||||
### Local VM on your Apple Silicon Mac (Lume)
|
||||
|
||||
Run Clawdbot in a sandboxed macOS VM on your existing Apple Silicon Mac using [Lume](https://cua.ai/docs/lume).
|
||||
|
||||
This gives you:
|
||||
- Full macOS environment in isolation (your host stays clean)
|
||||
- iMessage support via BlueBubbles (impossible on Linux/Windows)
|
||||
- Instant reset by cloning VMs
|
||||
- No extra hardware or cloud costs
|
||||
|
||||
### Hosted Mac providers (cloud)
|
||||
|
||||
If you want macOS in the cloud, hosted Mac providers work too:
|
||||
- [MacStadium](https://www.macstadium.com/) (hosted Macs)
|
||||
- Other hosted Mac vendors also work; follow their VM + SSH docs
|
||||
|
||||
Once you have SSH access to a macOS VM, continue at step 6 below.
|
||||
|
||||
---
|
||||
|
||||
## Quick path (Lume, experienced users)
|
||||
|
||||
1. Install Lume
|
||||
2. `lume create clawdbot --os macos --ipsw latest`
|
||||
3. Complete Setup Assistant, enable Remote Login (SSH)
|
||||
4. `lume run clawdbot --no-display`
|
||||
5. SSH in, install Clawdbot, configure channels
|
||||
6. Done
|
||||
|
||||
---
|
||||
|
||||
## What you need (Lume)
|
||||
|
||||
- Apple Silicon Mac (M1/M2/M3/M4)
|
||||
- macOS Sequoia or later on the host
|
||||
- ~60 GB free disk space per VM
|
||||
- ~20 minutes
|
||||
|
||||
---
|
||||
|
||||
## 1) Install Lume
|
||||
|
||||
```bash
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)"
|
||||
```
|
||||
|
||||
If `~/.local/bin` isn't in your PATH:
|
||||
|
||||
```bash
|
||||
echo 'export PATH="$PATH:$HOME/.local/bin"' >> ~/.zshrc && source ~/.zshrc
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
lume --version
|
||||
```
|
||||
|
||||
Docs: [Lume Installation](https://cua.ai/docs/lume/guide/getting-started/installation)
|
||||
|
||||
---
|
||||
|
||||
## 2) Create the macOS VM
|
||||
|
||||
```bash
|
||||
lume create clawdbot --os macos --ipsw latest
|
||||
```
|
||||
|
||||
This downloads macOS and creates the VM. A VNC window opens automatically.
|
||||
|
||||
Note: The download can take a while depending on your connection.
|
||||
|
||||
---
|
||||
|
||||
## 3) Complete Setup Assistant
|
||||
|
||||
In the VNC window:
|
||||
1. Select language and region
|
||||
2. Skip Apple ID (or sign in if you want iMessage later)
|
||||
3. Create a user account (remember the username and password)
|
||||
4. Skip all optional features
|
||||
|
||||
After setup completes, enable SSH:
|
||||
1. Open System Settings → General → Sharing
|
||||
2. Enable "Remote Login"
|
||||
|
||||
---
|
||||
|
||||
## 4) Get the VM's IP address
|
||||
|
||||
```bash
|
||||
lume get clawdbot
|
||||
```
|
||||
|
||||
Look for the IP address (usually `192.168.64.x`).
|
||||
|
||||
---
|
||||
|
||||
## 5) SSH into the VM
|
||||
|
||||
```bash
|
||||
ssh youruser@192.168.64.X
|
||||
```
|
||||
|
||||
Replace `youruser` with the account you created, and the IP with your VM's IP.
|
||||
|
||||
---
|
||||
|
||||
## 6) Install Clawdbot
|
||||
|
||||
Inside the VM:
|
||||
|
||||
```bash
|
||||
npm install -g clawdbot@latest
|
||||
clawdbot onboard --install-daemon
|
||||
```
|
||||
|
||||
Follow the onboarding prompts to set up your model provider (Anthropic, OpenAI, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 7) Configure channels
|
||||
|
||||
Edit the config file:
|
||||
|
||||
```bash
|
||||
nano ~/.clawdbot/clawdbot.json
|
||||
```
|
||||
|
||||
Add your channels:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"dmPolicy": "allowlist",
|
||||
"allowFrom": ["+15551234567"]
|
||||
},
|
||||
"telegram": {
|
||||
"botToken": "YOUR_BOT_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then login to WhatsApp (scan QR):
|
||||
|
||||
```bash
|
||||
clawdbot channels login
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8) Run the VM headlessly
|
||||
|
||||
Stop the VM and restart without display:
|
||||
|
||||
```bash
|
||||
lume stop clawdbot
|
||||
lume run clawdbot --no-display
|
||||
```
|
||||
|
||||
The VM runs in the background. Clawdbot's daemon keeps the gateway running.
|
||||
|
||||
To check status:
|
||||
|
||||
```bash
|
||||
ssh youruser@192.168.64.X "clawdbot status"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bonus: iMessage integration
|
||||
|
||||
This is the killer feature of running on macOS. Use [BlueBubbles](https://bluebubbles.app) to add iMessage to Clawdbot.
|
||||
|
||||
Inside the VM:
|
||||
|
||||
1. Download BlueBubbles from bluebubbles.app
|
||||
2. Sign in with your Apple ID
|
||||
3. Enable the Web API and set a password
|
||||
4. Point BlueBubbles webhooks at your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`)
|
||||
|
||||
Add to your Clawdbot config:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"bluebubbles": {
|
||||
"serverUrl": "http://localhost:1234",
|
||||
"password": "your-api-password",
|
||||
"webhookPath": "/bluebubbles-webhook"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart the gateway. Now your agent can send and receive iMessages.
|
||||
|
||||
Full setup details: [BlueBubbles channel](/channels/bluebubbles)
|
||||
|
||||
---
|
||||
|
||||
## Save a golden image
|
||||
|
||||
Before customizing further, snapshot your clean state:
|
||||
|
||||
```bash
|
||||
lume stop clawdbot
|
||||
lume clone clawdbot clawdbot-golden
|
||||
```
|
||||
|
||||
Reset anytime:
|
||||
|
||||
```bash
|
||||
lume stop clawdbot && lume delete clawdbot
|
||||
lume clone clawdbot-golden clawdbot
|
||||
lume run clawdbot --no-display
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running 24/7
|
||||
|
||||
Keep the VM running by:
|
||||
- Keeping your Mac plugged in
|
||||
- Disabling sleep in System Settings → Energy Saver
|
||||
- Using `caffeinate` if needed
|
||||
|
||||
For true always-on, consider a dedicated Mac mini or a small VPS. See [VPS hosting](/vps).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| Can't SSH into VM | Check "Remote Login" is enabled in VM's System Settings |
|
||||
| VM IP not showing | Wait for VM to fully boot, run `lume get clawdbot` again |
|
||||
| Lume command not found | Add `~/.local/bin` to your PATH |
|
||||
| WhatsApp QR not scanning | Ensure you're logged into the VM (not host) when running `clawdbot channels login` |
|
||||
|
||||
---
|
||||
|
||||
## Related docs
|
||||
|
||||
- [VPS hosting](/vps)
|
||||
- [Nodes](/nodes)
|
||||
- [Gateway remote](/gateway/remote)
|
||||
- [BlueBubbles channel](/channels/bluebubbles)
|
||||
- [Lume Quickstart](https://cua.ai/docs/lume/guide/getting-started/quickstart)
|
||||
- [Lume CLI Reference](https://cua.ai/docs/lume/reference/cli-reference)
|
||||
- [Unattended VM Setup](https://cua.ai/docs/lume/guide/fundamentals/unattended-setup) (advanced)
|
||||
- [Docker Sandboxing](/install/docker) (alternative isolation approach)
|
||||
@@ -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`
|
||||
|
||||
@@ -174,6 +174,7 @@ Search the web using your configured provider.
|
||||
- `country` (optional): 2-letter country code for region-specific results (e.g., "DE", "US", "ALL"). If omitted, Brave chooses its default region.
|
||||
- `search_lang` (optional): ISO language code for search results (e.g., "de", "en", "fr")
|
||||
- `ui_lang` (optional): ISO language code for UI elements
|
||||
- `freshness` (optional, Brave only): filter by discovery time (`pd`, `pw`, `pm`, `py`, or `YYYY-MM-DDtoYYYY-MM-DD`)
|
||||
|
||||
**Examples:**
|
||||
|
||||
@@ -193,6 +194,12 @@ await web_search({
|
||||
search_lang: "fr",
|
||||
ui_lang: "fr"
|
||||
});
|
||||
|
||||
// Recent results (past week)
|
||||
await web_search({
|
||||
query: "TMBG interview",
|
||||
freshness: "pw"
|
||||
});
|
||||
```
|
||||
|
||||
## web_fetch
|
||||
|
||||
+63
-11
@@ -33,11 +33,18 @@ does not publish limits, so assume similar or lower limits. citeturn0searc
|
||||
|
||||
If you want OpenAI or ElevenLabs:
|
||||
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
|
||||
- `OPENAI_API_KEY`
|
||||
- `OPENAI_API_KEY` (optional for custom OpenAI-compatible endpoints)
|
||||
|
||||
Optional:
|
||||
- `OPENAI_TTS_BASE_URL` (defaults to `https://api.openai.com/v1`)
|
||||
|
||||
Edge TTS does **not** require an API key. If no API keys are found, Clawdbot defaults
|
||||
to Edge TTS (unless disabled via `messages.tts.edge.enabled=false`).
|
||||
|
||||
When `OPENAI_TTS_BASE_URL` points to a non-OpenAI endpoint, Clawdbot relaxes
|
||||
OpenAI model/voice validation and does **not** require an API key. If your
|
||||
endpoint needs authentication, set `messages.tts.openai.apiKey` or `OPENAI_API_KEY`.
|
||||
|
||||
If multiple providers are configured, the selected provider is used first and the others are fallback options.
|
||||
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
|
||||
so that provider must also be authenticated if you enable summaries.
|
||||
@@ -53,8 +60,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 +77,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
auto: "always",
|
||||
provider: "elevenlabs"
|
||||
}
|
||||
}
|
||||
@@ -83,7 +90,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
auto: "always",
|
||||
provider: "openai",
|
||||
summaryModel: "openai/gpt-4.1-mini",
|
||||
modelOverrides: {
|
||||
@@ -115,13 +122,39 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
}
|
||||
```
|
||||
|
||||
### Custom OpenAI-compatible endpoint
|
||||
|
||||
Set an endpoint in the environment:
|
||||
|
||||
```bash
|
||||
export OPENAI_TTS_BASE_URL="http://localhost:8880/v1"
|
||||
```
|
||||
|
||||
Then configure TTS as usual (API key optional for custom endpoints):
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "openai",
|
||||
openai: {
|
||||
baseUrl: "http://localhost:8880/v1",
|
||||
model: "kokoro",
|
||||
voice: "zm_yunxia"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Edge TTS primary (no API key)
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
auto: "always",
|
||||
provider: "edge",
|
||||
edge: {
|
||||
enabled: true,
|
||||
@@ -156,7 +189,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 +198,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 +230,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 +243,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 +266,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 +387,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 +401,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",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "../infra/shell-env.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { logInfo, logWarn } from "../logger.js";
|
||||
import { formatSpawnError, spawnWithFallback } from "../process/spawn-utils.js";
|
||||
import {
|
||||
type ProcessSession,
|
||||
type SessionStdin,
|
||||
@@ -362,23 +363,38 @@ async function runExecProcess(opts: {
|
||||
let stdin: SessionStdin | undefined;
|
||||
|
||||
if (opts.sandbox) {
|
||||
child = spawn(
|
||||
"docker",
|
||||
buildDockerExecArgs({
|
||||
containerName: opts.sandbox.containerName,
|
||||
command: opts.command,
|
||||
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
|
||||
env: opts.env,
|
||||
tty: opts.usePty,
|
||||
}),
|
||||
{
|
||||
const { child: spawned } = await spawnWithFallback({
|
||||
argv: [
|
||||
"docker",
|
||||
...buildDockerExecArgs({
|
||||
containerName: opts.sandbox.containerName,
|
||||
command: opts.command,
|
||||
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
|
||||
env: opts.env,
|
||||
tty: opts.usePty,
|
||||
}),
|
||||
],
|
||||
options: {
|
||||
cwd: opts.workdir,
|
||||
env: process.env,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
},
|
||||
) as ChildProcessWithoutNullStreams;
|
||||
fallbacks: [
|
||||
{
|
||||
label: "no-detach",
|
||||
options: { detached: false },
|
||||
},
|
||||
],
|
||||
onFallback: (err, fallback) => {
|
||||
const errText = formatSpawnError(err);
|
||||
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`;
|
||||
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`);
|
||||
opts.warnings.push(warning);
|
||||
},
|
||||
});
|
||||
child = spawned as ChildProcessWithoutNullStreams;
|
||||
stdin = child.stdin;
|
||||
} else if (opts.usePty) {
|
||||
const { shell, args: shellArgs } = getShellConfig();
|
||||
@@ -422,24 +438,56 @@ async function runExecProcess(opts: {
|
||||
const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`;
|
||||
logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`);
|
||||
opts.warnings.push(warning);
|
||||
child = spawn(shell, [...shellArgs, opts.command], {
|
||||
const { child: spawned } = await spawnWithFallback({
|
||||
argv: [shell, ...shellArgs, opts.command],
|
||||
options: {
|
||||
cwd: opts.workdir,
|
||||
env: opts.env,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
},
|
||||
fallbacks: [
|
||||
{
|
||||
label: "no-detach",
|
||||
options: { detached: false },
|
||||
},
|
||||
],
|
||||
onFallback: (fallbackErr, fallback) => {
|
||||
const fallbackText = formatSpawnError(fallbackErr);
|
||||
const fallbackWarning = `Warning: spawn failed (${fallbackText}); retrying with ${fallback.label}.`;
|
||||
logWarn(`exec: spawn failed (${fallbackText}); retrying with ${fallback.label}.`);
|
||||
opts.warnings.push(fallbackWarning);
|
||||
},
|
||||
});
|
||||
child = spawned as ChildProcessWithoutNullStreams;
|
||||
stdin = child.stdin;
|
||||
}
|
||||
} else {
|
||||
const { shell, args: shellArgs } = getShellConfig();
|
||||
const { child: spawned } = await spawnWithFallback({
|
||||
argv: [shell, ...shellArgs, opts.command],
|
||||
options: {
|
||||
cwd: opts.workdir,
|
||||
env: opts.env,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
}) as ChildProcessWithoutNullStreams;
|
||||
stdin = child.stdin;
|
||||
}
|
||||
} else {
|
||||
const { shell, args: shellArgs } = getShellConfig();
|
||||
child = spawn(shell, [...shellArgs, opts.command], {
|
||||
cwd: opts.workdir,
|
||||
env: opts.env,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
}) as ChildProcessWithoutNullStreams;
|
||||
},
|
||||
fallbacks: [
|
||||
{
|
||||
label: "no-detach",
|
||||
options: { detached: false },
|
||||
},
|
||||
],
|
||||
onFallback: (err, fallback) => {
|
||||
const errText = formatSpawnError(err);
|
||||
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`;
|
||||
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`);
|
||||
opts.warnings.push(warning);
|
||||
},
|
||||
});
|
||||
child = spawned as ChildProcessWithoutNullStreams;
|
||||
stdin = child.stdin;
|
||||
}
|
||||
|
||||
|
||||
+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 = (
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { __testing } from "./web-search.js";
|
||||
|
||||
const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl } = __testing;
|
||||
const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness } =
|
||||
__testing;
|
||||
|
||||
describe("web_search perplexity baseUrl defaults", () => {
|
||||
it("detects a Perplexity key prefix", () => {
|
||||
@@ -51,3 +52,20 @@ describe("web_search perplexity baseUrl defaults", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_search freshness normalization", () => {
|
||||
it("accepts Brave shortcut values", () => {
|
||||
expect(normalizeFreshness("pd")).toBe("pd");
|
||||
expect(normalizeFreshness("PW")).toBe("pw");
|
||||
});
|
||||
|
||||
it("accepts valid date ranges", () => {
|
||||
expect(normalizeFreshness("2024-01-01to2024-01-31")).toBe("2024-01-01to2024-01-31");
|
||||
});
|
||||
|
||||
it("rejects invalid date ranges", () => {
|
||||
expect(normalizeFreshness("2024-13-01to2024-01-31")).toBeUndefined();
|
||||
expect(normalizeFreshness("2024-02-30to2024-03-01")).toBeUndefined();
|
||||
expect(normalizeFreshness("2024-03-10to2024-03-01")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,8 @@ const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
|
||||
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
|
||||
|
||||
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
|
||||
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
|
||||
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;
|
||||
|
||||
const WebSearchSchema = Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
@@ -55,6 +57,12 @@ const WebSearchSchema = Type.Object({
|
||||
description: "ISO language code for UI elements.",
|
||||
}),
|
||||
),
|
||||
freshness: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Filter results by discovery time (Brave only). Values: 'pd' (past 24h), 'pw' (past week), 'pm' (past month), 'py' (past year), or date range 'YYYY-MM-DDtoYYYY-MM-DD'.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type WebSearchConfig = NonNullable<ClawdbotConfig["tools"]>["web"] extends infer Web
|
||||
@@ -219,6 +227,35 @@ function resolveSearchCount(value: unknown, fallback: number): number {
|
||||
return clamped;
|
||||
}
|
||||
|
||||
function normalizeFreshness(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) return lower;
|
||||
|
||||
const match = trimmed.match(BRAVE_FRESHNESS_RANGE);
|
||||
if (!match) return undefined;
|
||||
|
||||
const [, start, end] = match;
|
||||
if (!isValidIsoDate(start) || !isValidIsoDate(end)) return undefined;
|
||||
if (start > end) return undefined;
|
||||
|
||||
return `${start}to${end}`;
|
||||
}
|
||||
|
||||
function isValidIsoDate(value: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
const [year, month, day] = value.split("-").map((part) => Number.parseInt(part, 10));
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return false;
|
||||
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
return (
|
||||
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSiteName(url: string | undefined): string | undefined {
|
||||
if (!url) return undefined;
|
||||
try {
|
||||
@@ -279,11 +316,14 @@ async function runWebSearch(params: {
|
||||
country?: string;
|
||||
search_lang?: string;
|
||||
ui_lang?: string;
|
||||
freshness?: string;
|
||||
perplexityBaseUrl?: string;
|
||||
perplexityModel?: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const cacheKey = normalizeCacheKey(
|
||||
`${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
|
||||
params.provider === "brave"
|
||||
? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}`
|
||||
: `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
|
||||
);
|
||||
const cached = readCache(SEARCH_CACHE, cacheKey);
|
||||
if (cached) return { ...cached.value, cached: true };
|
||||
@@ -327,6 +367,9 @@ async function runWebSearch(params: {
|
||||
if (params.ui_lang) {
|
||||
url.searchParams.set("ui_lang", params.ui_lang);
|
||||
}
|
||||
if (params.freshness) {
|
||||
url.searchParams.set("freshness", params.freshness);
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
@@ -399,6 +442,23 @@ export function createWebSearchTool(options?: {
|
||||
const country = readStringParam(params, "country");
|
||||
const search_lang = readStringParam(params, "search_lang");
|
||||
const ui_lang = readStringParam(params, "ui_lang");
|
||||
const rawFreshness = readStringParam(params, "freshness");
|
||||
if (rawFreshness && provider !== "brave") {
|
||||
return jsonResult({
|
||||
error: "unsupported_freshness",
|
||||
message: "freshness is only supported by the Brave web_search provider.",
|
||||
docs: "https://docs.clawd.bot/tools/web",
|
||||
});
|
||||
}
|
||||
const freshness = rawFreshness ? normalizeFreshness(rawFreshness) : undefined;
|
||||
if (rawFreshness && !freshness) {
|
||||
return jsonResult({
|
||||
error: "invalid_freshness",
|
||||
message:
|
||||
"freshness must be one of pd, pw, pm, py, or a range like YYYY-MM-DDtoYYYY-MM-DD.",
|
||||
docs: "https://docs.clawd.bot/tools/web",
|
||||
});
|
||||
}
|
||||
const result = await runWebSearch({
|
||||
query,
|
||||
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
@@ -409,6 +469,7 @@ export function createWebSearchTool(options?: {
|
||||
country,
|
||||
search_lang,
|
||||
ui_lang,
|
||||
freshness,
|
||||
perplexityBaseUrl: resolvePerplexityBaseUrl(
|
||||
perplexityConfig,
|
||||
perplexityAuth?.source,
|
||||
@@ -424,4 +485,5 @@ export function createWebSearchTool(options?: {
|
||||
export const __testing = {
|
||||
inferPerplexityBaseUrlFromApiKey,
|
||||
resolvePerplexityBaseUrl,
|
||||
normalizeFreshness,
|
||||
} as const;
|
||||
@@ -88,6 +88,40 @@ describe("web_search country and language parameters", () => {
|
||||
const url = new URL(mockFetch.mock.calls[0][0] as string);
|
||||
expect(url.searchParams.get("ui_lang")).toBe("de");
|
||||
});
|
||||
|
||||
it("should pass freshness parameter to Brave API", async () => {
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ web: { results: [] } }),
|
||||
} as Response),
|
||||
);
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
|
||||
await tool?.execute?.(1, { query: "test", freshness: "pw" });
|
||||
|
||||
const url = new URL(mockFetch.mock.calls[0][0] as string);
|
||||
expect(url.searchParams.get("freshness")).toBe("pw");
|
||||
});
|
||||
|
||||
it("rejects invalid freshness values", async () => {
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ web: { results: [] } }),
|
||||
} as Response),
|
||||
);
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
|
||||
const result = await tool?.execute?.(1, { query: "test", freshness: "yesterday" });
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(result?.details).toMatchObject({ error: "invalid_freshness" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_search perplexity baseUrl defaults", () => {
|
||||
@@ -120,6 +154,27 @@ describe("web_search perplexity baseUrl defaults", () => {
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://api.perplexity.ai/chat/completions");
|
||||
});
|
||||
|
||||
it("rejects freshness for Perplexity provider", async () => {
|
||||
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
|
||||
} as Response),
|
||||
);
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebSearchTool({
|
||||
config: { tools: { web: { search: { provider: "perplexity" } } } },
|
||||
sandboxed: true,
|
||||
});
|
||||
const result = await tool?.execute?.(1, { query: "test", freshness: "pw" });
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(result?.details).toMatchObject({ error: "unsupported_freshness" });
|
||||
});
|
||||
|
||||
it("defaults to OpenRouter when OPENROUTER_API_KEY is set", async () => {
|
||||
vi.stubEnv("PERPLEXITY_API_KEY", "");
|
||||
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
|
||||
|
||||
@@ -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,7 +1,8 @@
|
||||
import type { ReasoningLevel } from "../thinking.js";
|
||||
import type { NoticeLevel, ReasoningLevel } from "../thinking.js";
|
||||
import {
|
||||
type ElevatedLevel,
|
||||
normalizeElevatedLevel,
|
||||
normalizeNoticeLevel,
|
||||
normalizeReasoningLevel,
|
||||
normalizeThinkLevel,
|
||||
normalizeVerboseLevel,
|
||||
@@ -112,6 +113,22 @@ export function extractVerboseDirective(body?: string): {
|
||||
};
|
||||
}
|
||||
|
||||
export function extractNoticeDirective(body?: string): {
|
||||
cleaned: string;
|
||||
noticeLevel?: NoticeLevel;
|
||||
rawLevel?: string;
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
const extracted = extractLevelDirective(body, ["notice", "notices"], normalizeNoticeLevel);
|
||||
return {
|
||||
cleaned: extracted.cleaned,
|
||||
noticeLevel: extracted.level,
|
||||
rawLevel: extracted.rawLevel,
|
||||
hasDirective: extracted.hasDirective,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractElevatedDirective(body?: string): {
|
||||
cleaned: string;
|
||||
elevatedLevel?: ElevatedLevel;
|
||||
@@ -152,5 +169,5 @@ export function extractStatusDirective(body?: string): {
|
||||
return extractSimpleDirective(body, ["status"]);
|
||||
}
|
||||
|
||||
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
||||
export type { ElevatedLevel, NoticeLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
||||
export { extractExecDirective } from "./exec/directive.js";
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
export type VerboseLevel = "off" | "on" | "full";
|
||||
export type NoticeLevel = "off" | "on" | "full";
|
||||
export type ElevatedLevel = "off" | "on" | "ask" | "full";
|
||||
export type ElevatedMode = "off" | "ask" | "full";
|
||||
export type ReasoningLevel = "off" | "on" | "stream";
|
||||
@@ -93,6 +94,16 @@ export function normalizeVerboseLevel(raw?: string | null): VerboseLevel | undef
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Normalize system notice flags used to toggle system notifications.
|
||||
export function normalizeNoticeLevel(raw?: string | null): NoticeLevel | undefined {
|
||||
if (!raw) return undefined;
|
||||
const key = raw.toLowerCase();
|
||||
if (["off", "false", "no", "0"].includes(key)) return "off";
|
||||
if (["full", "all", "everything"].includes(key)) return "full";
|
||||
if (["on", "minimal", "true", "yes", "1"].includes(key)) return "on";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Normalize response-usage display modes used to toggle per-response usage footers.
|
||||
export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined {
|
||||
if (!raw) return undefined;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -33,6 +33,8 @@ export type SignalAccountConfig = {
|
||||
cliPath?: string;
|
||||
/** Auto-start signal-cli daemon (default: true if httpUrl not set). */
|
||||
autoStart?: boolean;
|
||||
/** Max time to wait for signal-cli daemon startup (ms, cap 120000). */
|
||||
startupTimeoutMs?: number;
|
||||
receiveMode?: "on-start" | "manual";
|
||||
ignoreAttachments?: boolean;
|
||||
ignoreStories?: boolean;
|
||||
|
||||
@@ -118,6 +118,8 @@ export type TelegramAccountConfig = {
|
||||
reactionLevel?: "off" | "ack" | "minimal" | "extensive";
|
||||
/** Heartbeat visibility settings for this channel. */
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
/** Controls whether link previews are shown in outbound messages. Default: true. */
|
||||
linkPreview?: boolean;
|
||||
};
|
||||
|
||||
export type TelegramTopicConfig = {
|
||||
|
||||
@@ -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;
|
||||
@@ -52,6 +56,7 @@ export type TtsConfig = {
|
||||
/** OpenAI configuration. */
|
||||
openai?: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
voice?: string;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
@@ -202,6 +204,7 @@ export const TtsConfigSchema = z
|
||||
openai: z
|
||||
.object({
|
||||
apiKey: z.string().optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
voice: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -125,6 +125,7 @@ export const TelegramAccountSchemaBase = z
|
||||
reactionNotifications: z.enum(["off", "own", "all"]).optional(),
|
||||
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
linkPreview: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -486,6 +487,7 @@ export const SignalAccountSchemaBase = z
|
||||
httpPort: z.number().int().positive().optional(),
|
||||
cliPath: ExecutableTokenSchema.optional(),
|
||||
autoStart: z.boolean().optional(),
|
||||
startupTimeoutMs: z.number().int().min(1000).max(120000).optional(),
|
||||
receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(),
|
||||
ignoreAttachments: z.boolean().optional(),
|
||||
ignoreStories: z.boolean().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;
|
||||
|
||||
+17
-66
@@ -1,77 +1,28 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const testTailnetIPv4 = { value: undefined as string | undefined };
|
||||
const testTailnetIPv6 = { value: undefined as string | undefined };
|
||||
import { resolveGatewayListenHosts } from "./net.js";
|
||||
|
||||
vi.mock("../infra/tailnet.js", () => ({
|
||||
pickPrimaryTailnetIPv4: () => testTailnetIPv4.value,
|
||||
pickPrimaryTailnetIPv6: () => testTailnetIPv6.value,
|
||||
}));
|
||||
|
||||
import { isLocalGatewayAddress, resolveGatewayClientIp } from "./net.js";
|
||||
|
||||
describe("gateway net", () => {
|
||||
beforeEach(() => {
|
||||
testTailnetIPv4.value = undefined;
|
||||
testTailnetIPv6.value = undefined;
|
||||
});
|
||||
|
||||
test("treats loopback as local", () => {
|
||||
expect(isLocalGatewayAddress("127.0.0.1")).toBe(true);
|
||||
expect(isLocalGatewayAddress("127.0.1.1")).toBe(true);
|
||||
expect(isLocalGatewayAddress("::1")).toBe(true);
|
||||
expect(isLocalGatewayAddress("::ffff:127.0.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
test("treats local tailnet IPv4 as local", () => {
|
||||
testTailnetIPv4.value = "100.64.0.1";
|
||||
expect(isLocalGatewayAddress("100.64.0.1")).toBe(true);
|
||||
expect(isLocalGatewayAddress("::ffff:100.64.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores non-matching tailnet IPv4", () => {
|
||||
testTailnetIPv4.value = "100.64.0.1";
|
||||
expect(isLocalGatewayAddress("100.64.0.2")).toBe(false);
|
||||
});
|
||||
|
||||
test("treats local tailnet IPv6 as local", () => {
|
||||
testTailnetIPv6.value = "fd7a:115c:a1e0::123";
|
||||
expect(isLocalGatewayAddress("fd7a:115c:a1e0::123")).toBe(true);
|
||||
});
|
||||
|
||||
test("uses forwarded-for when remote is a trusted proxy", () => {
|
||||
const clientIp = resolveGatewayClientIp({
|
||||
remoteAddr: "10.0.0.2",
|
||||
forwardedFor: "203.0.113.9, 10.0.0.2",
|
||||
trustedProxies: ["10.0.0.2"],
|
||||
describe("resolveGatewayListenHosts", () => {
|
||||
it("returns the input host when not loopback", async () => {
|
||||
const hosts = await resolveGatewayListenHosts("0.0.0.0", {
|
||||
canBindToHost: async () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
});
|
||||
expect(clientIp).toBe("203.0.113.9");
|
||||
expect(hosts).toEqual(["0.0.0.0"]);
|
||||
});
|
||||
|
||||
test("ignores forwarded-for from untrusted proxies", () => {
|
||||
const clientIp = resolveGatewayClientIp({
|
||||
remoteAddr: "10.0.0.3",
|
||||
forwardedFor: "203.0.113.9",
|
||||
trustedProxies: ["10.0.0.2"],
|
||||
it("adds ::1 when IPv6 loopback is available", async () => {
|
||||
const hosts = await resolveGatewayListenHosts("127.0.0.1", {
|
||||
canBindToHost: async () => true,
|
||||
});
|
||||
expect(clientIp).toBe("10.0.0.3");
|
||||
expect(hosts).toEqual(["127.0.0.1", "::1"]);
|
||||
});
|
||||
|
||||
test("normalizes trusted proxy IPs and strips forwarded ports", () => {
|
||||
const clientIp = resolveGatewayClientIp({
|
||||
remoteAddr: "::ffff:10.0.0.2",
|
||||
forwardedFor: "203.0.113.9:1234",
|
||||
trustedProxies: ["10.0.0.2"],
|
||||
it("keeps only IPv4 loopback when IPv6 is unavailable", async () => {
|
||||
const hosts = await resolveGatewayListenHosts("127.0.0.1", {
|
||||
canBindToHost: async () => false,
|
||||
});
|
||||
expect(clientIp).toBe("203.0.113.9");
|
||||
});
|
||||
|
||||
test("falls back to x-real-ip when forwarded-for is missing", () => {
|
||||
const clientIp = resolveGatewayClientIp({
|
||||
remoteAddr: "10.0.0.2",
|
||||
realIp: "203.0.113.10",
|
||||
trustedProxies: ["10.0.0.2"],
|
||||
});
|
||||
expect(clientIp).toBe("203.0.113.10");
|
||||
expect(hosts).toEqual(["127.0.0.1"]);
|
||||
});
|
||||
});
|
||||
+16
-6
@@ -97,14 +97,14 @@ export async function resolveGatewayBindHost(
|
||||
|
||||
if (mode === "loopback") {
|
||||
// 127.0.0.1 rarely fails, but handle gracefully
|
||||
if (await canBindTo("127.0.0.1")) return "127.0.0.1";
|
||||
if (await canBindToHost("127.0.0.1")) return "127.0.0.1";
|
||||
return "0.0.0.0"; // extreme fallback
|
||||
}
|
||||
|
||||
if (mode === "tailnet") {
|
||||
const tailnetIP = pickPrimaryTailnetIPv4();
|
||||
if (tailnetIP && (await canBindTo(tailnetIP))) return tailnetIP;
|
||||
if (await canBindTo("127.0.0.1")) return "127.0.0.1";
|
||||
if (tailnetIP && (await canBindToHost(tailnetIP))) return tailnetIP;
|
||||
if (await canBindToHost("127.0.0.1")) return "127.0.0.1";
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
@@ -116,13 +116,13 @@ export async function resolveGatewayBindHost(
|
||||
const host = customHost?.trim();
|
||||
if (!host) return "0.0.0.0"; // invalid config → fall back to all
|
||||
|
||||
if (isValidIPv4(host) && (await canBindTo(host))) return host;
|
||||
if (isValidIPv4(host) && (await canBindToHost(host))) return host;
|
||||
// Custom IP failed → fall back to LAN
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
if (mode === "auto") {
|
||||
if (await canBindTo("127.0.0.1")) return "127.0.0.1";
|
||||
if (await canBindToHost("127.0.0.1")) return "127.0.0.1";
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export async function resolveGatewayBindHost(
|
||||
* @param host - The host address to test
|
||||
* @returns True if we can successfully bind to this address
|
||||
*/
|
||||
async function canBindTo(host: string): Promise<boolean> {
|
||||
export async function canBindToHost(host: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const testServer = net.createServer();
|
||||
testServer.once("error", () => {
|
||||
@@ -151,6 +151,16 @@ async function canBindTo(host: string): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveGatewayListenHosts(
|
||||
bindHost: string,
|
||||
opts?: { canBindToHost?: (host: string) => Promise<boolean> },
|
||||
): Promise<string[]> {
|
||||
if (bindHost !== "127.0.0.1") return [bindHost];
|
||||
const canBind = opts?.canBindToHost ?? canBindToHost;
|
||||
if (await canBind("::1")) return [bindHost, "::1"];
|
||||
return [bindHost];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a string is a valid IPv4 address.
|
||||
*
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createGatewayCloseHandler(params: {
|
||||
browserControl: { stop: () => Promise<void> } | null;
|
||||
wss: WebSocketServer;
|
||||
httpServer: HttpServer;
|
||||
httpServers?: HttpServer[];
|
||||
}) {
|
||||
return async (opts?: { reason?: string; restartExpectedMs?: number | null }) => {
|
||||
const reasonRaw = typeof opts?.reason === "string" ? opts.reason.trim() : "";
|
||||
@@ -108,14 +109,20 @@ export function createGatewayCloseHandler(params: {
|
||||
await params.browserControl.stop().catch(() => {});
|
||||
}
|
||||
await new Promise<void>((resolve) => params.wss.close(() => resolve()));
|
||||
const httpServer = params.httpServer as HttpServer & {
|
||||
closeIdleConnections?: () => void;
|
||||
};
|
||||
if (typeof httpServer.closeIdleConnections === "function") {
|
||||
httpServer.closeIdleConnections();
|
||||
const servers =
|
||||
params.httpServers && params.httpServers.length > 0
|
||||
? params.httpServers
|
||||
: [params.httpServer];
|
||||
for (const server of servers) {
|
||||
const httpServer = server as HttpServer & {
|
||||
closeIdleConnections?: () => void;
|
||||
};
|
||||
if (typeof httpServer.closeIdleConnections === "function") {
|
||||
httpServer.closeIdleConnections();
|
||||
}
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
httpServer.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
}
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
params.httpServer.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
@@ -128,7 +131,7 @@ export const ttsHandlers: GatewayRequestHandlers = {
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
configured: Boolean(resolveTtsApiKey(config, "openai")),
|
||||
configured: isTtsProviderConfigured(config, "openai"),
|
||||
models: [...OPENAI_TTS_MODELS],
|
||||
voices: [...OPENAI_TTS_VOICES],
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js";
|
||||
import type { HooksConfigResolved } from "./hooks.js";
|
||||
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
|
||||
import { listenGatewayHttpServer } from "./server/http-listen.js";
|
||||
import { resolveGatewayListenHosts } from "./net.js";
|
||||
import { createGatewayPluginRequestHandler } from "./server/plugins-http.js";
|
||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||
import { createGatewayBroadcaster } from "./server-broadcast.js";
|
||||
@@ -38,11 +39,14 @@ export async function createGatewayRuntimeState(params: {
|
||||
canvasHostEnabled: boolean;
|
||||
allowCanvasHostInTests?: boolean;
|
||||
logCanvas: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||
log: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||
logHooks: ReturnType<typeof createSubsystemLogger>;
|
||||
logPlugins: ReturnType<typeof createSubsystemLogger>;
|
||||
}): Promise<{
|
||||
canvasHost: CanvasHostHandler | null;
|
||||
httpServer: HttpServer;
|
||||
httpServers: HttpServer[];
|
||||
httpBindHosts: string[];
|
||||
wss: WebSocketServer;
|
||||
clients: Set<GatewayWsClient>;
|
||||
broadcast: (
|
||||
@@ -100,30 +104,49 @@ export async function createGatewayRuntimeState(params: {
|
||||
log: params.logPlugins,
|
||||
});
|
||||
|
||||
const httpServer = createGatewayHttpServer({
|
||||
canvasHost,
|
||||
controlUiEnabled: params.controlUiEnabled,
|
||||
controlUiBasePath: params.controlUiBasePath,
|
||||
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
|
||||
openResponsesEnabled: params.openResponsesEnabled,
|
||||
openResponsesConfig: params.openResponsesConfig,
|
||||
handleHooksRequest,
|
||||
handlePluginRequest,
|
||||
resolvedAuth: params.resolvedAuth,
|
||||
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
|
||||
});
|
||||
|
||||
await listenGatewayHttpServer({
|
||||
httpServer,
|
||||
bindHost: params.bindHost,
|
||||
port: params.port,
|
||||
});
|
||||
const bindHosts = await resolveGatewayListenHosts(params.bindHost);
|
||||
const httpServers: HttpServer[] = [];
|
||||
const httpBindHosts: string[] = [];
|
||||
for (const host of bindHosts) {
|
||||
const httpServer = createGatewayHttpServer({
|
||||
canvasHost,
|
||||
controlUiEnabled: params.controlUiEnabled,
|
||||
controlUiBasePath: params.controlUiBasePath,
|
||||
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
|
||||
openResponsesEnabled: params.openResponsesEnabled,
|
||||
openResponsesConfig: params.openResponsesConfig,
|
||||
handleHooksRequest,
|
||||
handlePluginRequest,
|
||||
resolvedAuth: params.resolvedAuth,
|
||||
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
|
||||
});
|
||||
try {
|
||||
await listenGatewayHttpServer({
|
||||
httpServer,
|
||||
bindHost: host,
|
||||
port: params.port,
|
||||
});
|
||||
httpServers.push(httpServer);
|
||||
httpBindHosts.push(host);
|
||||
} catch (err) {
|
||||
if (host === bindHosts[0]) throw err;
|
||||
params.log.warn(
|
||||
`gateway: failed to bind loopback alias ${host}:${params.port} (${String(err)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const httpServer = httpServers[0];
|
||||
if (!httpServer) {
|
||||
throw new Error("Gateway HTTP server failed to start");
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: MAX_PAYLOAD_BYTES,
|
||||
});
|
||||
attachGatewayUpgradeHandler({ httpServer, wss, canvasHost });
|
||||
for (const server of httpServers) {
|
||||
attachGatewayUpgradeHandler({ httpServer: server, wss, canvasHost });
|
||||
}
|
||||
|
||||
const clients = new Set<GatewayWsClient>();
|
||||
const { broadcast } = createGatewayBroadcaster({ clients });
|
||||
@@ -140,6 +163,8 @@ export async function createGatewayRuntimeState(params: {
|
||||
return {
|
||||
canvasHost,
|
||||
httpServer,
|
||||
httpServers,
|
||||
httpBindHosts,
|
||||
wss,
|
||||
clients,
|
||||
broadcast,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getResolvedLoggerSettings } from "../logging.js";
|
||||
export function logGatewayStartup(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
bindHost: string;
|
||||
bindHosts?: string[];
|
||||
port: number;
|
||||
tlsEnabled?: boolean;
|
||||
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
|
||||
@@ -22,9 +23,16 @@ export function logGatewayStartup(params: {
|
||||
consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`,
|
||||
});
|
||||
const scheme = params.tlsEnabled ? "wss" : "ws";
|
||||
const formatHost = (host: string) => (host.includes(":") ? `[${host}]` : host);
|
||||
const hosts =
|
||||
params.bindHosts && params.bindHosts.length > 0 ? params.bindHosts : [params.bindHost];
|
||||
const primaryHost = hosts[0] ?? params.bindHost;
|
||||
params.log.info(
|
||||
`listening on ${scheme}://${params.bindHost}:${params.port} (PID ${process.pid})`,
|
||||
`listening on ${scheme}://${formatHost(primaryHost)}:${params.port} (PID ${process.pid})`,
|
||||
);
|
||||
for (const host of hosts.slice(1)) {
|
||||
params.log.info(`listening on ${scheme}://${formatHost(host)}:${params.port}`);
|
||||
}
|
||||
params.log.info(`log file: ${getResolvedLoggerSettings().file}`);
|
||||
if (params.isNixMode) {
|
||||
params.log.info("gateway: running in Nix mode (config managed externally)");
|
||||
|
||||
@@ -230,6 +230,21 @@ describe("gateway server auth/connect", () => {
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("returns control ui hint when token is missing", async () => {
|
||||
const ws = await openWs(port);
|
||||
const res = await connectReq(ws, {
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
version: "1.0.0",
|
||||
platform: "web",
|
||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("Control UI settings");
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("rejects control ui without device identity by default", async () => {
|
||||
const ws = await openWs(port);
|
||||
const res = await connectReq(ws, {
|
||||
|
||||
@@ -263,6 +263,8 @@ export async function startGatewayServer(
|
||||
const {
|
||||
canvasHost,
|
||||
httpServer,
|
||||
httpServers,
|
||||
httpBindHosts,
|
||||
wss,
|
||||
clients,
|
||||
broadcast,
|
||||
@@ -292,6 +294,7 @@ export async function startGatewayServer(
|
||||
canvasHostEnabled,
|
||||
allowCanvasHostInTests: opts.allowCanvasHostInTests,
|
||||
logCanvas,
|
||||
log,
|
||||
logHooks,
|
||||
logPlugins,
|
||||
});
|
||||
@@ -464,6 +467,7 @@ export async function startGatewayServer(
|
||||
logGatewayStartup({
|
||||
cfg: cfgAtStart,
|
||||
bindHost,
|
||||
bindHosts: httpBindHosts,
|
||||
port,
|
||||
tlsEnabled: gatewayTls.enabled,
|
||||
log,
|
||||
@@ -552,6 +556,7 @@ export async function startGatewayServer(
|
||||
browserControl,
|
||||
wss,
|
||||
httpServer,
|
||||
httpServers,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -66,19 +66,34 @@ function formatGatewayAuthFailureMessage(params: {
|
||||
authMode: ResolvedGatewayAuth["mode"];
|
||||
authProvided: AuthProvidedKind;
|
||||
reason?: string;
|
||||
client?: { id?: string | null; mode?: string | null };
|
||||
}): string {
|
||||
const { authMode, authProvided, reason } = params;
|
||||
const { authMode, authProvided, reason, client } = params;
|
||||
const isCli = isGatewayCliClient(client);
|
||||
const isControlUi = client?.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
||||
const isWebchat = isWebchatClient(client);
|
||||
const uiHint = "open a tokenized dashboard URL or paste token in Control UI settings";
|
||||
const tokenHint = isCli
|
||||
? "set gateway.remote.token to match gateway.auth.token"
|
||||
: isControlUi || isWebchat
|
||||
? uiHint
|
||||
: "provide gateway auth token";
|
||||
const passwordHint = isCli
|
||||
? "set gateway.remote.password to match gateway.auth.password"
|
||||
: isControlUi || isWebchat
|
||||
? "enter the password in Control UI settings"
|
||||
: "provide gateway auth password";
|
||||
switch (reason) {
|
||||
case "token_missing":
|
||||
return "unauthorized: gateway token missing (set gateway.remote.token to match gateway.auth.token)";
|
||||
return `unauthorized: gateway token missing (${tokenHint})`;
|
||||
case "token_mismatch":
|
||||
return "unauthorized: gateway token mismatch (set gateway.remote.token to match gateway.auth.token)";
|
||||
return `unauthorized: gateway token mismatch (${tokenHint})`;
|
||||
case "token_missing_config":
|
||||
return "unauthorized: gateway token not configured on gateway (set gateway.auth.token)";
|
||||
case "password_missing":
|
||||
return "unauthorized: gateway password missing (set gateway.remote.password to match gateway.auth.password)";
|
||||
return `unauthorized: gateway password missing (${passwordHint})`;
|
||||
case "password_mismatch":
|
||||
return "unauthorized: gateway password mismatch (set gateway.remote.password to match gateway.auth.password)";
|
||||
return `unauthorized: gateway password mismatch (${passwordHint})`;
|
||||
case "password_missing_config":
|
||||
return "unauthorized: gateway password not configured on gateway (set gateway.auth.password)";
|
||||
case "tailscale_user_missing":
|
||||
@@ -90,10 +105,10 @@ function formatGatewayAuthFailureMessage(params: {
|
||||
}
|
||||
|
||||
if (authMode === "token" && authProvided === "none") {
|
||||
return "unauthorized: gateway token missing (set gateway.remote.token to match gateway.auth.token)";
|
||||
return `unauthorized: gateway token missing (${tokenHint})`;
|
||||
}
|
||||
if (authMode === "password" && authProvided === "none") {
|
||||
return "unauthorized: gateway password missing (set gateway.remote.password to match gateway.auth.password)";
|
||||
return `unauthorized: gateway password missing (${passwordHint})`;
|
||||
}
|
||||
return "unauthorized";
|
||||
}
|
||||
@@ -532,6 +547,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
authMode: resolvedAuth.mode,
|
||||
authProvided,
|
||||
reason: authResult.reason,
|
||||
client: connectParams.client,
|
||||
});
|
||||
setCloseCause("unauthorized", {
|
||||
authMode: resolvedAuth.mode,
|
||||
|
||||
+11
-7
@@ -4,6 +4,7 @@ import { promisify } from "node:util";
|
||||
|
||||
import { danger, shouldLogVerbose } from "../globals.js";
|
||||
import { logDebug, logError } from "../logger.js";
|
||||
import { resolveCommandStdio } from "./spawn-utils.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -78,19 +79,22 @@ export async function runCommandWithTimeout(
|
||||
if (resolvedEnv.npm_config_fund == null) resolvedEnv.npm_config_fund = "false";
|
||||
}
|
||||
|
||||
const stdio = resolveCommandStdio({ hasInput, preferInherit: true });
|
||||
const child = spawn(argv[0], argv.slice(1), {
|
||||
stdio,
|
||||
cwd,
|
||||
env: resolvedEnv,
|
||||
windowsVerbatimArguments,
|
||||
});
|
||||
// Spawn with inherited stdin (TTY) so tools like `pi` stay interactive when needed.
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(argv[0], argv.slice(1), {
|
||||
stdio: [hasInput ? "pipe" : "inherit", "pipe", "pipe"],
|
||||
cwd,
|
||||
env: resolvedEnv,
|
||||
windowsVerbatimArguments,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
if (typeof child.kill === "function") {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
if (hasInput && child.stdin) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { spawnWithFallback } from "./spawn-utils.js";
|
||||
|
||||
function createStubChild() {
|
||||
const child = new EventEmitter() as ChildProcess;
|
||||
child.stdin = new PassThrough() as ChildProcess["stdin"];
|
||||
child.stdout = new PassThrough() as ChildProcess["stdout"];
|
||||
child.stderr = new PassThrough() as ChildProcess["stderr"];
|
||||
child.pid = 1234;
|
||||
child.killed = false;
|
||||
child.kill = vi.fn(() => true) as ChildProcess["kill"];
|
||||
queueMicrotask(() => {
|
||||
child.emit("spawn");
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
describe("spawnWithFallback", () => {
|
||||
it("retries on EBADF using fallback options", async () => {
|
||||
const spawnMock = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
const err = new Error("spawn EBADF");
|
||||
(err as NodeJS.ErrnoException).code = "EBADF";
|
||||
throw err;
|
||||
})
|
||||
.mockImplementationOnce(() => createStubChild());
|
||||
|
||||
const result = await spawnWithFallback({
|
||||
argv: ["echo", "ok"],
|
||||
options: { stdio: ["pipe", "pipe", "pipe"] },
|
||||
fallbacks: [{ label: "safe-stdin", options: { stdio: ["ignore", "pipe", "pipe"] } }],
|
||||
spawnImpl: spawnMock,
|
||||
});
|
||||
|
||||
expect(result.usedFallback).toBe(true);
|
||||
expect(result.fallbackLabel).toBe("safe-stdin");
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
expect(spawnMock.mock.calls[0]?.[2]?.stdio).toEqual(["pipe", "pipe", "pipe"]);
|
||||
expect(spawnMock.mock.calls[1]?.[2]?.stdio).toEqual(["ignore", "pipe", "pipe"]);
|
||||
});
|
||||
|
||||
it("does not retry on non-EBADF errors", async () => {
|
||||
const spawnMock = vi.fn().mockImplementationOnce(() => {
|
||||
const err = new Error("spawn ENOENT");
|
||||
(err as NodeJS.ErrnoException).code = "ENOENT";
|
||||
throw err;
|
||||
});
|
||||
|
||||
await expect(
|
||||
spawnWithFallback({
|
||||
argv: ["missing"],
|
||||
options: { stdio: ["pipe", "pipe", "pipe"] },
|
||||
fallbacks: [{ label: "safe-stdin", options: { stdio: ["ignore", "pipe", "pipe"] } }],
|
||||
spawnImpl: spawnMock,
|
||||
}),
|
||||
).rejects.toThrow(/ENOENT/);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ChildProcess, SpawnOptions } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export type SpawnFallback = {
|
||||
label: string;
|
||||
options: SpawnOptions;
|
||||
};
|
||||
|
||||
export type SpawnWithFallbackResult = {
|
||||
child: ChildProcess;
|
||||
usedFallback: boolean;
|
||||
fallbackLabel?: string;
|
||||
};
|
||||
|
||||
type SpawnWithFallbackParams = {
|
||||
argv: string[];
|
||||
options: SpawnOptions;
|
||||
fallbacks?: SpawnFallback[];
|
||||
spawnImpl?: typeof spawn;
|
||||
retryCodes?: string[];
|
||||
onFallback?: (err: unknown, fallback: SpawnFallback) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_RETRY_CODES = ["EBADF"];
|
||||
|
||||
export function resolveCommandStdio(params: {
|
||||
hasInput: boolean;
|
||||
preferInherit: boolean;
|
||||
}): ["pipe" | "inherit" | "ignore", "pipe", "pipe"] {
|
||||
const stdin = params.hasInput ? "pipe" : params.preferInherit ? "inherit" : "pipe";
|
||||
return [stdin, "pipe", "pipe"];
|
||||
}
|
||||
|
||||
export function formatSpawnError(err: unknown): string {
|
||||
if (!(err instanceof Error)) return String(err);
|
||||
const details = err as NodeJS.ErrnoException;
|
||||
const parts: string[] = [];
|
||||
const message = err.message?.trim();
|
||||
if (message) parts.push(message);
|
||||
if (details.code && !message?.includes(details.code)) parts.push(details.code);
|
||||
if (details.syscall) parts.push(`syscall=${details.syscall}`);
|
||||
if (typeof details.errno === "number") parts.push(`errno=${details.errno}`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function shouldRetry(err: unknown, codes: string[]): boolean {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err ? String((err as { code?: unknown }).code) : "";
|
||||
return code.length > 0 && codes.includes(code);
|
||||
}
|
||||
|
||||
async function spawnAndWaitForSpawn(
|
||||
spawnImpl: typeof spawn,
|
||||
argv: string[],
|
||||
options: SpawnOptions,
|
||||
): Promise<ChildProcess> {
|
||||
const child = spawnImpl(argv[0], argv.slice(1), options);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
child.removeListener("error", onError);
|
||||
child.removeListener("spawn", onSpawn);
|
||||
};
|
||||
const finishResolve = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(child);
|
||||
};
|
||||
const onError = (err: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
const onSpawn = () => {
|
||||
finishResolve();
|
||||
};
|
||||
child.once("error", onError);
|
||||
child.once("spawn", onSpawn);
|
||||
// Ensure mocked spawns that never emit "spawn" don't stall.
|
||||
process.nextTick(() => {
|
||||
if (typeof child.pid === "number") {
|
||||
finishResolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function spawnWithFallback(
|
||||
params: SpawnWithFallbackParams,
|
||||
): Promise<SpawnWithFallbackResult> {
|
||||
const spawnImpl = params.spawnImpl ?? spawn;
|
||||
const retryCodes = params.retryCodes ?? DEFAULT_RETRY_CODES;
|
||||
const baseOptions = { ...params.options };
|
||||
const fallbacks = params.fallbacks ?? [];
|
||||
const attempts: Array<{ label?: string; options: SpawnOptions }> = [
|
||||
{ options: baseOptions },
|
||||
...fallbacks.map((fallback) => ({
|
||||
label: fallback.label,
|
||||
options: { ...baseOptions, ...fallback.options },
|
||||
})),
|
||||
];
|
||||
|
||||
let lastError: unknown;
|
||||
for (let index = 0; index < attempts.length; index += 1) {
|
||||
const attempt = attempts[index];
|
||||
try {
|
||||
const child = await spawnAndWaitForSpawn(spawnImpl, params.argv, attempt.options);
|
||||
return {
|
||||
child,
|
||||
usedFallback: index > 0,
|
||||
fallbackLabel: attempt.label,
|
||||
};
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const nextFallback = fallbacks[index];
|
||||
if (!nextFallback || !shouldRetry(err, retryCodes)) {
|
||||
throw err;
|
||||
}
|
||||
params.onFallback?.(err, nextFallback);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
@@ -126,6 +126,90 @@ describe("monitorSignalProvider tool results", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses startupTimeoutMs override when provided", async () => {
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
config = {
|
||||
...config,
|
||||
channels: {
|
||||
...config.channels,
|
||||
signal: {
|
||||
autoStart: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
startupTimeoutMs: 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const abortController = new AbortController();
|
||||
streamMock.mockImplementation(async () => {
|
||||
abortController.abort();
|
||||
return;
|
||||
});
|
||||
|
||||
await monitorSignalProvider({
|
||||
autoStart: true,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
abortSignal: abortController.signal,
|
||||
runtime,
|
||||
startupTimeoutMs: 90_000,
|
||||
});
|
||||
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledTimes(1);
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeoutMs: 90_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("caps startupTimeoutMs at 2 minutes", async () => {
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
config = {
|
||||
...config,
|
||||
channels: {
|
||||
...config.channels,
|
||||
signal: {
|
||||
autoStart: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
startupTimeoutMs: 180_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const abortController = new AbortController();
|
||||
streamMock.mockImplementation(async () => {
|
||||
abortController.abort();
|
||||
return;
|
||||
});
|
||||
|
||||
await monitorSignalProvider({
|
||||
autoStart: true,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
abortSignal: abortController.signal,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledTimes(1);
|
||||
expect(waitForTransportReadyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeoutMs: 120_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends tool summaries with responsePrefix", async () => {
|
||||
const abortController = new AbortController();
|
||||
replyMock.mockImplementation(async (_ctx, opts) => {
|
||||
|
||||
@@ -43,6 +43,7 @@ export type MonitorSignalOpts = {
|
||||
config?: ClawdbotConfig;
|
||||
baseUrl?: string;
|
||||
autoStart?: boolean;
|
||||
startupTimeoutMs?: number;
|
||||
cliPath?: string;
|
||||
httpHost?: string;
|
||||
httpPort?: number;
|
||||
@@ -285,6 +286,10 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts);
|
||||
|
||||
const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl;
|
||||
const startupTimeoutMs = Math.min(
|
||||
120_000,
|
||||
Math.max(1_000, opts.startupTimeoutMs ?? accountInfo.config.startupTimeoutMs ?? 30_000),
|
||||
);
|
||||
const readReceiptsViaDaemon = Boolean(autoStart && sendReadReceipts);
|
||||
let daemonHandle: ReturnType<typeof spawnSignalDaemon> | null = null;
|
||||
|
||||
@@ -315,7 +320,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
await waitForSignalDaemonReady({
|
||||
baseUrl,
|
||||
abortSignal: opts.abortSignal,
|
||||
timeoutMs: 30_000,
|
||||
timeoutMs: startupTimeoutMs,
|
||||
logAfterMs: 10_000,
|
||||
logIntervalMs: 10_000,
|
||||
runtime,
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -151,6 +151,7 @@ export const dispatchTelegramMessage = async ({
|
||||
tableMode,
|
||||
chunkMode,
|
||||
onVoiceRecording: sendRecordVoice,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
|
||||
@@ -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
|
||||
@@ -340,6 +348,7 @@ export const registerTelegramNativeCommands = ({
|
||||
messageThreadId: resolvedThreadId,
|
||||
tableMode,
|
||||
chunkMode,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -108,4 +108,60 @@ describe("deliverReplies", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes link_preview_options when linkPreview is false", async () => {
|
||||
const runtime = { error: vi.fn(), log: vi.fn() };
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 3,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = { api: { sendMessage } } as unknown as Bot;
|
||||
|
||||
await deliverReplies({
|
||||
replies: [{ text: "Check https://example.com" }],
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "off",
|
||||
textLimit: 4000,
|
||||
linkPreview: false,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
link_preview_options: { is_disabled: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not include link_preview_options when linkPreview is true", async () => {
|
||||
const runtime = { error: vi.fn(), log: vi.fn() };
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 4,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = { api: { sendMessage } } as unknown as Bot;
|
||||
|
||||
await deliverReplies({
|
||||
replies: [{ text: "Check https://example.com" }],
|
||||
chatId: "123",
|
||||
token: "tok",
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode: "off",
|
||||
textLimit: 4000,
|
||||
linkPreview: true,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
"123",
|
||||
expect.any(String),
|
||||
expect.not.objectContaining({
|
||||
link_preview_options: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -36,8 +36,11 @@ export async function deliverReplies(params: {
|
||||
chunkMode?: ChunkMode;
|
||||
/** Callback invoked before sending a voice message to switch typing indicator. */
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
/** Controls whether link previews are shown. Default: true (previews enabled). */
|
||||
linkPreview?: boolean;
|
||||
}) {
|
||||
const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId } = params;
|
||||
const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId, linkPreview } =
|
||||
params;
|
||||
const chunkMode = params.chunkMode ?? "length";
|
||||
const threadParams = buildTelegramThreadParams(messageThreadId);
|
||||
let hasReplied = false;
|
||||
@@ -85,6 +88,7 @@ export async function deliverReplies(params: {
|
||||
messageThreadId,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview,
|
||||
});
|
||||
if (replyToId && !hasReplied) {
|
||||
hasReplied = true;
|
||||
@@ -180,6 +184,7 @@ export async function deliverReplies(params: {
|
||||
messageThreadId,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview,
|
||||
});
|
||||
if (replyToId && !hasReplied) {
|
||||
hasReplied = true;
|
||||
@@ -248,17 +253,22 @@ async function sendTelegramText(
|
||||
messageThreadId?: number;
|
||||
textMode?: "markdown" | "html";
|
||||
plainText?: string;
|
||||
linkPreview?: boolean;
|
||||
},
|
||||
): Promise<number | undefined> {
|
||||
const baseParams = buildTelegramSendParams({
|
||||
replyToMessageId: opts?.replyToMessageId,
|
||||
messageThreadId: opts?.messageThreadId,
|
||||
});
|
||||
// Add link_preview_options when link preview is disabled.
|
||||
const linkPreviewEnabled = opts?.linkPreview ?? true;
|
||||
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
|
||||
const textMode = opts?.textMode ?? "markdown";
|
||||
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
|
||||
try {
|
||||
const res = await bot.api.sendMessage(chatId, htmlText, {
|
||||
parse_mode: "HTML",
|
||||
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
||||
...baseParams,
|
||||
});
|
||||
return res.message_id;
|
||||
@@ -268,6 +278,7 @@ async function sendTelegramText(
|
||||
runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`);
|
||||
const fallbackText = opts?.plainText ?? text;
|
||||
const res = await bot.api.sendMessage(chatId, fallbackText, {
|
||||
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
||||
...baseParams,
|
||||
});
|
||||
return res.message_id;
|
||||
|
||||
@@ -152,6 +152,62 @@ describe("sendMessageTelegram", () => {
|
||||
expect(res.messageId).toBe("42");
|
||||
});
|
||||
|
||||
it("adds link_preview_options when previews are disabled in config", async () => {
|
||||
const chatId = "123";
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 7,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
channels: { telegram: { linkPreview: false } },
|
||||
});
|
||||
|
||||
await sendMessageTelegram(chatId, "hi", { token: "tok", api });
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(chatId, "hi", {
|
||||
parse_mode: "HTML",
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps link_preview_options on plain-text fallback when disabled", async () => {
|
||||
const chatId = "123";
|
||||
const parseErr = new Error(
|
||||
"400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9",
|
||||
);
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(parseErr)
|
||||
.mockResolvedValueOnce({
|
||||
message_id: 42,
|
||||
chat: { id: chatId },
|
||||
});
|
||||
const api = { sendMessage } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
};
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
channels: { telegram: { linkPreview: false } },
|
||||
});
|
||||
|
||||
await sendMessageTelegram(chatId, "_oops_", {
|
||||
token: "tok",
|
||||
api,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "<i>oops</i>", {
|
||||
parse_mode: "HTML",
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_oops_", {
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses native fetch for BAN compatibility when api is omitted", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalBun = (globalThis as { Bun?: unknown }).Bun;
|
||||
|
||||
+14
-9
@@ -198,20 +198,25 @@ export async function sendMessageTelegram(
|
||||
});
|
||||
const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode });
|
||||
|
||||
// Resolve link preview setting from config (default: enabled).
|
||||
const linkPreviewEnabled = account.config.linkPreview ?? true;
|
||||
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
|
||||
|
||||
const sendTelegramText = async (
|
||||
rawText: string,
|
||||
params?: Record<string, unknown>,
|
||||
fallbackText?: string,
|
||||
) => {
|
||||
const htmlText = renderHtmlText(rawText);
|
||||
const sendParams = params
|
||||
? {
|
||||
parse_mode: "HTML" as const,
|
||||
...params,
|
||||
}
|
||||
: {
|
||||
parse_mode: "HTML" as const,
|
||||
};
|
||||
const baseParams = params ? { ...params } : {};
|
||||
if (linkPreviewOptions) {
|
||||
baseParams.link_preview_options = linkPreviewOptions;
|
||||
}
|
||||
const hasBaseParams = Object.keys(baseParams).length > 0;
|
||||
const sendParams = {
|
||||
parse_mode: "HTML" as const,
|
||||
...baseParams,
|
||||
};
|
||||
const res = await request(() => api.sendMessage(chatId, htmlText, sendParams), "message").catch(
|
||||
async (err) => {
|
||||
// Telegram rejects malformed HTML (e.g., unsupported tags or entities).
|
||||
@@ -222,7 +227,7 @@ export async function sendMessageTelegram(
|
||||
console.warn(`telegram HTML parse failed, retrying as plain text: ${errText}`);
|
||||
}
|
||||
const fallback = fallbackText ?? rawText;
|
||||
const plainParams = params && Object.keys(params).length > 0 ? { ...params } : undefined;
|
||||
const plainParams = hasBaseParams ? baseParams : undefined;
|
||||
return await request(
|
||||
() =>
|
||||
plainParams
|
||||
|
||||
+242
-7
@@ -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,
|
||||
@@ -104,26 +106,36 @@ describe("tts", () => {
|
||||
expect(isValidOpenAIVoice("alloy ")).toBe(false);
|
||||
expect(isValidOpenAIVoice(" alloy")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts custom voices for custom endpoints", () => {
|
||||
expect(isValidOpenAIVoice("zm_yunxia", { baseUrl: "http://localhost:8880/v1" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidOpenAIModel", () => {
|
||||
it("accepts gpt-4o-mini-tts model", () => {
|
||||
it("accepts supported models", () => {
|
||||
expect(isValidOpenAIModel("gpt-4o-mini-tts")).toBe(true);
|
||||
expect(isValidOpenAIModel("tts-1")).toBe(true);
|
||||
expect(isValidOpenAIModel("tts-1-hd")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other models", () => {
|
||||
expect(isValidOpenAIModel("tts-1")).toBe(false);
|
||||
expect(isValidOpenAIModel("tts-1-hd")).toBe(false);
|
||||
expect(isValidOpenAIModel("invalid")).toBe(false);
|
||||
expect(isValidOpenAIModel("")).toBe(false);
|
||||
expect(isValidOpenAIModel("gpt-4")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts custom models for custom endpoints", () => {
|
||||
expect(isValidOpenAIModel("kokoro", { baseUrl: "http://localhost:8880/v1" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OPENAI_TTS_MODELS", () => {
|
||||
it("contains only gpt-4o-mini-tts", () => {
|
||||
it("contains supported models", () => {
|
||||
expect(OPENAI_TTS_MODELS).toContain("gpt-4o-mini-tts");
|
||||
expect(OPENAI_TTS_MODELS).toHaveLength(1);
|
||||
expect(OPENAI_TTS_MODELS).toContain("tts-1");
|
||||
expect(OPENAI_TTS_MODELS).toContain("tts-1-hd");
|
||||
expect(OPENAI_TTS_MODELS).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("is a non-empty array", () => {
|
||||
@@ -198,6 +210,30 @@ describe("tts", () => {
|
||||
expect(result.overrides.provider).toBe("edge");
|
||||
});
|
||||
|
||||
it("routes custom OpenAI models when custom endpoints are used", () => {
|
||||
const policy = resolveModelOverridePolicy({ enabled: true });
|
||||
const input = "Hello [[tts:provider=openai model=kokoro voice=zm_yunxia]] world";
|
||||
const result = parseTtsDirectives(input, policy, {
|
||||
defaultProvider: "openai",
|
||||
openaiBaseUrl: "http://localhost:8880/v1",
|
||||
});
|
||||
|
||||
expect(result.overrides.openai?.model).toBe("kokoro");
|
||||
expect(result.overrides.openai?.voice).toBe("zm_yunxia");
|
||||
});
|
||||
|
||||
it("routes model overrides to ElevenLabs when provider is ElevenLabs", () => {
|
||||
const policy = resolveModelOverridePolicy({ enabled: true });
|
||||
const input = "Hello [[tts:provider=elevenlabs model=eleven_multilingual_v2]] world";
|
||||
const result = parseTtsDirectives(input, policy, {
|
||||
defaultProvider: "openai",
|
||||
openaiBaseUrl: "http://localhost:8880/v1",
|
||||
});
|
||||
|
||||
expect(result.overrides.elevenlabs?.modelId).toBe("eleven_multilingual_v2");
|
||||
expect(result.overrides.openai?.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps text intact when overrides are disabled", () => {
|
||||
const policy = resolveModelOverridePolicy({ enabled: false });
|
||||
const input = "Hello [[tts:voice=alloy]] world";
|
||||
@@ -355,7 +391,12 @@ describe("tts", () => {
|
||||
};
|
||||
|
||||
const restoreEnv = (snapshot: Record<string, string | undefined>) => {
|
||||
const keys = ["OPENAI_API_KEY", "ELEVENLABS_API_KEY", "XI_API_KEY"] as const;
|
||||
const keys = [
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_TTS_BASE_URL",
|
||||
"ELEVENLABS_API_KEY",
|
||||
"XI_API_KEY",
|
||||
] as const;
|
||||
for (const key of keys) {
|
||||
const value = snapshot[key];
|
||||
if (value === undefined) {
|
||||
@@ -369,6 +410,7 @@ describe("tts", () => {
|
||||
const withEnv = (env: Record<string, string | undefined>, run: () => void) => {
|
||||
const snapshot = {
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
OPENAI_TTS_BASE_URL: process.env.OPENAI_TTS_BASE_URL,
|
||||
ELEVENLABS_API_KEY: process.env.ELEVENLABS_API_KEY,
|
||||
XI_API_KEY: process.env.XI_API_KEY,
|
||||
};
|
||||
@@ -390,6 +432,7 @@ describe("tts", () => {
|
||||
withEnv(
|
||||
{
|
||||
OPENAI_API_KEY: "test-openai-key",
|
||||
OPENAI_TTS_BASE_URL: undefined,
|
||||
ELEVENLABS_API_KEY: undefined,
|
||||
XI_API_KEY: undefined,
|
||||
},
|
||||
@@ -405,6 +448,7 @@ describe("tts", () => {
|
||||
withEnv(
|
||||
{
|
||||
OPENAI_API_KEY: undefined,
|
||||
OPENAI_TTS_BASE_URL: undefined,
|
||||
ELEVENLABS_API_KEY: "test-elevenlabs-key",
|
||||
XI_API_KEY: undefined,
|
||||
},
|
||||
@@ -420,6 +464,7 @@ describe("tts", () => {
|
||||
withEnv(
|
||||
{
|
||||
OPENAI_API_KEY: undefined,
|
||||
OPENAI_TTS_BASE_URL: undefined,
|
||||
ELEVENLABS_API_KEY: undefined,
|
||||
XI_API_KEY: undefined,
|
||||
},
|
||||
@@ -430,5 +475,195 @@ describe("tts", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers OpenAI when a custom endpoint is configured without API keys", () => {
|
||||
withEnv(
|
||||
{
|
||||
OPENAI_API_KEY: undefined,
|
||||
OPENAI_TTS_BASE_URL: "http://localhost:8880/v1",
|
||||
ELEVENLABS_API_KEY: undefined,
|
||||
XI_API_KEY: undefined,
|
||||
},
|
||||
() => {
|
||||
const config = resolveTtsConfig(baseCfg);
|
||||
const provider = getTtsProvider(config, "/tmp/tts-prefs-openai-custom.json");
|
||||
expect(provider).toBe("openai");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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("allows custom OpenAI endpoints without API keys", async () => {
|
||||
const prevPrefs = process.env.CLAWDBOT_TTS_PREFS;
|
||||
const prevBaseUrl = process.env.OPENAI_TTS_BASE_URL;
|
||||
const prevOpenAiKey = process.env.OPENAI_API_KEY;
|
||||
process.env.CLAWDBOT_TTS_PREFS = `/tmp/tts-test-${Date.now()}.json`;
|
||||
process.env.OPENAI_TTS_BASE_URL = "http://localhost:8880/v1";
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
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 = {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } },
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "openai",
|
||||
openai: { model: "kokoro", voice: "zm_yunxia" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await maybeApplyTtsToPayload({
|
||||
payload: { text: "Hello world" },
|
||||
cfg,
|
||||
kind: "final",
|
||||
});
|
||||
|
||||
expect(result.mediaUrl).toBeDefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(url).toBe("http://localhost:8880/v1/audio/speech");
|
||||
expect(
|
||||
(options as { headers?: Record<string, string> })?.headers?.Authorization,
|
||||
).toBeUndefined();
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
process.env.OPENAI_TTS_BASE_URL = prevBaseUrl;
|
||||
if (prevOpenAiKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = prevOpenAiKey;
|
||||
}
|
||||
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;
|
||||
});
|
||||
});
|
||||
});
|
||||
+198
-43
@@ -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,
|
||||
@@ -47,6 +48,7 @@ const DEFAULT_ELEVENLABS_VOICE_ID = "pMsXgVXv3BLzUgSXRplE";
|
||||
const DEFAULT_ELEVENLABS_MODEL_ID = "eleven_multilingual_v2";
|
||||
const DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts";
|
||||
const DEFAULT_OPENAI_VOICE = "alloy";
|
||||
const DEFAULT_OPENAI_TTS_BASE_URL = "https://api.openai.com/v1";
|
||||
const DEFAULT_EDGE_VOICE = "en-US-MichelleNeural";
|
||||
const DEFAULT_EDGE_LANG = "en-US";
|
||||
const DEFAULT_EDGE_OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3";
|
||||
@@ -75,8 +77,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";
|
||||
@@ -100,6 +104,7 @@ export type ResolvedTtsConfig = {
|
||||
};
|
||||
openai: {
|
||||
apiKey?: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
};
|
||||
@@ -123,6 +128,7 @@ export type ResolvedTtsConfig = {
|
||||
|
||||
type TtsUserPrefs = {
|
||||
tts?: {
|
||||
auto?: TtsAutoMode;
|
||||
enabled?: boolean;
|
||||
provider?: TtsProvider;
|
||||
maxLength?: number;
|
||||
@@ -161,6 +167,7 @@ type TtsDirectiveOverrides = {
|
||||
type TtsDirectiveParseResult = {
|
||||
cleanedText: string;
|
||||
ttsText?: string;
|
||||
hasDirective: boolean;
|
||||
overrides: TtsDirectiveOverrides;
|
||||
warnings: string[];
|
||||
};
|
||||
@@ -187,6 +194,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 +236,12 @@ 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 openaiBaseUrl = normalizeOpenAiTtsBaseUrl(
|
||||
raw.openai?.baseUrl?.trim() || process.env.OPENAI_TTS_BASE_URL,
|
||||
);
|
||||
const auto = normalizeTtsAutoMode(raw.auto) ?? (raw.enabled ? "always" : "off");
|
||||
return {
|
||||
enabled: raw.enabled ?? false,
|
||||
auto,
|
||||
mode: raw.mode ?? "final",
|
||||
provider: raw.provider ?? "edge",
|
||||
providerSource,
|
||||
@@ -250,6 +270,7 @@ export function resolveTtsConfig(cfg: ClawdbotConfig): ResolvedTtsConfig {
|
||||
},
|
||||
openai: {
|
||||
apiKey: raw.openai?.apiKey,
|
||||
baseUrl: openaiBaseUrl,
|
||||
model: raw.openai?.model ?? DEFAULT_OPENAI_MODEL,
|
||||
voice: raw.openai?.voice ?? DEFAULT_OPENAI_VOICE,
|
||||
},
|
||||
@@ -279,17 +300,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 +375,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 {
|
||||
@@ -340,7 +401,9 @@ export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): Tt
|
||||
if (prefs.tts?.provider) return prefs.tts.provider;
|
||||
if (config.providerSource === "config") return config.provider;
|
||||
|
||||
if (resolveTtsApiKey(config, "openai")) return "openai";
|
||||
if (resolveTtsApiKey(config, "openai") || isCustomOpenAiTtsEndpoint(config.openai.baseUrl)) {
|
||||
return "openai";
|
||||
}
|
||||
if (resolveTtsApiKey(config, "elevenlabs")) return "elevenlabs";
|
||||
return "edge";
|
||||
}
|
||||
@@ -415,6 +478,12 @@ export function resolveTtsProviderOrder(primary: TtsProvider): TtsProvider[] {
|
||||
|
||||
export function isTtsProviderConfigured(config: ResolvedTtsConfig, provider: TtsProvider): boolean {
|
||||
if (provider === "edge") return config.edge.enabled;
|
||||
if (provider === "openai") {
|
||||
return (
|
||||
Boolean(resolveTtsApiKey(config, provider)) ||
|
||||
isCustomOpenAiTtsEndpoint(config.openai.baseUrl)
|
||||
);
|
||||
}
|
||||
return Boolean(resolveTtsApiKey(config, provider));
|
||||
}
|
||||
|
||||
@@ -428,6 +497,16 @@ function normalizeElevenLabsBaseUrl(baseUrl: string): string {
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function normalizeOpenAiTtsBaseUrl(baseUrl?: string): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) return DEFAULT_OPENAI_TTS_BASE_URL;
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function isCustomOpenAiTtsEndpoint(baseUrl: string): boolean {
|
||||
return normalizeOpenAiTtsBaseUrl(baseUrl) !== DEFAULT_OPENAI_TTS_BASE_URL;
|
||||
}
|
||||
|
||||
function requireInRange(value: number, min: number, max: number, label: string): void {
|
||||
if (!Number.isFinite(value) || value < min || value > max) {
|
||||
throw new Error(`${label} must be between ${min} and ${max}`);
|
||||
@@ -483,17 +562,20 @@ function parseNumberValue(value: string): number | undefined {
|
||||
function parseTtsDirectives(
|
||||
text: string,
|
||||
policy: ResolvedTtsModelOverrides,
|
||||
options: { defaultProvider?: TtsProvider; openaiBaseUrl?: string } = {},
|
||||
): 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,7 +584,32 @@ function parseTtsDirectives(
|
||||
|
||||
const directiveRegex = /\[\[tts:([^\]]+)\]\]/gi;
|
||||
cleanedText = cleanedText.replace(directiveRegex, (_match, body: string) => {
|
||||
hasDirective = true;
|
||||
const tokens = body.split(/\s+/).filter(Boolean);
|
||||
let providerOverride: TtsProvider | undefined;
|
||||
|
||||
for (const token of tokens) {
|
||||
const eqIndex = token.indexOf("=");
|
||||
if (eqIndex === -1) continue;
|
||||
const rawKey = token.slice(0, eqIndex).trim();
|
||||
const rawValue = token.slice(eqIndex + 1).trim();
|
||||
if (!rawKey || !rawValue) continue;
|
||||
if (rawKey.toLowerCase() !== "provider") continue;
|
||||
if (!policy.allowProvider) break;
|
||||
if (rawValue === "openai" || rawValue === "elevenlabs" || rawValue === "edge") {
|
||||
providerOverride = rawValue;
|
||||
} else {
|
||||
warnings.push(`unsupported provider "${rawValue}"`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (providerOverride) {
|
||||
overrides.provider = providerOverride;
|
||||
}
|
||||
|
||||
const providerHint = overrides.provider ?? options.defaultProvider;
|
||||
const openAiContext = { baseUrl: options.openaiBaseUrl };
|
||||
for (const token of tokens) {
|
||||
const eqIndex = token.indexOf("=");
|
||||
if (eqIndex === -1) continue;
|
||||
@@ -513,18 +620,12 @@ function parseTtsDirectives(
|
||||
try {
|
||||
switch (key) {
|
||||
case "provider":
|
||||
if (!policy.allowProvider) break;
|
||||
if (rawValue === "openai" || rawValue === "elevenlabs" || rawValue === "edge") {
|
||||
overrides.provider = rawValue;
|
||||
} else {
|
||||
warnings.push(`unsupported provider "${rawValue}"`);
|
||||
}
|
||||
break;
|
||||
case "voice":
|
||||
case "openai_voice":
|
||||
case "openaivoice":
|
||||
if (!policy.allowVoice) break;
|
||||
if (isValidOpenAIVoice(rawValue)) {
|
||||
if (isValidOpenAIVoice(rawValue, openAiContext)) {
|
||||
overrides.openai = { ...overrides.openai, voice: rawValue };
|
||||
} else {
|
||||
warnings.push(`invalid OpenAI voice "${rawValue}"`);
|
||||
@@ -544,17 +645,40 @@ function parseTtsDirectives(
|
||||
case "model":
|
||||
case "modelid":
|
||||
case "model_id":
|
||||
case "elevenlabs_model":
|
||||
case "elevenlabsmodel":
|
||||
case "openai_model":
|
||||
case "openaimodel":
|
||||
if (!policy.allowModelId) break;
|
||||
if (isValidOpenAIModel(rawValue)) {
|
||||
if (key === "openai_model" || key === "openaimodel") {
|
||||
if (isValidOpenAIModel(rawValue, openAiContext)) {
|
||||
overrides.openai = { ...overrides.openai, model: rawValue };
|
||||
} else {
|
||||
warnings.push(`invalid OpenAI model "${rawValue}"`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (providerHint === "openai") {
|
||||
if (isValidOpenAIModel(rawValue, openAiContext)) {
|
||||
overrides.openai = { ...overrides.openai, model: rawValue };
|
||||
} else {
|
||||
warnings.push(`invalid OpenAI model "${rawValue}"`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (providerHint === "elevenlabs") {
|
||||
overrides.elevenlabs = { ...overrides.elevenlabs, modelId: rawValue };
|
||||
break;
|
||||
}
|
||||
if (isValidOpenAIModel(rawValue, openAiContext)) {
|
||||
overrides.openai = { ...overrides.openai, model: rawValue };
|
||||
} else {
|
||||
overrides.elevenlabs = { ...overrides.elevenlabs, modelId: rawValue };
|
||||
}
|
||||
break;
|
||||
case "elevenlabs_model":
|
||||
case "elevenlabsmodel":
|
||||
if (!policy.allowModelId) break;
|
||||
overrides.elevenlabs = { ...overrides.elevenlabs, modelId: rawValue };
|
||||
break;
|
||||
case "stability":
|
||||
if (!policy.allowVoiceSettings) break;
|
||||
{
|
||||
@@ -672,12 +796,13 @@ function parseTtsDirectives(
|
||||
return {
|
||||
cleanedText,
|
||||
ttsText: overrides.ttsText,
|
||||
hasDirective,
|
||||
overrides,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts"] as const;
|
||||
export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts", "tts-1", "tts-1-hd"] as const;
|
||||
export const OPENAI_TTS_VOICES = [
|
||||
"alloy",
|
||||
"ash",
|
||||
@@ -691,12 +816,15 @@ export const OPENAI_TTS_VOICES = [
|
||||
] as const;
|
||||
|
||||
type OpenAiTtsVoice = (typeof OPENAI_TTS_VOICES)[number];
|
||||
type OpenAiTtsValidationContext = { baseUrl?: string };
|
||||
|
||||
function isValidOpenAIModel(model: string): boolean {
|
||||
function isValidOpenAIModel(model: string, context: OpenAiTtsValidationContext = {}): boolean {
|
||||
if (context.baseUrl && isCustomOpenAiTtsEndpoint(context.baseUrl)) return true;
|
||||
return OPENAI_TTS_MODELS.includes(model as (typeof OPENAI_TTS_MODELS)[number]);
|
||||
}
|
||||
|
||||
function isValidOpenAIVoice(voice: string): voice is OpenAiTtsVoice {
|
||||
function isValidOpenAIVoice(voice: string, context: OpenAiTtsValidationContext = {}): boolean {
|
||||
if (context.baseUrl && isCustomOpenAiTtsEndpoint(context.baseUrl)) return true;
|
||||
return OPENAI_TTS_VOICES.includes(voice as OpenAiTtsVoice);
|
||||
}
|
||||
|
||||
@@ -904,18 +1032,21 @@ async function elevenLabsTTS(params: {
|
||||
|
||||
async function openaiTTS(params: {
|
||||
text: string;
|
||||
apiKey: string;
|
||||
apiKey?: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
responseFormat: "mp3" | "opus";
|
||||
timeoutMs: number;
|
||||
}): Promise<Buffer> {
|
||||
const { text, apiKey, model, voice, responseFormat, timeoutMs } = params;
|
||||
const { text, model, voice, responseFormat, timeoutMs } = params;
|
||||
const apiKey = params.apiKey?.trim();
|
||||
const baseUrl = normalizeOpenAiTtsBaseUrl(params.baseUrl);
|
||||
|
||||
if (!isValidOpenAIModel(model)) {
|
||||
if (!isValidOpenAIModel(model, { baseUrl })) {
|
||||
throw new Error(`Invalid model: ${model}`);
|
||||
}
|
||||
if (!isValidOpenAIVoice(voice)) {
|
||||
if (!isValidOpenAIVoice(voice, { baseUrl })) {
|
||||
throw new Error(`Invalid voice: ${voice}`);
|
||||
}
|
||||
|
||||
@@ -923,12 +1054,15 @@ async function openaiTTS(params: {
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.openai.com/v1/audio/speech", {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
const response = await fetch(`${baseUrl}/audio/speech`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
input: text,
|
||||
@@ -1078,13 +1212,19 @@ export async function textToSpeech(params: {
|
||||
}
|
||||
|
||||
const apiKey = resolveTtsApiKey(config, provider);
|
||||
if (!apiKey) {
|
||||
const allowMissingKey =
|
||||
provider === "openai" && isCustomOpenAiTtsEndpoint(config.openai.baseUrl);
|
||||
if (!apiKey && !allowMissingKey) {
|
||||
lastError = `No API key for ${provider}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
let audioBuffer: Buffer;
|
||||
if (provider === "elevenlabs") {
|
||||
if (!apiKey) {
|
||||
lastError = "No API key for elevenlabs";
|
||||
continue;
|
||||
}
|
||||
const voiceIdOverride = params.overrides?.elevenlabs?.voiceId;
|
||||
const modelIdOverride = params.overrides?.elevenlabs?.modelId;
|
||||
const voiceSettings = {
|
||||
@@ -1113,6 +1253,7 @@ export async function textToSpeech(params: {
|
||||
audioBuffer = await openaiTTS({
|
||||
text: params.text,
|
||||
apiKey,
|
||||
baseUrl: config.openai.baseUrl,
|
||||
model: openaiModelOverride ?? config.openai.model,
|
||||
voice: openaiVoiceOverride ?? config.openai.voice,
|
||||
responseFormat: output.openai,
|
||||
@@ -1156,16 +1297,24 @@ 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 defaultProvider = getTtsProvider(config, prefsPath);
|
||||
const text = params.payload.text ?? "";
|
||||
const directives = parseTtsDirectives(text, config.modelOverrides);
|
||||
const directives = parseTtsDirectives(text, config.modelOverrides, {
|
||||
defaultProvider,
|
||||
openaiBaseUrl: config.openai.baseUrl,
|
||||
});
|
||||
if (directives.warnings.length > 0) {
|
||||
logVerbose(`TTS: ignored directive overrides (${directives.warnings.join("; ")})`);
|
||||
}
|
||||
@@ -1183,6 +1332,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 +1352,7 @@ export async function maybeApplyTtsToPayload(params: {
|
||||
logVerbose(
|
||||
`TTS: skipping long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
|
||||
);
|
||||
return params.payload;
|
||||
return nextPayload;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1219,7 +1374,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);
|
||||
|
||||
@@ -124,6 +124,7 @@ export function connectGateway(host: GatewayHost) {
|
||||
mode: "webchat",
|
||||
onHello: (hello) => {
|
||||
host.connected = true;
|
||||
host.lastError = null;
|
||||
host.hello = hello;
|
||||
applySnapshot(host, hello);
|
||||
void loadAssistantIdentity(host as unknown as ClawdbotApp);
|
||||
@@ -134,7 +135,10 @@ export function connectGateway(host: GatewayHost) {
|
||||
},
|
||||
onClose: ({ code, reason }) => {
|
||||
host.connected = false;
|
||||
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
|
||||
// Code 1012 = Service Restart (expected during config saves, don't show as error)
|
||||
if (code !== 1012) {
|
||||
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
|
||||
}
|
||||
},
|
||||
onEvent: (evt) => handleGatewayEvent(host, evt),
|
||||
onGap: ({ expected, received }) => {
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("config view", () => {
|
||||
onSubsectionChange: vi.fn(),
|
||||
});
|
||||
|
||||
it("disables save when form is unsafe", () => {
|
||||
it("allows save when form is unsafe", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfig({
|
||||
@@ -59,6 +59,28 @@ describe("config view", () => {
|
||||
container,
|
||||
);
|
||||
|
||||
const saveButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((btn) => btn.textContent?.trim() === "Save") as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
expect(saveButton).not.toBeUndefined();
|
||||
expect(saveButton?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("disables save when schema is missing", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfig({
|
||||
...baseProps(),
|
||||
schema: null,
|
||||
formMode: "form",
|
||||
formValue: { gateway: { mode: "local" } },
|
||||
originalValue: {},
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const saveButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((btn) => btn.textContent?.trim() === "Save") as
|
||||
|
||||
@@ -233,9 +233,10 @@ export function renderConfig(props: ConfigProps) {
|
||||
const hasRawChanges = props.formMode === "raw" && props.raw !== props.originalRaw;
|
||||
const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges;
|
||||
|
||||
// Save/apply buttons require actual changes to be enabled
|
||||
// Save/apply buttons require actual changes to be enabled.
|
||||
// Note: formUnsafe warns about unsupported schema paths but shouldn't block saving.
|
||||
const canSaveForm =
|
||||
Boolean(props.formValue) && !props.loading && !formUnsafe;
|
||||
Boolean(props.formValue) && !props.loading && Boolean(analysis.schema);
|
||||
const canSave =
|
||||
props.connected &&
|
||||
!props.saving &&
|
||||
|
||||
Reference in new issue
Block a user