Compare commits

...
Author SHA1 Message Date
Peter Steinberger 32d370e92e fix: polish tts auto mode + tests (#1667) (thanks @sebslight) 2026-01-25 04:27:43 +00:00
Peter Steinberger 2c1be8af4b chore: save tts auto work (local) 2026-01-25 04:09:23 +00:00
Sebastian 50519189d2 Tests: mock fetch for TTS gating 2026-01-25 04:03:53 +00:00
Sebastian d25fc7aa2b TTS: gate auto audio on inbound audio 2026-01-25 04:03:53 +00:00
Shadow cdceff2284 Discord: add forum parent context 2026-01-24 21:57:48 -06:00
Peter Steinberger cb52ffb842 fix: drop unused cli import 2026-01-25 03:42:32 +00:00
Peter Steinberger 3a35d313d9 fix: signal reactions 2026-01-25 03:24:44 +00:00
Tom McKenzieandClaude Sonnet 4.5 116fbb747f CLI: fix subcommand registration to work without --help/--version flags (#1683)
## Problem

The clawdbot-gateway systemd service was crash-looping on Linux (Fedora 42,
aarch64) with the error:

    error: unknown command '/usr/bin/node-22'

After ~20 seconds of runtime, the gateway would exit with status 1/FAILURE
and systemd would restart it, repeating the cycle indefinitely (80+ restarts
observed).

## Root Cause Analysis

### Investigation Steps

1. Examined systemd service logs via `journalctl --user -u clawdbot-gateway.service`
2. Found the error appeared consistently after the service had been running
   for 20-30 seconds
3. Added debug logging to trace argv at parseAsync() call
4. Discovered that argv was being passed to Commander.js with the node binary
   and script paths still present: `["/usr/bin/node-22", "/path/to/entry.js", "gateway", "--port", "18789"]`
5. Traced the issue to the lazy subcommand registration logic in runCli()

### The Bug

The lazy-loading logic for subcommands was gated behind `hasHelpOrVersion(parseArgv)`:

```typescript
if (hasHelpOrVersion(parseArgv)) {
  const primary = getPrimaryCommand(parseArgv);
  if (primary) {
    const { registerSubCliByName } = await import("./program/register.subclis.js");
    await registerSubCliByName(program, primary);
  }
}
```

This meant that when running `clawdbot gateway --port 18789` (without --help
or --version), the `gateway` subcommand was never registered before
`program.parseAsync(parseArgv)` was called. Commander.js would then try to
parse the arguments without knowing about the gateway command, leading to
parse errors.

The error message "unknown command '/usr/bin/node-22'" appeared because
Commander was treating the first positional argument as a command name due to
argv not being properly stripped on non-Windows platforms in some code paths.

## The Fix

Remove the `hasHelpOrVersion()` gate and always register the primary
subcommand when one is detected:

```typescript
// Register the primary subcommand if one exists (for lazy-loading)
const primary = getPrimaryCommand(parseArgv);
if (primary) {
  const { registerSubCliByName } = await import("./program/register.subclis.js");
  await registerSubCliByName(program, primary);
}
```

This ensures that subcommands like `gateway` are properly registered before
parsing begins, regardless of what flags are present.

## Environment

- OS: Fedora 42 (Linux 6.15.9-201.fc42.aarch64)
- Arch: aarch64
- Node: /usr/bin/node-22 (symlink to node-22)
- Deployment: systemd user service
- Runtime: Gateway started via `clawdbot gateway --port 18789`

## Why This Should Be Merged

1. **Critical Bug**: The gateway service cannot run reliably on Linux without
   this fix, making it a blocking issue for production deployments via systemd.

2. **Affects All Non-Help Invocations**: Any direct subcommand invocation
   (gateway, channels, etc.) without --help/--version is broken.

3. **Simple & Safe Fix**: The change removes an unnecessary condition that was
   preventing lazy-loading from working correctly. Subcommands should always be
   registered when detected, not just for help/version requests.

4. **No Regression Risk**: The fix maintains the lazy-loading behavior (only
   loads the requested subcommand), just ensures it works in all cases instead
   of only help/version scenarios.

5. **Tested**: Verified that the gateway service now runs stably for extended
   periods (45+ seconds continuous runtime with no crashes) after applying this
   fix.

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 03:17:02 +00:00
Peter Steinberger b1a555da13 fix: skip tailscale dns probe when off 2026-01-25 02:51:20 +00:00
Peter Steinberger c92aaca8b0 docs: answer local data storage faq 2026-01-25 02:48:28 +00:00
Peter Steinberger c3e777e3e1 fix: keep raw config edits scoped to config view (#1673) (thanks @Glucksberg) 2026-01-25 02:48:07 +00:00
Peter Steinberger 2e3b14187b fix: stabilize venice model discovery 2026-01-25 02:43:08 +00:00
Peter Steinberger f8a22521bd docs: clarify WSL2 recommendation 2026-01-25 02:30:09 +00:00
Peter Steinberger 8477394414 docs: explain unstuck commands 2026-01-25 02:04:32 +00:00
Peter SteinbergerandNathan Broadbent e6e71457e0 fix: honor trusted proxy client IPs (PR #1654)
Thanks @ndbroadbent.

Co-authored-by: Nathan Broadbent <git@ndbroadbent.com>
2026-01-25 01:52:19 +00:00
Peter Steinberger 2684a364c6 docs: add basic debug commands to unstuck faq 2026-01-25 01:51:38 +00:00
Peter Steinberger b9dc117309 docs: refine venice highlight 2026-01-25 01:49:53 +00:00
Peter Steinberger 9205ee55de docs: add fastest-unstuck guidance 2026-01-25 01:22:22 +00:00
Peter Steinberger 6e23e81678 docs: clarify lobster DSL rationale 2026-01-25 01:13:55 +00:00
Peter Steinberger 0163f53f5d fix: regenerate protocol models 2026-01-25 01:12:49 +00:00
jonisjongithub 25f2d2adb3 docs: remove rate limits claim from Venice docs 2026-01-25 01:11:57 +00:00
jonisjongithub 7540d1e8c1 feat: add Venice AI provider integration
Venice AI is a privacy-focused AI inference provider with support for
uncensored models and access to major proprietary models via their
anonymized proxy.

This integration adds:

- Complete model catalog with 25 models:
  - 15 private models (Llama, Qwen, DeepSeek, Venice Uncensored, etc.)
  - 10 anonymized models (Claude, GPT-5.2, Gemini, Grok, Kimi, MiniMax)
- Auto-discovery from Venice API with fallback to static catalog
- VENICE_API_KEY environment variable support
- Interactive onboarding via 'venice-api-key' auth choice
- Model selection prompt showing all available Venice models
- Provider auto-registration when API key is detected
- Comprehensive documentation covering:
  - Privacy modes (private vs anonymized)
  - All 25 models with context windows and features
  - Streaming, function calling, and vision support
  - Model selection recommendations

Privacy modes:
- Private: Fully private, no logging (open-source models)
- Anonymized: Proxied through Venice (proprietary models)

Default model: venice/llama-3.3-70b (good balance of capability + privacy)
Venice API: https://api.venice.ai/api/v1 (OpenAI-compatible)
2026-01-25 01:11:57 +00:00
Peter Steinberger fc0e303e05 feat: add edge tts fallback provider 2026-01-25 01:05:43 +00:00
Peter SteinbergerandNathan Broadbent 6a7a1d7085 fix: add chat stop button
Co-authored-by: Nathan Broadbent <ndbroadbent@users.noreply.github.com>
2026-01-25 01:00:23 +00:00
85 changed files with 3104 additions and 167 deletions

No files matched your search

+6
View File
@@ -6,8 +6,11 @@ Docs: https://docs.clawd.bot
### Highlights
- Ollama: provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama
- Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. https://docs.clawd.bot/providers/venice
### 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 auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
- Docs: add verbose installer troubleshooting guidance.
- Docs: update Fly.io guide notes.
@@ -18,13 +21,16 @@ Docs: https://docs.clawd.bot
### Fixes
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
- Web UI: hide internal `message_id` hints in chat bubbles.
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
- Heartbeat: normalize target identifiers for consistent routing.
- TUI: reload history after gateway reconnect to restore session state. (#1663)
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
- Agents: use the active auth profile for auto-compaction recovery.
- Models: default missing custom provider fields so minimal configs are accepted.
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- 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)
@@ -1167,17 +1167,29 @@ public struct ConfigApplyParams: Codable, Sendable {
public struct ConfigPatchParams: Codable, Sendable {
public let raw: String
public let basehash: String?
public let sessionkey: String?
public let note: String?
public let restartdelayms: Int?
public init(
raw: String,
basehash: String?
basehash: String?,
sessionkey: String?,
note: String?,
restartdelayms: Int?
) {
self.raw = raw
self.basehash = basehash
self.sessionkey = sessionkey
self.note = note
self.restartdelayms = restartdelayms
}
private enum CodingKeys: String, CodingKey {
case raw
case basehash = "baseHash"
case sessionkey = "sessionKey"
case note
case restartdelayms = "restartDelayMs"
}
}
@@ -1167,17 +1167,29 @@ public struct ConfigApplyParams: Codable, Sendable {
public struct ConfigPatchParams: Codable, Sendable {
public let raw: String
public let basehash: String?
public let sessionkey: String?
public let note: String?
public let restartdelayms: Int?
public init(
raw: String,
basehash: String?
basehash: String?,
sessionkey: String?,
note: String?,
restartdelayms: Int?
) {
self.raw = raw
self.basehash = basehash
self.sessionkey = sessionkey
self.note = note
self.restartdelayms = restartdelayms
}
private enum CodingKeys: String, CodingKey {
case raw
case basehash = "baseHash"
case sessionkey = "sessionKey"
case note
case restartdelayms = "restartDelayMs"
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only).
- Under **Connection settings**, select **HTTP endpoint URL**.
- Under **Triggers**, select **Use a common HTTP endpoint URL for all triggers** and set it to your gateway's public URL followed by `/googlechat`.
- *Tip: Run `clawdbot status` to find your gateway's public URL.*
- Under **Visibility**, check **Make this Chat app available to specific people and groups in <Your Domain>**.
- Under **Visibility**, check **Make this Chat app available to specific people and groups in &lt;Your Domain&gt;**.
- Enter your email address (e.g. `user@example.com`) in the text box.
- Click **Save** at the bottom.
6) **Enable the app status**:
+21
View File
@@ -105,8 +105,29 @@ Groups:
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
- Signal-cli does not expose read receipts for groups.
## Reactions (message tool)
- Use `message action=react` with `channel=signal`.
- Targets: sender E.164 or UUID (use `uuid:<id>` from pairing output; bare UUID works too).
- `messageId` is the Signal timestamp for the message you’re reacting to.
- Group reactions require `targetAuthor` or `targetAuthorUuid`.
Examples:
```
message action=react channel=signal target=uuid:123e4567-e89b-12d3-a456-426614174000 messageId=1737630212345 emoji=🔥
message action=react channel=signal target=+15551234567 messageId=1737630212345 emoji=🔥 remove=true
message action=react channel=signal target=signal:group:<groupId> targetAuthor=uuid:<sender-uuid> messageId=1737630212345 emoji=✅
```
Config:
- `channels.signal.actions.reactions`: enable/disable reaction actions (default true).
- `channels.signal.reactionLevel`: `off | ack | minimal | extensive`.
- `off`/`ack` disables agent reactions (message tool `react` will error).
- `minimal`/`extensive` enables agent reactions and sets the guidance level.
- Per-account overrides: `channels.signal.accounts.<id>.actions.reactions`, `channels.signal.accounts.<id>.reactionLevel`.
## Delivery targets (CLI/cron)
- DMs: `signal:+15551234567` (or plain E.164).
- UUID DMs: `uuid:<id>` (or bare UUID).
- Groups: `signal:group:<groupId>`.
- Usernames: `username:<name>` (if supported by your Signal account).
+10 -2
View File
@@ -66,11 +66,12 @@ Name lookup:
- Discord only: `--poll-duration-hours`, `--message`
- `react`
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal
- Required: `--message-id`, `--target`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--target-author`, `--target-author-uuid`
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
- WhatsApp only: `--participant`, `--from-me`
- Signal group reactions: `--target-author` or `--target-author-uuid` required
- `reactions`
- Channels: Discord/Google Chat/Slack
@@ -213,6 +214,13 @@ clawdbot message react --channel slack \
--target C123 --message-id 456 --emoji "✅"
```
React in a Signal group:
```
clawdbot message react --channel signal \
--target signal:group:abc123 --message-id 1737630212345 \
--emoji "✅" --target-author-uuid 123e4567-e89b-12d3-a456-426614174000
```
Send Telegram inline buttons:
```
clawdbot message send --channel telegram --target @mychat --message "Choose:" \
+10 -3
View File
@@ -1502,7 +1502,7 @@ voice notes; other channels send MP3 audio.
{
messages: {
tts: {
enabled: true,
auto: "always", // off | always | inbound | tagged
mode: "final", // final | all (include tool/block replies)
provider: "elevenlabs",
summaryModel: "openai/gpt-4.1-mini",
@@ -1539,8 +1539,10 @@ voice notes; other channels send MP3 audio.
```
Notes:
- `messages.tts.enabled` can be overridden by local user prefs (see `/tts on`, `/tts off`).
- `prefsPath` stores local overrides (enabled/provider/limit/summarize).
- `messages.tts.auto` controls auto‑TTS (`off`, `always`, `inbound`, `tagged`).
- `/tts off|always|inbound|tagged` sets the per‑session auto mode (overrides config).
- `messages.tts.enabled` is legacy; doctor migrates it to `messages.tts.auto`.
- `prefsPath` stores local overrides (provider/limit/summarize).
- `maxTextLength` is a hard cap for TTS input; summaries are truncated to fit.
- `summaryModel` overrides `agents.defaults.model.primary` for auto-summary.
- Accepts `provider/model` or an alias from `agents.defaults.models`.
@@ -2846,6 +2848,11 @@ Related docs:
- [Tailscale](/gateway/tailscale)
- [Remote access](/gateway/remote)
Trusted proxies:
- `gateway.trustedProxies`: list of reverse proxy IPs that terminate TLS in front of the Gateway.
- When a connection comes from one of these IPs, Clawdbot uses `x-forwarded-for` (or `x-real-ip`) to determine the client IP for local pairing checks and HTTP auth/local checks.
- Only list proxies you fully control, and ensure they **overwrite** incoming `x-forwarded-for`.
Notes:
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
+5
View File
@@ -322,6 +322,11 @@ Tailscale.
you terminate TLS or proxy in front of the gateway, disable
`gateway.auth.allowTailscale` and use token/password auth instead.
Trusted proxies:
- If you terminate TLS in front of the Gateway, set `gateway.trustedProxies` to your proxy IPs.
- Clawdbot will trust `x-forwarded-for` (or `x-real-ip`) from those IPs to determine the client IP for local pairing checks and HTTP auth/local checks.
- Ensure your proxy **overwrites** `x-forwarded-for` and blocks direct access to the Gateway port.
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
### 0.6.1) Browser control server over Tailscale (recommended)
+91 -31
View File
@@ -7,10 +7,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
## Table of contents
- [What is Clawdbot?](#what-is-clawdbot)
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
- [What’s the value proposition?](#whats-the-value-proposition)
- [Quick start and first-run setup](#quick-start-and-first-run-setup)
- [I'm stuck — what's the fastest way to get unstuck?](#im-stuck--whats-the-fastest-way-to-get-unstuck)
- [What’s the recommended way to install and set up Clawdbot?](#whats-the-recommended-way-to-install-and-set-up-clawdbot)
- [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding)
- [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote)
@@ -50,6 +48,9 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [What’s the difference between the hackable (git) install and npm install?](#whats-the-difference-between-the-hackable-git-install-and-npm-install)
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
- [Should I run the Gateway on my laptop or a VPS?](#should-i-run-the-gateway-on-my-laptop-or-a-vps)
- [What is Clawdbot?](#what-is-clawdbot)
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
- [What’s the value proposition?](#whats-the-value-proposition)
- [Skills and automation](#skills-and-automation)
- [How do I customize skills without keeping the repo dirty?](#how-do-i-customize-skills-without-keeping-the-repo-dirty)
- [Can I load skills from a custom folder?](#can-i-load-skills-from-a-custom-folder)
@@ -65,6 +66,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [How does memory work?](#how-does-memory-work)
- [Does semantic memory search require an OpenAI API key?](#does-semantic-memory-search-require-an-openai-api-key)
- [Where things live on disk](#where-things-live-on-disk)
- [Is all data used with Clawdbot saved locally?](#is-all-data-used-with-clawdbot-saved-locally)
- [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
- [Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?](#where-should-agentsmd--soulmd--usermd--memorymd-live)
- [What’s the recommended backup strategy?](#whats-the-recommended-backup-strategy)
@@ -205,36 +207,52 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
```
Asks the running gateway for a full snapshot (WS-only). See [Health](/gateway/health).
## What is Clawdbot?
### What is Clawdbot, in one paragraph?
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost (plugin), Discord, Google Chat, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product.
### What’s the value proposition?
Clawdbot is not “just a Claude wrapper.” It’s a **local-first control plane** that lets you run a
capable assistant on **your own hardware**, reachable from the chat apps you already use, with
stateful sessions, memory, and tools — without handing control of your workflows to a hosted
SaaS.
Highlights:
- **Your devices, your data:** run the Gateway wherever you want (Mac, Linux, VPS) and keep the
workspace + session history local.
- **Real channels, not a web sandbox:** WhatsApp/Telegram/Slack/Discord/Signal/iMessage/etc,
plus mobile voice and Canvas on supported platforms.
- **Model-agnostic:** use Anthropic, OpenAI, MiniMax, OpenRouter, etc., with per‑agent routing
and failover.
- **Local-only option:** run local models so **all data can stay on your device** if you want.
- **Multi-agent routing:** separate agents per channel, account, or task, each with its own
workspace and defaults.
- **Open source and hackable:** inspect, extend, and self-host without vendor lock‑in.
Docs: [Gateway](/gateway), [Channels](/channels), [Multi‑agent](/concepts/multi-agent),
[Memory](/concepts/memory).
## Quick start and first-run setup
### I'm stuck — what's the fastest way to get unstuck?
Use a local AI agent that can **see your machine**. That is far more effective than asking
in Discord, because most "I'm stuck" cases are **local config or environment issues** that
remote helpers cannot inspect.
- **Claude Code**: https://www.anthropic.com/claude-code/
- **OpenAI Codex**: https://openai.com/codex/
These tools can read the repo, run commands, inspect logs, and help fix your machine-level
setup (PATH, services, permissions, auth files). Give them the **full source checkout** via
the hackable (git) install:
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git
```
This installs Clawdbot **from a git checkout**, so the agent can read the code + docs and
reason about the exact version you are running. You can always switch back to stable later
by re-running the installer without `--install-method git`.
If you discover a real bug or fix, please file a GitHub issue or send a PR:
https://github.com/clawdbot/clawdbot/issues
https://github.com/clawdbot/clawdbot/pulls
Start with these commands (share outputs when asking for help):
```bash
clawdbot status
clawdbot models status
clawdbot doctor
```
What they do:
- `clawdbot status`: quick snapshot of gateway/agent health + basic config.
- `clawdbot models status`: checks provider auth + model availability.
- `clawdbot doctor`: validates and repairs common config/state issues.
Other useful CLI checks: `clawdbot status --all`, `clawdbot logs --follow`,
`clawdbot gateway status`, `clawdbot health --verbose`.
Quick debug loop: [First 60 seconds if something's broken](#first-60-seconds-if-somethings-broken).
Install docs: [Install](/install), [Installer flags](/install/installer), [Updating](/install/updating).
### What’s the recommended way to install and set up Clawdbot?
The repo recommends running from source and using the onboarding wizard:
@@ -702,6 +720,34 @@ lowest friction and you’re okay with sleep/restarts, run it locally.
**Recommended default:** VPS if you had gateway disconnects before. Local is great when you’re actively using the Mac and want local file access or UI automation with a visible browser.
## What is Clawdbot?
### What is Clawdbot, in one paragraph?
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost (plugin), Discord, Google Chat, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product.
### What’s the value proposition?
Clawdbot is not “just a Claude wrapper.” It’s a **local-first control plane** that lets you run a
capable assistant on **your own hardware**, reachable from the chat apps you already use, with
stateful sessions, memory, and tools — without handing control of your workflows to a hosted
SaaS.
Highlights:
- **Your devices, your data:** run the Gateway wherever you want (Mac, Linux, VPS) and keep the
workspace + session history local.
- **Real channels, not a web sandbox:** WhatsApp/Telegram/Slack/Discord/Signal/iMessage/etc,
plus mobile voice and Canvas on supported platforms.
- **Model-agnostic:** use Anthropic, OpenAI, MiniMax, OpenRouter, etc., with per‑agent routing
and failover.
- **Local-only option:** run local models so **all data can stay on your device** if you want.
- **Multi-agent routing:** separate agents per channel, account, or task, each with its own
workspace and defaults.
- **Open source and hackable:** inspect, extend, and self-host without vendor lock‑in.
Docs: [Gateway](/gateway), [Channels](/channels), [Multi‑agent](/concepts/multi-agent),
[Memory](/concepts/memory).
## Skills and automation
### How do I customize skills without keeping the repo dirty?
@@ -873,6 +919,20 @@ models — see [Memory](/concepts/memory) for the setup details.
## Where things live on disk
### Is all data used with Clawdbot saved locally?
No — **Clawdbot’s state is local**, but **external services still see what you send them**.
- **Local by default:** sessions, memory files, config, and workspace live on the Gateway host
(`~/.clawdbot` + your workspace directory).
- **Remote by necessity:** messages you send to model providers (Anthropic/OpenAI/etc.) go to
their APIs, and chat platforms (WhatsApp/Telegram/Slack/etc.) store message data on their
servers.
- **You control the footprint:** using local models keeps prompts on your machine, but channel
traffic still goes through the channel’s servers.
Related: [Agent workspace](/concepts/agent-workspace), [Memory](/concepts/memory).
### Where does Clawdbot store its data?
Everything lives under `$CLAWDBOT_STATE_DIR` (default: `~/.clawdbot`):
+2 -1
View File
@@ -7,7 +7,8 @@ read_when:
# Windows (WSL2)
Clawdbot on Windows is recommended **via WSL2** (Ubuntu recommended). The
CLI + Gateway run inside Linux, which keeps the runtime consistent. Native
CLI + Gateway run inside Linux, which keeps the runtime consistent and makes
tooling far more compatible (Node/Bun/pnpm, Linux binaries, skills). Native
Windows installs are untested and more problematic.
Native Windows companion apps are planned.
+10
View File
@@ -11,6 +11,15 @@ default model as `provider/model`.
Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/etc.)? See [Channels](/channels).
## Highlight: Venius (Venice AI)
Venius is our recommended Venice AI setup for privacy-first inference with an option to use Opus for hard tasks.
- Default: `venice/llama-3.3-70b`
- Best overall: `venice/claude-opus-45` (Opus remains the strongest)
See [Venice AI](/providers/venice).
## Quick start
1) Authenticate with the provider (usually via `clawdbot onboard`).
@@ -35,6 +44,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
- [MiniMax](/providers/minimax)
- [Venius (Venice AI, privacy-focused)](/providers/venice)
- [Ollama (local models)](/providers/ollama)
## Transcription providers
+10
View File
@@ -9,6 +9,15 @@ read_when:
Clawdbot can use many LLM providers. Pick one, authenticate, then set the default
model as `provider/model`.
## Highlight: Venius (Venice AI)
Venius is our recommended Venice AI setup for privacy-first inference with an option to use Opus for the hardest tasks.
- Default: `venice/llama-3.3-70b`
- Best overall: `venice/claude-opus-45` (Opus remains the strongest)
See [Venice AI](/providers/venice).
## Quick start (two steps)
1) Authenticate with the provider (usually via `clawdbot onboard`).
@@ -32,6 +41,7 @@ model as `provider/model`.
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
- [MiniMax](/providers/minimax)
- [Venius (Venice AI)](/providers/venice)
- [Amazon Bedrock](/bedrock)
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
+264
View File
@@ -0,0 +1,264 @@
---
summary: "Use Venice AI privacy-focused models in Clawdbot"
read_when:
- You want privacy-focused inference in Clawdbot
- You want Venice AI setup guidance
---
# Venice AI (Venius highlight)
**Venius** is our highlight Venice setup for privacy-first inference with optional anonymized access to proprietary models.
Venice AI provides privacy-focused AI inference with support for uncensored models and access to major proprietary models through their anonymized proxy. All inference is private by default—no training on your data, no logging.
## Why Venice in Clawdbot
- **Private inference** for open-source models (no logging).
- **Uncensored models** when you need them.
- **Anonymized access** to proprietary models (Opus/GPT/Gemini) when quality matters.
- OpenAI-compatible `/v1` endpoints.
## Privacy Modes
Venice offers two privacy levels — understanding this is key to choosing your model:
| Mode | Description | Models |
|------|-------------|--------|
| **Private** | Fully private. Prompts/responses are **never stored or logged**. Ephemeral. | Llama, Qwen, DeepSeek, Venice Uncensored, etc. |
| **Anonymized** | Proxied through Venice with metadata stripped. The underlying provider (OpenAI, Anthropic) sees anonymized requests. | Claude, GPT, Gemini, Grok, Kimi, MiniMax |
## Features
- **Privacy-focused**: Choose between "private" (fully private) and "anonymized" (proxied) modes
- **Uncensored models**: Access to models without content restrictions
- **Major model access**: Use Claude, GPT-5.2, Gemini, Grok via Venice's anonymized proxy
- **OpenAI-compatible API**: Standard `/v1` endpoints for easy integration
- **Streaming**: ✅ Supported on all models
- **Function calling**: ✅ Supported on select models (check model capabilities)
- **Vision**: ✅ Supported on models with vision capability
- **No hard rate limits**: Fair-use throttling may apply for extreme usage
## Setup
### 1. Get API Key
1. Sign up at [venice.ai](https://venice.ai)
2. Go to **Settings → API Keys → Create new key**
3. Copy your API key (format: `vapi_xxxxxxxxxxxx`)
### 2. Configure Clawdbot
**Option A: Environment Variable**
```bash
export VENICE_API_KEY="vapi_xxxxxxxxxxxx"
```
**Option B: Interactive Setup (Recommended)**
```bash
clawdbot onboard --auth-choice venice-api-key
```
This will:
1. Prompt for your API key (or use existing `VENICE_API_KEY`)
2. Show all available Venice models
3. Let you pick your default model
4. Configure the provider automatically
**Option C: Non-interactive**
```bash
clawdbot onboard --non-interactive \
--auth-choice venice-api-key \
--venice-api-key "vapi_xxxxxxxxxxxx"
```
### 3. Verify Setup
```bash
clawdbot chat --model venice/llama-3.3-70b "Hello, are you working?"
```
## Model Selection
After setup, Clawdbot shows all available Venice models. Pick based on your needs:
- **Default (our pick)**: `venice/llama-3.3-70b` for private, balanced performance.
- **Best overall quality**: `venice/claude-opus-45` for hard jobs (Opus remains the strongest).
- **Privacy**: Choose "private" models for fully private inference.
- **Capability**: Choose "anonymized" models to access Claude, GPT, Gemini via Venice's proxy.
Change your default model anytime:
```bash
clawdbot models set venice/claude-opus-45
clawdbot models set venice/llama-3.3-70b
```
List all available models:
```bash
clawdbot models list | grep venice
```
## Configure via `clawdbot configure`
1. Run `clawdbot configure`
2. Select **Model/auth**
3. Choose **Venice AI**
## Which Model Should I Use?
| Use Case | Recommended Model | Why |
|----------|-------------------|-----|
| **General chat** | `llama-3.3-70b` | Good all-around, fully private |
| **Best overall quality** | `claude-opus-45` | Opus remains the strongest for hard tasks |
| **Privacy + Claude quality** | `claude-opus-45` | Best reasoning via anonymized proxy |
| **Coding** | `qwen3-coder-480b-a35b-instruct` | Code-optimized, 262k context |
| **Vision tasks** | `qwen3-vl-235b-a22b` | Best private vision model |
| **Uncensored** | `venice-uncensored` | No content restrictions |
| **Fast + cheap** | `qwen3-4b` | Lightweight, still capable |
| **Complex reasoning** | `deepseek-v3.2` | Strong reasoning, private |
## Available Models (25 Total)
### Private Models (15) — Fully Private, No Logging
| Model ID | Name | Context (tokens) | Features |
|----------|------|------------------|----------|
| `llama-3.3-70b` | Llama 3.3 70B | 131k | General |
| `llama-3.2-3b` | Llama 3.2 3B | 131k | Fast, lightweight |
| `hermes-3-llama-3.1-405b` | Hermes 3 Llama 3.1 405B | 131k | Complex tasks |
| `qwen3-235b-a22b-thinking-2507` | Qwen3 235B Thinking | 131k | Reasoning |
| `qwen3-235b-a22b-instruct-2507` | Qwen3 235B Instruct | 131k | General |
| `qwen3-coder-480b-a35b-instruct` | Qwen3 Coder 480B | 262k | Code |
| `qwen3-next-80b` | Qwen3 Next 80B | 262k | General |
| `qwen3-vl-235b-a22b` | Qwen3 VL 235B | 262k | Vision |
| `qwen3-4b` | Venice Small (Qwen3 4B) | 32k | Fast, reasoning |
| `deepseek-v3.2` | DeepSeek V3.2 | 163k | Reasoning |
| `venice-uncensored` | Venice Uncensored | 32k | Uncensored |
| `mistral-31-24b` | Venice Medium (Mistral) | 131k | Vision |
| `google-gemma-3-27b-it` | Gemma 3 27B Instruct | 202k | Vision |
| `openai-gpt-oss-120b` | OpenAI GPT OSS 120B | 131k | General |
| `zai-org-glm-4.7` | GLM 4.7 | 202k | Reasoning, multilingual |
### Anonymized Models (10) — Via Venice Proxy
| Model ID | Original | Context (tokens) | Features |
|----------|----------|------------------|----------|
| `claude-opus-45` | Claude Opus 4.5 | 202k | Reasoning, vision |
| `claude-sonnet-45` | Claude Sonnet 4.5 | 202k | Reasoning, vision |
| `openai-gpt-52` | GPT-5.2 | 262k | Reasoning |
| `openai-gpt-52-codex` | GPT-5.2 Codex | 262k | Reasoning, vision |
| `gemini-3-pro-preview` | Gemini 3 Pro | 202k | Reasoning, vision |
| `gemini-3-flash-preview` | Gemini 3 Flash | 262k | Reasoning, vision |
| `grok-41-fast` | Grok 4.1 Fast | 262k | Reasoning, vision |
| `grok-code-fast-1` | Grok Code Fast 1 | 262k | Reasoning, code |
| `kimi-k2-thinking` | Kimi K2 Thinking | 262k | Reasoning |
| `minimax-m21` | MiniMax M2.1 | 202k | Reasoning |
## Model Discovery
Clawdbot automatically discovers models from the Venice API when `VENICE_API_KEY` is set. If the API is unreachable, it falls back to a static catalog.
The `/models` endpoint is public (no auth needed for listing), but inference requires a valid API key.
## Streaming & Tool Support
| Feature | Support |
|---------|---------|
| **Streaming** | ✅ All models |
| **Function calling** | ✅ Most models (check `supportsFunctionCalling` in API) |
| **Vision/Images** | ✅ Models marked with "Vision" feature |
| **JSON mode** | ✅ Supported via `response_format` |
## Pricing
Venice uses a credit-based system. Check [venice.ai/pricing](https://venice.ai/pricing) for current rates:
- **Private models**: Generally lower cost
- **Anonymized models**: Similar to direct API pricing + small Venice fee
## Comparison: Venice vs Direct API
| Aspect | Venice (Anonymized) | Direct API |
|--------|---------------------|------------|
| **Privacy** | Metadata stripped, anonymized | Your account linked |
| **Latency** | +10-50ms (proxy) | Direct |
| **Features** | Most features supported | Full features |
| **Billing** | Venice credits | Provider billing |
## Usage Examples
```bash
# Use default private model
clawdbot chat --model venice/llama-3.3-70b
# Use Claude via Venice (anonymized)
clawdbot chat --model venice/claude-opus-45
# Use uncensored model
clawdbot chat --model venice/venice-uncensored
# Use vision model with image
clawdbot chat --model venice/qwen3-vl-235b-a22b
# Use coding model
clawdbot chat --model venice/qwen3-coder-480b-a35b-instruct
```
## Troubleshooting
### API key not recognized
```bash
echo $VENICE_API_KEY
clawdbot models list | grep venice
```
Ensure the key starts with `vapi_`.
### Model not available
The Venice model catalog updates dynamically. Run `clawdbot models list` to see currently available models. Some models may be temporarily offline.
### Connection issues
Venice API is at `https://api.venice.ai/api/v1`. Ensure your network allows HTTPS connections.
## Config file example
```json5
{
env: { VENICE_API_KEY: "vapi_..." },
agents: { defaults: { model: { primary: "venice/llama-3.3-70b" } } },
models: {
mode: "merge",
providers: {
venice: {
baseUrl: "https://api.venice.ai/api/v1",
apiKey: "${VENICE_API_KEY}",
api: "openai-completions",
models: [
{
id: "llama-3.3-70b",
name: "Llama 3.3 70B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 131072,
maxTokens: 8192
}
]
}
}
}
}
```
## Links
- [Venice AI](https://venice.ai)
- [API Documentation](https://docs.venice.ai)
- [Pricing](https://venice.ai/pricing)
- [Status](https://status.venice.ai)
+1 -1
View File
@@ -45,7 +45,7 @@ run on host, set an explicit per-agent override:
See [Web tools](/tools/web).
macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough.
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested and more problematic. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested, more problematic, and has poorer tool compatibility. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).
## 1) Install the CLI (recommended)
+10
View File
@@ -23,6 +23,16 @@ Today, complex workflows require many back-and-forth tool calls. Each call costs
- **Approvals built in**: Side effects (send email, post comment) halt the workflow until explicitly approved.
- **Resumable**: Halted workflows return a token; approve and resume without re-running everything.
## Why a DSL instead of plain programs?
Lobster is intentionally small. The goal is not "a new language," it's a predictable, AI-friendly pipeline spec with first-class approvals and resume tokens.
- **Approve/resume is built in**: A normal program can prompt a human, but it can’t *pause and resume* with a durable token without you inventing that runtime yourself.
- **Determinism + auditability**: Pipelines are data, so they’re easy to log, diff, replay, and review.
- **Constrained surface for AI**: A tiny grammar + JSON piping reduces “creative” code paths and makes validation realistic.
- **Safety policy baked in**: Timeouts, output caps, sandbox checks, and allowlists are enforced by the runtime, not each script.
- **Still programmable**: Each step can call any CLI or script. If you want JS/TS, generate `.lobster` files from code.
## How it works
Clawdbot launches the local `lobster` CLI in **tool mode** and parses a JSON envelope from stdout.
+1 -1
View File
@@ -68,7 +68,7 @@ Text + native (when enabled):
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
- `/tts on|off|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
- `/tts off|always|inbound|tagged|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
- Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works.
- `/stop`
- `/restart`
+109 -16
View File
@@ -8,21 +8,37 @@ read_when:
# Text-to-speech (TTS)
Clawdbot can convert outbound replies into audio using ElevenLabs or OpenAI.
Clawdbot can convert outbound replies into audio using ElevenLabs, OpenAI, or Edge TTS.
It works anywhere Clawdbot can send audio; Telegram gets a round voice-note bubble.
## Supported services
- **ElevenLabs** (primary or fallback provider)
- **OpenAI** (primary or fallback provider; also used for summaries)
- **Edge TTS** (primary or fallback provider; uses `node-edge-tts`, default when no API keys)
## Required keys
### Edge TTS notes
At least one of:
Edge TTS uses Microsoft Edge's online neural TTS service via the `node-edge-tts`
library. It's a hosted service (not local), uses Microsoft’s endpoints, and does
not require an API key. `node-edge-tts` exposes speech configuration options and
output formats, but not all options are supported by the Edge service. citeturn2search0
Because Edge TTS is a public web service without a published SLA or quota, treat it
as best-effort. If you need guaranteed limits and support, use OpenAI or ElevenLabs.
Microsoft's Speech REST API documents a 10‑minute audio limit per request; Edge TTS
does not publish limits, so assume similar or lower limits. citeturn0search3
## Optional keys
If you want OpenAI or ElevenLabs:
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
- `OPENAI_API_KEY`
If both are configured, the selected provider is used first and the other is a fallback.
Edge TTS does **not** require an API key. If no API keys are found, Clawdbot defaults
to Edge TTS (unless disabled via `messages.tts.edge.enabled=false`).
If multiple providers are configured, the selected provider is used first and the others are fallback options.
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
so that provider must also be authenticated if you enable summaries.
@@ -32,11 +48,16 @@ so that provider must also be authenticated if you enable summaries.
- [OpenAI Audio API reference](https://platform.openai.com/docs/api-reference/audio)
- [ElevenLabs Text to Speech](https://elevenlabs.io/docs/api-reference/text-to-speech)
- [ElevenLabs Authentication](https://elevenlabs.io/docs/api-reference/authentication)
- [node-edge-tts](https://github.com/SchneeHertz/node-edge-tts)
- [Microsoft Speech output formats](https://learn.microsoft.com/azure/ai-services/speech-service/rest-text-to-speech#audio-outputs)
## Is it enabled by default?
No. TTS is **disabled** by default. Enable it in config or with `/tts on`,
which writes a local preference override.
No. Auto‑TTS is **off** by default. Enable it in config with
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
Edge TTS **is** enabled by default once TTS is on, and is used automatically
when no OpenAI or ElevenLabs API keys are available.
## Config
@@ -49,7 +70,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{
messages: {
tts: {
enabled: true,
auto: "always",
provider: "elevenlabs"
}
}
@@ -62,7 +83,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{
messages: {
tts: {
enabled: true,
auto: "always",
provider: "openai",
summaryModel: "openai/gpt-4.1-mini",
modelOverrides: {
@@ -94,13 +115,48 @@ Full schema is in [Gateway configuration](/gateway/configuration).
}
```
### Edge TTS primary (no API key)
```json5
{
messages: {
tts: {
auto: "always",
provider: "edge",
edge: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%"
}
}
}
}
```
### Disable Edge TTS
```json5
{
messages: {
tts: {
edge: {
enabled: false
}
}
}
}
```
### Custom limits + prefs path
```json5
{
messages: {
tts: {
enabled: true,
auto: "always",
maxTextLength: 4000,
timeoutMs: 30000,
prefsPath: "~/.clawdbot/settings/tts.json"
@@ -109,13 +165,25 @@ Full schema is in [Gateway configuration](/gateway/configuration).
}
```
### Only reply with audio after an inbound voice note
```json5
{
messages: {
tts: {
auto: "inbound"
}
}
}
```
### Disable auto-summary for long replies
```json5
{
messages: {
tts: {
enabled: true
auto: "always"
}
}
}
@@ -129,15 +197,20 @@ Then run:
### Notes on fields
- `enabled`: master toggle (default `false`; local prefs can override).
- `auto`: auto‑TTS mode (`off`, `always`, `inbound`, `tagged`).
- `inbound` only sends audio after an inbound voice note.
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
- `provider`: `"elevenlabs"` or `"openai"` (fallback is automatic).
- `provider`: `"elevenlabs"`, `"openai"`, or `"edge"` (fallback is automatic).
- If `provider` is **unset**, Clawdbot prefers `openai` (if key), then `elevenlabs` (if key),
otherwise `edge`.
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
- Accepts `provider/model` or a configured model alias.
- `modelOverrides`: allow the model to emit TTS directives (on by default).
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
- `timeoutMs`: request timeout (ms).
- `prefsPath`: override the local prefs JSON path.
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `elevenlabs.voiceSettings`:
@@ -147,10 +220,20 @@ Then run:
- `elevenlabs.applyTextNormalization`: `auto|on|off`
- `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `edge.enabled`: allow Edge TTS usage (default `true`; no API key).
- `edge.voice`: Edge neural voice name (e.g. `en-US-MichelleNeural`).
- `edge.lang`: language code (e.g. `en-US`).
- `edge.outputFormat`: Edge output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- See Microsoft Speech output formats for valid values; not all formats are supported by Edge.
- `edge.rate` / `edge.pitch` / `edge.volume`: percent strings (e.g. `+10%`, `-5%`).
- `edge.saveSubtitles`: write JSON subtitles alongside the audio file.
- `edge.proxy`: proxy URL for Edge TTS requests.
- `edge.timeoutMs`: request timeout override (ms).
## Model-driven overrides (default on)
By default, the model **can** emit TTS directives for a single reply.
When `messages.tts.auto` is `tagged`, these directives are required to trigger audio.
When enabled, the model can emit `[[tts:...]]` directives to override the voice
for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
@@ -167,7 +250,7 @@ Here you go.
```
Available directive keys (when enabled):
- `provider` (`openai` | `elevenlabs`)
- `provider` (`openai` | `elevenlabs` | `edge`)
- `voice` (OpenAI voice) or `voiceId` (ElevenLabs)
- `model` (OpenAI TTS model or ElevenLabs model id)
- `stability`, `similarityBoost`, `style`, `speed`, `useSpeakerBoost`
@@ -225,8 +308,15 @@ These override `messages.tts.*` for that host.
- 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble.
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
- 44.1kHz / 128kbps is the default balance for speech clarity.
- **Edge TTS**: uses `edge.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
- `node-edge-tts` accepts an `outputFormat`, but not all formats are available
from the Edge service. citeturn2search0
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus). citeturn1search0
- Telegram `sendVoice` accepts OGG/MP3/M4A; use OpenAI/ElevenLabs if you need
guaranteed Opus voice notes. citeturn1search1
- If the configured Edge output format fails, Clawdbot retries with MP3.
This is not configurable; Telegram expects Opus for voice-note UX.
OpenAI/ElevenLabs formats are fixed; Telegram expects Opus for voice-note UX.
## Auto-TTS behavior
@@ -264,8 +354,10 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
`/voice` as the native command there. Text `/tts ...` still works.
```
/tts on
/tts off
/tts always
/tts inbound
/tts tagged
/tts status
/tts provider openai
/tts limit 2000
@@ -276,6 +368,7 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
Notes:
- Commands require an authorized sender (allowlist/owner rules still apply).
- `commands.text` or native command registration must be enabled.
- `off|always|inbound|tagged` are per‑session toggles (`/tts on` is an alias for `/tts always`).
- `limit` and `summary` are stored in local prefs, not the main config.
- `/tts audio` generates a one-off audio reply (does not toggle TTS on).
+11 -1
View File
@@ -18,12 +18,20 @@ import {
setAccountEnabledInConfigSection,
signalOnboardingAdapter,
SignalConfigSchema,
type ChannelMessageActionAdapter,
type ChannelPlugin,
type ResolvedSignalAccount,
} from "clawdbot/plugin-sdk";
import { getSignalRuntime } from "./runtime.js";
const signalMessageActions: ChannelMessageActionAdapter = {
listActions: (ctx) => getSignalRuntime().channel.signal.messageActions.listActions(ctx),
supportsAction: (ctx) => getSignalRuntime().channel.signal.messageActions.supportsAction?.(ctx),
handleAction: async (ctx) =>
await getSignalRuntime().channel.signal.messageActions.handleAction(ctx),
};
const meta = getChatChannelMeta("signal");
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
@@ -42,7 +50,9 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
},
actions: signalMessageActions,
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
@@ -115,7 +125,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
normalizeTarget: normalizeSignalMessagingTarget,
targetResolver: {
looksLikeId: looksLikeSignalTargetId,
hint: "<E.164|group:ID|signal:group:ID|signal:+E.164>",
hint: "<E.164|uuid:ID|group:ID|signal:group:ID|signal:+E.164>",
},
},
setup: {
+1
View File
@@ -185,6 +185,7 @@
"linkedom": "^0.18.12",
"long": "5.3.2",
"markdown-it": "^14.1.0",
"node-edge-tts": "^1.2.9",
"osc-progress": "^0.3.0",
"pdfjs-dist": "^5.4.530",
"playwright-core": "1.58.0",
+17
View File
@@ -127,6 +127,9 @@ importers:
markdown-it:
specifier: ^14.1.0
version: 14.1.0
node-edge-tts:
specifier: ^1.2.9
version: 1.2.9
osc-progress:
specifier: ^0.3.0
version: 0.3.0
@@ -4298,6 +4301,10 @@ packages:
engines: {node: '>=14.18'}
hasBin: true
node-edge-tts@1.2.9:
resolution: {integrity: sha512-fvfW1dUgJdZAdTniC6MzLTMwnNUFKGKaUdRJ1OsveOYlfnPUETBU973CG89565txvbBowCQ4Czdeu3qSX8bNOg==}
hasBin: true
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@@ -10202,6 +10209,16 @@ snapshots:
node-downloader-helper@2.1.10: {}
node-edge-tts@1.2.9:
dependencies:
https-proxy-agent: 7.0.6
ws: 8.19.0
yargs: 17.7.2
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
+1
View File
@@ -282,6 +282,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
"kimi-code": "KIMICODE_API_KEY",
minimax: "MINIMAX_API_KEY",
synthetic: "SYNTHETIC_API_KEY",
venice: "VENICE_API_KEY",
mistral: "MISTRAL_API_KEY",
opencode: "OPENCODE_API_KEY",
};
+17
View File
@@ -12,6 +12,7 @@ import {
SYNTHETIC_BASE_URL,
SYNTHETIC_MODEL_CATALOG,
} from "./synthetic-models.js";
import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
type ModelsConfig = NonNullable<ClawdbotConfig["models"]>;
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
@@ -340,6 +341,15 @@ function buildSyntheticProvider(): ProviderConfig {
};
}
async function buildVeniceProvider(): Promise<ProviderConfig> {
const models = await discoverVeniceModels();
return {
baseUrl: VENICE_BASE_URL,
api: "openai-completions",
models,
};
}
async function buildOllamaProvider(): Promise<ProviderConfig> {
const models = await discoverOllamaModels();
return {
@@ -385,6 +395,13 @@ export async function resolveImplicitProviders(params: {
providers.synthetic = { ...buildSyntheticProvider(), apiKey: syntheticKey };
}
const veniceKey =
resolveEnvApiKeyVarName("venice") ??
resolveApiKeyFromProfiles({ provider: "venice", store: authStore });
if (veniceKey) {
providers.venice = { ...(await buildVeniceProvider()), apiKey: veniceKey };
}
const qwenProfiles = listProfilesForProvider(authStore, "qwen-portal");
if (qwenProfiles.length > 0) {
providers["qwen-portal"] = {
@@ -48,15 +48,19 @@ describe("models-config", () => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_TOKEN;
const previousGithub = process.env.GITHUB_TOKEN;
const previousKimiCode = process.env.KIMICODE_API_KEY;
const previousMinimax = process.env.MINIMAX_API_KEY;
const previousMoonshot = process.env.MOONSHOT_API_KEY;
const previousSynthetic = process.env.SYNTHETIC_API_KEY;
const previousVenice = process.env.VENICE_API_KEY;
delete process.env.COPILOT_GITHUB_TOKEN;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.KIMICODE_API_KEY;
delete process.env.MINIMAX_API_KEY;
delete process.env.MOONSHOT_API_KEY;
delete process.env.SYNTHETIC_API_KEY;
delete process.env.VENICE_API_KEY;
try {
vi.resetModules();
@@ -79,12 +83,16 @@ describe("models-config", () => {
else process.env.GH_TOKEN = previousGh;
if (previousGithub === undefined) delete process.env.GITHUB_TOKEN;
else process.env.GITHUB_TOKEN = previousGithub;
if (previousKimiCode === undefined) delete process.env.KIMICODE_API_KEY;
else process.env.KIMICODE_API_KEY = previousKimiCode;
if (previousMinimax === undefined) delete process.env.MINIMAX_API_KEY;
else process.env.MINIMAX_API_KEY = previousMinimax;
if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY;
else process.env.MOONSHOT_API_KEY = previousMoonshot;
if (previousSynthetic === undefined) delete process.env.SYNTHETIC_API_KEY;
else process.env.SYNTHETIC_API_KEY = previousSynthetic;
if (previousVenice === undefined) delete process.env.VENICE_API_KEY;
else process.env.VENICE_API_KEY = previousVenice;
}
});
});
+25
View File
@@ -15,6 +15,8 @@ import { resolveChannelCapabilities } from "../../config/channel-capabilities.js
import type { ClawdbotConfig } from "../../config/config.js";
import { getMachineDisplayName } from "../../infra/machine-name.js";
import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
import { resolveSignalReactionLevel } from "../../signal/reaction-level.js";
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
@@ -255,6 +257,28 @@ export async function compactEmbeddedPiSessionDirect(
}
}
}
const reactionGuidance =
runtimeChannel && params.config
? (() => {
if (runtimeChannel === "telegram") {
const resolved = resolveTelegramReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Telegram" } : undefined;
}
if (runtimeChannel === "signal") {
const resolved = resolveSignalReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Signal" } : undefined;
}
return undefined;
})()
: undefined;
// Resolve channel-specific message actions for system prompt
const channelActions = runtimeChannel
? listChannelSupportedActions({
@@ -313,6 +337,7 @@ export async function compactEmbeddedPiSessionDirect(
ttsHint,
promptMode,
runtimeInfo,
reactionGuidance,
messageToolHints,
sandboxInfo,
tools,
+19 -7
View File
@@ -15,6 +15,7 @@ import { resolveChannelCapabilities } from "../../../config/channel-capabilities
import { getMachineDisplayName } from "../../../infra/machine-name.js";
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js";
@@ -255,14 +256,25 @@ export async function runEmbeddedAttempt(
}
}
const reactionGuidance =
runtimeChannel === "telegram" && params.config
runtimeChannel && params.config
? (() => {
const resolved = resolveTelegramReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Telegram" } : undefined;
if (runtimeChannel === "telegram") {
const resolved = resolveTelegramReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Telegram" } : undefined;
}
if (runtimeChannel === "signal") {
const resolved = resolveSignalReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Signal" } : undefined;
}
return undefined;
})()
: undefined;
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
+3
View File
@@ -94,6 +94,9 @@ function buildReactionSchema() {
messageId: Type.Optional(Type.String()),
emoji: Type.Optional(Type.String()),
remove: Type.Optional(Type.Boolean()),
targetAuthor: Type.Optional(Type.String()),
targetAuthorUuid: Type.Optional(Type.String()),
groupId: Type.Optional(Type.String()),
};
}
+393
View File
@@ -0,0 +1,393 @@
import type { ModelDefinitionConfig } from "../config/types.js";
export const VENICE_BASE_URL = "https://api.venice.ai/api/v1";
export const VENICE_DEFAULT_MODEL_ID = "llama-3.3-70b";
export const VENICE_DEFAULT_MODEL_REF = `venice/${VENICE_DEFAULT_MODEL_ID}`;
// Venice uses credit-based pricing, not per-token costs.
// Set to 0 as costs vary by model and account type.
export const VENICE_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
/**
* Complete catalog of Venice AI models.
*
* Venice provides two privacy modes:
* - "private": Fully private inference, no logging, ephemeral
* - "anonymized": Proxied through Venice with metadata stripped (for proprietary models)
*
* Note: The `privacy` field is included for documentation purposes but is not
* propagated to ModelDefinitionConfig as it's not part of the core model schema.
* Privacy mode is determined by the model itself, not configurable at runtime.
*
* This catalog serves as a fallback when the Venice API is unreachable.
*/
export const VENICE_MODEL_CATALOG = [
// ============================================
// PRIVATE MODELS (Fully private, no logging)
// ============================================
// Llama models
{
id: "llama-3.3-70b",
name: "Llama 3.3 70B",
reasoning: false,
input: ["text"],
contextWindow: 131072,
maxTokens: 8192,
privacy: "private",
},
{
id: "llama-3.2-3b",
name: "Llama 3.2 3B",
reasoning: false,
input: ["text"],
contextWindow: 131072,
maxTokens: 8192,
privacy: "private",
},
{
id: "hermes-3-llama-3.1-405b",
name: "Hermes 3 Llama 3.1 405B",
reasoning: false,
input: ["text"],
contextWindow: 131072,
maxTokens: 8192,
privacy: "private",
},
// Qwen models
{
id: "qwen3-235b-a22b-thinking-2507",
name: "Qwen3 235B Thinking",
reasoning: true,
input: ["text"],
contextWindow: 131072,
maxTokens: 8192,
privacy: "private",
},
{
id: "qwen3-235b-a22b-instruct-2507",
name: "Qwen3 235B Instruct",
reasoning: false,
input: ["text"],
contextWindow: 131072,
maxTokens: 8192,
privacy: "private",
},
{
id: "qwen3-coder-480b-a35b-instruct",
name: "Qwen3 Coder 480B",
reasoning: false,
input: ["text"],
contextWindow: 262144,
maxTokens: 8192,
privacy: "private",
},
{
id: "qwen3-next-80b",
name: "Qwen3 Next 80B",
reasoning: false,
input: ["text"],
contextWindow: 262144,
maxTokens: 8192,
privacy: "private",
},
{
id: "qwen3-vl-235b-a22b",
name: "Qwen3 VL 235B (Vision)",
reasoning: false,
input: ["text", "image"],
contextWindow: 262144,
maxTokens: 8192,
privacy: "private",
},
{
id: "qwen3-4b",
name: "Venice Small (Qwen3 4B)",
reasoning: true,
input: ["text"],
contextWindow: 32768,
maxTokens: 8192,
privacy: "private",
},
// DeepSeek
{
id: "deepseek-v3.2",
name: "DeepSeek V3.2",
reasoning: true,
input: ["text"],
contextWindow: 163840,
maxTokens: 8192,
privacy: "private",
},
// Venice-specific models
{
id: "venice-uncensored",
name: "Venice Uncensored (Dolphin-Mistral)",
reasoning: false,
input: ["text"],
contextWindow: 32768,
maxTokens: 8192,
privacy: "private",
},
{
id: "mistral-31-24b",
name: "Venice Medium (Mistral)",
reasoning: false,
input: ["text", "image"],
contextWindow: 131072,
maxTokens: 8192,
privacy: "private",
},
// Other private models
{
id: "google-gemma-3-27b-it",
name: "Google Gemma 3 27B Instruct",
reasoning: false,
input: ["text", "image"],
contextWindow: 202752,
maxTokens: 8192,
privacy: "private",
},
{
id: "openai-gpt-oss-120b",
name: "OpenAI GPT OSS 120B",
reasoning: false,
input: ["text"],
contextWindow: 131072,
maxTokens: 8192,
privacy: "private",
},
{
id: "zai-org-glm-4.7",
name: "GLM 4.7",
reasoning: true,
input: ["text"],
contextWindow: 202752,
maxTokens: 8192,
privacy: "private",
},
// ============================================
// ANONYMIZED MODELS (Proxied through Venice)
// These are proprietary models accessed via Venice's proxy
// ============================================
// Anthropic (via Venice)
{
id: "claude-opus-45",
name: "Claude Opus 4.5 (via Venice)",
reasoning: true,
input: ["text", "image"],
contextWindow: 202752,
maxTokens: 8192,
privacy: "anonymized",
},
{
id: "claude-sonnet-45",
name: "Claude Sonnet 4.5 (via Venice)",
reasoning: true,
input: ["text", "image"],
contextWindow: 202752,
maxTokens: 8192,
privacy: "anonymized",
},
// OpenAI (via Venice)
{
id: "openai-gpt-52",
name: "GPT-5.2 (via Venice)",
reasoning: true,
input: ["text"],
contextWindow: 262144,
maxTokens: 8192,
privacy: "anonymized",
},
{
id: "openai-gpt-52-codex",
name: "GPT-5.2 Codex (via Venice)",
reasoning: true,
input: ["text", "image"],
contextWindow: 262144,
maxTokens: 8192,
privacy: "anonymized",
},
// Google (via Venice)
{
id: "gemini-3-pro-preview",
name: "Gemini 3 Pro (via Venice)",
reasoning: true,
input: ["text", "image"],
contextWindow: 202752,
maxTokens: 8192,
privacy: "anonymized",
},
{
id: "gemini-3-flash-preview",
name: "Gemini 3 Flash (via Venice)",
reasoning: true,
input: ["text", "image"],
contextWindow: 262144,
maxTokens: 8192,
privacy: "anonymized",
},
// xAI (via Venice)
{
id: "grok-41-fast",
name: "Grok 4.1 Fast (via Venice)",
reasoning: true,
input: ["text", "image"],
contextWindow: 262144,
maxTokens: 8192,
privacy: "anonymized",
},
{
id: "grok-code-fast-1",
name: "Grok Code Fast 1 (via Venice)",
reasoning: true,
input: ["text"],
contextWindow: 262144,
maxTokens: 8192,
privacy: "anonymized",
},
// Other anonymized models
{
id: "kimi-k2-thinking",
name: "Kimi K2 Thinking (via Venice)",
reasoning: true,
input: ["text"],
contextWindow: 262144,
maxTokens: 8192,
privacy: "anonymized",
},
{
id: "minimax-m21",
name: "MiniMax M2.1 (via Venice)",
reasoning: true,
input: ["text"],
contextWindow: 202752,
maxTokens: 8192,
privacy: "anonymized",
},
] as const;
export type VeniceCatalogEntry = (typeof VENICE_MODEL_CATALOG)[number];
/**
* Build a ModelDefinitionConfig from a Venice catalog entry.
*
* Note: The `privacy` field from the catalog is not included in the output
* as ModelDefinitionConfig doesn't support custom metadata fields. Privacy
* mode is inherent to each model and documented in the catalog/docs.
*/
export function buildVeniceModelDefinition(entry: VeniceCatalogEntry): ModelDefinitionConfig {
return {
id: entry.id,
name: entry.name,
reasoning: entry.reasoning,
input: [...entry.input],
cost: VENICE_DEFAULT_COST,
contextWindow: entry.contextWindow,
maxTokens: entry.maxTokens,
};
}
// Venice API response types
interface VeniceModelSpec {
name: string;
privacy: "private" | "anonymized";
availableContextTokens: number;
capabilities: {
supportsReasoning: boolean;
supportsVision: boolean;
supportsFunctionCalling: boolean;
};
}
interface VeniceModel {
id: string;
model_spec: VeniceModelSpec;
}
interface VeniceModelsResponse {
data: VeniceModel[];
}
/**
* Discover models from Venice API with fallback to static catalog.
* The /models endpoint is public and doesn't require authentication.
*/
export async function discoverVeniceModels(): Promise<ModelDefinitionConfig[]> {
// Skip API discovery in test environment
if (process.env.NODE_ENV === "test" || process.env.VITEST) {
return VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
}
try {
const response = await fetch(`${VENICE_BASE_URL}/models`, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
console.warn(
`[venice-models] Failed to discover models: HTTP ${response.status}, using static catalog`,
);
return VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
}
const data = (await response.json()) as VeniceModelsResponse;
if (!Array.isArray(data.data) || data.data.length === 0) {
console.warn("[venice-models] No models found from API, using static catalog");
return VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
}
// Merge discovered models with catalog metadata
const catalogById = new Map<string, VeniceCatalogEntry>(
VENICE_MODEL_CATALOG.map((m) => [m.id, m]),
);
const models: ModelDefinitionConfig[] = [];
for (const apiModel of data.data) {
const catalogEntry = catalogById.get(apiModel.id);
if (catalogEntry) {
// Use catalog metadata for known models
models.push(buildVeniceModelDefinition(catalogEntry));
} else {
// Create definition for newly discovered models not in catalog
const isReasoning =
apiModel.model_spec.capabilities.supportsReasoning ||
apiModel.id.toLowerCase().includes("thinking") ||
apiModel.id.toLowerCase().includes("reason") ||
apiModel.id.toLowerCase().includes("r1");
const hasVision = apiModel.model_spec.capabilities.supportsVision;
models.push({
id: apiModel.id,
name: apiModel.model_spec.name || apiModel.id,
reasoning: isReasoning,
input: hasVision ? ["text", "image"] : ["text"],
cost: VENICE_DEFAULT_COST,
contextWindow: apiModel.model_spec.availableContextTokens || 128000,
maxTokens: 8192,
});
}
}
return models.length > 0 ? models : VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
} catch (error) {
console.warn(`[venice-models] Discovery failed: ${String(error)}, using static catalog`);
return VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
}
}
+65 -22
View File
@@ -6,17 +6,20 @@ import {
getTtsMaxLength,
getTtsProvider,
isSummarizationEnabled,
isTtsEnabled,
isTtsProviderConfigured,
normalizeTtsAutoMode,
resolveTtsAutoMode,
resolveTtsApiKey,
resolveTtsConfig,
resolveTtsPrefsPath,
resolveTtsProviderOrder,
setLastTtsAttempt,
setSummarizationEnabled,
setTtsEnabled,
setTtsMaxLength,
setTtsProvider,
textToSpeech,
} from "../../tts/tts.js";
import { updateSessionStore } from "../../config/sessions.js";
type ParsedTtsCommand = {
action: string;
@@ -37,10 +40,11 @@ function ttsUsage(): ReplyPayload {
// Keep usage in one place so help/validation stays consistent.
return {
text:
"⚙️ Usage: /tts <on|off|status|provider|limit|summary|audio> [value]" +
"⚙️ Usage: /tts <off|always|inbound|tagged|status|provider|limit|summary|audio> [value]" +
"\nExamples:\n" +
"/tts on\n" +
"/tts always\n" +
"/tts provider openai\n" +
"/tts provider edge\n" +
"/tts limit 2000\n" +
"/tts summary off\n" +
"/tts audio Hello from Clawdbot",
@@ -68,14 +72,30 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
return { shouldContinue: false, reply: ttsUsage() };
}
if (action === "on") {
setTtsEnabled(prefsPath, true);
return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
}
if (action === "off") {
setTtsEnabled(prefsPath, false);
return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
const requestedAuto = normalizeTtsAutoMode(
action === "on" ? "always" : action === "off" ? "off" : action,
);
if (requestedAuto) {
const entry = params.sessionEntry;
const sessionKey = params.sessionKey;
const store = params.sessionStore;
if (entry && store && sessionKey) {
entry.ttsAuto = requestedAuto;
entry.updatedAt = Date.now();
store[sessionKey] = entry;
if (params.storePath) {
await updateSessionStore(params.storePath, (store) => {
store[sessionKey] = entry;
});
}
}
const label = requestedAuto === "always" ? "enabled (always)" : requestedAuto;
return {
shouldContinue: false,
reply: {
text: requestedAuto === "off" ? "🔇 TTS disabled." : `🔊 TTS ${label}.`,
},
};
}
if (action === "audio") {
@@ -126,33 +146,45 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (action === "provider") {
const currentProvider = getTtsProvider(config, prefsPath);
if (!args.trim()) {
const fallback = currentProvider === "openai" ? "elevenlabs" : "openai";
const fallback = resolveTtsProviderOrder(currentProvider)
.slice(1)
.filter((provider) => isTtsProviderConfigured(config, provider));
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
const hasEdge = isTtsProviderConfigured(config, "edge");
return {
shouldContinue: false,
reply: {
text:
`🎙️ TTS provider\n` +
`Primary: ${currentProvider}\n` +
`Fallback: ${fallback}\n` +
`Fallbacks: ${fallback.join(", ") || "none"}\n` +
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
`Usage: /tts provider openai | elevenlabs`,
`Edge enabled: ${hasEdge ? "✅" : "❌"}\n` +
`Usage: /tts provider openai | elevenlabs | edge`,
},
};
}
const requested = args.trim().toLowerCase();
if (requested !== "openai" && requested !== "elevenlabs") {
if (requested !== "openai" && requested !== "elevenlabs" && requested !== "edge") {
return { shouldContinue: false, reply: ttsUsage() };
}
setTtsProvider(prefsPath, requested);
const fallback = requested === "openai" ? "elevenlabs" : "openai";
const fallback = resolveTtsProviderOrder(requested)
.slice(1)
.filter((provider) => isTtsProviderConfigured(config, provider));
return {
shouldContinue: false,
reply: { text: `✅ TTS provider set to ${requested} (fallback: ${fallback}).` },
reply: {
text:
`✅ TTS provider set to ${requested} (fallbacks: ${fallback.join(", ") || "none"}).` +
(requested === "edge"
? "\nEnable Edge TTS in config: messages.tts.edge.enabled = true."
: ""),
},
};
}
@@ -197,16 +229,27 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
}
if (action === "status") {
const enabled = isTtsEnabled(config, prefsPath);
const sessionAuto = params.sessionEntry?.ttsAuto;
const autoMode = resolveTtsAutoMode({ config, prefsPath, sessionAuto });
const enabled = autoMode !== "off";
const provider = getTtsProvider(config, prefsPath);
const hasKey = Boolean(resolveTtsApiKey(config, provider));
const hasKey = isTtsProviderConfigured(config, provider);
const providerStatus =
provider === "edge"
? hasKey
? "✅ enabled"
: "❌ disabled"
: hasKey
? "✅ key"
: "❌ no key";
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath);
const last = getLastTtsAttempt();
const autoLabel = sessionAuto ? `${autoMode} (session)` : autoMode;
const lines = [
"📊 TTS status",
`State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
`Provider: ${provider} (${hasKey ? "✅ key" : "❌ no key"})`,
`Auto: ${enabled ? autoLabel : "off"}`,
`Provider: ${provider} (${providerStatus})`,
`Text limit: ${maxLength} chars`,
`Auto-summary: ${summarize ? "on" : "off"}`,
];
+59 -1
View File
@@ -1,4 +1,6 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
import {
@@ -14,7 +16,55 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
import { isRoutableChannel, routeReply } from "./route-reply.js";
import { maybeApplyTtsToPayload } from "../../tts/tts.js";
import { maybeApplyTtsToPayload, normalizeTtsAutoMode } from "../../tts/tts.js";
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
const AUDIO_HEADER_RE = /^\[Audio\b/i;
const normalizeMediaType = (value: string): string => value.split(";")[0]?.trim().toLowerCase();
const isInboundAudioContext = (ctx: FinalizedMsgContext): boolean => {
const rawTypes = [
typeof ctx.MediaType === "string" ? ctx.MediaType : undefined,
...(Array.isArray(ctx.MediaTypes) ? ctx.MediaTypes : []),
].filter(Boolean) as string[];
const types = rawTypes.map((type) => normalizeMediaType(type));
if (types.some((type) => type === "audio" || type.startsWith("audio/"))) return true;
const body =
typeof ctx.BodyForCommands === "string"
? ctx.BodyForCommands
: typeof ctx.CommandBody === "string"
? ctx.CommandBody
: typeof ctx.RawBody === "string"
? ctx.RawBody
: typeof ctx.Body === "string"
? ctx.Body
: "";
const trimmed = body.trim();
if (!trimmed) return false;
if (AUDIO_PLACEHOLDER_RE.test(trimmed)) return true;
return AUDIO_HEADER_RE.test(trimmed);
};
const resolveSessionTtsAuto = (
ctx: FinalizedMsgContext,
cfg: ClawdbotConfig,
): string | undefined => {
const targetSessionKey =
ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
const sessionKey = (targetSessionKey ?? ctx.SessionKey)?.trim();
if (!sessionKey) return undefined;
const agentId = resolveSessionAgentId({ sessionKey, config: cfg });
const storePath = resolveStorePath(cfg.session?.store, { agentId });
try {
const store = loadSessionStore(storePath);
const entry = store[sessionKey.toLowerCase()] ?? store[sessionKey];
return normalizeTtsAutoMode(entry?.ttsAuto);
} catch {
return undefined;
}
};
export type DispatchFromConfigResult = {
queuedFinal: boolean;
@@ -81,6 +131,8 @@ export async function dispatchReplyFromConfig(params: {
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
}
const inboundAudio = isInboundAudioContext(ctx);
const sessionTtsAuto = resolveSessionTtsAuto(ctx, cfg);
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("message_received")) {
const timestamp =
@@ -223,6 +275,8 @@ export async function dispatchReplyFromConfig(params: {
cfg,
channel: ttsChannel,
kind: "tool",
inboundAudio,
ttsAuto: sessionTtsAuto,
});
if (shouldRouteToOriginating) {
await sendPayloadAsync(ttsPayload);
@@ -239,6 +293,8 @@ export async function dispatchReplyFromConfig(params: {
cfg,
channel: ttsChannel,
kind: "block",
inboundAudio,
ttsAuto: sessionTtsAuto,
});
if (shouldRouteToOriginating) {
await sendPayloadAsync(ttsPayload, context?.abortSignal);
@@ -262,6 +318,8 @@ export async function dispatchReplyFromConfig(params: {
cfg,
channel: ttsChannel,
kind: "final",
inboundAudio,
ttsAuto: sessionTtsAuto,
});
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
// Route final reply to originating channel.
+4
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { TtsAutoMode } from "../../config/types.tts.js";
import {
DEFAULT_RESET_TRIGGERS,
deriveSessionMetaPatch,
@@ -128,6 +129,7 @@ export async function initSessionState(params: {
let persistedThinking: string | undefined;
let persistedVerbose: string | undefined;
let persistedReasoning: string | undefined;
let persistedTtsAuto: TtsAutoMode | undefined;
let persistedModelOverride: string | undefined;
let persistedProviderOverride: string | undefined;
@@ -220,6 +222,7 @@ export async function initSessionState(params: {
persistedThinking = entry.thinkingLevel;
persistedVerbose = entry.verboseLevel;
persistedReasoning = entry.reasoningLevel;
persistedTtsAuto = entry.ttsAuto;
persistedModelOverride = entry.modelOverride;
persistedProviderOverride = entry.providerOverride;
} else {
@@ -258,6 +261,7 @@ export async function initSessionState(params: {
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
reasoningLevel: persistedReasoning ?? baseEntry?.reasoningLevel,
ttsAuto: persistedTtsAuto ?? baseEntry?.ttsAuto,
responseUsage: baseEntry?.responseUsage,
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
+13 -5
View File
@@ -17,7 +17,7 @@ import {
getTtsMaxLength,
getTtsProvider,
isSummarizationEnabled,
isTtsEnabled,
resolveTtsAutoMode,
resolveTtsConfig,
resolveTtsPrefsPath,
} from "../tts/tts.js";
@@ -252,15 +252,23 @@ const formatMediaUnderstandingLine = (decisions?: MediaUnderstandingDecision[])
return `📎 Media: ${parts.join(" · ")}`;
};
const formatVoiceModeLine = (config?: ClawdbotConfig): string | null => {
const formatVoiceModeLine = (
config?: ClawdbotConfig,
sessionEntry?: SessionEntry,
): string | null => {
if (!config) return null;
const ttsConfig = resolveTtsConfig(config);
const prefsPath = resolveTtsPrefsPath(ttsConfig);
if (!isTtsEnabled(ttsConfig, prefsPath)) return null;
const autoMode = resolveTtsAutoMode({
config: ttsConfig,
prefsPath,
sessionAuto: sessionEntry?.ttsAuto,
});
if (autoMode === "off") return null;
const provider = getTtsProvider(ttsConfig, prefsPath);
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
return `🔊 Voice: on · provider=${provider} · limit=${maxLength} · summary=${summarize}`;
return `🔊 Voice: ${autoMode} · provider=${provider} · limit=${maxLength} · summary=${summarize}`;
};
export function buildStatusMessage(args: StatusArgs): string {
@@ -398,7 +406,7 @@ export function buildStatusMessage(args: StatusArgs): string {
const usageCostLine =
usagePair && costLine ? `${usagePair} · ${costLine}` : (usagePair ?? costLine);
const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions);
const voiceLine = formatVoiceModeLine(args.config);
const voiceLine = formatVoiceModeLine(args.config, args.sessionEntry);
return [
versionLine,
+151
View File
@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../../config/config.js";
import { signalMessageActions } from "./signal.js";
const sendReactionSignal = vi.fn(async () => ({ ok: true }));
const removeReactionSignal = vi.fn(async () => ({ ok: true }));
vi.mock("../../../signal/send-reactions.js", () => ({
sendReactionSignal: (...args: unknown[]) => sendReactionSignal(...args),
removeReactionSignal: (...args: unknown[]) => removeReactionSignal(...args),
}));
describe("signalMessageActions", () => {
it("returns no actions when no configured accounts exist", () => {
const cfg = {} as ClawdbotConfig;
expect(signalMessageActions.listActions({ cfg })).toEqual([]);
});
it("hides react when reactions are disabled", () => {
const cfg = {
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
} as ClawdbotConfig;
expect(signalMessageActions.listActions({ cfg })).toEqual(["send"]);
});
it("enables react when at least one account allows reactions", () => {
const cfg = {
channels: {
signal: {
actions: { reactions: false },
accounts: {
work: { account: "+15550001111", actions: { reactions: true } },
},
},
},
} as ClawdbotConfig;
expect(signalMessageActions.listActions({ cfg })).toEqual(["send", "react"]);
});
it("skips send for plugin dispatch", () => {
expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false);
expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true);
});
it("blocks reactions when action gate is disabled", async () => {
const cfg = {
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
} as ClawdbotConfig;
await expect(
signalMessageActions.handleAction({
action: "react",
params: { to: "+15550001111", messageId: "123", emoji: "✅" },
cfg,
accountId: undefined,
}),
).rejects.toThrow(/actions\.reactions/);
});
it("uses account-level actions when enabled", async () => {
sendReactionSignal.mockClear();
const cfg = {
channels: {
signal: {
actions: { reactions: false },
accounts: {
work: { account: "+15550001111", actions: { reactions: true } },
},
},
},
} as ClawdbotConfig;
await signalMessageActions.handleAction({
action: "react",
params: { to: "+15550001111", messageId: "123", emoji: "👍" },
cfg,
accountId: "work",
});
expect(sendReactionSignal).toHaveBeenCalledWith("+15550001111", 123, "👍", {
accountId: "work",
});
});
it("normalizes uuid recipients", async () => {
sendReactionSignal.mockClear();
const cfg = {
channels: { signal: { account: "+15550001111" } },
} as ClawdbotConfig;
await signalMessageActions.handleAction({
action: "react",
params: {
recipient: "uuid:123e4567-e89b-12d3-a456-426614174000",
messageId: "123",
emoji: "🔥",
},
cfg,
accountId: undefined,
});
expect(sendReactionSignal).toHaveBeenCalledWith(
"123e4567-e89b-12d3-a456-426614174000",
123,
"🔥",
{ accountId: undefined },
);
});
it("requires targetAuthor for group reactions", async () => {
const cfg = {
channels: { signal: { account: "+15550001111" } },
} as ClawdbotConfig;
await expect(
signalMessageActions.handleAction({
action: "react",
params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" },
cfg,
accountId: undefined,
}),
).rejects.toThrow(/targetAuthor/);
});
it("passes groupId and targetAuthor for group reactions", async () => {
sendReactionSignal.mockClear();
const cfg = {
channels: { signal: { account: "+15550001111" } },
} as ClawdbotConfig;
await signalMessageActions.handleAction({
action: "react",
params: {
to: "signal:group:group-id",
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
messageId: "123",
emoji: "✅",
},
cfg,
accountId: undefined,
});
expect(sendReactionSignal).toHaveBeenCalledWith("", 123, "✅", {
accountId: undefined,
groupId: "group-id",
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
targetAuthorUuid: undefined,
});
});
});
+130
View File
@@ -0,0 +1,130 @@
import { createActionGate, jsonResult, readStringParam } from "../../../agents/tools/common.js";
import { listEnabledSignalAccounts, resolveSignalAccount } from "../../../signal/accounts.js";
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
import { sendReactionSignal, removeReactionSignal } from "../../../signal/send-reactions.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
const providerId = "signal";
const GROUP_PREFIX = "group:";
function normalizeSignalReactionRecipient(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return trimmed;
const withoutSignal = trimmed.replace(/^signal:/i, "").trim();
if (!withoutSignal) return withoutSignal;
if (withoutSignal.toLowerCase().startsWith("uuid:")) {
return withoutSignal.slice("uuid:".length).trim();
}
return withoutSignal;
}
function resolveSignalReactionTarget(raw: string): { recipient?: string; groupId?: string } {
const trimmed = raw.trim();
if (!trimmed) return {};
const withoutSignal = trimmed.replace(/^signal:/i, "").trim();
if (!withoutSignal) return {};
if (withoutSignal.toLowerCase().startsWith(GROUP_PREFIX)) {
const groupId = withoutSignal.slice(GROUP_PREFIX.length).trim();
return groupId ? { groupId } : {};
}
return { recipient: normalizeSignalReactionRecipient(withoutSignal) };
}
export const signalMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledSignalAccounts(cfg);
if (accounts.length === 0) return [];
const configuredAccounts = accounts.filter((account) => account.configured);
if (configuredAccounts.length === 0) return [];
const actions = new Set<ChannelMessageActionName>(["send"]);
const reactionsEnabled = configuredAccounts.some((account) =>
createActionGate(account.config.actions)("reactions"),
);
if (reactionsEnabled) {
actions.add("react");
}
return Array.from(actions);
},
supportsAction: ({ action }) => action !== "send",
handleAction: async ({ action, params, cfg, accountId }) => {
if (action === "send") {
throw new Error("Send should be handled by outbound, not actions handler.");
}
if (action === "react") {
// Check reaction level first
const reactionLevelInfo = resolveSignalReactionLevel({
cfg,
accountId: accountId ?? undefined,
});
if (!reactionLevelInfo.agentReactionsEnabled) {
throw new Error(
`Signal agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` +
`Set channels.signal.reactionLevel to "minimal" or "extensive" to enable.`,
);
}
// Also check the action gate for backward compatibility
const actionConfig = resolveSignalAccount({ cfg, accountId }).config.actions;
const isActionEnabled = createActionGate(actionConfig);
if (!isActionEnabled("reactions")) {
throw new Error("Signal reactions are disabled via actions.reactions.");
}
const recipientRaw =
readStringParam(params, "recipient") ??
readStringParam(params, "to", {
required: true,
label: "recipient (UUID, phone number, or group)",
});
const target = resolveSignalReactionTarget(recipientRaw);
if (!target.recipient && !target.groupId) {
throw new Error("recipient or group required");
}
const messageId = readStringParam(params, "messageId", {
required: true,
label: "messageId (timestamp)",
});
const targetAuthor = readStringParam(params, "targetAuthor");
const targetAuthorUuid = readStringParam(params, "targetAuthorUuid");
if (target.groupId && !targetAuthor && !targetAuthorUuid) {
throw new Error("targetAuthor or targetAuthorUuid required for group reactions.");
}
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
const timestamp = parseInt(messageId, 10);
if (!Number.isFinite(timestamp)) {
throw new Error(`Invalid messageId: ${messageId}. Expected numeric timestamp.`);
}
if (remove) {
if (!emoji) throw new Error("Emoji required to remove reaction.");
await removeReactionSignal(target.recipient ?? "", timestamp, emoji, {
accountId: accountId ?? undefined,
groupId: target.groupId,
targetAuthor,
targetAuthorUuid,
});
return jsonResult({ ok: true, removed: emoji });
}
if (!emoji) throw new Error("Emoji required to add reaction.");
await sendReactionSignal(target.recipient ?? "", timestamp, emoji, {
accountId: accountId ?? undefined,
groupId: target.groupId,
targetAuthor,
targetAuthorUuid,
});
return jsonResult({ ok: true, added: emoji });
}
throw new Error(`Action ${action} not supported for ${providerId}.`);
},
};
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./signal.js";
describe("signal target normalization", () => {
it("normalizes uuid targets by stripping uuid:", () => {
expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
"123e4567-e89b-12d3-a456-426614174000",
);
});
it("normalizes signal:uuid targets", () => {
expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
"123e4567-e89b-12d3-a456-426614174000",
);
});
it("accepts uuid prefixes for target detection", () => {
expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
});
it("accepts compact UUIDs for target detection", () => {
expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true);
expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true);
});
it("rejects invalid uuid prefixes", () => {
expect(looksLikeSignalTargetId("uuid:")).toBe(false);
expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false);
});
});
+18
View File
@@ -19,12 +19,30 @@ export function normalizeSignalMessagingTarget(raw: string): string | undefined
const id = normalized.slice("u:".length).trim();
return id ? `username:${id}`.toLowerCase() : undefined;
}
if (lower.startsWith("uuid:")) {
const id = normalized.slice("uuid:".length).trim();
return id ? id.toLowerCase() : undefined;
}
return normalized.toLowerCase();
}
// UUID pattern for signal-cli recipient IDs
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const UUID_COMPACT_PATTERN = /^[0-9a-f]{32}$/i;
export function looksLikeSignalTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(signal:)?(group:|username:|u:)/i.test(trimmed)) return true;
if (/^(signal:)?uuid:/i.test(trimmed)) {
const stripped = trimmed
.replace(/^signal:/i, "")
.replace(/^uuid:/i, "")
.trim();
if (!stripped) return false;
return UUID_PATTERN.test(stripped) || UUID_COMPACT_PATTERN.test(stripped);
}
// Accept UUIDs (used by signal-cli for reactions)
if (UUID_PATTERN.test(trimmed) || UUID_COMPACT_PATTERN.test(trimmed)) return true;
return /^\+?\d{3,}$/.test(trimmed);
}
+22
View File
@@ -67,6 +67,28 @@ describe("cli program (smoke)", () => {
expect(messageCommand).toHaveBeenCalled();
});
it("runs message react with signal author fields", async () => {
const program = buildProgram();
await program.parseAsync(
[
"message",
"react",
"--channel",
"signal",
"--target",
"signal:group:abc123",
"--message-id",
"1737630212345",
"--emoji",
"✅",
"--target-author-uuid",
"123e4567-e89b-12d3-a456-426614174000",
],
{ from: "user" },
);
expect(messageCommand).toHaveBeenCalled();
});
it("runs status command", async () => {
const program = buildProgram();
await program.parseAsync(["status"], { from: "user" });
@@ -13,6 +13,8 @@ export function registerMessageReactionsCommands(message: Command, helpers: Mess
.option("--remove", "Remove reaction", false)
.option("--participant <id>", "WhatsApp reaction participant")
.option("--from-me", "WhatsApp reaction fromMe", false)
.option("--target-author <id>", "Signal reaction target author (uuid or phone)")
.option("--target-author-uuid <uuid>", "Signal reaction target author uuid")
.action(async (opts) => {
await helpers.runMessageAction("react", opts);
});
+6 -7
View File
@@ -11,7 +11,7 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { formatUncaughtError } from "../infra/errors.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js";
import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
import { getPrimaryCommand } from "./argv.js";
import { tryRouteCli } from "./route.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
@@ -50,12 +50,11 @@ export async function runCli(argv: string[] = process.argv) {
});
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
if (hasHelpOrVersion(parseArgv)) {
const primary = getPrimaryCommand(parseArgv);
if (primary) {
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
// Register the primary subcommand if one exists (for lazy-loading)
const primary = getPrimaryCommand(parseArgv);
if (primary) {
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
await program.parseAsync(parseArgv);
}
+12
View File
@@ -21,6 +21,7 @@ export type AuthChoiceGroupId =
| "opencode-zen"
| "minimax"
| "synthetic"
| "venice"
| "qwen";
export type AuthChoiceGroup = {
@@ -66,6 +67,12 @@ const AUTH_CHOICE_GROUP_DEFS: {
hint: "Anthropic-compatible (multi-model)",
choices: ["synthetic-api-key"],
},
{
value: "venice",
label: "Venice AI",
hint: "Privacy-focused (uncensored models)",
choices: ["venice-api-key"],
},
{
value: "google",
label: "Google",
@@ -190,6 +197,11 @@ export function buildAuthChoiceOptions(params: {
options.push({ value: "moonshot-api-key", label: "Moonshot AI API key" });
options.push({ value: "kimi-code-api-key", label: "Kimi Code API key" });
options.push({ value: "synthetic-api-key", label: "Synthetic API key" });
options.push({
value: "venice-api-key",
label: "Venice AI API key",
hint: "Privacy-focused inference (uncensored models)",
});
options.push({
value: "github-copilot",
label: "GitHub Copilot (GitHub device login)",
@@ -23,6 +23,8 @@ import {
applyOpenrouterProviderConfig,
applySyntheticConfig,
applySyntheticProviderConfig,
applyVeniceConfig,
applyVeniceProviderConfig,
applyVercelAiGatewayConfig,
applyVercelAiGatewayProviderConfig,
applyZaiConfig,
@@ -30,6 +32,7 @@ import {
MOONSHOT_DEFAULT_MODEL_REF,
OPENROUTER_DEFAULT_MODEL_REF,
SYNTHETIC_DEFAULT_MODEL_REF,
VENICE_DEFAULT_MODEL_REF,
VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
setGeminiApiKey,
setKimiCodeApiKey,
@@ -37,6 +40,7 @@ import {
setOpencodeZenApiKey,
setOpenrouterApiKey,
setSyntheticApiKey,
setVeniceApiKey,
setVercelAiGatewayApiKey,
setZaiApiKey,
ZAI_DEFAULT_MODEL_REF,
@@ -77,6 +81,8 @@ export async function applyAuthChoiceApiProviders(
authChoice = "zai-api-key";
} else if (params.opts.tokenProvider === "synthetic") {
authChoice = "synthetic-api-key";
} else if (params.opts.tokenProvider === "venice") {
authChoice = "venice-api-key";
} else if (params.opts.tokenProvider === "opencode") {
authChoice = "opencode-zen";
}
@@ -457,6 +463,65 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (authChoice === "venice-api-key") {
let hasCredential = false;
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "venice") {
await setVeniceApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
hasCredential = true;
}
if (!hasCredential) {
await params.prompter.note(
[
"Venice AI provides privacy-focused inference with uncensored models.",
"Get your API key at: https://venice.ai/settings/api",
"Supports 'private' (fully private) and 'anonymized' (proxy) modes.",
].join("\n"),
"Venice AI",
);
}
const envKey = resolveEnvApiKey("venice");
if (envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing VENICE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
});
if (useExisting) {
await setVeniceApiKey(envKey.apiKey, params.agentDir);
hasCredential = true;
}
}
if (!hasCredential) {
const key = await params.prompter.text({
message: "Enter Venice AI API key",
validate: validateApiKeyInput,
});
await setVeniceApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "venice:default",
provider: "venice",
mode: "api_key",
});
{
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: VENICE_DEFAULT_MODEL_REF,
applyDefaultConfig: applyVeniceConfig,
applyProviderConfig: applyVeniceProviderConfig,
noteDefault: VENICE_DEFAULT_MODEL_REF,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
}
return { config: nextConfig, agentModelOverride };
}
if (authChoice === "opencode-zen") {
let hasCredential = false;
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "opencode") {
@@ -19,6 +19,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
"google-gemini-cli": "google-gemini-cli",
"zai-api-key": "zai",
"synthetic-api-key": "synthetic",
"venice-api-key": "venice",
"github-copilot": "github-copilot",
"copilot-proxy": "copilot-proxy",
"minimax-cloud": "minimax",
+81
View File
@@ -4,6 +4,12 @@ import {
SYNTHETIC_DEFAULT_MODEL_REF,
SYNTHETIC_MODEL_CATALOG,
} from "../agents/synthetic-models.js";
import {
buildVeniceModelDefinition,
VENICE_BASE_URL,
VENICE_DEFAULT_MODEL_REF,
VENICE_MODEL_CATALOG,
} from "../agents/venice-models.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
OPENROUTER_DEFAULT_MODEL_REF,
@@ -330,6 +336,81 @@ export function applySyntheticConfig(cfg: ClawdbotConfig): ClawdbotConfig {
};
}
/**
* Apply Venice provider configuration without changing the default model.
* Registers Venice models and sets up the provider, but preserves existing model selection.
*/
export function applyVeniceProviderConfig(cfg: ClawdbotConfig): ClawdbotConfig {
const models = { ...cfg.agents?.defaults?.models };
models[VENICE_DEFAULT_MODEL_REF] = {
...models[VENICE_DEFAULT_MODEL_REF],
alias: models[VENICE_DEFAULT_MODEL_REF]?.alias ?? "Llama 3.3 70B",
};
const providers = { ...cfg.models?.providers };
const existingProvider = providers.venice;
const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : [];
const veniceModels = VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
const mergedModels = [
...existingModels,
...veniceModels.filter((model) => !existingModels.some((existing) => existing.id === model.id)),
];
const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record<
string,
unknown
> as { apiKey?: string };
const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined;
const normalizedApiKey = resolvedApiKey?.trim();
providers.venice = {
...existingProviderRest,
baseUrl: VENICE_BASE_URL,
api: "openai-completions",
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
models: mergedModels.length > 0 ? mergedModels : veniceModels,
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
models: {
mode: cfg.models?.mode ?? "merge",
providers,
},
};
}
/**
* Apply Venice provider configuration AND set Venice as the default model.
* Use this when Venice is the primary provider choice during onboarding.
*/
export function applyVeniceConfig(cfg: ClawdbotConfig): ClawdbotConfig {
const next = applyVeniceProviderConfig(cfg);
const existingModel = next.agents?.defaults?.model;
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(existingModel && "fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks,
}
: undefined),
primary: VENICE_DEFAULT_MODEL_REF,
},
},
},
};
}
export function applyAuthProfileConfig(
cfg: ClawdbotConfig,
params: {
+13
View File
@@ -99,6 +99,19 @@ export async function setSyntheticApiKey(key: string, agentDir?: string) {
});
}
export async function setVeniceApiKey(key: string, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: "venice:default",
credential: {
type: "api_key",
provider: "venice",
key,
},
agentDir: resolveAuthAgentDir(agentDir),
});
}
export const ZAI_DEFAULT_MODEL_REF = "zai/glm-4.7";
export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto";
export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.5";
+4
View File
@@ -2,6 +2,7 @@ export {
SYNTHETIC_DEFAULT_MODEL_ID,
SYNTHETIC_DEFAULT_MODEL_REF,
} from "../agents/synthetic-models.js";
export { VENICE_DEFAULT_MODEL_ID, VENICE_DEFAULT_MODEL_REF } from "../agents/venice-models.js";
export {
applyAuthProfileConfig,
applyKimiCodeConfig,
@@ -12,6 +13,8 @@ export {
applyOpenrouterProviderConfig,
applySyntheticConfig,
applySyntheticProviderConfig,
applyVeniceConfig,
applyVeniceProviderConfig,
applyVercelAiGatewayConfig,
applyVercelAiGatewayProviderConfig,
applyZaiConfig,
@@ -39,6 +42,7 @@ export {
setOpencodeZenApiKey,
setOpenrouterApiKey,
setSyntheticApiKey,
setVeniceApiKey,
setVercelAiGatewayApiKey,
setZaiApiKey,
writeOAuthCredentials,
+2
View File
@@ -16,6 +16,7 @@ export type AuthChoice =
| "moonshot-api-key"
| "kimi-code-api-key"
| "synthetic-api-key"
| "venice-api-key"
| "codex-cli"
| "apiKey"
| "gemini-api-key"
@@ -68,6 +69,7 @@ export type OnboardOptions = {
zaiApiKey?: string;
minimaxApiKey?: string;
syntheticApiKey?: string;
veniceApiKey?: string;
opencodeZenApiKey?: string;
gatewayPort?: number;
gatewayBind?: GatewayBind;
@@ -138,6 +138,16 @@ describe("legacy config detection", () => {
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.channels?.telegram?.requireMention).toBeUndefined();
});
it("migrates messages.tts.enabled to messages.tts.auto", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
messages: { tts: { enabled: true } },
});
expect(res.changes).toContain("Moved messages.tts.enabled → messages.tts.auto (always).");
expect(res.config?.messages?.tts?.auto).toBe("always");
expect(res.config?.messages?.tts?.enabled).toBeUndefined();
});
it("migrates legacy model config to agent.models + model lists", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
+20
View File
@@ -40,6 +40,26 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
delete tools.bash;
},
},
{
id: "messages.tts.enabled->auto",
describe: "Move messages.tts.enabled to messages.tts.auto",
apply: (raw, changes) => {
const messages = getRecord(raw.messages);
const tts = getRecord(messages?.tts);
if (!tts) return;
if (tts.auto !== undefined) {
if ("enabled" in tts) {
delete tts.enabled;
changes.push("Removed messages.tts.enabled (messages.tts.auto already set).");
}
return;
}
if (typeof tts.enabled !== "boolean") return;
tts.auto = tts.enabled ? "always" : "off";
delete tts.enabled;
changes.push(`Moved messages.tts.enabled → messages.tts.auto (${String(tts.auto)}).`);
},
},
{
id: "agent.defaults-v2",
describe: "Move agent config to agents.defaults and tools",
+4
View File
@@ -120,6 +120,10 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
message:
"agent.imageModelFallbacks was replaced by agents.defaults.imageModel.fallbacks (auto-migrated on load).",
},
{
path: ["messages", "tts", "enabled"],
message: "messages.tts.enabled was replaced by messages.tts.auto (auto-migrated on load).",
},
{
path: ["gateway", "token"],
message: "gateway.token is ignored; use gateway.auth.token instead (auto-migrated on load).",
+2
View File
@@ -4,6 +4,7 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
import type { NormalizedChatType } from "../../channels/chat-type.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { DeliveryContext } from "../../utils/delivery-context.js";
import type { TtsAutoMode } from "../types.tts.js";
export type SessionScope = "per-sender" | "global";
@@ -42,6 +43,7 @@ export type SessionEntry = {
verboseLevel?: string;
reasoningLevel?: string;
elevatedLevel?: string;
ttsAuto?: TtsAutoMode;
execHost?: string;
execSecurity?: string;
execAsk?: string;
+6
View File
@@ -218,4 +218,10 @@ export type GatewayConfig = {
tls?: GatewayTlsConfig;
http?: GatewayHttpConfig;
nodes?: GatewayNodesConfig;
/**
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection
* arrives from one of these IPs, the Gateway trusts `x-forwarded-for` (or
* `x-real-ip`) to determine the client IP for local pairing and HTTP checks.
*/
trustedProxies?: string[];
};
+14
View File
@@ -8,6 +8,7 @@ import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig } from "./types.messages.js";
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive";
export type SignalAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
@@ -64,6 +65,19 @@ export type SignalAccountConfig = {
reactionNotifications?: SignalReactionNotificationMode;
/** Allowlist for reaction notifications when mode is allowlist. */
reactionAllowlist?: Array<string | number>;
/** Action toggles for message tool capabilities. */
actions?: {
/** Enable/disable sending reactions via message tool (default: true). */
reactions?: boolean;
};
/**
* Controls agent reaction behavior:
* - "off": No reactions
* - "ack": Only automatic ack reactions (👀 when processing)
* - "minimal": Agent can react sparingly (default)
* - "extensive": Agent can react liberally
*/
reactionLevel?: SignalReactionLevel;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
+20 -2
View File
@@ -1,7 +1,9 @@
export type TtsProvider = "elevenlabs" | "openai";
export type TtsProvider = "elevenlabs" | "openai" | "edge";
export type TtsMode = "final" | "all";
export type TtsAutoMode = "off" | "always" | "inbound" | "tagged";
export type TtsModelOverrideConfig = {
/** Enable model-provided overrides for TTS. */
enabled?: boolean;
@@ -22,7 +24,9 @@ export type TtsModelOverrideConfig = {
};
export type TtsConfig = {
/** Enable auto-TTS (can be overridden by local prefs). */
/** Auto-TTS mode (preferred). */
auto?: TtsAutoMode;
/** Legacy: enable auto-TTS when `auto` is not set. */
enabled?: boolean;
/** Apply TTS to final replies only or to all replies (tool/block/final). */
mode?: TtsMode;
@@ -55,6 +59,20 @@ export type TtsConfig = {
model?: string;
voice?: string;
};
/** Microsoft Edge (node-edge-tts) configuration. */
edge?: {
/** Explicitly allow Edge TTS usage (no API key required). */
enabled?: boolean;
voice?: string;
lang?: string;
outputFormat?: string;
pitch?: string;
rate?: string;
volume?: string;
saveSubtitles?: boolean;
proxy?: string;
timeoutMs?: number;
};
/** Optional path for local TTS user preferences JSON. */
prefsPath?: string;
/** Hard cap for text sent to TTS (chars). */
+18 -1
View File
@@ -156,10 +156,12 @@ export const MarkdownConfigSchema = z
.strict()
.optional();
export const TtsProviderSchema = z.enum(["elevenlabs", "openai"]);
export const TtsProviderSchema = z.enum(["elevenlabs", "openai", "edge"]);
export const TtsModeSchema = z.enum(["final", "all"]);
export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]);
export const TtsConfigSchema = z
.object({
auto: TtsAutoSchema.optional(),
enabled: z.boolean().optional(),
mode: TtsModeSchema.optional(),
provider: TtsProviderSchema.optional(),
@@ -207,6 +209,21 @@ export const TtsConfigSchema = z
})
.strict()
.optional(),
edge: z
.object({
enabled: z.boolean().optional(),
voice: z.string().optional(),
lang: z.string().optional(),
outputFormat: z.string().optional(),
pitch: z.string().optional(),
rate: z.string().optional(),
volume: z.string().optional(),
saveSubtitles: z.boolean().optional(),
proxy: z.string().optional(),
timeoutMs: z.number().int().min(1000).max(120000).optional(),
})
.strict()
.optional(),
prefsPath: z.string().optional(),
maxTextLength: z.number().int().min(1).optional(),
timeoutMs: z.number().int().min(1000).max(120000).optional(),
+7
View File
@@ -499,6 +499,13 @@ export const SignalAccountSchemaBase = z
mediaMaxMb: z.number().int().positive().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
actions: z
.object({
reactions: z.boolean().optional(),
})
.strict()
.optional(),
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
})
.strict();
+1
View File
@@ -324,6 +324,7 @@ export const ClawdbotSchema = z
})
.strict()
.optional(),
trustedProxies: z.array(z.string()).optional(),
tailscale: z
.object({
mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(),
@@ -1,3 +1,4 @@
import { ChannelType } from "@buape/carbon";
import { resolveAckReaction, resolveHumanDelayConfig } from "../../agents/identity.js";
import {
removeAckReactionAfterReply,
@@ -129,6 +130,14 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
senderDisplay && senderTag && senderDisplay !== senderTag
? `${senderDisplay} (${senderTag})`
: (senderDisplay ?? senderTag ?? author.id);
const isForumParent =
threadParentType === ChannelType.GuildForum || threadParentType === ChannelType.GuildMedia;
const forumParentSlug =
isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : "";
const threadChannelId = threadChannel?.id;
const isForumStarter =
Boolean(threadChannelId && isForumParent && forumParentSlug) && message.id === threadChannelId;
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
const groupSubject = isDirectMessage ? undefined : groupChannel;
const channelDescription = channelInfo?.topic?.trim();
@@ -182,6 +191,9 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
if (replyContext) {
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
}
if (forumContextLine) {
combinedBody = `${combinedBody}\n${forumContextLine}`;
}
let threadStarterBody: string | undefined;
let threadLabel: string | undefined;
+15
View File
@@ -142,4 +142,19 @@ describe("gateway auth", () => {
expect(res.method).toBe("tailscale");
expect(res.user).toBe("peter");
});
it("treats trusted proxy loopback clients as direct", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
trustedProxies: ["10.0.0.2"],
req: {
socket: { remoteAddress: "10.0.0.2" },
headers: { host: "localhost", "x-forwarded-for": "127.0.0.1" },
} as never,
});
expect(res.ok).toBe(true);
expect(res.method).toBe("none");
});
});
+25 -5
View File
@@ -1,6 +1,7 @@
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage } from "node:http";
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
import { isTrustedProxyAddress, resolveGatewayClientIp } from "./net.js";
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
export type ResolvedGatewayAuth = {
@@ -53,9 +54,26 @@ function getHostName(hostHeader?: string): string {
return name ?? "";
}
function isLocalDirectRequest(req?: IncomingMessage): boolean {
function headerValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function resolveRequestClientIp(
req?: IncomingMessage,
trustedProxies?: string[],
): string | undefined {
if (!req) return undefined;
return resolveGatewayClientIp({
remoteAddr: req.socket?.remoteAddress ?? "",
forwardedFor: headerValue(req.headers?.["x-forwarded-for"]),
realIp: headerValue(req.headers?.["x-real-ip"]),
trustedProxies,
});
}
function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean {
if (!req) return false;
const clientIp = req.socket?.remoteAddress ?? "";
const clientIp = resolveRequestClientIp(req, trustedProxies) ?? "";
if (!isLoopbackAddress(clientIp)) return false;
const host = getHostName(req.headers?.host);
@@ -68,7 +86,8 @@ function isLocalDirectRequest(req?: IncomingMessage): boolean {
req.headers?.["x-forwarded-host"],
);
return (hostIsLocal || hostIsTailscaleServe) && !hasForwarded;
const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies);
return (hostIsLocal || hostIsTailscaleServe) && (!hasForwarded || remoteIsTrustedProxy);
}
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
@@ -135,9 +154,10 @@ export async function authorizeGatewayConnect(params: {
auth: ResolvedGatewayAuth;
connectAuth?: ConnectAuth | null;
req?: IncomingMessage;
trustedProxies?: string[];
}): Promise<GatewayAuthResult> {
const { auth, connectAuth, req } = params;
const localDirect = isLocalDirectRequest(req);
const { auth, connectAuth, req, trustedProxies } = params;
const localDirect = isLocalDirectRequest(req, trustedProxies);
if (auth.allowTailscale && !localDirect) {
const tailscaleUser = getTailscaleUser(req);
+37 -1
View File
@@ -8,7 +8,7 @@ vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv6: () => testTailnetIPv6.value,
}));
import { isLocalGatewayAddress } from "./net.js";
import { isLocalGatewayAddress, resolveGatewayClientIp } from "./net.js";
describe("gateway net", () => {
beforeEach(() => {
@@ -38,4 +38,40 @@ describe("gateway net", () => {
testTailnetIPv6.value = "fd7a:115c:a1e0::123";
expect(isLocalGatewayAddress("fd7a:115c:a1e0::123")).toBe(true);
});
test("uses forwarded-for when remote is a trusted proxy", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "10.0.0.2",
forwardedFor: "203.0.113.9, 10.0.0.2",
trustedProxies: ["10.0.0.2"],
});
expect(clientIp).toBe("203.0.113.9");
});
test("ignores forwarded-for from untrusted proxies", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "10.0.0.3",
forwardedFor: "203.0.113.9",
trustedProxies: ["10.0.0.2"],
});
expect(clientIp).toBe("10.0.0.3");
});
test("normalizes trusted proxy IPs and strips forwarded ports", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "::ffff:10.0.0.2",
forwardedFor: "203.0.113.9:1234",
trustedProxies: ["10.0.0.2"],
});
expect(clientIp).toBe("203.0.113.9");
});
test("falls back to x-real-ip when forwarded-for is missing", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "10.0.0.2",
realIp: "203.0.113.10",
trustedProxies: ["10.0.0.2"],
});
expect(clientIp).toBe("203.0.113.10");
});
});
+50
View File
@@ -16,6 +16,56 @@ function normalizeIPv4MappedAddress(ip: string): string {
return ip;
}
function normalizeIp(ip: string | undefined): string | undefined {
const trimmed = ip?.trim();
if (!trimmed) return undefined;
return normalizeIPv4MappedAddress(trimmed.toLowerCase());
}
function stripOptionalPort(ip: string): string {
if (ip.startsWith("[")) {
const end = ip.indexOf("]");
if (end !== -1) return ip.slice(1, end);
}
if (net.isIP(ip)) return ip;
const lastColon = ip.lastIndexOf(":");
if (lastColon > -1 && ip.includes(".") && ip.indexOf(":") === lastColon) {
const candidate = ip.slice(0, lastColon);
if (net.isIP(candidate) === 4) return candidate;
}
return ip;
}
function parseForwardedForClientIp(forwardedFor?: string): string | undefined {
const raw = forwardedFor?.split(",")[0]?.trim();
if (!raw) return undefined;
return normalizeIp(stripOptionalPort(raw));
}
function parseRealIp(realIp?: string): string | undefined {
const raw = realIp?.trim();
if (!raw) return undefined;
return normalizeIp(stripOptionalPort(raw));
}
export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[]): boolean {
const normalized = normalizeIp(ip);
if (!normalized || !trustedProxies || trustedProxies.length === 0) return false;
return trustedProxies.some((proxy) => normalizeIp(proxy) === normalized);
}
export function resolveGatewayClientIp(params: {
remoteAddr?: string;
forwardedFor?: string;
realIp?: string;
trustedProxies?: string[];
}): string | undefined {
const remote = normalizeIp(params.remoteAddr);
if (!remote) return undefined;
if (!isTrustedProxyAddress(remote, params.trustedProxies)) return remote;
return parseForwardedForClientIp(params.forwardedFor) ?? parseRealIp(params.realIp) ?? remote;
}
export function isLocalGatewayAddress(ip: string | undefined): boolean {
if (isLoopbackAddress(ip)) return true;
if (!ip) return false;
+2
View File
@@ -20,6 +20,7 @@ import { getBearerToken, resolveAgentIdForRequest, resolveSessionKey } from "./h
type OpenAiHttpOptions = {
auth: ResolvedGatewayAuth;
maxBodyBytes?: number;
trustedProxies?: string[];
};
type OpenAiChatMessage = {
@@ -168,6 +169,7 @@ export async function handleOpenAiHttpRequest(
auth: opts.auth,
connectAuth: { token, password: token },
req,
trustedProxies: opts.trustedProxies,
});
if (!authResult.ok) {
sendUnauthorized(res);
+2
View File
@@ -60,6 +60,7 @@ type OpenResponsesHttpOptions = {
auth: ResolvedGatewayAuth;
maxBodyBytes?: number;
config?: GatewayHttpResponsesConfig;
trustedProxies?: string[];
};
const DEFAULT_BODY_BYTES = 20 * 1024 * 1024;
@@ -331,6 +332,7 @@ export async function handleOpenResponsesHttpRequest(
auth: opts.auth,
connectAuth: { token, password: token },
req,
trustedProxies: opts.trustedProxies,
});
if (!authResult.ok) {
sendUnauthorized(res);
+5 -1
View File
@@ -13,6 +13,7 @@ export async function startGatewayDiscovery(params: {
gatewayTls?: { enabled: boolean; fingerprintSha256?: string };
canvasPort?: number;
wideAreaDiscoveryEnabled: boolean;
tailscaleMode: "off" | "serve" | "funnel";
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
}) {
let bonjourStop: (() => Promise<void>) | null = null;
@@ -20,8 +21,11 @@ export async function startGatewayDiscovery(params: {
process.env.CLAWDBOT_DISABLE_BONJOUR !== "1" &&
process.env.NODE_ENV !== "test" &&
!process.env.VITEST;
const tailscaleEnabled = params.tailscaleMode !== "off";
const needsTailnetDns = bonjourEnabled || params.wideAreaDiscoveryEnabled;
const tailnetDns = needsTailnetDns ? await resolveTailnetDnsHint() : undefined;
const tailnetDns = needsTailnetDns
? await resolveTailnetDnsHint({ enabled: tailscaleEnabled })
: undefined;
const sshPortEnv = process.env.CLAWDBOT_SSH_PORT?.trim();
const sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN;
const sshPort = Number.isFinite(sshPortParsed) && sshPortParsed > 0 ? sshPortParsed : undefined;
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const getTailnetHostname = vi.hoisted(() => vi.fn());
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
import { resolveTailnetDnsHint } from "./server-discovery.js";
describe("resolveTailnetDnsHint", () => {
const prevTailnetDns = { value: undefined as string | undefined };
beforeEach(() => {
prevTailnetDns.value = process.env.CLAWDBOT_TAILNET_DNS;
delete process.env.CLAWDBOT_TAILNET_DNS;
getTailnetHostname.mockReset();
});
afterEach(() => {
if (prevTailnetDns.value === undefined) {
delete process.env.CLAWDBOT_TAILNET_DNS;
} else {
process.env.CLAWDBOT_TAILNET_DNS = prevTailnetDns.value;
}
});
test("returns env hint when disabled", async () => {
process.env.CLAWDBOT_TAILNET_DNS = "studio.tailnet.ts.net.";
const value = await resolveTailnetDnsHint({ enabled: false });
expect(value).toBe("studio.tailnet.ts.net");
expect(getTailnetHostname).not.toHaveBeenCalled();
});
test("skips tailscale lookup when disabled", async () => {
const value = await resolveTailnetDnsHint({ enabled: false });
expect(value).toBeUndefined();
expect(getTailnetHostname).not.toHaveBeenCalled();
});
test("uses tailscale lookup when enabled", async () => {
getTailnetHostname.mockResolvedValue("host.tailnet.ts.net");
const value = await resolveTailnetDnsHint({ enabled: true });
expect(value).toBe("host.tailnet.ts.net");
expect(getTailnetHostname).toHaveBeenCalledTimes(1);
});
});
+2
View File
@@ -55,11 +55,13 @@ export function resolveBonjourCliPath(opts: ResolveBonjourCliPathOptions = {}):
export async function resolveTailnetDnsHint(opts?: {
env?: NodeJS.ProcessEnv;
exec?: typeof runExec;
enabled?: boolean;
}): Promise<string | undefined> {
const env = opts?.env ?? process.env;
const envRaw = env.CLAWDBOT_TAILNET_DNS?.trim();
const envValue = envRaw && envRaw.length > 0 ? envRaw.replace(/\.$/, "") : "";
if (envValue) return envValue;
if (opts?.enabled === false) return undefined;
const exec =
opts?.exec ??
+19 -4
View File
@@ -227,21 +227,36 @@ export function createGatewayHttpServer(opts: {
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
try {
const configSnapshot = loadConfig();
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
if (await handleHooksRequest(req, res)) return;
if (await handleSlackHttpRequest(req, res)) return;
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
if (await handleToolsInvokeHttpRequest(req, res, { auth: resolvedAuth })) return;
if (
await handleToolsInvokeHttpRequest(req, res, {
auth: resolvedAuth,
trustedProxies,
})
)
return;
if (openResponsesEnabled) {
if (
await handleOpenResponsesHttpRequest(req, res, {
auth: resolvedAuth,
config: openResponsesConfig,
trustedProxies,
})
)
return;
}
if (openAiChatCompletionsEnabled) {
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return;
if (
await handleOpenAiHttpRequest(req, res, {
auth: resolvedAuth,
trustedProxies,
})
)
return;
}
if (canvasHost) {
if (await handleA2uiHttpRequest(req, res)) return;
@@ -251,14 +266,14 @@ export function createGatewayHttpServer(opts: {
if (
handleControlUiAvatarRequest(req, res, {
basePath: controlUiBasePath,
resolveAvatar: (agentId) => resolveAgentAvatar(loadConfig(), agentId),
resolveAvatar: (agentId) => resolveAgentAvatar(configSnapshot, agentId),
})
)
return;
if (
handleControlUiHttpRequest(req, res, {
basePath: controlUiBasePath,
config: loadConfig(),
config: configSnapshot,
})
)
return;
+22 -3
View File
@@ -4,9 +4,12 @@ import {
OPENAI_TTS_VOICES,
getTtsProvider,
isTtsEnabled,
isTtsProviderConfigured,
resolveTtsAutoMode,
resolveTtsApiKey,
resolveTtsConfig,
resolveTtsPrefsPath,
resolveTtsProviderOrder,
setTtsEnabled,
setTtsProvider,
textToSpeech,
@@ -22,13 +25,20 @@ export const ttsHandlers: GatewayRequestHandlers = {
const config = resolveTtsConfig(cfg);
const prefsPath = resolveTtsPrefsPath(config);
const provider = getTtsProvider(config, prefsPath);
const autoMode = resolveTtsAutoMode({ config, prefsPath });
const fallbackProviders = resolveTtsProviderOrder(provider)
.slice(1)
.filter((candidate) => isTtsProviderConfigured(config, candidate));
respond(true, {
enabled: isTtsEnabled(config, prefsPath),
auto: autoMode,
provider,
fallbackProvider: provider === "openai" ? "elevenlabs" : "openai",
fallbackProvider: fallbackProviders[0] ?? null,
fallbackProviders,
prefsPath,
hasOpenAIKey: Boolean(resolveTtsApiKey(config, "openai")),
hasElevenLabsKey: Boolean(resolveTtsApiKey(config, "elevenlabs")),
edgeEnabled: isTtsProviderConfigured(config, "edge"),
});
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
@@ -90,11 +100,14 @@ export const ttsHandlers: GatewayRequestHandlers = {
},
"tts.setProvider": async ({ params, respond }) => {
const provider = typeof params.provider === "string" ? params.provider.trim() : "";
if (provider !== "openai" && provider !== "elevenlabs") {
if (provider !== "openai" && provider !== "elevenlabs" && provider !== "edge") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "Invalid provider. Use openai or elevenlabs."),
errorShape(
ErrorCodes.INVALID_REQUEST,
"Invalid provider. Use openai, elevenlabs, or edge.",
),
);
return;
}
@@ -128,6 +141,12 @@ export const ttsHandlers: GatewayRequestHandlers = {
configured: Boolean(resolveTtsApiKey(config, "elevenlabs")),
models: ["eleven_multilingual_v2", "eleven_turbo_v2_5", "eleven_monolingual_v1"],
},
{
id: "edge",
name: "Edge TTS",
configured: isTtsProviderConfigured(config, "edge"),
models: [],
},
],
active: getTtsProvider(config, prefsPath),
});
+1
View File
@@ -339,6 +339,7 @@ export async function startGatewayServer(
? { enabled: true, fingerprintSha256: gatewayTls.fingerprintSha256 }
: undefined,
wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true,
tailscaleMode,
logDiscovery,
});
bonjourStop = discovery.bonjourStop;
+2
View File
@@ -73,6 +73,7 @@ export function attachGatewayWsConnectionHandler(params: {
const requestOrigin = headerValue(upgradeReq.headers.origin);
const requestUserAgent = headerValue(upgradeReq.headers["user-agent"]);
const forwardedFor = headerValue(upgradeReq.headers["x-forwarded-for"]);
const realIp = headerValue(upgradeReq.headers["x-real-ip"]);
const canvasHostPortForWs = canvasHostServerPort ?? (canvasHostEnabled ? port : undefined);
const canvasHostOverride =
@@ -228,6 +229,7 @@ export function attachGatewayWsConnectionHandler(params: {
connId,
remoteAddr,
forwardedFor,
realIp,
requestHost,
requestOrigin,
requestUserAgent,
@@ -26,7 +26,7 @@ import type { ResolvedGatewayAuth } from "../../auth.js";
import { authorizeGatewayConnect } from "../../auth.js";
import { loadConfig } from "../../../config/config.js";
import { buildDeviceAuthPayload } from "../../device-auth.js";
import { isLocalGatewayAddress } from "../../net.js";
import { isLocalGatewayAddress, resolveGatewayClientIp } from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import {
type ConnectParams,
@@ -104,6 +104,7 @@ export function attachGatewayWsMessageHandler(params: {
connId: string;
remoteAddr?: string;
forwardedFor?: string;
realIp?: string;
requestHost?: string;
requestOrigin?: string;
requestUserAgent?: string;
@@ -133,6 +134,7 @@ export function attachGatewayWsMessageHandler(params: {
connId,
remoteAddr,
forwardedFor,
realIp,
requestHost,
requestOrigin,
requestUserAgent,
@@ -157,6 +159,11 @@ export function attachGatewayWsMessageHandler(params: {
logWsControl,
} = params;
const configSnapshot = loadConfig();
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
const clientIp = resolveGatewayClientIp({ remoteAddr, forwardedFor, realIp, trustedProxies });
const isLocalClient = isLocalGatewayAddress(clientIp);
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
socket.on("message", async (data) => {
@@ -300,7 +307,7 @@ export function attachGatewayWsMessageHandler(params: {
if (!device) {
const allowInsecureControlUi =
isControlUi && loadConfig().gateway?.controlUi?.allowInsecureAuth === true;
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
const canSkipDevice =
isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth;
@@ -380,7 +387,7 @@ export function attachGatewayWsMessageHandler(params: {
close(1008, "device signature expired");
return;
}
const nonceRequired = !isLocalGatewayAddress(remoteAddr);
const nonceRequired = !isLocalClient;
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
if (nonceRequired && !providedNonce) {
setHandshakeState("failed");
@@ -495,6 +502,7 @@ export function attachGatewayWsMessageHandler(params: {
auth: resolvedAuth,
connectAuth: connectParams.auth,
req: upgradeReq,
trustedProxies,
});
let authOk = authResult.ok;
let authMethod = authResult.method ?? "none";
@@ -556,8 +564,8 @@ export function attachGatewayWsMessageHandler(params: {
clientMode: connectParams.client.mode,
role,
scopes,
remoteIp: remoteAddr,
silent: isLocalGatewayAddress(remoteAddr),
remoteIp: clientIp,
silent: isLocalClient,
});
const context = buildRequestContext();
if (pairing.request.silent === true) {
@@ -640,7 +648,7 @@ export function attachGatewayWsMessageHandler(params: {
clientMode: connectParams.client.mode,
role,
scopes,
remoteIp: remoteAddr,
remoteIp: clientIp,
});
}
}
@@ -689,7 +697,7 @@ export function attachGatewayWsMessageHandler(params: {
if (presenceKey) {
upsertPresence(presenceKey, {
host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(),
ip: isLocalGatewayAddress(remoteAddr) ? undefined : remoteAddr,
ip: isLocalClient ? undefined : clientIp,
version: connectParams.client.version,
platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily,
@@ -748,7 +756,7 @@ export function attachGatewayWsMessageHandler(params: {
setHandshakeState("connected");
if (role === "node") {
const context = buildRequestContext();
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr });
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: clientIp });
const instanceIdRaw = connectParams.client.instanceId;
const instanceId = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : "";
const nodeIdsForPairing = new Set<string>([nodeSession.nodeId]);
+3 -2
View File
@@ -70,7 +70,7 @@ function mergeActionIntoArgsIfSupported(params: {
export async function handleToolsInvokeHttpRequest(
req: IncomingMessage,
res: ServerResponse,
opts: { auth: ResolvedGatewayAuth; maxBodyBytes?: number },
opts: { auth: ResolvedGatewayAuth; maxBodyBytes?: number; trustedProxies?: string[] },
): Promise<boolean> {
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
if (url.pathname !== "/tools/invoke") return false;
@@ -80,11 +80,13 @@ export async function handleToolsInvokeHttpRequest(
return true;
}
const cfg = loadConfig();
const token = getBearerToken(req);
const authResult = await authorizeGatewayConnect({
auth: opts.auth,
connectAuth: token ? { token, password: token } : null,
req,
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
});
if (!authResult.ok) {
sendUnauthorized(res);
@@ -110,7 +112,6 @@ export async function handleToolsInvokeHttpRequest(
: {}
) as Record<string, unknown>;
const cfg = loadConfig();
const rawSessionKey = resolveSessionKeyFromBody(body);
const sessionKey =
!rawSessionKey || rawSessionKey === "main" ? resolveMainSessionKey(cfg) : rawSessionKey;
+2
View File
@@ -40,6 +40,7 @@ import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/a
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { recordInboundSession } from "../../channels/session.js";
import { discordMessageActions } from "../../channels/plugins/actions/discord.js";
import { signalMessageActions } from "../../channels/plugins/actions/signal.js";
import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js";
import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js";
import { monitorWebChannel } from "../../channels/web/index.js";
@@ -269,6 +270,7 @@ export function createPluginRuntime(): PluginRuntime {
probeSignal,
sendMessageSignal,
monitorSignalProvider,
messageActions: signalMessageActions,
},
imessage: {
monitorIMessageProvider,
+3
View File
@@ -123,6 +123,8 @@ type TelegramMessageActions =
type ProbeSignal = typeof import("../../signal/probe.js").probeSignal;
type SendMessageSignal = typeof import("../../signal/send.js").sendMessageSignal;
type MonitorSignalProvider = typeof import("../../signal/index.js").monitorSignalProvider;
type SignalMessageActions =
typeof import("../../channels/plugins/actions/signal.js").signalMessageActions;
type MonitorIMessageProvider = typeof import("../../imessage/monitor.js").monitorIMessageProvider;
type ProbeIMessage = typeof import("../../imessage/probe.js").probeIMessage;
type SendMessageIMessage = typeof import("../../imessage/send.js").sendMessageIMessage;
@@ -278,6 +280,7 @@ export type PluginRuntime = {
probeSignal: ProbeSignal;
sendMessageSignal: SendMessageSignal;
monitorSignalProvider: MonitorSignalProvider;
messageActions: SignalMessageActions;
};
imessage: {
monitorIMessageProvider: MonitorIMessageProvider;
+2
View File
@@ -1,3 +1,5 @@
export { monitorSignalProvider } from "./monitor.js";
export { probeSignal } from "./probe.js";
export { sendMessageSignal } from "./send.js";
export { sendReactionSignal, removeReactionSignal } from "./send-reactions.js";
export { resolveSignalReactionLevel } from "./reaction-level.js";
+71
View File
@@ -0,0 +1,71 @@
import type { ClawdbotConfig } from "../config/config.js";
import { resolveSignalAccount } from "./accounts.js";
export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive";
export type ResolvedSignalReactionLevel = {
level: SignalReactionLevel;
/** Whether ACK reactions (e.g., 👀 when processing) are enabled. */
ackEnabled: boolean;
/** Whether agent-controlled reactions are enabled. */
agentReactionsEnabled: boolean;
/** Guidance level for agent reactions (minimal = sparse, extensive = liberal). */
agentReactionGuidance?: "minimal" | "extensive";
};
/**
* Resolve the effective reaction level and its implications for Signal.
*
* Levels:
* - "off": No reactions at all
* - "ack": Only automatic ack reactions (👀 when processing), no agent reactions
* - "minimal": Agent can react, but sparingly (default)
* - "extensive": Agent can react liberally
*/
export function resolveSignalReactionLevel(params: {
cfg: ClawdbotConfig;
accountId?: string;
}): ResolvedSignalReactionLevel {
const account = resolveSignalAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const level = (account.config.reactionLevel ?? "minimal") as SignalReactionLevel;
switch (level) {
case "off":
return {
level,
ackEnabled: false,
agentReactionsEnabled: false,
};
case "ack":
return {
level,
ackEnabled: true,
agentReactionsEnabled: false,
};
case "minimal":
return {
level,
ackEnabled: false,
agentReactionsEnabled: true,
agentReactionGuidance: "minimal",
};
case "extensive":
return {
level,
ackEnabled: false,
agentReactionsEnabled: true,
agentReactionGuidance: "extensive",
};
default:
// Fallback to minimal behavior
return {
level: "minimal",
ackEnabled: false,
agentReactionsEnabled: true,
agentReactionGuidance: "minimal",
};
}
}
+69
View File
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const rpcMock = vi.fn();
const loadSendReactions = async () => await import("./send-reactions.js");
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({}),
};
});
vi.mock("./accounts.js", () => ({
resolveSignalAccount: () => ({
accountId: "default",
enabled: true,
baseUrl: "http://signal.local",
configured: true,
config: { account: "+15550001111" },
}),
}));
vi.mock("./client.js", () => ({
signalRpcRequest: (...args: unknown[]) => rpcMock(...args),
}));
describe("sendReactionSignal", () => {
beforeEach(() => {
rpcMock.mockReset().mockResolvedValue({ timestamp: 123 });
vi.resetModules();
});
it("uses recipients array and targetAuthor for uuid dms", async () => {
const { sendReactionSignal } = await loadSendReactions();
await sendReactionSignal("uuid:123e4567-e89b-12d3-a456-426614174000", 123, "🔥");
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
expect(rpcMock).toHaveBeenCalledWith("sendReaction", expect.any(Object), expect.any(Object));
expect(params.recipients).toEqual(["123e4567-e89b-12d3-a456-426614174000"]);
expect(params.groupIds).toBeUndefined();
expect(params.targetAuthor).toBe("123e4567-e89b-12d3-a456-426614174000");
expect(params).not.toHaveProperty("recipient");
expect(params).not.toHaveProperty("groupId");
});
it("uses groupIds array and maps targetAuthorUuid", async () => {
const { sendReactionSignal } = await loadSendReactions();
await sendReactionSignal("", 123, "✅", {
groupId: "group-id",
targetAuthorUuid: "uuid:123e4567-e89b-12d3-a456-426614174000",
});
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
expect(params.recipients).toBeUndefined();
expect(params.groupIds).toEqual(["group-id"]);
expect(params.targetAuthor).toBe("123e4567-e89b-12d3-a456-426614174000");
});
it("defaults targetAuthor to recipient for removals", async () => {
const { removeReactionSignal } = await loadSendReactions();
await removeReactionSignal("+15551230000", 456, "❌");
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
expect(params.recipients).toEqual(["+15551230000"]);
expect(params.targetAuthor).toBe("+15551230000");
expect(params.remove).toBe(true);
});
});
+195
View File
@@ -0,0 +1,195 @@
/**
* Signal reactions via signal-cli JSON-RPC API
*/
import { loadConfig } from "../config/config.js";
import { resolveSignalAccount } from "./accounts.js";
import { signalRpcRequest } from "./client.js";
export type SignalReactionOpts = {
baseUrl?: string;
account?: string;
accountId?: string;
timeoutMs?: number;
targetAuthor?: string;
targetAuthorUuid?: string;
groupId?: string;
};
export type SignalReactionResult = {
ok: boolean;
timestamp?: number;
};
function normalizeSignalId(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "";
return trimmed.replace(/^signal:/i, "").trim();
}
function normalizeSignalUuid(raw: string): string {
const trimmed = normalizeSignalId(raw);
if (!trimmed) return "";
if (trimmed.toLowerCase().startsWith("uuid:")) {
return trimmed.slice("uuid:".length).trim();
}
return trimmed;
}
function resolveTargetAuthorParams(params: {
targetAuthor?: string;
targetAuthorUuid?: string;
fallback?: string;
}): { targetAuthor?: string } {
const candidates = [params.targetAuthor, params.targetAuthorUuid, params.fallback];
for (const candidate of candidates) {
const raw = candidate?.trim();
if (!raw) continue;
const normalized = normalizeSignalUuid(raw);
if (normalized) return { targetAuthor: normalized };
}
return {};
}
function resolveReactionRpcContext(
opts: SignalReactionOpts,
accountInfo?: ReturnType<typeof resolveSignalAccount>,
) {
const hasBaseUrl = Boolean(opts.baseUrl?.trim());
const hasAccount = Boolean(opts.account?.trim());
const resolvedAccount =
accountInfo ||
(!hasBaseUrl || !hasAccount
? resolveSignalAccount({
cfg: loadConfig(),
accountId: opts.accountId,
})
: undefined);
const baseUrl = opts.baseUrl?.trim() || resolvedAccount?.baseUrl;
if (!baseUrl) {
throw new Error("Signal base URL is required");
}
const account = opts.account?.trim() || resolvedAccount?.config.account?.trim();
return { baseUrl, account };
}
/**
* Send a Signal reaction to a message
* @param recipient - UUID or E.164 phone number of the message author
* @param targetTimestamp - Message ID (timestamp) to react to
* @param emoji - Emoji to react with
* @param opts - Optional account/connection overrides
*/
export async function sendReactionSignal(
recipient: string,
targetTimestamp: number,
emoji: string,
opts: SignalReactionOpts = {},
): Promise<SignalReactionResult> {
const accountInfo = resolveSignalAccount({
cfg: loadConfig(),
accountId: opts.accountId,
});
const { baseUrl, account } = resolveReactionRpcContext(opts, accountInfo);
const normalizedRecipient = normalizeSignalUuid(recipient);
const groupId = opts.groupId?.trim();
if (!normalizedRecipient && !groupId) {
throw new Error("Recipient or groupId is required for Signal reaction");
}
if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) {
throw new Error("Valid targetTimestamp is required for Signal reaction");
}
if (!emoji?.trim()) {
throw new Error("Emoji is required for Signal reaction");
}
const targetAuthorParams = resolveTargetAuthorParams({
targetAuthor: opts.targetAuthor,
targetAuthorUuid: opts.targetAuthorUuid,
fallback: normalizedRecipient,
});
if (groupId && !targetAuthorParams.targetAuthor) {
throw new Error("targetAuthor is required for group reactions");
}
const params: Record<string, unknown> = {
emoji: emoji.trim(),
targetTimestamp,
...targetAuthorParams,
};
if (normalizedRecipient) params.recipients = [normalizedRecipient];
if (groupId) params.groupIds = [groupId];
if (account) params.account = account;
const result = await signalRpcRequest<{ timestamp?: number }>("sendReaction", params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
return {
ok: true,
timestamp: result?.timestamp,
};
}
/**
* Remove a Signal reaction from a message
* @param recipient - UUID or E.164 phone number of the message author
* @param targetTimestamp - Message ID (timestamp) to remove reaction from
* @param emoji - Emoji to remove
* @param opts - Optional account/connection overrides
*/
export async function removeReactionSignal(
recipient: string,
targetTimestamp: number,
emoji: string,
opts: SignalReactionOpts = {},
): Promise<SignalReactionResult> {
const accountInfo = resolveSignalAccount({
cfg: loadConfig(),
accountId: opts.accountId,
});
const { baseUrl, account } = resolveReactionRpcContext(opts, accountInfo);
const normalizedRecipient = normalizeSignalUuid(recipient);
const groupId = opts.groupId?.trim();
if (!normalizedRecipient && !groupId) {
throw new Error("Recipient or groupId is required for Signal reaction removal");
}
if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) {
throw new Error("Valid targetTimestamp is required for Signal reaction removal");
}
if (!emoji?.trim()) {
throw new Error("Emoji is required for Signal reaction removal");
}
const targetAuthorParams = resolveTargetAuthorParams({
targetAuthor: opts.targetAuthor,
targetAuthorUuid: opts.targetAuthorUuid,
fallback: normalizedRecipient,
});
if (groupId && !targetAuthorParams.targetAuthor) {
throw new Error("targetAuthor is required for group reaction removal");
}
const params: Record<string, unknown> = {
emoji: emoji.trim(),
targetTimestamp,
remove: true,
...targetAuthorParams,
};
if (normalizedRecipient) params.recipients = [normalizedRecipient];
if (groupId) params.groupIds = [groupId];
if (account) params.account = account;
const result = await signalRpcRequest<{ timestamp?: number }>("sendReaction", params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
return {
ok: true,
timestamp: result?.timestamp,
};
}
+245 -1
View File
@@ -4,7 +4,7 @@ import { completeSimple } from "@mariozechner/pi-ai";
import { getApiKeyForModel } from "../agents/model-auth.js";
import { resolveModel } from "../agents/pi-embedded-runner/model.js";
import { _test, resolveTtsConfig } from "./tts.js";
import * as tts from "./tts.js";
vi.mock("@mariozechner/pi-ai", () => ({
completeSimple: vi.fn(),
@@ -37,6 +37,8 @@ vi.mock("../agents/model-auth.js", () => ({
requireApiKey: vi.fn((auth: { apiKey?: string }) => auth.apiKey ?? ""),
}));
const { _test, resolveTtsConfig, maybeApplyTtsToPayload, getTtsProvider } = tts;
const {
isValidVoiceId,
isValidOpenAIVoice,
@@ -47,6 +49,7 @@ const {
resolveModelOverridePolicy,
summarizeText,
resolveOutputFormat,
resolveEdgeOutputFormat,
} = _test;
describe("tts", () => {
@@ -149,6 +152,30 @@ describe("tts", () => {
});
});
describe("resolveEdgeOutputFormat", () => {
const baseCfg = {
agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } },
messages: { tts: {} },
};
it("uses default output format when edge output format is not configured", () => {
const config = resolveTtsConfig(baseCfg);
expect(resolveEdgeOutputFormat(config)).toBe("audio-24khz-48kbitrate-mono-mp3");
});
it("uses configured output format when provided", () => {
const config = resolveTtsConfig({
...baseCfg,
messages: {
tts: {
edge: { outputFormat: "audio-24khz-96kbitrate-mono-mp3" },
},
},
});
expect(resolveEdgeOutputFormat(config)).toBe("audio-24khz-96kbitrate-mono-mp3");
});
});
describe("parseTtsDirectives", () => {
it("extracts overrides and strips directives when enabled", () => {
const policy = resolveModelOverridePolicy({ enabled: true });
@@ -165,6 +192,14 @@ describe("tts", () => {
expect(result.overrides.elevenlabs?.voiceSettings?.speed).toBe(1.1);
});
it("accepts edge as provider override", () => {
const policy = resolveModelOverridePolicy({ enabled: true });
const input = "Hello [[tts:provider=edge]] world";
const result = parseTtsDirectives(input, policy);
expect(result.overrides.provider).toBe("edge");
});
it("keeps text intact when overrides are disabled", () => {
const policy = resolveModelOverridePolicy({ enabled: false });
const input = "Hello [[tts:voice=alloy]] world";
@@ -314,4 +349,213 @@ describe("tts", () => {
).rejects.toThrow("No summary returned");
});
});
describe("getTtsProvider", () => {
const baseCfg = {
agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } },
messages: { tts: {} },
};
const restoreEnv = (snapshot: Record<string, string | undefined>) => {
const keys = ["OPENAI_API_KEY", "ELEVENLABS_API_KEY", "XI_API_KEY"] as const;
for (const key of keys) {
const value = snapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
};
const withEnv = (env: Record<string, string | undefined>, run: () => void) => {
const snapshot = {
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
ELEVENLABS_API_KEY: process.env.ELEVENLABS_API_KEY,
XI_API_KEY: process.env.XI_API_KEY,
};
try {
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
run();
} finally {
restoreEnv(snapshot);
}
};
it("prefers OpenAI when no provider is configured and API key exists", () => {
withEnv(
{
OPENAI_API_KEY: "test-openai-key",
ELEVENLABS_API_KEY: undefined,
XI_API_KEY: undefined,
},
() => {
const config = resolveTtsConfig(baseCfg);
const provider = getTtsProvider(config, "/tmp/tts-prefs-openai.json");
expect(provider).toBe("openai");
},
);
});
it("prefers ElevenLabs when OpenAI is missing and ElevenLabs key exists", () => {
withEnv(
{
OPENAI_API_KEY: undefined,
ELEVENLABS_API_KEY: "test-elevenlabs-key",
XI_API_KEY: undefined,
},
() => {
const config = resolveTtsConfig(baseCfg);
const provider = getTtsProvider(config, "/tmp/tts-prefs-elevenlabs.json");
expect(provider).toBe("elevenlabs");
},
);
});
it("falls back to Edge when no API keys are present", () => {
withEnv(
{
OPENAI_API_KEY: undefined,
ELEVENLABS_API_KEY: undefined,
XI_API_KEY: undefined,
},
() => {
const config = resolveTtsConfig(baseCfg);
const provider = getTtsProvider(config, "/tmp/tts-prefs-edge.json");
expect(provider).toBe("edge");
},
);
});
});
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;
});
});
});
+253 -28
View File
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { completeSimple, type TextContent } from "@mariozechner/pi-ai";
import { EdgeTTS } from "node-edge-tts";
import type { ReplyPayload } from "../auto-reply/types.js";
import { normalizeChannelId } from "../channels/plugins/index.js";
@@ -19,11 +20,13 @@ import type { ChannelId } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import type {
TtsConfig,
TtsAutoMode,
TtsMode,
TtsProvider,
TtsModelOverrideConfig,
} from "../config/types.tts.js";
import { logVerbose } from "../globals.js";
import { isVoiceCompatibleAudio } from "../media/audio.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { getApiKeyForModel, requireApiKey } from "../agents/model-auth.js";
import {
@@ -45,6 +48,9 @@ const DEFAULT_ELEVENLABS_VOICE_ID = "pMsXgVXv3BLzUgSXRplE";
const DEFAULT_ELEVENLABS_MODEL_ID = "eleven_multilingual_v2";
const DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts";
const DEFAULT_OPENAI_VOICE = "alloy";
const DEFAULT_EDGE_VOICE = "en-US-MichelleNeural";
const DEFAULT_EDGE_LANG = "en-US";
const DEFAULT_EDGE_OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3";
const DEFAULT_ELEVENLABS_VOICE_SETTINGS = {
stability: 0.5,
@@ -70,10 +76,13 @@ const DEFAULT_OUTPUT = {
voiceCompatible: false,
};
const TTS_AUTO_MODES = new Set<TtsAutoMode>(["off", "always", "inbound", "tagged"]);
export type ResolvedTtsConfig = {
enabled: boolean;
auto: TtsAutoMode;
mode: TtsMode;
provider: TtsProvider;
providerSource: "config" | "default";
summaryModel?: string;
modelOverrides: ResolvedTtsModelOverrides;
elevenlabs: {
@@ -97,6 +106,19 @@ export type ResolvedTtsConfig = {
model: string;
voice: string;
};
edge: {
enabled: boolean;
voice: string;
lang: string;
outputFormat: string;
outputFormatConfigured: boolean;
pitch?: string;
rate?: string;
volume?: string;
saveSubtitles: boolean;
proxy?: string;
timeoutMs?: number;
};
prefsPath?: string;
maxTextLength: number;
timeoutMs: number;
@@ -104,6 +126,7 @@ export type ResolvedTtsConfig = {
type TtsUserPrefs = {
tts?: {
auto?: TtsAutoMode;
enabled?: boolean;
provider?: TtsProvider;
maxLength?: number;
@@ -142,6 +165,7 @@ type TtsDirectiveOverrides = {
type TtsDirectiveParseResult = {
cleanedText: string;
ttsText?: string;
hasDirective: boolean;
overrides: TtsDirectiveOverrides;
warnings: string[];
};
@@ -168,6 +192,15 @@ type TtsStatusEntry = {
let lastTtsAttempt: TtsStatusEntry | undefined;
export function normalizeTtsAutoMode(value: unknown): TtsAutoMode | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
if (TTS_AUTO_MODES.has(normalized as TtsAutoMode)) {
return normalized as TtsAutoMode;
}
return undefined;
}
function resolveModelOverridePolicy(
overrides: TtsModelOverrideConfig | undefined,
): ResolvedTtsModelOverrides {
@@ -199,10 +232,14 @@ function resolveModelOverridePolicy(
export function resolveTtsConfig(cfg: ClawdbotConfig): ResolvedTtsConfig {
const raw: TtsConfig = cfg.messages?.tts ?? {};
const providerSource = raw.provider ? "config" : "default";
const edgeOutputFormat = raw.edge?.outputFormat?.trim();
const auto = normalizeTtsAutoMode(raw.auto) ?? (raw.enabled ? "always" : "off");
return {
enabled: raw.enabled ?? false,
auto,
mode: raw.mode ?? "final",
provider: raw.provider ?? "elevenlabs",
provider: raw.provider ?? "edge",
providerSource,
summaryModel: raw.summaryModel?.trim() || undefined,
modelOverrides: resolveModelOverridePolicy(raw.modelOverrides),
elevenlabs: {
@@ -231,6 +268,19 @@ export function resolveTtsConfig(cfg: ClawdbotConfig): ResolvedTtsConfig {
model: raw.openai?.model ?? DEFAULT_OPENAI_MODEL,
voice: raw.openai?.voice ?? DEFAULT_OPENAI_VOICE,
},
edge: {
enabled: raw.edge?.enabled ?? true,
voice: raw.edge?.voice?.trim() || DEFAULT_EDGE_VOICE,
lang: raw.edge?.lang?.trim() || DEFAULT_EDGE_LANG,
outputFormat: edgeOutputFormat || DEFAULT_EDGE_OUTPUT_FORMAT,
outputFormatConfigured: Boolean(edgeOutputFormat),
pitch: raw.edge?.pitch?.trim() || undefined,
rate: raw.edge?.rate?.trim() || undefined,
volume: raw.edge?.volume?.trim() || undefined,
saveSubtitles: raw.edge?.saveSubtitles ?? false,
proxy: raw.edge?.proxy?.trim() || undefined,
timeoutMs: raw.edge?.timeoutMs,
},
prefsPath: raw.prefsPath,
maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
timeoutMs: raw.timeoutMs ?? DEFAULT_TIMEOUT_MS,
@@ -244,17 +294,48 @@ export function resolveTtsPrefsPath(config: ResolvedTtsConfig): string {
return path.join(CONFIG_DIR, "settings", "tts.json");
}
function resolveTtsAutoModeFromPrefs(prefs: TtsUserPrefs): TtsAutoMode | undefined {
const auto = normalizeTtsAutoMode(prefs.tts?.auto);
if (auto) return auto;
if (typeof prefs.tts?.enabled === "boolean") {
return prefs.tts.enabled ? "always" : "off";
}
return undefined;
}
export function resolveTtsAutoMode(params: {
config: ResolvedTtsConfig;
prefsPath: string;
sessionAuto?: string;
}): TtsAutoMode {
const sessionAuto = normalizeTtsAutoMode(params.sessionAuto);
if (sessionAuto) return sessionAuto;
const prefsAuto = resolveTtsAutoModeFromPrefs(readPrefs(params.prefsPath));
if (prefsAuto) return prefsAuto;
return params.config.auto;
}
export function buildTtsSystemPromptHint(cfg: ClawdbotConfig): string | undefined {
const config = resolveTtsConfig(cfg);
const prefsPath = resolveTtsPrefsPath(config);
if (!isTtsEnabled(config, prefsPath)) return undefined;
const autoMode = resolveTtsAutoMode({ config, prefsPath });
if (autoMode === "off") return undefined;
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
const autoHint =
autoMode === "inbound"
? "Only use TTS when the user's last message includes audio/voice."
: autoMode === "tagged"
? "Only use TTS when you include [[tts]] or [[tts:text]] tags."
: undefined;
return [
"Voice (TTS) is enabled.",
autoHint,
`Keep spoken text ≤${maxLength} chars to avoid auto-summary (summary ${summarize}).`,
"Use [[tts:...]] and optional [[tts:text]]...[[/tts:text]] to control voice/expressiveness.",
].join("\n");
]
.filter(Boolean)
.join("\n");
}
function readPrefs(prefsPath: string): TtsUserPrefs {
@@ -288,21 +369,35 @@ function updatePrefs(prefsPath: string, update: (prefs: TtsUserPrefs) => void):
atomicWriteFileSync(prefsPath, JSON.stringify(prefs, null, 2));
}
export function isTtsEnabled(config: ResolvedTtsConfig, prefsPath: string): boolean {
const prefs = readPrefs(prefsPath);
if (prefs.tts?.enabled !== undefined) return prefs.tts.enabled === true;
return config.enabled;
export function isTtsEnabled(
config: ResolvedTtsConfig,
prefsPath: string,
sessionAuto?: string,
): boolean {
return resolveTtsAutoMode({ config, prefsPath, sessionAuto }) !== "off";
}
export function setTtsAutoMode(prefsPath: string, mode: TtsAutoMode): void {
updatePrefs(prefsPath, (prefs) => {
const next = { ...prefs.tts };
delete next.enabled;
next.auto = mode;
prefs.tts = next;
});
}
export function setTtsEnabled(prefsPath: string, enabled: boolean): void {
updatePrefs(prefsPath, (prefs) => {
prefs.tts = { ...prefs.tts, enabled };
});
setTtsAutoMode(prefsPath, enabled ? "always" : "off");
}
export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): TtsProvider {
const prefs = readPrefs(prefsPath);
return prefs.tts?.provider ?? config.provider;
if (prefs.tts?.provider) return prefs.tts.provider;
if (config.providerSource === "config") return config.provider;
if (resolveTtsApiKey(config, "openai")) return "openai";
if (resolveTtsApiKey(config, "elevenlabs")) return "elevenlabs";
return "edge";
}
export function setTtsProvider(prefsPath: string, provider: TtsProvider): void {
@@ -350,6 +445,10 @@ function resolveChannelId(channel: string | undefined): ChannelId | null {
return channel ? normalizeChannelId(channel) : null;
}
function resolveEdgeOutputFormat(config: ResolvedTtsConfig): string {
return config.edge.outputFormat;
}
export function resolveTtsApiKey(
config: ResolvedTtsConfig,
provider: TtsProvider,
@@ -363,6 +462,17 @@ export function resolveTtsApiKey(
return undefined;
}
export const TTS_PROVIDERS = ["openai", "elevenlabs", "edge"] as const;
export function resolveTtsProviderOrder(primary: TtsProvider): TtsProvider[] {
return [primary, ...TTS_PROVIDERS.filter((provider) => provider !== primary)];
}
export function isTtsProviderConfigured(config: ResolvedTtsConfig, provider: TtsProvider): boolean {
if (provider === "edge") return config.edge.enabled;
return Boolean(resolveTtsApiKey(config, provider));
}
function isValidVoiceId(voiceId: string): boolean {
return /^[a-zA-Z0-9]{10,40}$/.test(voiceId);
}
@@ -430,15 +540,17 @@ function parseTtsDirectives(
policy: ResolvedTtsModelOverrides,
): TtsDirectiveParseResult {
if (!policy.enabled) {
return { cleanedText: text, overrides: {}, warnings: [] };
return { cleanedText: text, overrides: {}, warnings: [], hasDirective: false };
}
const overrides: TtsDirectiveOverrides = {};
const warnings: string[] = [];
let cleanedText = text;
let hasDirective = false;
const blockRegex = /\[\[tts:text\]\]([\s\S]*?)\[\[\/tts:text\]\]/gi;
cleanedText = cleanedText.replace(blockRegex, (_match, inner: string) => {
hasDirective = true;
if (policy.allowText && overrides.ttsText == null) {
overrides.ttsText = inner.trim();
}
@@ -447,6 +559,7 @@ function parseTtsDirectives(
const directiveRegex = /\[\[tts:([^\]]+)\]\]/gi;
cleanedText = cleanedText.replace(directiveRegex, (_match, body: string) => {
hasDirective = true;
const tokens = body.split(/\s+/).filter(Boolean);
for (const token of tokens) {
const eqIndex = token.indexOf("=");
@@ -459,7 +572,7 @@ function parseTtsDirectives(
switch (key) {
case "provider":
if (!policy.allowProvider) break;
if (rawValue === "openai" || rawValue === "elevenlabs") {
if (rawValue === "openai" || rawValue === "elevenlabs" || rawValue === "edge") {
overrides.provider = rawValue;
} else {
warnings.push(`unsupported provider "${rawValue}"`);
@@ -617,6 +730,7 @@ function parseTtsDirectives(
return {
cleanedText,
ttsText: overrides.ttsText,
hasDirective,
overrides,
warnings,
};
@@ -893,6 +1007,38 @@ async function openaiTTS(params: {
}
}
function inferEdgeExtension(outputFormat: string): string {
const normalized = outputFormat.toLowerCase();
if (normalized.includes("webm")) return ".webm";
if (normalized.includes("ogg")) return ".ogg";
if (normalized.includes("opus")) return ".opus";
if (normalized.includes("wav") || normalized.includes("riff") || normalized.includes("pcm")) {
return ".wav";
}
return ".mp3";
}
async function edgeTTS(params: {
text: string;
outputPath: string;
config: ResolvedTtsConfig["edge"];
timeoutMs: number;
}): Promise<void> {
const { text, outputPath, config, timeoutMs } = params;
const tts = new EdgeTTS({
voice: config.voice,
lang: config.lang,
outputFormat: config.outputFormat,
saveSubtitles: config.saveSubtitles,
proxy: config.proxy,
rate: config.rate,
pitch: config.pitch,
volume: config.volume,
timeout: config.timeoutMs ?? timeoutMs,
});
await tts.ttsPromise(text, outputPath);
}
export async function textToSpeech(params: {
text: string;
cfg: ClawdbotConfig;
@@ -915,19 +1061,87 @@ export async function textToSpeech(params: {
const userProvider = getTtsProvider(config, prefsPath);
const overrideProvider = params.overrides?.provider;
const provider = overrideProvider ?? userProvider;
const providers: TtsProvider[] = [provider, provider === "openai" ? "elevenlabs" : "openai"];
const providers = resolveTtsProviderOrder(provider);
let lastError: string | undefined;
for (const provider of providers) {
const apiKey = resolveTtsApiKey(config, provider);
if (!apiKey) {
lastError = `No API key for ${provider}`;
continue;
}
const providerStart = Date.now();
try {
if (provider === "edge") {
if (!config.edge.enabled) {
lastError = "edge: disabled";
continue;
}
const tempDir = mkdtempSync(path.join(tmpdir(), "tts-"));
let edgeOutputFormat = resolveEdgeOutputFormat(config);
const fallbackEdgeOutputFormat =
edgeOutputFormat !== DEFAULT_EDGE_OUTPUT_FORMAT ? DEFAULT_EDGE_OUTPUT_FORMAT : undefined;
const attemptEdgeTts = async (outputFormat: string) => {
const extension = inferEdgeExtension(outputFormat);
const audioPath = path.join(tempDir, `voice-${Date.now()}${extension}`);
await edgeTTS({
text: params.text,
outputPath: audioPath,
config: {
...config.edge,
outputFormat,
},
timeoutMs: config.timeoutMs,
});
return { audioPath, outputFormat };
};
let edgeResult: { audioPath: string; outputFormat: string };
try {
edgeResult = await attemptEdgeTts(edgeOutputFormat);
} catch (err) {
if (fallbackEdgeOutputFormat && fallbackEdgeOutputFormat !== edgeOutputFormat) {
logVerbose(
`TTS: Edge output ${edgeOutputFormat} failed; retrying with ${fallbackEdgeOutputFormat}.`,
);
edgeOutputFormat = fallbackEdgeOutputFormat;
try {
edgeResult = await attemptEdgeTts(edgeOutputFormat);
} catch (fallbackErr) {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
throw fallbackErr;
}
} else {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
throw err;
}
}
scheduleCleanup(tempDir);
const voiceCompatible = isVoiceCompatibleAudio({ fileName: edgeResult.audioPath });
return {
success: true,
audioPath: edgeResult.audioPath,
latencyMs: Date.now() - providerStart,
provider,
outputFormat: edgeResult.outputFormat,
voiceCompatible,
};
}
const apiKey = resolveTtsApiKey(config, provider);
if (!apiKey) {
lastError = `No API key for ${provider}`;
continue;
}
let audioBuffer: Buffer;
if (provider === "elevenlabs") {
const voiceIdOverride = params.overrides?.elevenlabs?.voiceId;
@@ -1001,13 +1215,17 @@ export async function maybeApplyTtsToPayload(params: {
cfg: ClawdbotConfig;
channel?: string;
kind?: "tool" | "block" | "final";
inboundAudio?: boolean;
ttsAuto?: string;
}): Promise<ReplyPayload> {
const config = resolveTtsConfig(params.cfg);
const prefsPath = resolveTtsPrefsPath(config);
if (!isTtsEnabled(config, prefsPath)) return params.payload;
const mode = config.mode ?? "final";
if (mode === "final" && params.kind && params.kind !== "final") return params.payload;
const autoMode = resolveTtsAutoMode({
config,
prefsPath,
sessionAuto: params.ttsAuto,
});
if (autoMode === "off") return params.payload;
const text = params.payload.text ?? "";
const directives = parseTtsDirectives(text, config.modelOverrides);
@@ -1028,6 +1246,12 @@ export async function maybeApplyTtsToPayload(params: {
text: visibleText.length > 0 ? visibleText : undefined,
};
if (autoMode === "tagged" && !directives.hasDirective) return nextPayload;
if (autoMode === "inbound" && params.inboundAudio !== true) return nextPayload;
const mode = config.mode ?? "final";
if (mode === "final" && params.kind && params.kind !== "final") return nextPayload;
if (!ttsText.trim()) return nextPayload;
if (params.payload.mediaUrl || (params.payload.mediaUrls?.length ?? 0) > 0) return nextPayload;
if (text.includes("MEDIA:")) return nextPayload;
@@ -1042,7 +1266,7 @@ export async function maybeApplyTtsToPayload(params: {
logVerbose(
`TTS: skipping long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
);
return params.payload;
return nextPayload;
}
try {
@@ -1064,7 +1288,7 @@ export async function maybeApplyTtsToPayload(params: {
} catch (err) {
const error = err as Error;
logVerbose(`TTS: summarization failed: ${error.message}`);
return params.payload;
return nextPayload;
}
}
@@ -1120,4 +1344,5 @@ export const _test = {
resolveModelOverridePolicy,
summarizeText,
resolveOutputFormat,
resolveEdgeOutputFormat,
};
+18
View File
@@ -0,0 +1,18 @@
declare module "node-edge-tts" {
export type EdgeTTSOptions = {
voice?: string;
lang?: string;
outputFormat?: string;
saveSubtitles?: boolean;
proxy?: string;
rate?: string;
pitch?: string;
volume?: string;
timeout?: number;
};
export class EdgeTTS {
constructor(options?: EdgeTTSOptions);
ttsPromise(text: string, outputPath: string): Promise<void>;
}
}
+6 -6
View File
@@ -127,9 +127,9 @@ describe("web inbound media saves with extension", () => {
realSock.ev.emit("messages.upsert", upsert);
// Allow a brief window for the async handler to fire on slower hosts.
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 50; i++) {
if (onMessage.mock.calls.length > 0) break;
await new Promise((resolve) => setTimeout(resolve, 5));
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(onMessage).toHaveBeenCalledTimes(1);
@@ -178,9 +178,9 @@ describe("web inbound media saves with extension", () => {
realSock.ev.emit("messages.upsert", upsert);
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 50; i++) {
if (onMessage.mock.calls.length > 0) break;
await new Promise((resolve) => setTimeout(resolve, 5));
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(onMessage).toHaveBeenCalledTimes(1);
@@ -218,9 +218,9 @@ describe("web inbound media saves with extension", () => {
realSock.ev.emit("messages.upsert", upsert);
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 50; i++) {
if (onMessage.mock.calls.length > 0) break;
await new Promise((resolve) => setTimeout(resolve, 5));
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(onMessage).toHaveBeenCalledTimes(1);
-1
View File
@@ -512,7 +512,6 @@ export function renderApp(state: AppViewState) {
activeSubsection: state.configActiveSubsection,
onRawChange: (next) => {
state.configRaw = next;
state.configFormDirty = true;
},
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
+96
View File
@@ -0,0 +1,96 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { SessionsListResult } from "../types";
import { renderChat, type ChatProps } from "./chat";
function createSessions(): SessionsListResult {
return {
ts: 0,
path: "",
count: 0,
defaults: { model: null, contextTokens: null },
sessions: [],
};
}
function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
return {
sessionKey: "main",
onSessionKeyChange: () => undefined,
thinkingLevel: null,
showThinking: false,
loading: false,
sending: false,
canAbort: false,
compactionStatus: null,
messages: [],
toolMessages: [],
stream: null,
streamStartedAt: null,
assistantAvatarUrl: null,
draft: "",
queue: [],
connected: true,
canSend: true,
disabledReason: null,
error: null,
sessions: createSessions(),
focusMode: false,
assistantName: "Clawdbot",
assistantAvatar: null,
onRefresh: () => undefined,
onToggleFocusMode: () => undefined,
onDraftChange: () => undefined,
onSend: () => undefined,
onQueueRemove: () => undefined,
onNewSession: () => undefined,
...overrides,
};
}
describe("chat view", () => {
it("shows a stop button when aborting is available", () => {
const container = document.createElement("div");
const onAbort = vi.fn();
render(
renderChat(
createProps({
canAbort: true,
onAbort,
}),
),
container,
);
const stopButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Stop",
);
expect(stopButton).not.toBeUndefined();
stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onAbort).toHaveBeenCalledTimes(1);
expect(container.textContent).not.toContain("New session");
});
it("shows a new session button when aborting is unavailable", () => {
const container = document.createElement("div");
const onNewSession = vi.fn();
render(
renderChat(
createProps({
canAbort: false,
onNewSession,
}),
),
container,
);
const newSessionButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "New session",
);
expect(newSessionButton).not.toBeUndefined();
newSessionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onNewSession).toHaveBeenCalledTimes(1);
expect(container.textContent).not.toContain("Stop");
});
});
+4 -3
View File
@@ -97,6 +97,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
export function renderChat(props: ChatProps) {
const canCompose = props.connected;
const isBusy = props.sending || props.stream !== null;
const canAbort = Boolean(props.canAbort && props.onAbort);
const activeSession = props.sessions?.sessions?.find(
(row) => row.key === props.sessionKey,
);
@@ -254,10 +255,10 @@ export function renderChat(props: ChatProps) {
<div class="chat-compose__actions">
<button
class="btn"
?disabled=${!props.connected || props.sending}
@click=${props.onNewSession}
?disabled=${!props.connected || (!canAbort && props.sending)}
@click=${canAbort ? props.onAbort : props.onNewSession}
>
New session
${canAbort ? "Stop" : "New session"}
</button>
<button
class="btn primary"
+28
View File
@@ -96,6 +96,34 @@ describe("config view", () => {
expect(applyButton?.disabled).toBe(true);
});
it("enables save and apply when raw changes", () => {
const container = document.createElement("div");
render(
renderConfig({
...baseProps(),
formMode: "raw",
raw: "{\n gateway: { mode: \"local\" }\n}\n",
originalRaw: "{\n}\n",
}),
container,
);
const saveButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Save") as
| HTMLButtonElement
| undefined;
const applyButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Apply") as
| HTMLButtonElement
| undefined;
expect(saveButton).not.toBeUndefined();
expect(applyButton).not.toBeUndefined();
expect(saveButton?.disabled).toBe(false);
expect(applyButton?.disabled).toBe(false);
});
it("switches mode via the sidebar toggle", () => {
const container = document.createElement("div");
const onFormModeChange = vi.fn();