Compare commits

..
Author SHA1 Message Date
Peter Steinberger d649710627 fix: support custom TTS endpoints (#1701) (thanks @louzhixian) 2026-01-25 08:01:11 +00:00
zhixian 498c15555e test: update tests for expanded OpenAI TTS model list
- Accept tts-1 and tts-1-hd as valid models
- Update OPENAI_TTS_MODELS length expectation to 3
2026-01-25 07:59:20 +00:00
zhixian 25345a909a style: format code with oxfmt 2026-01-25 07:59:20 +00:00
zhixian bfd8321b42 fix: strip trailing slashes from OPENAI_TTS_BASE_URL
Address review feedback: normalize the base URL by removing trailing
slashes to prevent double-slash paths like /v1//audio/speech which
cause 404 errors on some OpenAI-compatible servers.
2026-01-25 07:59:20 +00:00
zhixian fbbed439bd feat(tts): support custom OpenAI-compatible TTS endpoints
Add OPENAI_TTS_BASE_URL environment variable to allow using self-hosted
or third-party OpenAI-compatible TTS services like Kokoro, LocalAI, or
OpenedAI-Speech.

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

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

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

Tested with Kokoro-FastAPI.
2026-01-25 07:59:20 +00:00
291 changed files with 8375 additions and 21556 deletions

No files matched your search

-4
View File
@@ -7,10 +7,6 @@
[exclude-files]
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
pattern = (^|/)pnpm-lock\.yaml$
# Generated output and vendored assets.
pattern = (^|/)(dist|vendor)/
# Local config file with allowlist patterns.
pattern = (^|/)\.detect-secrets\.cfg$
[exclude-lines]
# Fastlane checks for private key marker; not a real key.
-17
View File
@@ -1,17 +0,0 @@
# actionlint configuration
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
self-hosted-runner:
labels:
# Blacksmith CI runners
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-4vcpu-windows-2025
# Ignore patterns for known issues
paths:
.github/workflows/**/*.yml:
ignore:
# Ignore shellcheck warnings (we run shellcheck separately)
- 'shellcheck reported issue.+'
# Ignore intentional if: false for disabled jobs
- 'constant expression "false" in condition'
-113
View File
@@ -1,113 +0,0 @@
# Dependabot configuration
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
registries:
npm-npmjs:
type: npm-registry
url: https://registry.npmjs.org
replaces-base: true
updates:
# npm dependencies (root)
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
production:
dependency-type: production
update-types:
- minor
- patch
development:
dependency-type: development
update-types:
- minor
- patch
open-pull-requests-limit: 10
registries:
- npm-npmjs
# GitHub Actions
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
actions:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Swift Package Manager - macOS app
- package-ecosystem: swift
directory: /apps/macos
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
swift-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Swift Package Manager - shared ClawdbotKit
- package-ecosystem: swift
directory: /apps/shared/ClawdbotKit
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
swift-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Swift Package Manager - Swabble
- package-ecosystem: swift
directory: /Swabble
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
swift-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Gradle - Android app
- package-ecosystem: gradle
directory: /apps/android
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
android-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
+24 -28
View File
@@ -32,21 +32,20 @@ jobs:
node-version: 22.x
check-latest: true
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
run_install: false
- name: Runtime versions
run: |
node -v
npm -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Enable corepack and pin pnpm
run: |
corepack enable
corepack prepare pnpm@10.23.0 --activate
pnpm -v
- name: Install dependencies (frozen)
env:
CI: true
@@ -109,12 +108,6 @@ jobs:
node-version: 22.x
check-latest: true
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
run_install: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
@@ -125,11 +118,16 @@ jobs:
node -v
npm -v
bun -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Enable corepack and pin pnpm
run: |
corepack enable
corepack prepare pnpm@10.23.0 --activate
pnpm -v
- name: Install dependencies
env:
CI: true
@@ -214,12 +212,6 @@ jobs:
node-version: 22.x
check-latest: true
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
run_install: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
@@ -230,11 +222,16 @@ jobs:
node -v
npm -v
bun -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Enable corepack and pin pnpm
run: |
corepack enable
corepack prepare pnpm@10.23.0 --activate
pnpm -v
- name: Install dependencies
env:
CI: true
@@ -282,21 +279,20 @@ jobs:
node-version: 22.x
check-latest: true
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
run_install: false
- name: Runtime versions
run: |
node -v
npm -v
pnpm -v
- name: Capture node path
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Enable corepack and pin pnpm
run: |
corepack enable
corepack prepare pnpm@10.23.0 --activate
pnpm -v
- name: Install dependencies
env:
CI: true
+4 -3
View File
@@ -14,10 +14,11 @@ jobs:
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v3
with:
version: 10.23.0
run_install: false
version: 10
- name: Enable Corepack
run: corepack enable
- name: Install pnpm deps (minimal)
run: pnpm install --ignore-scripts --frozen-lockfile
-105
View File
@@ -1,105 +0,0 @@
# Pre-commit hooks for clawdbot
# Install: prek install
# Run manually: prek run --all-files
#
# See https://pre-commit.com for more information
repos:
# Basic file hygiene
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
- id: end-of-file-fixer
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
- id: check-yaml
args: [--allow-multiple-documents]
- id: check-added-large-files
args: [--maxkb=500]
- id: check-merge-conflict
# Secret detection (same as CI)
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args:
- --baseline
- .secrets.baseline
- --exclude-files
- '(^|/)(dist/|vendor/|pnpm-lock\.yaml$|\.detect-secrets\.cfg$)'
- --exclude-lines
- 'key_content\.include\?\("BEGIN PRIVATE KEY"\)'
- --exclude-lines
- 'case \.apiKeyEnv: "API key \(env var\)"'
- --exclude-lines
- 'case apikey = "apiKey"'
- --exclude-lines
- '"gateway\.remote\.password"'
- --exclude-lines
- '"gateway\.auth\.password"'
- --exclude-lines
- '"talk\.apiKey"'
- --exclude-lines
- '=== "string"'
- --exclude-lines
- 'typeof remote\?\.password === "string"'
# Shell script linting
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.11.0
hooks:
- id: shellcheck
args: [--severity=error] # Only fail on errors, not warnings/info
# Exclude vendor and scripts with embedded code or known issues
exclude: '^(vendor/|scripts/e2e/)'
# GitHub Actions linting
- repo: https://github.com/rhysd/actionlint
rev: v1.7.10
hooks:
- id: actionlint
# GitHub Actions security audit
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.22.0
hooks:
- id: zizmor
args: [--persona=regular, --min-severity=medium, --min-confidence=medium]
exclude: '^(vendor/|Swabble/)'
# Project checks (same commands as CI)
- repo: local
hooks:
# oxlint --type-aware src test
- id: oxlint
name: oxlint
entry: scripts/pre-commit/run-node-tool.sh oxlint --type-aware src test
language: system
pass_filenames: false
types_or: [javascript, jsx, ts, tsx]
# oxfmt --check src test
- id: oxfmt
name: oxfmt
entry: scripts/pre-commit/run-node-tool.sh oxfmt --check src test
language: system
pass_filenames: false
types_or: [javascript, jsx, ts, tsx]
# swiftlint (same as CI)
- id: swiftlint
name: swiftlint
entry: swiftlint --config .swiftlint.yml
language: system
pass_filenames: false
types: [swift]
# swiftformat --lint (same as CI)
- id: swiftformat
name: swiftformat
entry: swiftformat --lint apps/macos/Sources --config .swiftformat
language: system
pass_filenames: false
types: [swift]
+123 -1796
View File
File diff suppressed because it is too large. Load diff
-25
View File
@@ -1,25 +0,0 @@
# ShellCheck configuration
# https://www.shellcheck.net/wiki/
# Disable common false positives and style suggestions
# SC2034: Variable appears unused (often exported or used indirectly)
disable=SC2034
# SC2155: Declare and assign separately (common idiom, rarely causes issues)
disable=SC2155
# SC2295: Expansions inside ${..} need quoting (info-level, rarely causes issues)
disable=SC2295
# SC1012: \r is literal (tr -d '\r' works as intended on most systems)
disable=SC1012
# SC2026: Word outside quotes (info-level, often intentional)
disable=SC2026
# SC2016: Expressions don't expand in single quotes (often intentional in sed/awk)
disable=SC2016
# SC2129: Consider using { cmd1; cmd2; } >> file (style preference)
disable=SC2129
+1 -1
View File
@@ -23,7 +23,7 @@
# Whitespace
--trimwhitespace always
--emptybraces no-space
--nospaceoperators ...,..<
--nospaceoperators ...,..<
--ranges no-space
--someAny true
--voidtype void
-1
View File
@@ -37,7 +37,6 @@
## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install`
- Pre-commit hooks: `prek install` (runs same checks as CI)
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
+21 -36
View File
@@ -5,64 +5,49 @@ Docs: https://docs.clawd.bot
## 2026.1.24
### Highlights
- Providers: Ollama discovery + docs; Venice guide upgrades + cross-links. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama https://docs.clawd.bot/providers/venice
- Channels: LINE plugin (Messaging API) with rich replies + quick replies. (#1630) Thanks @plum-dawg.
- TTS: Edge fallback (keyless) + `/tts` auto modes. (#1668, #1667) Thanks @steipete, @sebslight. https://docs.clawd.bot/tts
- Exec approvals: approve in-chat via `/approve` across all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
- Telegram: DM topics as separate sessions + outbound link preview toggle. (#1597, #1700) Thanks @rohannagpal, @zerone0x. https://docs.clawd.bot/channels/telegram
- Ollama: provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama
- Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. https://docs.clawd.bot/providers/venice
### Changes
- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.
- TTS: allow custom OpenAI-compatible TTS endpoints without API keys. (#1701) Thanks @louzhixian. https://docs.clawd.bot/tts
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
- UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg.
- 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.
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
- Docs: add verbose installer troubleshooting guidance.
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
- Docs: update Fly.io guide notes.
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
### Fixes
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
- Web UI: hide internal `message_id` hints in chat bubbles.
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
- Web UI: hide internal `message_id` hints in chat bubbles.
- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete.
- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.
- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
- Heartbeat: normalize target identifiers for consistent routing.
- TUI: reload history after gateway reconnect to restore session state. (#1663)
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
- Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido.
- 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.
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
- Agents: use the active auth profile for auto-compaction recovery.
- Agents: let cron isolated runs inherit subagent allowlists from the parent agent. (#1771) Thanks @Noctivoro.
- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.
- Models: default missing custom provider fields so minimal configs are accepted.
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
- TUI: reload history after gateway reconnect to restore session state. (#1663)
- Heartbeat: normalize target identifiers for consistent routing.
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19.
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
- Google Chat: normalize space targets without double `spaces/` prefix.
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.
- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.
- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.
-10
View File
@@ -40,13 +40,3 @@ Please include in your PR:
- [ ] Confirm you understand what the code does
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
## Current Focus & Roadmap 🗺
We are currently prioritizing:
- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram).
- **UX**: Improving the onboarding wizard and error messages.
- **Skills**: Expanding the library of bundled skills and improving the Skill Creation developer experience.
- **Performance**: Optimizing token usage and compaction logic.
Check the [GitHub Issues](https://github.com/clawdbot/clawdbot/issues) for "good first issue" labels!
+2 -2
View File
@@ -459,7 +459,7 @@ Use these when you’re past the onboarding flow and want the deeper reference.
## Clawd
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
by Peter Steinberger and the community.
- [clawd.me](https://clawd.me)
@@ -468,7 +468,7 @@ by Peter Steinberger and the community.
## Community
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
+1
View File
@@ -12,3 +12,4 @@ If you believe you’ve found a security issue in Clawdbot, please report it pri
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
- `https://docs.clawd.bot/gateway/security`
+1 -1
View File
@@ -212,4 +212,4 @@
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
</item>
</channel>
</rss>
</rss>
@@ -12,3 +12,4 @@ data class CameraHudState(
val kind: CameraHudKind,
val message: String,
)
@@ -12,3 +12,4 @@ enum class VoiceWakeMode(val rawValue: String) {
}
}
}
@@ -135,7 +135,7 @@ class SmsManager(private val context: Context) {
/**
* Send an SMS message.
*
*
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
* @return SendResult indicating success or failure
*/
@@ -1,3 +1,4 @@
<resources>
<color name="ic_launcher_background">#0A0A0A</color>
</resources>
@@ -1,3 +1,4 @@
<resources>
<string name="app_name">Clawdbot Node</string>
</resources>
@@ -23,3 +23,4 @@ class VoiceWakeCommandExtractorTest {
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
}
}
+1
View File
@@ -16,3 +16,4 @@ dependencyResolutionManagement {
rootProject.name = "ClawdbotNodeAndroid"
include(":app")
+1
View File
@@ -3,3 +3,4 @@ parent_config: ../../.swiftlint.yml
included:
- Sources
- ../shared/ClawdisNodeKit/Sources
+1 -1
View File
@@ -33,4 +33,4 @@
],
"squares" : "shared"
}
}
}
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.
@@ -173,4 +173,4 @@
"iPod5,1": "iPod touch (5th generation)",
"iPod7,1": "iPod touch (6th generation)",
"iPod9,1": "iPod touch (7th generation)"
}
}
@@ -211,4 +211,4 @@
"Mac Pro (2019)",
"Mac Pro (Rack, 2019)"
]
}
}
@@ -574,22 +574,46 @@ public actor GatewayChannelActor {
params: [String: AnyCodable]?,
timeoutMs: Double? = nil) async throws -> Data
{
try await self.connectOrThrow(context: "gateway connect")
do {
try await self.connect()
} catch {
throw self.wrap(error, context: "gateway connect")
}
let id = UUID().uuidString
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
let payload = try self.encodeRequest(method: method, params: params, kind: "request")
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
let paramsObject: ProtoAnyCodable? = params.map { entries in
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
dict[entry.key] = ProtoAnyCodable(entry.value.value)
}
return ProtoAnyCodable(dict)
}
let frame = RequestFrame(
type: "req",
id: id,
method: method,
params: paramsObject)
let data: Data
do {
data = try self.encoder.encode(frame)
} catch {
self.logger.error(
"gateway request encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
throw error
}
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
self.pending[payload.id] = cont
self.pending[id] = cont
Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout)
await self.timeoutRequest(id: id, timeoutMs: effectiveTimeout)
}
Task {
do {
try await self.task?.send(.data(payload.data))
try await self.task?.send(.data(data))
} catch {
let wrapped = self.wrap(error, context: "gateway send \(method)")
let waiter = self.pending.removeValue(forKey: payload.id)
let waiter = self.pending.removeValue(forKey: id)
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
self.connected = false
self.task?.cancel(with: .goingAway, reason: nil)
@@ -619,29 +643,6 @@ public actor GatewayChannelActor {
return Data() // Should not happen, but tolerate empty payloads.
}
public func send(method: String, params: [String: AnyCodable]?) async throws {
try await self.connectOrThrow(context: "gateway connect")
let payload = try self.encodeRequest(method: method, params: params, kind: "send")
guard let task = self.task else {
throw NSError(
domain: "Gateway",
code: 5,
userInfo: [NSLocalizedDescriptionKey: "gateway socket unavailable"])
}
do {
try await task.send(.data(payload.data))
} catch {
let wrapped = self.wrap(error, context: "gateway send \(method)")
self.connected = false
self.task?.cancel(with: .goingAway, reason: nil)
Task { [weak self] in
guard let self else { return }
await self.scheduleReconnect()
}
throw wrapped
}
}
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
private func wrap(_ error: Error, context: String) -> Error {
if let urlError = error as? URLError {
@@ -656,42 +657,6 @@ public actor GatewayChannelActor {
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
}
private func connectOrThrow(context: String) async throws {
do {
try await self.connect()
} catch {
throw self.wrap(error, context: context)
}
}
private func encodeRequest(
method: String,
params: [String: AnyCodable]?,
kind: String) throws -> (id: String, data: Data)
{
let id = UUID().uuidString
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
let paramsObject: ProtoAnyCodable? = params.map { entries in
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
dict[entry.key] = ProtoAnyCodable(entry.value.value)
}
return ProtoAnyCodable(dict)
}
let frame = RequestFrame(
type: "req",
id: id,
method: method,
params: paramsObject)
do {
let data = try self.encoder.encode(frame)
return (id: id, data: data)
} catch {
self.logger.error(
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
throw error
}
}
private func failPending(_ error: Error) async {
let waiters = self.pending
self.pending.removeAll()
@@ -143,7 +143,7 @@ public actor GatewayNodeSession {
"payloadJSON": AnyCodable(payloadJSON ?? NSNull()),
]
do {
try await channel.send(method: "node.event", params: params)
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 8000)
} catch {
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
}
@@ -224,7 +224,7 @@ public actor GatewayNodeSession {
])
}
do {
try await channel.send(method: "node.invoke.result", params: params)
_ = try await channel.request(method: "node.invoke.result", params: params, timeoutMs: 15000)
} catch {
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
}
-1
View File
@@ -1 +0,0 @@
@import"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap";:root{--bg: #0c0d12;--bg-accent: #0d0e14;--bg-elevated: #181a21;--bg-hover: #252830;--bg-muted: #252830;--card: #13151c;--card-foreground: #f8fafc;--card-highlight: rgba(255, 255, 255, .04);--popover: #13151c;--popover-foreground: #f8fafc;--panel: #0c0d12;--panel-strong: #181a21;--panel-hover: #252830;--chrome: rgba(12, 13, 18, .95);--chrome-strong: rgba(12, 13, 18, .98);--text: #f8fafc;--text-strong: #ffffff;--chat-text: #f8fafc;--muted: #94a3b8;--muted-strong: #64748b;--muted-foreground: #94a3b8;--border: #333842;--border-strong: #454d5c;--border-hover: #5a6373;--input: #333842;--ring: #ff4d4d;--accent: #ff4d4d;--accent-hover: #ff6666;--accent-muted: #ff4d4d;--accent-subtle: rgba(255, 77, 77, .12);--accent-foreground: #f8fafc;--primary: #ff4d4d;--primary-foreground: #ffffff;--secondary: #252830;--secondary-foreground: #f8fafc;--accent-2: #3b82f6;--accent-2-muted: rgba(59, 130, 246, .7);--ok: #22c55e;--ok-muted: rgba(34, 197, 94, .7);--ok-subtle: rgba(34, 197, 94, .1);--destructive: #ef4444;--destructive-foreground: #fafafa;--warn: #eab308;--warn-muted: rgba(234, 179, 8, .7);--warn-subtle: rgba(234, 179, 8, .1);--danger: #ef4444;--danger-muted: rgba(239, 68, 68, .7);--danger-subtle: rgba(239, 68, 68, .1);--info: #3b82f6;--focus: rgba(255, 77, 77, .2);--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);--grid-line: rgba(255, 255, 255, .03);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .05);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -2px rgba(0, 0, 0, .1);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .1), 0 4px 6px -4px rgba(0, 0, 0, .1);--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .1), 0 8px 10px -6px rgba(0, 0, 0, .1);--radius-sm: 4px;--radius-md: 6px;--radius-lg: 8px;--radius-xl: 12px;--radius-full: 9999px;--radius: 6px;--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-in-out: cubic-bezier(.4, 0, .2, 1);--duration-fast: .15s;--duration-normal: .2s;--duration-slow: .3s;color-scheme:dark}:root[data-theme=light]{--bg: #f8f8f7;--bg-accent: #f3f2f0;--bg-elevated: #ffffff;--bg-hover: #eae8e6;--bg-muted: #eae8e6;--bg-content: #f0efed;--card: #ffffff;--card-foreground: #1c1917;--card-highlight: rgba(0, 0, 0, .04);--popover: #ffffff;--popover-foreground: #1c1917;--panel: #f8f8f7;--panel-strong: #f0efed;--panel-hover: #e5e3e1;--chrome: rgba(248, 248, 247, .95);--chrome-strong: rgba(248, 248, 247, .98);--text: #44403c;--text-strong: #292524;--chat-text: #44403c;--muted: #5c5856;--muted-strong: #44403c;--muted-foreground: #5c5856;--border: #e0dedc;--border-strong: #d6d3d1;--border-hover: #a8a5a0;--input: #e0dedc;--accent: #b91c1c;--accent-hover: #dc2626;--accent-muted: #b91c1c;--accent-subtle: rgba(185, 28, 28, .18);--accent-foreground: #ffffff;--primary: #b91c1c;--primary-foreground: #ffffff;--secondary: #eae8e6;--secondary-foreground: #44403c;--ok: #15803d;--ok-muted: rgba(21, 128, 61, .75);--ok-subtle: rgba(21, 128, 61, .12);--destructive: #b91c1c;--destructive-foreground: #fafafa;--warn: #a16207;--warn-muted: rgba(161, 98, 7, .75);--warn-subtle: rgba(161, 98, 7, .12);--danger: #b91c1c;--danger-muted: rgba(185, 28, 28, .75);--danger-subtle: rgba(185, 28, 28, .12);--info: #1d4ed8;--focus: rgba(185, 28, 28, .25);--grid-line: rgba(0, 0, 0, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:400 14px/1.5 var(--font-body);letter-spacing:-.011em;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .4s var(--ease-out) forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button,input,textarea,select{font:inherit;color:inherit}::selection{background:var(--accent-subtle);color:var(--text-strong)}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:var(--radius-full)}::-webLine truncated
+1
View File
@@ -0,0 +1 @@
@import"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";:root{--bg: #0a0f14;--bg-accent: #111826;--bg-grad-1: #162031;--bg-grad-2: #1f2a22;--bg-overlay: rgba(255, 255, 255, .05);--bg-glow: rgba(245, 159, 74, .12);--panel: rgba(14, 20, 30, .88);--panel-strong: rgba(18, 26, 38, .96);--chrome: rgba(9, 14, 20, .72);--chrome-strong: rgba(9, 14, 20, .86);--text: rgba(244, 246, 251, .96);--chat-text: rgba(231, 237, 244, .92);--muted: rgba(156, 169, 189, .72);--border: rgba(255, 255, 255, .09);--border-strong: rgba(255, 255, 255, .16);--accent: #f59f4a;--accent-2: #34c7b7;--ok: #2bd97f;--warn: #f2c94c;--danger: #ff6b6b;--focus: rgba(245, 159, 74, .35);--grid-line: rgba(255, 255, 255, .04);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--font-body: "Work Sans", system-ui, sans-serif;--font-display: "Unbounded", "Times New Roman", serif;color-scheme:dark}:root[data-theme=light]{--bg: #f5f1ea;--bg-accent: #ffffff;--bg-grad-1: #f1e6d6;--bg-grad-2: #e5eef4;--bg-overlay: rgba(28, 32, 46, .05);--bg-glow: rgba(52, 199, 183, .14);--panel: rgba(255, 255, 255, .9);--panel-strong: rgba(255, 255, 255, .97);--chrome: rgba(255, 255, 255, .75);--chrome-strong: rgba(255, 255, 255, .88);--text: rgba(27, 36, 50, .98);--chat-text: rgba(36, 48, 66, .9);--muted: rgba(80, 94, 114, .7);--border: rgba(18, 24, 40, .12);--border-strong: rgba(18, 24, 40, .2);--accent: #e28a3f;--accent-2: #1ba99d;--ok: #1aa86c;--warn: #b3771c;--danger: #d44848;--focus: rgba(226, 138, 63, .35);--grid-line: rgba(18, 24, 40, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:15px/1.5 var(--font-body);background:radial-gradient(1200px 900px at 15% -10%,var(--bg-grad-1) 0%,transparent 55%) fixed,radial-gradient(900px 700px at 80% 10%,var(--bg-grad-2) 0%,transparent 60%) fixed,linear-gradient(160deg,var(--bg) 0%,var(--bg-accent) 100%) fixed;color:var(--text)}body:before{content:"";position:fixed;inset:0;background:linear-gradient(140deg,var(--bg-overlay) 0%,rgba(255,255,255,0) 40%),radial-gradient(620px 420px at 75% 75%,var(--bg-glow),transparent 60%);pointer-events:none;z-index:0}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .45s ease-out forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:inherit}button,input,textarea,select{font:inherit;color:inherit}@keyframes rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@keyframes dashboard-enter{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.shell{--shell-pad: 16px;--shell-gap: 16px;--shell-nav-width: 220px;--shell-topbar-height: 56px;--shell-focus-duration: .22s;--shell-focus-ease: cubic-bezier(.2, .85, .25, 1);height:100vh;display:grid;grid-template-columns:var(--shell-nav-width) minmax(0,1fr);grid-template-rows:var(--shell-topbar-height) 1fr;grid-template-areas:"topbar topbar" "nav content";gap:0;animation:dashboard-enter .6s ease-out;transition:grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);overflow:hidden}@supports (height: 100dvh){.shell{height:100dvh}}.shell--chat{min-height:100vh;height:100vh;overflow:hidden}@supports (height: 100dvh){.shell--chat{height:100dvh}}.shell--nav-collapsed,.shell--chat-focus{grid-template-columns:0px minmax(0,1fr)}.shell--onboarding{grid-template-rows:0 1fr}.shell--onboarding .topbar{display:none}.shell--onboarding .content{padding-top:0}.shell--chat-focus .content{padding-top:0;gap:0}.topbar{grid-area:topbar;position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 20px;height:var(--shell-topbar-height);border-bottom:1px solid var(--border);background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.topbar-left{display:flex;align-items:center;gap:12px}.topbar .nav-collapse-toggle{width:44px;height:44px;margin-bottom:0}.topbar .nav-collapse-toggle__icon{font-size:22px}.brand{display:flex;flex-direction:column;gap:2px}.brand-title{font-family:var(--font-display);font-size:16px;letter-spacing:1px;text-transform:uppercase;font-weight:600;line-height:1.1}.brand-sub{font-size:10px;color:var(--muted);letter-spacing:.8px;text-transform:uppercase;line-heightLine truncated
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"index-DQcOTEYz.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/icons.ts","../../../src/shared/text/reasoning-tags.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.googlechat.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyLine truncated
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index-DsXRcnEw.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index-bYQnHP3a.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
+2 -2
View File
@@ -6,8 +6,8 @@
<title>Clawdbot Control</title>
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="./favicon.ico" sizes="any" />
<script type="module" crossorigin src="./assets/index-DQcOTEYz.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css">
<script type="module" crossorigin src="./assets/index-DsXRcnEw.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BvhR9FCb.css">
</head>
<body>
<clawdbot-app></clawdbot-app>
-1
View File
@@ -213,7 +213,6 @@ Prefer `chat_guid` for stable routing:
- `chat_id:123`
- `chat_identifier:...`
- Direct handles: `+15555550123`, `user@example.com`
- If a direct handle does not have an existing DM chat, Clawdbot will create one via `POST /api/v1/chat/new`. This requires the BlueBubbles Private API to be enabled.
## Security
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
+15 -15
View File
@@ -26,7 +26,7 @@ Minimal config:
```
### Setup
1) Create a Slack app (From scratch) in https://api.slack.com/apps.
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
4) Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`).
@@ -245,29 +245,29 @@ If you enable native commands, add one `slash_commands` entry per command you wa
## Scopes (current vs optional)
Slack's Conversations API is type-scoped: you only need the scopes for the
conversation types you actually touch (channels, groups, im, mpim). See
https://docs.slack.dev/apis/web-api/using-the-conversations-api/ for the overview.
https://api.channels.slack.com/docs/conversations-api for the overview.
### Bot token scopes (required)
- `chat:write` (send/update/delete messages via `chat.postMessage`)
https://docs.slack.dev/reference/methods/chat.postMessage
https://api.channels.slack.com/methods/chat.postMessage
- `im:write` (open DMs via `conversations.open` for user DMs)
https://docs.slack.dev/reference/methods/conversations.open
https://api.channels.slack.com/methods/conversations.open
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
https://docs.slack.dev/reference/methods/conversations.history
https://api.channels.slack.com/methods/conversations.history
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
https://docs.slack.dev/reference/methods/conversations.info
https://api.channels.slack.com/methods/conversations.info
- `users:read` (user lookup)
https://docs.slack.dev/reference/methods/users.info
https://api.channels.slack.com/methods/users.info
- `reactions:read`, `reactions:write` (`reactions.get` / `reactions.add`)
https://docs.slack.dev/reference/methods/reactions.get
https://docs.slack.dev/reference/methods/reactions.add
https://api.channels.slack.com/methods/reactions.get
https://api.channels.slack.com/methods/reactions.add
- `pins:read`, `pins:write` (`pins.list` / `pins.add` / `pins.remove`)
https://docs.slack.dev/reference/scopes/pins.read
https://docs.slack.dev/reference/scopes/pins.write
https://api.channels.slack.com/scopes/pins:read
https://api.channels.slack.com/scopes/pins:write
- `emoji:read` (`emoji.list`)
https://docs.slack.dev/reference/scopes/emoji.read
https://api.channels.slack.com/scopes/emoji:read
- `files:write` (uploads via `files.uploadV2`)
https://docs.slack.dev/messaging/working-with-files/#upload
https://api.channels.slack.com/messaging/files/uploading
### User token scopes (optional, read-only by default)
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
@@ -284,9 +284,9 @@ Add these under **User Token Scopes** if you configure `channels.slack.userToken
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`)
- `groups:write` (only if we add private-channel management: create/rename/invite/archive)
- `chat:write.public` (only if we want to post to channels the bot isn't in)
https://docs.slack.dev/reference/scopes/chat.write.public
https://api.channels.slack.com/scopes/chat:write.public
- `users:read.email` (only if we need email fields from `users.info`)
https://docs.slack.dev/changelog/2017-04-narrowing-email-access
https://api.channels.slack.com/changelog/2017-04-narrowing-email-access
- `files:read` (only if we start listing/reading file metadata)
## Config
-89
View File
@@ -1,89 +0,0 @@
---
summary: "Diagnostics flags for targeted debug logs"
read_when:
- You need targeted debug logs without raising global logging levels
- You need to capture subsystem-specific logs for support
---
# Diagnostics Flags
Diagnostics flags let you enable targeted debug logs without turning on verbose logging everywhere. Flags are opt-in and have no effect unless a subsystem checks them.
## How it works
- Flags are strings (case-insensitive).
- You can enable flags in config or via an env override.
- Wildcards are supported:
- `telegram.*` matches `telegram.http`
- `*` enables all flags
## Enable via config
```json
{
"diagnostics": {
"flags": ["telegram.http"]
}
}
```
Multiple flags:
```json
{
"diagnostics": {
"flags": ["telegram.http", "gateway.*"]
}
}
```
Restart the gateway after changing flags.
## Env override (one-off)
```bash
CLAWDBOT_DIAGNOSTICS=telegram.http,telegram.payload
```
Disable all flags:
```bash
CLAWDBOT_DIAGNOSTICS=0
```
## Where logs go
Flags emit logs into the standard diagnostics log file. By default:
```
/tmp/clawdbot/clawdbot-YYYY-MM-DD.log
```
If you set `logging.file`, use that path instead. Logs are JSONL (one JSON object per line). Redaction still applies based on `logging.redactSensitive`.
## Extract logs
Pick the latest log file:
```bash
ls -t /tmp/clawdbot/clawdbot-*.log | head -n 1
```
Filter for Telegram HTTP diagnostics:
```bash
rg "telegram http error" /tmp/clawdbot/clawdbot-*.log
```
Or tail while reproducing:
```bash
tail -f /tmp/clawdbot/clawdbot-$(date +%F).log | rg "telegram http error"
```
For remote gateways, you can also use `clawdbot logs --follow` (see [/cli/logs](/cli/logs)).
## Notes
- If `logging.level` is set higher than `warn`, these logs may be suppressed. Default `info` is fine.
- Flags are safe to leave enabled; they only affect log volume for the specific subsystem.
- Use [/logging](/logging) to change log destinations, levels, and redaction.
+5 -3
View File
@@ -1538,6 +1538,7 @@ voice notes; other channels send MP3 audio.
},
openai: {
apiKey: "openai_api_key",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy"
}
@@ -1558,6 +1559,8 @@ Notes:
- `/tts limit` and `/tts summary` control per-user summarization settings.
- `apiKey` values fall back to `ELEVENLABS_API_KEY`/`XI_API_KEY` and `OPENAI_API_KEY`.
- `elevenlabs.baseUrl` overrides the ElevenLabs API base URL.
- `openai.baseUrl` overrides the OpenAI TTS base URL (defaults to `https://api.openai.com/v1`,
or `OPENAI_TTS_BASE_URL` when set). Custom endpoints do not require an API key.
- `elevenlabs.voiceSettings` supports `stability`/`similarityBoost`/`style` (0..1),
`useSpeakerBoost`, and `speed` (0.5..2.0).
@@ -2847,9 +2850,8 @@ Control UI base path:
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
- Default: root (`/`) (unchanged).
- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI and skips
device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS
(Tailscale Serve) or `127.0.0.1`.
- `gateway.controlUi.allowInsecureAuth` allows token-only auth over **HTTP** (no device identity).
Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`.
Related docs:
- [Control UI](/web/control-ui)
+1 -1
View File
@@ -58,7 +58,7 @@ When the audit prints findings, treat this as a priority order:
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
to **token-only auth** and skips device pairing (even on HTTPS). This is a security
to **token-only auth** on plain HTTP and skips device pairing. This is a security
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
`clawdbot security audit` warns when this setting is enabled.
-11
View File
@@ -138,7 +138,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Can I use self-hosted models (llama.cpp, vLLM, Ollama)?](#can-i-use-selfhosted-models-llamacpp-vllm-ollama)
- [What do Clawd, Flawd, and Krill use for models?](#what-do-clawd-flawd-and-krill-use-for-models)
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
- [Can I use GPT 5.2 for daily tasks and Codex 5.2 for coding](#can-i-use-gpt-52-for-daily-tasks-and-codex-52-for-coding)
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
- [Why do I see “Unknown model: minimax/MiniMax-M2.1”?](#why-do-i-see-unknown-model-minimaxminimaxm21)
- [Can I use MiniMax as my default and OpenAI for complex tasks?](#can-i-use-minimax-as-my-default-and-openai-for-complex-tasks)
@@ -1948,16 +1947,6 @@ Re-run `/model` **without** the `@profile` suffix:
If you want to return to the default, pick it from `/model` (or send `/model <default provider/model>`).
Use `/model status` to confirm which auth profile is active.
### Can I use GPT 5.2 for daily tasks and Codex 5.2 for coding
Yes. Set one as default and switch as needed:
- **Quick switch (per session):** `/model gpt-5.2` for daily tasks, `/model gpt-5.2-codex` for coding.
- **Default + switch:** set `agents.defaults.model.primary` to `openai-codex/gpt-5.2`, then switch to `openai-codex/gpt-5.2-codex` when coding (or the other way around).
- **Sub-agents:** route coding tasks to sub-agents with a different default model.
See [Models](/concepts/models) and [Slash commands](/tools/slash-commands).
### Why do I see Model is not allowed and then no reply
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any
-24
View File
@@ -192,30 +192,6 @@ Use this if you want diagnostics events available to plugins or custom sinks:
}
```
### Diagnostics flags (targeted logs)
Use flags to turn on extra, targeted debug logs without raising `logging.level`.
Flags are case-insensitive and support wildcards (e.g. `telegram.*` or `*`).
```json
{
"diagnostics": {
"flags": ["telegram.http"]
}
}
```
Env override (one-off):
```
CLAWDBOT_DIAGNOSTICS=telegram.http,telegram.payload
```
Notes:
- Flag logs go to the standard log file (same as `logging.file`).
- Output is still redacted according to `logging.redactSensitive`.
- Full guide: [/diagnostics/flags](/diagnostics/flags).
### Export to OpenTelemetry
Diagnostics can be exported via the `diagnostics-otel` plugin (OTLP/HTTP). This
+5 -19
View File
@@ -80,7 +80,6 @@ primary_region = "iad"
|---------|-----|
| `--bind lan` | Binds to `0.0.0.0` so Fly's proxy can reach the gateway |
| `--allow-unconfigured` | Starts without a config file (you'll create one after) |
| `internal_port = 3000` | Must match `--port 3000` (or `CLAWDBOT_GATEWAY_PORT`) for Fly health checks |
| `memory = "2048mb"` | 512MB is too small; 2GB recommended |
| `CLAWDBOT_STATE_DIR = "/data"` | Persists state on the volume |
@@ -236,12 +235,6 @@ The gateway is binding to `127.0.0.1` instead of `0.0.0.0`.
**Fix:** Add `--bind lan` to your process command in `fly.toml`.
### Health checks failing / connection refused
Fly can't reach the gateway on the configured port.
**Fix:** Ensure `internal_port` matches the gateway port (set `--port 3000` or `CLAWDBOT_GATEWAY_PORT=3000`).
### OOM / Memory Issues
Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts.
@@ -275,11 +268,11 @@ The lock file is at `/data/gateway.*.lock` (not in a subdirectory).
### Config Not Being Read
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/clawdbot.json` should be read on restart.
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/.clawdbot/clawdbot.json` should be read on restart.
Verify the config exists:
```bash
fly ssh console --command "cat /data/clawdbot.json"
fly ssh console --command "cat /data/.clawdbot/clawdbot.json"
```
### Writing Config via SSH
@@ -288,24 +281,18 @@ The `fly ssh console -C` command doesn't support shell redirection. To write a c
```bash
# Use echo + tee (pipe from local to remote)
echo '{"your":"config"}' | fly ssh console -C "tee /data/clawdbot.json"
echo '{"your":"config"}' | fly ssh console -C "tee /data/.clawdbot/clawdbot.json"
# Or use sftp
fly sftp shell
> put /local/path/config.json /data/clawdbot.json
> put /local/path/config.json /data/.clawdbot/clawdbot.json
```
**Note:** `fly sftp` may fail if the file already exists. Delete first:
```bash
fly ssh console --command "rm /data/clawdbot.json"
fly ssh console --command "rm /data/.clawdbot/clawdbot.json"
```
### State Not Persisting
If you lose credentials or sessions after a restart, the state dir is writing to the container filesystem.
**Fix:** Ensure `CLAWDBOT_STATE_DIR=/data` is set in `fly.toml` and redeploy.
## Updates
```bash
@@ -343,7 +330,6 @@ fly machine update <machine-id> --vm-memory 2048 --command "node dist/index.js g
- The Dockerfile is compatible with both architectures
- For WhatsApp/Telegram onboarding, use `fly ssh console`
- Persistent data lives on the volume at `/data`
- Signal requires Java + signal-cli; use a custom image and keep memory at 2GB+.
## Cost
-16
View File
@@ -67,22 +67,6 @@ Plugins can register:
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
## Runtime helpers
Plugins can access selected core helpers via `api.runtime`. For telephony TTS:
```ts
const result = await api.runtime.tts.textToSpeechTelephony({
text: "Hello from Clawdbot",
cfg: api.config,
});
```
Notes:
- Uses core `messages.tts` configuration (OpenAI or ElevenLabs).
- Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
- Edge TTS is not supported for telephony.
## Discovery & precedence
Clawdbot scans, in order:
-81
View File
@@ -104,87 +104,6 @@ Notes:
- `mock` is a local dev provider (no network calls).
- `skipSignatureVerification` is for local testing only.
## TTS for calls
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
streaming speech on calls. You can override it under the plugin config with the
**same shape** — it deep‑merges with `messages.tts`.
```json5
{
tts: {
provider: "elevenlabs",
elevenlabs: {
voiceId: "pMsXgVXv3BLzUgSXRplE",
modelId: "eleven_multilingual_v2"
}
}
}
```
Notes:
- **Edge TTS is ignored for voice calls** (telephony audio needs PCM; Edge output is unreliable).
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
### More examples
Use core TTS only (no override):
```json5
{
messages: {
tts: {
provider: "openai",
openai: { voice: "alloy" }
}
}
}
```
Override to ElevenLabs just for calls (keep core default elsewhere):
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
tts: {
provider: "elevenlabs",
elevenlabs: {
apiKey: "elevenlabs_key",
voiceId: "pMsXgVXv3BLzUgSXRplE",
modelId: "eleven_multilingual_v2"
}
}
}
}
}
}
}
```
Override only the OpenAI model for calls (deep‑merge example):
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
tts: {
openai: {
model: "gpt-4o-mini-tts",
voice: "marin"
}
}
}
}
}
}
}
```
## Inbound calls
Inbound policy defaults to `disabled`. To enable inbound calls, set:
+3 -5
View File
@@ -16,7 +16,7 @@ and you configure everything via the `/setup` web wizard.
## One-click deploy
<a href="https://railway.app/new/template?template=https://github.com/vignesh07/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
<a href="https://railway.com/deploy/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
After deploy, find your public URL in **Railway → your service → Settings → Domains**.
@@ -55,7 +55,6 @@ Attach a volume mounted at:
Set these variables on the service:
- `SETUP_PASSWORD` (required)
- `PORT=8080` (required — must match the port in Public Networking)
- `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended)
- `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended)
- `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret)
@@ -83,9 +82,8 @@ If Telegram DMs are set to pairing, the setup wizard can approve the pairing cod
1) Go to https://discord.com/developers/applications
2) **New Application** → choose a name
3) **Bot** → **Add Bot**
4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup)
5) Copy the **Bot Token** and paste into `/setup`
6) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
4) Copy the **Bot Token** and paste into `/setup`
5) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
## Backups & migration
-41
View File
@@ -1,41 +0,0 @@
# Creating Custom Skills 🛠
Clawdbot is designed to be easily extensible. "Skills" are the primary way to add new capabilities to your assistant.
## What is a Skill?
A skill is a directory containing a `SKILL.md` file (which provides instructions and tool definitions to the LLM) and optionally some scripts or resources.
## Step-by-Step: Your First Skill
### 1. Create the Directory
Skills live in your workspace, usually `~/clawd/skills/`. Create a new folder for your skill:
```bash
mkdir -p ~/clawd/skills/hello-world
```
### 2. Define the `SKILL.md`
Create a `SKILL.md` file in that directory. This file uses YAML frontmatter for metadata and Markdown for instructions.
```markdown
---
name: hello_world
description: A simple skill that says hello.
---
# Hello World Skill
When the user asks for a greeting, use the `echo` tool to say "Hello from your custom skill!".
```
### 3. Add Tools (Optional)
You can define custom tools in the frontmatter or instruct the agent to use existing system tools (like `bash` or `browser`).
### 4. Refresh Clawdbot
Ask your agent to "refresh skills" or restart the gateway. Clawdbot will discover the new directory and index the `SKILL.md`.
## Best Practices
- **Be Concise**: Instruct the model on *what* to do, not how to be an AI.
- **Safety First**: If your skill uses `bash`, ensure the prompts don't allow arbitrary command injection from untrusted user input.
- **Test Locally**: Use `clawdbot agent --message "use my new skill"` to test.
## Shared Skills
You can also browse and contribute skills to [ClawdHub](https://clawdhub.com).
+34 -1
View File
@@ -33,11 +33,18 @@ does not publish limits, so assume similar or lower limits. citeturn0searc
If you want OpenAI or ElevenLabs:
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
- `OPENAI_API_KEY`
- `OPENAI_API_KEY` (optional for custom OpenAI-compatible endpoints)
Optional:
- `OPENAI_TTS_BASE_URL` (defaults to `https://api.openai.com/v1`)
Edge TTS does **not** require an API key. If no API keys are found, Clawdbot defaults
to Edge TTS (unless disabled via `messages.tts.edge.enabled=false`).
When `OPENAI_TTS_BASE_URL` points to a non-OpenAI endpoint, Clawdbot relaxes
OpenAI model/voice validation and does **not** require an API key. If your
endpoint needs authentication, set `messages.tts.openai.apiKey` or `OPENAI_API_KEY`.
If multiple providers are configured, the selected provider is used first and the others are fallback options.
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
so that provider must also be authenticated if you enable summaries.
@@ -115,6 +122,32 @@ Full schema is in [Gateway configuration](/gateway/configuration).
}
```
### Custom OpenAI-compatible endpoint
Set an endpoint in the environment:
```bash
export OPENAI_TTS_BASE_URL="http://localhost:8880/v1"
```
Then configure TTS as usual (API key optional for custom endpoints):
```json5
{
messages: {
tts: {
auto: "always",
provider: "openai",
openai: {
baseUrl: "http://localhost:8880/v1",
model: "kokoro",
voice: "zm_yunxia"
}
}
}
}
```
### Edge TTS primary (no API key)
```json5
+2 -2
View File
@@ -108,8 +108,8 @@ Clawdbot **blocks** Control UI connections without device identity.
}
```
This disables device identity + pairing for the Control UI (even on HTTPS). Use
only if you trust the network.
This disables device identity + pairing for the Control UI. Use only if you
trust the network.
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
-54
View File
@@ -25,11 +25,9 @@ import { resolveBlueBubblesMessageId } from "./monitor.js";
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js";
import {
extractHandleFromChatGuid,
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesHandle,
normalizeBlueBubblesMessagingTarget,
parseBlueBubblesTarget,
} from "./targets.js";
import { bluebubblesMessageActions } from "./actions.js";
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
@@ -150,58 +148,6 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
looksLikeId: looksLikeBlueBubblesTargetId,
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
},
formatTargetDisplay: ({ target, display }) => {
const shouldParseDisplay = (value: string): boolean => {
if (looksLikeBlueBubblesTargetId(value)) return true;
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
};
// Helper to extract a clean handle from any BlueBubbles target format
const extractCleanDisplay = (value: string | undefined): string | null => {
const trimmed = value?.trim();
if (!trimmed) return null;
try {
const parsed = parseBlueBubblesTarget(trimmed);
if (parsed.kind === "chat_guid") {
const handle = extractHandleFromChatGuid(parsed.chatGuid);
if (handle) return handle;
}
if (parsed.kind === "handle") {
return normalizeBlueBubblesHandle(parsed.to);
}
} catch {
// Fall through
}
// Strip common prefixes and try raw extraction
const stripped = trimmed
.replace(/^bluebubbles:/i, "")
.replace(/^chat_guid:/i, "")
.replace(/^chat_id:/i, "")
.replace(/^chat_identifier:/i, "");
const handle = extractHandleFromChatGuid(stripped);
if (handle) return handle;
// Don't return raw chat_guid formats - they contain internal routing info
if (stripped.includes(";-;") || stripped.includes(";+;")) return null;
return stripped;
};
// Try to get a clean display from the display parameter first
const trimmedDisplay = display?.trim();
if (trimmedDisplay) {
if (!shouldParseDisplay(trimmedDisplay)) {
return trimmedDisplay;
}
const cleanDisplay = extractCleanDisplay(trimmedDisplay);
if (cleanDisplay) return cleanDisplay;
}
// Fall back to extracting from target
const cleanTarget = extractCleanDisplay(target);
if (cleanTarget) return cleanTarget;
// Last resort: return display or target as-is
return display?.trim() || target?.trim() || "";
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
+2 -94
View File
@@ -187,47 +187,6 @@ describe("send", () => {
expect(result).toBe("iMessage;-;+15551234567");
});
it("returns null when handle only exists in group chat (not DM)", async () => {
// This is the critical fix: if a phone number only exists as a participant in a group chat
// (no direct DM chat), we should NOT send to that group. Return null instead.
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;+;group-the-council",
participants: [
{ address: "+12622102921" },
{ address: "+15550001111" },
{ address: "+15550002222" },
],
},
],
}),
})
// Empty second page to stop pagination
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+12622102921",
service: "imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
// Should return null, NOT the group chat GUID
expect(result).toBeNull();
});
it("returns null when chat not found", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
@@ -385,14 +344,14 @@ describe("send", () => {
).rejects.toThrow("password is required");
});
it("throws when chatGuid cannot be resolved for non-handle targets", async () => {
it("throws when chatGuid cannot be resolved", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
await expect(
sendMessageBlueBubbles("chat_id:999", "Hello", {
sendMessageBlueBubbles("+15559999999", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
}),
@@ -439,57 +398,6 @@ describe("send", () => {
expect(body.method).toBeUndefined();
});
it("creates a new chat when handle target is missing", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "new-msg-guid" },
}),
),
});
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("new-msg-guid");
expect(mockFetch).toHaveBeenCalledTimes(2);
const createCall = mockFetch.mock.calls[1];
expect(createCall[0]).toContain("/api/v1/chat/new");
const body = JSON.parse(createCall[1].body);
expect(body.addresses).toEqual(["+15550009999"]);
expect(body.message).toBe("Hello new chat");
});
it("throws when creating a new chat requires Private API", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: false,
status: 403,
text: () => Promise.resolve("Private API not enabled"),
});
await expect(
sendMessageBlueBubbles("+15550008888", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("Private API must be enabled");
});
it("uses private-api when reply metadata is present", async () => {
mockFetch
.mockResolvedValueOnce({
+5 -71
View File
@@ -257,17 +257,11 @@ export async function resolveChatGuidForTarget(params: {
return guid;
}
if (!participantMatch && guid) {
// Only consider DM chats (`;-;` separator) as participant matches.
// Group chats (`;+;` separator) should never match when searching by handle/phone.
// This prevents routing "send to +1234567890" to a group chat that contains that number.
const isDmChat = guid.includes(";-;");
if (isDmChat) {
const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
);
if (participants.includes(normalizedHandle)) {
participantMatch = guid;
}
const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
);
if (participants.includes(normalizedHandle)) {
participantMatch = guid;
}
}
}
@@ -276,55 +270,6 @@ export async function resolveChatGuidForTarget(params: {
return participantMatch;
}
/**
* Creates a new chat (DM) and optionally sends an initial message.
* Requires Private API to be enabled in BlueBubbles.
*/
async function createNewChatWithMessage(params: {
baseUrl: string;
password: string;
address: string;
message: string;
timeoutMs?: number;
}): Promise<BlueBubblesSendResult> {
const url = buildBlueBubblesApiUrl({
baseUrl: params.baseUrl,
path: "/api/v1/chat/new",
password: params.password,
});
const payload = {
addresses: [params.address],
message: params.message,
};
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
params.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text();
// Check for Private API not enabled error
if (res.status === 400 || res.status === 403 || errorText.toLowerCase().includes("private api")) {
throw new Error(
`BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`,
);
}
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
if (!body) return { messageId: "ok" };
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
}
export async function sendMessageBlueBubbles(
to: string,
text: string,
@@ -352,17 +297,6 @@ export async function sendMessageBlueBubbles(
target,
});
if (!chatGuid) {
// If target is a phone number/handle and no existing chat found,
// auto-create a new DM chat using the /api/v1/chat/new endpoint
if (target.kind === "handle") {
return createNewChatWithMessage({
baseUrl,
password,
address: target.address,
message: trimmedText,
timeoutMs: opts.timeoutMs,
});
}
throw new Error(
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
);
-11
View File
@@ -1,11 +0,0 @@
{
"id": "line",
"channels": [
"line"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
-20
View File
@@ -1,20 +0,0 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { linePlugin } from "./src/channel.js";
import { registerLineCardCommand } from "./src/card-command.js";
import { setLineRuntime } from "./src/runtime.js";
const plugin = {
id: "line",
name: "LINE",
description: "LINE Messaging API channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setLineRuntime(api.runtime);
api.registerChannel({ plugin: linePlugin });
registerLineCardCommand(api);
},
};
export default plugin;
-29
View File
@@ -1,29 +0,0 @@
{
"name": "@clawdbot/line",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot LINE channel plugin",
"clawdbot": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "line",
"label": "LINE",
"selectionLabel": "LINE (Messaging API)",
"docsPath": "/channels/line",
"docsLabel": "line",
"blurb": "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
"order": 75,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/line",
"localPath": "extensions/line",
"defaultChoice": "npm"
}
},
"devDependencies": {
"clawdbot": "workspace:*"
}
}
-338
View File
@@ -1,338 +0,0 @@
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)}` };
}
},
});
}
@@ -1,96 +0,0 @@
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({});
});
});
@@ -1,308 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import { linePlugin } from "./channel.js";
import { setLineRuntime } from "./runtime.js";
type LineRuntimeMocks = {
pushMessageLine: ReturnType<typeof vi.fn>;
pushMessagesLine: ReturnType<typeof vi.fn>;
pushFlexMessage: ReturnType<typeof vi.fn>;
pushTemplateMessage: ReturnType<typeof vi.fn>;
pushLocationMessage: ReturnType<typeof vi.fn>;
pushTextMessageWithQuickReplies: ReturnType<typeof vi.fn>;
createQuickReplyItems: ReturnType<typeof vi.fn>;
buildTemplateMessageFromPayload: ReturnType<typeof vi.fn>;
sendMessageLine: ReturnType<typeof vi.fn>;
chunkMarkdownText: ReturnType<typeof vi.fn>;
resolveLineAccount: ReturnType<typeof vi.fn>;
resolveTextChunkLimit: ReturnType<typeof vi.fn>;
};
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
const pushMessageLine = vi.fn(async () => ({ messageId: "m-text", chatId: "c1" }));
const pushMessagesLine = vi.fn(async () => ({ messageId: "m-batch", chatId: "c1" }));
const pushFlexMessage = vi.fn(async () => ({ messageId: "m-flex", chatId: "c1" }));
const pushTemplateMessage = vi.fn(async () => ({ messageId: "m-template", chatId: "c1" }));
const pushLocationMessage = vi.fn(async () => ({ messageId: "m-loc", chatId: "c1" }));
const pushTextMessageWithQuickReplies = vi.fn(async () => ({
messageId: "m-quick",
chatId: "c1",
}));
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
const buildTemplateMessageFromPayload = vi.fn(() => ({ type: "buttons" }));
const sendMessageLine = vi.fn(async () => ({ messageId: "m-media", chatId: "c1" }));
const chunkMarkdownText = vi.fn((text: string) => [text]);
const resolveTextChunkLimit = vi.fn(() => 123);
const resolveLineAccount = vi.fn(({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string }) => {
const resolved = accountId ?? "default";
const lineConfig = (cfg.channels?.line ?? {}) as {
accounts?: Record<string, Record<string, unknown>>;
};
const accountConfig =
resolved !== "default" ? lineConfig.accounts?.[resolved] ?? {} : {};
return {
accountId: resolved,
config: { ...lineConfig, ...accountConfig },
};
});
const runtime = {
channel: {
line: {
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
buildTemplateMessageFromPayload,
sendMessageLine,
resolveLineAccount,
},
text: {
chunkMarkdownText,
resolveTextChunkLimit,
},
},
} as unknown as PluginRuntime;
return {
runtime,
mocks: {
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
buildTemplateMessageFromPayload,
sendMessageLine,
chunkMarkdownText,
resolveLineAccount,
resolveTextChunkLimit,
},
};
}
describe("linePlugin outbound.sendPayload", () => {
it("sends flex message without dropping text", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as ClawdbotConfig;
const payload = {
text: "Now playing:",
channelData: {
line: {
flexMessage: {
altText: "Now playing",
contents: { type: "bubble" },
},
},
},
};
await linePlugin.outbound.sendPayload({
to: "line:group:1",
payload,
accountId: "default",
cfg,
});
expect(mocks.pushFlexMessage).toHaveBeenCalledTimes(1);
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:group:1", "Now playing:", {
verbose: false,
accountId: "default",
});
});
it("sends template message without dropping text", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as ClawdbotConfig;
const payload = {
text: "Choose one:",
channelData: {
line: {
templateMessage: {
type: "confirm",
text: "Continue?",
confirmLabel: "Yes",
confirmData: "yes",
cancelLabel: "No",
cancelData: "no",
},
},
},
};
await linePlugin.outbound.sendPayload({
to: "line:user:1",
payload,
accountId: "default",
cfg,
});
expect(mocks.buildTemplateMessageFromPayload).toHaveBeenCalledTimes(1);
expect(mocks.pushTemplateMessage).toHaveBeenCalledTimes(1);
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:1", "Choose one:", {
verbose: false,
accountId: "default",
});
});
it("attaches quick replies when no text chunks are present", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as ClawdbotConfig;
const payload = {
channelData: {
line: {
quickReplies: ["One", "Two"],
flexMessage: {
altText: "Card",
contents: { type: "bubble" },
},
},
},
};
await linePlugin.outbound.sendPayload({
to: "line:user:2",
payload,
accountId: "default",
cfg,
});
expect(mocks.pushFlexMessage).not.toHaveBeenCalled();
expect(mocks.pushMessagesLine).toHaveBeenCalledWith(
"line:user:2",
[
{
type: "flex",
altText: "Card",
contents: { type: "bubble" },
quickReply: { items: ["One", "Two"] },
},
],
{ verbose: false, accountId: "default" },
);
expect(mocks.createQuickReplyItems).toHaveBeenCalledWith(["One", "Two"]);
});
it("sends media before quick-reply text so buttons stay visible", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: {} } } as ClawdbotConfig;
const payload = {
text: "Hello",
mediaUrl: "https://example.com/img.jpg",
channelData: {
line: {
quickReplies: ["One", "Two"],
},
},
};
await linePlugin.outbound.sendPayload({
to: "line:user:3",
payload,
accountId: "default",
cfg,
});
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:3", "", {
verbose: false,
mediaUrl: "https://example.com/img.jpg",
accountId: "default",
});
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
"line:user:3",
"Hello",
["One", "Two"],
{ verbose: false, accountId: "default" },
);
const mediaOrder = mocks.sendMessageLine.mock.invocationCallOrder[0];
const quickReplyOrder = mocks.pushTextMessageWithQuickReplies.mock.invocationCallOrder[0];
expect(mediaOrder).toBeLessThan(quickReplyOrder);
});
it("uses configured text chunk limit for payloads", async () => {
const { runtime, mocks } = createRuntime();
setLineRuntime(runtime);
const cfg = { channels: { line: { textChunkLimit: 123 } } } as ClawdbotConfig;
const payload = {
text: "Hello world",
channelData: {
line: {
flexMessage: {
altText: "Card",
contents: { type: "bubble" },
},
},
},
};
await linePlugin.outbound.sendPayload({
to: "line:user:3",
payload,
accountId: "primary",
cfg,
});
expect(mocks.resolveTextChunkLimit).toHaveBeenCalledWith(
cfg,
"line",
"primary",
{ fallbackLimit: 5000 },
);
expect(mocks.chunkMarkdownText).toHaveBeenCalledWith("Hello world", 123);
});
});
describe("linePlugin config.formatAllowFrom", () => {
it("strips line:user: prefixes without lowercasing", () => {
const formatted = linePlugin.config.formatAllowFrom({
allowFrom: ["line:user:UABC", "line:UDEF"],
});
expect(formatted).toEqual(["UABC", "UDEF"]);
});
});
describe("linePlugin groups.resolveRequireMention", () => {
it("uses account-level group settings when provided", () => {
const { runtime } = createRuntime();
setLineRuntime(runtime);
const cfg = {
channels: {
line: {
groups: {
"*": { requireMention: false },
},
accounts: {
primary: {
groups: {
"group-1": { requireMention: true },
},
},
},
},
},
} as ClawdbotConfig;
const requireMention = linePlugin.groups.resolveRequireMention({
cfg,
accountId: "primary",
groupId: "group-1",
});
expect(requireMention).toBe(true);
});
});
-773
View File
@@ -1,773 +0,0 @@
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
LineConfigSchema,
processLineMessage,
type ChannelPlugin,
type ClawdbotConfig,
type LineConfig,
type LineChannelData,
type ResolvedLineAccount,
} from "clawdbot/plugin-sdk";
import { getLineRuntime } from "./runtime.js";
// LINE channel metadata
const meta = {
id: "line",
label: "LINE",
selectionLabel: "LINE (Messaging API)",
detailLabel: "LINE Bot",
docsPath: "/channels/line",
docsLabel: "line",
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
systemImage: "message.fill",
};
function parseThreadId(threadId?: string | number | null): number | undefined {
if (threadId == null) return undefined;
if (typeof threadId === "number") {
return Number.isFinite(threadId) ? Math.trunc(threadId) : undefined;
}
const trimmed = threadId.trim();
if (!trimmed) return undefined;
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
id: "line",
meta: {
...meta,
quickstartAllowFrom: true,
},
pairing: {
idLabel: "lineUserId",
normalizeAllowEntry: (entry) => {
// LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:).
return entry.replace(/^line:(?:user:)?/i, "");
},
notifyApproval: async ({ cfg, id }) => {
const line = getLineRuntime().channel.line;
const account = line.resolveLineAccount({ cfg });
if (!account.channelAccessToken) {
throw new Error("LINE channel access token not configured");
}
await line.pushMessageLine(id, "Clawdbot: your access has been approved.", {
channelAccessToken: account.channelAccessToken,
});
},
},
capabilities: {
chatTypes: ["direct", "group"],
reactions: false,
threads: false,
media: true,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.line"] },
configSchema: buildChannelConfigSchema(LineConfigSchema),
config: {
listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg),
resolveAccount: (cfg, accountId) =>
getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }),
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
enabled,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
accounts: {
...lineConfig.accounts,
[accountId]: {
...lineConfig.accounts?.[accountId],
enabled,
},
},
},
},
};
},
deleteAccount: ({ cfg, accountId }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
const { channelAccessToken, channelSecret, tokenFile, secretFile, ...rest } = lineConfig;
return {
...cfg,
channels: {
...cfg.channels,
line: rest,
},
};
}
const accounts = { ...lineConfig.accounts };
delete accounts[accountId];
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
},
},
};
},
isConfigured: (account) => Boolean(account.channelAccessToken?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.channelAccessToken?.trim()),
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => {
// LINE sender IDs are case-sensitive; keep original casing.
return entry.replace(/^line:(?:user:)?/i, "");
}),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg.channels?.line as LineConfig | undefined)?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.line.accounts.${resolvedAccountId}.`
: "channels.line.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: "clawdbot pairing approve line <code>",
normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy =
(cfg.channels?.defaults as { groupPolicy?: string } | undefined)?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- LINE groups: groupPolicy="open" allows any member in groups to trigger. Set channels.line.groupPolicy="allowlist" + channels.line.groupAllowFrom to restrict senders.`,
];
},
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
const account = getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId });
const groups = account.config.groups;
if (!groups) return false;
const groupConfig = groups[groupId] ?? groups["*"];
return groupConfig?.requireMention ?? false;
},
},
messaging: {
normalizeTarget: (target) => {
const trimmed = target.trim();
if (!trimmed) return null;
return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
},
targetResolver: {
looksLikeId: (id) => {
const trimmed = id?.trim();
if (!trimmed) return false;
// LINE user IDs are typically U followed by 32 hex characters
// Group IDs are C followed by 32 hex characters
// Room IDs are R followed by 32 hex characters
return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed);
},
hint: "<userId|groupId|roomId>",
},
},
directory: {
self: async () => null,
listPeers: async () => [],
listGroups: async () => [],
},
setup: {
resolveAccountId: ({ accountId }) =>
getLineRuntime().channel.line.normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
name,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
accounts: {
...lineConfig.accounts,
[accountId]: {
...lineConfig.accounts?.[accountId],
name,
},
},
},
},
};
},
validateInput: ({ accountId, input }) => {
const typedInput = input as {
useEnv?: boolean;
channelAccessToken?: string;
channelSecret?: string;
tokenFile?: string;
secretFile?: string;
};
if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.";
}
if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) {
return "LINE requires channelAccessToken or --token-file (or --use-env).";
}
if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) {
return "LINE requires channelSecret or --secret-file (or --use-env).";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const typedInput = input as {
name?: string;
useEnv?: boolean;
channelAccessToken?: string;
channelSecret?: string;
tokenFile?: string;
secretFile?: string;
};
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
enabled: true,
...(typedInput.name ? { name: typedInput.name } : {}),
...(typedInput.useEnv
? {}
: typedInput.tokenFile
? { tokenFile: typedInput.tokenFile }
: typedInput.channelAccessToken
? { channelAccessToken: typedInput.channelAccessToken }
: {}),
...(typedInput.useEnv
? {}
: typedInput.secretFile
? { secretFile: typedInput.secretFile }
: typedInput.channelSecret
? { channelSecret: typedInput.channelSecret }
: {}),
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
enabled: true,
accounts: {
...lineConfig.accounts,
[accountId]: {
...lineConfig.accounts?.[accountId],
enabled: true,
...(typedInput.name ? { name: typedInput.name } : {}),
...(typedInput.tokenFile
? { tokenFile: typedInput.tokenFile }
: typedInput.channelAccessToken
? { channelAccessToken: typedInput.channelAccessToken }
: {}),
...(typedInput.secretFile
? { secretFile: typedInput.secretFile }
: typedInput.channelSecret
? { channelSecret: typedInput.channelSecret }
: {}),
},
},
},
},
};
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
textChunkLimit: 5000, // LINE allows up to 5000 characters per text message
sendPayload: async ({ to, payload, accountId, cfg }) => {
const runtime = getLineRuntime();
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
const sendText = runtime.channel.line.pushMessageLine;
const sendBatch = runtime.channel.line.pushMessagesLine;
const sendFlex = runtime.channel.line.pushFlexMessage;
const sendTemplate = runtime.channel.line.pushTemplateMessage;
const sendLocation = runtime.channel.line.pushLocationMessage;
const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies;
const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload;
const createQuickReplyItems = runtime.channel.line.createQuickReplyItems;
let lastResult: { messageId: string; chatId: string } | null = null;
const hasQuickReplies = Boolean(lineData.quickReplies?.length);
const quickReply = hasQuickReplies
? createQuickReplyItems(lineData.quickReplies!)
: undefined;
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
if (messages.length === 0) return;
for (let i = 0; i < messages.length; i += 5) {
const result = await sendBatch(to, messages.slice(i, i + 5), {
verbose: false,
accountId: accountId ?? undefined,
});
lastResult = { messageId: result.messageId, chatId: result.chatId };
}
};
const processed = payload.text
? processLineMessage(payload.text)
: { text: "", flexMessages: [] };
const chunkLimit =
runtime.channel.text.resolveTextChunkLimit?.(
cfg,
"line",
accountId ?? undefined,
{
fallbackLimit: 5000,
},
) ?? 5000;
const chunks = processed.text
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
: [];
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
if (!shouldSendQuickRepliesInline) {
if (lineData.flexMessage) {
lastResult = await sendFlex(
to,
lineData.flexMessage.altText,
lineData.flexMessage.contents,
{
verbose: false,
accountId: accountId ?? undefined,
},
);
}
if (lineData.templateMessage) {
const template = buildTemplate(lineData.templateMessage);
if (template) {
lastResult = await sendTemplate(to, template, {
verbose: false,
accountId: accountId ?? undefined,
});
}
}
if (lineData.location) {
lastResult = await sendLocation(to, lineData.location, {
verbose: false,
accountId: accountId ?? undefined,
});
}
for (const flexMsg of processed.flexMessages) {
lastResult = await sendFlex(to, flexMsg.altText, flexMsg.contents, {
verbose: false,
accountId: accountId ?? undefined,
});
}
}
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
for (const url of mediaUrls) {
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
verbose: false,
mediaUrl: url,
accountId: accountId ?? undefined,
});
}
}
if (chunks.length > 0) {
for (let i = 0; i < chunks.length; i += 1) {
const isLast = i === chunks.length - 1;
if (isLast && hasQuickReplies) {
lastResult = await sendQuickReplies(to, chunks[i]!, lineData.quickReplies!, {
verbose: false,
accountId: accountId ?? undefined,
});
} else {
lastResult = await sendText(to, chunks[i]!, {
verbose: false,
accountId: accountId ?? undefined,
});
}
}
} else if (shouldSendQuickRepliesInline) {
const quickReplyMessages: Array<Record<string, unknown>> = [];
if (lineData.flexMessage) {
quickReplyMessages.push({
type: "flex",
altText: lineData.flexMessage.altText.slice(0, 400),
contents: lineData.flexMessage.contents,
});
}
if (lineData.templateMessage) {
const template = buildTemplate(lineData.templateMessage);
if (template) {
quickReplyMessages.push(template);
}
}
if (lineData.location) {
quickReplyMessages.push({
type: "location",
title: lineData.location.title.slice(0, 100),
address: lineData.location.address.slice(0, 100),
latitude: lineData.location.latitude,
longitude: lineData.location.longitude,
});
}
for (const flexMsg of processed.flexMessages) {
quickReplyMessages.push({
type: "flex",
altText: flexMsg.altText.slice(0, 400),
contents: flexMsg.contents,
});
}
for (const url of mediaUrls) {
const trimmed = url?.trim();
if (!trimmed) continue;
quickReplyMessages.push({
type: "image",
originalContentUrl: trimmed,
previewImageUrl: trimmed,
});
}
if (quickReplyMessages.length > 0 && quickReply) {
const lastIndex = quickReplyMessages.length - 1;
quickReplyMessages[lastIndex] = {
...quickReplyMessages[lastIndex],
quickReply,
};
await sendMessageBatch(quickReplyMessages);
}
}
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
for (const url of mediaUrls) {
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
verbose: false,
mediaUrl: url,
accountId: accountId ?? undefined,
});
}
}
if (lastResult) return { channel: "line", ...lastResult };
return { channel: "line", messageId: "empty", chatId: to };
},
sendText: async ({ to, text, accountId }) => {
const runtime = getLineRuntime();
const sendText = runtime.channel.line.pushMessageLine;
const sendFlex = runtime.channel.line.pushFlexMessage;
// Process markdown: extract tables/code blocks, strip formatting
const processed = processLineMessage(text);
// Send cleaned text first (if non-empty)
let result: { messageId: string; chatId: string };
if (processed.text.trim()) {
result = await sendText(to, processed.text, {
verbose: false,
accountId: accountId ?? undefined,
});
} else {
// If text is empty after processing, still need a result
result = { messageId: "processed", chatId: to };
}
// Send flex messages for tables/code blocks
for (const flexMsg of processed.flexMessages) {
await sendFlex(to, flexMsg.altText, flexMsg.contents, {
verbose: false,
accountId: accountId ?? undefined,
});
}
return { channel: "line", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
const send = getLineRuntime().channel.line.sendMessageLine;
const result = await send(to, text, {
verbose: false,
mediaUrl,
accountId: accountId ?? undefined,
});
return { channel: "line", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: ({ account }) => {
const issues: Array<{ level: "error" | "warning"; message: string }> = [];
if (!account.channelAccessToken?.trim()) {
issues.push({
level: "error",
message: "LINE channel access token not configured",
});
}
if (!account.channelSecret?.trim()) {
issues.push({
level: "error",
message: "LINE channel secret not configured",
});
}
return issues;
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
tokenSource: snapshot.tokenSource ?? "none",
running: snapshot.running ?? false,
mode: snapshot.mode ?? null,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs),
buildAccountSnapshot: ({ account, runtime, probe }) => {
const configured = Boolean(account.channelAccessToken?.trim());
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
mode: "webhook",
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const token = account.channelAccessToken.trim();
const secret = account.channelSecret.trim();
let lineBotLabel = "";
try {
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
if (displayName) lineBotLabel = ` (${displayName})`;
} catch (err) {
if (getLineRuntime().logging.shouldLogVerbose()) {
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
}
}
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
return getLineRuntime().channel.line.monitorLineProvider({
channelAccessToken: token,
channelSecret: secret,
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
});
},
logoutAccount: async ({ accountId, cfg }) => {
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
const nextCfg = { ...cfg } as ClawdbotConfig;
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
const nextLine = { ...lineConfig };
let cleared = false;
let changed = false;
if (accountId === DEFAULT_ACCOUNT_ID) {
if (
nextLine.channelAccessToken ||
nextLine.channelSecret ||
nextLine.tokenFile ||
nextLine.secretFile
) {
delete nextLine.channelAccessToken;
delete nextLine.channelSecret;
delete nextLine.tokenFile;
delete nextLine.secretFile;
cleared = true;
changed = true;
}
}
const accounts = nextLine.accounts ? { ...nextLine.accounts } : undefined;
if (accounts && accountId in accounts) {
const entry = accounts[accountId];
if (entry && typeof entry === "object") {
const nextEntry = { ...entry } as Record<string, unknown>;
if (
"channelAccessToken" in nextEntry ||
"channelSecret" in nextEntry ||
"tokenFile" in nextEntry ||
"secretFile" in nextEntry
) {
cleared = true;
delete nextEntry.channelAccessToken;
delete nextEntry.channelSecret;
delete nextEntry.tokenFile;
delete nextEntry.secretFile;
changed = true;
}
if (Object.keys(nextEntry).length === 0) {
delete accounts[accountId];
changed = true;
} else {
accounts[accountId] = nextEntry as typeof entry;
}
}
}
if (accounts) {
if (Object.keys(accounts).length === 0) {
delete nextLine.accounts;
changed = true;
} else {
nextLine.accounts = accounts;
}
}
if (changed) {
if (Object.keys(nextLine).length > 0) {
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
} else {
const nextChannels = { ...nextCfg.channels };
delete (nextChannels as Record<string, unknown>).line;
if (Object.keys(nextChannels).length > 0) {
nextCfg.channels = nextChannels;
} else {
delete nextCfg.channels;
}
}
await getLineRuntime().config.writeConfigFile(nextCfg);
}
const resolved = getLineRuntime().channel.line.resolveLineAccount({
cfg: changed ? nextCfg : cfg,
accountId,
});
const loggedOut = resolved.tokenSource === "none";
return { cleared, envToken: Boolean(envToken), loggedOut };
},
},
agentPrompt: {
messageToolHints: () => [
"",
"### LINE Rich Messages",
"LINE supports rich visual messages. Use these directives in your reply when appropriate:",
"",
"**Quick Replies** (bottom button suggestions):",
" [[quick_replies: Option 1, Option 2, Option 3]]",
"",
"**Location** (map pin):",
" [[location: Place Name | Address | latitude | longitude]]",
"",
"**Confirm Dialog** (yes/no prompt):",
" [[confirm: Question text? | Yes Label | No Label]]",
"",
"**Button Menu** (title + text + buttons):",
" [[buttons: Title | Description | Btn1:action1, Btn2:https://url.com]]",
"",
"**Media Player Card** (music status):",
" [[media_player: Song Title | Artist Name | Source | https://albumart.url | playing]]",
" - Status: 'playing' or 'paused' (optional)",
"",
"**Event Card** (calendar events, meetings):",
" [[event: Event Title | Date | Time | Location | Description]]",
" - Time, Location, Description are optional",
"",
"**Agenda Card** (multiple events/schedule):",
" [[agenda: Schedule Title | Event1:9:00 AM, Event2:12:00 PM, Event3:3:00 PM]]",
"",
"**Device Control Card** (smart devices, TVs, etc.):",
" [[device: Device Name | Device Type | Status | Control1:data1, Control2:data2]]",
"",
"**Apple TV Remote** (full D-pad + transport):",
" [[appletv_remote: Apple TV | Playing]]",
"",
"**Auto-converted**: Markdown tables become Flex cards, code blocks become styled cards.",
"",
"When to use rich messages:",
"- Use [[quick_replies:...]] when offering 2-4 clear options",
"- Use [[confirm:...]] for yes/no decisions",
"- Use [[buttons:...]] for menus with actions/links",
"- Use [[location:...]] when sharing a place",
"- Use [[media_player:...]] when showing what's playing",
"- Use [[event:...]] for calendar event details",
"- Use [[agenda:...]] for a day's schedule or event list",
"- Use [[device:...]] for smart device status/controls",
"- Tables/code in your response auto-convert to visual cards",
],
},
};
-14
View File
@@ -1,14 +0,0 @@
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;
}
@@ -33,7 +33,7 @@ export function registerMatrixAutoJoin(params: {
// For "allowlist" mode, handle invites manually
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
if (autoJoin !== "allowlist") return;
// Get room alias if available
let alias: string | undefined;
let altAliases: string[] = [];
@@ -329,20 +329,16 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
return;
}
const contentInfo =
"info" in content && content.info && typeof content.info === "object"
? (content.info as { mimetype?: string; size?: number })
const contentType =
"info" in content && content.info && "mimetype" in content.info
? (content.info as { mimetype?: string }).mimetype
: undefined;
const contentType = contentInfo?.mimetype;
const contentSize =
typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
if (mediaUrl?.startsWith("mxc://")) {
try {
media = await downloadMatrixMedia({
client,
mxcUrl: mediaUrl,
contentType,
sizeBytes: contentSize,
maxBytes: mediaMaxBytes,
file: contentFile,
});
@@ -25,8 +25,10 @@ describe("downloadMatrixMedia", () => {
it("decrypts encrypted media when file payloads are present", async () => {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const downloadContent = vi.fn().mockResolvedValue(Buffer.from("encrypted"));
const client = {
downloadContent,
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("matrix-bot-sdk").MatrixClient;
@@ -53,8 +55,7 @@ describe("downloadMatrixMedia", () => {
file,
});
// decryptMedia should be called with just the file object (it handles download internally)
expect(decryptMedia).toHaveBeenCalledWith(file);
expect(decryptMedia).toHaveBeenCalled();
expect(saveMediaBuffer).toHaveBeenCalledWith(
Buffer.from("decrypted"),
"image/png",
@@ -63,41 +64,4 @@ describe("downloadMatrixMedia", () => {
);
expect(result?.path).toBe("/tmp/media");
});
it("rejects encrypted media that exceeds maxBytes before decrypting", async () => {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("matrix-bot-sdk").MatrixClient;
const file = {
url: "mxc://example/file",
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
};
await expect(
downloadMatrixMedia({
client,
mxcUrl: "mxc://example/file",
contentType: "image/png",
sizeBytes: 2048,
maxBytes: 1024,
file,
}),
).rejects.toThrow("Matrix media exceeds configured size limit");
expect(decryptMedia).not.toHaveBeenCalled();
expect(saveMediaBuffer).not.toHaveBeenCalled();
});
});
+12 -15
View File
@@ -25,7 +25,7 @@ async function fetchMatrixMediaBuffer(params: {
// matrix-bot-sdk provides mxcToHttp helper
const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) return null;
// Use the client's download method which handles auth
try {
const buffer = await params.client.downloadContent(params.mxcUrl);
@@ -40,7 +40,6 @@ async function fetchMatrixMediaBuffer(params: {
/**
* Download and decrypt encrypted media from a Matrix room.
* Uses matrix-bot-sdk's decryptMedia which handles both download and decryption.
*/
async function fetchEncryptedMediaBuffer(params: {
client: MatrixClient;
@@ -51,13 +50,18 @@ async function fetchEncryptedMediaBuffer(params: {
throw new Error("Cannot decrypt media: crypto not enabled");
}
// decryptMedia handles downloading and decrypting the encrypted content internally
const decrypted = await params.client.crypto.decryptMedia(params.file);
if (decrypted.byteLength > params.maxBytes) {
// Download the encrypted content
const encryptedBuffer = await params.client.downloadContent(params.file.url);
if (encryptedBuffer.byteLength > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
// Decrypt using matrix-bot-sdk crypto
const decrypted = await params.client.crypto.decryptMedia(
Buffer.from(encryptedBuffer),
params.file,
);
return { buffer: decrypted };
}
@@ -65,7 +69,6 @@ export async function downloadMatrixMedia(params: {
client: MatrixClient;
mxcUrl: string;
contentType?: string;
sizeBytes?: number;
maxBytes: number;
file?: EncryptedFile;
}): Promise<{
@@ -74,13 +77,7 @@ export async function downloadMatrixMedia(params: {
placeholder: string;
} | null> {
let fetched: { buffer: Buffer; headerType?: string } | null;
if (
typeof params.sizeBytes === "number" &&
params.sizeBytes > params.maxBytes
) {
throw new Error("Matrix media exceeds configured size limit");
}
if (params.file) {
// Encrypted media
fetched = await fetchEncryptedMediaBuffer({
@@ -96,7 +93,7 @@ export async function downloadMatrixMedia(params: {
maxBytes: params.maxBytes,
});
}
if (!fetched) return null;
const headerType = fetched.headerType ?? params.contentType ?? undefined;
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
@@ -29,7 +29,6 @@ export type RoomMessageEventContent = MessageEventContent & {
file?: EncryptedFile;
info?: {
mimetype?: string;
size?: number;
};
"m.relates_to"?: {
rel_type?: string;
+1 -1
View File
@@ -11,4 +11,4 @@ export function resolveMattermostGroupRequireMention(
});
if (typeof account.requireMention === "boolean") return account.requireMention;
return true;
}
}
@@ -112,4 +112,4 @@ export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMatt
return listMattermostAccountIds(cfg)
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
}
@@ -205,4 +205,4 @@ export async function uploadMattermostFile(
throw new Error("Mattermost file upload failed");
}
return info;
}
}
@@ -147,4 +147,4 @@ export function resolveThreadSessionKeys(params: {
? `${params.baseSessionKey}:thread:${threadId}`
: params.baseSessionKey;
return { sessionKey, parentSessionKey: params.parentSessionKey };
}
}
@@ -67,4 +67,4 @@ export async function probeMattermost(
} finally {
if (timer) clearTimeout(timer);
}
}
}
@@ -39,4 +39,4 @@ export async function promptAccountId(params: PromptAccountIdParams): Promise<st
);
}
return normalized;
}
}
+1 -1
View File
@@ -184,4 +184,4 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
mattermost: { ...cfg.channels?.mattermost, enabled: false },
},
}),
};
};
@@ -22,11 +22,11 @@ parallel:
security = session: security_expert
prompt: "Perform a deep security audit of the changes. Look for OWASP top 10 issues."
context: overview
perf = session: performance_expert
prompt: "Analyze the performance implications. Identify potential bottlenecks or regressions."
context: overview
style = session: reviewer
prompt: "Review for code style, maintainability, and adherence to best practices."
context: overview
-7
View File
@@ -1,12 +1,5 @@
# Changelog
## 2026.1.24
### Changes
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
- Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls.
- Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields.
## 2026.1.23
### Changes
-21
View File
@@ -75,27 +75,6 @@ Notes:
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
- `mock` is a local dev provider (no network calls).
## TTS for calls
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
streaming speech on calls. You can override it under the plugin config with the
same shape — overrides deep-merge with `messages.tts`.
```json5
{
tts: {
provider: "openai",
openai: {
voice: "alloy"
}
}
}
```
Notes:
- Edge TTS is ignored for voice calls (telephony audio needs PCM; Edge output is unreliable).
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
## CLI
```bash
+11 -207
View File
@@ -99,39 +99,16 @@
"label": "Media Stream Path",
"advanced": true
},
"tts.provider": {
"label": "TTS Provider Override",
"help": "Deep-merges with messages.tts (Edge is ignored for calls).",
"tts.model": {
"label": "TTS Model",
"advanced": true
},
"tts.openai.model": {
"label": "OpenAI TTS Model",
"tts.voice": {
"label": "TTS Voice",
"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",
"tts.instructions": {
"label": "TTS Instructions",
"advanced": true
},
"publicUrl": {
@@ -393,193 +370,20 @@
"type": "object",
"additionalProperties": false,
"properties": {
"auto": {
"type": "string",
"enum": [
"off",
"always",
"inbound",
"tagged"
]
},
"enabled": {
"type": "boolean"
},
"mode": {
"type": "string",
"enum": [
"final",
"all"
]
},
"provider": {
"type": "string",
"enum": [
"openai",
"elevenlabs",
"edge"
"openai"
]
},
"summaryModel": {
"model": {
"type": "string"
},
"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": {
"voice": {
"type": "string"
},
"maxTextLength": {
"type": "integer",
"minimum": 1
},
"timeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 120000
"instructions": {
"type": "string"
}
}
},
+3 -21
View File
@@ -74,26 +74,9 @@ const voiceCallConfigSchema = {
},
"streaming.sttModel": { label: "Realtime STT Model", advanced: true },
"streaming.streamPath": { label: "Media Stream Path", advanced: true },
"tts.provider": {
label: "TTS Provider Override",
help: "Deep-merges with messages.tts (Edge is ignored for calls).",
advanced: true,
},
"tts.openai.model": { label: "OpenAI TTS Model", advanced: true },
"tts.openai.voice": { label: "OpenAI TTS Voice", advanced: true },
"tts.openai.apiKey": {
label: "OpenAI API Key",
sensitive: true,
advanced: true,
},
"tts.elevenlabs.modelId": { label: "ElevenLabs Model ID", advanced: true },
"tts.elevenlabs.voiceId": { label: "ElevenLabs Voice ID", advanced: true },
"tts.elevenlabs.apiKey": {
label: "ElevenLabs API Key",
sensitive: true,
advanced: true,
},
"tts.elevenlabs.baseUrl": { label: "ElevenLabs Base URL", advanced: true },
"tts.model": { label: "TTS Model", advanced: true },
"tts.voice": { label: "TTS Voice", advanced: true },
"tts.instructions": { label: "TTS Instructions", advanced: true },
publicUrl: { label: "Public Webhook URL", advanced: true },
skipSignatureVerification: {
label: "Skip Signature Verification",
@@ -178,7 +161,6 @@ const voiceCallPlugin = {
runtimePromise = createVoiceCallRuntime({
config: cfg,
coreConfig: api.config as CoreConfig,
ttsRuntime: api.runtime.tts,
logger: api.logger,
});
}
+22 -73
View File
@@ -82,82 +82,31 @@ export const SttConfigSchema = z
.default({ provider: "openai", model: "whisper-1" });
export type SttConfig = z.infer<typeof SttConfigSchema>;
export const TtsProviderSchema = z.enum(["openai", "elevenlabs", "edge"]);
export const TtsModeSchema = z.enum(["final", "all"]);
export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]);
export const TtsConfigSchema = z
.object({
auto: TtsAutoSchema.optional(),
enabled: z.boolean().optional(),
mode: TtsModeSchema.optional(),
provider: TtsProviderSchema.optional(),
summaryModel: z.string().optional(),
modelOverrides: z
.object({
enabled: z.boolean().optional(),
allowText: z.boolean().optional(),
allowProvider: z.boolean().optional(),
allowVoice: z.boolean().optional(),
allowModelId: z.boolean().optional(),
allowVoiceSettings: z.boolean().optional(),
allowNormalization: z.boolean().optional(),
allowSeed: z.boolean().optional(),
})
.strict()
.optional(),
elevenlabs: z
.object({
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
voiceId: z.string().optional(),
modelId: z.string().optional(),
seed: z.number().int().min(0).max(4294967295).optional(),
applyTextNormalization: z.enum(["auto", "on", "off"]).optional(),
languageCode: z.string().optional(),
voiceSettings: z
.object({
stability: z.number().min(0).max(1).optional(),
similarityBoost: z.number().min(0).max(1).optional(),
style: z.number().min(0).max(1).optional(),
useSpeakerBoost: z.boolean().optional(),
speed: z.number().min(0.5).max(2).optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
openai: z
.object({
apiKey: z.string().optional(),
model: z.string().optional(),
voice: z.string().optional(),
})
.strict()
.optional(),
edge: z
.object({
enabled: z.boolean().optional(),
voice: z.string().optional(),
lang: z.string().optional(),
outputFormat: z.string().optional(),
pitch: z.string().optional(),
rate: z.string().optional(),
volume: z.string().optional(),
saveSubtitles: z.boolean().optional(),
proxy: z.string().optional(),
timeoutMs: z.number().int().min(1000).max(120000).optional(),
})
.strict()
.optional(),
prefsPath: z.string().optional(),
maxTextLength: z.number().int().min(1).optional(),
timeoutMs: z.number().int().min(1000).max(120000).optional(),
/** TTS provider (currently only OpenAI supported) */
provider: z.literal("openai").default("openai"),
/**
* TTS model to use:
* - gpt-4o-mini-tts: newest, supports instructions for tone/style control (recommended)
* - tts-1: lower latency
* - tts-1-hd: higher quality
*/
model: z.string().min(1).default("gpt-4o-mini-tts"),
/**
* Voice ID. For best quality, use marin or cedar.
* All voices: alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse, marin, cedar
*/
voice: z.string().min(1).default("coral"),
/**
* Instructions for speech style (only works with gpt-4o-mini-tts).
* Examples: "Speak in a cheerful tone", "Talk like a sympathetic customer service agent"
*/
instructions: z.string().optional(),
})
.strict()
.optional();
export type VoiceCallTtsConfig = z.infer<typeof TtsConfigSchema>;
.default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
export type TtsConfig = z.infer<typeof TtsConfigSchema>;
// -----------------------------------------------------------------------------
// Webhook Server Configuration
@@ -358,7 +307,7 @@ export const VoiceCallConfigSchema = z
/** STT configuration */
stt: SttConfigSchema,
/** TTS override (deep-merges with core messages.tts) */
/** TTS configuration */
tts: TtsConfigSchema,
/** Store path for call logs */
-6
View File
@@ -2,16 +2,10 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { VoiceCallTtsConfig } from "./config.js";
export type CoreConfig = {
session?: {
store?: string;
};
messages?: {
tts?: VoiceCallTtsConfig;
};
[key: string]: unknown;
};
type CoreAgentDeps = {
+2 -4
View File
@@ -143,7 +143,7 @@ export class CallManager {
// For notify mode with a message, use inline TwiML with <Say>
let inlineTwiml: string | undefined;
if (mode === "notify" && initialMessage) {
const pollyVoice = mapVoiceToPolly(this.config.tts?.openai?.voice);
const pollyVoice = mapVoiceToPolly(this.config.tts.voice);
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
console.log(
`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
@@ -210,13 +210,11 @@ export class CallManager {
this.addTranscriptEntry(call, "bot", text);
// Play TTS
const voice =
this.provider?.name === "twilio" ? this.config.tts?.openai?.voice : undefined;
await this.provider.playTts({
callId,
providerCallId: call.providerCallId,
text,
voice,
voice: this.config.tts.voice,
});
return { success: true };
@@ -19,3 +19,4 @@ export type CallManagerContext = {
transcriptWaiters: Map<CallId, TranscriptWaiter>;
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
};
@@ -175,3 +175,4 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
persistCallRecord(ctx.storePath, call);
}
@@ -31,3 +31,4 @@ export function findCall(params: {
providerCallId: params.callIdOrProviderCallId,
});
}
@@ -68,7 +68,7 @@ export async function initiateCall(
// For notify mode with a message, use inline TwiML with <Say>.
let inlineTwiml: string | undefined;
if (mode === "notify" && initialMessage) {
const pollyVoice = mapVoiceToPolly(ctx.config.tts?.openai?.voice);
const pollyVoice = mapVoiceToPolly(ctx.config.tts.voice);
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
}
@@ -120,13 +120,11 @@ export async function speak(
addTranscriptEntry(call, "bot", text);
const voice =
ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
await ctx.provider.playTts({
callId,
providerCallId: call.providerCallId,
text,
voice,
voice: ctx.config.tts.voice,
});
return { success: true };
@@ -246,3 +244,4 @@ export async function endCall(
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}
@@ -48,3 +48,4 @@ export function addTranscriptEntry(
};
call.transcript.push(entry);
}
@@ -86,3 +86,4 @@ export async function getCallHistoryFromStore(
return calls;
}
@@ -84,3 +84,4 @@ export function waitForFinalTranscript(
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout });
});
}
@@ -7,3 +7,4 @@ export function generateNotifyTwiml(message: string, voice: string): string {
<Hangup/>
</Response>`;
}
@@ -1,97 +0,0 @@
import { describe, expect, it } from "vitest";
import type {
OpenAIRealtimeSTTProvider,
RealtimeSTTSession,
} from "./providers/stt-openai-realtime.js";
import { MediaStreamHandler } from "./media-stream.js";
const createStubSession = (): RealtimeSTTSession => ({
connect: async () => {},
sendAudio: () => {},
waitForTranscript: async () => "",
onPartial: () => {},
onTranscript: () => {},
onSpeechStart: () => {},
close: () => {},
isConnected: () => true,
});
const createStubSttProvider = (): OpenAIRealtimeSTTProvider =>
({
createSession: () => createStubSession(),
}) as unknown as OpenAIRealtimeSTTProvider;
const flush = async (): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, 0));
};
const waitForAbort = (signal: AbortSignal): Promise<void> =>
new Promise((resolve) => {
if (signal.aborted) {
resolve();
return;
}
signal.addEventListener("abort", () => resolve(), { once: true });
});
describe("MediaStreamHandler TTS queue", () => {
it("serializes TTS playback and resolves in order", async () => {
const handler = new MediaStreamHandler({
sttProvider: createStubSttProvider(),
});
const started: number[] = [];
const finished: number[] = [];
let resolveFirst!: () => void;
const firstGate = new Promise<void>((resolve) => {
resolveFirst = resolve;
});
const first = handler.queueTts("stream-1", async () => {
started.push(1);
await firstGate;
finished.push(1);
});
const second = handler.queueTts("stream-1", async () => {
started.push(2);
finished.push(2);
});
await flush();
expect(started).toEqual([1]);
resolveFirst();
await first;
await second;
expect(started).toEqual([1, 2]);
expect(finished).toEqual([1, 2]);
});
it("cancels active playback and clears queued items", async () => {
const handler = new MediaStreamHandler({
sttProvider: createStubSttProvider(),
});
let queuedRan = false;
const started: string[] = [];
const active = handler.queueTts("stream-1", async (signal) => {
started.push("active");
await waitForAbort(signal);
});
void handler.queueTts("stream-1", async () => {
queuedRan = true;
});
await flush();
expect(started).toEqual(["active"]);
handler.clearTtsQueue("stream-1");
await active;
await flush();
expect(queuedRan).toBe(false);
});
});
-114
View File
@@ -29,8 +29,6 @@ export interface MediaStreamConfig {
onPartialTranscript?: (callId: string, partial: string) => void;
/** Callback when stream connects */
onConnect?: (callId: string, streamSid: string) => void;
/** Callback when speech starts (barge-in) */
onSpeechStart?: (callId: string) => void;
/** Callback when stream disconnects */
onDisconnect?: (callId: string) => void;
}
@@ -45,13 +43,6 @@ interface StreamSession {
sttSession: RealtimeSTTSession;
}
type TtsQueueEntry = {
playFn: (signal: AbortSignal) => Promise<void>;
controller: AbortController;
resolve: () => void;
reject: (error: unknown) => void;
};
/**
* Manages WebSocket connections for Twilio media streams.
*/
@@ -59,12 +50,6 @@ export class MediaStreamHandler {
private wss: WebSocketServer | null = null;
private sessions = new Map<string, StreamSession>();
private config: MediaStreamConfig;
/** TTS playback queues per stream (serialize audio to prevent overlap) */
private ttsQueues = new Map<string, TtsQueueEntry[]>();
/** Whether TTS is currently playing per stream */
private ttsPlaying = new Map<string, boolean>();
/** Active TTS playback controllers per stream */
private ttsActiveControllers = new Map<string, AbortController>();
constructor(config: MediaStreamConfig) {
this.config = config;
@@ -163,10 +148,6 @@ export class MediaStreamHandler {
this.config.onTranscript?.(callSid, transcript);
});
sttSession.onSpeechStart(() => {
this.config.onSpeechStart?.(callSid);
});
const session: StreamSession = {
callId: callSid,
streamSid,
@@ -196,7 +177,6 @@ export class MediaStreamHandler {
private handleStop(session: StreamSession): void {
console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
this.clearTtsState(session.streamSid);
session.sttSession.close();
this.sessions.delete(session.streamSid);
this.config.onDisconnect?.(session.callId);
@@ -248,46 +228,6 @@ export class MediaStreamHandler {
this.sendToStream(streamSid, { event: "clear", streamSid });
}
/**
* Queue a TTS operation for sequential playback.
* Only one TTS operation plays at a time per stream to prevent overlap.
*/
async queueTts(
streamSid: string,
playFn: (signal: AbortSignal) => Promise<void>,
): Promise<void> {
const queue = this.getTtsQueue(streamSid);
let resolveEntry: () => void;
let rejectEntry: (error: unknown) => void;
const promise = new Promise<void>((resolve, reject) => {
resolveEntry = resolve;
rejectEntry = reject;
});
queue.push({
playFn,
controller: new AbortController(),
resolve: resolveEntry!,
reject: rejectEntry!,
});
if (!this.ttsPlaying.get(streamSid)) {
void this.processQueue(streamSid);
}
return promise;
}
/**
* Clear TTS queue and interrupt current playback (barge-in).
*/
clearTtsQueue(streamSid: string): void {
const queue = this.getTtsQueue(streamSid);
queue.length = 0;
this.ttsActiveControllers.get(streamSid)?.abort();
this.clearAudio(streamSid);
}
/**
* Get active session by call ID.
*/
@@ -302,65 +242,11 @@ export class MediaStreamHandler {
*/
closeAll(): void {
for (const session of this.sessions.values()) {
this.clearTtsState(session.streamSid);
session.sttSession.close();
session.ws.close();
}
this.sessions.clear();
}
private getTtsQueue(streamSid: string): TtsQueueEntry[] {
const existing = this.ttsQueues.get(streamSid);
if (existing) return existing;
const queue: TtsQueueEntry[] = [];
this.ttsQueues.set(streamSid, queue);
return queue;
}
/**
* Process the TTS queue for a stream.
* Uses iterative approach to avoid stack accumulation from recursion.
*/
private async processQueue(streamSid: string): Promise<void> {
this.ttsPlaying.set(streamSid, true);
while (true) {
const queue = this.ttsQueues.get(streamSid);
if (!queue || queue.length === 0) {
this.ttsPlaying.set(streamSid, false);
this.ttsActiveControllers.delete(streamSid);
return;
}
const entry = queue.shift()!;
this.ttsActiveControllers.set(streamSid, entry.controller);
try {
await entry.playFn(entry.controller.signal);
entry.resolve();
} catch (error) {
if (entry.controller.signal.aborted) {
entry.resolve();
} else {
console.error("[MediaStream] TTS playback error:", error);
entry.reject(error);
}
} finally {
if (this.ttsActiveControllers.get(streamSid) === entry.controller) {
this.ttsActiveControllers.delete(streamSid);
}
}
}
}
private clearTtsState(streamSid: string): void {
const queue = this.ttsQueues.get(streamSid);
if (queue) queue.length = 0;
this.ttsActiveControllers.get(streamSid)?.abort();
this.ttsActiveControllers.delete(streamSid);
this.ttsPlaying.delete(streamSid);
this.ttsQueues.delete(streamSid);
}
}
/**
@@ -26,3 +26,4 @@ describe("PlivoProvider", () => {
expect(result.providerResponseBody).toContain('length="300"');
});
});
@@ -38,8 +38,6 @@ export interface RealtimeSTTSession {
onPartial(callback: (partial: string) => void): void;
/** Set callback for final transcripts */
onTranscript(callback: (transcript: string) => void): void;
/** Set callback when speech starts (VAD) */
onSpeechStart(callback: () => void): void;
/** Close the session */
close(): void;
/** Check if session is connected */
@@ -93,7 +91,6 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
private pendingTranscript = "";
private onTranscriptCallback: ((transcript: string) => void) | null = null;
private onPartialCallback: ((partial: string) => void) | null = null;
private onSpeechStartCallback: (() => void) | null = null;
constructor(
private readonly apiKey: string,
@@ -246,7 +243,6 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
case "input_audio_buffer.speech_started":
console.log("[RealtimeSTT] Speech started");
this.pendingTranscript = "";
this.onSpeechStartCallback?.();
break;
case "error":
@@ -277,10 +273,6 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
this.onTranscriptCallback = callback;
}
onSpeechStart(callback: () => void): void {
this.onSpeechStartCallback = callback;
}
async waitForTranscript(timeoutMs = 30000): Promise<string> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
+23 -41
View File
@@ -15,9 +15,9 @@ import type {
WebhookVerificationResult,
} from "../types.js";
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
import { chunkAudio } from "../telephony-audio.js";
import type { TelephonyTtsProvider } from "../telephony-tts.js";
import type { VoiceCallProvider } from "./base.js";
import type { OpenAITTSProvider } from "./tts-openai.js";
import { chunkAudio } from "./tts-openai.js";
import { twilioApiRequest } from "./twilio/api.js";
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
@@ -53,8 +53,8 @@ export class TwilioProvider implements VoiceCallProvider {
/** Current public webhook URL (set when tunnel starts or from config) */
private currentPublicUrl: string | null = null;
/** Optional telephony TTS provider for streaming TTS */
private ttsProvider: TelephonyTtsProvider | null = null;
/** Optional OpenAI TTS provider for streaming TTS */
private ttsProvider: OpenAITTSProvider | null = null;
/** Optional media stream handler for sending audio */
private mediaStreamHandler: MediaStreamHandler | null = null;
@@ -119,7 +119,7 @@ export class TwilioProvider implements VoiceCallProvider {
return this.currentPublicUrl;
}
setTTSProvider(provider: TelephonyTtsProvider): void {
setTTSProvider(provider: OpenAITTSProvider): void {
this.ttsProvider = provider;
}
@@ -135,17 +135,6 @@ export class TwilioProvider implements VoiceCallProvider {
this.callStreamMap.delete(callSid);
}
/**
* Clear TTS queue for a call (barge-in).
* Used when user starts speaking to interrupt current TTS playback.
*/
clearTtsQueue(callSid: string): void {
const streamSid = this.callStreamMap.get(callSid);
if (streamSid && this.mediaStreamHandler) {
this.mediaStreamHandler.clearTtsQueue(streamSid);
}
}
/**
* Make an authenticated request to the Twilio API.
*/
@@ -465,13 +454,13 @@ export class TwilioProvider implements VoiceCallProvider {
* Play TTS audio via Twilio.
*
* Two modes:
* 1. Core TTS + Media Streams: If TTS provider and media stream are available,
* generates audio via core TTS and streams it through WebSocket (preferred).
* 1. OpenAI TTS + Media Streams: If TTS provider and media stream are available,
* generates audio via OpenAI and streams it through WebSocket (preferred).
* 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices.
* Note: This may not work on all Twilio accounts.
*/
async playTts(input: PlayTtsInput): Promise<void> {
// Try telephony TTS via media stream first (if configured)
// Try OpenAI TTS via media stream first (if configured)
const streamSid = this.callStreamMap.get(input.providerCallId);
if (this.ttsProvider && this.mediaStreamHandler && streamSid) {
try {
@@ -479,7 +468,7 @@ export class TwilioProvider implements VoiceCallProvider {
return;
} catch (err) {
console.warn(
`[voice-call] Telephony TTS failed, falling back to Twilio <Say>:`,
`[voice-call] OpenAI TTS failed, falling back to Twilio <Say>:`,
err instanceof Error ? err.message : err,
);
// Fall through to TwiML <Say> fallback
@@ -495,7 +484,7 @@ export class TwilioProvider implements VoiceCallProvider {
}
console.warn(
"[voice-call] Using TwiML <Say> fallback - telephony TTS not configured or media stream not active",
"[voice-call] Using TwiML <Say> fallback - OpenAI TTS not configured or media stream not active",
);
const pollyVoice = mapVoiceToPolly(input.voice);
@@ -513,9 +502,9 @@ export class TwilioProvider implements VoiceCallProvider {
}
/**
* Play TTS via core TTS and Twilio Media Streams.
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
* Uses a queue to serialize playback and prevent overlapping audio.
* Play TTS via OpenAI and Twilio Media Streams.
* Generates audio with OpenAI TTS, converts to mu-law, and streams via WebSocket.
* Uses a jitter buffer to smooth out timing variations.
*/
private async playTtsViaStream(
text: string,
@@ -525,29 +514,22 @@ export class TwilioProvider implements VoiceCallProvider {
throw new Error("TTS provider and media stream handler required");
}
// Generate audio with OpenAI TTS (returns mu-law at 8kHz)
const muLawAudio = await this.ttsProvider.synthesizeForTwilio(text);
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
const CHUNK_SIZE = 160;
const CHUNK_DELAY_MS = 20;
const handler = this.mediaStreamHandler;
const ttsProvider = this.ttsProvider;
await handler.queueTts(streamSid, async (signal) => {
// Generate audio with core TTS (returns mu-law at 8kHz)
const muLawAudio = await ttsProvider.synthesizeForTelephony(text);
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
if (signal.aborted) break;
handler.sendAudio(streamSid, chunk);
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
this.mediaStreamHandler.sendAudio(streamSid, chunk);
// Pace the audio to match real-time playback
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
if (signal.aborted) break;
}
// Pace the audio to match real-time playback
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
}
if (!signal.aborted) {
// Send a mark to track when audio finishes
handler.sendMark(streamSid, `tts-${Date.now()}`);
}
});
// Send a mark to track when audio finishes
this.mediaStreamHandler.sendMark(streamSid, `tts-${Date.now()}`);
}
/**
@@ -27,3 +27,4 @@ export function verifyTwilioProviderWebhook(params: {
reason: result.reason,
};
}
+13 -12
View File
@@ -6,9 +6,8 @@ import type { VoiceCallProvider } from "./providers/base.js";
import { MockProvider } from "./providers/mock.js";
import { PlivoProvider } from "./providers/plivo.js";
import { TelnyxProvider } from "./providers/telnyx.js";
import { OpenAITTSProvider } from "./providers/tts-openai.js";
import { TwilioProvider } from "./providers/twilio.js";
import type { TelephonyTtsRuntime } from "./telephony-tts.js";
import { createTelephonyTtsProvider } from "./telephony-tts.js";
import { startTunnel, type TunnelResult } from "./tunnel.js";
import {
cleanupTailscaleExposure,
@@ -82,10 +81,9 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
export async function createVoiceCallRuntime(params: {
config: VoiceCallConfig;
coreConfig: CoreConfig;
ttsRuntime?: TelephonyTtsRuntime;
logger?: Logger;
}): Promise<VoiceCallRuntime> {
const { config, coreConfig, ttsRuntime, logger } = params;
const { config, coreConfig, logger } = params;
const log = logger ?? {
info: console.log,
warn: console.warn,
@@ -151,24 +149,27 @@ export async function createVoiceCallRuntime(params: {
if (provider.name === "twilio" && config.streaming?.enabled) {
const twilioProvider = provider as TwilioProvider;
if (ttsRuntime?.textToSpeechTelephony) {
const openaiApiKey =
config.streaming.openaiApiKey || process.env.OPENAI_API_KEY;
if (openaiApiKey) {
try {
const ttsProvider = createTelephonyTtsProvider({
coreConfig,
ttsOverride: config.tts,
runtime: ttsRuntime,
const ttsProvider = new OpenAITTSProvider({
apiKey: openaiApiKey,
voice: config.tts.voice,
model: config.tts.model,
instructions: config.tts.instructions,
});
twilioProvider.setTTSProvider(ttsProvider);
log.info("[voice-call] Telephony TTS provider configured");
log.info("[voice-call] OpenAI TTS provider configured");
} catch (err) {
log.warn(
`[voice-call] Failed to initialize telephony TTS: ${
`[voice-call] Failed to initialize OpenAI TTS: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
} else {
log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled");
log.warn("[voice-call] OpenAI TTS key missing; streaming TTS disabled");
}
const mediaHandler = webhookServer.getMediaStreamHandler();
@@ -1,88 +0,0 @@
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;
}
@@ -1,95 +0,0 @@
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);
}
Loaded 100 of 291 files, more files were not shown because too many files have changed in this diff. Show more