Compare commits

..
Author SHA1 Message Date
Peter Steinberger 43c2c9b697 test: align BlueBubbles send target fallback (#1630) (thanks @plum-dawg) 2026-01-25 10:13:30 +00:00
Peter Steinberger bb82851124 chore: drop line plugin node_modules (#1630) (thanks @plum-dawg) 2026-01-25 10:05:58 +00:00
Peter Steinberger ca627ddcd4 feat: complete LINE plugin (#1630) (thanks @plum-dawg) 2026-01-25 10:05:57 +00:00
Peter Steinberger 9a6964b81f feat: add LINE plugin (#1630) (thanks @plum-dawg) 2026-01-25 10:04:36 +00:00
Tyler Yust 0f662c2935 fix(bluebubbles): route phone-number targets to direct chats; prevent internal IDs leaking in cross-context prefix (#1751)
* fix(bluebubbles): prefer DM resolution + hide routing markers

* fix(bluebubbles): prevent message routing to group chats when targeting phone numbers

When sending a message to a phone number like +12622102921, the
resolveChatGuidForTarget function was finding and returning a GROUP
CHAT containing that phone number instead of a direct DM chat.

The bug was in the participantMatch fallback logic which matched ANY
chat containing the phone number as a participant, including groups.

This fix adds a check to ensure participantMatch only considers DM
chats (identified by ';-;' separator in the chat GUID). Group chats
(identified by ';+;' separator) are now explicitly excluded from
handle-based matching.

If a phone number only exists in a group chat (no direct DM exists),
the function now correctly returns null, which causes the send to
fail with a clear error rather than accidentally messaging a group.

Added test case to verify this behavior.

* feat(bluebubbles): auto-create new DM chats when sending to unknown phone numbers

When sending to a phone number that doesn't have an existing chat,
instead of failing with 'chatGuid not found', now automatically creates
a new chat using the /api/v1/chat/new endpoint.

- Added createNewChatWithMessage() helper function
- When resolveChatGuidForTarget returns null for a handle target,
  uses the new chat endpoint with addresses array and message
- Includes helpful error message if Private API isn't enabled
- Only applies to handle targets (phone numbers), not group chats

* fix(bluebubbles): hide internal routing metadata in cross-context markers

When sending cross-context messages via BlueBubbles, the origin marker was
exposing internal chat_guid routing info like '[from bluebubbles:chat_guid:any;-;+19257864429]'.

This adds a formatTargetDisplay() function to the BlueBubbles plugin that:
- Extracts phone numbers from chat_guid formats (iMessage;-;+1234567890 -> +1234567890)
- Normalizes handles for clean display
- Avoids returning raw chat_guid formats containing internal routing metadata

Now cross-context markers show clean identifiers like '[from +19257864429]' instead
of exposing internal routing details to recipients.

* fix: prevent cross-context decoration on direct message tool sends

Two fixes:

1. Cross-context decoration (e.g., '[from +19257864429]' prefix) was being
   added to ALL messages sent to a different target, even when the agent
   was just composing a new message via the message tool. This decoration
   should only be applied when forwarding/relaying messages between chats.

   Fix: Added skipCrossContextDecoration flag to ChannelThreadingToolContext.
   The message tool now sets this flag to true, so direct sends don't get
   decorated. The buildCrossContextDecoration function checks this flag
   and returns null when set.

2. Aborted requests were still completing because the abort signal wasn't
   being passed through the message tool execution chain.

   Fix: Added abortSignal propagation from message tool → runMessageAction →
   executeSendAction → sendMessage → deliverOutboundPayloads. Added abort
   checks at key points in the chain to fail fast when aborted.

Files changed:
- src/channels/plugins/types.core.ts: Added skipCrossContextDecoration field
- src/infra/outbound/outbound-policy.ts: Check skip flag before decorating
- src/agents/tools/message-tool.ts: Set skip flag, accept and pass abort signal
- src/infra/outbound/message-action-runner.ts: Pass abort signal through
- src/infra/outbound/outbound-send-service.ts: Check and pass abort signal
- src/infra/outbound/message.ts: Pass abort signal to delivery

* fix(bluebubbles): preserve friendly display names in formatTargetDisplay
2026-01-25 10:03:08 +00:00
uos-statusandClawdbot Bot 32bcd291d5 Fix models command (#1753)
* Auto-reply: ignore /models in model directive

* Auto-reply: add /models directive regression test

* Auto-reply: cover bare /models regression

---------

Co-authored-by: Clawdbot Bot <bot@clawd>
2026-01-25 10:02:12 +00:00
Peter SteinbergerandTyler Yust 5f9863098b fix: skip image understanding for vision models (#1747)
Thanks @tyler6204.

Co-authored-by: Tyler Yust <64381258+tyler6204@users.noreply.github.com>
2026-01-25 09:57:19 +00:00
Tyler YustandClaude Opus 4.5 fdecf5c59a fix: skip image understanding when primary model has vision
When the primary model supports vision natively (e.g., Claude Opus 4.5),
skip the image understanding call entirely. The image will be injected
directly into the model context instead, saving an API call and avoiding
redundant descriptions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 09:57:19 +00:00
Peter Steinberger 83f92e34af refactor: align voice-call TTS with core config 2026-01-25 09:29:57 +00:00
Vignesh Natarajan 9366cbc7db Docs: add Discord MESSAGE_CONTENT intent step to Railway guide 2026-01-25 01:26:53 -08:00
Peter Steinberger d4f895d8f2 fix: move gateway lock to temp dir 2026-01-25 09:21:46 +00:00
Vignesh Natarajan f08c34a73f Docs: fix Railway deploy URL and add PORT variable 2026-01-25 01:18:12 -08:00
zhixianandzhixian 6a9301c27d feat(tts): support custom OpenAI-compatible TTS endpoints (#1701)
* feat(tts): support custom OpenAI-compatible TTS endpoints

Add OPENAI_TTS_BASE_URL environment variable to allow using self-hosted
or third-party OpenAI-compatible TTS services like Kokoro, LocalAI, or
OpenedAI-Speech.

Changes:
- Add OPENAI_TTS_BASE_URL env var (defaults to OpenAI official API)
- Relax model/voice validation when using custom endpoints
- Add tts-1 and tts-1-hd to the model allowlist

This enables users to:
- Use local TTS for privacy and cost savings
- Use models with better non-English language support (Chinese, Japanese)
- Reduce latency with local inference

Example usage:
  OPENAI_TTS_BASE_URL=http://localhost:8880/v1

Tested with Kokoro-FastAPI.

* fix: strip trailing slashes from OPENAI_TTS_BASE_URL

Address review feedback: normalize the base URL by removing trailing
slashes to prevent double-slash paths like /v1//audio/speech which
cause 404 errors on some OpenAI-compatible servers.

* style: format code with oxfmt

* test: update tests for expanded OpenAI TTS model list

- Accept tts-1 and tts-1-hd as valid models
- Update OPENAI_TTS_MODELS length expectation to 3

---------

Co-authored-by: zhixian <zhixian@bunker.local>
2026-01-25 08:04:20 +00:00
Peter Steinberger 653401774d fix(telegram): honor linkPreview on fallback (#1730)
* feat: add notice directive parsing

* fix: honor telegram linkPreview config (#1700) (thanks @zerone0x)
2026-01-25 07:55:39 +00:00
Peter Steinberger c6cdbb630c fix: harden exec spawn fallback 2026-01-25 06:37:39 +00:00
Peter Steinberger da2439f2cc docs: clarify Claude Pro Max auth 2026-01-25 06:05:11 +00:00
zerone0xandClaude 92ab3f22dc feat(telegram): add linkPreview config option
Add channels.telegram.linkPreview config to control whether link previews
are shown in outbound messages. When set to false, uses Telegram's
link_preview_options.is_disabled to suppress URL previews.

- Add linkPreview to TelegramAccountConfig type
- Add Zod schema validation for linkPreview
- Pass link_preview_options to sendMessage in send.ts and bot/delivery.ts
- Propagate linkPreview config through deliverReplies callers
- Add tests for link preview behavior

Fixes #1675

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-25 06:00:05 +00:00
Peter Steinberger 43a6c5b77f docs: clarify Gemini CLI OAuth 2026-01-25 05:53:25 +00:00
Peter Steinberger 495616d13e fix(ui): refine config save guardrails (#1707)
* fix: refine config save guardrails

* docs: add changelog for config save guardrails (#1707) (thanks @Glucksberg)
2026-01-25 05:52:32 +00:00
Peter Steinberger bac80f0886 fix: listen on ipv6 loopback for gateway 2026-01-25 05:49:48 +00:00
Peter Steinberger ef078fec70 docs: add Windows install troubleshooting 2026-01-25 05:48:24 +00:00
GlucksbergandClaude Opus 4.5 8e3ac01db6 fix(ui): improve config save UX (#1678)
Follow-up to #1609 fix:
- Remove formUnsafe check from canSave (was blocking save even with valid changes)
- Suppress disconnect message for code 1012 (service restart is expected during config save)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 05:46:55 +00:00
Peter Steinberger c3f90dd4e2 docs: add macOS VM link 2026-01-25 05:32:04 +00:00
Peter Steinberger 00c4556d7b docs: add Claude Max FAQ 2026-01-25 05:28:01 +00:00
Peter Steinberger a4bc69dbec docs: add first steps FAQ 2026-01-25 05:24:47 +00:00
Peter Steinberger 3f1457de2a docs: add new FAQ entries 2026-01-25 05:20:16 +00:00
Peter Steinberger 8507ea08bd docs: expand macOS VM guide (#1693) (thanks @f-trycua) 2026-01-25 05:16:41 +00:00
f-trycua 7ae2548fc6 docs: add macOS VM (Lume) platform guide
Add documentation for running Clawdbot in a sandboxed macOS VM
using Lume. This provides an alternative to buying dedicated
hardware or using cloud instances.

The guide covers:
- Installing Lume on Apple Silicon Macs
- Creating and configuring a macOS VM
- Installing Clawdbot inside the VM
- Running headlessly for 24/7 operation
- iMessage integration via BlueBubbles
- Saving golden images for easy reset
2026-01-25 05:14:13 +00:00
Peter Steinberger f06f83ddd0 docs: add CC comparison faq 2026-01-25 05:00:47 +00:00
Peter Steinberger c78297d80f docs: add account isolation faq 2026-01-25 04:56:41 +00:00
Peter Steinberger 69f6e1a20b docs: add multi-agent team faq 2026-01-25 04:55:22 +00:00
Peter Steinberger 5f6409a73d fix: configurable signal startup timeout 2026-01-25 04:51:35 +00:00
Rohan NagpalandPeter Steinberger 06a7e1e8ce Telegram: threaded conversation support (#1597)
* Telegram: isolate dm topic sessions

* Tests: cap vitest workers

* Tests: cap Vitest workers on CI macOS

* Tests: avoid timer-based pi-ai stream mock

* Tests: increase embedded runner timeout

* fix: harden telegram dm thread handling (#1597) (thanks @rohannagpal)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-25 04:48:51 +00:00
Peter Steinberger 9eaaadf8ee fix: clarify control ui auth hints (fixes #1690) 2026-01-25 04:46:42 +00:00
Seb Slight d4f60bf16a TTS: gate auto audio on inbound voice notes (#1667)
Co-authored-by: Sebastian <sebslight@gmail.com>
2026-01-25 04:35:20 +00:00
Peter Steinberger ede5145191 docs: sweep support troubleshooting updates 2026-01-25 04:33:14 +00:00
Peter Steinberger 26d3fbb09f docs: add faq answers from support stream 2026-01-25 04:30:37 +00:00
Peter Steinberger f7c89ba796 docs: fix faq wording + add heading guardrail 2026-01-25 04:25:17 +00:00
Peter Steinberger 9afde64e26 fix: validate web_search freshness (#1688) (thanks @JonUleis) 2026-01-25 04:23:25 +00:00
Jon Uleis 8682524da3 feat(web_search): add freshness parameter for Brave time filtering
Adds support for Brave Search API's freshness parameter to filter results
by discovery time:
- 'pd' - Past 24 hours
- 'pw' - Past week
- 'pm' - Past month
- 'py' - Past year
- 'YYYY-MM-DDtoYYYY-MM-DD' - Custom date range

Useful for cron jobs and monitoring tasks that need recent results.

Note: Perplexity provider ignores this parameter (Brave only).

---
🤖 AI-assisted: This PR was created with Claude (Opus). Lightly tested via
build script. The change follows existing patterns for optional parameters
(country, search_lang, ui_lang).
2026-01-25 04:19:58 +00:00
Peter Steinberger 5956dde459 docs: expand faq on heavy work + self-hosted models 2026-01-25 04:17:12 +00:00
185 changed files with 14969 additions and 595 deletions

No files matched your search

+1
View File
@@ -18,6 +18,7 @@
- Docs are hosted on Mintlify (docs.clawd.bot).
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
+15
View File
@@ -11,33 +11,48 @@ Docs: https://docs.clawd.bot
### Changes
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
- Docs: add verbose installer troubleshooting guidance.
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
- Docs: update Fly.io guide notes.
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
### Fixes
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
- Web UI: hide internal `message_id` hints in chat bubbles.
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
- Heartbeat: normalize target identifiers for consistent routing.
- TUI: reload history after gateway reconnect to restore session state. (#1663)
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
- Agents: use the active auth profile for auto-compaction recovery.
- Models: default missing custom provider fields so minimal configs are accepted.
- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
- Google Chat: normalize space targets without double `spaces/` prefix.
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.
- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.
- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.
## 2026.1.23-1
+1 -1
View File
@@ -17,7 +17,7 @@ read_when:
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls).
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
+17
View File
@@ -74,6 +74,22 @@ Example:
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
## External daemon mode (httpUrl)
If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point Clawdbot at it:
```json5
{
channels: {
signal: {
httpUrl: "http://127.0.0.1:8080",
autoStart: false
}
}
}
```
This skips auto-spawn and the startup wait inside Clawdbot. For slow starts when auto-spawning, set `channels.signal.startupTimeoutMs`.
## Access control (DMs + groups)
DMs:
- Default: `channels.signal.dmPolicy = "pairing"`.
@@ -142,6 +158,7 @@ Provider options:
- `channels.signal.httpUrl`: full daemon URL (overrides host/port).
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).
- `channels.signal.startupTimeoutMs`: startup wait timeout in ms (cap 120000).
- `channels.signal.receiveMode`: `on-start | manual`.
- `channels.signal.ignoreAttachments`: skip attachment downloads.
- `channels.signal.ignoreStories`: ignore stories from the daemon.
+8
View File
@@ -120,6 +120,13 @@ You can add custom commands to the menu via config:
}
```
## Troubleshooting
- `setMyCommands failed` in logs usually means outbound HTTPS/DNS is blocked to `api.telegram.org`.
- If you see `sendMessage` or `sendChatAction` failures, check IPv6 routing and DNS.
More help: [Channel troubleshooting](/channels/troubleshooting).
Notes:
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
@@ -518,6 +525,7 @@ Provider options:
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
+1
View File
@@ -22,3 +22,4 @@ clawdbot channels status --probe
## Telegram quick fixes
- Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting).
- Logs show `setMyCommands failed` → check outbound HTTPS and DNS reachability to `api.telegram.org` (common on locked-down VPS or proxies).
+1
View File
@@ -31,6 +31,7 @@ These files live under the workspace (`agents.defaults.workspace`, default
- Decisions, preferences, and durable facts go to `MEMORY.md`.
- Day-to-day notes and running context go to `memory/YYYY-MM-DD.md`.
- If someone says "remember this," write it down (do not keep it in RAM).
- This area is still evolving. It helps to remind the model to store memories; it will know what to do.
- If you want something to stick, **ask the bot to write it** into memory.
## Automatic memory flush (pre-compaction ping)
+2
View File
@@ -89,6 +89,8 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
- Enable: `clawdbot plugins enable google-gemini-cli-auth`
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
- Note: you do **not** paste a client id or secret into `clawdbot.json`. The CLI login flow stores
tokens in auth profiles on the gateway host.
### Z.AI (GLM)
+1
View File
@@ -1048,6 +1048,7 @@
"pages": [
"platforms",
"platforms/macos",
"platforms/macos-vm",
"platforms/ios",
"platforms/android",
"platforms/windows",
+9 -3
View File
@@ -46,6 +46,9 @@ better forms without hard-coding config knowledge.
Use `config.apply` to validate + write the full config and restart the Gateway in one step.
It writes a restart sentinel and pings the last active session after the Gateway comes back.
Warning: `config.apply` replaces the **entire config**. If you want to change only a few keys,
use `config.patch` or `clawdbot config set`. Keep a backup of `~/.clawdbot/clawdbot.json`.
Params:
- `raw` (string) — JSON5 payload for the entire config
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
@@ -1018,6 +1021,7 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
],
historyLimit: 50, // include last N group messages as context (0 disables)
replyToMode: "first", // off | first | all
linkPreview: true, // toggle outbound link previews
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
draftChunk: { // optional; only for streamMode=block
minChars: 200,
@@ -1506,7 +1510,7 @@ voice notes; other channels send MP3 audio.
{
messages: {
tts: {
enabled: true,
auto: "always", // off | always | inbound | tagged
mode: "final", // final | all (include tool/block replies)
provider: "elevenlabs",
summaryModel: "openai/gpt-4.1-mini",
@@ -1543,8 +1547,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`.
+620 -185
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -114,3 +114,9 @@ Git requirement:
If you choose `-InstallMethod git` and Git is missing, the installer will print the
Git for Windows link (`https://git-scm.com/download/win`) and exit.
Common Windows issues:
- **npm error spawn git / ENOENT**: install Git for Windows and reopen PowerShell, then rerun the installer.
- **"clawdbot" is not recognized**: your npm global bin folder is not on PATH. Most systems use
`%AppData%\\npm`. You can also run `npm config get prefix` and add `\\bin` to PATH, then reopen PowerShell.
+275
View File
@@ -0,0 +1,275 @@
---
summary: "Run Clawdbot in a sandboxed macOS VM (local or hosted) when you need isolation or iMessage"
read_when:
- You want Clawdbot isolated from your main macOS environment
- You want iMessage integration (BlueBubbles) in a sandbox
- You want a resettable macOS environment you can clone
- You want to compare local vs hosted macOS VM options
---
# Clawdbot on macOS VMs (Sandboxing)
## Recommended default (most users)
- **Small Linux VPS** for an always-on Gateway and low cost. See [VPS hosting](/vps).
- **Dedicated hardware** (Mac mini or Linux box) if you want full control and a **residential IP** for browser automation. Many sites block data center IPs, so local browsing often works better.
- **Hybrid:** keep the Gateway on a cheap VPS, and connect your Mac as a **node** when you need browser/UI automation. See [Nodes](/nodes) and [Gateway remote](/gateway/remote).
Use a macOS VM when you specifically need macOS-only capabilities (iMessage/BlueBubbles) or want strict isolation from your daily Mac.
## macOS VM options
### Local VM on your Apple Silicon Mac (Lume)
Run Clawdbot in a sandboxed macOS VM on your existing Apple Silicon Mac using [Lume](https://cua.ai/docs/lume).
This gives you:
- Full macOS environment in isolation (your host stays clean)
- iMessage support via BlueBubbles (impossible on Linux/Windows)
- Instant reset by cloning VMs
- No extra hardware or cloud costs
### Hosted Mac providers (cloud)
If you want macOS in the cloud, hosted Mac providers work too:
- [MacStadium](https://www.macstadium.com/) (hosted Macs)
- Other hosted Mac vendors also work; follow their VM + SSH docs
Once you have SSH access to a macOS VM, continue at step 6 below.
---
## Quick path (Lume, experienced users)
1. Install Lume
2. `lume create clawdbot --os macos --ipsw latest`
3. Complete Setup Assistant, enable Remote Login (SSH)
4. `lume run clawdbot --no-display`
5. SSH in, install Clawdbot, configure channels
6. Done
---
## What you need (Lume)
- Apple Silicon Mac (M1/M2/M3/M4)
- macOS Sequoia or later on the host
- ~60 GB free disk space per VM
- ~20 minutes
---
## 1) Install Lume
```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)"
```
If `~/.local/bin` isn't in your PATH:
```bash
echo 'export PATH="$PATH:$HOME/.local/bin"' >> ~/.zshrc && source ~/.zshrc
```
Verify:
```bash
lume --version
```
Docs: [Lume Installation](https://cua.ai/docs/lume/guide/getting-started/installation)
---
## 2) Create the macOS VM
```bash
lume create clawdbot --os macos --ipsw latest
```
This downloads macOS and creates the VM. A VNC window opens automatically.
Note: The download can take a while depending on your connection.
---
## 3) Complete Setup Assistant
In the VNC window:
1. Select language and region
2. Skip Apple ID (or sign in if you want iMessage later)
3. Create a user account (remember the username and password)
4. Skip all optional features
After setup completes, enable SSH:
1. Open System Settings → General → Sharing
2. Enable "Remote Login"
---
## 4) Get the VM's IP address
```bash
lume get clawdbot
```
Look for the IP address (usually `192.168.64.x`).
---
## 5) SSH into the VM
```bash
ssh youruser@192.168.64.X
```
Replace `youruser` with the account you created, and the IP with your VM's IP.
---
## 6) Install Clawdbot
Inside the VM:
```bash
npm install -g clawdbot@latest
clawdbot onboard --install-daemon
```
Follow the onboarding prompts to set up your model provider (Anthropic, OpenAI, etc.).
---
## 7) Configure channels
Edit the config file:
```bash
nano ~/.clawdbot/clawdbot.json
```
Add your channels:
```json
{
"channels": {
"whatsapp": {
"dmPolicy": "allowlist",
"allowFrom": ["+15551234567"]
},
"telegram": {
"botToken": "YOUR_BOT_TOKEN"
}
}
}
```
Then login to WhatsApp (scan QR):
```bash
clawdbot channels login
```
---
## 8) Run the VM headlessly
Stop the VM and restart without display:
```bash
lume stop clawdbot
lume run clawdbot --no-display
```
The VM runs in the background. Clawdbot's daemon keeps the gateway running.
To check status:
```bash
ssh youruser@192.168.64.X "clawdbot status"
```
---
## Bonus: iMessage integration
This is the killer feature of running on macOS. Use [BlueBubbles](https://bluebubbles.app) to add iMessage to Clawdbot.
Inside the VM:
1. Download BlueBubbles from bluebubbles.app
2. Sign in with your Apple ID
3. Enable the Web API and set a password
4. Point BlueBubbles webhooks at your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`)
Add to your Clawdbot config:
```json
{
"channels": {
"bluebubbles": {
"serverUrl": "http://localhost:1234",
"password": "your-api-password",
"webhookPath": "/bluebubbles-webhook"
}
}
}
```
Restart the gateway. Now your agent can send and receive iMessages.
Full setup details: [BlueBubbles channel](/channels/bluebubbles)
---
## Save a golden image
Before customizing further, snapshot your clean state:
```bash
lume stop clawdbot
lume clone clawdbot clawdbot-golden
```
Reset anytime:
```bash
lume stop clawdbot && lume delete clawdbot
lume clone clawdbot-golden clawdbot
lume run clawdbot --no-display
```
---
## Running 24/7
Keep the VM running by:
- Keeping your Mac plugged in
- Disabling sleep in System Settings → Energy Saver
- Using `caffeinate` if needed
For true always-on, consider a dedicated Mac mini or a small VPS. See [VPS hosting](/vps).
---
## Troubleshooting
| Problem | Solution |
|---------|----------|
| Can't SSH into VM | Check "Remote Login" is enabled in VM's System Settings |
| VM IP not showing | Wait for VM to fully boot, run `lume get clawdbot` again |
| Lume command not found | Add `~/.local/bin` to your PATH |
| WhatsApp QR not scanning | Ensure you're logged into the VM (not host) when running `clawdbot channels login` |
---
## Related docs
- [VPS hosting](/vps)
- [Nodes](/nodes)
- [Gateway remote](/gateway/remote)
- [BlueBubbles channel](/channels/bluebubbles)
- [Lume Quickstart](https://cua.ai/docs/lume/guide/getting-started/quickstart)
- [Lume CLI Reference](https://cua.ai/docs/lume/reference/cli-reference)
- [Unattended VM Setup](https://cua.ai/docs/lume/guide/fundamentals/unattended-setup) (advanced)
- [Docker Sandboxing](/install/docker) (alternative isolation approach)
+16
View File
@@ -67,6 +67,22 @@ Plugins can register:
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
## Runtime helpers
Plugins can access selected core helpers via `api.runtime`. For telephony TTS:
```ts
const result = await api.runtime.tts.textToSpeechTelephony({
text: "Hello from Clawdbot",
cfg: api.config,
});
```
Notes:
- Uses core `messages.tts` configuration (OpenAI or ElevenLabs).
- Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
- Edge TTS is not supported for telephony.
## Discovery & precedence
Clawdbot scans, in order:
+81
View File
@@ -104,6 +104,87 @@ Notes:
- `mock` is a local dev provider (no network calls).
- `skipSignatureVerification` is for local testing only.
## TTS for calls
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
streaming speech on calls. You can override it under the plugin config with the
**same shape** — it deep‑merges with `messages.tts`.
```json5
{
tts: {
provider: "elevenlabs",
elevenlabs: {
voiceId: "pMsXgVXv3BLzUgSXRplE",
modelId: "eleven_multilingual_v2"
}
}
}
```
Notes:
- **Edge TTS is ignored for voice calls** (telephony audio needs PCM; Edge output is unreliable).
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
### More examples
Use core TTS only (no override):
```json5
{
messages: {
tts: {
provider: "openai",
openai: { voice: "alloy" }
}
}
}
```
Override to ElevenLabs just for calls (keep core default elsewhere):
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
tts: {
provider: "elevenlabs",
elevenlabs: {
apiKey: "elevenlabs_key",
voiceId: "pMsXgVXv3BLzUgSXRplE",
modelId: "eleven_multilingual_v2"
}
}
}
}
}
}
}
```
Override only the OpenAI model for calls (deep‑merge example):
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
tts: {
openai: {
model: "gpt-4o-mini-tts",
voice: "marin"
}
}
}
}
}
}
}
```
## Inbound calls
Inbound policy defaults to `disabled`. To enable inbound calls, set:
+5 -3
View File
@@ -16,7 +16,7 @@ and you configure everything via the `/setup` web wizard.
## One-click deploy
<a href="https://railway.com/deploy/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
<a href="https://railway.app/new/template?template=https://github.com/vignesh07/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
After deploy, find your public URL in **Railway → your service → Settings → Domains**.
@@ -55,6 +55,7 @@ Attach a volume mounted at:
Set these variables on the service:
- `SETUP_PASSWORD` (required)
- `PORT=8080` (required — must match the port in Public Networking)
- `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended)
- `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended)
- `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret)
@@ -82,8 +83,9 @@ If Telegram DMs are set to pairing, the setup wizard can approve the pairing cod
1) Go to https://discord.com/developers/applications
2) **New Application** → choose a name
3) **Bot** → **Add Bot**
4) Copy the **Bot Token** and paste into `/setup`
5) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup)
5) Copy the **Bot Token** and paste into `/setup`
6) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
## Backups & migration
+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`
+29 -10
View File
@@ -53,8 +53,8 @@ so that provider must also be authenticated if you enable summaries.
## Is it enabled by default?
No. TTS is **disabled** by default. Enable it in config or with `/tts on`,
which writes a local preference override.
No. Auto‑TTS is **off** by default. Enable it in config with
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
Edge TTS **is** enabled by default once TTS is on, and is used automatically
when no OpenAI or ElevenLabs API keys are available.
@@ -70,7 +70,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{
messages: {
tts: {
enabled: true,
auto: "always",
provider: "elevenlabs"
}
}
@@ -83,7 +83,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{
messages: {
tts: {
enabled: true,
auto: "always",
provider: "openai",
summaryModel: "openai/gpt-4.1-mini",
modelOverrides: {
@@ -121,7 +121,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{
messages: {
tts: {
enabled: true,
auto: "always",
provider: "edge",
edge: {
enabled: true,
@@ -156,7 +156,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{
messages: {
tts: {
enabled: true,
auto: "always",
maxTextLength: 4000,
timeoutMs: 30000,
prefsPath: "~/.clawdbot/settings/tts.json"
@@ -165,13 +165,25 @@ Full schema is in [Gateway configuration](/gateway/configuration).
}
```
### Only reply with audio after an inbound voice note
```json5
{
messages: {
tts: {
auto: "inbound"
}
}
}
```
### Disable auto-summary for long replies
```json5
{
messages: {
tts: {
enabled: true
auto: "always"
}
}
}
@@ -185,7 +197,10 @@ Then run:
### Notes on fields
- `enabled`: master toggle (default `false`; local prefs can override).
- `auto`: auto‑TTS mode (`off`, `always`, `inbound`, `tagged`).
- `inbound` only sends audio after an inbound voice note.
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
- `provider`: `"elevenlabs"`, `"openai"`, or `"edge"` (fallback is automatic).
- If `provider` is **unset**, Clawdbot prefers `openai` (if key), then `elevenlabs` (if key),
@@ -195,7 +210,7 @@ Then run:
- `modelOverrides`: allow the model to emit TTS directives (on by default).
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
- `timeoutMs`: request timeout (ms).
- `prefsPath`: override the local prefs JSON path.
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `elevenlabs.voiceSettings`:
@@ -218,6 +233,7 @@ Then run:
## Model-driven overrides (default on)
By default, the model **can** emit TTS directives for a single reply.
When `messages.tts.auto` is `tagged`, these directives are required to trigger audio.
When enabled, the model can emit `[[tts:...]]` directives to override the voice
for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
@@ -338,8 +354,10 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
`/voice` as the native command there. Text `/tts ...` still works.
```
/tts on
/tts off
/tts always
/tts inbound
/tts tagged
/tts status
/tts provider openai
/tts limit 2000
@@ -350,6 +368,7 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
Notes:
- Commands require an authorized sender (allowlist/owner rules still apply).
- `commands.text` or native command registration must be enabled.
- `off|always|inbound|tagged` are per‑session toggles (`/tts on` is an alias for `/tts always`).
- `limit` and `summary` are stored in local prefs, not the main config.
- `/tts audio` generates a one-off audio reply (does not toggle TTS on).
+8
View File
@@ -118,6 +118,14 @@ Other Gateway slash commands (for example, `/context`) are forwarded to the Gate
- `--deliver`: Deliver assistant replies to the provider (default off)
- `--thinking <level>`: Override thinking level for sends
- `--timeout-ms <ms>`: Agent timeout in ms (defaults to `agents.defaults.timeoutSeconds`)
## Troubleshooting
No output after sending a message:
- Run `/status` in the TUI to confirm the Gateway is connected and idle/busy.
- Check the Gateway logs: `clawdbot logs --follow`.
- Confirm the agent can run: `clawdbot status` and `clawdbot models status`.
- If you expect messages in a chat channel, enable delivery (`/deliver on` or `--deliver`).
- `--history-limit <n>`: History entries to load (default 200)
## Troubleshooting
+54
View File
@@ -25,9 +25,11 @@ import { resolveBlueBubblesMessageId } from "./monitor.js";
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js";
import {
extractHandleFromChatGuid,
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesHandle,
normalizeBlueBubblesMessagingTarget,
parseBlueBubblesTarget,
} from "./targets.js";
import { bluebubblesMessageActions } from "./actions.js";
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
@@ -148,6 +150,58 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
looksLikeId: looksLikeBlueBubblesTargetId,
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
},
formatTargetDisplay: ({ target, display }) => {
const shouldParseDisplay = (value: string): boolean => {
if (looksLikeBlueBubblesTargetId(value)) return true;
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
};
// Helper to extract a clean handle from any BlueBubbles target format
const extractCleanDisplay = (value: string | undefined): string | null => {
const trimmed = value?.trim();
if (!trimmed) return null;
try {
const parsed = parseBlueBubblesTarget(trimmed);
if (parsed.kind === "chat_guid") {
const handle = extractHandleFromChatGuid(parsed.chatGuid);
if (handle) return handle;
}
if (parsed.kind === "handle") {
return normalizeBlueBubblesHandle(parsed.to);
}
} catch {
// Fall through
}
// Strip common prefixes and try raw extraction
const stripped = trimmed
.replace(/^bluebubbles:/i, "")
.replace(/^chat_guid:/i, "")
.replace(/^chat_id:/i, "")
.replace(/^chat_identifier:/i, "");
const handle = extractHandleFromChatGuid(stripped);
if (handle) return handle;
// Don't return raw chat_guid formats - they contain internal routing info
if (stripped.includes(";-;") || stripped.includes(";+;")) return null;
return stripped;
};
// Try to get a clean display from the display parameter first
const trimmedDisplay = display?.trim();
if (trimmedDisplay) {
if (!shouldParseDisplay(trimmedDisplay)) {
return trimmedDisplay;
}
const cleanDisplay = extractCleanDisplay(trimmedDisplay);
if (cleanDisplay) return cleanDisplay;
}
// Fall back to extracting from target
const cleanTarget = extractCleanDisplay(target);
if (cleanTarget) return cleanTarget;
// Last resort: return display or target as-is
return display?.trim() || target?.trim() || "";
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
+42 -1
View File
@@ -187,6 +187,47 @@ describe("send", () => {
expect(result).toBe("iMessage;-;+15551234567");
});
it("returns null when handle only exists in group chat (not DM)", async () => {
// This is the critical fix: if a phone number only exists as a participant in a group chat
// (no direct DM chat), we should NOT send to that group. Return null instead.
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;+;group-the-council",
participants: [
{ address: "+12622102921" },
{ address: "+15550001111" },
{ address: "+15550002222" },
],
},
],
}),
})
// Empty second page to stop pagination
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+12622102921",
service: "imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
// Should return null, NOT the group chat GUID
expect(result).toBeNull();
});
it("returns null when chat not found", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
@@ -351,7 +392,7 @@ describe("send", () => {
});
await expect(
sendMessageBlueBubbles("+15559999999", "Hello", {
sendMessageBlueBubbles("chat_id:123", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
}),
+71 -5
View File
@@ -257,11 +257,17 @@ export async function resolveChatGuidForTarget(params: {
return guid;
}
if (!participantMatch && guid) {
const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
);
if (participants.includes(normalizedHandle)) {
participantMatch = guid;
// Only consider DM chats (`;-;` separator) as participant matches.
// Group chats (`;+;` separator) should never match when searching by handle/phone.
// This prevents routing "send to +1234567890" to a group chat that contains that number.
const isDmChat = guid.includes(";-;");
if (isDmChat) {
const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
);
if (participants.includes(normalizedHandle)) {
participantMatch = guid;
}
}
}
}
@@ -270,6 +276,55 @@ export async function resolveChatGuidForTarget(params: {
return participantMatch;
}
/**
* Creates a new chat (DM) and optionally sends an initial message.
* Requires Private API to be enabled in BlueBubbles.
*/
async function createNewChatWithMessage(params: {
baseUrl: string;
password: string;
address: string;
message: string;
timeoutMs?: number;
}): Promise<BlueBubblesSendResult> {
const url = buildBlueBubblesApiUrl({
baseUrl: params.baseUrl,
path: "/api/v1/chat/new",
password: params.password,
});
const payload = {
addresses: [params.address],
message: params.message,
};
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
params.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text();
// Check for Private API not enabled error
if (res.status === 400 || res.status === 403 || errorText.toLowerCase().includes("private api")) {
throw new Error(
`BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`,
);
}
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
if (!body) return { messageId: "ok" };
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
}
export async function sendMessageBlueBubbles(
to: string,
text: string,
@@ -297,6 +352,17 @@ export async function sendMessageBlueBubbles(
target,
});
if (!chatGuid) {
// If target is a phone number/handle and no existing chat found,
// auto-create a new DM chat using the /api/v1/chat/new endpoint
if (target.kind === "handle") {
return createNewChatWithMessage({
baseUrl,
password,
address: target.address,
message: trimmedText,
timeoutMs: opts.timeoutMs,
});
}
throw new Error(
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
);
+11
View File
@@ -0,0 +1,11 @@
{
"id": "line",
"channels": [
"line"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { linePlugin } from "./src/channel.js";
import { registerLineCardCommand } from "./src/card-command.js";
import { setLineRuntime } from "./src/runtime.js";
const plugin = {
id: "line",
name: "LINE",
description: "LINE Messaging API channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setLineRuntime(api.runtime);
api.registerChannel({ plugin: linePlugin });
registerLineCardCommand(api);
},
};
export default plugin;
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@clawdbot/line",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot LINE channel plugin",
"clawdbot": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "line",
"label": "LINE",
"selectionLabel": "LINE (Messaging API)",
"docsPath": "/channels/line",
"docsLabel": "line",
"blurb": "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
"order": 75,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/line",
"localPath": "extensions/line",
"defaultChoice": "npm"
}
},
"devDependencies": {
"clawdbot": "workspace:*"
}
}
+338
View File
@@ -0,0 +1,338 @@
import type { ClawdbotPluginApi, LineChannelData, ReplyPayload } from "clawdbot/plugin-sdk";
import {
createActionCard,
createImageCard,
createInfoCard,
createListCard,
createReceiptCard,
type CardAction,
type ListItem,
} from "clawdbot/plugin-sdk";
const CARD_USAGE = `Usage: /card <type> "title" "body" [options]
Types:
info "Title" "Body" ["Footer"]
image "Title" "Caption" --url <image-url>
action "Title" "Body" --actions "Btn1|url1,Btn2|text2"
list "Title" "Item1|Desc1,Item2|Desc2"
receipt "Title" "Item1:$10,Item2:$20" --total "$30"
confirm "Question?" --yes "Yes|data" --no "No|data"
buttons "Title" "Text" --actions "Btn1|url1,Btn2|data2"
Examples:
/card info "Welcome" "Thanks for joining!"
/card image "Product" "Check it out" --url https://example.com/img.jpg
/card action "Menu" "Choose an option" --actions "Order|/order,Help|/help"`;
function buildLineReply(lineData: LineChannelData): ReplyPayload {
return {
channelData: {
line: lineData,
},
};
}
/**
* Parse action string format: "Label|data,Label2|data2"
* Data can be a URL (uri action) or plain text (message action) or key=value (postback)
*/
function parseActions(actionsStr: string | undefined): CardAction[] {
if (!actionsStr) return [];
const results: CardAction[] = [];
for (const part of actionsStr.split(",")) {
const [label, data] = part
.trim()
.split("|")
.map((s) => s.trim());
if (!label) continue;
const actionData = data || label;
if (actionData.startsWith("http://") || actionData.startsWith("https://")) {
results.push({
label,
action: { type: "uri", label: label.slice(0, 20), uri: actionData },
});
} else if (actionData.includes("=")) {
results.push({
label,
action: {
type: "postback",
label: label.slice(0, 20),
data: actionData.slice(0, 300),
displayText: label,
},
});
} else {
results.push({
label,
action: { type: "message", label: label.slice(0, 20), text: actionData },
});
}
}
return results;
}
/**
* Parse list items format: "Item1|Subtitle1,Item2|Subtitle2"
*/
function parseListItems(itemsStr: string): ListItem[] {
return itemsStr
.split(",")
.map((part) => {
const [title, subtitle] = part
.trim()
.split("|")
.map((s) => s.trim());
return { title: title || "", subtitle };
})
.filter((item) => item.title);
}
/**
* Parse receipt items format: "Item1:$10,Item2:$20"
*/
function parseReceiptItems(itemsStr: string): Array<{ name: string; value: string }> {
return itemsStr
.split(",")
.map((part) => {
const colonIndex = part.lastIndexOf(":");
if (colonIndex === -1) {
return { name: part.trim(), value: "" };
}
return {
name: part.slice(0, colonIndex).trim(),
value: part.slice(colonIndex + 1).trim(),
};
})
.filter((item) => item.name);
}
/**
* Parse quoted arguments from command string
* Supports: /card type "arg1" "arg2" "arg3" --flag value
*/
function parseCardArgs(argsStr: string): {
type: string;
args: string[];
flags: Record<string, string>;
} {
const result: { type: string; args: string[]; flags: Record<string, string> } = {
type: "",
args: [],
flags: {},
};
// Extract type (first word)
const typeMatch = argsStr.match(/^(\w+)/);
if (typeMatch) {
result.type = typeMatch[1].toLowerCase();
argsStr = argsStr.slice(typeMatch[0].length).trim();
}
// Extract quoted arguments
const quotedRegex = /"([^"]*?)"/g;
let match;
while ((match = quotedRegex.exec(argsStr)) !== null) {
result.args.push(match[1]);
}
// Extract flags (--key value or --key "value")
const flagRegex = /--(\w+)\s+(?:"([^"]*?)"|(\S+))/g;
while ((match = flagRegex.exec(argsStr)) !== null) {
result.flags[match[1]] = match[2] ?? match[3];
}
return result;
}
export function registerLineCardCommand(api: ClawdbotPluginApi): void {
api.registerCommand({
name: "card",
description: "Send a rich card message (LINE).",
acceptsArgs: true,
requireAuth: false,
handler: async (ctx) => {
const argsStr = ctx.args?.trim() ?? "";
if (!argsStr) return { text: CARD_USAGE };
const parsed = parseCardArgs(argsStr);
const { type, args, flags } = parsed;
if (!type) return { text: CARD_USAGE };
// Only LINE supports rich cards; fallback to text elsewhere.
if (ctx.channel !== "line") {
const fallbackText = args.join(" - ");
return { text: `[${type} card] ${fallbackText}`.trim() };
}
try {
switch (type) {
case "info": {
const [title = "Info", body = "", footer] = args;
const bubble = createInfoCard(title, body, footer);
return buildLineReply({
flexMessage: {
altText: `${title}: ${body}`.slice(0, 400),
contents: bubble,
},
});
}
case "image": {
const [title = "Image", caption = ""] = args;
const imageUrl = flags.url || flags.image;
if (!imageUrl) {
return { text: "Error: Image card requires --url <image-url>" };
}
const bubble = createImageCard(imageUrl, title, caption);
return buildLineReply({
flexMessage: {
altText: `${title}: ${caption}`.slice(0, 400),
contents: bubble,
},
});
}
case "action": {
const [title = "Actions", body = ""] = args;
const actions = parseActions(flags.actions);
if (actions.length === 0) {
return { text: 'Error: Action card requires --actions "Label1|data1,Label2|data2"' };
}
const bubble = createActionCard(title, body, actions, {
imageUrl: flags.url || flags.image,
});
return buildLineReply({
flexMessage: {
altText: `${title}: ${body}`.slice(0, 400),
contents: bubble,
},
});
}
case "list": {
const [title = "List", itemsStr = ""] = args;
const items = parseListItems(itemsStr || flags.items || "");
if (items.length === 0) {
return {
text:
'Error: List card requires items. Usage: /card list "Title" "Item1|Desc1,Item2|Desc2"',
};
}
const bubble = createListCard(title, items);
return buildLineReply({
flexMessage: {
altText: `${title}: ${items.map((i) => i.title).join(", ")}`.slice(0, 400),
contents: bubble,
},
});
}
case "receipt": {
const [title = "Receipt", itemsStr = ""] = args;
const items = parseReceiptItems(itemsStr || flags.items || "");
const total = flags.total ? { label: "Total", value: flags.total } : undefined;
const footer = flags.footer;
if (items.length === 0) {
return {
text:
'Error: Receipt card requires items. Usage: /card receipt "Title" "Item1:$10,Item2:$20" --total "$30"',
};
}
const bubble = createReceiptCard({ title, items, total, footer });
return buildLineReply({
flexMessage: {
altText: `${title}: ${items.map((i) => `${i.name} ${i.value}`).join(", ")}`.slice(
0,
400,
),
contents: bubble,
},
});
}
case "confirm": {
const [question = "Confirm?"] = args;
const yesStr = flags.yes || "Yes|yes";
const noStr = flags.no || "No|no";
const [yesLabel, yesData] = yesStr.split("|").map((s) => s.trim());
const [noLabel, noData] = noStr.split("|").map((s) => s.trim());
return buildLineReply({
templateMessage: {
type: "confirm",
text: question,
confirmLabel: yesLabel || "Yes",
confirmData: yesData || "yes",
cancelLabel: noLabel || "No",
cancelData: noData || "no",
altText: question,
},
});
}
case "buttons": {
const [title = "Menu", text = "Choose an option"] = args;
const actionsStr = flags.actions || "";
const actionParts = parseActions(actionsStr);
if (actionParts.length === 0) {
return { text: 'Error: Buttons card requires --actions "Label1|data1,Label2|data2"' };
}
const templateActions: Array<{
type: "message" | "uri" | "postback";
label: string;
data?: string;
uri?: string;
}> = actionParts.map((a) => {
const action = a.action;
const label = action.label ?? a.label;
if (action.type === "uri") {
return { type: "uri" as const, label, uri: (action as { uri: string }).uri };
}
if (action.type === "postback") {
return {
type: "postback" as const,
label,
data: (action as { data: string }).data,
};
}
return {
type: "message" as const,
label,
data: (action as { text: string }).text,
};
});
return buildLineReply({
templateMessage: {
type: "buttons",
title,
text,
thumbnailImageUrl: flags.url || flags.image,
actions: templateActions,
},
});
}
default:
return {
text: `Unknown card type: "${type}". Available types: info, image, action, list, receipt, confirm, buttons`,
};
}
} catch (err) {
return { text: `Error creating card: ${String(err)}` };
}
},
});
}
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import { linePlugin } from "./channel.js";
import { setLineRuntime } from "./runtime.js";
const DEFAULT_ACCOUNT_ID = "default";
type LineRuntimeMocks = {
writeConfigFile: ReturnType<typeof vi.fn>;
resolveLineAccount: ReturnType<typeof vi.fn>;
};
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
const writeConfigFile = vi.fn(async () => {});
const resolveLineAccount = vi.fn(({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string }) => {
const lineConfig = (cfg.channels?.line ?? {}) as {
tokenFile?: string;
secretFile?: string;
channelAccessToken?: string;
channelSecret?: string;
accounts?: Record<string, Record<string, unknown>>;
};
const entry =
accountId && accountId !== DEFAULT_ACCOUNT_ID
? lineConfig.accounts?.[accountId] ?? {}
: lineConfig;
const hasToken =
Boolean((entry as any).channelAccessToken) || Boolean((entry as any).tokenFile);
const hasSecret =
Boolean((entry as any).channelSecret) || Boolean((entry as any).secretFile);
return { tokenSource: hasToken && hasSecret ? "config" : "none" };
});
const runtime = {
config: { writeConfigFile },
channel: { line: { resolveLineAccount } },
} as unknown as PluginRuntime;
return { runtime, mocks: { writeConfigFile, resolveLineAccount } };
}
describe("linePlugin gateway.logoutAccount", () => {
beforeEach(() => {
setLineRuntime(createRuntime().runtime);
});
it("clears tokenFile/secretFile on default account logout", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg: ClawdbotConfig = {
channels: {
line: {
tokenFile: "/tmp/token",
secretFile: "/tmp/secret",
},
},
};
const result = await linePlugin.gateway.logoutAccount({
accountId: DEFAULT_ACCOUNT_ID,
cfg,
});
expect(result.cleared).toBe(true);
expect(result.loggedOut).toBe(true);
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
});
it("clears tokenFile/secretFile on account logout", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg: ClawdbotConfig = {
channels: {
line: {
accounts: {
primary: {
tokenFile: "/tmp/token",
secretFile: "/tmp/secret",
},
},
},
},
};
const result = await linePlugin.gateway.logoutAccount({
accountId: "primary",
cfg,
});
expect(result.cleared).toBe(true);
expect(result.loggedOut).toBe(true);
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
});
});
@@ -0,0 +1,308 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import { linePlugin } from "./channel.js";
import { setLineRuntime } from "./runtime.js";
type LineRuntimeMocks = {
pushMessageLine: ReturnType<typeof vi.fn>;
pushMessagesLine: ReturnType<typeof vi.fn>;
pushFlexMessage: ReturnType<typeof vi.fn>;
pushTemplateMessage: ReturnType<typeof vi.fn>;
pushLocationMessage: ReturnType<typeof vi.fn>;
pushTextMessageWithQuickReplies: ReturnType<typeof vi.fn>;
createQuickReplyItems: ReturnType<typeof vi.fn>;
buildTemplateMessageFromPayload: ReturnType<typeof vi.fn>;
sendMessageLine: ReturnType<typeof vi.fn>;
chunkMarkdownText: ReturnType<typeof vi.fn>;
resolveLineAccount: ReturnType<typeof vi.fn>;
resolveTextChunkLimit: ReturnType<typeof vi.fn>;
};
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
const pushMessageLine = vi.fn(async () => ({ messageId: "m-text", chatId: "c1" }));
const pushMessagesLine = vi.fn(async () => ({ messageId: "m-batch", chatId: "c1" }));
const pushFlexMessage = vi.fn(async () => ({ messageId: "m-flex", chatId: "c1" }));
const pushTemplateMessage = vi.fn(async () => ({ messageId: "m-template", chatId: "c1" }));
const pushLocationMessage = vi.fn(async () => ({ messageId: "m-loc", chatId: "c1" }));
const pushTextMessageWithQuickReplies = vi.fn(async () => ({
messageId: "m-quick",
chatId: "c1",
}));
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
const buildTemplateMessageFromPayload = vi.fn(() => ({ type: "buttons" }));
const sendMessageLine = vi.fn(async () => ({ messageId: "m-media", chatId: "c1" }));
const chunkMarkdownText = vi.fn((text: string) => [text]);
const resolveTextChunkLimit = vi.fn(() => 123);
const resolveLineAccount = vi.fn(({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string }) => {
const resolved = accountId ?? "default";
const lineConfig = (cfg.channels?.line ?? {}) as {
accounts?: Record<string, Record<string, unknown>>;
};
const accountConfig =
resolved !== "default" ? lineConfig.accounts?.[resolved] ?? {} : {};
return {
accountId: resolved,
config: { ...lineConfig, ...accountConfig },
};
});
const runtime = {
channel: {
line: {
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
buildTemplateMessageFromPayload,
sendMessageLine,
resolveLineAccount,
},
text: {
chunkMarkdownText,
resolveTextChunkLimit,
},
},
} as unknown as PluginRuntime;
return {
runtime,
mocks: {
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
buildTemplateMessageFromPayload,
sendMessageLine,
chunkMarkdownText,
resolveLineAccount,
resolveTextChunkLimit,
},
};
}
describe("linePlugin outbound.sendPayload", () => {
it("sends flex message without dropping text", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as ClawdbotConfig;
const payload = {
text: "Now playing:",
channelData: {
line: {
flexMessage: {
altText: "Now playing",
contents: { type: "bubble" },
},
},
},
};
await linePlugin.outbound.sendPayload({
to: "line:group:1",
payload,
accountId: "default",
cfg,
});
expect(mocks.pushFlexMessage).toHaveBeenCalledTimes(1);
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:group:1", "Now playing:", {
verbose: false,
accountId: "default",
});
});
it("sends template message without dropping text", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as ClawdbotConfig;
const payload = {
text: "Choose one:",
channelData: {
line: {
templateMessage: {
type: "confirm",
text: "Continue?",
confirmLabel: "Yes",
confirmData: "yes",
cancelLabel: "No",
cancelData: "no",
},
},
},
};
await linePlugin.outbound.sendPayload({
to: "line:user:1",
payload,
accountId: "default",
cfg,
});
expect(mocks.buildTemplateMessageFromPayload).toHaveBeenCalledTimes(1);
expect(mocks.pushTemplateMessage).toHaveBeenCalledTimes(1);
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:1", "Choose one:", {
verbose: false,
accountId: "default",
});
});
it("attaches quick replies when no text chunks are present", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as ClawdbotConfig;
const payload = {
channelData: {
line: {
quickReplies: ["One", "Two"],
flexMessage: {
altText: "Card",
contents: { type: "bubble" },
},
},
},
};
await linePlugin.outbound.sendPayload({
to: "line:user:2",
payload,
accountId: "default",
cfg,
});
expect(mocks.pushFlexMessage).not.toHaveBeenCalled();
expect(mocks.pushMessagesLine).toHaveBeenCalledWith(
"line:user:2",
[
{
type: "flex",
altText: "Card",
contents: { type: "bubble" },
quickReply: { items: ["One", "Two"] },
},
],
{ verbose: false, accountId: "default" },
);
expect(mocks.createQuickReplyItems).toHaveBeenCalledWith(["One", "Two"]);
});
it("sends media before quick-reply text so buttons stay visible", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as ClawdbotConfig;
const payload = {
text: "Hello",
mediaUrl: "https://example.com/img.jpg",
channelData: {
line: {
quickReplies: ["One", "Two"],
},
},
};
await linePlugin.outbound.sendPayload({
to: "line:user:3",
payload,
accountId: "default",
cfg,
});
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:3", "", {
verbose: false,
mediaUrl: "https://example.com/img.jpg",
accountId: "default",
});
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
"line:user:3",
"Hello",
["One", "Two"],
{ verbose: false, accountId: "default" },
);
const mediaOrder = mocks.sendMessageLine.mock.invocationCallOrder[0];
const quickReplyOrder = mocks.pushTextMessageWithQuickReplies.mock.invocationCallOrder[0];
expect(mediaOrder).toBeLessThan(quickReplyOrder);
});
it("uses configured text chunk limit for payloads", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: { textChunkLimit: 123 } } } as ClawdbotConfig;
const payload = {
text: "Hello world",
channelData: {
line: {
flexMessage: {
altText: "Card",
contents: { type: "bubble" },
},
},
},
};
await linePlugin.outbound.sendPayload({
to: "line:user:3",
payload,
accountId: "primary",
cfg,
});
expect(mocks.resolveTextChunkLimit).toHaveBeenCalledWith(
cfg,
"line",
"primary",
{ fallbackLimit: 5000 },
);
expect(mocks.chunkMarkdownText).toHaveBeenCalledWith("Hello world", 123);
});
});
describe("linePlugin config.formatAllowFrom", () => {
it("strips line:user: prefixes without lowercasing", () => {
const formatted = linePlugin.config.formatAllowFrom({
allowFrom: ["line:user:UABC", "line:UDEF"],
});
expect(formatted).toEqual(["UABC", "UDEF"]);
});
});
describe("linePlugin groups.resolveRequireMention", () => {
it("uses account-level group settings when provided", () => {
const { runtime } = createRuntime();
setLineRuntime(runtime);
const cfg = {
channels: {
line: {
groups: {
"*": { requireMention: false },
},
accounts: {
primary: {
groups: {
"group-1": { requireMention: true },
},
},
},
},
},
} as ClawdbotConfig;
const requireMention = linePlugin.groups.resolveRequireMention({
cfg,
accountId: "primary",
groupId: "group-1",
});
expect(requireMention).toBe(true);
});
});
+773
View File
@@ -0,0 +1,773 @@
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
LineConfigSchema,
processLineMessage,
type ChannelPlugin,
type ClawdbotConfig,
type LineConfig,
type LineChannelData,
type ResolvedLineAccount,
} from "clawdbot/plugin-sdk";
import { getLineRuntime } from "./runtime.js";
// LINE channel metadata
const meta = {
id: "line",
label: "LINE",
selectionLabel: "LINE (Messaging API)",
detailLabel: "LINE Bot",
docsPath: "/channels/line",
docsLabel: "line",
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
systemImage: "message.fill",
};
function parseThreadId(threadId?: string | number | null): number | undefined {
if (threadId == null) return undefined;
if (typeof threadId === "number") {
return Number.isFinite(threadId) ? Math.trunc(threadId) : undefined;
}
const trimmed = threadId.trim();
if (!trimmed) return undefined;
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
id: "line",
meta: {
...meta,
quickstartAllowFrom: true,
},
pairing: {
idLabel: "lineUserId",
normalizeAllowEntry: (entry) => {
// LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:).
return entry.replace(/^line:(?:user:)?/i, "");
},
notifyApproval: async ({ cfg, id }) => {
const line = getLineRuntime().channel.line;
const account = line.resolveLineAccount({ cfg });
if (!account.channelAccessToken) {
throw new Error("LINE channel access token not configured");
}
await line.pushMessageLine(id, "Clawdbot: your access has been approved.", {
channelAccessToken: account.channelAccessToken,
});
},
},
capabilities: {
chatTypes: ["direct", "group"],
reactions: false,
threads: false,
media: true,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.line"] },
configSchema: buildChannelConfigSchema(LineConfigSchema),
config: {
listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg),
resolveAccount: (cfg, accountId) =>
getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }),
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
enabled,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
accounts: {
...lineConfig.accounts,
[accountId]: {
...lineConfig.accounts?.[accountId],
enabled,
},
},
},
},
};
},
deleteAccount: ({ cfg, accountId }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
const { channelAccessToken, channelSecret, tokenFile, secretFile, ...rest } = lineConfig;
return {
...cfg,
channels: {
...cfg.channels,
line: rest,
},
};
}
const accounts = { ...lineConfig.accounts };
delete accounts[accountId];
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
},
},
};
},
isConfigured: (account) => Boolean(account.channelAccessToken?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.channelAccessToken?.trim()),
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => {
// LINE sender IDs are case-sensitive; keep original casing.
return entry.replace(/^line:(?:user:)?/i, "");
}),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg.channels?.line as LineConfig | undefined)?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.line.accounts.${resolvedAccountId}.`
: "channels.line.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: "clawdbot pairing approve line <code>",
normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy =
(cfg.channels?.defaults as { groupPolicy?: string } | undefined)?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- LINE groups: groupPolicy="open" allows any member in groups to trigger. Set channels.line.groupPolicy="allowlist" + channels.line.groupAllowFrom to restrict senders.`,
];
},
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
const account = getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId });
const groups = account.config.groups;
if (!groups) return false;
const groupConfig = groups[groupId] ?? groups["*"];
return groupConfig?.requireMention ?? false;
},
},
messaging: {
normalizeTarget: (target) => {
const trimmed = target.trim();
if (!trimmed) return null;
return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
},
targetResolver: {
looksLikeId: (id) => {
const trimmed = id?.trim();
if (!trimmed) return false;
// LINE user IDs are typically U followed by 32 hex characters
// Group IDs are C followed by 32 hex characters
// Room IDs are R followed by 32 hex characters
return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed);
},
hint: "<userId|groupId|roomId>",
},
},
directory: {
self: async () => null,
listPeers: async () => [],
listGroups: async () => [],
},
setup: {
resolveAccountId: ({ accountId }) =>
getLineRuntime().channel.line.normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
name,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
accounts: {
...lineConfig.accounts,
[accountId]: {
...lineConfig.accounts?.[accountId],
name,
},
},
},
},
};
},
validateInput: ({ accountId, input }) => {
const typedInput = input as {
useEnv?: boolean;
channelAccessToken?: string;
channelSecret?: string;
tokenFile?: string;
secretFile?: string;
};
if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.";
}
if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) {
return "LINE requires channelAccessToken or --token-file (or --use-env).";
}
if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) {
return "LINE requires channelSecret or --secret-file (or --use-env).";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const typedInput = input as {
name?: string;
useEnv?: boolean;
channelAccessToken?: string;
channelSecret?: string;
tokenFile?: string;
secretFile?: string;
};
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
enabled: true,
...(typedInput.name ? { name: typedInput.name } : {}),
...(typedInput.useEnv
? {}
: typedInput.tokenFile
? { tokenFile: typedInput.tokenFile }
: typedInput.channelAccessToken
? { channelAccessToken: typedInput.channelAccessToken }
: {}),
...(typedInput.useEnv
? {}
: typedInput.secretFile
? { secretFile: typedInput.secretFile }
: typedInput.channelSecret
? { channelSecret: typedInput.channelSecret }
: {}),
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
enabled: true,
accounts: {
...lineConfig.accounts,
[accountId]: {
...lineConfig.accounts?.[accountId],
enabled: true,
...(typedInput.name ? { name: typedInput.name } : {}),
...(typedInput.tokenFile
? { tokenFile: typedInput.tokenFile }
: typedInput.channelAccessToken
? { channelAccessToken: typedInput.channelAccessToken }
: {}),
...(typedInput.secretFile
? { secretFile: typedInput.secretFile }
: typedInput.channelSecret
? { channelSecret: typedInput.channelSecret }
: {}),
},
},
},
},
};
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
textChunkLimit: 5000, // LINE allows up to 5000 characters per text message
sendPayload: async ({ to, payload, accountId, cfg }) => {
const runtime = getLineRuntime();
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
const sendText = runtime.channel.line.pushMessageLine;
const sendBatch = runtime.channel.line.pushMessagesLine;
const sendFlex = runtime.channel.line.pushFlexMessage;
const sendTemplate = runtime.channel.line.pushTemplateMessage;
const sendLocation = runtime.channel.line.pushLocationMessage;
const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies;
const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload;
const createQuickReplyItems = runtime.channel.line.createQuickReplyItems;
let lastResult: { messageId: string; chatId: string } | null = null;
const hasQuickReplies = Boolean(lineData.quickReplies?.length);
const quickReply = hasQuickReplies
? createQuickReplyItems(lineData.quickReplies!)
: undefined;
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
if (messages.length === 0) return;
for (let i = 0; i < messages.length; i += 5) {
const result = await sendBatch(to, messages.slice(i, i + 5), {
verbose: false,
accountId: accountId ?? undefined,
});
lastResult = { messageId: result.messageId, chatId: result.chatId };
}
};
const processed = payload.text
? processLineMessage(payload.text)
: { text: "", flexMessages: [] };
const chunkLimit =
runtime.channel.text.resolveTextChunkLimit?.(
cfg,
"line",
accountId ?? undefined,
{
fallbackLimit: 5000,
},
) ?? 5000;
const chunks = processed.text
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
: [];
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
if (!shouldSendQuickRepliesInline) {
if (lineData.flexMessage) {
lastResult = await sendFlex(
to,
lineData.flexMessage.altText,
lineData.flexMessage.contents,
{
verbose: false,
accountId: accountId ?? undefined,
},
);
}
if (lineData.templateMessage) {
const template = buildTemplate(lineData.templateMessage);
if (template) {
lastResult = await sendTemplate(to, template, {
verbose: false,
accountId: accountId ?? undefined,
});
}
}
if (lineData.location) {
lastResult = await sendLocation(to, lineData.location, {
verbose: false,
accountId: accountId ?? undefined,
});
}
for (const flexMsg of processed.flexMessages) {
lastResult = await sendFlex(to, flexMsg.altText, flexMsg.contents, {
verbose: false,
accountId: accountId ?? undefined,
});
}
}
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
for (const url of mediaUrls) {
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
verbose: false,
mediaUrl: url,
accountId: accountId ?? undefined,
});
}
}
if (chunks.length > 0) {
for (let i = 0; i < chunks.length; i += 1) {
const isLast = i === chunks.length - 1;
if (isLast && hasQuickReplies) {
lastResult = await sendQuickReplies(to, chunks[i]!, lineData.quickReplies!, {
verbose: false,
accountId: accountId ?? undefined,
});
} else {
lastResult = await sendText(to, chunks[i]!, {
verbose: false,
accountId: accountId ?? undefined,
});
}
}
} else if (shouldSendQuickRepliesInline) {
const quickReplyMessages: Array<Record<string, unknown>> = [];
if (lineData.flexMessage) {
quickReplyMessages.push({
type: "flex",
altText: lineData.flexMessage.altText.slice(0, 400),
contents: lineData.flexMessage.contents,
});
}
if (lineData.templateMessage) {
const template = buildTemplate(lineData.templateMessage);
if (template) {
quickReplyMessages.push(template);
}
}
if (lineData.location) {
quickReplyMessages.push({
type: "location",
title: lineData.location.title.slice(0, 100),
address: lineData.location.address.slice(0, 100),
latitude: lineData.location.latitude,
longitude: lineData.location.longitude,
});
}
for (const flexMsg of processed.flexMessages) {
quickReplyMessages.push({
type: "flex",
altText: flexMsg.altText.slice(0, 400),
contents: flexMsg.contents,
});
}
for (const url of mediaUrls) {
const trimmed = url?.trim();
if (!trimmed) continue;
quickReplyMessages.push({
type: "image",
originalContentUrl: trimmed,
previewImageUrl: trimmed,
});
}
if (quickReplyMessages.length > 0 && quickReply) {
const lastIndex = quickReplyMessages.length - 1;
quickReplyMessages[lastIndex] = {
...quickReplyMessages[lastIndex],
quickReply,
};
await sendMessageBatch(quickReplyMessages);
}
}
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
for (const url of mediaUrls) {
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
verbose: false,
mediaUrl: url,
accountId: accountId ?? undefined,
});
}
}
if (lastResult) return { channel: "line", ...lastResult };
return { channel: "line", messageId: "empty", chatId: to };
},
sendText: async ({ to, text, accountId }) => {
const runtime = getLineRuntime();
const sendText = runtime.channel.line.pushMessageLine;
const sendFlex = runtime.channel.line.pushFlexMessage;
// Process markdown: extract tables/code blocks, strip formatting
const processed = processLineMessage(text);
// Send cleaned text first (if non-empty)
let result: { messageId: string; chatId: string };
if (processed.text.trim()) {
result = await sendText(to, processed.text, {
verbose: false,
accountId: accountId ?? undefined,
});
} else {
// If text is empty after processing, still need a result
result = { messageId: "processed", chatId: to };
}
// Send flex messages for tables/code blocks
for (const flexMsg of processed.flexMessages) {
await sendFlex(to, flexMsg.altText, flexMsg.contents, {
verbose: false,
accountId: accountId ?? undefined,
});
}
return { channel: "line", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
const send = getLineRuntime().channel.line.sendMessageLine;
const result = await send(to, text, {
verbose: false,
mediaUrl,
accountId: accountId ?? undefined,
});
return { channel: "line", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: ({ account }) => {
const issues: Array<{ level: "error" | "warning"; message: string }> = [];
if (!account.channelAccessToken?.trim()) {
issues.push({
level: "error",
message: "LINE channel access token not configured",
});
}
if (!account.channelSecret?.trim()) {
issues.push({
level: "error",
message: "LINE channel secret not configured",
});
}
return issues;
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
tokenSource: snapshot.tokenSource ?? "none",
running: snapshot.running ?? false,
mode: snapshot.mode ?? null,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs),
buildAccountSnapshot: ({ account, runtime, probe }) => {
const configured = Boolean(account.channelAccessToken?.trim());
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
mode: "webhook",
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const token = account.channelAccessToken.trim();
const secret = account.channelSecret.trim();
let lineBotLabel = "";
try {
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
if (displayName) lineBotLabel = ` (${displayName})`;
} catch (err) {
if (getLineRuntime().logging.shouldLogVerbose()) {
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
}
}
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
return getLineRuntime().channel.line.monitorLineProvider({
channelAccessToken: token,
channelSecret: secret,
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
});
},
logoutAccount: async ({ accountId, cfg }) => {
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
const nextCfg = { ...cfg } as ClawdbotConfig;
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
const nextLine = { ...lineConfig };
let cleared = false;
let changed = false;
if (accountId === DEFAULT_ACCOUNT_ID) {
if (
nextLine.channelAccessToken ||
nextLine.channelSecret ||
nextLine.tokenFile ||
nextLine.secretFile
) {
delete nextLine.channelAccessToken;
delete nextLine.channelSecret;
delete nextLine.tokenFile;
delete nextLine.secretFile;
cleared = true;
changed = true;
}
}
const accounts = nextLine.accounts ? { ...nextLine.accounts } : undefined;
if (accounts && accountId in accounts) {
const entry = accounts[accountId];
if (entry && typeof entry === "object") {
const nextEntry = { ...entry } as Record<string, unknown>;
if (
"channelAccessToken" in nextEntry ||
"channelSecret" in nextEntry ||
"tokenFile" in nextEntry ||
"secretFile" in nextEntry
) {
cleared = true;
delete nextEntry.channelAccessToken;
delete nextEntry.channelSecret;
delete nextEntry.tokenFile;
delete nextEntry.secretFile;
changed = true;
}
if (Object.keys(nextEntry).length === 0) {
delete accounts[accountId];
changed = true;
} else {
accounts[accountId] = nextEntry as typeof entry;
}
}
}
if (accounts) {
if (Object.keys(accounts).length === 0) {
delete nextLine.accounts;
changed = true;
} else {
nextLine.accounts = accounts;
}
}
if (changed) {
if (Object.keys(nextLine).length > 0) {
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
} else {
const nextChannels = { ...nextCfg.channels };
delete (nextChannels as Record<string, unknown>).line;
if (Object.keys(nextChannels).length > 0) {
nextCfg.channels = nextChannels;
} else {
delete nextCfg.channels;
}
}
await getLineRuntime().config.writeConfigFile(nextCfg);
}
const resolved = getLineRuntime().channel.line.resolveLineAccount({
cfg: changed ? nextCfg : cfg,
accountId,
});
const loggedOut = resolved.tokenSource === "none";
return { cleared, envToken: Boolean(envToken), loggedOut };
},
},
agentPrompt: {
messageToolHints: () => [
"",
"### LINE Rich Messages",
"LINE supports rich visual messages. Use these directives in your reply when appropriate:",
"",
"**Quick Replies** (bottom button suggestions):",
" [[quick_replies: Option 1, Option 2, Option 3]]",
"",
"**Location** (map pin):",
" [[location: Place Name | Address | latitude | longitude]]",
"",
"**Confirm Dialog** (yes/no prompt):",
" [[confirm: Question text? | Yes Label | No Label]]",
"",
"**Button Menu** (title + text + buttons):",
" [[buttons: Title | Description | Btn1:action1, Btn2:https://url.com]]",
"",
"**Media Player Card** (music status):",
" [[media_player: Song Title | Artist Name | Source | https://albumart.url | playing]]",
" - Status: 'playing' or 'paused' (optional)",
"",
"**Event Card** (calendar events, meetings):",
" [[event: Event Title | Date | Time | Location | Description]]",
" - Time, Location, Description are optional",
"",
"**Agenda Card** (multiple events/schedule):",
" [[agenda: Schedule Title | Event1:9:00 AM, Event2:12:00 PM, Event3:3:00 PM]]",
"",
"**Device Control Card** (smart devices, TVs, etc.):",
" [[device: Device Name | Device Type | Status | Control1:data1, Control2:data2]]",
"",
"**Apple TV Remote** (full D-pad + transport):",
" [[appletv_remote: Apple TV | Playing]]",
"",
"**Auto-converted**: Markdown tables become Flex cards, code blocks become styled cards.",
"",
"When to use rich messages:",
"- Use [[quick_replies:...]] when offering 2-4 clear options",
"- Use [[confirm:...]] for yes/no decisions",
"- Use [[buttons:...]] for menus with actions/links",
"- Use [[location:...]] when sharing a place",
"- Use [[media_player:...]] when showing what's playing",
"- Use [[event:...]] for calendar event details",
"- Use [[agenda:...]] for a day's schedule or event list",
"- Use [[device:...]] for smart device status/controls",
"- Tables/code in your response auto-convert to visual cards",
],
},
};
+14
View File
@@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setLineRuntime(r: PluginRuntime): void {
runtime = r;
}
export function getLineRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("LINE runtime not initialized - plugin not registered");
}
return runtime;
}
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## 2026.1.24
### Changes
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
- Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls.
- Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields.
## 2026.1.23
### Changes
+21
View File
@@ -75,6 +75,27 @@ Notes:
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
- `mock` is a local dev provider (no network calls).
## TTS for calls
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
streaming speech on calls. You can override it under the plugin config with the
same shape — overrides deep-merge with `messages.tts`.
```json5
{
tts: {
provider: "openai",
openai: {
voice: "alloy"
}
}
}
```
Notes:
- Edge TTS is ignored for voice calls (telephony audio needs PCM; Edge output is unreliable).
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
## CLI
```bash
+207 -11
View File
@@ -99,16 +99,39 @@
"label": "Media Stream Path",
"advanced": true
},
"tts.model": {
"label": "TTS Model",
"tts.provider": {
"label": "TTS Provider Override",
"help": "Deep-merges with messages.tts (Edge is ignored for calls).",
"advanced": true
},
"tts.voice": {
"label": "TTS Voice",
"tts.openai.model": {
"label": "OpenAI TTS Model",
"advanced": true
},
"tts.instructions": {
"label": "TTS Instructions",
"tts.openai.voice": {
"label": "OpenAI TTS Voice",
"advanced": true
},
"tts.openai.apiKey": {
"label": "OpenAI API Key",
"sensitive": true,
"advanced": true
},
"tts.elevenlabs.modelId": {
"label": "ElevenLabs Model ID",
"advanced": true
},
"tts.elevenlabs.voiceId": {
"label": "ElevenLabs Voice ID",
"advanced": true
},
"tts.elevenlabs.apiKey": {
"label": "ElevenLabs API Key",
"sensitive": true,
"advanced": true
},
"tts.elevenlabs.baseUrl": {
"label": "ElevenLabs Base URL",
"advanced": true
},
"publicUrl": {
@@ -370,20 +393,193 @@
"type": "object",
"additionalProperties": false,
"properties": {
"auto": {
"type": "string",
"enum": [
"off",
"always",
"inbound",
"tagged"
]
},
"enabled": {
"type": "boolean"
},
"mode": {
"type": "string",
"enum": [
"final",
"all"
]
},
"provider": {
"type": "string",
"enum": [
"openai"
"openai",
"elevenlabs",
"edge"
]
},
"model": {
"summaryModel": {
"type": "string"
},
"voice": {
"modelOverrides": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowText": {
"type": "boolean"
},
"allowProvider": {
"type": "boolean"
},
"allowVoice": {
"type": "boolean"
},
"allowModelId": {
"type": "boolean"
},
"allowVoiceSettings": {
"type": "boolean"
},
"allowNormalization": {
"type": "boolean"
},
"allowSeed": {
"type": "boolean"
}
}
},
"elevenlabs": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"baseUrl": {
"type": "string"
},
"voiceId": {
"type": "string"
},
"modelId": {
"type": "string"
},
"seed": {
"type": "integer",
"minimum": 0,
"maximum": 4294967295
},
"applyTextNormalization": {
"type": "string",
"enum": [
"auto",
"on",
"off"
]
},
"languageCode": {
"type": "string"
},
"voiceSettings": {
"type": "object",
"additionalProperties": false,
"properties": {
"stability": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"similarityBoost": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"style": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"useSpeakerBoost": {
"type": "boolean"
},
"speed": {
"type": "number",
"minimum": 0.5,
"maximum": 2
}
}
}
}
},
"openai": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"model": {
"type": "string"
},
"voice": {
"type": "string"
}
}
},
"edge": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"voice": {
"type": "string"
},
"lang": {
"type": "string"
},
"outputFormat": {
"type": "string"
},
"pitch": {
"type": "string"
},
"rate": {
"type": "string"
},
"volume": {
"type": "string"
},
"saveSubtitles": {
"type": "boolean"
},
"proxy": {
"type": "string"
},
"timeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 120000
}
}
},
"prefsPath": {
"type": "string"
},
"instructions": {
"type": "string"
"maxTextLength": {
"type": "integer",
"minimum": 1
},
"timeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 120000
}
}
},
+21 -3
View File
@@ -74,9 +74,26 @@ const voiceCallConfigSchema = {
},
"streaming.sttModel": { label: "Realtime STT Model", advanced: true },
"streaming.streamPath": { label: "Media Stream Path", advanced: true },
"tts.model": { label: "TTS Model", advanced: true },
"tts.voice": { label: "TTS Voice", advanced: true },
"tts.instructions": { label: "TTS Instructions", advanced: true },
"tts.provider": {
label: "TTS Provider Override",
help: "Deep-merges with messages.tts (Edge is ignored for calls).",
advanced: true,
},
"tts.openai.model": { label: "OpenAI TTS Model", advanced: true },
"tts.openai.voice": { label: "OpenAI TTS Voice", advanced: true },
"tts.openai.apiKey": {
label: "OpenAI API Key",
sensitive: true,
advanced: true,
},
"tts.elevenlabs.modelId": { label: "ElevenLabs Model ID", advanced: true },
"tts.elevenlabs.voiceId": { label: "ElevenLabs Voice ID", advanced: true },
"tts.elevenlabs.apiKey": {
label: "ElevenLabs API Key",
sensitive: true,
advanced: true,
},
"tts.elevenlabs.baseUrl": { label: "ElevenLabs Base URL", advanced: true },
publicUrl: { label: "Public Webhook URL", advanced: true },
skipSignatureVerification: {
label: "Skip Signature Verification",
@@ -161,6 +178,7 @@ const voiceCallPlugin = {
runtimePromise = createVoiceCallRuntime({
config: cfg,
coreConfig: api.config as CoreConfig,
ttsRuntime: api.runtime.tts,
logger: api.logger,
});
}
+73 -22
View File
@@ -82,31 +82,82 @@ export const SttConfigSchema = z
.default({ provider: "openai", model: "whisper-1" });
export type SttConfig = z.infer<typeof SttConfigSchema>;
export const TtsProviderSchema = z.enum(["openai", "elevenlabs", "edge"]);
export const TtsModeSchema = z.enum(["final", "all"]);
export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]);
export const TtsConfigSchema = z
.object({
/** TTS provider (currently only OpenAI supported) */
provider: z.literal("openai").default("openai"),
/**
* TTS model to use:
* - gpt-4o-mini-tts: newest, supports instructions for tone/style control (recommended)
* - tts-1: lower latency
* - tts-1-hd: higher quality
*/
model: z.string().min(1).default("gpt-4o-mini-tts"),
/**
* Voice ID. For best quality, use marin or cedar.
* All voices: alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse, marin, cedar
*/
voice: z.string().min(1).default("coral"),
/**
* Instructions for speech style (only works with gpt-4o-mini-tts).
* Examples: "Speak in a cheerful tone", "Talk like a sympathetic customer service agent"
*/
instructions: z.string().optional(),
auto: TtsAutoSchema.optional(),
enabled: z.boolean().optional(),
mode: TtsModeSchema.optional(),
provider: TtsProviderSchema.optional(),
summaryModel: z.string().optional(),
modelOverrides: z
.object({
enabled: z.boolean().optional(),
allowText: z.boolean().optional(),
allowProvider: z.boolean().optional(),
allowVoice: z.boolean().optional(),
allowModelId: z.boolean().optional(),
allowVoiceSettings: z.boolean().optional(),
allowNormalization: z.boolean().optional(),
allowSeed: z.boolean().optional(),
})
.strict()
.optional(),
elevenlabs: z
.object({
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
voiceId: z.string().optional(),
modelId: z.string().optional(),
seed: z.number().int().min(0).max(4294967295).optional(),
applyTextNormalization: z.enum(["auto", "on", "off"]).optional(),
languageCode: z.string().optional(),
voiceSettings: z
.object({
stability: z.number().min(0).max(1).optional(),
similarityBoost: z.number().min(0).max(1).optional(),
style: z.number().min(0).max(1).optional(),
useSpeakerBoost: z.boolean().optional(),
speed: z.number().min(0.5).max(2).optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
openai: z
.object({
apiKey: z.string().optional(),
model: z.string().optional(),
voice: z.string().optional(),
})
.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(),
})
.strict()
.default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
export type TtsConfig = z.infer<typeof TtsConfigSchema>;
.optional();
export type VoiceCallTtsConfig = z.infer<typeof TtsConfigSchema>;
// -----------------------------------------------------------------------------
// Webhook Server Configuration
@@ -307,7 +358,7 @@ export const VoiceCallConfigSchema = z
/** STT configuration */
stt: SttConfigSchema,
/** TTS configuration */
/** TTS override (deep-merges with core messages.tts) */
tts: TtsConfigSchema,
/** Store path for call logs */
+6
View File
@@ -2,10 +2,16 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { VoiceCallTtsConfig } from "./config.js";
export type CoreConfig = {
session?: {
store?: string;
};
messages?: {
tts?: VoiceCallTtsConfig;
};
[key: string]: unknown;
};
type CoreAgentDeps = {
+4 -2
View File
@@ -143,7 +143,7 @@ export class CallManager {
// For notify mode with a message, use inline TwiML with <Say>
let inlineTwiml: string | undefined;
if (mode === "notify" && initialMessage) {
const pollyVoice = mapVoiceToPolly(this.config.tts.voice);
const pollyVoice = mapVoiceToPolly(this.config.tts?.openai?.voice);
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
console.log(
`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
@@ -210,11 +210,13 @@ export class CallManager {
this.addTranscriptEntry(call, "bot", text);
// Play TTS
const voice =
this.provider?.name === "twilio" ? this.config.tts?.openai?.voice : undefined;
await this.provider.playTts({
callId,
providerCallId: call.providerCallId,
text,
voice: this.config.tts.voice,
voice,
});
return { success: true };
@@ -68,7 +68,7 @@ export async function initiateCall(
// For notify mode with a message, use inline TwiML with <Say>.
let inlineTwiml: string | undefined;
if (mode === "notify" && initialMessage) {
const pollyVoice = mapVoiceToPolly(ctx.config.tts.voice);
const pollyVoice = mapVoiceToPolly(ctx.config.tts?.openai?.voice);
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
}
@@ -120,11 +120,13 @@ export async function speak(
addTranscriptEntry(call, "bot", text);
const voice =
ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
await ctx.provider.playTts({
callId,
providerCallId: call.providerCallId,
text,
voice: ctx.config.tts.voice,
voice,
});
return { success: true };
@@ -244,4 +246,3 @@ export async function endCall(
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}
+14 -14
View File
@@ -15,9 +15,9 @@ import type {
WebhookVerificationResult,
} from "../types.js";
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
import { chunkAudio } from "../telephony-audio.js";
import type { TelephonyTtsProvider } from "../telephony-tts.js";
import type { VoiceCallProvider } from "./base.js";
import type { OpenAITTSProvider } from "./tts-openai.js";
import { chunkAudio } from "./tts-openai.js";
import { twilioApiRequest } from "./twilio/api.js";
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
@@ -53,8 +53,8 @@ export class TwilioProvider implements VoiceCallProvider {
/** Current public webhook URL (set when tunnel starts or from config) */
private currentPublicUrl: string | null = null;
/** Optional OpenAI TTS provider for streaming TTS */
private ttsProvider: OpenAITTSProvider | null = null;
/** Optional telephony TTS provider for streaming TTS */
private ttsProvider: TelephonyTtsProvider | null = null;
/** Optional media stream handler for sending audio */
private mediaStreamHandler: MediaStreamHandler | null = null;
@@ -119,7 +119,7 @@ export class TwilioProvider implements VoiceCallProvider {
return this.currentPublicUrl;
}
setTTSProvider(provider: OpenAITTSProvider): void {
setTTSProvider(provider: TelephonyTtsProvider): void {
this.ttsProvider = provider;
}
@@ -454,13 +454,13 @@ export class TwilioProvider implements VoiceCallProvider {
* Play TTS audio via Twilio.
*
* Two modes:
* 1. OpenAI TTS + Media Streams: If TTS provider and media stream are available,
* generates audio via OpenAI and streams it through WebSocket (preferred).
* 1. Core TTS + Media Streams: If TTS provider and media stream are available,
* generates audio via core TTS and streams it through WebSocket (preferred).
* 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices.
* Note: This may not work on all Twilio accounts.
*/
async playTts(input: PlayTtsInput): Promise<void> {
// Try OpenAI TTS via media stream first (if configured)
// Try telephony TTS via media stream first (if configured)
const streamSid = this.callStreamMap.get(input.providerCallId);
if (this.ttsProvider && this.mediaStreamHandler && streamSid) {
try {
@@ -468,7 +468,7 @@ export class TwilioProvider implements VoiceCallProvider {
return;
} catch (err) {
console.warn(
`[voice-call] OpenAI TTS failed, falling back to Twilio <Say>:`,
`[voice-call] Telephony TTS failed, falling back to Twilio <Say>:`,
err instanceof Error ? err.message : err,
);
// Fall through to TwiML <Say> fallback
@@ -484,7 +484,7 @@ export class TwilioProvider implements VoiceCallProvider {
}
console.warn(
"[voice-call] Using TwiML <Say> fallback - OpenAI TTS not configured or media stream not active",
"[voice-call] Using TwiML <Say> fallback - telephony TTS not configured or media stream not active",
);
const pollyVoice = mapVoiceToPolly(input.voice);
@@ -502,8 +502,8 @@ export class TwilioProvider implements VoiceCallProvider {
}
/**
* Play TTS via OpenAI and Twilio Media Streams.
* Generates audio with OpenAI TTS, converts to mu-law, and streams via WebSocket.
* Play TTS via core TTS and Twilio Media Streams.
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
* Uses a jitter buffer to smooth out timing variations.
*/
private async playTtsViaStream(
@@ -514,8 +514,8 @@ export class TwilioProvider implements VoiceCallProvider {
throw new Error("TTS provider and media stream handler required");
}
// Generate audio with OpenAI TTS (returns mu-law at 8kHz)
const muLawAudio = await this.ttsProvider.synthesizeForTwilio(text);
// Generate audio with core TTS (returns mu-law at 8kHz)
const muLawAudio = await this.ttsProvider.synthesizeForTelephony(text);
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
const CHUNK_SIZE = 160;
+12 -13
View File
@@ -6,8 +6,9 @@ import type { VoiceCallProvider } from "./providers/base.js";
import { MockProvider } from "./providers/mock.js";
import { PlivoProvider } from "./providers/plivo.js";
import { TelnyxProvider } from "./providers/telnyx.js";
import { OpenAITTSProvider } from "./providers/tts-openai.js";
import { TwilioProvider } from "./providers/twilio.js";
import type { TelephonyTtsRuntime } from "./telephony-tts.js";
import { createTelephonyTtsProvider } from "./telephony-tts.js";
import { startTunnel, type TunnelResult } from "./tunnel.js";
import {
cleanupTailscaleExposure,
@@ -81,9 +82,10 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
export async function createVoiceCallRuntime(params: {
config: VoiceCallConfig;
coreConfig: CoreConfig;
ttsRuntime?: TelephonyTtsRuntime;
logger?: Logger;
}): Promise<VoiceCallRuntime> {
const { config, coreConfig, logger } = params;
const { config, coreConfig, ttsRuntime, logger } = params;
const log = logger ?? {
info: console.log,
warn: console.warn,
@@ -149,27 +151,24 @@ export async function createVoiceCallRuntime(params: {
if (provider.name === "twilio" && config.streaming?.enabled) {
const twilioProvider = provider as TwilioProvider;
const openaiApiKey =
config.streaming.openaiApiKey || process.env.OPENAI_API_KEY;
if (openaiApiKey) {
if (ttsRuntime?.textToSpeechTelephony) {
try {
const ttsProvider = new OpenAITTSProvider({
apiKey: openaiApiKey,
voice: config.tts.voice,
model: config.tts.model,
instructions: config.tts.instructions,
const ttsProvider = createTelephonyTtsProvider({
coreConfig,
ttsOverride: config.tts,
runtime: ttsRuntime,
});
twilioProvider.setTTSProvider(ttsProvider);
log.info("[voice-call] OpenAI TTS provider configured");
log.info("[voice-call] Telephony TTS provider configured");
} catch (err) {
log.warn(
`[voice-call] Failed to initialize OpenAI TTS: ${
`[voice-call] Failed to initialize telephony TTS: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
} else {
log.warn("[voice-call] OpenAI TTS key missing; streaming TTS disabled");
log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled");
}
const mediaHandler = webhookServer.getMediaStreamHandler();
@@ -0,0 +1,88 @@
const TELEPHONY_SAMPLE_RATE = 8000;
function clamp16(value: number): number {
return Math.max(-32768, Math.min(32767, value));
}
/**
* Resample 16-bit PCM (little-endian mono) to 8kHz using linear interpolation.
*/
export function resamplePcmTo8k(input: Buffer, inputSampleRate: number): Buffer {
if (inputSampleRate === TELEPHONY_SAMPLE_RATE) return input;
const inputSamples = Math.floor(input.length / 2);
if (inputSamples === 0) return Buffer.alloc(0);
const ratio = inputSampleRate / TELEPHONY_SAMPLE_RATE;
const outputSamples = Math.floor(inputSamples / ratio);
const output = Buffer.alloc(outputSamples * 2);
for (let i = 0; i < outputSamples; i++) {
const srcPos = i * ratio;
const srcIndex = Math.floor(srcPos);
const frac = srcPos - srcIndex;
const s0 = input.readInt16LE(srcIndex * 2);
const s1Index = Math.min(srcIndex + 1, inputSamples - 1);
const s1 = input.readInt16LE(s1Index * 2);
const sample = Math.round(s0 + frac * (s1 - s0));
output.writeInt16LE(clamp16(sample), i * 2);
}
return output;
}
/**
* Convert 16-bit PCM to 8-bit mu-law (G.711).
*/
export function pcmToMulaw(pcm: Buffer): Buffer {
const samples = Math.floor(pcm.length / 2);
const mulaw = Buffer.alloc(samples);
for (let i = 0; i < samples; i++) {
const sample = pcm.readInt16LE(i * 2);
mulaw[i] = linearToMulaw(sample);
}
return mulaw;
}
export function convertPcmToMulaw8k(
pcm: Buffer,
inputSampleRate: number,
): Buffer {
const pcm8k = resamplePcmTo8k(pcm, inputSampleRate);
return pcmToMulaw(pcm8k);
}
/**
* Chunk audio buffer into 20ms frames for streaming (8kHz mono mu-law).
*/
export function chunkAudio(
audio: Buffer,
chunkSize = 160,
): Generator<Buffer, void, unknown> {
return (function* () {
for (let i = 0; i < audio.length; i += chunkSize) {
yield audio.subarray(i, Math.min(i + chunkSize, audio.length));
}
})();
}
function linearToMulaw(sample: number): number {
const BIAS = 132;
const CLIP = 32635;
const sign = sample < 0 ? 0x80 : 0;
if (sample < 0) sample = -sample;
if (sample > CLIP) sample = CLIP;
sample += BIAS;
let exponent = 7;
for (let expMask = 0x4000; (sample & expMask) === 0 && exponent > 0; exponent--) {
expMask >>= 1;
}
const mantissa = (sample >> (exponent + 3)) & 0x0f;
return ~(sign | (exponent << 4) | mantissa) & 0xff;
}
@@ -0,0 +1,95 @@
import type { CoreConfig } from "./core-bridge.js";
import type { VoiceCallTtsConfig } from "./config.js";
import { convertPcmToMulaw8k } from "./telephony-audio.js";
export type TelephonyTtsRuntime = {
textToSpeechTelephony: (params: {
text: string;
cfg: CoreConfig;
prefsPath?: string;
}) => Promise<{
success: boolean;
audioBuffer?: Buffer;
sampleRate?: number;
provider?: string;
error?: string;
}>;
};
export type TelephonyTtsProvider = {
synthesizeForTelephony: (text: string) => Promise<Buffer>;
};
export function createTelephonyTtsProvider(params: {
coreConfig: CoreConfig;
ttsOverride?: VoiceCallTtsConfig;
runtime: TelephonyTtsRuntime;
}): TelephonyTtsProvider {
const { coreConfig, ttsOverride, runtime } = params;
const mergedConfig = applyTtsOverride(coreConfig, ttsOverride);
return {
synthesizeForTelephony: async (text: string) => {
const result = await runtime.textToSpeechTelephony({
text,
cfg: mergedConfig,
});
if (!result.success || !result.audioBuffer || !result.sampleRate) {
throw new Error(result.error ?? "TTS conversion failed");
}
return convertPcmToMulaw8k(result.audioBuffer, result.sampleRate);
},
};
}
function applyTtsOverride(
coreConfig: CoreConfig,
override?: VoiceCallTtsConfig,
): CoreConfig {
if (!override) return coreConfig;
const base = coreConfig.messages?.tts;
const merged = mergeTtsConfig(base, override);
if (!merged) return coreConfig;
return {
...coreConfig,
messages: {
...(coreConfig.messages ?? {}),
tts: merged,
},
};
}
function mergeTtsConfig(
base?: VoiceCallTtsConfig,
override?: VoiceCallTtsConfig,
): VoiceCallTtsConfig | undefined {
if (!base && !override) return undefined;
if (!override) return base;
if (!base) return override;
return deepMerge(base, override);
}
function deepMerge<T>(base: T, override: T): T {
if (!isPlainObject(base) || !isPlainObject(override)) {
return override;
}
const result: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(override)) {
if (value === undefined) continue;
const existing = (base as Record<string, unknown>)[key];
if (isPlainObject(existing) && isPlainObject(value)) {
result[key] = deepMerge(existing, value);
} else {
result[key] = value;
}
}
return result as T;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
+2
View File
@@ -42,6 +42,7 @@
"dist/signal/**",
"dist/slack/**",
"dist/telegram/**",
"dist/line/**",
"dist/tui/**",
"dist/tts/**",
"dist/web/**",
@@ -154,6 +155,7 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@line/bot-sdk": "^10.6.0",
"@lydell/node-pty": "1.2.0-beta.3",
"@mariozechner/pi-agent-core": "0.49.3",
"@mariozechner/pi-ai": "0.49.3",
+28
View File
@@ -34,6 +34,9 @@ importers:
'@homebridge/ciao':
specifier: ^1.3.4
version: 1.3.4
'@line/bot-sdk':
specifier: ^10.6.0
version: 10.6.0
'@lydell/node-pty':
specifier: 1.2.0-beta.3
version: 1.2.0-beta.3
@@ -317,6 +320,12 @@ importers:
extensions/imessage: {}
extensions/line:
devDependencies:
clawdbot:
specifier: workspace:*
version: link:../..
extensions/llm-task: {}
extensions/lobster: {}
@@ -1260,6 +1269,10 @@ packages:
peerDependencies:
apache-arrow: '>=15.0.0 <=18.1.0'
'@line/bot-sdk@10.6.0':
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
engines: {node: '>=20'}
'@lit-labs/signals@0.2.0':
resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==}
@@ -2647,6 +2660,9 @@ packages:
'@types/node@20.19.30':
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
'@types/node@24.10.9':
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
'@types/node@25.0.10':
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
@@ -6721,6 +6737,14 @@ snapshots:
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
'@lancedb/lancedb-win32-x64-msvc': 0.23.0
'@line/bot-sdk@10.6.0':
dependencies:
'@types/node': 24.10.9
optionalDependencies:
axios: 1.13.2(debug@4.4.3)
transitivePeerDependencies:
- debug
'@lit-labs/signals@0.2.0':
dependencies:
lit: 3.3.2
@@ -8298,6 +8322,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@24.10.9':
dependencies:
undici-types: 7.16.0
'@types/node@25.0.10':
dependencies:
undici-types: 7.16.0
+4 -1
View File
@@ -23,11 +23,14 @@ const serialRuns = runs.filter((entry) => entry.name === "gateway");
const children = new Set();
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
const isMacOS = process.platform === "darwin" || process.env.RUNNER_OS === "macOS";
const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10);
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
const maxWorkers = isCI ? null : resolvedOverride ?? perRunWorkers;
// Keep worker counts predictable for local runs and for CI on macOS.
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : perRunWorkers);
const WARNING_SUPPRESSION_FLAGS = [
"--disable-warning=ExperimentalWarning",
+73 -25
View File
@@ -1,5 +1,5 @@
import crypto from "node:crypto";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
@@ -27,6 +27,7 @@ import {
} from "../infra/shell-env.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { logInfo, logWarn } from "../logger.js";
import { formatSpawnError, spawnWithFallback } from "../process/spawn-utils.js";
import {
type ProcessSession,
type SessionStdin,
@@ -362,23 +363,38 @@ async function runExecProcess(opts: {
let stdin: SessionStdin | undefined;
if (opts.sandbox) {
child = spawn(
"docker",
buildDockerExecArgs({
containerName: opts.sandbox.containerName,
command: opts.command,
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
env: opts.env,
tty: opts.usePty,
}),
{
const { child: spawned } = await spawnWithFallback({
argv: [
"docker",
...buildDockerExecArgs({
containerName: opts.sandbox.containerName,
command: opts.command,
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
env: opts.env,
tty: opts.usePty,
}),
],
options: {
cwd: opts.workdir,
env: process.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
},
) as ChildProcessWithoutNullStreams;
fallbacks: [
{
label: "no-detach",
options: { detached: false },
},
],
onFallback: (err, fallback) => {
const errText = formatSpawnError(err);
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`;
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`);
opts.warnings.push(warning);
},
});
child = spawned as ChildProcessWithoutNullStreams;
stdin = child.stdin;
} else if (opts.usePty) {
const { shell, args: shellArgs } = getShellConfig();
@@ -422,24 +438,56 @@ async function runExecProcess(opts: {
const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`;
logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`);
opts.warnings.push(warning);
child = spawn(shell, [...shellArgs, opts.command], {
const { child: spawned } = await spawnWithFallback({
argv: [shell, ...shellArgs, opts.command],
options: {
cwd: opts.workdir,
env: opts.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
},
fallbacks: [
{
label: "no-detach",
options: { detached: false },
},
],
onFallback: (fallbackErr, fallback) => {
const fallbackText = formatSpawnError(fallbackErr);
const fallbackWarning = `Warning: spawn failed (${fallbackText}); retrying with ${fallback.label}.`;
logWarn(`exec: spawn failed (${fallbackText}); retrying with ${fallback.label}.`);
opts.warnings.push(fallbackWarning);
},
});
child = spawned as ChildProcessWithoutNullStreams;
stdin = child.stdin;
}
} else {
const { shell, args: shellArgs } = getShellConfig();
const { child: spawned } = await spawnWithFallback({
argv: [shell, ...shellArgs, opts.command],
options: {
cwd: opts.workdir,
env: opts.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
}) as ChildProcessWithoutNullStreams;
stdin = child.stdin;
}
} else {
const { shell, args: shellArgs } = getShellConfig();
child = spawn(shell, [...shellArgs, opts.command], {
cwd: opts.workdir,
env: opts.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
}) as ChildProcessWithoutNullStreams;
},
fallbacks: [
{
label: "no-detach",
options: { detached: false },
},
],
onFallback: (err, fallback) => {
const errText = formatSpawnError(err);
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`;
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`);
opts.warnings.push(warning);
},
});
child = spawned as ChildProcessWithoutNullStreams;
stdin = child.stdin;
}
+30 -1
View File
@@ -8,6 +8,7 @@ export type ModelCatalogEntry = {
provider: string;
contextWindow?: number;
reasoning?: boolean;
input?: Array<"text" | "image">;
};
type DiscoveredModel = {
@@ -16,6 +17,7 @@ type DiscoveredModel = {
provider: string;
contextWindow?: number;
reasoning?: boolean;
input?: Array<"text" | "image">;
};
type PiSdkModule = typeof import("@mariozechner/pi-coding-agent");
@@ -80,7 +82,10 @@ export async function loadModelCatalog(params?: {
? entry.contextWindow
: undefined;
const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined;
models.push({ id, name, provider, contextWindow, reasoning });
const input = Array.isArray(entry?.input)
? (entry.input as Array<"text" | "image">)
: undefined;
models.push({ id, name, provider, contextWindow, reasoning, input });
}
if (models.length === 0) {
@@ -105,3 +110,27 @@ export async function loadModelCatalog(params?: {
return modelCatalogPromise;
}
/**
* Check if a model supports image input based on its catalog entry.
*/
export function modelSupportsVision(entry: ModelCatalogEntry | undefined): boolean {
return entry?.input?.includes("image") ?? false;
}
/**
* Find a model in the catalog by provider and model ID.
*/
export function findModelInCatalog(
catalog: ModelCatalogEntry[],
provider: string,
modelId: string,
): ModelCatalogEntry | undefined {
const normalizedProvider = provider.toLowerCase().trim();
const normalizedModelId = modelId.toLowerCase().trim();
return catalog.find(
(entry) =>
entry.provider.toLowerCase() === normalizedProvider &&
entry.id.toLowerCase() === normalizedModelId,
);
}
@@ -121,6 +121,26 @@ describe("getDmHistoryLimitFromSessionKey", () => {
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123", config)).toBe(10);
});
it("strips thread suffix from dm session keys", () => {
const config = {
channels: { telegram: { dmHistoryLimit: 10, dms: { "123": { historyLimit: 7 } } } },
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123:thread:999", config)).toBe(
7,
);
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123:topic:555", config)).toBe(7);
expect(getDmHistoryLimitFromSessionKey("telegram:dm:123:thread:999", config)).toBe(7);
});
it("keeps non-numeric thread markers in dm ids", () => {
const config = {
channels: {
telegram: { dms: { "user:thread:abc": { historyLimit: 9 } } },
},
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:user:thread:abc", config)).toBe(
9,
);
});
it("returns undefined for non-dm session kinds", () => {
const config = {
channels: {
+3 -3
View File
@@ -70,7 +70,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
},
streamSimple: (model: { api: string; provider: string; id: string }) => {
const stream = new actual.AssistantMessageEventStream();
setTimeout(() => {
queueMicrotask(() => {
stream.push({
type: "done",
reason: "stop",
@@ -80,7 +80,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
: buildAssistantMessage(model),
});
stream.end();
}, 0);
});
return stream;
},
};
@@ -213,7 +213,7 @@ describe("runEmbeddedPiAgent", () => {
itIfNotWin32(
"persists the first user message before assistant output",
{ timeout: 60_000 },
{ timeout: 120_000 },
async () => {
const sessionFile = nextSessionFile();
const cfg = makeOpenAiConfig(["mock-1"]);
+9 -1
View File
@@ -2,6 +2,13 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../../config/config.js";
const THREAD_SUFFIX_REGEX = /^(.*)(?::(?:thread|topic):\d+)$/i;
function stripThreadSuffix(value: string): string {
const match = value.match(THREAD_SUFFIX_REGEX);
return match?.[1] ?? value;
}
/**
* Limits conversation history to the last N user turns (and their associated
* assistant responses). This reduces token usage for long-running DM sessions.
@@ -44,7 +51,8 @@ export function getDmHistoryLimitFromSessionKey(
if (!provider) return undefined;
const kind = providerParts[1]?.toLowerCase();
const userId = providerParts.slice(2).join(":");
const userIdRaw = providerParts.slice(2).join(":");
const userId = stripThreadSuffix(userIdRaw);
if (kind !== "dm") return undefined;
const getLimit = (
+11 -1
View File
@@ -333,7 +333,13 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
name: "message",
description,
parameters: schema,
execute: async (_toolCallId, args) => {
execute: async (_toolCallId, args, signal) => {
// Check if already aborted before doing any work
if (signal?.aborted) {
const err = new Error("Message send aborted");
err.name = "AbortError";
throw err;
}
const params = args as Record<string, unknown>;
const cfg = options?.config ?? loadConfig();
const action = readStringParam(params, "action", {
@@ -366,6 +372,9 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
currentThreadTs: options?.currentThreadTs,
replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef,
// Direct tool invocations should not add cross-context decoration.
// The agent is composing a message, not forwarding from another chat.
skipCrossContextDecoration: true,
}
: undefined;
@@ -379,6 +388,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
agentId: options?.agentSessionKey
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
: undefined,
abortSignal: signal,
});
const toolResult = getToolResult(result);
+1 -3
View File
@@ -252,9 +252,7 @@ function isValidIsoDate(value: string): boolean {
const date = new Date(Date.UTC(year, month - 1, day));
return (
date.getUTCFullYear() === year &&
date.getUTCMonth() === month - 1 &&
date.getUTCDate() === day
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
);
}
+7 -21
View File
@@ -178,6 +178,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
textAlias: "/context",
acceptsArgs: true,
}),
defineChatCommand({
key: "tts",
nativeName: "tts",
description: "Configure text-to-speech.",
textAlias: "/tts",
acceptsArgs: true,
}),
defineChatCommand({
key: "whoami",
nativeName: "whoami",
@@ -279,27 +286,6 @@ function buildChatCommands(): ChatCommandDefinition[] {
],
argsMenu: "auto",
}),
defineChatCommand({
key: "tts",
nativeName: "tts",
description: "Control text-to-speech (TTS).",
textAlias: "/tts",
args: [
{
name: "action",
description: "on | off | status | provider | limit | summary | audio | help",
type: "string",
choices: ["on", "off", "status", "provider", "limit", "summary", "audio", "help"],
},
{
name: "value",
description: "Provider, limit, or text",
type: "string",
captureRemaining: true,
},
],
argsMenu: "auto",
}),
defineChatCommand({
key: "stop",
nativeName: "stop",
+10 -4
View File
@@ -10,11 +10,17 @@ describe("extractModelDirective", () => {
expect(result.cleaned).toBe("");
});
it("extracts /models with argument", () => {
it("does not treat /models as a /model directive", () => {
const result = extractModelDirective("/models gpt-5");
expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBe("gpt-5");
expect(result.cleaned).toBe("");
expect(result.hasDirective).toBe(false);
expect(result.rawModel).toBeUndefined();
expect(result.cleaned).toBe("/models gpt-5");
});
it("does not parse /models as a /model directive (no args)", () => {
const result = extractModelDirective("/models");
expect(result.hasDirective).toBe(false);
expect(result.cleaned).toBe("/models");
});
it("extracts /model with provider/model format", () => {
+1 -1
View File
@@ -14,7 +14,7 @@ export function extractModelDirective(
if (!body) return { cleaned: "", hasDirective: false };
const modelMatch = body.match(
/(?:^|\s)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
);
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
+4 -2
View File
@@ -15,10 +15,12 @@ import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
*/
export const handlePluginCommand: CommandHandler = async (
params,
_allowTextCommands,
allowTextCommands,
): Promise<CommandHandlerResult | null> => {
const { command, cfg } = params;
if (!allowTextCommands) return null;
// Try to match a plugin command
const match = matchPluginCommand(command.commandBodyNormalized);
if (!match) return null;
@@ -36,6 +38,6 @@ export const handlePluginCommand: CommandHandler = async (
return {
shouldContinue: false,
reply: { text: result.text },
reply: result,
};
};
+34 -14
View File
@@ -6,19 +6,20 @@ import {
getTtsMaxLength,
getTtsProvider,
isSummarizationEnabled,
isTtsEnabled,
isTtsProviderConfigured,
normalizeTtsAutoMode,
resolveTtsAutoMode,
resolveTtsApiKey,
resolveTtsConfig,
resolveTtsPrefsPath,
resolveTtsProviderOrder,
setLastTtsAttempt,
setSummarizationEnabled,
setTtsEnabled,
setTtsMaxLength,
setTtsProvider,
textToSpeech,
} from "../../tts/tts.js";
import { updateSessionStore } from "../../config/sessions.js";
type ParsedTtsCommand = {
action: string;
@@ -39,9 +40,9 @@ function ttsUsage(): ReplyPayload {
// Keep usage in one place so help/validation stays consistent.
return {
text:
"⚙️ Usage: /tts <on|off|status|provider|limit|summary|audio> [value]" +
"⚙️ Usage: /tts <off|always|inbound|tagged|status|provider|limit|summary|audio> [value]" +
"\nExamples:\n" +
"/tts on\n" +
"/tts always\n" +
"/tts provider openai\n" +
"/tts provider edge\n" +
"/tts limit 2000\n" +
@@ -71,14 +72,30 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
return { shouldContinue: false, reply: ttsUsage() };
}
if (action === "on") {
setTtsEnabled(prefsPath, true);
return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
}
if (action === "off") {
setTtsEnabled(prefsPath, false);
return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
const requestedAuto = normalizeTtsAutoMode(
action === "on" ? "always" : action === "off" ? "off" : action,
);
if (requestedAuto) {
const entry = params.sessionEntry;
const sessionKey = params.sessionKey;
const store = params.sessionStore;
if (entry && store && sessionKey) {
entry.ttsAuto = requestedAuto;
entry.updatedAt = Date.now();
store[sessionKey] = entry;
if (params.storePath) {
await updateSessionStore(params.storePath, (store) => {
store[sessionKey] = entry;
});
}
}
const label = requestedAuto === "always" ? "enabled (always)" : requestedAuto;
return {
shouldContinue: false,
reply: {
text: requestedAuto === "off" ? "🔇 TTS disabled." : `🔊 TTS ${label}.`,
},
};
}
if (action === "audio") {
@@ -212,7 +229,9 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
}
if (action === "status") {
const enabled = isTtsEnabled(config, prefsPath);
const sessionAuto = params.sessionEntry?.ttsAuto;
const autoMode = resolveTtsAutoMode({ config, prefsPath, sessionAuto });
const enabled = autoMode !== "off";
const provider = getTtsProvider(config, prefsPath);
const hasKey = isTtsProviderConfigured(config, provider);
const providerStatus =
@@ -226,9 +245,10 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath);
const last = getLastTtsAttempt();
const autoLabel = sessionAuto ? `${autoMode} (session)` : autoMode;
const lines = [
"📊 TTS status",
`State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
`Auto: ${enabled ? autoLabel : "off"}`,
`Provider: ${provider} (${providerStatus})`,
`Text limit: ${maxLength} chars`,
`Auto-summary: ${summarize ? "on" : "off"}`,
+24
View File
@@ -10,6 +10,7 @@ import {
} from "../../agents/subagent-registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import * as internalHooks from "../../hooks/internal-hooks.js";
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
import type { MsgContext } from "../templating.js";
import { resetBashChatCommandForTests } from "./bash-command.js";
import { buildCommandContext, handleCommands } from "./commands.js";
@@ -143,6 +144,29 @@ describe("handleCommands bash alias", () => {
});
});
describe("handleCommands plugin commands", () => {
it("dispatches registered plugin commands", async () => {
clearPluginCommands();
const result = registerPluginCommand("test-plugin", {
name: "card",
description: "Test card",
handler: async () => ({ text: "from plugin" }),
});
expect(result.ok).toBe(true);
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/card", cfg);
const commandResult = await handleCommands(params);
expect(commandResult.shouldContinue).toBe(false);
expect(commandResult.reply?.text).toBe("from plugin");
clearPluginCommands();
});
});
describe("handleCommands identity", () => {
it("returns sender details for /whoami", async () => {
const cfg = {
+19 -2
View File
@@ -1,7 +1,8 @@
import type { ReasoningLevel } from "../thinking.js";
import type { NoticeLevel, ReasoningLevel } from "../thinking.js";
import {
type ElevatedLevel,
normalizeElevatedLevel,
normalizeNoticeLevel,
normalizeReasoningLevel,
normalizeThinkLevel,
normalizeVerboseLevel,
@@ -112,6 +113,22 @@ export function extractVerboseDirective(body?: string): {
};
}
export function extractNoticeDirective(body?: string): {
cleaned: string;
noticeLevel?: NoticeLevel;
rawLevel?: string;
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
const extracted = extractLevelDirective(body, ["notice", "notices"], normalizeNoticeLevel);
return {
cleaned: extracted.cleaned,
noticeLevel: extracted.level,
rawLevel: extracted.rawLevel,
hasDirective: extracted.hasDirective,
};
}
export function extractElevatedDirective(body?: string): {
cleaned: string;
elevatedLevel?: ElevatedLevel;
@@ -152,5 +169,5 @@ export function extractStatusDirective(body?: string): {
return extractSimpleDirective(body, ["status"]);
}
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
export type { ElevatedLevel, NoticeLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
export { extractExecDirective } from "./exec/directive.js";
+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.
@@ -0,0 +1,377 @@
import { describe, expect, it } from "vitest";
import { parseLineDirectives, hasLineDirectives } from "./line-directives.js";
const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
describe("hasLineDirectives", () => {
it("detects quick_replies directive", () => {
expect(hasLineDirectives("Here are options [[quick_replies: A, B, C]]")).toBe(true);
});
it("detects location directive", () => {
expect(hasLineDirectives("[[location: Place | Address | 35.6 | 139.7]]")).toBe(true);
});
it("detects confirm directive", () => {
expect(hasLineDirectives("[[confirm: Continue? | Yes | No]]")).toBe(true);
});
it("detects buttons directive", () => {
expect(hasLineDirectives("[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]")).toBe(true);
});
it("returns false for regular text", () => {
expect(hasLineDirectives("Just regular text")).toBe(false);
});
it("returns false for similar but invalid patterns", () => {
expect(hasLineDirectives("[[not_a_directive: something]]")).toBe(false);
});
it("detects media_player directive", () => {
expect(hasLineDirectives("[[media_player: Song | Artist | Speaker]]")).toBe(true);
});
it("detects event directive", () => {
expect(hasLineDirectives("[[event: Meeting | Jan 24 | 2pm]]")).toBe(true);
});
it("detects agenda directive", () => {
expect(hasLineDirectives("[[agenda: Today | Meeting:9am, Lunch:12pm]]")).toBe(true);
});
it("detects device directive", () => {
expect(hasLineDirectives("[[device: TV | Room]]")).toBe(true);
});
it("detects appletv_remote directive", () => {
expect(hasLineDirectives("[[appletv_remote: Apple TV | Playing]]")).toBe(true);
});
});
describe("parseLineDirectives", () => {
describe("quick_replies", () => {
it("parses quick_replies and removes from text", () => {
const result = parseLineDirectives({
text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]",
});
expect(getLineData(result).quickReplies).toEqual(["Option A", "Option B", "Option C"]);
expect(result.text).toBe("Choose one:");
});
it("handles quick_replies in middle of text", () => {
const result = parseLineDirectives({
text: "Before [[quick_replies: A, B]] After",
});
expect(getLineData(result).quickReplies).toEqual(["A", "B"]);
expect(result.text).toBe("Before After");
});
it("merges with existing quickReplies", () => {
const result = parseLineDirectives({
text: "Text [[quick_replies: C, D]]",
channelData: { line: { quickReplies: ["A", "B"] } },
});
expect(getLineData(result).quickReplies).toEqual(["A", "B", "C", "D"]);
});
});
describe("location", () => {
it("parses location with all fields", () => {
const result = parseLineDirectives({
text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]",
});
expect(getLineData(result).location).toEqual({
title: "Tokyo Station",
address: "Tokyo, Japan",
latitude: 35.6812,
longitude: 139.7671,
});
expect(result.text).toBe("Here's the location:");
});
it("ignores invalid coordinates", () => {
const result = parseLineDirectives({
text: "[[location: Place | Address | invalid | 139.7]]",
});
expect(getLineData(result).location).toBeUndefined();
});
it("does not override existing location", () => {
const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 };
const result = parseLineDirectives({
text: "[[location: New | New Addr | 35.6 | 139.7]]",
channelData: { line: { location: existing } },
});
expect(getLineData(result).location).toEqual(existing);
});
});
describe("confirm", () => {
it("parses simple confirm", () => {
const result = parseLineDirectives({
text: "[[confirm: Delete this item? | Yes | No]]",
});
expect(getLineData(result).templateMessage).toEqual({
type: "confirm",
text: "Delete this item?",
confirmLabel: "Yes",
confirmData: "yes",
cancelLabel: "No",
cancelData: "no",
altText: "Delete this item?",
});
// Text is undefined when directive consumes entire text
expect(result.text).toBeUndefined();
});
it("parses confirm with custom data", () => {
const result = parseLineDirectives({
text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]",
});
expect(getLineData(result).templateMessage).toEqual({
type: "confirm",
text: "Proceed?",
confirmLabel: "OK",
confirmData: "action=confirm",
cancelLabel: "Cancel",
cancelData: "action=cancel",
altText: "Proceed?",
});
});
});
describe("buttons", () => {
it("parses buttons with message actions", () => {
const result = parseLineDirectives({
text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]",
});
expect(getLineData(result).templateMessage).toEqual({
type: "buttons",
title: "Menu",
text: "Select an option",
actions: [
{ type: "message", label: "Help", data: "/help" },
{ type: "message", label: "Status", data: "/status" },
],
altText: "Menu: Select an option",
});
});
it("parses buttons with uri actions", () => {
const result = parseLineDirectives({
text: "[[buttons: Links | Visit us | Site:https://example.com]]",
});
const templateMessage = getLineData(result).templateMessage as {
type?: string;
actions?: Array<Record<string, unknown>>;
};
expect(templateMessage?.type).toBe("buttons");
if (templateMessage?.type === "buttons") {
expect(templateMessage.actions?.[0]).toEqual({
type: "uri",
label: "Site",
uri: "https://example.com",
});
}
});
it("parses buttons with postback actions", () => {
const result = parseLineDirectives({
text: "[[buttons: Actions | Choose | Select:action=select&id=1]]",
});
const templateMessage = getLineData(result).templateMessage as {
type?: string;
actions?: Array<Record<string, unknown>>;
};
expect(templateMessage?.type).toBe("buttons");
if (templateMessage?.type === "buttons") {
expect(templateMessage.actions?.[0]).toEqual({
type: "postback",
label: "Select",
data: "action=select&id=1",
});
}
});
it("limits to 4 actions", () => {
const result = parseLineDirectives({
text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]",
});
const templateMessage = getLineData(result).templateMessage as {
type?: string;
actions?: Array<Record<string, unknown>>;
};
expect(templateMessage?.type).toBe("buttons");
if (templateMessage?.type === "buttons") {
expect(templateMessage.actions?.length).toBe(4);
}
});
});
describe("media_player", () => {
it("parses media_player with all fields", () => {
const result = parseLineDirectives({
text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]",
});
const flexMessage = getLineData(result).flexMessage as {
altText?: string;
contents?: { footer?: { contents?: unknown[] } };
};
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("🎵 Bohemian Rhapsody - Queen");
const contents = flexMessage?.contents as { footer?: { contents?: unknown[] } };
expect(contents.footer?.contents?.length).toBeGreaterThan(0);
expect(result.text).toBe("Now playing:");
});
it("parses media_player with minimal fields", () => {
const result = parseLineDirectives({
text: "[[media_player: Unknown Track]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("🎵 Unknown Track");
});
it("handles paused status", () => {
const result = parseLineDirectives({
text: "[[media_player: Song | Artist | Player | | paused]]",
});
const flexMessage = getLineData(result).flexMessage as {
contents?: { body: { contents: unknown[] } };
};
expect(flexMessage).toBeDefined();
const contents = flexMessage?.contents as { body: { contents: unknown[] } };
expect(contents).toBeDefined();
});
});
describe("event", () => {
it("parses event with all fields", () => {
const result = parseLineDirectives({
text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM");
});
it("parses event with minimal fields", () => {
const result = parseLineDirectives({
text: "[[event: Birthday Party | March 15]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("📅 Birthday Party - March 15");
});
});
describe("agenda", () => {
it("parses agenda with multiple events", () => {
const result = parseLineDirectives({
text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("📋 Today's Schedule (3 events)");
});
it("parses agenda with events without times", () => {
const result = parseLineDirectives({
text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("📋 Tasks (3 events)");
});
});
describe("device", () => {
it("parses device with controls", () => {
const result = parseLineDirectives({
text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("📱 TV: Playing");
});
it("parses device with minimal fields", () => {
const result = parseLineDirectives({
text: "[[device: Speaker]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toBe("📱 Speaker");
});
});
describe("appletv_remote", () => {
it("parses appletv_remote with status", () => {
const result = parseLineDirectives({
text: "[[appletv_remote: Apple TV | Playing]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
expect(flexMessage?.altText).toContain("Apple TV");
});
it("parses appletv_remote with minimal fields", () => {
const result = parseLineDirectives({
text: "[[appletv_remote: Apple TV]]",
});
const flexMessage = getLineData(result).flexMessage as { altText?: string };
expect(flexMessage).toBeDefined();
});
});
describe("combined directives", () => {
it("handles text with no directives", () => {
const result = parseLineDirectives({
text: "Just plain text here",
});
expect(result.text).toBe("Just plain text here");
expect(getLineData(result).quickReplies).toBeUndefined();
expect(getLineData(result).location).toBeUndefined();
expect(getLineData(result).templateMessage).toBeUndefined();
});
it("preserves other payload fields", () => {
const result = parseLineDirectives({
text: "Hello [[quick_replies: A, B]]",
mediaUrl: "https://example.com/image.jpg",
replyToId: "msg123",
});
expect(result.mediaUrl).toBe("https://example.com/image.jpg");
expect(result.replyToId).toBe("msg123");
expect(getLineData(result).quickReplies).toEqual(["A", "B"]);
});
});
});
+336
View File
@@ -0,0 +1,336 @@
import type { ReplyPayload } from "../types.js";
import type { LineChannelData } from "../../line/types.js";
import {
createMediaPlayerCard,
createEventCard,
createAgendaCard,
createDeviceControlCard,
createAppleTvRemoteCard,
} from "../../line/flex-templates.js";
/**
* Parse LINE-specific directives from text and extract them into ReplyPayload fields.
*
* Supported directives:
* - [[quick_replies: option1, option2, option3]]
* - [[location: title | address | latitude | longitude]]
* - [[confirm: question | yes_label | no_label]]
* - [[buttons: title | text | btn1:data1, btn2:data2]]
* - [[media_player: title | artist | source | imageUrl | playing/paused]]
* - [[event: title | date | time | location | description]]
* - [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
* - [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
* - [[appletv_remote: name | status]]
*
* Returns the modified payload with directives removed from text and fields populated.
*/
export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
let text = payload.text;
if (!text) return payload;
const result: ReplyPayload = { ...payload };
const lineData: LineChannelData = {
...(result.channelData?.line as LineChannelData | undefined),
};
const toSlug = (value: string): string =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "device";
const lineActionData = (action: string, extras?: Record<string, string>): string => {
const base = [`line.action=${encodeURIComponent(action)}`];
if (extras) {
for (const [key, value] of Object.entries(extras)) {
base.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
}
return base.join("&");
};
// Parse [[quick_replies: option1, option2, option3]]
const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i);
if (quickRepliesMatch) {
const options = quickRepliesMatch[1]
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (options.length > 0) {
lineData.quickReplies = [...(lineData.quickReplies || []), ...options];
}
text = text.replace(quickRepliesMatch[0], "").trim();
}
// Parse [[location: title | address | latitude | longitude]]
const locationMatch = text.match(/\[\[location:\s*([^\]]+)\]\]/i);
if (locationMatch && !lineData.location) {
const parts = locationMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 4) {
const [title, address, latStr, lonStr] = parts;
const latitude = parseFloat(latStr);
const longitude = parseFloat(lonStr);
if (!isNaN(latitude) && !isNaN(longitude)) {
lineData.location = {
title: title || "Location",
address: address || "",
latitude,
longitude,
};
}
}
text = text.replace(locationMatch[0], "").trim();
}
// Parse [[confirm: question | yes_label | no_label]] or [[confirm: question | yes_label:yes_data | no_label:no_data]]
const confirmMatch = text.match(/\[\[confirm:\s*([^\]]+)\]\]/i);
if (confirmMatch && !lineData.templateMessage) {
const parts = confirmMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 3) {
const [question, yesPart, noPart] = parts;
// Parse yes_label:yes_data format
const [yesLabel, yesData] = yesPart.includes(":")
? yesPart.split(":").map((s) => s.trim())
: [yesPart, yesPart.toLowerCase()];
const [noLabel, noData] = noPart.includes(":")
? noPart.split(":").map((s) => s.trim())
: [noPart, noPart.toLowerCase()];
lineData.templateMessage = {
type: "confirm",
text: question,
confirmLabel: yesLabel,
confirmData: yesData,
cancelLabel: noLabel,
cancelData: noData,
altText: question,
};
}
text = text.replace(confirmMatch[0], "").trim();
}
// Parse [[buttons: title | text | btn1:data1, btn2:data2]]
const buttonsMatch = text.match(/\[\[buttons:\s*([^\]]+)\]\]/i);
if (buttonsMatch && !lineData.templateMessage) {
const parts = buttonsMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 3) {
const [title, bodyText, actionsStr] = parts;
const actions = actionsStr.split(",").map((actionStr) => {
const trimmed = actionStr.trim();
// Find first colon delimiter, ignoring URLs without a label.
const colonIndex = (() => {
const index = trimmed.indexOf(":");
if (index === -1) return -1;
const lower = trimmed.toLowerCase();
if (lower.startsWith("http://") || lower.startsWith("https://")) return -1;
return index;
})();
let label: string;
let data: string;
if (colonIndex === -1) {
label = trimmed;
data = trimmed;
} else {
label = trimmed.slice(0, colonIndex).trim();
data = trimmed.slice(colonIndex + 1).trim();
}
// Detect action type
if (data.startsWith("http://") || data.startsWith("https://")) {
return { type: "uri" as const, label, uri: data };
}
if (data.includes("=")) {
return { type: "postback" as const, label, data };
}
return { type: "message" as const, label, data: data || label };
});
if (actions.length > 0) {
lineData.templateMessage = {
type: "buttons",
title,
text: bodyText,
actions: actions.slice(0, 4), // LINE limit
altText: `${title}: ${bodyText}`,
};
}
}
text = text.replace(buttonsMatch[0], "").trim();
}
// Parse [[media_player: title | artist | source | imageUrl | playing/paused]]
const mediaPlayerMatch = text.match(/\[\[media_player:\s*([^\]]+)\]\]/i);
if (mediaPlayerMatch && !lineData.flexMessage) {
const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 1) {
const [title, artist, source, imageUrl, statusStr] = parts;
const isPlaying = statusStr?.toLowerCase() === "playing";
// LINE requires HTTPS URLs for images - skip local/HTTP URLs
const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined;
const deviceKey = toSlug(source || title || "media");
const card = createMediaPlayerCard({
title: title || "Unknown Track",
subtitle: artist || undefined,
source: source || undefined,
imageUrl: validImageUrl,
isPlaying: statusStr ? isPlaying : undefined,
controls: {
previous: { data: lineActionData("previous", { "line.device": deviceKey }) },
play: { data: lineActionData("play", { "line.device": deviceKey }) },
pause: { data: lineActionData("pause", { "line.device": deviceKey }) },
next: { data: lineActionData("next", { "line.device": deviceKey }) },
},
});
lineData.flexMessage = {
altText: `🎵 ${title}${artist ? ` - ${artist}` : ""}`,
contents: card,
};
}
text = text.replace(mediaPlayerMatch[0], "").trim();
}
// Parse [[event: title | date | time | location | description]]
const eventMatch = text.match(/\[\[event:\s*([^\]]+)\]\]/i);
if (eventMatch && !lineData.flexMessage) {
const parts = eventMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 2) {
const [title, date, time, location, description] = parts;
const card = createEventCard({
title: title || "Event",
date: date || "TBD",
time: time || undefined,
location: location || undefined,
description: description || undefined,
});
lineData.flexMessage = {
altText: `📅 ${title} - ${date}${time ? ` ${time}` : ""}`,
contents: card,
};
}
text = text.replace(eventMatch[0], "").trim();
}
// Parse [[appletv_remote: name | status]]
const appleTvMatch = text.match(/\[\[appletv_remote:\s*([^\]]+)\]\]/i);
if (appleTvMatch && !lineData.flexMessage) {
const parts = appleTvMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 1) {
const [deviceName, status] = parts;
const deviceKey = toSlug(deviceName || "apple_tv");
const card = createAppleTvRemoteCard({
deviceName: deviceName || "Apple TV",
status: status || undefined,
actionData: {
up: lineActionData("up", { "line.device": deviceKey }),
down: lineActionData("down", { "line.device": deviceKey }),
left: lineActionData("left", { "line.device": deviceKey }),
right: lineActionData("right", { "line.device": deviceKey }),
select: lineActionData("select", { "line.device": deviceKey }),
menu: lineActionData("menu", { "line.device": deviceKey }),
home: lineActionData("home", { "line.device": deviceKey }),
play: lineActionData("play", { "line.device": deviceKey }),
pause: lineActionData("pause", { "line.device": deviceKey }),
volumeUp: lineActionData("volume_up", { "line.device": deviceKey }),
volumeDown: lineActionData("volume_down", { "line.device": deviceKey }),
mute: lineActionData("mute", { "line.device": deviceKey }),
},
});
lineData.flexMessage = {
altText: `📺 ${deviceName || "Apple TV"} Remote`,
contents: card,
};
}
text = text.replace(appleTvMatch[0], "").trim();
}
// Parse [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
const agendaMatch = text.match(/\[\[agenda:\s*([^\]]+)\]\]/i);
if (agendaMatch && !lineData.flexMessage) {
const parts = agendaMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 2) {
const [title, eventsStr] = parts;
const events = eventsStr.split(",").map((eventStr) => {
const trimmed = eventStr.trim();
const colonIdx = trimmed.lastIndexOf(":");
if (colonIdx > 0) {
return {
title: trimmed.slice(0, colonIdx).trim(),
time: trimmed.slice(colonIdx + 1).trim(),
};
}
return { title: trimmed };
});
const card = createAgendaCard({
title: title || "Agenda",
events,
});
lineData.flexMessage = {
altText: `📋 ${title} (${events.length} events)`,
contents: card,
};
}
text = text.replace(agendaMatch[0], "").trim();
}
// Parse [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
const deviceMatch = text.match(/\[\[device:\s*([^\]]+)\]\]/i);
if (deviceMatch && !lineData.flexMessage) {
const parts = deviceMatch[1].split("|").map((s) => s.trim());
if (parts.length >= 1) {
const [deviceName, deviceType, status, controlsStr] = parts;
const deviceKey = toSlug(deviceName || "device");
const controls = controlsStr
? controlsStr.split(",").map((ctrlStr) => {
const [label, data] = ctrlStr.split(":").map((s) => s.trim());
const action = data || label.toLowerCase().replace(/\s+/g, "_");
return { label, data: lineActionData(action, { "line.device": deviceKey }) };
})
: [];
const card = createDeviceControlCard({
deviceName: deviceName || "Device",
deviceType: deviceType || undefined,
status: status || undefined,
controls,
});
lineData.flexMessage = {
altText: `📱 ${deviceName}${status ? `: ${status}` : ""}`,
contents: card,
};
}
text = text.replace(deviceMatch[0], "").trim();
}
// Clean up multiple whitespace/newlines
text = text.replace(/\n{3,}/g, "\n\n").trim();
result.text = text || undefined;
if (Object.keys(lineData).length > 0) {
result.channelData = { ...result.channelData, line: lineData };
}
return result;
}
/**
* Check if text contains any LINE directives
*/
export function hasLineDirectives(text: string): boolean {
return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test(
text,
);
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { normalizeReplyPayload } from "./normalize-reply.js";
// Keep channelData-only payloads so channel-specific replies survive normalization.
describe("normalizeReplyPayload", () => {
it("keeps channelData-only replies", () => {
const payload = {
channelData: {
line: {
flexMessage: { type: "bubble" },
},
},
};
const normalized = normalizeReplyPayload(payload);
expect(normalized).not.toBeNull();
expect(normalized?.text).toBeUndefined();
expect(normalized?.channelData).toEqual(payload.channelData);
});
});
+16 -5
View File
@@ -6,6 +6,7 @@ import {
resolveResponsePrefixTemplate,
type ResponsePrefixContext,
} from "./response-prefix-template.js";
import { hasLineDirectives, parseLineDirectives } from "./line-directives.js";
export type NormalizeReplyOptions = {
responsePrefix?: string;
@@ -21,13 +22,16 @@ export function normalizeReplyPayload(
opts: NormalizeReplyOptions = {},
): ReplyPayload | null {
const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0);
const hasChannelData = Boolean(
payload.channelData && Object.keys(payload.channelData).length > 0,
);
const trimmed = payload.text?.trim() ?? "";
if (!trimmed && !hasMedia) return null;
if (!trimmed && !hasMedia && !hasChannelData) return null;
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
let text = payload.text ?? undefined;
if (text && isSilentReplyText(text, silentToken)) {
if (!hasMedia) return null;
if (!hasMedia && !hasChannelData) return null;
text = "";
}
if (text && !trimmed) {
@@ -39,14 +43,21 @@ export function normalizeReplyPayload(
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip) opts.onHeartbeatStrip?.();
if (stripped.shouldSkip && !hasMedia) return null;
if (stripped.shouldSkip && !hasMedia && !hasChannelData) return null;
text = stripped.text;
}
if (text) {
text = sanitizeUserFacingText(text);
}
if (!text?.trim() && !hasMedia) return null;
if (!text?.trim() && !hasMedia && !hasChannelData) return null;
// Parse LINE-specific directives from text (quick_replies, location, confirm, buttons)
let enrichedPayload: ReplyPayload = { ...payload, text };
if (text && hasLineDirectives(text)) {
enrichedPayload = parseLineDirectives(enrichedPayload);
text = enrichedPayload.text;
}
// Resolve template variables in responsePrefix if context is provided
const effectivePrefix = opts.responsePrefixContext
@@ -62,5 +73,5 @@ export function normalizeReplyPayload(
text = `${effectivePrefix} ${text}`;
}
return { ...payload, text };
return { ...enrichedPayload, text };
}
+2 -1
View File
@@ -45,7 +45,8 @@ export function isRenderablePayload(payload: ReplyPayload): boolean {
payload.text ||
payload.mediaUrl ||
(payload.mediaUrls && payload.mediaUrls.length > 0) ||
payload.audioAsVoice,
payload.audioAsVoice ||
payload.channelData,
);
}
+1
View File
@@ -72,6 +72,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
+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,
+23 -5
View File
@@ -17,7 +17,7 @@ import {
getTtsMaxLength,
getTtsProvider,
isSummarizationEnabled,
isTtsEnabled,
resolveTtsAutoMode,
resolveTtsConfig,
resolveTtsPrefsPath,
} from "../tts/tts.js";
@@ -30,6 +30,7 @@ import {
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
import { listPluginCommands } from "../plugins/commands.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
@@ -252,15 +253,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 +407,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,
@@ -465,5 +474,14 @@ export function buildCommandsMessage(
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
}
const pluginCommands = listPluginCommands();
if (pluginCommands.length > 0) {
lines.push("");
lines.push("Plugin commands:");
for (const command of pluginCommands) {
const pluginLabel = command.pluginId ? ` (plugin: ${command.pluginId})` : "";
lines.push(`/${command.name}${pluginLabel} - ${command.description}`);
}
}
return lines.join("\n");
}
+11
View File
@@ -1,5 +1,6 @@
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export type VerboseLevel = "off" | "on" | "full";
export type NoticeLevel = "off" | "on" | "full";
export type ElevatedLevel = "off" | "on" | "ask" | "full";
export type ElevatedMode = "off" | "ask" | "full";
export type ReasoningLevel = "off" | "on" | "stream";
@@ -93,6 +94,16 @@ export function normalizeVerboseLevel(raw?: string | null): VerboseLevel | undef
return undefined;
}
// Normalize system notice flags used to toggle system notifications.
export function normalizeNoticeLevel(raw?: string | null): NoticeLevel | undefined {
if (!raw) return undefined;
const key = raw.toLowerCase();
if (["off", "false", "no", "0"].includes(key)) return "off";
if (["full", "all", "everything"].includes(key)) return "full";
if (["on", "minimal", "true", "yes", "1"].includes(key)) return "on";
return undefined;
}
// Normalize response-usage display modes used to toggle per-response usage footers.
export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined {
if (!raw) return undefined;
+2
View File
@@ -52,4 +52,6 @@ export type ReplyPayload = {
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
audioAsVoice?: boolean;
isError?: boolean;
/** Channel-specific payload data (per-channel envelope). */
channelData?: Record<string, unknown>;
};
+1
View File
@@ -13,6 +13,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
+6
View File
@@ -1,4 +1,5 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
import type { RuntimeEnv } from "../../runtime.js";
@@ -81,6 +82,10 @@ export type ChannelOutboundContext = {
deps?: OutboundSendDeps;
};
export type ChannelOutboundPayloadContext = ChannelOutboundContext & {
payload: ReplyPayload;
};
export type ChannelOutboundAdapter = {
deliveryMode: "direct" | "gateway" | "hybrid";
chunker?: ((text: string, limit: number) => string[]) | null;
@@ -94,6 +99,7 @@ export type ChannelOutboundAdapter = {
accountId?: string | null;
mode?: ChannelOutboundTargetMode;
}) => { ok: true; to: string } | { ok: false; error: Error };
sendPayload?: (ctx: ChannelOutboundPayloadContext) => Promise<OutboundDeliveryResult>;
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;
+6
View File
@@ -240,6 +240,12 @@ export type ChannelThreadingToolContext = {
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all";
hasRepliedRef?: { value: boolean };
/**
* When true, skip cross-context decoration (e.g., "[from X]" prefix).
* Use this for direct tool invocations where the agent is composing a new message,
* not forwarding/relaying a message from another conversation.
*/
skipCrossContextDecoration?: boolean;
};
export type ChannelMessagingAdapter = {
+1
View File
@@ -134,6 +134,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -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).",
+11
View File
@@ -59,6 +59,17 @@ export const CONFIG_PATH_CLAWDBOT = resolveConfigPath();
export const DEFAULT_GATEWAY_PORT = 18789;
/**
* Gateway lock directory (ephemeral).
* Default: os.tmpdir()/clawdbot-<uid> (uid suffix when available).
*/
export function resolveGatewayLockDir(tmpdir: () => string = os.tmpdir): string {
const base = tmpdir();
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
const suffix = uid != null ? `clawdbot-${uid}` : "clawdbot";
return path.join(base, suffix);
}
const OAUTH_FILENAME = "oauth.json";
/**
+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;
+2
View File
@@ -33,6 +33,8 @@ export type SignalAccountConfig = {
cliPath?: string;
/** Auto-start signal-cli daemon (default: true if httpUrl not set). */
autoStart?: boolean;
/** Max time to wait for signal-cli daemon startup (ms, cap 120000). */
startupTimeoutMs?: number;
receiveMode?: "on-start" | "manual";
ignoreAttachments?: boolean;
ignoreStories?: boolean;
+2
View File
@@ -118,6 +118,8 @@ export type TelegramAccountConfig = {
reactionLevel?: "off" | "ack" | "minimal" | "extensive";
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
/** Controls whether link previews are shown in outbound messages. Default: true. */
linkPreview?: boolean;
};
export type TelegramTopicConfig = {
+5 -1
View File
@@ -2,6 +2,8 @@ export type TtsProvider = "elevenlabs" | "openai" | "edge";
export type TtsMode = "final" | "all";
export type TtsAutoMode = "off" | "always" | "inbound" | "tagged";
export type TtsModelOverrideConfig = {
/** Enable model-provided overrides for TTS. */
enabled?: boolean;
@@ -22,7 +24,9 @@ export type TtsModelOverrideConfig = {
};
export type TtsConfig = {
/** Enable auto-TTS (can be overridden by local prefs). */
/** Auto-TTS mode (preferred). */
auto?: TtsAutoMode;
/** Legacy: enable auto-TTS when `auto` is not set. */
enabled?: boolean;
/** Apply TTS to final replies only or to all replies (tool/block/final). */
mode?: TtsMode;
+2
View File
@@ -158,8 +158,10 @@ export const MarkdownConfigSchema = z
export const TtsProviderSchema = z.enum(["elevenlabs", "openai", "edge"]);
export const TtsModeSchema = z.enum(["final", "all"]);
export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]);
export const TtsConfigSchema = z
.object({
auto: TtsAutoSchema.optional(),
enabled: z.boolean().optional(),
mode: TtsModeSchema.optional(),
provider: TtsProviderSchema.optional(),
+2
View File
@@ -125,6 +125,7 @@ export const TelegramAccountSchemaBase = z
reactionNotifications: z.enum(["off", "own", "all"]).optional(),
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
linkPreview: z.boolean().optional(),
})
.strict();
@@ -486,6 +487,7 @@ export const SignalAccountSchemaBase = z
httpPort: z.number().int().positive().optional(),
cliPath: ExecutableTokenSchema.optional(),
autoStart: z.boolean().optional(),
startupTimeoutMs: z.number().int().min(1000).max(120000).optional(),
receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(),
ignoreAttachments: z.boolean().optional(),
ignoreStories: z.boolean().optional(),
@@ -136,9 +136,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
const forumParentSlug =
isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : "";
const threadChannelId = threadChannel?.id;
const isForumStarter = Boolean(
threadChannelId && isForumParent && forumParentSlug && message.id === threadChannelId,
);
const isForumStarter =
Boolean(threadChannelId && isForumParent && forumParentSlug) && message.id === threadChannelId;
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
const groupSubject = isDirectMessage ? undefined : groupChannel;
+17 -66
View File
@@ -1,77 +1,28 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { describe, expect, it } from "vitest";
const testTailnetIPv4 = { value: undefined as string | undefined };
const testTailnetIPv6 = { value: undefined as string | undefined };
import { resolveGatewayListenHosts } from "./net.js";
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: () => testTailnetIPv4.value,
pickPrimaryTailnetIPv6: () => testTailnetIPv6.value,
}));
import { isLocalGatewayAddress, resolveGatewayClientIp } from "./net.js";
describe("gateway net", () => {
beforeEach(() => {
testTailnetIPv4.value = undefined;
testTailnetIPv6.value = undefined;
});
test("treats loopback as local", () => {
expect(isLocalGatewayAddress("127.0.0.1")).toBe(true);
expect(isLocalGatewayAddress("127.0.1.1")).toBe(true);
expect(isLocalGatewayAddress("::1")).toBe(true);
expect(isLocalGatewayAddress("::ffff:127.0.0.1")).toBe(true);
});
test("treats local tailnet IPv4 as local", () => {
testTailnetIPv4.value = "100.64.0.1";
expect(isLocalGatewayAddress("100.64.0.1")).toBe(true);
expect(isLocalGatewayAddress("::ffff:100.64.0.1")).toBe(true);
});
test("ignores non-matching tailnet IPv4", () => {
testTailnetIPv4.value = "100.64.0.1";
expect(isLocalGatewayAddress("100.64.0.2")).toBe(false);
});
test("treats local tailnet IPv6 as local", () => {
testTailnetIPv6.value = "fd7a:115c:a1e0::123";
expect(isLocalGatewayAddress("fd7a:115c:a1e0::123")).toBe(true);
});
test("uses forwarded-for when remote is a trusted proxy", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "10.0.0.2",
forwardedFor: "203.0.113.9, 10.0.0.2",
trustedProxies: ["10.0.0.2"],
describe("resolveGatewayListenHosts", () => {
it("returns the input host when not loopback", async () => {
const hosts = await resolveGatewayListenHosts("0.0.0.0", {
canBindToHost: async () => {
throw new Error("should not be called");
},
});
expect(clientIp).toBe("203.0.113.9");
expect(hosts).toEqual(["0.0.0.0"]);
});
test("ignores forwarded-for from untrusted proxies", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "10.0.0.3",
forwardedFor: "203.0.113.9",
trustedProxies: ["10.0.0.2"],
it("adds ::1 when IPv6 loopback is available", async () => {
const hosts = await resolveGatewayListenHosts("127.0.0.1", {
canBindToHost: async () => true,
});
expect(clientIp).toBe("10.0.0.3");
expect(hosts).toEqual(["127.0.0.1", "::1"]);
});
test("normalizes trusted proxy IPs and strips forwarded ports", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "::ffff:10.0.0.2",
forwardedFor: "203.0.113.9:1234",
trustedProxies: ["10.0.0.2"],
it("keeps only IPv4 loopback when IPv6 is unavailable", async () => {
const hosts = await resolveGatewayListenHosts("127.0.0.1", {
canBindToHost: async () => false,
});
expect(clientIp).toBe("203.0.113.9");
});
test("falls back to x-real-ip when forwarded-for is missing", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "10.0.0.2",
realIp: "203.0.113.10",
trustedProxies: ["10.0.0.2"],
});
expect(clientIp).toBe("203.0.113.10");
expect(hosts).toEqual(["127.0.0.1"]);
});
});
+16 -6
View File
@@ -97,14 +97,14 @@ export async function resolveGatewayBindHost(
if (mode === "loopback") {
// 127.0.0.1 rarely fails, but handle gracefully
if (await canBindTo("127.0.0.1")) return "127.0.0.1";
if (await canBindToHost("127.0.0.1")) return "127.0.0.1";
return "0.0.0.0"; // extreme fallback
}
if (mode === "tailnet") {
const tailnetIP = pickPrimaryTailnetIPv4();
if (tailnetIP && (await canBindTo(tailnetIP))) return tailnetIP;
if (await canBindTo("127.0.0.1")) return "127.0.0.1";
if (tailnetIP && (await canBindToHost(tailnetIP))) return tailnetIP;
if (await canBindToHost("127.0.0.1")) return "127.0.0.1";
return "0.0.0.0";
}
@@ -116,13 +116,13 @@ export async function resolveGatewayBindHost(
const host = customHost?.trim();
if (!host) return "0.0.0.0"; // invalid config → fall back to all
if (isValidIPv4(host) && (await canBindTo(host))) return host;
if (isValidIPv4(host) && (await canBindToHost(host))) return host;
// Custom IP failed → fall back to LAN
return "0.0.0.0";
}
if (mode === "auto") {
if (await canBindTo("127.0.0.1")) return "127.0.0.1";
if (await canBindToHost("127.0.0.1")) return "127.0.0.1";
return "0.0.0.0";
}
@@ -136,7 +136,7 @@ export async function resolveGatewayBindHost(
* @param host - The host address to test
* @returns True if we can successfully bind to this address
*/
async function canBindTo(host: string): Promise<boolean> {
export async function canBindToHost(host: string): Promise<boolean> {
return new Promise((resolve) => {
const testServer = net.createServer();
testServer.once("error", () => {
@@ -151,6 +151,16 @@ async function canBindTo(host: string): Promise<boolean> {
});
}
export async function resolveGatewayListenHosts(
bindHost: string,
opts?: { canBindToHost?: (host: string) => Promise<boolean> },
): Promise<string[]> {
if (bindHost !== "127.0.0.1") return [bindHost];
const canBind = opts?.canBindToHost ?? canBindToHost;
if (await canBind("::1")) return [bindHost, "::1"];
return [bindHost];
}
/**
* Validate if a string is a valid IPv4 address.
*
+15 -8
View File
@@ -28,6 +28,7 @@ export function createGatewayCloseHandler(params: {
browserControl: { stop: () => Promise<void> } | null;
wss: WebSocketServer;
httpServer: HttpServer;
httpServers?: HttpServer[];
}) {
return async (opts?: { reason?: string; restartExpectedMs?: number | null }) => {
const reasonRaw = typeof opts?.reason === "string" ? opts.reason.trim() : "";
@@ -108,14 +109,20 @@ export function createGatewayCloseHandler(params: {
await params.browserControl.stop().catch(() => {});
}
await new Promise<void>((resolve) => params.wss.close(() => resolve()));
const httpServer = params.httpServer as HttpServer & {
closeIdleConnections?: () => void;
};
if (typeof httpServer.closeIdleConnections === "function") {
httpServer.closeIdleConnections();
const servers =
params.httpServers && params.httpServers.length > 0
? params.httpServers
: [params.httpServer];
for (const server of servers) {
const httpServer = server as HttpServer & {
closeIdleConnections?: () => void;
};
if (typeof httpServer.closeIdleConnections === "function") {
httpServer.closeIdleConnections();
}
await new Promise<void>((resolve, reject) =>
httpServer.close((err) => (err ? reject(err) : resolve())),
);
}
await new Promise<void>((resolve, reject) =>
params.httpServer.close((err) => (err ? reject(err) : resolve())),
);
};
}
+2 -2
View File
@@ -230,8 +230,6 @@ export function createGatewayHttpServer(opts: {
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,
@@ -239,6 +237,8 @@ export function createGatewayHttpServer(opts: {
})
)
return;
if (await handleSlackHttpRequest(req, res)) return;
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
if (openResponsesEnabled) {
if (
await handleOpenResponsesHttpRequest(req, res, {
+3
View File
@@ -5,6 +5,7 @@ import {
getTtsProvider,
isTtsEnabled,
isTtsProviderConfigured,
resolveTtsAutoMode,
resolveTtsApiKey,
resolveTtsConfig,
resolveTtsPrefsPath,
@@ -24,11 +25,13 @@ export const ttsHandlers: GatewayRequestHandlers = {
const config = resolveTtsConfig(cfg);
const prefsPath = resolveTtsPrefsPath(config);
const provider = getTtsProvider(config, prefsPath);
const autoMode = resolveTtsAutoMode({ config, prefsPath });
const fallbackProviders = resolveTtsProviderOrder(provider)
.slice(1)
.filter((candidate) => isTtsProviderConfigured(config, candidate));
respond(true, {
enabled: isTtsEnabled(config, prefsPath),
auto: autoMode,
provider,
fallbackProvider: fallbackProviders[0] ?? null,
fallbackProviders,
+1
View File
@@ -18,6 +18,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics,
+44 -19
View File
@@ -10,6 +10,7 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js";
import type { HooksConfigResolved } from "./hooks.js";
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
import { listenGatewayHttpServer } from "./server/http-listen.js";
import { resolveGatewayListenHosts } from "./net.js";
import { createGatewayPluginRequestHandler } from "./server/plugins-http.js";
import type { GatewayWsClient } from "./server/ws-types.js";
import { createGatewayBroadcaster } from "./server-broadcast.js";
@@ -38,11 +39,14 @@ export async function createGatewayRuntimeState(params: {
canvasHostEnabled: boolean;
allowCanvasHostInTests?: boolean;
logCanvas: { info: (msg: string) => void; warn: (msg: string) => void };
log: { info: (msg: string) => void; warn: (msg: string) => void };
logHooks: ReturnType<typeof createSubsystemLogger>;
logPlugins: ReturnType<typeof createSubsystemLogger>;
}): Promise<{
canvasHost: CanvasHostHandler | null;
httpServer: HttpServer;
httpServers: HttpServer[];
httpBindHosts: string[];
wss: WebSocketServer;
clients: Set<GatewayWsClient>;
broadcast: (
@@ -100,30 +104,49 @@ export async function createGatewayRuntimeState(params: {
log: params.logPlugins,
});
const httpServer = createGatewayHttpServer({
canvasHost,
controlUiEnabled: params.controlUiEnabled,
controlUiBasePath: params.controlUiBasePath,
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
openResponsesEnabled: params.openResponsesEnabled,
openResponsesConfig: params.openResponsesConfig,
handleHooksRequest,
handlePluginRequest,
resolvedAuth: params.resolvedAuth,
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
});
await listenGatewayHttpServer({
httpServer,
bindHost: params.bindHost,
port: params.port,
});
const bindHosts = await resolveGatewayListenHosts(params.bindHost);
const httpServers: HttpServer[] = [];
const httpBindHosts: string[] = [];
for (const host of bindHosts) {
const httpServer = createGatewayHttpServer({
canvasHost,
controlUiEnabled: params.controlUiEnabled,
controlUiBasePath: params.controlUiBasePath,
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
openResponsesEnabled: params.openResponsesEnabled,
openResponsesConfig: params.openResponsesConfig,
handleHooksRequest,
handlePluginRequest,
resolvedAuth: params.resolvedAuth,
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
});
try {
await listenGatewayHttpServer({
httpServer,
bindHost: host,
port: params.port,
});
httpServers.push(httpServer);
httpBindHosts.push(host);
} catch (err) {
if (host === bindHosts[0]) throw err;
params.log.warn(
`gateway: failed to bind loopback alias ${host}:${params.port} (${String(err)})`,
);
}
}
const httpServer = httpServers[0];
if (!httpServer) {
throw new Error("Gateway HTTP server failed to start");
}
const wss = new WebSocketServer({
noServer: true,
maxPayload: MAX_PAYLOAD_BYTES,
});
attachGatewayUpgradeHandler({ httpServer, wss, canvasHost });
for (const server of httpServers) {
attachGatewayUpgradeHandler({ httpServer: server, wss, canvasHost });
}
const clients = new Set<GatewayWsClient>();
const { broadcast } = createGatewayBroadcaster({ clients });
@@ -140,6 +163,8 @@ export async function createGatewayRuntimeState(params: {
return {
canvasHost,
httpServer,
httpServers,
httpBindHosts,
wss,
clients,
broadcast,
+9 -1
View File
@@ -7,6 +7,7 @@ import { getResolvedLoggerSettings } from "../logging.js";
export function logGatewayStartup(params: {
cfg: ReturnType<typeof loadConfig>;
bindHost: string;
bindHosts?: string[];
port: number;
tlsEnabled?: boolean;
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
@@ -22,9 +23,16 @@ export function logGatewayStartup(params: {
consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`,
});
const scheme = params.tlsEnabled ? "wss" : "ws";
const formatHost = (host: string) => (host.includes(":") ? `[${host}]` : host);
const hosts =
params.bindHosts && params.bindHosts.length > 0 ? params.bindHosts : [params.bindHost];
const primaryHost = hosts[0] ?? params.bindHost;
params.log.info(
`listening on ${scheme}://${params.bindHost}:${params.port} (PID ${process.pid})`,
`listening on ${scheme}://${formatHost(primaryHost)}:${params.port} (PID ${process.pid})`,
);
for (const host of hosts.slice(1)) {
params.log.info(`listening on ${scheme}://${formatHost(host)}:${params.port}`);
}
params.log.info(`log file: ${getResolvedLoggerSettings().file}`);
if (params.isNixMode) {
params.log.info("gateway: running in Nix mode (config managed externally)");
@@ -40,6 +40,7 @@ const registryState = vi.hoisted(() => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -81,6 +82,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -49,6 +49,7 @@ const registryState = vi.hoisted(() => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -78,6 +79,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
+15
View File
@@ -230,6 +230,21 @@ describe("gateway server auth/connect", () => {
ws.close();
});
test("returns control ui hint when token is missing", async () => {
const ws = await openWs(port);
const res = await connectReq(ws, {
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "1.0.0",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("Control UI settings");
ws.close();
});
test("rejects control ui without device identity by default", async () => {
const ws = await openWs(port);
const res = await connectReq(ws, {
+2
View File
@@ -21,6 +21,7 @@ const registryState = vi.hoisted(() => ({
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -47,6 +48,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
+5
View File
@@ -263,6 +263,8 @@ export async function startGatewayServer(
const {
canvasHost,
httpServer,
httpServers,
httpBindHosts,
wss,
clients,
broadcast,
@@ -292,6 +294,7 @@ export async function startGatewayServer(
canvasHostEnabled,
allowCanvasHostInTests: opts.allowCanvasHostInTests,
logCanvas,
log,
logHooks,
logPlugins,
});
@@ -464,6 +467,7 @@ export async function startGatewayServer(
logGatewayStartup({
cfg: cfgAtStart,
bindHost,
bindHosts: httpBindHosts,
port,
tlsEnabled: gatewayTls.enabled,
log,
@@ -552,6 +556,7 @@ export async function startGatewayServer(
browserControl,
wss,
httpServer,
httpServers,
});
return {
@@ -75,6 +75,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
diagnostics: [],
@@ -10,6 +10,7 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
providers: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
@@ -20,5 +21,6 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
...merged,
gatewayHandlers: merged.gatewayHandlers ?? {},
httpHandlers: merged.httpHandlers ?? [],
httpRoutes: merged.httpRoutes ?? [],
};
};
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff. Show more