Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43c2c9b697 | ||
|
|
bb82851124 | ||
|
|
ca627ddcd4 | ||
|
|
9a6964b81f | ||
|
|
0f662c2935 | ||
|
|
32bcd291d5 | ||
|
|
5f9863098b | ||
|
|
fdecf5c59a | ||
|
|
83f92e34af | ||
|
|
9366cbc7db | ||
|
|
d4f895d8f2 | ||
|
|
f08c34a73f | ||
|
|
6a9301c27d | ||
|
|
653401774d | ||
|
|
c6cdbb630c | ||
|
|
da2439f2cc | ||
|
|
92ab3f22dc | ||
|
|
43a6c5b77f | ||
|
|
495616d13e | ||
|
|
bac80f0886 | ||
|
|
ef078fec70 | ||
|
|
8e3ac01db6 | ||
|
|
c3f90dd4e2 | ||
|
|
00c4556d7b | ||
|
|
a4bc69dbec | ||
|
|
3f1457de2a | ||
|
|
8507ea08bd | ||
|
|
7ae2548fc6 | ||
|
|
f06f83ddd0 | ||
|
|
c78297d80f | ||
|
|
69f6e1a20b | ||
|
|
5f6409a73d | ||
|
|
06a7e1e8ce | ||
|
|
9eaaadf8ee | ||
|
|
d4f60bf16a | ||
|
|
ede5145191 | ||
|
|
26d3fbb09f | ||
|
|
f7c89ba796 | ||
|
|
9afde64e26 | ||
|
|
8682524da3 | ||
|
|
5956dde459 |
No files matched your search
@@ -18,6 +18,7 @@
|
|||||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
||||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
||||||
|
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
|
||||||
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
||||||
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
||||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||||
|
|||||||
@@ -11,33 +11,48 @@ Docs: https://docs.clawd.bot
|
|||||||
### Changes
|
### Changes
|
||||||
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
|
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
|
||||||
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
||||||
|
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
||||||
|
- 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: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||||
- Docs: add verbose installer troubleshooting guidance.
|
- Docs: add verbose installer troubleshooting guidance.
|
||||||
|
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
||||||
- Docs: update Fly.io guide notes.
|
- Docs: update Fly.io guide notes.
|
||||||
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
||||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||||
|
- Telegram: 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
|
### Fixes
|
||||||
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||||
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||||
|
- 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.
|
- Heartbeat: normalize target identifiers for consistent routing.
|
||||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
||||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||||
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||||
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
||||||
|
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
|
||||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||||
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||||
- Agents: use the active auth profile for auto-compaction recovery.
|
- Agents: use the active auth profile for auto-compaction recovery.
|
||||||
- Models: default missing custom provider fields so minimal configs are accepted.
|
- Models: default missing custom provider fields so minimal configs are accepted.
|
||||||
|
- 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: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
||||||
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||||
|
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
|
||||||
|
- 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.
|
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||||
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||||
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||||
- Google Chat: normalize space targets without double `spaces/` prefix.
|
- Google Chat: normalize space targets without double `spaces/` prefix.
|
||||||
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
||||||
|
- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.
|
||||||
|
- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||||
|
- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||||
|
|
||||||
## 2026.1.23-1
|
## 2026.1.23-1
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ read_when:
|
|||||||
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
- **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).
|
- **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.
|
- **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.
|
- **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.
|
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ Example:
|
|||||||
|
|
||||||
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||||
|
|
||||||
|
## External daemon mode (httpUrl)
|
||||||
|
If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point Clawdbot at it:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
signal: {
|
||||||
|
httpUrl: "http://127.0.0.1:8080",
|
||||||
|
autoStart: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This skips auto-spawn and the startup wait inside Clawdbot. For slow starts when auto-spawning, set `channels.signal.startupTimeoutMs`.
|
||||||
|
|
||||||
## Access control (DMs + groups)
|
## Access control (DMs + groups)
|
||||||
DMs:
|
DMs:
|
||||||
- Default: `channels.signal.dmPolicy = "pairing"`.
|
- Default: `channels.signal.dmPolicy = "pairing"`.
|
||||||
@@ -142,6 +158,7 @@ Provider options:
|
|||||||
- `channels.signal.httpUrl`: full daemon URL (overrides host/port).
|
- `channels.signal.httpUrl`: full daemon URL (overrides host/port).
|
||||||
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
|
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
|
||||||
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).
|
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).
|
||||||
|
- `channels.signal.startupTimeoutMs`: startup wait timeout in ms (cap 120000).
|
||||||
- `channels.signal.receiveMode`: `on-start | manual`.
|
- `channels.signal.receiveMode`: `on-start | manual`.
|
||||||
- `channels.signal.ignoreAttachments`: skip attachment downloads.
|
- `channels.signal.ignoreAttachments`: skip attachment downloads.
|
||||||
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
||||||
|
|||||||
@@ -120,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:
|
Notes:
|
||||||
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
|
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
|
||||||
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
|
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
|
||||||
@@ -518,6 +525,7 @@ Provider options:
|
|||||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
||||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
- `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.streamMode`: `off | partial | block` (draft streaming).
|
||||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ clawdbot channels status --probe
|
|||||||
|
|
||||||
## Telegram quick fixes
|
## Telegram quick fixes
|
||||||
- Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting).
|
- Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting).
|
||||||
|
- Logs show `setMyCommands failed` → check outbound HTTPS and DNS reachability to `api.telegram.org` (common on locked-down VPS or proxies).
|
||||||
@@ -31,6 +31,7 @@ These files live under the workspace (`agents.defaults.workspace`, default
|
|||||||
- Decisions, preferences, and durable facts go to `MEMORY.md`.
|
- Decisions, preferences, and durable facts go to `MEMORY.md`.
|
||||||
- Day-to-day notes and running context go to `memory/YYYY-MM-DD.md`.
|
- Day-to-day notes and running context go to `memory/YYYY-MM-DD.md`.
|
||||||
- If someone says "remember this," write it down (do not keep it in RAM).
|
- If someone says "remember this," write it down (do not keep it in RAM).
|
||||||
|
- This area is still evolving. It helps to remind the model to store memories; it will know what to do.
|
||||||
- If you want something to stick, **ask the bot to write it** into memory.
|
- If you want something to stick, **ask the bot to write it** into memory.
|
||||||
|
|
||||||
## Automatic memory flush (pre-compaction ping)
|
## Automatic memory flush (pre-compaction ping)
|
||||||
|
|||||||
@@ -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).
|
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
|
||||||
- Enable: `clawdbot plugins enable google-gemini-cli-auth`
|
- Enable: `clawdbot plugins enable google-gemini-cli-auth`
|
||||||
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
|
- 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)
|
### Z.AI (GLM)
|
||||||
|
|
||||||
|
|||||||
@@ -1048,6 +1048,7 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"platforms",
|
"platforms",
|
||||||
"platforms/macos",
|
"platforms/macos",
|
||||||
|
"platforms/macos-vm",
|
||||||
"platforms/ios",
|
"platforms/ios",
|
||||||
"platforms/android",
|
"platforms/android",
|
||||||
"platforms/windows",
|
"platforms/windows",
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ better forms without hard-coding config knowledge.
|
|||||||
Use `config.apply` to validate + write the full config and restart the Gateway in one step.
|
Use `config.apply` to validate + write the full config and restart the Gateway in one step.
|
||||||
It writes a restart sentinel and pings the last active session after the Gateway comes back.
|
It writes a restart sentinel and pings the last active session after the Gateway comes back.
|
||||||
|
|
||||||
|
Warning: `config.apply` replaces the **entire config**. If you want to change only a few keys,
|
||||||
|
use `config.patch` or `clawdbot config set`. Keep a backup of `~/.clawdbot/clawdbot.json`.
|
||||||
|
|
||||||
Params:
|
Params:
|
||||||
- `raw` (string) — JSON5 payload for the entire config
|
- `raw` (string) — JSON5 payload for the entire config
|
||||||
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
|
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
|
||||||
@@ -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)
|
historyLimit: 50, // include last N group messages as context (0 disables)
|
||||||
replyToMode: "first", // off | first | all
|
replyToMode: "first", // off | first | all
|
||||||
|
linkPreview: true, // toggle outbound link previews
|
||||||
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
|
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
|
||||||
draftChunk: { // optional; only for streamMode=block
|
draftChunk: { // optional; only for streamMode=block
|
||||||
minChars: 200,
|
minChars: 200,
|
||||||
@@ -1506,7 +1510,7 @@ voice notes; other channels send MP3 audio.
|
|||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
enabled: true,
|
auto: "always", // off | always | inbound | tagged
|
||||||
mode: "final", // final | all (include tool/block replies)
|
mode: "final", // final | all (include tool/block replies)
|
||||||
provider: "elevenlabs",
|
provider: "elevenlabs",
|
||||||
summaryModel: "openai/gpt-4.1-mini",
|
summaryModel: "openai/gpt-4.1-mini",
|
||||||
@@ -1543,8 +1547,10 @@ voice notes; other channels send MP3 audio.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `messages.tts.enabled` can be overridden by local user prefs (see `/tts on`, `/tts off`).
|
- `messages.tts.auto` controls auto‑TTS (`off`, `always`, `inbound`, `tagged`).
|
||||||
- `prefsPath` stores local overrides (enabled/provider/limit/summarize).
|
- `/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.
|
- `maxTextLength` is a hard cap for TTS input; summaries are truncated to fit.
|
||||||
- `summaryModel` overrides `agents.defaults.model.primary` for auto-summary.
|
- `summaryModel` overrides `agents.defaults.model.primary` for auto-summary.
|
||||||
- Accepts `provider/model` or an alias from `agents.defaults.models`.
|
- Accepts `provider/model` or an alias from `agents.defaults.models`.
|
||||||
|
|||||||
+620
-185
File diff suppressed because it is too large.
Load diff
@@ -114,3 +114,9 @@ Git requirement:
|
|||||||
|
|
||||||
If you choose `-InstallMethod git` and Git is missing, the installer will print the
|
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.
|
Git for Windows link (`https://git-scm.com/download/win`) and exit.
|
||||||
|
|
||||||
|
Common Windows issues:
|
||||||
|
|
||||||
|
- **npm error spawn git / ENOENT**: install Git for Windows and reopen PowerShell, then rerun the installer.
|
||||||
|
- **"clawdbot" is not recognized**: your npm global bin folder is not on PATH. Most systems use
|
||||||
|
`%AppData%\\npm`. You can also run `npm config get prefix` and add `\\bin` to PATH, then reopen PowerShell.
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
---
|
||||||
|
summary: "Run Clawdbot in a sandboxed macOS VM (local or hosted) when you need isolation or iMessage"
|
||||||
|
read_when:
|
||||||
|
- You want Clawdbot isolated from your main macOS environment
|
||||||
|
- You want iMessage integration (BlueBubbles) in a sandbox
|
||||||
|
- You want a resettable macOS environment you can clone
|
||||||
|
- You want to compare local vs hosted macOS VM options
|
||||||
|
---
|
||||||
|
|
||||||
|
# Clawdbot on macOS VMs (Sandboxing)
|
||||||
|
|
||||||
|
## Recommended default (most users)
|
||||||
|
|
||||||
|
- **Small Linux VPS** for an always-on Gateway and low cost. See [VPS hosting](/vps).
|
||||||
|
- **Dedicated hardware** (Mac mini or Linux box) if you want full control and a **residential IP** for browser automation. Many sites block data center IPs, so local browsing often works better.
|
||||||
|
- **Hybrid:** keep the Gateway on a cheap VPS, and connect your Mac as a **node** when you need browser/UI automation. See [Nodes](/nodes) and [Gateway remote](/gateway/remote).
|
||||||
|
|
||||||
|
Use a macOS VM when you specifically need macOS-only capabilities (iMessage/BlueBubbles) or want strict isolation from your daily Mac.
|
||||||
|
|
||||||
|
## macOS VM options
|
||||||
|
|
||||||
|
### Local VM on your Apple Silicon Mac (Lume)
|
||||||
|
|
||||||
|
Run Clawdbot in a sandboxed macOS VM on your existing Apple Silicon Mac using [Lume](https://cua.ai/docs/lume).
|
||||||
|
|
||||||
|
This gives you:
|
||||||
|
- Full macOS environment in isolation (your host stays clean)
|
||||||
|
- iMessage support via BlueBubbles (impossible on Linux/Windows)
|
||||||
|
- Instant reset by cloning VMs
|
||||||
|
- No extra hardware or cloud costs
|
||||||
|
|
||||||
|
### Hosted Mac providers (cloud)
|
||||||
|
|
||||||
|
If you want macOS in the cloud, hosted Mac providers work too:
|
||||||
|
- [MacStadium](https://www.macstadium.com/) (hosted Macs)
|
||||||
|
- Other hosted Mac vendors also work; follow their VM + SSH docs
|
||||||
|
|
||||||
|
Once you have SSH access to a macOS VM, continue at step 6 below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick path (Lume, experienced users)
|
||||||
|
|
||||||
|
1. Install Lume
|
||||||
|
2. `lume create clawdbot --os macos --ipsw latest`
|
||||||
|
3. Complete Setup Assistant, enable Remote Login (SSH)
|
||||||
|
4. `lume run clawdbot --no-display`
|
||||||
|
5. SSH in, install Clawdbot, configure channels
|
||||||
|
6. Done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What you need (Lume)
|
||||||
|
|
||||||
|
- Apple Silicon Mac (M1/M2/M3/M4)
|
||||||
|
- macOS Sequoia or later on the host
|
||||||
|
- ~60 GB free disk space per VM
|
||||||
|
- ~20 minutes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Install Lume
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `~/.local/bin` isn't in your PATH:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'export PATH="$PATH:$HOME/.local/bin"' >> ~/.zshrc && source ~/.zshrc
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Docs: [Lume Installation](https://cua.ai/docs/lume/guide/getting-started/installation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Create the macOS VM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume create clawdbot --os macos --ipsw latest
|
||||||
|
```
|
||||||
|
|
||||||
|
This downloads macOS and creates the VM. A VNC window opens automatically.
|
||||||
|
|
||||||
|
Note: The download can take a while depending on your connection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Complete Setup Assistant
|
||||||
|
|
||||||
|
In the VNC window:
|
||||||
|
1. Select language and region
|
||||||
|
2. Skip Apple ID (or sign in if you want iMessage later)
|
||||||
|
3. Create a user account (remember the username and password)
|
||||||
|
4. Skip all optional features
|
||||||
|
|
||||||
|
After setup completes, enable SSH:
|
||||||
|
1. Open System Settings → General → Sharing
|
||||||
|
2. Enable "Remote Login"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Get the VM's IP address
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume get clawdbot
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for the IP address (usually `192.168.64.x`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) SSH into the VM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh youruser@192.168.64.X
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `youruser` with the account you created, and the IP with your VM's IP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Install Clawdbot
|
||||||
|
|
||||||
|
Inside the VM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g clawdbot@latest
|
||||||
|
clawdbot onboard --install-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
Follow the onboarding prompts to set up your model provider (Anthropic, OpenAI, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Configure channels
|
||||||
|
|
||||||
|
Edit the config file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano ~/.clawdbot/clawdbot.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Add your channels:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"whatsapp": {
|
||||||
|
"dmPolicy": "allowlist",
|
||||||
|
"allowFrom": ["+15551234567"]
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"botToken": "YOUR_BOT_TOKEN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then login to WhatsApp (scan QR):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot channels login
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Run the VM headlessly
|
||||||
|
|
||||||
|
Stop the VM and restart without display:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume stop clawdbot
|
||||||
|
lume run clawdbot --no-display
|
||||||
|
```
|
||||||
|
|
||||||
|
The VM runs in the background. Clawdbot's daemon keeps the gateway running.
|
||||||
|
|
||||||
|
To check status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh youruser@192.168.64.X "clawdbot status"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bonus: iMessage integration
|
||||||
|
|
||||||
|
This is the killer feature of running on macOS. Use [BlueBubbles](https://bluebubbles.app) to add iMessage to Clawdbot.
|
||||||
|
|
||||||
|
Inside the VM:
|
||||||
|
|
||||||
|
1. Download BlueBubbles from bluebubbles.app
|
||||||
|
2. Sign in with your Apple ID
|
||||||
|
3. Enable the Web API and set a password
|
||||||
|
4. Point BlueBubbles webhooks at your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`)
|
||||||
|
|
||||||
|
Add to your Clawdbot config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"bluebubbles": {
|
||||||
|
"serverUrl": "http://localhost:1234",
|
||||||
|
"password": "your-api-password",
|
||||||
|
"webhookPath": "/bluebubbles-webhook"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the gateway. Now your agent can send and receive iMessages.
|
||||||
|
|
||||||
|
Full setup details: [BlueBubbles channel](/channels/bluebubbles)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Save a golden image
|
||||||
|
|
||||||
|
Before customizing further, snapshot your clean state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume stop clawdbot
|
||||||
|
lume clone clawdbot clawdbot-golden
|
||||||
|
```
|
||||||
|
|
||||||
|
Reset anytime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume stop clawdbot && lume delete clawdbot
|
||||||
|
lume clone clawdbot-golden clawdbot
|
||||||
|
lume run clawdbot --no-display
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running 24/7
|
||||||
|
|
||||||
|
Keep the VM running by:
|
||||||
|
- Keeping your Mac plugged in
|
||||||
|
- Disabling sleep in System Settings → Energy Saver
|
||||||
|
- Using `caffeinate` if needed
|
||||||
|
|
||||||
|
For true always-on, consider a dedicated Mac mini or a small VPS. See [VPS hosting](/vps).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Solution |
|
||||||
|
|---------|----------|
|
||||||
|
| Can't SSH into VM | Check "Remote Login" is enabled in VM's System Settings |
|
||||||
|
| VM IP not showing | Wait for VM to fully boot, run `lume get clawdbot` again |
|
||||||
|
| Lume command not found | Add `~/.local/bin` to your PATH |
|
||||||
|
| WhatsApp QR not scanning | Ensure you're logged into the VM (not host) when running `clawdbot channels login` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- [VPS hosting](/vps)
|
||||||
|
- [Nodes](/nodes)
|
||||||
|
- [Gateway remote](/gateway/remote)
|
||||||
|
- [BlueBubbles channel](/channels/bluebubbles)
|
||||||
|
- [Lume Quickstart](https://cua.ai/docs/lume/guide/getting-started/quickstart)
|
||||||
|
- [Lume CLI Reference](https://cua.ai/docs/lume/reference/cli-reference)
|
||||||
|
- [Unattended VM Setup](https://cua.ai/docs/lume/guide/fundamentals/unattended-setup) (advanced)
|
||||||
|
- [Docker Sandboxing](/install/docker) (alternative isolation approach)
|
||||||
@@ -67,6 +67,22 @@ Plugins can register:
|
|||||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
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
|
## Discovery & precedence
|
||||||
|
|
||||||
Clawdbot scans, in order:
|
Clawdbot scans, in order:
|
||||||
|
|||||||
@@ -104,6 +104,87 @@ Notes:
|
|||||||
- `mock` is a local dev provider (no network calls).
|
- `mock` is a local dev provider (no network calls).
|
||||||
- `skipSignatureVerification` is for local testing only.
|
- `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 calls
|
||||||
|
|
||||||
Inbound policy defaults to `disabled`. To enable inbound calls, set:
|
Inbound policy defaults to `disabled`. To enable inbound calls, set:
|
||||||
|
|||||||
+5
-3
@@ -16,7 +16,7 @@ and you configure everything via the `/setup` web wizard.
|
|||||||
|
|
||||||
## One-click deploy
|
## 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**.
|
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:
|
Set these variables on the service:
|
||||||
|
|
||||||
- `SETUP_PASSWORD` (required)
|
- `SETUP_PASSWORD` (required)
|
||||||
|
- `PORT=8080` (required — must match the port in Public Networking)
|
||||||
- `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended)
|
- `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended)
|
||||||
- `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended)
|
- `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended)
|
||||||
- `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret)
|
- `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
|
1) Go to https://discord.com/developers/applications
|
||||||
2) **New Application** → choose a name
|
2) **New Application** → choose a name
|
||||||
3) **Bot** → **Add Bot**
|
3) **Bot** → **Add Bot**
|
||||||
4) Copy the **Bot Token** and paste into `/setup`
|
4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup)
|
||||||
5) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
|
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
|
## Backups & migration
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ Text + native (when enabled):
|
|||||||
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
||||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||||
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
|
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
|
||||||
- `/tts 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.
|
- Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works.
|
||||||
- `/stop`
|
- `/stop`
|
||||||
- `/restart`
|
- `/restart`
|
||||||
|
|||||||
+29
-10
@@ -53,8 +53,8 @@ so that provider must also be authenticated if you enable summaries.
|
|||||||
|
|
||||||
## Is it enabled by default?
|
## Is it enabled by default?
|
||||||
|
|
||||||
No. TTS is **disabled** by default. Enable it in config or with `/tts on`,
|
No. Auto‑TTS is **off** by default. Enable it in config with
|
||||||
which writes a local preference override.
|
`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
|
Edge TTS **is** enabled by default once TTS is on, and is used automatically
|
||||||
when no OpenAI or ElevenLabs API keys are available.
|
when no OpenAI or ElevenLabs API keys are available.
|
||||||
@@ -70,7 +70,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
|||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
enabled: true,
|
auto: "always",
|
||||||
provider: "elevenlabs"
|
provider: "elevenlabs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
|||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
enabled: true,
|
auto: "always",
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
summaryModel: "openai/gpt-4.1-mini",
|
summaryModel: "openai/gpt-4.1-mini",
|
||||||
modelOverrides: {
|
modelOverrides: {
|
||||||
@@ -121,7 +121,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
|||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
enabled: true,
|
auto: "always",
|
||||||
provider: "edge",
|
provider: "edge",
|
||||||
edge: {
|
edge: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -156,7 +156,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
|||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
enabled: true,
|
auto: "always",
|
||||||
maxTextLength: 4000,
|
maxTextLength: 4000,
|
||||||
timeoutMs: 30000,
|
timeoutMs: 30000,
|
||||||
prefsPath: "~/.clawdbot/settings/tts.json"
|
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
|
### Disable auto-summary for long replies
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
enabled: true
|
auto: "always"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,7 +197,10 @@ Then run:
|
|||||||
|
|
||||||
### Notes on fields
|
### 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).
|
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
|
||||||
- `provider`: `"elevenlabs"`, `"openai"`, or `"edge"` (fallback is automatic).
|
- `provider`: `"elevenlabs"`, `"openai"`, or `"edge"` (fallback is automatic).
|
||||||
- If `provider` is **unset**, Clawdbot prefers `openai` (if key), then `elevenlabs` (if key),
|
- If `provider` is **unset**, Clawdbot prefers `openai` (if key), then `elevenlabs` (if key),
|
||||||
@@ -195,7 +210,7 @@ Then run:
|
|||||||
- `modelOverrides`: allow the model to emit TTS directives (on by default).
|
- `modelOverrides`: allow the model to emit TTS directives (on by default).
|
||||||
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
|
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
|
||||||
- `timeoutMs`: request timeout (ms).
|
- `timeoutMs`: request timeout (ms).
|
||||||
- `prefsPath`: override the local prefs JSON path.
|
- `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`).
|
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
|
||||||
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
|
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
|
||||||
- `elevenlabs.voiceSettings`:
|
- `elevenlabs.voiceSettings`:
|
||||||
@@ -218,6 +233,7 @@ Then run:
|
|||||||
## Model-driven overrides (default on)
|
## Model-driven overrides (default on)
|
||||||
|
|
||||||
By default, the model **can** emit TTS directives for a single reply.
|
By default, the model **can** emit TTS directives for a single reply.
|
||||||
|
When `messages.tts.auto` is `tagged`, these directives are required to trigger audio.
|
||||||
|
|
||||||
When enabled, the model can emit `[[tts:...]]` directives to override the voice
|
When enabled, the model can emit `[[tts:...]]` directives to override the voice
|
||||||
for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
|
for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
|
||||||
@@ -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.
|
`/voice` as the native command there. Text `/tts ...` still works.
|
||||||
|
|
||||||
```
|
```
|
||||||
/tts on
|
|
||||||
/tts off
|
/tts off
|
||||||
|
/tts always
|
||||||
|
/tts inbound
|
||||||
|
/tts tagged
|
||||||
/tts status
|
/tts status
|
||||||
/tts provider openai
|
/tts provider openai
|
||||||
/tts limit 2000
|
/tts limit 2000
|
||||||
@@ -350,6 +368,7 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
|
|||||||
Notes:
|
Notes:
|
||||||
- Commands require an authorized sender (allowlist/owner rules still apply).
|
- Commands require an authorized sender (allowlist/owner rules still apply).
|
||||||
- `commands.text` or native command registration must be enabled.
|
- `commands.text` or native command registration must be enabled.
|
||||||
|
- `off|always|inbound|tagged` are per‑session toggles (`/tts on` is an alias for `/tts always`).
|
||||||
- `limit` and `summary` are stored in local prefs, not the main config.
|
- `limit` and `summary` are stored in local prefs, not the main config.
|
||||||
- `/tts audio` generates a one-off audio reply (does not toggle TTS on).
|
- `/tts audio` generates a one-off audio reply (does not toggle TTS on).
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,14 @@ Other Gateway slash commands (for example, `/context`) are forwarded to the Gate
|
|||||||
- `--deliver`: Deliver assistant replies to the provider (default off)
|
- `--deliver`: Deliver assistant replies to the provider (default off)
|
||||||
- `--thinking <level>`: Override thinking level for sends
|
- `--thinking <level>`: Override thinking level for sends
|
||||||
- `--timeout-ms <ms>`: Agent timeout in ms (defaults to `agents.defaults.timeoutSeconds`)
|
- `--timeout-ms <ms>`: Agent timeout in ms (defaults to `agents.defaults.timeoutSeconds`)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
No output after sending a message:
|
||||||
|
- Run `/status` in the TUI to confirm the Gateway is connected and idle/busy.
|
||||||
|
- Check the Gateway logs: `clawdbot logs --follow`.
|
||||||
|
- Confirm the agent can run: `clawdbot status` and `clawdbot models status`.
|
||||||
|
- If you expect messages in a chat channel, enable delivery (`/deliver on` or `--deliver`).
|
||||||
- `--history-limit <n>`: History entries to load (default 200)
|
- `--history-limit <n>`: History entries to load (default 200)
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ import { resolveBlueBubblesMessageId } from "./monitor.js";
|
|||||||
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
||||||
import { sendMessageBlueBubbles } from "./send.js";
|
import { sendMessageBlueBubbles } from "./send.js";
|
||||||
import {
|
import {
|
||||||
|
extractHandleFromChatGuid,
|
||||||
looksLikeBlueBubblesTargetId,
|
looksLikeBlueBubblesTargetId,
|
||||||
normalizeBlueBubblesHandle,
|
normalizeBlueBubblesHandle,
|
||||||
normalizeBlueBubblesMessagingTarget,
|
normalizeBlueBubblesMessagingTarget,
|
||||||
|
parseBlueBubblesTarget,
|
||||||
} from "./targets.js";
|
} from "./targets.js";
|
||||||
import { bluebubblesMessageActions } from "./actions.js";
|
import { bluebubblesMessageActions } from "./actions.js";
|
||||||
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
||||||
@@ -148,6 +150,58 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
looksLikeId: looksLikeBlueBubblesTargetId,
|
looksLikeId: looksLikeBlueBubblesTargetId,
|
||||||
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
|
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: {
|
setup: {
|
||||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||||
|
|||||||
@@ -187,6 +187,47 @@ describe("send", () => {
|
|||||||
expect(result).toBe("iMessage;-;+15551234567");
|
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 () => {
|
it("returns null when chat not found", async () => {
|
||||||
mockFetch.mockResolvedValueOnce({
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -351,7 +392,7 @@ describe("send", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sendMessageBlueBubbles("+15559999999", "Hello", {
|
sendMessageBlueBubbles("chat_id:123", "Hello", {
|
||||||
serverUrl: "http://localhost:1234",
|
serverUrl: "http://localhost:1234",
|
||||||
password: "test",
|
password: "test",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -257,11 +257,17 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
return guid;
|
return guid;
|
||||||
}
|
}
|
||||||
if (!participantMatch && guid) {
|
if (!participantMatch && guid) {
|
||||||
const participants = extractParticipantAddresses(chat).map((entry) =>
|
// Only consider DM chats (`;-;` separator) as participant matches.
|
||||||
normalizeBlueBubblesHandle(entry),
|
// 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.
|
||||||
if (participants.includes(normalizedHandle)) {
|
const isDmChat = guid.includes(";-;");
|
||||||
participantMatch = guid;
|
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;
|
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(
|
export async function sendMessageBlueBubbles(
|
||||||
to: string,
|
to: string,
|
||||||
text: string,
|
text: string,
|
||||||
@@ -297,6 +352,17 @@ export async function sendMessageBlueBubbles(
|
|||||||
target,
|
target,
|
||||||
});
|
});
|
||||||
if (!chatGuid) {
|
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(
|
throw new Error(
|
||||||
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "line",
|
||||||
|
"channels": [
|
||||||
|
"line"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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
|
## 2026.1.23
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -75,6 +75,27 @@ Notes:
|
|||||||
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
|
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
|
||||||
- `mock` is a local dev provider (no network calls).
|
- `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
|
## CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -99,16 +99,39 @@
|
|||||||
"label": "Media Stream Path",
|
"label": "Media Stream Path",
|
||||||
"advanced": true
|
"advanced": true
|
||||||
},
|
},
|
||||||
"tts.model": {
|
"tts.provider": {
|
||||||
"label": "TTS Model",
|
"label": "TTS Provider Override",
|
||||||
|
"help": "Deep-merges with messages.tts (Edge is ignored for calls).",
|
||||||
"advanced": true
|
"advanced": true
|
||||||
},
|
},
|
||||||
"tts.voice": {
|
"tts.openai.model": {
|
||||||
"label": "TTS Voice",
|
"label": "OpenAI TTS Model",
|
||||||
"advanced": true
|
"advanced": true
|
||||||
},
|
},
|
||||||
"tts.instructions": {
|
"tts.openai.voice": {
|
||||||
"label": "TTS Instructions",
|
"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
|
"advanced": true
|
||||||
},
|
},
|
||||||
"publicUrl": {
|
"publicUrl": {
|
||||||
@@ -370,20 +393,193 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"auto": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"off",
|
||||||
|
"always",
|
||||||
|
"inbound",
|
||||||
|
"tagged"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"final",
|
||||||
|
"all"
|
||||||
|
]
|
||||||
|
},
|
||||||
"provider": {
|
"provider": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"openai"
|
"openai",
|
||||||
|
"elevenlabs",
|
||||||
|
"edge"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"model": {
|
"summaryModel": {
|
||||||
"type": "string"
|
"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"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"instructions": {
|
"maxTextLength": {
|
||||||
"type": "string"
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"timeoutMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1000,
|
||||||
|
"maximum": 120000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -74,9 +74,26 @@ const voiceCallConfigSchema = {
|
|||||||
},
|
},
|
||||||
"streaming.sttModel": { label: "Realtime STT Model", advanced: true },
|
"streaming.sttModel": { label: "Realtime STT Model", advanced: true },
|
||||||
"streaming.streamPath": { label: "Media Stream Path", advanced: true },
|
"streaming.streamPath": { label: "Media Stream Path", advanced: true },
|
||||||
"tts.model": { label: "TTS Model", advanced: true },
|
"tts.provider": {
|
||||||
"tts.voice": { label: "TTS Voice", advanced: true },
|
label: "TTS Provider Override",
|
||||||
"tts.instructions": { label: "TTS Instructions", advanced: true },
|
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 },
|
publicUrl: { label: "Public Webhook URL", advanced: true },
|
||||||
skipSignatureVerification: {
|
skipSignatureVerification: {
|
||||||
label: "Skip Signature Verification",
|
label: "Skip Signature Verification",
|
||||||
@@ -161,6 +178,7 @@ const voiceCallPlugin = {
|
|||||||
runtimePromise = createVoiceCallRuntime({
|
runtimePromise = createVoiceCallRuntime({
|
||||||
config: cfg,
|
config: cfg,
|
||||||
coreConfig: api.config as CoreConfig,
|
coreConfig: api.config as CoreConfig,
|
||||||
|
ttsRuntime: api.runtime.tts,
|
||||||
logger: api.logger,
|
logger: api.logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,31 +82,82 @@ export const SttConfigSchema = z
|
|||||||
.default({ provider: "openai", model: "whisper-1" });
|
.default({ provider: "openai", model: "whisper-1" });
|
||||||
export type SttConfig = z.infer<typeof SttConfigSchema>;
|
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
|
export const TtsConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
/** TTS provider (currently only OpenAI supported) */
|
auto: TtsAutoSchema.optional(),
|
||||||
provider: z.literal("openai").default("openai"),
|
enabled: z.boolean().optional(),
|
||||||
/**
|
mode: TtsModeSchema.optional(),
|
||||||
* TTS model to use:
|
provider: TtsProviderSchema.optional(),
|
||||||
* - gpt-4o-mini-tts: newest, supports instructions for tone/style control (recommended)
|
summaryModel: z.string().optional(),
|
||||||
* - tts-1: lower latency
|
modelOverrides: z
|
||||||
* - tts-1-hd: higher quality
|
.object({
|
||||||
*/
|
enabled: z.boolean().optional(),
|
||||||
model: z.string().min(1).default("gpt-4o-mini-tts"),
|
allowText: z.boolean().optional(),
|
||||||
/**
|
allowProvider: z.boolean().optional(),
|
||||||
* Voice ID. For best quality, use marin or cedar.
|
allowVoice: z.boolean().optional(),
|
||||||
* All voices: alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse, marin, cedar
|
allowModelId: z.boolean().optional(),
|
||||||
*/
|
allowVoiceSettings: z.boolean().optional(),
|
||||||
voice: z.string().min(1).default("coral"),
|
allowNormalization: z.boolean().optional(),
|
||||||
/**
|
allowSeed: z.boolean().optional(),
|
||||||
* Instructions for speech style (only works with gpt-4o-mini-tts).
|
})
|
||||||
* Examples: "Speak in a cheerful tone", "Talk like a sympathetic customer service agent"
|
.strict()
|
||||||
*/
|
.optional(),
|
||||||
instructions: z.string().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()
|
.strict()
|
||||||
.default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
|
.optional();
|
||||||
export type TtsConfig = z.infer<typeof TtsConfigSchema>;
|
export type VoiceCallTtsConfig = z.infer<typeof TtsConfigSchema>;
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Webhook Server Configuration
|
// Webhook Server Configuration
|
||||||
@@ -307,7 +358,7 @@ export const VoiceCallConfigSchema = z
|
|||||||
/** STT configuration */
|
/** STT configuration */
|
||||||
stt: SttConfigSchema,
|
stt: SttConfigSchema,
|
||||||
|
|
||||||
/** TTS configuration */
|
/** TTS override (deep-merges with core messages.tts) */
|
||||||
tts: TtsConfigSchema,
|
tts: TtsConfigSchema,
|
||||||
|
|
||||||
/** Store path for call logs */
|
/** Store path for call logs */
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
import type { VoiceCallTtsConfig } from "./config.js";
|
||||||
|
|
||||||
export type CoreConfig = {
|
export type CoreConfig = {
|
||||||
session?: {
|
session?: {
|
||||||
store?: string;
|
store?: string;
|
||||||
};
|
};
|
||||||
|
messages?: {
|
||||||
|
tts?: VoiceCallTtsConfig;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CoreAgentDeps = {
|
type CoreAgentDeps = {
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export class CallManager {
|
|||||||
// For notify mode with a message, use inline TwiML with <Say>
|
// For notify mode with a message, use inline TwiML with <Say>
|
||||||
let inlineTwiml: string | undefined;
|
let inlineTwiml: string | undefined;
|
||||||
if (mode === "notify" && initialMessage) {
|
if (mode === "notify" && initialMessage) {
|
||||||
const pollyVoice = mapVoiceToPolly(this.config.tts.voice);
|
const pollyVoice = mapVoiceToPolly(this.config.tts?.openai?.voice);
|
||||||
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
|
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
|
||||||
console.log(
|
console.log(
|
||||||
`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
|
`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
|
||||||
@@ -210,11 +210,13 @@ export class CallManager {
|
|||||||
this.addTranscriptEntry(call, "bot", text);
|
this.addTranscriptEntry(call, "bot", text);
|
||||||
|
|
||||||
// Play TTS
|
// Play TTS
|
||||||
|
const voice =
|
||||||
|
this.provider?.name === "twilio" ? this.config.tts?.openai?.voice : undefined;
|
||||||
await this.provider.playTts({
|
await this.provider.playTts({
|
||||||
callId,
|
callId,
|
||||||
providerCallId: call.providerCallId,
|
providerCallId: call.providerCallId,
|
||||||
text,
|
text,
|
||||||
voice: this.config.tts.voice,
|
voice,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export async function initiateCall(
|
|||||||
// For notify mode with a message, use inline TwiML with <Say>.
|
// For notify mode with a message, use inline TwiML with <Say>.
|
||||||
let inlineTwiml: string | undefined;
|
let inlineTwiml: string | undefined;
|
||||||
if (mode === "notify" && initialMessage) {
|
if (mode === "notify" && initialMessage) {
|
||||||
const pollyVoice = mapVoiceToPolly(ctx.config.tts.voice);
|
const pollyVoice = mapVoiceToPolly(ctx.config.tts?.openai?.voice);
|
||||||
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
|
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
|
||||||
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${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);
|
addTranscriptEntry(call, "bot", text);
|
||||||
|
|
||||||
|
const voice =
|
||||||
|
ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
|
||||||
await ctx.provider.playTts({
|
await ctx.provider.playTts({
|
||||||
callId,
|
callId,
|
||||||
providerCallId: call.providerCallId,
|
providerCallId: call.providerCallId,
|
||||||
text,
|
text,
|
||||||
voice: ctx.config.tts.voice,
|
voice,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -244,4 +246,3 @@ export async function endCall(
|
|||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,9 +15,9 @@ import type {
|
|||||||
WebhookVerificationResult,
|
WebhookVerificationResult,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.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 { VoiceCallProvider } from "./base.js";
|
||||||
import type { OpenAITTSProvider } from "./tts-openai.js";
|
|
||||||
import { chunkAudio } from "./tts-openai.js";
|
|
||||||
import { twilioApiRequest } from "./twilio/api.js";
|
import { twilioApiRequest } from "./twilio/api.js";
|
||||||
import { verifyTwilioProviderWebhook } from "./twilio/webhook.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) */
|
/** Current public webhook URL (set when tunnel starts or from config) */
|
||||||
private currentPublicUrl: string | null = null;
|
private currentPublicUrl: string | null = null;
|
||||||
|
|
||||||
/** Optional OpenAI TTS provider for streaming TTS */
|
/** Optional telephony TTS provider for streaming TTS */
|
||||||
private ttsProvider: OpenAITTSProvider | null = null;
|
private ttsProvider: TelephonyTtsProvider | null = null;
|
||||||
|
|
||||||
/** Optional media stream handler for sending audio */
|
/** Optional media stream handler for sending audio */
|
||||||
private mediaStreamHandler: MediaStreamHandler | null = null;
|
private mediaStreamHandler: MediaStreamHandler | null = null;
|
||||||
@@ -119,7 +119,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
return this.currentPublicUrl;
|
return this.currentPublicUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTTSProvider(provider: OpenAITTSProvider): void {
|
setTTSProvider(provider: TelephonyTtsProvider): void {
|
||||||
this.ttsProvider = provider;
|
this.ttsProvider = provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,13 +454,13 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
* Play TTS audio via Twilio.
|
* Play TTS audio via Twilio.
|
||||||
*
|
*
|
||||||
* Two modes:
|
* Two modes:
|
||||||
* 1. OpenAI TTS + Media Streams: If TTS provider and media stream are available,
|
* 1. Core TTS + Media Streams: If TTS provider and media stream are available,
|
||||||
* generates audio via OpenAI and streams it through WebSocket (preferred).
|
* generates audio via core TTS and streams it through WebSocket (preferred).
|
||||||
* 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices.
|
* 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices.
|
||||||
* Note: This may not work on all Twilio accounts.
|
* Note: This may not work on all Twilio accounts.
|
||||||
*/
|
*/
|
||||||
async playTts(input: PlayTtsInput): Promise<void> {
|
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);
|
const streamSid = this.callStreamMap.get(input.providerCallId);
|
||||||
if (this.ttsProvider && this.mediaStreamHandler && streamSid) {
|
if (this.ttsProvider && this.mediaStreamHandler && streamSid) {
|
||||||
try {
|
try {
|
||||||
@@ -468,7 +468,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
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,
|
err instanceof Error ? err.message : err,
|
||||||
);
|
);
|
||||||
// Fall through to TwiML <Say> fallback
|
// Fall through to TwiML <Say> fallback
|
||||||
@@ -484,7 +484,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.warn(
|
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);
|
const pollyVoice = mapVoiceToPolly(input.voice);
|
||||||
@@ -502,8 +502,8 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Play TTS via OpenAI and Twilio Media Streams.
|
* Play TTS via core TTS and Twilio Media Streams.
|
||||||
* Generates audio with OpenAI TTS, converts to mu-law, and streams via WebSocket.
|
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
|
||||||
* Uses a jitter buffer to smooth out timing variations.
|
* Uses a jitter buffer to smooth out timing variations.
|
||||||
*/
|
*/
|
||||||
private async playTtsViaStream(
|
private async playTtsViaStream(
|
||||||
@@ -514,8 +514,8 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
throw new Error("TTS provider and media stream handler required");
|
throw new Error("TTS provider and media stream handler required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate audio with OpenAI TTS (returns mu-law at 8kHz)
|
// Generate audio with core TTS (returns mu-law at 8kHz)
|
||||||
const muLawAudio = await this.ttsProvider.synthesizeForTwilio(text);
|
const muLawAudio = await this.ttsProvider.synthesizeForTelephony(text);
|
||||||
|
|
||||||
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
|
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
|
||||||
const CHUNK_SIZE = 160;
|
const CHUNK_SIZE = 160;
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import type { VoiceCallProvider } from "./providers/base.js";
|
|||||||
import { MockProvider } from "./providers/mock.js";
|
import { MockProvider } from "./providers/mock.js";
|
||||||
import { PlivoProvider } from "./providers/plivo.js";
|
import { PlivoProvider } from "./providers/plivo.js";
|
||||||
import { TelnyxProvider } from "./providers/telnyx.js";
|
import { TelnyxProvider } from "./providers/telnyx.js";
|
||||||
import { OpenAITTSProvider } from "./providers/tts-openai.js";
|
|
||||||
import { TwilioProvider } from "./providers/twilio.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 { startTunnel, type TunnelResult } from "./tunnel.js";
|
||||||
import {
|
import {
|
||||||
cleanupTailscaleExposure,
|
cleanupTailscaleExposure,
|
||||||
@@ -81,9 +82,10 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
|
|||||||
export async function createVoiceCallRuntime(params: {
|
export async function createVoiceCallRuntime(params: {
|
||||||
config: VoiceCallConfig;
|
config: VoiceCallConfig;
|
||||||
coreConfig: CoreConfig;
|
coreConfig: CoreConfig;
|
||||||
|
ttsRuntime?: TelephonyTtsRuntime;
|
||||||
logger?: Logger;
|
logger?: Logger;
|
||||||
}): Promise<VoiceCallRuntime> {
|
}): Promise<VoiceCallRuntime> {
|
||||||
const { config, coreConfig, logger } = params;
|
const { config, coreConfig, ttsRuntime, logger } = params;
|
||||||
const log = logger ?? {
|
const log = logger ?? {
|
||||||
info: console.log,
|
info: console.log,
|
||||||
warn: console.warn,
|
warn: console.warn,
|
||||||
@@ -149,27 +151,24 @@ export async function createVoiceCallRuntime(params: {
|
|||||||
|
|
||||||
if (provider.name === "twilio" && config.streaming?.enabled) {
|
if (provider.name === "twilio" && config.streaming?.enabled) {
|
||||||
const twilioProvider = provider as TwilioProvider;
|
const twilioProvider = provider as TwilioProvider;
|
||||||
const openaiApiKey =
|
if (ttsRuntime?.textToSpeechTelephony) {
|
||||||
config.streaming.openaiApiKey || process.env.OPENAI_API_KEY;
|
|
||||||
if (openaiApiKey) {
|
|
||||||
try {
|
try {
|
||||||
const ttsProvider = new OpenAITTSProvider({
|
const ttsProvider = createTelephonyTtsProvider({
|
||||||
apiKey: openaiApiKey,
|
coreConfig,
|
||||||
voice: config.tts.voice,
|
ttsOverride: config.tts,
|
||||||
model: config.tts.model,
|
runtime: ttsRuntime,
|
||||||
instructions: config.tts.instructions,
|
|
||||||
});
|
});
|
||||||
twilioProvider.setTTSProvider(ttsProvider);
|
twilioProvider.setTTSProvider(ttsProvider);
|
||||||
log.info("[voice-call] OpenAI TTS provider configured");
|
log.info("[voice-call] Telephony TTS provider configured");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn(
|
log.warn(
|
||||||
`[voice-call] Failed to initialize OpenAI TTS: ${
|
`[voice-call] Failed to initialize telephony TTS: ${
|
||||||
err instanceof Error ? err.message : String(err)
|
err instanceof Error ? err.message : String(err)
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} 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();
|
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);
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"dist/signal/**",
|
"dist/signal/**",
|
||||||
"dist/slack/**",
|
"dist/slack/**",
|
||||||
"dist/telegram/**",
|
"dist/telegram/**",
|
||||||
|
"dist/line/**",
|
||||||
"dist/tui/**",
|
"dist/tui/**",
|
||||||
"dist/tts/**",
|
"dist/tts/**",
|
||||||
"dist/web/**",
|
"dist/web/**",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"@grammyjs/runner": "^2.0.3",
|
"@grammyjs/runner": "^2.0.3",
|
||||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||||
"@homebridge/ciao": "^1.3.4",
|
"@homebridge/ciao": "^1.3.4",
|
||||||
|
"@line/bot-sdk": "^10.6.0",
|
||||||
"@lydell/node-pty": "1.2.0-beta.3",
|
"@lydell/node-pty": "1.2.0-beta.3",
|
||||||
"@mariozechner/pi-agent-core": "0.49.3",
|
"@mariozechner/pi-agent-core": "0.49.3",
|
||||||
"@mariozechner/pi-ai": "0.49.3",
|
"@mariozechner/pi-ai": "0.49.3",
|
||||||
|
|||||||
Generated
+28
@@ -34,6 +34,9 @@ importers:
|
|||||||
'@homebridge/ciao':
|
'@homebridge/ciao':
|
||||||
specifier: ^1.3.4
|
specifier: ^1.3.4
|
||||||
version: 1.3.4
|
version: 1.3.4
|
||||||
|
'@line/bot-sdk':
|
||||||
|
specifier: ^10.6.0
|
||||||
|
version: 10.6.0
|
||||||
'@lydell/node-pty':
|
'@lydell/node-pty':
|
||||||
specifier: 1.2.0-beta.3
|
specifier: 1.2.0-beta.3
|
||||||
version: 1.2.0-beta.3
|
version: 1.2.0-beta.3
|
||||||
@@ -317,6 +320,12 @@ importers:
|
|||||||
|
|
||||||
extensions/imessage: {}
|
extensions/imessage: {}
|
||||||
|
|
||||||
|
extensions/line:
|
||||||
|
devDependencies:
|
||||||
|
clawdbot:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../..
|
||||||
|
|
||||||
extensions/llm-task: {}
|
extensions/llm-task: {}
|
||||||
|
|
||||||
extensions/lobster: {}
|
extensions/lobster: {}
|
||||||
@@ -1260,6 +1269,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
apache-arrow: '>=15.0.0 <=18.1.0'
|
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':
|
'@lit-labs/signals@0.2.0':
|
||||||
resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==}
|
resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==}
|
||||||
|
|
||||||
@@ -2647,6 +2660,9 @@ packages:
|
|||||||
'@types/node@20.19.30':
|
'@types/node@20.19.30':
|
||||||
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
||||||
|
|
||||||
|
'@types/node@24.10.9':
|
||||||
|
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
|
||||||
|
|
||||||
'@types/node@25.0.10':
|
'@types/node@25.0.10':
|
||||||
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
|
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
|
||||||
|
|
||||||
@@ -6721,6 +6737,14 @@ snapshots:
|
|||||||
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
||||||
'@lancedb/lancedb-win32-x64-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':
|
'@lit-labs/signals@0.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
lit: 3.3.2
|
lit: 3.3.2
|
||||||
@@ -8298,6 +8322,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/node@24.10.9':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 7.16.0
|
||||||
|
|
||||||
'@types/node@25.0.10':
|
'@types/node@25.0.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
|
|||||||
@@ -23,11 +23,14 @@ const serialRuns = runs.filter((entry) => entry.name === "gateway");
|
|||||||
|
|
||||||
const children = new Set();
|
const children = new Set();
|
||||||
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
|
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
|
||||||
|
const isMacOS = process.platform === "darwin" || process.env.RUNNER_OS === "macOS";
|
||||||
const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10);
|
const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10);
|
||||||
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
||||||
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||||
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
||||||
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 = [
|
const WARNING_SUPPRESSION_FLAGS = [
|
||||||
"--disable-warning=ExperimentalWarning",
|
"--disable-warning=ExperimentalWarning",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
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 path from "node:path";
|
||||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "../infra/shell-env.js";
|
} from "../infra/shell-env.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { logInfo, logWarn } from "../logger.js";
|
import { logInfo, logWarn } from "../logger.js";
|
||||||
|
import { formatSpawnError, spawnWithFallback } from "../process/spawn-utils.js";
|
||||||
import {
|
import {
|
||||||
type ProcessSession,
|
type ProcessSession,
|
||||||
type SessionStdin,
|
type SessionStdin,
|
||||||
@@ -362,23 +363,38 @@ async function runExecProcess(opts: {
|
|||||||
let stdin: SessionStdin | undefined;
|
let stdin: SessionStdin | undefined;
|
||||||
|
|
||||||
if (opts.sandbox) {
|
if (opts.sandbox) {
|
||||||
child = spawn(
|
const { child: spawned } = await spawnWithFallback({
|
||||||
"docker",
|
argv: [
|
||||||
buildDockerExecArgs({
|
"docker",
|
||||||
containerName: opts.sandbox.containerName,
|
...buildDockerExecArgs({
|
||||||
command: opts.command,
|
containerName: opts.sandbox.containerName,
|
||||||
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
|
command: opts.command,
|
||||||
env: opts.env,
|
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
|
||||||
tty: opts.usePty,
|
env: opts.env,
|
||||||
}),
|
tty: opts.usePty,
|
||||||
{
|
}),
|
||||||
|
],
|
||||||
|
options: {
|
||||||
cwd: opts.workdir,
|
cwd: opts.workdir,
|
||||||
env: process.env,
|
env: process.env,
|
||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
windowsHide: true,
|
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;
|
stdin = child.stdin;
|
||||||
} else if (opts.usePty) {
|
} else if (opts.usePty) {
|
||||||
const { shell, args: shellArgs } = getShellConfig();
|
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}\`.`;
|
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}".`);
|
logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`);
|
||||||
opts.warnings.push(warning);
|
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,
|
cwd: opts.workdir,
|
||||||
env: opts.env,
|
env: opts.env,
|
||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
}) as ChildProcessWithoutNullStreams;
|
},
|
||||||
stdin = child.stdin;
|
fallbacks: [
|
||||||
}
|
{
|
||||||
} else {
|
label: "no-detach",
|
||||||
const { shell, args: shellArgs } = getShellConfig();
|
options: { detached: false },
|
||||||
child = spawn(shell, [...shellArgs, opts.command], {
|
},
|
||||||
cwd: opts.workdir,
|
],
|
||||||
env: opts.env,
|
onFallback: (err, fallback) => {
|
||||||
detached: process.platform !== "win32",
|
const errText = formatSpawnError(err);
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`;
|
||||||
windowsHide: true,
|
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`);
|
||||||
}) as ChildProcessWithoutNullStreams;
|
opts.warnings.push(warning);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
child = spawned as ChildProcessWithoutNullStreams;
|
||||||
stdin = child.stdin;
|
stdin = child.stdin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type ModelCatalogEntry = {
|
|||||||
provider: string;
|
provider: string;
|
||||||
contextWindow?: number;
|
contextWindow?: number;
|
||||||
reasoning?: boolean;
|
reasoning?: boolean;
|
||||||
|
input?: Array<"text" | "image">;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DiscoveredModel = {
|
type DiscoveredModel = {
|
||||||
@@ -16,6 +17,7 @@ type DiscoveredModel = {
|
|||||||
provider: string;
|
provider: string;
|
||||||
contextWindow?: number;
|
contextWindow?: number;
|
||||||
reasoning?: boolean;
|
reasoning?: boolean;
|
||||||
|
input?: Array<"text" | "image">;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PiSdkModule = typeof import("@mariozechner/pi-coding-agent");
|
type PiSdkModule = typeof import("@mariozechner/pi-coding-agent");
|
||||||
@@ -80,7 +82,10 @@ export async function loadModelCatalog(params?: {
|
|||||||
? entry.contextWindow
|
? entry.contextWindow
|
||||||
: undefined;
|
: undefined;
|
||||||
const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : 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) {
|
if (models.length === 0) {
|
||||||
@@ -105,3 +110,27 @@ export async function loadModelCatalog(params?: {
|
|||||||
|
|
||||||
return modelCatalogPromise;
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
+20
@@ -121,6 +121,26 @@ describe("getDmHistoryLimitFromSessionKey", () => {
|
|||||||
} as ClawdbotConfig;
|
} as ClawdbotConfig;
|
||||||
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123", config)).toBe(10);
|
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123", config)).toBe(10);
|
||||||
});
|
});
|
||||||
|
it("strips thread suffix from dm session keys", () => {
|
||||||
|
const config = {
|
||||||
|
channels: { telegram: { dmHistoryLimit: 10, dms: { "123": { historyLimit: 7 } } } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123:thread:999", config)).toBe(
|
||||||
|
7,
|
||||||
|
);
|
||||||
|
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123:topic:555", config)).toBe(7);
|
||||||
|
expect(getDmHistoryLimitFromSessionKey("telegram:dm:123:thread:999", config)).toBe(7);
|
||||||
|
});
|
||||||
|
it("keeps non-numeric thread markers in dm ids", () => {
|
||||||
|
const config = {
|
||||||
|
channels: {
|
||||||
|
telegram: { dms: { "user:thread:abc": { historyLimit: 9 } } },
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:user:thread:abc", config)).toBe(
|
||||||
|
9,
|
||||||
|
);
|
||||||
|
});
|
||||||
it("returns undefined for non-dm session kinds", () => {
|
it("returns undefined for non-dm session kinds", () => {
|
||||||
const config = {
|
const config = {
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
|
|||||||
},
|
},
|
||||||
streamSimple: (model: { api: string; provider: string; id: string }) => {
|
streamSimple: (model: { api: string; provider: string; id: string }) => {
|
||||||
const stream = new actual.AssistantMessageEventStream();
|
const stream = new actual.AssistantMessageEventStream();
|
||||||
setTimeout(() => {
|
queueMicrotask(() => {
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "done",
|
type: "done",
|
||||||
reason: "stop",
|
reason: "stop",
|
||||||
@@ -80,7 +80,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
|
|||||||
: buildAssistantMessage(model),
|
: buildAssistantMessage(model),
|
||||||
});
|
});
|
||||||
stream.end();
|
stream.end();
|
||||||
}, 0);
|
});
|
||||||
return stream;
|
return stream;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -213,7 +213,7 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
|
|
||||||
itIfNotWin32(
|
itIfNotWin32(
|
||||||
"persists the first user message before assistant output",
|
"persists the first user message before assistant output",
|
||||||
{ timeout: 60_000 },
|
{ timeout: 120_000 },
|
||||||
async () => {
|
async () => {
|
||||||
const sessionFile = nextSessionFile();
|
const sessionFile = nextSessionFile();
|
||||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
|
||||||
|
const THREAD_SUFFIX_REGEX = /^(.*)(?::(?:thread|topic):\d+)$/i;
|
||||||
|
|
||||||
|
function stripThreadSuffix(value: string): string {
|
||||||
|
const match = value.match(THREAD_SUFFIX_REGEX);
|
||||||
|
return match?.[1] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Limits conversation history to the last N user turns (and their associated
|
* Limits conversation history to the last N user turns (and their associated
|
||||||
* assistant responses). This reduces token usage for long-running DM sessions.
|
* assistant responses). This reduces token usage for long-running DM sessions.
|
||||||
@@ -44,7 +51,8 @@ export function getDmHistoryLimitFromSessionKey(
|
|||||||
if (!provider) return undefined;
|
if (!provider) return undefined;
|
||||||
|
|
||||||
const kind = providerParts[1]?.toLowerCase();
|
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;
|
if (kind !== "dm") return undefined;
|
||||||
|
|
||||||
const getLimit = (
|
const getLimit = (
|
||||||
|
|||||||
@@ -333,7 +333,13 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
name: "message",
|
name: "message",
|
||||||
description,
|
description,
|
||||||
parameters: schema,
|
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 params = args as Record<string, unknown>;
|
||||||
const cfg = options?.config ?? loadConfig();
|
const cfg = options?.config ?? loadConfig();
|
||||||
const action = readStringParam(params, "action", {
|
const action = readStringParam(params, "action", {
|
||||||
@@ -366,6 +372,9 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
currentThreadTs: options?.currentThreadTs,
|
currentThreadTs: options?.currentThreadTs,
|
||||||
replyToMode: options?.replyToMode,
|
replyToMode: options?.replyToMode,
|
||||||
hasRepliedRef: options?.hasRepliedRef,
|
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;
|
: undefined;
|
||||||
|
|
||||||
@@ -379,6 +388,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
agentId: options?.agentSessionKey
|
agentId: options?.agentSessionKey
|
||||||
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
|
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
|
||||||
: undefined,
|
: undefined,
|
||||||
|
abortSignal: signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolResult = getToolResult(result);
|
const toolResult = getToolResult(result);
|
||||||
|
|||||||
@@ -252,9 +252,7 @@ function isValidIsoDate(value: string): boolean {
|
|||||||
|
|
||||||
const date = new Date(Date.UTC(year, month - 1, day));
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
return (
|
return (
|
||||||
date.getUTCFullYear() === year &&
|
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
||||||
date.getUTCMonth() === month - 1 &&
|
|
||||||
date.getUTCDate() === day
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
textAlias: "/context",
|
textAlias: "/context",
|
||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
}),
|
}),
|
||||||
|
defineChatCommand({
|
||||||
|
key: "tts",
|
||||||
|
nativeName: "tts",
|
||||||
|
description: "Configure text-to-speech.",
|
||||||
|
textAlias: "/tts",
|
||||||
|
acceptsArgs: true,
|
||||||
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "whoami",
|
key: "whoami",
|
||||||
nativeName: "whoami",
|
nativeName: "whoami",
|
||||||
@@ -279,27 +286,6 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
],
|
],
|
||||||
argsMenu: "auto",
|
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({
|
defineChatCommand({
|
||||||
key: "stop",
|
key: "stop",
|
||||||
nativeName: "stop",
|
nativeName: "stop",
|
||||||
|
|||||||
@@ -10,11 +10,17 @@ describe("extractModelDirective", () => {
|
|||||||
expect(result.cleaned).toBe("");
|
expect(result.cleaned).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts /models with argument", () => {
|
it("does not treat /models as a /model directive", () => {
|
||||||
const result = extractModelDirective("/models gpt-5");
|
const result = extractModelDirective("/models gpt-5");
|
||||||
expect(result.hasDirective).toBe(true);
|
expect(result.hasDirective).toBe(false);
|
||||||
expect(result.rawModel).toBe("gpt-5");
|
expect(result.rawModel).toBeUndefined();
|
||||||
expect(result.cleaned).toBe("");
|
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", () => {
|
it("extracts /model with provider/model format", () => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function extractModelDirective(
|
|||||||
if (!body) return { cleaned: "", hasDirective: false };
|
if (!body) return { cleaned: "", hasDirective: false };
|
||||||
|
|
||||||
const modelMatch = body.match(
|
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);
|
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
|
|||||||
*/
|
*/
|
||||||
export const handlePluginCommand: CommandHandler = async (
|
export const handlePluginCommand: CommandHandler = async (
|
||||||
params,
|
params,
|
||||||
_allowTextCommands,
|
allowTextCommands,
|
||||||
): Promise<CommandHandlerResult | null> => {
|
): Promise<CommandHandlerResult | null> => {
|
||||||
const { command, cfg } = params;
|
const { command, cfg } = params;
|
||||||
|
|
||||||
|
if (!allowTextCommands) return null;
|
||||||
|
|
||||||
// Try to match a plugin command
|
// Try to match a plugin command
|
||||||
const match = matchPluginCommand(command.commandBodyNormalized);
|
const match = matchPluginCommand(command.commandBodyNormalized);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
@@ -36,6 +38,6 @@ export const handlePluginCommand: CommandHandler = async (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
shouldContinue: false,
|
shouldContinue: false,
|
||||||
reply: { text: result.text },
|
reply: result,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -6,19 +6,20 @@ import {
|
|||||||
getTtsMaxLength,
|
getTtsMaxLength,
|
||||||
getTtsProvider,
|
getTtsProvider,
|
||||||
isSummarizationEnabled,
|
isSummarizationEnabled,
|
||||||
isTtsEnabled,
|
|
||||||
isTtsProviderConfigured,
|
isTtsProviderConfigured,
|
||||||
|
normalizeTtsAutoMode,
|
||||||
|
resolveTtsAutoMode,
|
||||||
resolveTtsApiKey,
|
resolveTtsApiKey,
|
||||||
resolveTtsConfig,
|
resolveTtsConfig,
|
||||||
resolveTtsPrefsPath,
|
resolveTtsPrefsPath,
|
||||||
resolveTtsProviderOrder,
|
resolveTtsProviderOrder,
|
||||||
setLastTtsAttempt,
|
setLastTtsAttempt,
|
||||||
setSummarizationEnabled,
|
setSummarizationEnabled,
|
||||||
setTtsEnabled,
|
|
||||||
setTtsMaxLength,
|
setTtsMaxLength,
|
||||||
setTtsProvider,
|
setTtsProvider,
|
||||||
textToSpeech,
|
textToSpeech,
|
||||||
} from "../../tts/tts.js";
|
} from "../../tts/tts.js";
|
||||||
|
import { updateSessionStore } from "../../config/sessions.js";
|
||||||
|
|
||||||
type ParsedTtsCommand = {
|
type ParsedTtsCommand = {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -39,9 +40,9 @@ function ttsUsage(): ReplyPayload {
|
|||||||
// Keep usage in one place so help/validation stays consistent.
|
// Keep usage in one place so help/validation stays consistent.
|
||||||
return {
|
return {
|
||||||
text:
|
text:
|
||||||
"⚙️ Usage: /tts <on|off|status|provider|limit|summary|audio> [value]" +
|
"⚙️ Usage: /tts <off|always|inbound|tagged|status|provider|limit|summary|audio> [value]" +
|
||||||
"\nExamples:\n" +
|
"\nExamples:\n" +
|
||||||
"/tts on\n" +
|
"/tts always\n" +
|
||||||
"/tts provider openai\n" +
|
"/tts provider openai\n" +
|
||||||
"/tts provider edge\n" +
|
"/tts provider edge\n" +
|
||||||
"/tts limit 2000\n" +
|
"/tts limit 2000\n" +
|
||||||
@@ -71,14 +72,30 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
|||||||
return { shouldContinue: false, reply: ttsUsage() };
|
return { shouldContinue: false, reply: ttsUsage() };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "on") {
|
const requestedAuto = normalizeTtsAutoMode(
|
||||||
setTtsEnabled(prefsPath, true);
|
action === "on" ? "always" : action === "off" ? "off" : action,
|
||||||
return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
|
);
|
||||||
}
|
if (requestedAuto) {
|
||||||
|
const entry = params.sessionEntry;
|
||||||
if (action === "off") {
|
const sessionKey = params.sessionKey;
|
||||||
setTtsEnabled(prefsPath, false);
|
const store = params.sessionStore;
|
||||||
return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
|
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") {
|
if (action === "audio") {
|
||||||
@@ -212,7 +229,9 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "status") {
|
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 provider = getTtsProvider(config, prefsPath);
|
||||||
const hasKey = isTtsProviderConfigured(config, provider);
|
const hasKey = isTtsProviderConfigured(config, provider);
|
||||||
const providerStatus =
|
const providerStatus =
|
||||||
@@ -226,9 +245,10 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
|||||||
const maxLength = getTtsMaxLength(prefsPath);
|
const maxLength = getTtsMaxLength(prefsPath);
|
||||||
const summarize = isSummarizationEnabled(prefsPath);
|
const summarize = isSummarizationEnabled(prefsPath);
|
||||||
const last = getLastTtsAttempt();
|
const last = getLastTtsAttempt();
|
||||||
|
const autoLabel = sessionAuto ? `${autoMode} (session)` : autoMode;
|
||||||
const lines = [
|
const lines = [
|
||||||
"📊 TTS status",
|
"📊 TTS status",
|
||||||
`State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
|
`Auto: ${enabled ? autoLabel : "off"}`,
|
||||||
`Provider: ${provider} (${providerStatus})`,
|
`Provider: ${provider} (${providerStatus})`,
|
||||||
`Text limit: ${maxLength} chars`,
|
`Text limit: ${maxLength} chars`,
|
||||||
`Auto-summary: ${summarize ? "on" : "off"}`,
|
`Auto-summary: ${summarize ? "on" : "off"}`,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "../../agents/subagent-registry.js";
|
} from "../../agents/subagent-registry.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import * as internalHooks from "../../hooks/internal-hooks.js";
|
import * as internalHooks from "../../hooks/internal-hooks.js";
|
||||||
|
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
|
||||||
import type { MsgContext } from "../templating.js";
|
import type { MsgContext } from "../templating.js";
|
||||||
import { resetBashChatCommandForTests } from "./bash-command.js";
|
import { resetBashChatCommandForTests } from "./bash-command.js";
|
||||||
import { buildCommandContext, handleCommands } from "./commands.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", () => {
|
describe("handleCommands identity", () => {
|
||||||
it("returns sender details for /whoami", async () => {
|
it("returns sender details for /whoami", async () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { ReasoningLevel } from "../thinking.js";
|
import type { NoticeLevel, ReasoningLevel } from "../thinking.js";
|
||||||
import {
|
import {
|
||||||
type ElevatedLevel,
|
type ElevatedLevel,
|
||||||
normalizeElevatedLevel,
|
normalizeElevatedLevel,
|
||||||
|
normalizeNoticeLevel,
|
||||||
normalizeReasoningLevel,
|
normalizeReasoningLevel,
|
||||||
normalizeThinkLevel,
|
normalizeThinkLevel,
|
||||||
normalizeVerboseLevel,
|
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): {
|
export function extractElevatedDirective(body?: string): {
|
||||||
cleaned: string;
|
cleaned: string;
|
||||||
elevatedLevel?: ElevatedLevel;
|
elevatedLevel?: ElevatedLevel;
|
||||||
@@ -152,5 +169,5 @@ export function extractStatusDirective(body?: string): {
|
|||||||
return extractSimpleDirective(body, ["status"]);
|
return extractSimpleDirective(body, ["status"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
export type { ElevatedLevel, NoticeLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
||||||
export { extractExecDirective } from "./exec/directive.js";
|
export { extractExecDirective } from "./exec/directive.js";
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||||
|
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +16,55 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
|
|||||||
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
|
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
|
||||||
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
|
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
|
||||||
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
||||||
import { maybeApplyTtsToPayload } 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 = {
|
export type DispatchFromConfigResult = {
|
||||||
queuedFinal: boolean;
|
queuedFinal: boolean;
|
||||||
@@ -81,6 +131,8 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
|
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inboundAudio = isInboundAudioContext(ctx);
|
||||||
|
const sessionTtsAuto = resolveSessionTtsAuto(ctx, cfg);
|
||||||
const hookRunner = getGlobalHookRunner();
|
const hookRunner = getGlobalHookRunner();
|
||||||
if (hookRunner?.hasHooks("message_received")) {
|
if (hookRunner?.hasHooks("message_received")) {
|
||||||
const timestamp =
|
const timestamp =
|
||||||
@@ -223,6 +275,8 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
cfg,
|
cfg,
|
||||||
channel: ttsChannel,
|
channel: ttsChannel,
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
|
inboundAudio,
|
||||||
|
ttsAuto: sessionTtsAuto,
|
||||||
});
|
});
|
||||||
if (shouldRouteToOriginating) {
|
if (shouldRouteToOriginating) {
|
||||||
await sendPayloadAsync(ttsPayload);
|
await sendPayloadAsync(ttsPayload);
|
||||||
@@ -239,6 +293,8 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
cfg,
|
cfg,
|
||||||
channel: ttsChannel,
|
channel: ttsChannel,
|
||||||
kind: "block",
|
kind: "block",
|
||||||
|
inboundAudio,
|
||||||
|
ttsAuto: sessionTtsAuto,
|
||||||
});
|
});
|
||||||
if (shouldRouteToOriginating) {
|
if (shouldRouteToOriginating) {
|
||||||
await sendPayloadAsync(ttsPayload, context?.abortSignal);
|
await sendPayloadAsync(ttsPayload, context?.abortSignal);
|
||||||
@@ -262,6 +318,8 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
cfg,
|
cfg,
|
||||||
channel: ttsChannel,
|
channel: ttsChannel,
|
||||||
kind: "final",
|
kind: "final",
|
||||||
|
inboundAudio,
|
||||||
|
ttsAuto: sessionTtsAuto,
|
||||||
});
|
});
|
||||||
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
||||||
// Route final reply to originating channel.
|
// Route final reply to originating channel.
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
resolveResponsePrefixTemplate,
|
resolveResponsePrefixTemplate,
|
||||||
type ResponsePrefixContext,
|
type ResponsePrefixContext,
|
||||||
} from "./response-prefix-template.js";
|
} from "./response-prefix-template.js";
|
||||||
|
import { hasLineDirectives, parseLineDirectives } from "./line-directives.js";
|
||||||
|
|
||||||
export type NormalizeReplyOptions = {
|
export type NormalizeReplyOptions = {
|
||||||
responsePrefix?: string;
|
responsePrefix?: string;
|
||||||
@@ -21,13 +22,16 @@ export function normalizeReplyPayload(
|
|||||||
opts: NormalizeReplyOptions = {},
|
opts: NormalizeReplyOptions = {},
|
||||||
): ReplyPayload | null {
|
): ReplyPayload | null {
|
||||||
const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0);
|
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() ?? "";
|
const trimmed = payload.text?.trim() ?? "";
|
||||||
if (!trimmed && !hasMedia) return null;
|
if (!trimmed && !hasMedia && !hasChannelData) return null;
|
||||||
|
|
||||||
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
||||||
let text = payload.text ?? undefined;
|
let text = payload.text ?? undefined;
|
||||||
if (text && isSilentReplyText(text, silentToken)) {
|
if (text && isSilentReplyText(text, silentToken)) {
|
||||||
if (!hasMedia) return null;
|
if (!hasMedia && !hasChannelData) return null;
|
||||||
text = "";
|
text = "";
|
||||||
}
|
}
|
||||||
if (text && !trimmed) {
|
if (text && !trimmed) {
|
||||||
@@ -39,14 +43,21 @@ export function normalizeReplyPayload(
|
|||||||
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
|
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
|
||||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||||
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
||||||
if (stripped.shouldSkip && !hasMedia) return null;
|
if (stripped.shouldSkip && !hasMedia && !hasChannelData) return null;
|
||||||
text = stripped.text;
|
text = stripped.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text) {
|
if (text) {
|
||||||
text = sanitizeUserFacingText(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
|
// Resolve template variables in responsePrefix if context is provided
|
||||||
const effectivePrefix = opts.responsePrefixContext
|
const effectivePrefix = opts.responsePrefixContext
|
||||||
@@ -62,5 +73,5 @@ export function normalizeReplyPayload(
|
|||||||
text = `${effectivePrefix} ${text}`;
|
text = `${effectivePrefix} ${text}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...payload, text };
|
return { ...enrichedPayload, text };
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,8 @@ export function isRenderablePayload(payload: ReplyPayload): boolean {
|
|||||||
payload.text ||
|
payload.text ||
|
||||||
payload.mediaUrl ||
|
payload.mediaUrl ||
|
||||||
(payload.mediaUrls && payload.mediaUrls.length > 0) ||
|
(payload.mediaUrls && payload.mediaUrls.length > 0) ||
|
||||||
payload.audioAsVoice,
|
payload.audioAsVoice ||
|
||||||
|
payload.channelData,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
|||||||
providers: [],
|
providers: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpHandlers: [],
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
|||||||
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { TtsAutoMode } from "../../config/types.tts.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_RESET_TRIGGERS,
|
DEFAULT_RESET_TRIGGERS,
|
||||||
deriveSessionMetaPatch,
|
deriveSessionMetaPatch,
|
||||||
@@ -128,6 +129,7 @@ export async function initSessionState(params: {
|
|||||||
let persistedThinking: string | undefined;
|
let persistedThinking: string | undefined;
|
||||||
let persistedVerbose: string | undefined;
|
let persistedVerbose: string | undefined;
|
||||||
let persistedReasoning: string | undefined;
|
let persistedReasoning: string | undefined;
|
||||||
|
let persistedTtsAuto: TtsAutoMode | undefined;
|
||||||
let persistedModelOverride: string | undefined;
|
let persistedModelOverride: string | undefined;
|
||||||
let persistedProviderOverride: string | undefined;
|
let persistedProviderOverride: string | undefined;
|
||||||
|
|
||||||
@@ -220,6 +222,7 @@ export async function initSessionState(params: {
|
|||||||
persistedThinking = entry.thinkingLevel;
|
persistedThinking = entry.thinkingLevel;
|
||||||
persistedVerbose = entry.verboseLevel;
|
persistedVerbose = entry.verboseLevel;
|
||||||
persistedReasoning = entry.reasoningLevel;
|
persistedReasoning = entry.reasoningLevel;
|
||||||
|
persistedTtsAuto = entry.ttsAuto;
|
||||||
persistedModelOverride = entry.modelOverride;
|
persistedModelOverride = entry.modelOverride;
|
||||||
persistedProviderOverride = entry.providerOverride;
|
persistedProviderOverride = entry.providerOverride;
|
||||||
} else {
|
} else {
|
||||||
@@ -258,6 +261,7 @@ export async function initSessionState(params: {
|
|||||||
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
|
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
|
||||||
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
|
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
|
||||||
reasoningLevel: persistedReasoning ?? baseEntry?.reasoningLevel,
|
reasoningLevel: persistedReasoning ?? baseEntry?.reasoningLevel,
|
||||||
|
ttsAuto: persistedTtsAuto ?? baseEntry?.ttsAuto,
|
||||||
responseUsage: baseEntry?.responseUsage,
|
responseUsage: baseEntry?.responseUsage,
|
||||||
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
||||||
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
getTtsMaxLength,
|
getTtsMaxLength,
|
||||||
getTtsProvider,
|
getTtsProvider,
|
||||||
isSummarizationEnabled,
|
isSummarizationEnabled,
|
||||||
isTtsEnabled,
|
resolveTtsAutoMode,
|
||||||
resolveTtsConfig,
|
resolveTtsConfig,
|
||||||
resolveTtsPrefsPath,
|
resolveTtsPrefsPath,
|
||||||
} from "../tts/tts.js";
|
} from "../tts/tts.js";
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from "../utils/usage-format.js";
|
} from "../utils/usage-format.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
|
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
|
||||||
|
import { listPluginCommands } from "../plugins/commands.js";
|
||||||
import type { SkillCommandSpec } from "../agents/skills.js";
|
import type { SkillCommandSpec } from "../agents/skills.js";
|
||||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
|
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
|
||||||
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
|
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
|
||||||
@@ -252,15 +253,23 @@ const formatMediaUnderstandingLine = (decisions?: MediaUnderstandingDecision[])
|
|||||||
return `📎 Media: ${parts.join(" · ")}`;
|
return `📎 Media: ${parts.join(" · ")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatVoiceModeLine = (config?: ClawdbotConfig): string | null => {
|
const formatVoiceModeLine = (
|
||||||
|
config?: ClawdbotConfig,
|
||||||
|
sessionEntry?: SessionEntry,
|
||||||
|
): string | null => {
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
const ttsConfig = resolveTtsConfig(config);
|
const ttsConfig = resolveTtsConfig(config);
|
||||||
const prefsPath = resolveTtsPrefsPath(ttsConfig);
|
const prefsPath = resolveTtsPrefsPath(ttsConfig);
|
||||||
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 provider = getTtsProvider(ttsConfig, prefsPath);
|
||||||
const maxLength = getTtsMaxLength(prefsPath);
|
const maxLength = getTtsMaxLength(prefsPath);
|
||||||
const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
|
const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
|
||||||
return `🔊 Voice: on · provider=${provider} · limit=${maxLength} · summary=${summarize}`;
|
return `🔊 Voice: ${autoMode} · provider=${provider} · limit=${maxLength} · summary=${summarize}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildStatusMessage(args: StatusArgs): string {
|
export function buildStatusMessage(args: StatusArgs): string {
|
||||||
@@ -398,7 +407,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
const usageCostLine =
|
const usageCostLine =
|
||||||
usagePair && costLine ? `${usagePair} · ${costLine}` : (usagePair ?? costLine);
|
usagePair && costLine ? `${usagePair} · ${costLine}` : (usagePair ?? costLine);
|
||||||
const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions);
|
const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions);
|
||||||
const voiceLine = formatVoiceModeLine(args.config);
|
const voiceLine = formatVoiceModeLine(args.config, args.sessionEntry);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
versionLine,
|
versionLine,
|
||||||
@@ -465,5 +474,14 @@ export function buildCommandsMessage(
|
|||||||
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
|
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
|
||||||
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
|
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");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||||
export type VerboseLevel = "off" | "on" | "full";
|
export type VerboseLevel = "off" | "on" | "full";
|
||||||
|
export type NoticeLevel = "off" | "on" | "full";
|
||||||
export type ElevatedLevel = "off" | "on" | "ask" | "full";
|
export type ElevatedLevel = "off" | "on" | "ask" | "full";
|
||||||
export type ElevatedMode = "off" | "ask" | "full";
|
export type ElevatedMode = "off" | "ask" | "full";
|
||||||
export type ReasoningLevel = "off" | "on" | "stream";
|
export type ReasoningLevel = "off" | "on" | "stream";
|
||||||
@@ -93,6 +94,16 @@ export function normalizeVerboseLevel(raw?: string | null): VerboseLevel | undef
|
|||||||
return undefined;
|
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.
|
// Normalize response-usage display modes used to toggle per-response usage footers.
|
||||||
export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined {
|
export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined {
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
|
|||||||
@@ -52,4 +52,6 @@ export type ReplyPayload = {
|
|||||||
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
|
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
|
||||||
audioAsVoice?: boolean;
|
audioAsVoice?: boolean;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
/** Channel-specific payload data (per-channel envelope). */
|
||||||
|
channelData?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
@@ -13,6 +13,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
|||||||
providers: [],
|
providers: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpHandlers: [],
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
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 { GroupToolPolicyConfig } from "../../config/types.tools.js";
|
||||||
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
|
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
@@ -81,6 +82,10 @@ export type ChannelOutboundContext = {
|
|||||||
deps?: OutboundSendDeps;
|
deps?: OutboundSendDeps;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChannelOutboundPayloadContext = ChannelOutboundContext & {
|
||||||
|
payload: ReplyPayload;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChannelOutboundAdapter = {
|
export type ChannelOutboundAdapter = {
|
||||||
deliveryMode: "direct" | "gateway" | "hybrid";
|
deliveryMode: "direct" | "gateway" | "hybrid";
|
||||||
chunker?: ((text: string, limit: number) => string[]) | null;
|
chunker?: ((text: string, limit: number) => string[]) | null;
|
||||||
@@ -94,6 +99,7 @@ export type ChannelOutboundAdapter = {
|
|||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
mode?: ChannelOutboundTargetMode;
|
mode?: ChannelOutboundTargetMode;
|
||||||
}) => { ok: true; to: string } | { ok: false; error: Error };
|
}) => { ok: true; to: string } | { ok: false; error: Error };
|
||||||
|
sendPayload?: (ctx: ChannelOutboundPayloadContext) => Promise<OutboundDeliveryResult>;
|
||||||
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
||||||
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
||||||
sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;
|
sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;
|
||||||
|
|||||||
@@ -240,6 +240,12 @@ export type ChannelThreadingToolContext = {
|
|||||||
currentThreadTs?: string;
|
currentThreadTs?: string;
|
||||||
replyToMode?: "off" | "first" | "all";
|
replyToMode?: "off" | "first" | "all";
|
||||||
hasRepliedRef?: { value: boolean };
|
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 = {
|
export type ChannelMessagingAdapter = {
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
|||||||
providers: [],
|
providers: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpHandlers: [],
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
|
|||||||
@@ -138,6 +138,16 @@ describe("legacy config detection", () => {
|
|||||||
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
||||||
expect(res.config?.channels?.telegram?.requireMention).toBeUndefined();
|
expect(res.config?.channels?.telegram?.requireMention).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
it("migrates messages.tts.enabled to messages.tts.auto", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const { migrateLegacyConfig } = await import("./config.js");
|
||||||
|
const res = migrateLegacyConfig({
|
||||||
|
messages: { tts: { enabled: true } },
|
||||||
|
});
|
||||||
|
expect(res.changes).toContain("Moved messages.tts.enabled → messages.tts.auto (always).");
|
||||||
|
expect(res.config?.messages?.tts?.auto).toBe("always");
|
||||||
|
expect(res.config?.messages?.tts?.enabled).toBeUndefined();
|
||||||
|
});
|
||||||
it("migrates legacy model config to agent.models + model lists", async () => {
|
it("migrates legacy model config to agent.models + model lists", async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const { migrateLegacyConfig } = await import("./config.js");
|
const { migrateLegacyConfig } = await import("./config.js");
|
||||||
|
|||||||
@@ -40,6 +40,26 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
|||||||
delete tools.bash;
|
delete tools.bash;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "messages.tts.enabled->auto",
|
||||||
|
describe: "Move messages.tts.enabled to messages.tts.auto",
|
||||||
|
apply: (raw, changes) => {
|
||||||
|
const messages = getRecord(raw.messages);
|
||||||
|
const tts = getRecord(messages?.tts);
|
||||||
|
if (!tts) return;
|
||||||
|
if (tts.auto !== undefined) {
|
||||||
|
if ("enabled" in tts) {
|
||||||
|
delete tts.enabled;
|
||||||
|
changes.push("Removed messages.tts.enabled (messages.tts.auto already set).");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof tts.enabled !== "boolean") return;
|
||||||
|
tts.auto = tts.enabled ? "always" : "off";
|
||||||
|
delete tts.enabled;
|
||||||
|
changes.push(`Moved messages.tts.enabled → messages.tts.auto (${String(tts.auto)}).`);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "agent.defaults-v2",
|
id: "agent.defaults-v2",
|
||||||
describe: "Move agent config to agents.defaults and tools",
|
describe: "Move agent config to agents.defaults and tools",
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
|||||||
message:
|
message:
|
||||||
"agent.imageModelFallbacks was replaced by agents.defaults.imageModel.fallbacks (auto-migrated on load).",
|
"agent.imageModelFallbacks was replaced by agents.defaults.imageModel.fallbacks (auto-migrated on load).",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: ["messages", "tts", "enabled"],
|
||||||
|
message: "messages.tts.enabled was replaced by messages.tts.auto (auto-migrated on load).",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: ["gateway", "token"],
|
path: ["gateway", "token"],
|
||||||
message: "gateway.token is ignored; use gateway.auth.token instead (auto-migrated on load).",
|
message: "gateway.token is ignored; use gateway.auth.token instead (auto-migrated on load).",
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ export const CONFIG_PATH_CLAWDBOT = resolveConfigPath();
|
|||||||
|
|
||||||
export const DEFAULT_GATEWAY_PORT = 18789;
|
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";
|
const OAUTH_FILENAME = "oauth.json";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
|
|||||||
import type { NormalizedChatType } from "../../channels/chat-type.js";
|
import type { NormalizedChatType } from "../../channels/chat-type.js";
|
||||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||||
import type { DeliveryContext } from "../../utils/delivery-context.js";
|
import type { DeliveryContext } from "../../utils/delivery-context.js";
|
||||||
|
import type { TtsAutoMode } from "../types.tts.js";
|
||||||
|
|
||||||
export type SessionScope = "per-sender" | "global";
|
export type SessionScope = "per-sender" | "global";
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ export type SessionEntry = {
|
|||||||
verboseLevel?: string;
|
verboseLevel?: string;
|
||||||
reasoningLevel?: string;
|
reasoningLevel?: string;
|
||||||
elevatedLevel?: string;
|
elevatedLevel?: string;
|
||||||
|
ttsAuto?: TtsAutoMode;
|
||||||
execHost?: string;
|
execHost?: string;
|
||||||
execSecurity?: string;
|
execSecurity?: string;
|
||||||
execAsk?: string;
|
execAsk?: string;
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export type SignalAccountConfig = {
|
|||||||
cliPath?: string;
|
cliPath?: string;
|
||||||
/** Auto-start signal-cli daemon (default: true if httpUrl not set). */
|
/** Auto-start signal-cli daemon (default: true if httpUrl not set). */
|
||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
|
/** Max time to wait for signal-cli daemon startup (ms, cap 120000). */
|
||||||
|
startupTimeoutMs?: number;
|
||||||
receiveMode?: "on-start" | "manual";
|
receiveMode?: "on-start" | "manual";
|
||||||
ignoreAttachments?: boolean;
|
ignoreAttachments?: boolean;
|
||||||
ignoreStories?: boolean;
|
ignoreStories?: boolean;
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ export type TelegramAccountConfig = {
|
|||||||
reactionLevel?: "off" | "ack" | "minimal" | "extensive";
|
reactionLevel?: "off" | "ack" | "minimal" | "extensive";
|
||||||
/** Heartbeat visibility settings for this channel. */
|
/** Heartbeat visibility settings for this channel. */
|
||||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||||
|
/** Controls whether link previews are shown in outbound messages. Default: true. */
|
||||||
|
linkPreview?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TelegramTopicConfig = {
|
export type TelegramTopicConfig = {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export type TtsProvider = "elevenlabs" | "openai" | "edge";
|
|||||||
|
|
||||||
export type TtsMode = "final" | "all";
|
export type TtsMode = "final" | "all";
|
||||||
|
|
||||||
|
export type TtsAutoMode = "off" | "always" | "inbound" | "tagged";
|
||||||
|
|
||||||
export type TtsModelOverrideConfig = {
|
export type TtsModelOverrideConfig = {
|
||||||
/** Enable model-provided overrides for TTS. */
|
/** Enable model-provided overrides for TTS. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -22,7 +24,9 @@ export type TtsModelOverrideConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TtsConfig = {
|
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;
|
enabled?: boolean;
|
||||||
/** Apply TTS to final replies only or to all replies (tool/block/final). */
|
/** Apply TTS to final replies only or to all replies (tool/block/final). */
|
||||||
mode?: TtsMode;
|
mode?: TtsMode;
|
||||||
|
|||||||
@@ -158,8 +158,10 @@ export const MarkdownConfigSchema = z
|
|||||||
|
|
||||||
export const TtsProviderSchema = z.enum(["elevenlabs", "openai", "edge"]);
|
export const TtsProviderSchema = z.enum(["elevenlabs", "openai", "edge"]);
|
||||||
export const TtsModeSchema = z.enum(["final", "all"]);
|
export const TtsModeSchema = z.enum(["final", "all"]);
|
||||||
|
export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]);
|
||||||
export const TtsConfigSchema = z
|
export const TtsConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
auto: TtsAutoSchema.optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
mode: TtsModeSchema.optional(),
|
mode: TtsModeSchema.optional(),
|
||||||
provider: TtsProviderSchema.optional(),
|
provider: TtsProviderSchema.optional(),
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export const TelegramAccountSchemaBase = z
|
|||||||
reactionNotifications: z.enum(["off", "own", "all"]).optional(),
|
reactionNotifications: z.enum(["off", "own", "all"]).optional(),
|
||||||
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
|
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
|
||||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
|
linkPreview: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -486,6 +487,7 @@ export const SignalAccountSchemaBase = z
|
|||||||
httpPort: z.number().int().positive().optional(),
|
httpPort: z.number().int().positive().optional(),
|
||||||
cliPath: ExecutableTokenSchema.optional(),
|
cliPath: ExecutableTokenSchema.optional(),
|
||||||
autoStart: z.boolean().optional(),
|
autoStart: z.boolean().optional(),
|
||||||
|
startupTimeoutMs: z.number().int().min(1000).max(120000).optional(),
|
||||||
receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(),
|
receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(),
|
||||||
ignoreAttachments: z.boolean().optional(),
|
ignoreAttachments: z.boolean().optional(),
|
||||||
ignoreStories: z.boolean().optional(),
|
ignoreStories: z.boolean().optional(),
|
||||||
|
|||||||
@@ -136,9 +136,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
const forumParentSlug =
|
const forumParentSlug =
|
||||||
isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : "";
|
isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : "";
|
||||||
const threadChannelId = threadChannel?.id;
|
const threadChannelId = threadChannel?.id;
|
||||||
const isForumStarter = Boolean(
|
const isForumStarter =
|
||||||
threadChannelId && isForumParent && forumParentSlug && message.id === threadChannelId,
|
Boolean(threadChannelId && isForumParent && forumParentSlug) && message.id === threadChannelId;
|
||||||
);
|
|
||||||
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
|
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
|
||||||
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
|
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
|
||||||
const groupSubject = isDirectMessage ? undefined : groupChannel;
|
const groupSubject = isDirectMessage ? undefined : groupChannel;
|
||||||
|
|||||||
+17
-66
@@ -1,77 +1,28 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const testTailnetIPv4 = { value: undefined as string | undefined };
|
import { resolveGatewayListenHosts } from "./net.js";
|
||||||
const testTailnetIPv6 = { value: undefined as string | undefined };
|
|
||||||
|
|
||||||
vi.mock("../infra/tailnet.js", () => ({
|
describe("resolveGatewayListenHosts", () => {
|
||||||
pickPrimaryTailnetIPv4: () => testTailnetIPv4.value,
|
it("returns the input host when not loopback", async () => {
|
||||||
pickPrimaryTailnetIPv6: () => testTailnetIPv6.value,
|
const hosts = await resolveGatewayListenHosts("0.0.0.0", {
|
||||||
}));
|
canBindToHost: async () => {
|
||||||
|
throw new Error("should not be called");
|
||||||
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"],
|
|
||||||
});
|
});
|
||||||
expect(clientIp).toBe("203.0.113.9");
|
expect(hosts).toEqual(["0.0.0.0"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ignores forwarded-for from untrusted proxies", () => {
|
it("adds ::1 when IPv6 loopback is available", async () => {
|
||||||
const clientIp = resolveGatewayClientIp({
|
const hosts = await resolveGatewayListenHosts("127.0.0.1", {
|
||||||
remoteAddr: "10.0.0.3",
|
canBindToHost: async () => true,
|
||||||
forwardedFor: "203.0.113.9",
|
|
||||||
trustedProxies: ["10.0.0.2"],
|
|
||||||
});
|
});
|
||||||
expect(clientIp).toBe("10.0.0.3");
|
expect(hosts).toEqual(["127.0.0.1", "::1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("normalizes trusted proxy IPs and strips forwarded ports", () => {
|
it("keeps only IPv4 loopback when IPv6 is unavailable", async () => {
|
||||||
const clientIp = resolveGatewayClientIp({
|
const hosts = await resolveGatewayListenHosts("127.0.0.1", {
|
||||||
remoteAddr: "::ffff:10.0.0.2",
|
canBindToHost: async () => false,
|
||||||
forwardedFor: "203.0.113.9:1234",
|
|
||||||
trustedProxies: ["10.0.0.2"],
|
|
||||||
});
|
});
|
||||||
expect(clientIp).toBe("203.0.113.9");
|
expect(hosts).toEqual(["127.0.0.1"]);
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+16
-6
@@ -97,14 +97,14 @@ export async function resolveGatewayBindHost(
|
|||||||
|
|
||||||
if (mode === "loopback") {
|
if (mode === "loopback") {
|
||||||
// 127.0.0.1 rarely fails, but handle gracefully
|
// 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
|
return "0.0.0.0"; // extreme fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "tailnet") {
|
if (mode === "tailnet") {
|
||||||
const tailnetIP = pickPrimaryTailnetIPv4();
|
const tailnetIP = pickPrimaryTailnetIPv4();
|
||||||
if (tailnetIP && (await canBindTo(tailnetIP))) return tailnetIP;
|
if (tailnetIP && (await canBindToHost(tailnetIP))) return tailnetIP;
|
||||||
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";
|
return "0.0.0.0";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,13 +116,13 @@ export async function resolveGatewayBindHost(
|
|||||||
const host = customHost?.trim();
|
const host = customHost?.trim();
|
||||||
if (!host) return "0.0.0.0"; // invalid config → fall back to all
|
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
|
// Custom IP failed → fall back to LAN
|
||||||
return "0.0.0.0";
|
return "0.0.0.0";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "auto") {
|
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";
|
return "0.0.0.0";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ export async function resolveGatewayBindHost(
|
|||||||
* @param host - The host address to test
|
* @param host - The host address to test
|
||||||
* @returns True if we can successfully bind to this address
|
* @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) => {
|
return new Promise((resolve) => {
|
||||||
const testServer = net.createServer();
|
const testServer = net.createServer();
|
||||||
testServer.once("error", () => {
|
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.
|
* Validate if a string is a valid IPv4 address.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function createGatewayCloseHandler(params: {
|
|||||||
browserControl: { stop: () => Promise<void> } | null;
|
browserControl: { stop: () => Promise<void> } | null;
|
||||||
wss: WebSocketServer;
|
wss: WebSocketServer;
|
||||||
httpServer: HttpServer;
|
httpServer: HttpServer;
|
||||||
|
httpServers?: HttpServer[];
|
||||||
}) {
|
}) {
|
||||||
return async (opts?: { reason?: string; restartExpectedMs?: number | null }) => {
|
return async (opts?: { reason?: string; restartExpectedMs?: number | null }) => {
|
||||||
const reasonRaw = typeof opts?.reason === "string" ? opts.reason.trim() : "";
|
const reasonRaw = typeof opts?.reason === "string" ? opts.reason.trim() : "";
|
||||||
@@ -108,14 +109,20 @@ export function createGatewayCloseHandler(params: {
|
|||||||
await params.browserControl.stop().catch(() => {});
|
await params.browserControl.stop().catch(() => {});
|
||||||
}
|
}
|
||||||
await new Promise<void>((resolve) => params.wss.close(() => resolve()));
|
await new Promise<void>((resolve) => params.wss.close(() => resolve()));
|
||||||
const httpServer = params.httpServer as HttpServer & {
|
const servers =
|
||||||
closeIdleConnections?: () => void;
|
params.httpServers && params.httpServers.length > 0
|
||||||
};
|
? params.httpServers
|
||||||
if (typeof httpServer.closeIdleConnections === "function") {
|
: [params.httpServer];
|
||||||
httpServer.closeIdleConnections();
|
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())),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -230,8 +230,6 @@ export function createGatewayHttpServer(opts: {
|
|||||||
const configSnapshot = loadConfig();
|
const configSnapshot = loadConfig();
|
||||||
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
||||||
if (await handleHooksRequest(req, res)) return;
|
if (await handleHooksRequest(req, res)) return;
|
||||||
if (await handleSlackHttpRequest(req, res)) return;
|
|
||||||
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
|
|
||||||
if (
|
if (
|
||||||
await handleToolsInvokeHttpRequest(req, res, {
|
await handleToolsInvokeHttpRequest(req, res, {
|
||||||
auth: resolvedAuth,
|
auth: resolvedAuth,
|
||||||
@@ -239,6 +237,8 @@ export function createGatewayHttpServer(opts: {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
if (await handleSlackHttpRequest(req, res)) return;
|
||||||
|
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
|
||||||
if (openResponsesEnabled) {
|
if (openResponsesEnabled) {
|
||||||
if (
|
if (
|
||||||
await handleOpenResponsesHttpRequest(req, res, {
|
await handleOpenResponsesHttpRequest(req, res, {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getTtsProvider,
|
getTtsProvider,
|
||||||
isTtsEnabled,
|
isTtsEnabled,
|
||||||
isTtsProviderConfigured,
|
isTtsProviderConfigured,
|
||||||
|
resolveTtsAutoMode,
|
||||||
resolveTtsApiKey,
|
resolveTtsApiKey,
|
||||||
resolveTtsConfig,
|
resolveTtsConfig,
|
||||||
resolveTtsPrefsPath,
|
resolveTtsPrefsPath,
|
||||||
@@ -24,11 +25,13 @@ export const ttsHandlers: GatewayRequestHandlers = {
|
|||||||
const config = resolveTtsConfig(cfg);
|
const config = resolveTtsConfig(cfg);
|
||||||
const prefsPath = resolveTtsPrefsPath(config);
|
const prefsPath = resolveTtsPrefsPath(config);
|
||||||
const provider = getTtsProvider(config, prefsPath);
|
const provider = getTtsProvider(config, prefsPath);
|
||||||
|
const autoMode = resolveTtsAutoMode({ config, prefsPath });
|
||||||
const fallbackProviders = resolveTtsProviderOrder(provider)
|
const fallbackProviders = resolveTtsProviderOrder(provider)
|
||||||
.slice(1)
|
.slice(1)
|
||||||
.filter((candidate) => isTtsProviderConfigured(config, candidate));
|
.filter((candidate) => isTtsProviderConfigured(config, candidate));
|
||||||
respond(true, {
|
respond(true, {
|
||||||
enabled: isTtsEnabled(config, prefsPath),
|
enabled: isTtsEnabled(config, prefsPath),
|
||||||
|
auto: autoMode,
|
||||||
provider,
|
provider,
|
||||||
fallbackProvider: fallbackProviders[0] ?? null,
|
fallbackProvider: fallbackProviders[0] ?? null,
|
||||||
fallbackProviders,
|
fallbackProviders,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
|
|||||||
providers: [],
|
providers: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpHandlers: [],
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
diagnostics,
|
diagnostics,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js";
|
|||||||
import type { HooksConfigResolved } from "./hooks.js";
|
import type { HooksConfigResolved } from "./hooks.js";
|
||||||
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
|
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
|
||||||
import { listenGatewayHttpServer } from "./server/http-listen.js";
|
import { listenGatewayHttpServer } from "./server/http-listen.js";
|
||||||
|
import { resolveGatewayListenHosts } from "./net.js";
|
||||||
import { createGatewayPluginRequestHandler } from "./server/plugins-http.js";
|
import { createGatewayPluginRequestHandler } from "./server/plugins-http.js";
|
||||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||||
import { createGatewayBroadcaster } from "./server-broadcast.js";
|
import { createGatewayBroadcaster } from "./server-broadcast.js";
|
||||||
@@ -38,11 +39,14 @@ export async function createGatewayRuntimeState(params: {
|
|||||||
canvasHostEnabled: boolean;
|
canvasHostEnabled: boolean;
|
||||||
allowCanvasHostInTests?: boolean;
|
allowCanvasHostInTests?: boolean;
|
||||||
logCanvas: { info: (msg: string) => void; warn: (msg: string) => void };
|
logCanvas: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||||
|
log: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||||
logHooks: ReturnType<typeof createSubsystemLogger>;
|
logHooks: ReturnType<typeof createSubsystemLogger>;
|
||||||
logPlugins: ReturnType<typeof createSubsystemLogger>;
|
logPlugins: ReturnType<typeof createSubsystemLogger>;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
canvasHost: CanvasHostHandler | null;
|
canvasHost: CanvasHostHandler | null;
|
||||||
httpServer: HttpServer;
|
httpServer: HttpServer;
|
||||||
|
httpServers: HttpServer[];
|
||||||
|
httpBindHosts: string[];
|
||||||
wss: WebSocketServer;
|
wss: WebSocketServer;
|
||||||
clients: Set<GatewayWsClient>;
|
clients: Set<GatewayWsClient>;
|
||||||
broadcast: (
|
broadcast: (
|
||||||
@@ -100,30 +104,49 @@ export async function createGatewayRuntimeState(params: {
|
|||||||
log: params.logPlugins,
|
log: params.logPlugins,
|
||||||
});
|
});
|
||||||
|
|
||||||
const httpServer = createGatewayHttpServer({
|
const bindHosts = await resolveGatewayListenHosts(params.bindHost);
|
||||||
canvasHost,
|
const httpServers: HttpServer[] = [];
|
||||||
controlUiEnabled: params.controlUiEnabled,
|
const httpBindHosts: string[] = [];
|
||||||
controlUiBasePath: params.controlUiBasePath,
|
for (const host of bindHosts) {
|
||||||
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
|
const httpServer = createGatewayHttpServer({
|
||||||
openResponsesEnabled: params.openResponsesEnabled,
|
canvasHost,
|
||||||
openResponsesConfig: params.openResponsesConfig,
|
controlUiEnabled: params.controlUiEnabled,
|
||||||
handleHooksRequest,
|
controlUiBasePath: params.controlUiBasePath,
|
||||||
handlePluginRequest,
|
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
|
||||||
resolvedAuth: params.resolvedAuth,
|
openResponsesEnabled: params.openResponsesEnabled,
|
||||||
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
|
openResponsesConfig: params.openResponsesConfig,
|
||||||
});
|
handleHooksRequest,
|
||||||
|
handlePluginRequest,
|
||||||
await listenGatewayHttpServer({
|
resolvedAuth: params.resolvedAuth,
|
||||||
httpServer,
|
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
|
||||||
bindHost: params.bindHost,
|
});
|
||||||
port: params.port,
|
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({
|
const wss = new WebSocketServer({
|
||||||
noServer: true,
|
noServer: true,
|
||||||
maxPayload: MAX_PAYLOAD_BYTES,
|
maxPayload: MAX_PAYLOAD_BYTES,
|
||||||
});
|
});
|
||||||
attachGatewayUpgradeHandler({ httpServer, wss, canvasHost });
|
for (const server of httpServers) {
|
||||||
|
attachGatewayUpgradeHandler({ httpServer: server, wss, canvasHost });
|
||||||
|
}
|
||||||
|
|
||||||
const clients = new Set<GatewayWsClient>();
|
const clients = new Set<GatewayWsClient>();
|
||||||
const { broadcast } = createGatewayBroadcaster({ clients });
|
const { broadcast } = createGatewayBroadcaster({ clients });
|
||||||
@@ -140,6 +163,8 @@ export async function createGatewayRuntimeState(params: {
|
|||||||
return {
|
return {
|
||||||
canvasHost,
|
canvasHost,
|
||||||
httpServer,
|
httpServer,
|
||||||
|
httpServers,
|
||||||
|
httpBindHosts,
|
||||||
wss,
|
wss,
|
||||||
clients,
|
clients,
|
||||||
broadcast,
|
broadcast,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getResolvedLoggerSettings } from "../logging.js";
|
|||||||
export function logGatewayStartup(params: {
|
export function logGatewayStartup(params: {
|
||||||
cfg: ReturnType<typeof loadConfig>;
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
bindHost: string;
|
bindHost: string;
|
||||||
|
bindHosts?: string[];
|
||||||
port: number;
|
port: number;
|
||||||
tlsEnabled?: boolean;
|
tlsEnabled?: boolean;
|
||||||
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
|
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
|
||||||
@@ -22,9 +23,16 @@ export function logGatewayStartup(params: {
|
|||||||
consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`,
|
consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`,
|
||||||
});
|
});
|
||||||
const scheme = params.tlsEnabled ? "wss" : "ws";
|
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(
|
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}`);
|
params.log.info(`log file: ${getResolvedLoggerSettings().file}`);
|
||||||
if (params.isNixMode) {
|
if (params.isNixMode) {
|
||||||
params.log.info("gateway: running in Nix mode (config managed externally)");
|
params.log.info("gateway: running in Nix mode (config managed externally)");
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const registryState = vi.hoisted(() => ({
|
|||||||
providers: [],
|
providers: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpHandlers: [],
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
@@ -81,6 +82,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
|||||||
providers: [],
|
providers: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpHandlers: [],
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const registryState = vi.hoisted(() => ({
|
|||||||
providers: [],
|
providers: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpHandlers: [],
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
@@ -78,6 +79,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
|||||||
providers: [],
|
providers: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpHandlers: [],
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
|
|||||||
@@ -230,6 +230,21 @@ describe("gateway server auth/connect", () => {
|
|||||||
ws.close();
|
ws.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returns control ui hint when token is missing", async () => {
|
||||||
|
const ws = await openWs(port);
|
||||||
|
const res = await connectReq(ws, {
|
||||||
|
client: {
|
||||||
|
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||||
|
version: "1.0.0",
|
||||||
|
platform: "web",
|
||||||
|
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.error?.message ?? "").toContain("Control UI settings");
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects control ui without device identity by default", async () => {
|
test("rejects control ui without device identity by default", async () => {
|
||||||
const ws = await openWs(port);
|
const ws = await openWs(port);
|
||||||
const res = await connectReq(ws, {
|
const res = await connectReq(ws, {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const registryState = vi.hoisted(() => ({
|
|||||||
providers: [],
|
providers: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpHandlers: [],
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
@@ -47,6 +48,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
|||||||
providers: [],
|
providers: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpHandlers: [],
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
|
|||||||
@@ -263,6 +263,8 @@ export async function startGatewayServer(
|
|||||||
const {
|
const {
|
||||||
canvasHost,
|
canvasHost,
|
||||||
httpServer,
|
httpServer,
|
||||||
|
httpServers,
|
||||||
|
httpBindHosts,
|
||||||
wss,
|
wss,
|
||||||
clients,
|
clients,
|
||||||
broadcast,
|
broadcast,
|
||||||
@@ -292,6 +294,7 @@ export async function startGatewayServer(
|
|||||||
canvasHostEnabled,
|
canvasHostEnabled,
|
||||||
allowCanvasHostInTests: opts.allowCanvasHostInTests,
|
allowCanvasHostInTests: opts.allowCanvasHostInTests,
|
||||||
logCanvas,
|
logCanvas,
|
||||||
|
log,
|
||||||
logHooks,
|
logHooks,
|
||||||
logPlugins,
|
logPlugins,
|
||||||
});
|
});
|
||||||
@@ -464,6 +467,7 @@ export async function startGatewayServer(
|
|||||||
logGatewayStartup({
|
logGatewayStartup({
|
||||||
cfg: cfgAtStart,
|
cfg: cfgAtStart,
|
||||||
bindHost,
|
bindHost,
|
||||||
|
bindHosts: httpBindHosts,
|
||||||
port,
|
port,
|
||||||
tlsEnabled: gatewayTls.enabled,
|
tlsEnabled: gatewayTls.enabled,
|
||||||
log,
|
log,
|
||||||
@@ -552,6 +556,7 @@ export async function startGatewayServer(
|
|||||||
browserControl,
|
browserControl,
|
||||||
wss,
|
wss,
|
||||||
httpServer,
|
httpServer,
|
||||||
|
httpServers,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
|||||||
providers: [],
|
providers: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpHandlers: [],
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
|
|||||||
providers: [],
|
providers: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpHandlers: [],
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
commands: [],
|
commands: [],
|
||||||
@@ -20,5 +21,6 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
|
|||||||
...merged,
|
...merged,
|
||||||
gatewayHandlers: merged.gatewayHandlers ?? {},
|
gatewayHandlers: merged.gatewayHandlers ?? {},
|
||||||
httpHandlers: merged.httpHandlers ?? [],
|
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
Reference in new issue
Block a user