Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b89efef749 | ||
|
|
8a3b2eefa3 |
No files matched your search
@@ -18,7 +18,6 @@
|
|||||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
- 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)`).
|
- 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 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.
|
- 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.
|
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||||
|
|||||||
@@ -11,43 +11,33 @@ Docs: https://docs.clawd.bot
|
|||||||
### Changes
|
### Changes
|
||||||
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
|
- 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
|
- 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: 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 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: update Fly.io guide notes.
|
||||||
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
- 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
|
- 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.
|
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||||
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
|
||||||
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
- 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: hide internal `message_id` hints in chat bubbles.
|
||||||
- Web UI: allow form saves with unsupported schema paths while blocking missing schema; clear stale disconnect banners on reconnect. (#1678) Thanks @Glucksberg.
|
|
||||||
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||||
- Heartbeat: normalize target identifiers for consistent routing.
|
- Heartbeat: normalize target identifiers for consistent routing.
|
||||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
- 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: 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.
|
- 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: 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.
|
- 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: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||||
- Agents: use the active auth profile for auto-compaction recovery.
|
- Agents: use the active auth profile for auto-compaction recovery.
|
||||||
- Models: default missing custom provider fields so minimal configs are accepted.
|
- Models: default missing custom provider fields so minimal configs are accepted.
|
||||||
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
- 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: 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)
|
|
||||||
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
- 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)
|
- 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: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||||
- Google Chat: normalize space targets without double `spaces/` prefix.
|
- Google Chat: normalize space targets without double `spaces/` prefix.
|
||||||
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
- 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
|
## 2026.1.23-1
|
||||||
|
|
||||||
|
|||||||
@@ -74,22 +74,6 @@ 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.
|
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)
|
## Access control (DMs + groups)
|
||||||
DMs:
|
DMs:
|
||||||
- Default: `channels.signal.dmPolicy = "pairing"`.
|
- Default: `channels.signal.dmPolicy = "pairing"`.
|
||||||
@@ -158,7 +142,6 @@ Provider options:
|
|||||||
- `channels.signal.httpUrl`: full daemon URL (overrides host/port).
|
- `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.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.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.receiveMode`: `on-start | manual`.
|
||||||
- `channels.signal.ignoreAttachments`: skip attachment downloads.
|
- `channels.signal.ignoreAttachments`: skip attachment downloads.
|
||||||
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
||||||
|
|||||||
@@ -120,13 +120,6 @@ 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:
|
Notes:
|
||||||
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
|
- 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).
|
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
|
||||||
|
|||||||
@@ -22,4 +22,3 @@ clawdbot channels status --probe
|
|||||||
|
|
||||||
## Telegram quick fixes
|
## 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 `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,7 +31,6 @@ These files live under the workspace (`agents.defaults.workspace`, default
|
|||||||
- Decisions, preferences, and durable facts go to `MEMORY.md`.
|
- Decisions, preferences, and durable facts go to `MEMORY.md`.
|
||||||
- Day-to-day notes and running context go to `memory/YYYY-MM-DD.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).
|
- 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.
|
- If you want something to stick, **ask the bot to write it** into memory.
|
||||||
|
|
||||||
## Automatic memory flush (pre-compaction ping)
|
## Automatic memory flush (pre-compaction ping)
|
||||||
|
|||||||
@@ -1048,7 +1048,6 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"platforms",
|
"platforms",
|
||||||
"platforms/macos",
|
"platforms/macos",
|
||||||
"platforms/macos-vm",
|
|
||||||
"platforms/ios",
|
"platforms/ios",
|
||||||
"platforms/android",
|
"platforms/android",
|
||||||
"platforms/windows",
|
"platforms/windows",
|
||||||
|
|||||||
@@ -46,9 +46,6 @@ better forms without hard-coding config knowledge.
|
|||||||
Use `config.apply` to validate + write the full config and restart the Gateway in one step.
|
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.
|
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:
|
Params:
|
||||||
- `raw` (string) — JSON5 payload for the entire config
|
- `raw` (string) — JSON5 payload for the entire config
|
||||||
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
|
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
|
||||||
@@ -1509,7 +1506,7 @@ voice notes; other channels send MP3 audio.
|
|||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
auto: "always", // off | always | inbound | tagged
|
enabled: true,
|
||||||
mode: "final", // final | all (include tool/block replies)
|
mode: "final", // final | all (include tool/block replies)
|
||||||
provider: "elevenlabs",
|
provider: "elevenlabs",
|
||||||
summaryModel: "openai/gpt-4.1-mini",
|
summaryModel: "openai/gpt-4.1-mini",
|
||||||
@@ -1546,10 +1543,8 @@ voice notes; other channels send MP3 audio.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `messages.tts.auto` controls auto‑TTS (`off`, `always`, `inbound`, `tagged`).
|
- `messages.tts.enabled` can be overridden by local user prefs (see `/tts on`, `/tts off`).
|
||||||
- `/tts off|always|inbound|tagged` sets the per‑session auto mode (overrides config).
|
- `prefsPath` stores local overrides (enabled/provider/limit/summarize).
|
||||||
- `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.
|
- `maxTextLength` is a hard cap for TTS input; summaries are truncated to fit.
|
||||||
- `summaryModel` overrides `agents.defaults.model.primary` for auto-summary.
|
- `summaryModel` overrides `agents.defaults.model.primary` for auto-summary.
|
||||||
- Accepts `provider/model` or an alias from `agents.defaults.models`.
|
- Accepts `provider/model` or an alias from `agents.defaults.models`.
|
||||||
|
|||||||
+185
-587
File diff suppressed because it is too large.
Load diff
@@ -1,275 +0,0 @@
|
|||||||
---
|
|
||||||
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`)
|
- `/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`)
|
- `/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)
|
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
|
||||||
- `/tts off|always|inbound|tagged|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
|
- `/tts on|off|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
|
||||||
- Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works.
|
- Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works.
|
||||||
- `/stop`
|
- `/stop`
|
||||||
- `/restart`
|
- `/restart`
|
||||||
|
|||||||
+10
-29
@@ -53,8 +53,8 @@ so that provider must also be authenticated if you enable summaries.
|
|||||||
|
|
||||||
## Is it enabled by default?
|
## Is it enabled by default?
|
||||||
|
|
||||||
No. Auto‑TTS is **off** by default. Enable it in config with
|
No. TTS is **disabled** by default. Enable it in config or with `/tts on`,
|
||||||
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
|
which writes a local preference override.
|
||||||
|
|
||||||
Edge TTS **is** enabled by default once TTS is on, and is used automatically
|
Edge TTS **is** enabled by default once TTS is on, and is used automatically
|
||||||
when no OpenAI or ElevenLabs API keys are available.
|
when no OpenAI or ElevenLabs API keys are available.
|
||||||
@@ -70,7 +70,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
|||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
auto: "always",
|
enabled: true,
|
||||||
provider: "elevenlabs"
|
provider: "elevenlabs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
|||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
auto: "always",
|
enabled: true,
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
summaryModel: "openai/gpt-4.1-mini",
|
summaryModel: "openai/gpt-4.1-mini",
|
||||||
modelOverrides: {
|
modelOverrides: {
|
||||||
@@ -121,7 +121,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
|||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
auto: "always",
|
enabled: true,
|
||||||
provider: "edge",
|
provider: "edge",
|
||||||
edge: {
|
edge: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -156,7 +156,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
|||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
auto: "always",
|
enabled: true,
|
||||||
maxTextLength: 4000,
|
maxTextLength: 4000,
|
||||||
timeoutMs: 30000,
|
timeoutMs: 30000,
|
||||||
prefsPath: "~/.clawdbot/settings/tts.json"
|
prefsPath: "~/.clawdbot/settings/tts.json"
|
||||||
@@ -165,25 +165,13 @@ 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
|
### Disable auto-summary for long replies
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
auto: "always"
|
enabled: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,10 +185,7 @@ Then run:
|
|||||||
|
|
||||||
### Notes on fields
|
### Notes on fields
|
||||||
|
|
||||||
- `auto`: auto‑TTS mode (`off`, `always`, `inbound`, `tagged`).
|
- `enabled`: master toggle (default `false`; local prefs can override).
|
||||||
- `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).
|
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
|
||||||
- `provider`: `"elevenlabs"`, `"openai"`, or `"edge"` (fallback is automatic).
|
- `provider`: `"elevenlabs"`, `"openai"`, or `"edge"` (fallback is automatic).
|
||||||
- If `provider` is **unset**, Clawdbot prefers `openai` (if key), then `elevenlabs` (if key),
|
- If `provider` is **unset**, Clawdbot prefers `openai` (if key), then `elevenlabs` (if key),
|
||||||
@@ -210,7 +195,7 @@ Then run:
|
|||||||
- `modelOverrides`: allow the model to emit TTS directives (on by default).
|
- `modelOverrides`: allow the model to emit TTS directives (on by default).
|
||||||
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
|
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
|
||||||
- `timeoutMs`: request timeout (ms).
|
- `timeoutMs`: request timeout (ms).
|
||||||
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
|
- `prefsPath`: override the local prefs JSON path.
|
||||||
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
|
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
|
||||||
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
|
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
|
||||||
- `elevenlabs.voiceSettings`:
|
- `elevenlabs.voiceSettings`:
|
||||||
@@ -233,7 +218,6 @@ Then run:
|
|||||||
## Model-driven overrides (default on)
|
## Model-driven overrides (default on)
|
||||||
|
|
||||||
By default, the model **can** emit TTS directives for a single reply.
|
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
|
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
|
for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
|
||||||
@@ -354,10 +338,8 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
|
|||||||
`/voice` as the native command there. Text `/tts ...` still works.
|
`/voice` as the native command there. Text `/tts ...` still works.
|
||||||
|
|
||||||
```
|
```
|
||||||
|
/tts on
|
||||||
/tts off
|
/tts off
|
||||||
/tts always
|
|
||||||
/tts inbound
|
|
||||||
/tts tagged
|
|
||||||
/tts status
|
/tts status
|
||||||
/tts provider openai
|
/tts provider openai
|
||||||
/tts limit 2000
|
/tts limit 2000
|
||||||
@@ -368,7 +350,6 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
|
|||||||
Notes:
|
Notes:
|
||||||
- Commands require an authorized sender (allowlist/owner rules still apply).
|
- Commands require an authorized sender (allowlist/owner rules still apply).
|
||||||
- `commands.text` or native command registration must be enabled.
|
- `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.
|
- `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).
|
- `/tts audio` generates a one-off audio reply (does not toggle TTS on).
|
||||||
|
|
||||||
|
|||||||
@@ -118,14 +118,6 @@ Other Gateway slash commands (for example, `/context`) are forwarded to the Gate
|
|||||||
- `--deliver`: Deliver assistant replies to the provider (default off)
|
- `--deliver`: Deliver assistant replies to the provider (default off)
|
||||||
- `--thinking <level>`: Override thinking level for sends
|
- `--thinking <level>`: Override thinking level for sends
|
||||||
- `--timeout-ms <ms>`: Agent timeout in ms (defaults to `agents.defaults.timeoutSeconds`)
|
- `--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)
|
- `--history-limit <n>`: History entries to load (default 200)
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
@@ -23,14 +23,11 @@ const serialRuns = runs.filter((entry) => entry.name === "gateway");
|
|||||||
|
|
||||||
const children = new Set();
|
const children = new Set();
|
||||||
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
|
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 overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10);
|
||||||
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
||||||
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||||
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
||||||
// Keep worker counts predictable for local runs and for CI on macOS.
|
const maxWorkers = isCI ? null : resolvedOverride ?? perRunWorkers;
|
||||||
// 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 = [
|
const WARNING_SUPPRESSION_FLAGS = [
|
||||||
"--disable-warning=ExperimentalWarning",
|
"--disable-warning=ExperimentalWarning",
|
||||||
|
|||||||
-20
@@ -121,26 +121,6 @@ describe("getDmHistoryLimitFromSessionKey", () => {
|
|||||||
} as ClawdbotConfig;
|
} as ClawdbotConfig;
|
||||||
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123", config)).toBe(10);
|
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", () => {
|
it("returns undefined for non-dm session kinds", () => {
|
||||||
const config = {
|
const config = {
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
|
|||||||
},
|
},
|
||||||
streamSimple: (model: { api: string; provider: string; id: string }) => {
|
streamSimple: (model: { api: string; provider: string; id: string }) => {
|
||||||
const stream = new actual.AssistantMessageEventStream();
|
const stream = new actual.AssistantMessageEventStream();
|
||||||
queueMicrotask(() => {
|
setTimeout(() => {
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "done",
|
type: "done",
|
||||||
reason: "stop",
|
reason: "stop",
|
||||||
@@ -80,7 +80,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
|
|||||||
: buildAssistantMessage(model),
|
: buildAssistantMessage(model),
|
||||||
});
|
});
|
||||||
stream.end();
|
stream.end();
|
||||||
});
|
}, 0);
|
||||||
return stream;
|
return stream;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -213,7 +213,7 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
|
|
||||||
itIfNotWin32(
|
itIfNotWin32(
|
||||||
"persists the first user message before assistant output",
|
"persists the first user message before assistant output",
|
||||||
{ timeout: 120_000 },
|
{ timeout: 60_000 },
|
||||||
async () => {
|
async () => {
|
||||||
const sessionFile = nextSessionFile();
|
const sessionFile = nextSessionFile();
|
||||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||||
|
|||||||
@@ -2,13 +2,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
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
|
* Limits conversation history to the last N user turns (and their associated
|
||||||
* assistant responses). This reduces token usage for long-running DM sessions.
|
* assistant responses). This reduces token usage for long-running DM sessions.
|
||||||
@@ -51,8 +44,7 @@ export function getDmHistoryLimitFromSessionKey(
|
|||||||
if (!provider) return undefined;
|
if (!provider) return undefined;
|
||||||
|
|
||||||
const kind = providerParts[1]?.toLowerCase();
|
const kind = providerParts[1]?.toLowerCase();
|
||||||
const userIdRaw = providerParts.slice(2).join(":");
|
const userId = providerParts.slice(2).join(":");
|
||||||
const userId = stripThreadSuffix(userIdRaw);
|
|
||||||
if (kind !== "dm") return undefined;
|
if (kind !== "dm") return undefined;
|
||||||
|
|
||||||
const getLimit = (
|
const getLimit = (
|
||||||
|
|||||||
@@ -252,7 +252,9 @@ function isValidIsoDate(value: string): boolean {
|
|||||||
|
|
||||||
const date = new Date(Date.UTC(year, month - 1, day));
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
return (
|
return (
|
||||||
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
date.getUTCFullYear() === year &&
|
||||||
|
date.getUTCMonth() === month - 1 &&
|
||||||
|
date.getUTCDate() === day
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,20 +6,19 @@ import {
|
|||||||
getTtsMaxLength,
|
getTtsMaxLength,
|
||||||
getTtsProvider,
|
getTtsProvider,
|
||||||
isSummarizationEnabled,
|
isSummarizationEnabled,
|
||||||
|
isTtsEnabled,
|
||||||
isTtsProviderConfigured,
|
isTtsProviderConfigured,
|
||||||
normalizeTtsAutoMode,
|
|
||||||
resolveTtsAutoMode,
|
|
||||||
resolveTtsApiKey,
|
resolveTtsApiKey,
|
||||||
resolveTtsConfig,
|
resolveTtsConfig,
|
||||||
resolveTtsPrefsPath,
|
resolveTtsPrefsPath,
|
||||||
resolveTtsProviderOrder,
|
resolveTtsProviderOrder,
|
||||||
setLastTtsAttempt,
|
setLastTtsAttempt,
|
||||||
setSummarizationEnabled,
|
setSummarizationEnabled,
|
||||||
|
setTtsEnabled,
|
||||||
setTtsMaxLength,
|
setTtsMaxLength,
|
||||||
setTtsProvider,
|
setTtsProvider,
|
||||||
textToSpeech,
|
textToSpeech,
|
||||||
} from "../../tts/tts.js";
|
} from "../../tts/tts.js";
|
||||||
import { updateSessionStore } from "../../config/sessions.js";
|
|
||||||
|
|
||||||
type ParsedTtsCommand = {
|
type ParsedTtsCommand = {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -40,9 +39,9 @@ function ttsUsage(): ReplyPayload {
|
|||||||
// Keep usage in one place so help/validation stays consistent.
|
// Keep usage in one place so help/validation stays consistent.
|
||||||
return {
|
return {
|
||||||
text:
|
text:
|
||||||
"⚙️ Usage: /tts <off|always|inbound|tagged|status|provider|limit|summary|audio> [value]" +
|
"⚙️ Usage: /tts <on|off|status|provider|limit|summary|audio> [value]" +
|
||||||
"\nExamples:\n" +
|
"\nExamples:\n" +
|
||||||
"/tts always\n" +
|
"/tts on\n" +
|
||||||
"/tts provider openai\n" +
|
"/tts provider openai\n" +
|
||||||
"/tts provider edge\n" +
|
"/tts provider edge\n" +
|
||||||
"/tts limit 2000\n" +
|
"/tts limit 2000\n" +
|
||||||
@@ -72,30 +71,14 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
|||||||
return { shouldContinue: false, reply: ttsUsage() };
|
return { shouldContinue: false, reply: ttsUsage() };
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedAuto = normalizeTtsAutoMode(
|
if (action === "on") {
|
||||||
action === "on" ? "always" : action === "off" ? "off" : action,
|
setTtsEnabled(prefsPath, true);
|
||||||
);
|
return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
|
||||||
if (requestedAuto) {
|
}
|
||||||
const entry = params.sessionEntry;
|
|
||||||
const sessionKey = params.sessionKey;
|
if (action === "off") {
|
||||||
const store = params.sessionStore;
|
setTtsEnabled(prefsPath, false);
|
||||||
if (entry && store && sessionKey) {
|
return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
|
||||||
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") {
|
if (action === "audio") {
|
||||||
@@ -229,9 +212,7 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "status") {
|
if (action === "status") {
|
||||||
const sessionAuto = params.sessionEntry?.ttsAuto;
|
const enabled = isTtsEnabled(config, prefsPath);
|
||||||
const autoMode = resolveTtsAutoMode({ config, prefsPath, sessionAuto });
|
|
||||||
const enabled = autoMode !== "off";
|
|
||||||
const provider = getTtsProvider(config, prefsPath);
|
const provider = getTtsProvider(config, prefsPath);
|
||||||
const hasKey = isTtsProviderConfigured(config, provider);
|
const hasKey = isTtsProviderConfigured(config, provider);
|
||||||
const providerStatus =
|
const providerStatus =
|
||||||
@@ -245,10 +226,9 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
|||||||
const maxLength = getTtsMaxLength(prefsPath);
|
const maxLength = getTtsMaxLength(prefsPath);
|
||||||
const summarize = isSummarizationEnabled(prefsPath);
|
const summarize = isSummarizationEnabled(prefsPath);
|
||||||
const last = getLastTtsAttempt();
|
const last = getLastTtsAttempt();
|
||||||
const autoLabel = sessionAuto ? `${autoMode} (session)` : autoMode;
|
|
||||||
const lines = [
|
const lines = [
|
||||||
"📊 TTS status",
|
"📊 TTS status",
|
||||||
`Auto: ${enabled ? autoLabel : "off"}`,
|
`State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
|
||||||
`Provider: ${provider} (${providerStatus})`,
|
`Provider: ${provider} (${providerStatus})`,
|
||||||
`Text limit: ${maxLength} chars`,
|
`Text limit: ${maxLength} chars`,
|
||||||
`Auto-summary: ${summarize ? "on" : "off"}`,
|
`Auto-summary: ${summarize ? "on" : "off"}`,
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
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 { logVerbose } from "../../globals.js";
|
||||||
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||||
import {
|
import {
|
||||||
@@ -16,55 +14,7 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
|
|||||||
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
|
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
|
||||||
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
|
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
|
||||||
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
||||||
import { maybeApplyTtsToPayload, normalizeTtsAutoMode } from "../../tts/tts.js";
|
import { maybeApplyTtsToPayload } 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 = {
|
export type DispatchFromConfigResult = {
|
||||||
queuedFinal: boolean;
|
queuedFinal: boolean;
|
||||||
@@ -131,8 +81,6 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
|
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
|
||||||
}
|
}
|
||||||
|
|
||||||
const inboundAudio = isInboundAudioContext(ctx);
|
|
||||||
const sessionTtsAuto = resolveSessionTtsAuto(ctx, cfg);
|
|
||||||
const hookRunner = getGlobalHookRunner();
|
const hookRunner = getGlobalHookRunner();
|
||||||
if (hookRunner?.hasHooks("message_received")) {
|
if (hookRunner?.hasHooks("message_received")) {
|
||||||
const timestamp =
|
const timestamp =
|
||||||
@@ -275,8 +223,6 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
cfg,
|
cfg,
|
||||||
channel: ttsChannel,
|
channel: ttsChannel,
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
inboundAudio,
|
|
||||||
ttsAuto: sessionTtsAuto,
|
|
||||||
});
|
});
|
||||||
if (shouldRouteToOriginating) {
|
if (shouldRouteToOriginating) {
|
||||||
await sendPayloadAsync(ttsPayload);
|
await sendPayloadAsync(ttsPayload);
|
||||||
@@ -293,8 +239,6 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
cfg,
|
cfg,
|
||||||
channel: ttsChannel,
|
channel: ttsChannel,
|
||||||
kind: "block",
|
kind: "block",
|
||||||
inboundAudio,
|
|
||||||
ttsAuto: sessionTtsAuto,
|
|
||||||
});
|
});
|
||||||
if (shouldRouteToOriginating) {
|
if (shouldRouteToOriginating) {
|
||||||
await sendPayloadAsync(ttsPayload, context?.abortSignal);
|
await sendPayloadAsync(ttsPayload, context?.abortSignal);
|
||||||
@@ -318,8 +262,6 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
cfg,
|
cfg,
|
||||||
channel: ttsChannel,
|
channel: ttsChannel,
|
||||||
kind: "final",
|
kind: "final",
|
||||||
inboundAudio,
|
|
||||||
ttsAuto: sessionTtsAuto,
|
|
||||||
});
|
});
|
||||||
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
||||||
// Route final reply to originating channel.
|
// Route final reply to originating channel.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import path from "node:path";
|
|||||||
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import type { TtsAutoMode } from "../../config/types.tts.js";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_RESET_TRIGGERS,
|
DEFAULT_RESET_TRIGGERS,
|
||||||
deriveSessionMetaPatch,
|
deriveSessionMetaPatch,
|
||||||
@@ -129,7 +128,6 @@ export async function initSessionState(params: {
|
|||||||
let persistedThinking: string | undefined;
|
let persistedThinking: string | undefined;
|
||||||
let persistedVerbose: string | undefined;
|
let persistedVerbose: string | undefined;
|
||||||
let persistedReasoning: string | undefined;
|
let persistedReasoning: string | undefined;
|
||||||
let persistedTtsAuto: TtsAutoMode | undefined;
|
|
||||||
let persistedModelOverride: string | undefined;
|
let persistedModelOverride: string | undefined;
|
||||||
let persistedProviderOverride: string | undefined;
|
let persistedProviderOverride: string | undefined;
|
||||||
|
|
||||||
@@ -222,7 +220,6 @@ export async function initSessionState(params: {
|
|||||||
persistedThinking = entry.thinkingLevel;
|
persistedThinking = entry.thinkingLevel;
|
||||||
persistedVerbose = entry.verboseLevel;
|
persistedVerbose = entry.verboseLevel;
|
||||||
persistedReasoning = entry.reasoningLevel;
|
persistedReasoning = entry.reasoningLevel;
|
||||||
persistedTtsAuto = entry.ttsAuto;
|
|
||||||
persistedModelOverride = entry.modelOverride;
|
persistedModelOverride = entry.modelOverride;
|
||||||
persistedProviderOverride = entry.providerOverride;
|
persistedProviderOverride = entry.providerOverride;
|
||||||
} else {
|
} else {
|
||||||
@@ -261,7 +258,6 @@ export async function initSessionState(params: {
|
|||||||
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
|
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
|
||||||
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
|
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
|
||||||
reasoningLevel: persistedReasoning ?? baseEntry?.reasoningLevel,
|
reasoningLevel: persistedReasoning ?? baseEntry?.reasoningLevel,
|
||||||
ttsAuto: persistedTtsAuto ?? baseEntry?.ttsAuto,
|
|
||||||
responseUsage: baseEntry?.responseUsage,
|
responseUsage: baseEntry?.responseUsage,
|
||||||
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
||||||
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
getTtsMaxLength,
|
getTtsMaxLength,
|
||||||
getTtsProvider,
|
getTtsProvider,
|
||||||
isSummarizationEnabled,
|
isSummarizationEnabled,
|
||||||
resolveTtsAutoMode,
|
isTtsEnabled,
|
||||||
resolveTtsConfig,
|
resolveTtsConfig,
|
||||||
resolveTtsPrefsPath,
|
resolveTtsPrefsPath,
|
||||||
} from "../tts/tts.js";
|
} from "../tts/tts.js";
|
||||||
@@ -252,23 +252,15 @@ const formatMediaUnderstandingLine = (decisions?: MediaUnderstandingDecision[])
|
|||||||
return `📎 Media: ${parts.join(" · ")}`;
|
return `📎 Media: ${parts.join(" · ")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatVoiceModeLine = (
|
const formatVoiceModeLine = (config?: ClawdbotConfig): string | null => {
|
||||||
config?: ClawdbotConfig,
|
|
||||||
sessionEntry?: SessionEntry,
|
|
||||||
): string | null => {
|
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
const ttsConfig = resolveTtsConfig(config);
|
const ttsConfig = resolveTtsConfig(config);
|
||||||
const prefsPath = resolveTtsPrefsPath(ttsConfig);
|
const prefsPath = resolveTtsPrefsPath(ttsConfig);
|
||||||
const autoMode = resolveTtsAutoMode({
|
if (!isTtsEnabled(ttsConfig, prefsPath)) return null;
|
||||||
config: ttsConfig,
|
|
||||||
prefsPath,
|
|
||||||
sessionAuto: sessionEntry?.ttsAuto,
|
|
||||||
});
|
|
||||||
if (autoMode === "off") return null;
|
|
||||||
const provider = getTtsProvider(ttsConfig, prefsPath);
|
const provider = getTtsProvider(ttsConfig, prefsPath);
|
||||||
const maxLength = getTtsMaxLength(prefsPath);
|
const maxLength = getTtsMaxLength(prefsPath);
|
||||||
const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
|
const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
|
||||||
return `🔊 Voice: ${autoMode} · provider=${provider} · limit=${maxLength} · summary=${summarize}`;
|
return `🔊 Voice: on · provider=${provider} · limit=${maxLength} · summary=${summarize}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildStatusMessage(args: StatusArgs): string {
|
export function buildStatusMessage(args: StatusArgs): string {
|
||||||
@@ -406,7 +398,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
const usageCostLine =
|
const usageCostLine =
|
||||||
usagePair && costLine ? `${usagePair} · ${costLine}` : (usagePair ?? costLine);
|
usagePair && costLine ? `${usagePair} · ${costLine}` : (usagePair ?? costLine);
|
||||||
const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions);
|
const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions);
|
||||||
const voiceLine = formatVoiceModeLine(args.config, args.sessionEntry);
|
const voiceLine = formatVoiceModeLine(args.config);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
versionLine,
|
versionLine,
|
||||||
|
|||||||
@@ -138,16 +138,6 @@ describe("legacy config detection", () => {
|
|||||||
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
||||||
expect(res.config?.channels?.telegram?.requireMention).toBeUndefined();
|
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 () => {
|
it("migrates legacy model config to agent.models + model lists", async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const { migrateLegacyConfig } = await import("./config.js");
|
const { migrateLegacyConfig } = await import("./config.js");
|
||||||
|
|||||||
@@ -40,26 +40,6 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
|||||||
delete tools.bash;
|
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",
|
id: "agent.defaults-v2",
|
||||||
describe: "Move agent config to agents.defaults and tools",
|
describe: "Move agent config to agents.defaults and tools",
|
||||||
|
|||||||
@@ -120,10 +120,6 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
|||||||
message:
|
message:
|
||||||
"agent.imageModelFallbacks was replaced by agents.defaults.imageModel.fallbacks (auto-migrated on load).",
|
"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"],
|
path: ["gateway", "token"],
|
||||||
message: "gateway.token is ignored; use gateway.auth.token instead (auto-migrated on load).",
|
message: "gateway.token is ignored; use gateway.auth.token instead (auto-migrated on load).",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
|
|||||||
import type { NormalizedChatType } from "../../channels/chat-type.js";
|
import type { NormalizedChatType } from "../../channels/chat-type.js";
|
||||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||||
import type { DeliveryContext } from "../../utils/delivery-context.js";
|
import type { DeliveryContext } from "../../utils/delivery-context.js";
|
||||||
import type { TtsAutoMode } from "../types.tts.js";
|
|
||||||
|
|
||||||
export type SessionScope = "per-sender" | "global";
|
export type SessionScope = "per-sender" | "global";
|
||||||
|
|
||||||
@@ -43,7 +42,6 @@ export type SessionEntry = {
|
|||||||
verboseLevel?: string;
|
verboseLevel?: string;
|
||||||
reasoningLevel?: string;
|
reasoningLevel?: string;
|
||||||
elevatedLevel?: string;
|
elevatedLevel?: string;
|
||||||
ttsAuto?: TtsAutoMode;
|
|
||||||
execHost?: string;
|
execHost?: string;
|
||||||
execSecurity?: string;
|
execSecurity?: string;
|
||||||
execAsk?: string;
|
execAsk?: string;
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ export type SignalAccountConfig = {
|
|||||||
cliPath?: string;
|
cliPath?: string;
|
||||||
/** Auto-start signal-cli daemon (default: true if httpUrl not set). */
|
/** Auto-start signal-cli daemon (default: true if httpUrl not set). */
|
||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
/** Max time to wait for signal-cli daemon startup (ms, cap 120000). */
|
|
||||||
startupTimeoutMs?: number;
|
|
||||||
receiveMode?: "on-start" | "manual";
|
receiveMode?: "on-start" | "manual";
|
||||||
ignoreAttachments?: boolean;
|
ignoreAttachments?: boolean;
|
||||||
ignoreStories?: boolean;
|
ignoreStories?: boolean;
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ export type TtsProvider = "elevenlabs" | "openai" | "edge";
|
|||||||
|
|
||||||
export type TtsMode = "final" | "all";
|
export type TtsMode = "final" | "all";
|
||||||
|
|
||||||
export type TtsAutoMode = "off" | "always" | "inbound" | "tagged";
|
|
||||||
|
|
||||||
export type TtsModelOverrideConfig = {
|
export type TtsModelOverrideConfig = {
|
||||||
/** Enable model-provided overrides for TTS. */
|
/** Enable model-provided overrides for TTS. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -24,9 +22,7 @@ export type TtsModelOverrideConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TtsConfig = {
|
export type TtsConfig = {
|
||||||
/** Auto-TTS mode (preferred). */
|
/** Enable auto-TTS (can be overridden by local prefs). */
|
||||||
auto?: TtsAutoMode;
|
|
||||||
/** Legacy: enable auto-TTS when `auto` is not set. */
|
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Apply TTS to final replies only or to all replies (tool/block/final). */
|
/** Apply TTS to final replies only or to all replies (tool/block/final). */
|
||||||
mode?: TtsMode;
|
mode?: TtsMode;
|
||||||
|
|||||||
@@ -158,10 +158,8 @@ export const MarkdownConfigSchema = z
|
|||||||
|
|
||||||
export const TtsProviderSchema = z.enum(["elevenlabs", "openai", "edge"]);
|
export const TtsProviderSchema = z.enum(["elevenlabs", "openai", "edge"]);
|
||||||
export const TtsModeSchema = z.enum(["final", "all"]);
|
export const TtsModeSchema = z.enum(["final", "all"]);
|
||||||
export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]);
|
|
||||||
export const TtsConfigSchema = z
|
export const TtsConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
auto: TtsAutoSchema.optional(),
|
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
mode: TtsModeSchema.optional(),
|
mode: TtsModeSchema.optional(),
|
||||||
provider: TtsProviderSchema.optional(),
|
provider: TtsProviderSchema.optional(),
|
||||||
|
|||||||
@@ -486,7 +486,6 @@ export const SignalAccountSchemaBase = z
|
|||||||
httpPort: z.number().int().positive().optional(),
|
httpPort: z.number().int().positive().optional(),
|
||||||
cliPath: ExecutableTokenSchema.optional(),
|
cliPath: ExecutableTokenSchema.optional(),
|
||||||
autoStart: z.boolean().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(),
|
receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(),
|
||||||
ignoreAttachments: z.boolean().optional(),
|
ignoreAttachments: z.boolean().optional(),
|
||||||
ignoreStories: z.boolean().optional(),
|
ignoreStories: z.boolean().optional(),
|
||||||
|
|||||||
@@ -136,8 +136,9 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
const forumParentSlug =
|
const forumParentSlug =
|
||||||
isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : "";
|
isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : "";
|
||||||
const threadChannelId = threadChannel?.id;
|
const threadChannelId = threadChannel?.id;
|
||||||
const isForumStarter =
|
const isForumStarter = Boolean(
|
||||||
Boolean(threadChannelId && isForumParent && forumParentSlug) && message.id === threadChannelId;
|
threadChannelId && isForumParent && forumParentSlug && message.id === threadChannelId,
|
||||||
|
);
|
||||||
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
|
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
|
||||||
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
|
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
|
||||||
const groupSubject = isDirectMessage ? undefined : groupChannel;
|
const groupSubject = isDirectMessage ? undefined : groupChannel;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
getTtsProvider,
|
getTtsProvider,
|
||||||
isTtsEnabled,
|
isTtsEnabled,
|
||||||
isTtsProviderConfigured,
|
isTtsProviderConfigured,
|
||||||
resolveTtsAutoMode,
|
|
||||||
resolveTtsApiKey,
|
resolveTtsApiKey,
|
||||||
resolveTtsConfig,
|
resolveTtsConfig,
|
||||||
resolveTtsPrefsPath,
|
resolveTtsPrefsPath,
|
||||||
@@ -25,13 +24,11 @@ export const ttsHandlers: GatewayRequestHandlers = {
|
|||||||
const config = resolveTtsConfig(cfg);
|
const config = resolveTtsConfig(cfg);
|
||||||
const prefsPath = resolveTtsPrefsPath(config);
|
const prefsPath = resolveTtsPrefsPath(config);
|
||||||
const provider = getTtsProvider(config, prefsPath);
|
const provider = getTtsProvider(config, prefsPath);
|
||||||
const autoMode = resolveTtsAutoMode({ config, prefsPath });
|
|
||||||
const fallbackProviders = resolveTtsProviderOrder(provider)
|
const fallbackProviders = resolveTtsProviderOrder(provider)
|
||||||
.slice(1)
|
.slice(1)
|
||||||
.filter((candidate) => isTtsProviderConfigured(config, candidate));
|
.filter((candidate) => isTtsProviderConfigured(config, candidate));
|
||||||
respond(true, {
|
respond(true, {
|
||||||
enabled: isTtsEnabled(config, prefsPath),
|
enabled: isTtsEnabled(config, prefsPath),
|
||||||
auto: autoMode,
|
|
||||||
provider,
|
provider,
|
||||||
fallbackProvider: fallbackProviders[0] ?? null,
|
fallbackProvider: fallbackProviders[0] ?? null,
|
||||||
fallbackProviders,
|
fallbackProviders,
|
||||||
|
|||||||
@@ -230,21 +230,6 @@ describe("gateway server auth/connect", () => {
|
|||||||
ws.close();
|
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 () => {
|
test("rejects control ui without device identity by default", async () => {
|
||||||
const ws = await openWs(port);
|
const ws = await openWs(port);
|
||||||
const res = await connectReq(ws, {
|
const res = await connectReq(ws, {
|
||||||
|
|||||||
@@ -66,34 +66,19 @@ function formatGatewayAuthFailureMessage(params: {
|
|||||||
authMode: ResolvedGatewayAuth["mode"];
|
authMode: ResolvedGatewayAuth["mode"];
|
||||||
authProvided: AuthProvidedKind;
|
authProvided: AuthProvidedKind;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
client?: { id?: string | null; mode?: string | null };
|
|
||||||
}): string {
|
}): string {
|
||||||
const { authMode, authProvided, reason, client } = params;
|
const { authMode, authProvided, reason } = 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) {
|
switch (reason) {
|
||||||
case "token_missing":
|
case "token_missing":
|
||||||
return `unauthorized: gateway token missing (${tokenHint})`;
|
return "unauthorized: gateway token missing (set gateway.remote.token to match gateway.auth.token)";
|
||||||
case "token_mismatch":
|
case "token_mismatch":
|
||||||
return `unauthorized: gateway token mismatch (${tokenHint})`;
|
return "unauthorized: gateway token mismatch (set gateway.remote.token to match gateway.auth.token)";
|
||||||
case "token_missing_config":
|
case "token_missing_config":
|
||||||
return "unauthorized: gateway token not configured on gateway (set gateway.auth.token)";
|
return "unauthorized: gateway token not configured on gateway (set gateway.auth.token)";
|
||||||
case "password_missing":
|
case "password_missing":
|
||||||
return `unauthorized: gateway password missing (${passwordHint})`;
|
return "unauthorized: gateway password missing (set gateway.remote.password to match gateway.auth.password)";
|
||||||
case "password_mismatch":
|
case "password_mismatch":
|
||||||
return `unauthorized: gateway password mismatch (${passwordHint})`;
|
return "unauthorized: gateway password mismatch (set gateway.remote.password to match gateway.auth.password)";
|
||||||
case "password_missing_config":
|
case "password_missing_config":
|
||||||
return "unauthorized: gateway password not configured on gateway (set gateway.auth.password)";
|
return "unauthorized: gateway password not configured on gateway (set gateway.auth.password)";
|
||||||
case "tailscale_user_missing":
|
case "tailscale_user_missing":
|
||||||
@@ -105,10 +90,10 @@ function formatGatewayAuthFailureMessage(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (authMode === "token" && authProvided === "none") {
|
if (authMode === "token" && authProvided === "none") {
|
||||||
return `unauthorized: gateway token missing (${tokenHint})`;
|
return "unauthorized: gateway token missing (set gateway.remote.token to match gateway.auth.token)";
|
||||||
}
|
}
|
||||||
if (authMode === "password" && authProvided === "none") {
|
if (authMode === "password" && authProvided === "none") {
|
||||||
return `unauthorized: gateway password missing (${passwordHint})`;
|
return "unauthorized: gateway password missing (set gateway.remote.password to match gateway.auth.password)";
|
||||||
}
|
}
|
||||||
return "unauthorized";
|
return "unauthorized";
|
||||||
}
|
}
|
||||||
@@ -547,7 +532,6 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
authMode: resolvedAuth.mode,
|
authMode: resolvedAuth.mode,
|
||||||
authProvided,
|
authProvided,
|
||||||
reason: authResult.reason,
|
reason: authResult.reason,
|
||||||
client: connectParams.client,
|
|
||||||
});
|
});
|
||||||
setCloseCause("unauthorized", {
|
setCloseCause("unauthorized", {
|
||||||
authMode: resolvedAuth.mode,
|
authMode: resolvedAuth.mode,
|
||||||
|
|||||||
@@ -126,90 +126,6 @@ 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 () => {
|
it("sends tool summaries with responsePrefix", async () => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
replyMock.mockImplementation(async (_ctx, opts) => {
|
replyMock.mockImplementation(async (_ctx, opts) => {
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export type MonitorSignalOpts = {
|
|||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
startupTimeoutMs?: number;
|
|
||||||
cliPath?: string;
|
cliPath?: string;
|
||||||
httpHost?: string;
|
httpHost?: string;
|
||||||
httpPort?: number;
|
httpPort?: number;
|
||||||
@@ -286,10 +285,6 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
|||||||
const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts);
|
const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts);
|
||||||
|
|
||||||
const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl;
|
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);
|
const readReceiptsViaDaemon = Boolean(autoStart && sendReadReceipts);
|
||||||
let daemonHandle: ReturnType<typeof spawnSignalDaemon> | null = null;
|
let daemonHandle: ReturnType<typeof spawnSignalDaemon> | null = null;
|
||||||
|
|
||||||
@@ -320,7 +315,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
|||||||
await waitForSignalDaemonReady({
|
await waitForSignalDaemonReady({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
abortSignal: opts.abortSignal,
|
abortSignal: opts.abortSignal,
|
||||||
timeoutMs: startupTimeoutMs,
|
timeoutMs: 30_000,
|
||||||
logAfterMs: 10_000,
|
logAfterMs: 10_000,
|
||||||
logIntervalMs: 10_000,
|
logIntervalMs: 10_000,
|
||||||
runtime,
|
runtime,
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
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,7 +20,6 @@ import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../conf
|
|||||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.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 { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reactions.js";
|
||||||
import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
|
import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
|
||||||
import { resolveControlCommandGate } from "../channels/command-gating.js";
|
import { resolveControlCommandGate } from "../channels/command-gating.js";
|
||||||
@@ -137,13 +136,6 @@ export const buildTelegramMessageContext = async ({
|
|||||||
id: peerId,
|
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 mentionRegexes = buildMentionRegexes(cfg, route.agentId);
|
||||||
const effectiveDmAllow = normalizeAllowFromWithStore({ allowFrom, storeAllowFrom });
|
const effectiveDmAllow = normalizeAllowFromWithStore({ allowFrom, storeAllowFrom });
|
||||||
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||||
@@ -333,7 +325,7 @@ export const buildTelegramMessageContext = async ({
|
|||||||
const activationOverride = resolveGroupActivation({
|
const activationOverride = resolveGroupActivation({
|
||||||
chatId,
|
chatId,
|
||||||
messageThreadId: resolvedThreadId,
|
messageThreadId: resolvedThreadId,
|
||||||
sessionKey: sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
});
|
||||||
const baseRequireMention = resolveGroupRequireMention(chatId);
|
const baseRequireMention = resolveGroupRequireMention(chatId);
|
||||||
@@ -440,7 +432,7 @@ export const buildTelegramMessageContext = async ({
|
|||||||
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||||
const previousTimestamp = readSessionUpdatedAt({
|
const previousTimestamp = readSessionUpdatedAt({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
});
|
});
|
||||||
const body = formatInboundEnvelope({
|
const body = formatInboundEnvelope({
|
||||||
channel: "Telegram",
|
channel: "Telegram",
|
||||||
@@ -490,7 +482,7 @@ export const buildTelegramMessageContext = async ({
|
|||||||
CommandBody: commandBody,
|
CommandBody: commandBody,
|
||||||
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
|
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
|
||||||
To: `telegram:${chatId}`,
|
To: `telegram:${chatId}`,
|
||||||
SessionKey: sessionKey,
|
SessionKey: route.sessionKey,
|
||||||
AccountId: route.accountId,
|
AccountId: route.accountId,
|
||||||
ChatType: isGroup ? "group" : "direct",
|
ChatType: isGroup ? "group" : "direct",
|
||||||
ConversationLabel: conversationLabel,
|
ConversationLabel: conversationLabel,
|
||||||
@@ -534,7 +526,7 @@ export const buildTelegramMessageContext = async ({
|
|||||||
|
|
||||||
await recordInboundSession({
|
await recordInboundSession({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
updateLastRoute: !isGroup
|
updateLastRoute: !isGroup
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
|||||||
import { danger, logVerbose } from "../globals.js";
|
import { danger, logVerbose } from "../globals.js";
|
||||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
|
||||||
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
|
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
|
||||||
import type { ChannelGroupPolicy } from "../config/group-policy.js";
|
import type { ChannelGroupPolicy } from "../config/group-policy.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -272,13 +271,6 @@ export const registerTelegramNativeCommands = ({
|
|||||||
id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId),
|
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({
|
const tableMode = resolveMarkdownTableMode({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
@@ -317,7 +309,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
CommandSource: "native" as const,
|
CommandSource: "native" as const,
|
||||||
SessionKey: `telegram:slash:${senderId || chatId}`,
|
SessionKey: `telegram:slash:${senderId || chatId}`,
|
||||||
CommandTargetSessionKey: sessionKey,
|
CommandTargetSessionKey: route.sessionKey,
|
||||||
MessageThreadId: resolvedThreadId,
|
MessageThreadId: resolvedThreadId,
|
||||||
IsForum: isForum,
|
IsForum: isForum,
|
||||||
// Originating context for sub-agent announce routing
|
// Originating context for sub-agent announce routing
|
||||||
|
|||||||
@@ -2163,46 +2163,6 @@ describe("createTelegramBot", () => {
|
|||||||
expect.objectContaining({ message_thread_id: 99 }),
|
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 () => {
|
it("allows native DM commands for paired users", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
@@ -2829,41 +2789,4 @@ describe("createTelegramBot", () => {
|
|||||||
const sessionKey = enqueueSystemEvent.mock.calls[0][1].sessionKey;
|
const sessionKey = enqueueSystemEvent.mock.calls[0][1].sessionKey;
|
||||||
expect(sessionKey).not.toContain(":topic:");
|
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"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
+1
-45
@@ -20,11 +20,9 @@ import {
|
|||||||
} from "../config/group-policy.js";
|
} from "../config/group-policy.js";
|
||||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { resolveTelegramAccount } from "./accounts.js";
|
import { resolveTelegramAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
@@ -163,42 +161,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
return skipped;
|
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) => {
|
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();
|
await next();
|
||||||
recordUpdateId(ctx);
|
recordUpdateId(ctx);
|
||||||
});
|
});
|
||||||
@@ -409,20 +372,13 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
peer: { kind: isGroup ? "group" : "dm", id: peerId },
|
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
|
// Enqueue system event for each added reaction
|
||||||
for (const r of addedReactions) {
|
for (const r of addedReactions) {
|
||||||
const emoji = r.emoji;
|
const emoji = r.emoji;
|
||||||
const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`;
|
const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`;
|
||||||
enqueueSystemEvent(text, {
|
enqueueSystemEvent(text, {
|
||||||
sessionKey: sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `telegram:reaction:add:${chatId}:${messageId}:${user?.id ?? "anon"}:${emoji}`,
|
contextKey: `telegram:reaction:add:${chatId}:${messageId}:${user?.id ?? "anon"}:${emoji}`,
|
||||||
});
|
});
|
||||||
logVerbose(`telegram: reaction event enqueued: ${text}`);
|
logVerbose(`telegram: reaction event enqueued: ${text}`);
|
||||||
|
|||||||
+1
-128
@@ -4,7 +4,7 @@ import { completeSimple } from "@mariozechner/pi-ai";
|
|||||||
|
|
||||||
import { getApiKeyForModel } from "../agents/model-auth.js";
|
import { getApiKeyForModel } from "../agents/model-auth.js";
|
||||||
import { resolveModel } from "../agents/pi-embedded-runner/model.js";
|
import { resolveModel } from "../agents/pi-embedded-runner/model.js";
|
||||||
import * as tts from "./tts.js";
|
import { _test, getTtsProvider, resolveTtsConfig } from "./tts.js";
|
||||||
|
|
||||||
vi.mock("@mariozechner/pi-ai", () => ({
|
vi.mock("@mariozechner/pi-ai", () => ({
|
||||||
completeSimple: vi.fn(),
|
completeSimple: vi.fn(),
|
||||||
@@ -37,8 +37,6 @@ vi.mock("../agents/model-auth.js", () => ({
|
|||||||
requireApiKey: vi.fn((auth: { apiKey?: string }) => auth.apiKey ?? ""),
|
requireApiKey: vi.fn((auth: { apiKey?: string }) => auth.apiKey ?? ""),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { _test, resolveTtsConfig, maybeApplyTtsToPayload, getTtsProvider } = tts;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isValidVoiceId,
|
isValidVoiceId,
|
||||||
isValidOpenAIVoice,
|
isValidOpenAIVoice,
|
||||||
@@ -433,129 +431,4 @@ describe("tts", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("maybeApplyTtsToPayload", () => {
|
|
||||||
const baseCfg = {
|
|
||||||
agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } },
|
|
||||||
messages: {
|
|
||||||
tts: {
|
|
||||||
auto: "inbound",
|
|
||||||
provider: "openai",
|
|
||||||
openai: { apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
it("skips auto-TTS when inbound audio gating is on and the message is not audio", async () => {
|
|
||||||
const prevPrefs = process.env.CLAWDBOT_TTS_PREFS;
|
|
||||||
process.env.CLAWDBOT_TTS_PREFS = `/tmp/tts-test-${Date.now()}.json`;
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const fetchMock = vi.fn(async () => ({
|
|
||||||
ok: true,
|
|
||||||
arrayBuffer: async () => new ArrayBuffer(1),
|
|
||||||
}));
|
|
||||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const payload = { text: "Hello world" };
|
|
||||||
const result = await maybeApplyTtsToPayload({
|
|
||||||
payload,
|
|
||||||
cfg: baseCfg,
|
|
||||||
kind: "final",
|
|
||||||
inboundAudio: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBe(payload);
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
process.env.CLAWDBOT_TTS_PREFS = prevPrefs;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("attempts auto-TTS when inbound audio gating is on and the message is audio", async () => {
|
|
||||||
const prevPrefs = process.env.CLAWDBOT_TTS_PREFS;
|
|
||||||
process.env.CLAWDBOT_TTS_PREFS = `/tmp/tts-test-${Date.now()}.json`;
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const fetchMock = vi.fn(async () => ({
|
|
||||||
ok: true,
|
|
||||||
arrayBuffer: async () => new ArrayBuffer(1),
|
|
||||||
}));
|
|
||||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const result = await maybeApplyTtsToPayload({
|
|
||||||
payload: { text: "Hello world" },
|
|
||||||
cfg: baseCfg,
|
|
||||||
kind: "final",
|
|
||||||
inboundAudio: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.mediaUrl).toBeDefined();
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
process.env.CLAWDBOT_TTS_PREFS = prevPrefs;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips auto-TTS in tagged mode unless a tts tag is present", async () => {
|
|
||||||
const prevPrefs = process.env.CLAWDBOT_TTS_PREFS;
|
|
||||||
process.env.CLAWDBOT_TTS_PREFS = `/tmp/tts-test-${Date.now()}.json`;
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const fetchMock = vi.fn(async () => ({
|
|
||||||
ok: true,
|
|
||||||
arrayBuffer: async () => new ArrayBuffer(1),
|
|
||||||
}));
|
|
||||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
...baseCfg,
|
|
||||||
messages: {
|
|
||||||
...baseCfg.messages,
|
|
||||||
tts: { ...baseCfg.messages.tts, auto: "tagged" },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const payload = { text: "Hello world" };
|
|
||||||
const result = await maybeApplyTtsToPayload({
|
|
||||||
payload,
|
|
||||||
cfg,
|
|
||||||
kind: "final",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBe(payload);
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
process.env.CLAWDBOT_TTS_PREFS = prevPrefs;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs auto-TTS in tagged mode when tags are present", async () => {
|
|
||||||
const prevPrefs = process.env.CLAWDBOT_TTS_PREFS;
|
|
||||||
process.env.CLAWDBOT_TTS_PREFS = `/tmp/tts-test-${Date.now()}.json`;
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const fetchMock = vi.fn(async () => ({
|
|
||||||
ok: true,
|
|
||||||
arrayBuffer: async () => new ArrayBuffer(1),
|
|
||||||
}));
|
|
||||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
...baseCfg,
|
|
||||||
messages: {
|
|
||||||
...baseCfg.messages,
|
|
||||||
tts: { ...baseCfg.messages.tts, auto: "tagged" },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await maybeApplyTtsToPayload({
|
|
||||||
payload: { text: "[[tts:text]]Hello world[[/tts:text]]" },
|
|
||||||
cfg,
|
|
||||||
kind: "final",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.mediaUrl).toBeDefined();
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
process.env.CLAWDBOT_TTS_PREFS = prevPrefs;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
+18
-87
@@ -20,7 +20,6 @@ import type { ChannelId } from "../channels/plugins/types.js";
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import type {
|
import type {
|
||||||
TtsConfig,
|
TtsConfig,
|
||||||
TtsAutoMode,
|
|
||||||
TtsMode,
|
TtsMode,
|
||||||
TtsProvider,
|
TtsProvider,
|
||||||
TtsModelOverrideConfig,
|
TtsModelOverrideConfig,
|
||||||
@@ -76,10 +75,8 @@ const DEFAULT_OUTPUT = {
|
|||||||
voiceCompatible: false,
|
voiceCompatible: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const TTS_AUTO_MODES = new Set<TtsAutoMode>(["off", "always", "inbound", "tagged"]);
|
|
||||||
|
|
||||||
export type ResolvedTtsConfig = {
|
export type ResolvedTtsConfig = {
|
||||||
auto: TtsAutoMode;
|
enabled: boolean;
|
||||||
mode: TtsMode;
|
mode: TtsMode;
|
||||||
provider: TtsProvider;
|
provider: TtsProvider;
|
||||||
providerSource: "config" | "default";
|
providerSource: "config" | "default";
|
||||||
@@ -126,7 +123,6 @@ export type ResolvedTtsConfig = {
|
|||||||
|
|
||||||
type TtsUserPrefs = {
|
type TtsUserPrefs = {
|
||||||
tts?: {
|
tts?: {
|
||||||
auto?: TtsAutoMode;
|
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
provider?: TtsProvider;
|
provider?: TtsProvider;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
@@ -165,7 +161,6 @@ type TtsDirectiveOverrides = {
|
|||||||
type TtsDirectiveParseResult = {
|
type TtsDirectiveParseResult = {
|
||||||
cleanedText: string;
|
cleanedText: string;
|
||||||
ttsText?: string;
|
ttsText?: string;
|
||||||
hasDirective: boolean;
|
|
||||||
overrides: TtsDirectiveOverrides;
|
overrides: TtsDirectiveOverrides;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
};
|
};
|
||||||
@@ -192,15 +187,6 @@ type TtsStatusEntry = {
|
|||||||
|
|
||||||
let lastTtsAttempt: TtsStatusEntry | undefined;
|
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(
|
function resolveModelOverridePolicy(
|
||||||
overrides: TtsModelOverrideConfig | undefined,
|
overrides: TtsModelOverrideConfig | undefined,
|
||||||
): ResolvedTtsModelOverrides {
|
): ResolvedTtsModelOverrides {
|
||||||
@@ -234,9 +220,8 @@ export function resolveTtsConfig(cfg: ClawdbotConfig): ResolvedTtsConfig {
|
|||||||
const raw: TtsConfig = cfg.messages?.tts ?? {};
|
const raw: TtsConfig = cfg.messages?.tts ?? {};
|
||||||
const providerSource = raw.provider ? "config" : "default";
|
const providerSource = raw.provider ? "config" : "default";
|
||||||
const edgeOutputFormat = raw.edge?.outputFormat?.trim();
|
const edgeOutputFormat = raw.edge?.outputFormat?.trim();
|
||||||
const auto = normalizeTtsAutoMode(raw.auto) ?? (raw.enabled ? "always" : "off");
|
|
||||||
return {
|
return {
|
||||||
auto,
|
enabled: raw.enabled ?? false,
|
||||||
mode: raw.mode ?? "final",
|
mode: raw.mode ?? "final",
|
||||||
provider: raw.provider ?? "edge",
|
provider: raw.provider ?? "edge",
|
||||||
providerSource,
|
providerSource,
|
||||||
@@ -294,48 +279,17 @@ export function resolveTtsPrefsPath(config: ResolvedTtsConfig): string {
|
|||||||
return path.join(CONFIG_DIR, "settings", "tts.json");
|
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 {
|
export function buildTtsSystemPromptHint(cfg: ClawdbotConfig): string | undefined {
|
||||||
const config = resolveTtsConfig(cfg);
|
const config = resolveTtsConfig(cfg);
|
||||||
const prefsPath = resolveTtsPrefsPath(config);
|
const prefsPath = resolveTtsPrefsPath(config);
|
||||||
const autoMode = resolveTtsAutoMode({ config, prefsPath });
|
if (!isTtsEnabled(config, prefsPath)) return undefined;
|
||||||
if (autoMode === "off") return undefined;
|
|
||||||
const maxLength = getTtsMaxLength(prefsPath);
|
const maxLength = getTtsMaxLength(prefsPath);
|
||||||
const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
|
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 [
|
return [
|
||||||
"Voice (TTS) is enabled.",
|
"Voice (TTS) is enabled.",
|
||||||
autoHint,
|
|
||||||
`Keep spoken text ≤${maxLength} chars to avoid auto-summary (summary ${summarize}).`,
|
`Keep spoken text ≤${maxLength} chars to avoid auto-summary (summary ${summarize}).`,
|
||||||
"Use [[tts:...]] and optional [[tts:text]]...[[/tts:text]] to control voice/expressiveness.",
|
"Use [[tts:...]] and optional [[tts:text]]...[[/tts:text]] to control voice/expressiveness.",
|
||||||
]
|
].join("\n");
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPrefs(prefsPath: string): TtsUserPrefs {
|
function readPrefs(prefsPath: string): TtsUserPrefs {
|
||||||
@@ -369,25 +323,16 @@ function updatePrefs(prefsPath: string, update: (prefs: TtsUserPrefs) => void):
|
|||||||
atomicWriteFileSync(prefsPath, JSON.stringify(prefs, null, 2));
|
atomicWriteFileSync(prefsPath, JSON.stringify(prefs, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTtsEnabled(
|
export function isTtsEnabled(config: ResolvedTtsConfig, prefsPath: string): boolean {
|
||||||
config: ResolvedTtsConfig,
|
const prefs = readPrefs(prefsPath);
|
||||||
prefsPath: string,
|
if (prefs.tts?.enabled !== undefined) return prefs.tts.enabled === true;
|
||||||
sessionAuto?: string,
|
return config.enabled;
|
||||||
): 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 {
|
export function setTtsEnabled(prefsPath: string, enabled: boolean): void {
|
||||||
setTtsAutoMode(prefsPath, enabled ? "always" : "off");
|
updatePrefs(prefsPath, (prefs) => {
|
||||||
|
prefs.tts = { ...prefs.tts, enabled };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): TtsProvider {
|
export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): TtsProvider {
|
||||||
@@ -540,17 +485,15 @@ function parseTtsDirectives(
|
|||||||
policy: ResolvedTtsModelOverrides,
|
policy: ResolvedTtsModelOverrides,
|
||||||
): TtsDirectiveParseResult {
|
): TtsDirectiveParseResult {
|
||||||
if (!policy.enabled) {
|
if (!policy.enabled) {
|
||||||
return { cleanedText: text, overrides: {}, warnings: [], hasDirective: false };
|
return { cleanedText: text, overrides: {}, warnings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const overrides: TtsDirectiveOverrides = {};
|
const overrides: TtsDirectiveOverrides = {};
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
let cleanedText = text;
|
let cleanedText = text;
|
||||||
let hasDirective = false;
|
|
||||||
|
|
||||||
const blockRegex = /\[\[tts:text\]\]([\s\S]*?)\[\[\/tts:text\]\]/gi;
|
const blockRegex = /\[\[tts:text\]\]([\s\S]*?)\[\[\/tts:text\]\]/gi;
|
||||||
cleanedText = cleanedText.replace(blockRegex, (_match, inner: string) => {
|
cleanedText = cleanedText.replace(blockRegex, (_match, inner: string) => {
|
||||||
hasDirective = true;
|
|
||||||
if (policy.allowText && overrides.ttsText == null) {
|
if (policy.allowText && overrides.ttsText == null) {
|
||||||
overrides.ttsText = inner.trim();
|
overrides.ttsText = inner.trim();
|
||||||
}
|
}
|
||||||
@@ -559,7 +502,6 @@ function parseTtsDirectives(
|
|||||||
|
|
||||||
const directiveRegex = /\[\[tts:([^\]]+)\]\]/gi;
|
const directiveRegex = /\[\[tts:([^\]]+)\]\]/gi;
|
||||||
cleanedText = cleanedText.replace(directiveRegex, (_match, body: string) => {
|
cleanedText = cleanedText.replace(directiveRegex, (_match, body: string) => {
|
||||||
hasDirective = true;
|
|
||||||
const tokens = body.split(/\s+/).filter(Boolean);
|
const tokens = body.split(/\s+/).filter(Boolean);
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
const eqIndex = token.indexOf("=");
|
const eqIndex = token.indexOf("=");
|
||||||
@@ -730,7 +672,6 @@ function parseTtsDirectives(
|
|||||||
return {
|
return {
|
||||||
cleanedText,
|
cleanedText,
|
||||||
ttsText: overrides.ttsText,
|
ttsText: overrides.ttsText,
|
||||||
hasDirective,
|
|
||||||
overrides,
|
overrides,
|
||||||
warnings,
|
warnings,
|
||||||
};
|
};
|
||||||
@@ -1215,17 +1156,13 @@ export async function maybeApplyTtsToPayload(params: {
|
|||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
kind?: "tool" | "block" | "final";
|
kind?: "tool" | "block" | "final";
|
||||||
inboundAudio?: boolean;
|
|
||||||
ttsAuto?: string;
|
|
||||||
}): Promise<ReplyPayload> {
|
}): Promise<ReplyPayload> {
|
||||||
const config = resolveTtsConfig(params.cfg);
|
const config = resolveTtsConfig(params.cfg);
|
||||||
const prefsPath = resolveTtsPrefsPath(config);
|
const prefsPath = resolveTtsPrefsPath(config);
|
||||||
const autoMode = resolveTtsAutoMode({
|
if (!isTtsEnabled(config, prefsPath)) return params.payload;
|
||||||
config,
|
|
||||||
prefsPath,
|
const mode = config.mode ?? "final";
|
||||||
sessionAuto: params.ttsAuto,
|
if (mode === "final" && params.kind && params.kind !== "final") return params.payload;
|
||||||
});
|
|
||||||
if (autoMode === "off") return params.payload;
|
|
||||||
|
|
||||||
const text = params.payload.text ?? "";
|
const text = params.payload.text ?? "";
|
||||||
const directives = parseTtsDirectives(text, config.modelOverrides);
|
const directives = parseTtsDirectives(text, config.modelOverrides);
|
||||||
@@ -1246,12 +1183,6 @@ export async function maybeApplyTtsToPayload(params: {
|
|||||||
text: visibleText.length > 0 ? visibleText : undefined,
|
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 (!ttsText.trim()) return nextPayload;
|
||||||
if (params.payload.mediaUrl || (params.payload.mediaUrls?.length ?? 0) > 0) return nextPayload;
|
if (params.payload.mediaUrl || (params.payload.mediaUrls?.length ?? 0) > 0) return nextPayload;
|
||||||
if (text.includes("MEDIA:")) return nextPayload;
|
if (text.includes("MEDIA:")) return nextPayload;
|
||||||
@@ -1266,7 +1197,7 @@ export async function maybeApplyTtsToPayload(params: {
|
|||||||
logVerbose(
|
logVerbose(
|
||||||
`TTS: skipping long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
|
`TTS: skipping long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
|
||||||
);
|
);
|
||||||
return nextPayload;
|
return params.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1288,7 +1219,7 @@ export async function maybeApplyTtsToPayload(params: {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err as Error;
|
const error = err as Error;
|
||||||
logVerbose(`TTS: summarization failed: ${error.message}`);
|
logVerbose(`TTS: summarization failed: ${error.message}`);
|
||||||
return nextPayload;
|
return params.payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
-18
@@ -1,18 +0,0 @@
|
|||||||
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);
|
realSock.ev.emit("messages.upsert", upsert);
|
||||||
|
|
||||||
// Allow a brief window for the async handler to fire on slower hosts.
|
// Allow a brief window for the async handler to fire on slower hosts.
|
||||||
for (let i = 0; i < 50; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
if (onMessage.mock.calls.length > 0) break;
|
if (onMessage.mock.calls.length > 0) break;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||||
@@ -178,9 +178,9 @@ describe("web inbound media saves with extension", () => {
|
|||||||
|
|
||||||
realSock.ev.emit("messages.upsert", upsert);
|
realSock.ev.emit("messages.upsert", upsert);
|
||||||
|
|
||||||
for (let i = 0; i < 50; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
if (onMessage.mock.calls.length > 0) break;
|
if (onMessage.mock.calls.length > 0) break;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||||
@@ -218,9 +218,9 @@ describe("web inbound media saves with extension", () => {
|
|||||||
|
|
||||||
realSock.ev.emit("messages.upsert", upsert);
|
realSock.ev.emit("messages.upsert", upsert);
|
||||||
|
|
||||||
for (let i = 0; i < 50; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
if (onMessage.mock.calls.length > 0) break;
|
if (onMessage.mock.calls.length > 0) break;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ export function connectGateway(host: GatewayHost) {
|
|||||||
mode: "webchat",
|
mode: "webchat",
|
||||||
onHello: (hello) => {
|
onHello: (hello) => {
|
||||||
host.connected = true;
|
host.connected = true;
|
||||||
host.lastError = null;
|
|
||||||
host.hello = hello;
|
host.hello = hello;
|
||||||
applySnapshot(host, hello);
|
applySnapshot(host, hello);
|
||||||
void loadAssistantIdentity(host as unknown as ClawdbotApp);
|
void loadAssistantIdentity(host as unknown as ClawdbotApp);
|
||||||
@@ -135,10 +134,7 @@ export function connectGateway(host: GatewayHost) {
|
|||||||
},
|
},
|
||||||
onClose: ({ code, reason }) => {
|
onClose: ({ code, reason }) => {
|
||||||
host.connected = false;
|
host.connected = false;
|
||||||
// Code 1012 = Service Restart (expected during config saves, don't show as error)
|
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
|
||||||
if (code !== 1012) {
|
|
||||||
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onEvent: (evt) => handleGatewayEvent(host, evt),
|
onEvent: (evt) => handleGatewayEvent(host, evt),
|
||||||
onGap: ({ expected, received }) => {
|
onGap: ({ expected, received }) => {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe("config view", () => {
|
|||||||
onSubsectionChange: vi.fn(),
|
onSubsectionChange: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows save when form is unsafe", () => {
|
it("disables save when form is unsafe", () => {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
render(
|
render(
|
||||||
renderConfig({
|
renderConfig({
|
||||||
@@ -59,28 +59,6 @@ describe("config view", () => {
|
|||||||
container,
|
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(
|
const saveButton = Array.from(
|
||||||
container.querySelectorAll("button"),
|
container.querySelectorAll("button"),
|
||||||
).find((btn) => btn.textContent?.trim() === "Save") as
|
).find((btn) => btn.textContent?.trim() === "Save") as
|
||||||
|
|||||||
@@ -233,10 +233,9 @@ export function renderConfig(props: ConfigProps) {
|
|||||||
const hasRawChanges = props.formMode === "raw" && props.raw !== props.originalRaw;
|
const hasRawChanges = props.formMode === "raw" && props.raw !== props.originalRaw;
|
||||||
const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges;
|
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 =
|
const canSaveForm =
|
||||||
Boolean(props.formValue) && !props.loading && Boolean(analysis.schema);
|
Boolean(props.formValue) && !props.loading && !formUnsafe;
|
||||||
const canSave =
|
const canSave =
|
||||||
props.connected &&
|
props.connected &&
|
||||||
!props.saving &&
|
!props.saving &&
|
||||||
|
|||||||
Reference in new issue
Block a user