Merge branch 'main' into feature/twitch-plugin
This commit is contained in:
commit
be3a16f294
@ -7,6 +7,10 @@
|
|||||||
[exclude-files]
|
[exclude-files]
|
||||||
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
||||||
pattern = (^|/)pnpm-lock\.yaml$
|
pattern = (^|/)pnpm-lock\.yaml$
|
||||||
|
# Generated output and vendored assets.
|
||||||
|
pattern = (^|/)(dist|vendor)/
|
||||||
|
# Local config file with allowlist patterns.
|
||||||
|
pattern = (^|/)\.detect-secrets\.cfg$
|
||||||
|
|
||||||
[exclude-lines]
|
[exclude-lines]
|
||||||
# Fastlane checks for private key marker; not a real key.
|
# Fastlane checks for private key marker; not a real key.
|
||||||
|
|||||||
17
.github/actionlint.yaml
vendored
Normal file
17
.github/actionlint.yaml
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# 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
.github/dependabot.yml
vendored
Normal file
113
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# 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
|
||||||
105
.pre-commit-config.yaml
Normal file
105
.pre-commit-config.yaml
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# 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]
|
||||||
1923
.secrets.baseline
1923
.secrets.baseline
File diff suppressed because it is too large
Load Diff
25
.shellcheckrc
Normal file
25
.shellcheckrc
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# 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
|
||||||
@ -37,6 +37,7 @@
|
|||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||||
- Install deps: `pnpm install`
|
- 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).
|
- 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>`.
|
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||||
|
|||||||
@ -12,6 +12,8 @@ Docs: https://docs.clawd.bot
|
|||||||
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
|
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
|
||||||
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
||||||
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
||||||
|
- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.
|
||||||
|
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
|
||||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||||
- Docs: add verbose installer troubleshooting guidance.
|
- Docs: add verbose installer troubleshooting guidance.
|
||||||
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
||||||
@ -22,8 +24,11 @@ Docs: https://docs.clawd.bot
|
|||||||
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
- 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: 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.
|
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
|
||||||
|
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
|
||||||
|
- 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.
|
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||||
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||||
@ -32,18 +37,22 @@ Docs: https://docs.clawd.bot
|
|||||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
||||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||||
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||||
|
- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.
|
||||||
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
||||||
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
|
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
|
||||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||||
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||||
- Agents: use the active auth profile for auto-compaction recovery.
|
- Agents: use the active auth profile for auto-compaction recovery.
|
||||||
- Models: default missing custom provider fields so minimal configs are accepted.
|
- Models: default missing custom provider fields so minimal configs are accepted.
|
||||||
|
- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.
|
||||||
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
||||||
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||||
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
|
- Gateway: 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: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
|
||||||
|
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
|
||||||
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||||
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||||
|
- 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: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||||
- Google Chat: normalize space targets without double `spaces/` prefix.
|
- Google Chat: normalize space targets without double `spaces/` prefix.
|
||||||
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
||||||
|
|||||||
@ -12,4 +12,3 @@ 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:
|
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
|
||||||
|
|
||||||
- `https://docs.clawd.bot/gateway/security`
|
- `https://docs.clawd.bot/gateway/security`
|
||||||
|
|
||||||
|
|||||||
@ -12,4 +12,3 @@ data class CameraHudState(
|
|||||||
val kind: CameraHudKind,
|
val kind: CameraHudKind,
|
||||||
val message: String,
|
val message: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -12,4 +12,3 @@ enum class VoiceWakeMode(val rawValue: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#0A0A0A</color>
|
<color name="ic_launcher_background">#0A0A0A</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Clawdbot Node</string>
|
<string name="app_name">Clawdbot Node</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@ -23,4 +23,3 @@ class VoiceWakeCommandExtractorTest {
|
|||||||
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
|
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,4 +16,3 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "ClawdbotNodeAndroid"
|
rootProject.name = "ClawdbotNodeAndroid"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|
||||||
|
|||||||
@ -3,4 +3,3 @@ parent_config: ../../.swiftlint.yml
|
|||||||
included:
|
included:
|
||||||
- Sources
|
- Sources
|
||||||
- ../shared/ClawdisNodeKit/Sources
|
- ../shared/ClawdisNodeKit/Sources
|
||||||
|
|
||||||
|
|||||||
@ -574,46 +574,22 @@ public actor GatewayChannelActor {
|
|||||||
params: [String: AnyCodable]?,
|
params: [String: AnyCodable]?,
|
||||||
timeoutMs: Double? = nil) async throws -> Data
|
timeoutMs: Double? = nil) async throws -> Data
|
||||||
{
|
{
|
||||||
do {
|
try await self.connectOrThrow(context: "gateway connect")
|
||||||
try await self.connect()
|
|
||||||
} catch {
|
|
||||||
throw self.wrap(error, context: "gateway connect")
|
|
||||||
}
|
|
||||||
let id = UUID().uuidString
|
|
||||||
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
|
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
|
||||||
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
|
let payload = try self.encodeRequest(method: method, params: params, kind: "request")
|
||||||
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
|
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
||||||
self.pending[id] = cont
|
self.pending[payload.id] = cont
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
|
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
|
||||||
await self.timeoutRequest(id: id, timeoutMs: effectiveTimeout)
|
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout)
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await self.task?.send(.data(data))
|
try await self.task?.send(.data(payload.data))
|
||||||
} catch {
|
} catch {
|
||||||
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
||||||
let waiter = self.pending.removeValue(forKey: id)
|
let waiter = self.pending.removeValue(forKey: payload.id)
|
||||||
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
|
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
|
||||||
self.connected = false
|
self.connected = false
|
||||||
self.task?.cancel(with: .goingAway, reason: nil)
|
self.task?.cancel(with: .goingAway, reason: nil)
|
||||||
@ -643,6 +619,29 @@ public actor GatewayChannelActor {
|
|||||||
return Data() // Should not happen, but tolerate empty payloads.
|
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.
|
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
|
||||||
private func wrap(_ error: Error, context: String) -> Error {
|
private func wrap(_ error: Error, context: String) -> Error {
|
||||||
if let urlError = error as? URLError {
|
if let urlError = error as? URLError {
|
||||||
@ -657,6 +656,42 @@ public actor GatewayChannelActor {
|
|||||||
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
|
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 {
|
private func failPending(_ error: Error) async {
|
||||||
let waiters = self.pending
|
let waiters = self.pending
|
||||||
self.pending.removeAll()
|
self.pending.removeAll()
|
||||||
|
|||||||
@ -143,7 +143,7 @@ public actor GatewayNodeSession {
|
|||||||
"payloadJSON": AnyCodable(payloadJSON ?? NSNull()),
|
"payloadJSON": AnyCodable(payloadJSON ?? NSNull()),
|
||||||
]
|
]
|
||||||
do {
|
do {
|
||||||
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 8000)
|
try await channel.send(method: "node.event", params: params)
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
@ -224,7 +224,7 @@ public actor GatewayNodeSession {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
_ = try await channel.request(method: "node.invoke.result", params: params, timeoutMs: 15000)
|
try await channel.send(method: "node.invoke.result", params: params)
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|||||||
1
dist/control-ui/assets/index-08nzABV3.css
vendored
Normal file
1
dist/control-ui/assets/index-08nzABV3.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/control-ui/assets/index-BvhR9FCb.css
vendored
1
dist/control-ui/assets/index-BvhR9FCb.css
vendored
File diff suppressed because one or more lines are too long
3119
dist/control-ui/assets/index-DQcOTEYz.js
vendored
Normal file
3119
dist/control-ui/assets/index-DQcOTEYz.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/control-ui/assets/index-DQcOTEYz.js.map
vendored
Normal file
1
dist/control-ui/assets/index-DQcOTEYz.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3059
dist/control-ui/assets/index-DsXRcnEw.js
vendored
3059
dist/control-ui/assets/index-DsXRcnEw.js
vendored
File diff suppressed because one or more lines are too long
1
dist/control-ui/assets/index-DsXRcnEw.js.map
vendored
1
dist/control-ui/assets/index-DsXRcnEw.js.map
vendored
File diff suppressed because one or more lines are too long
3047
dist/control-ui/assets/index-bYQnHP3a.js
vendored
3047
dist/control-ui/assets/index-bYQnHP3a.js
vendored
File diff suppressed because one or more lines are too long
1
dist/control-ui/assets/index-bYQnHP3a.js.map
vendored
1
dist/control-ui/assets/index-bYQnHP3a.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/control-ui/index.html
vendored
4
dist/control-ui/index.html
vendored
@ -6,8 +6,8 @@
|
|||||||
<title>Clawdbot Control</title>
|
<title>Clawdbot Control</title>
|
||||||
<meta name="color-scheme" content="dark light" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
<link rel="icon" href="./favicon.ico" sizes="any" />
|
<link rel="icon" href="./favicon.ico" sizes="any" />
|
||||||
<script type="module" crossorigin src="./assets/index-DsXRcnEw.js"></script>
|
<script type="module" crossorigin src="./assets/index-DQcOTEYz.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-BvhR9FCb.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<clawdbot-app></clawdbot-app>
|
<clawdbot-app></clawdbot-app>
|
||||||
|
|||||||
@ -213,6 +213,7 @@ Prefer `chat_guid` for stable routing:
|
|||||||
- `chat_id:123`
|
- `chat_id:123`
|
||||||
- `chat_identifier:...`
|
- `chat_identifier:...`
|
||||||
- Direct handles: `+15555550123`, `user@example.com`
|
- 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
|
## Security
|
||||||
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
||||||
|
|||||||
89
docs/diagnostics/flags.md
Normal file
89
docs/diagnostics/flags.md
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@ -138,6 +138,7 @@ 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)
|
- [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)
|
- [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)
|
- [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 “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)
|
- [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)
|
- [Can I use MiniMax as my default and OpenAI for complex tasks?](#can-i-use-minimax-as-my-default-and-openai-for-complex-tasks)
|
||||||
@ -1947,6 +1948,16 @@ 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>`).
|
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.
|
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
|
### 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
|
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any
|
||||||
|
|||||||
@ -192,6 +192,30 @@ 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
|
### Export to OpenTelemetry
|
||||||
|
|
||||||
Diagnostics can be exported via the `diagnostics-otel` plugin (OTLP/HTTP). This
|
Diagnostics can be exported via the `diagnostics-otel` plugin (OTLP/HTTP). This
|
||||||
|
|||||||
@ -80,6 +80,7 @@ primary_region = "iad"
|
|||||||
|---------|-----|
|
|---------|-----|
|
||||||
| `--bind lan` | Binds to `0.0.0.0` so Fly's proxy can reach the gateway |
|
| `--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) |
|
| `--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 |
|
| `memory = "2048mb"` | 512MB is too small; 2GB recommended |
|
||||||
| `CLAWDBOT_STATE_DIR = "/data"` | Persists state on the volume |
|
| `CLAWDBOT_STATE_DIR = "/data"` | Persists state on the volume |
|
||||||
|
|
||||||
@ -235,6 +236,12 @@ 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`.
|
**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
|
### OOM / Memory Issues
|
||||||
|
|
||||||
Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts.
|
Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts.
|
||||||
@ -268,11 +275,11 @@ The lock file is at `/data/gateway.*.lock` (not in a subdirectory).
|
|||||||
|
|
||||||
### Config Not Being Read
|
### Config Not Being Read
|
||||||
|
|
||||||
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/.clawdbot/clawdbot.json` should be read on restart.
|
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/clawdbot.json` should be read on restart.
|
||||||
|
|
||||||
Verify the config exists:
|
Verify the config exists:
|
||||||
```bash
|
```bash
|
||||||
fly ssh console --command "cat /data/.clawdbot/clawdbot.json"
|
fly ssh console --command "cat /data/clawdbot.json"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Writing Config via SSH
|
### Writing Config via SSH
|
||||||
@ -281,18 +288,24 @@ The `fly ssh console -C` command doesn't support shell redirection. To write a c
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Use echo + tee (pipe from local to remote)
|
# Use echo + tee (pipe from local to remote)
|
||||||
echo '{"your":"config"}' | fly ssh console -C "tee /data/.clawdbot/clawdbot.json"
|
echo '{"your":"config"}' | fly ssh console -C "tee /data/clawdbot.json"
|
||||||
|
|
||||||
# Or use sftp
|
# Or use sftp
|
||||||
fly sftp shell
|
fly sftp shell
|
||||||
> put /local/path/config.json /data/.clawdbot/clawdbot.json
|
> put /local/path/config.json /data/clawdbot.json
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** `fly sftp` may fail if the file already exists. Delete first:
|
**Note:** `fly sftp` may fail if the file already exists. Delete first:
|
||||||
```bash
|
```bash
|
||||||
fly ssh console --command "rm /data/.clawdbot/clawdbot.json"
|
fly ssh console --command "rm /data/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
|
## Updates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -330,6 +343,7 @@ fly machine update <machine-id> --vm-memory 2048 --command "node dist/index.js g
|
|||||||
- The Dockerfile is compatible with both architectures
|
- The Dockerfile is compatible with both architectures
|
||||||
- For WhatsApp/Telegram onboarding, use `fly ssh console`
|
- For WhatsApp/Telegram onboarding, use `fly ssh console`
|
||||||
- Persistent data lives on the volume at `/data`
|
- Persistent data lives on the volume at `/data`
|
||||||
|
- Signal requires Java + signal-cli; use a custom image and keep memory at 2GB+.
|
||||||
|
|
||||||
## Cost
|
## Cost
|
||||||
|
|
||||||
|
|||||||
@ -67,6 +67,22 @@ Plugins can register:
|
|||||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||||
|
|
||||||
|
## Runtime helpers
|
||||||
|
|
||||||
|
Plugins can access selected core helpers via `api.runtime`. For telephony TTS:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const result = await api.runtime.tts.textToSpeechTelephony({
|
||||||
|
text: "Hello from Clawdbot",
|
||||||
|
cfg: api.config,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Uses core `messages.tts` configuration (OpenAI or ElevenLabs).
|
||||||
|
- Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
|
||||||
|
- Edge TTS is not supported for telephony.
|
||||||
|
|
||||||
## Discovery & precedence
|
## Discovery & precedence
|
||||||
|
|
||||||
Clawdbot scans, in order:
|
Clawdbot scans, in order:
|
||||||
|
|||||||
@ -104,6 +104,87 @@ Notes:
|
|||||||
- `mock` is a local dev provider (no network calls).
|
- `mock` is a local dev provider (no network calls).
|
||||||
- `skipSignatureVerification` is for local testing only.
|
- `skipSignatureVerification` is for local testing only.
|
||||||
|
|
||||||
|
## TTS for calls
|
||||||
|
|
||||||
|
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
|
||||||
|
streaming speech on calls. You can override it under the plugin config with the
|
||||||
|
**same shape** — it deep‑merges with `messages.tts`.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
tts: {
|
||||||
|
provider: "elevenlabs",
|
||||||
|
elevenlabs: {
|
||||||
|
voiceId: "pMsXgVXv3BLzUgSXRplE",
|
||||||
|
modelId: "eleven_multilingual_v2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- **Edge TTS is ignored for voice calls** (telephony audio needs PCM; Edge output is unreliable).
|
||||||
|
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
|
||||||
|
|
||||||
|
### More examples
|
||||||
|
|
||||||
|
Use core TTS only (no override):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
messages: {
|
||||||
|
tts: {
|
||||||
|
provider: "openai",
|
||||||
|
openai: { voice: "alloy" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Override to ElevenLabs just for calls (keep core default elsewhere):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
"voice-call": {
|
||||||
|
config: {
|
||||||
|
tts: {
|
||||||
|
provider: "elevenlabs",
|
||||||
|
elevenlabs: {
|
||||||
|
apiKey: "elevenlabs_key",
|
||||||
|
voiceId: "pMsXgVXv3BLzUgSXRplE",
|
||||||
|
modelId: "eleven_multilingual_v2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Override only the OpenAI model for calls (deep‑merge example):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
"voice-call": {
|
||||||
|
config: {
|
||||||
|
tts: {
|
||||||
|
openai: {
|
||||||
|
model: "gpt-4o-mini-tts",
|
||||||
|
voice: "marin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Inbound calls
|
## Inbound calls
|
||||||
|
|
||||||
Inbound policy defaults to `disabled`. To enable inbound calls, set:
|
Inbound policy defaults to `disabled`. To enable inbound calls, set:
|
||||||
|
|||||||
@ -16,7 +16,7 @@ and you configure everything via the `/setup` web wizard.
|
|||||||
|
|
||||||
## One-click deploy
|
## One-click deploy
|
||||||
|
|
||||||
<a href="https://railway.com/deploy/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
|
<a href="https://railway.app/new/template?template=https://github.com/vignesh07/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
|
||||||
|
|
||||||
After deploy, find your public URL in **Railway → your service → Settings → Domains**.
|
After deploy, find your public URL in **Railway → your service → Settings → Domains**.
|
||||||
|
|
||||||
@ -55,6 +55,7 @@ Attach a volume mounted at:
|
|||||||
Set these variables on the service:
|
Set these variables on the service:
|
||||||
|
|
||||||
- `SETUP_PASSWORD` (required)
|
- `SETUP_PASSWORD` (required)
|
||||||
|
- `PORT=8080` (required — must match the port in Public Networking)
|
||||||
- `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended)
|
- `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended)
|
||||||
- `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended)
|
- `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended)
|
||||||
- `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret)
|
- `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret)
|
||||||
@ -82,8 +83,9 @@ If Telegram DMs are set to pairing, the setup wizard can approve the pairing cod
|
|||||||
1) Go to https://discord.com/developers/applications
|
1) Go to https://discord.com/developers/applications
|
||||||
2) **New Application** → choose a name
|
2) **New Application** → choose a name
|
||||||
3) **Bot** → **Add Bot**
|
3) **Bot** → **Add Bot**
|
||||||
4) Copy the **Bot Token** and paste into `/setup`
|
4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup)
|
||||||
5) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
|
5) Copy the **Bot Token** and paste into `/setup`
|
||||||
|
6) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
|
||||||
|
|
||||||
## Backups & migration
|
## Backups & migration
|
||||||
|
|
||||||
|
|||||||
@ -25,9 +25,11 @@ import { resolveBlueBubblesMessageId } from "./monitor.js";
|
|||||||
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
||||||
import { sendMessageBlueBubbles } from "./send.js";
|
import { sendMessageBlueBubbles } from "./send.js";
|
||||||
import {
|
import {
|
||||||
|
extractHandleFromChatGuid,
|
||||||
looksLikeBlueBubblesTargetId,
|
looksLikeBlueBubblesTargetId,
|
||||||
normalizeBlueBubblesHandle,
|
normalizeBlueBubblesHandle,
|
||||||
normalizeBlueBubblesMessagingTarget,
|
normalizeBlueBubblesMessagingTarget,
|
||||||
|
parseBlueBubblesTarget,
|
||||||
} from "./targets.js";
|
} from "./targets.js";
|
||||||
import { bluebubblesMessageActions } from "./actions.js";
|
import { bluebubblesMessageActions } from "./actions.js";
|
||||||
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
||||||
@ -148,6 +150,58 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
looksLikeId: looksLikeBlueBubblesTargetId,
|
looksLikeId: looksLikeBlueBubblesTargetId,
|
||||||
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
|
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
|
||||||
},
|
},
|
||||||
|
formatTargetDisplay: ({ target, display }) => {
|
||||||
|
const shouldParseDisplay = (value: string): boolean => {
|
||||||
|
if (looksLikeBlueBubblesTargetId(value)) return true;
|
||||||
|
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to extract a clean handle from any BlueBubbles target format
|
||||||
|
const extractCleanDisplay = (value: string | undefined): string | null => {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
try {
|
||||||
|
const parsed = parseBlueBubblesTarget(trimmed);
|
||||||
|
if (parsed.kind === "chat_guid") {
|
||||||
|
const handle = extractHandleFromChatGuid(parsed.chatGuid);
|
||||||
|
if (handle) return handle;
|
||||||
|
}
|
||||||
|
if (parsed.kind === "handle") {
|
||||||
|
return normalizeBlueBubblesHandle(parsed.to);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through
|
||||||
|
}
|
||||||
|
// Strip common prefixes and try raw extraction
|
||||||
|
const stripped = trimmed
|
||||||
|
.replace(/^bluebubbles:/i, "")
|
||||||
|
.replace(/^chat_guid:/i, "")
|
||||||
|
.replace(/^chat_id:/i, "")
|
||||||
|
.replace(/^chat_identifier:/i, "");
|
||||||
|
const handle = extractHandleFromChatGuid(stripped);
|
||||||
|
if (handle) return handle;
|
||||||
|
// Don't return raw chat_guid formats - they contain internal routing info
|
||||||
|
if (stripped.includes(";-;") || stripped.includes(";+;")) return null;
|
||||||
|
return stripped;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to get a clean display from the display parameter first
|
||||||
|
const trimmedDisplay = display?.trim();
|
||||||
|
if (trimmedDisplay) {
|
||||||
|
if (!shouldParseDisplay(trimmedDisplay)) {
|
||||||
|
return trimmedDisplay;
|
||||||
|
}
|
||||||
|
const cleanDisplay = extractCleanDisplay(trimmedDisplay);
|
||||||
|
if (cleanDisplay) return cleanDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to extracting from target
|
||||||
|
const cleanTarget = extractCleanDisplay(target);
|
||||||
|
if (cleanTarget) return cleanTarget;
|
||||||
|
|
||||||
|
// Last resort: return display or target as-is
|
||||||
|
return display?.trim() || target?.trim() || "";
|
||||||
|
},
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||||
|
|||||||
@ -187,6 +187,47 @@ describe("send", () => {
|
|||||||
expect(result).toBe("iMessage;-;+15551234567");
|
expect(result).toBe("iMessage;-;+15551234567");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns null when handle only exists in group chat (not DM)", async () => {
|
||||||
|
// This is the critical fix: if a phone number only exists as a participant in a group chat
|
||||||
|
// (no direct DM chat), we should NOT send to that group. Return null instead.
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
guid: "iMessage;+;group-the-council",
|
||||||
|
participants: [
|
||||||
|
{ address: "+12622102921" },
|
||||||
|
{ address: "+15550001111" },
|
||||||
|
{ address: "+15550002222" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
// Empty second page to stop pagination
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ data: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const target: BlueBubblesSendTarget = {
|
||||||
|
kind: "handle",
|
||||||
|
address: "+12622102921",
|
||||||
|
service: "imessage",
|
||||||
|
};
|
||||||
|
const result = await resolveChatGuidForTarget({
|
||||||
|
baseUrl: "http://localhost:1234",
|
||||||
|
password: "test",
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should return null, NOT the group chat GUID
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns null when chat not found", async () => {
|
it("returns null when chat not found", async () => {
|
||||||
mockFetch.mockResolvedValueOnce({
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
@ -344,14 +385,14 @@ describe("send", () => {
|
|||||||
).rejects.toThrow("password is required");
|
).rejects.toThrow("password is required");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when chatGuid cannot be resolved", async () => {
|
it("throws when chatGuid cannot be resolved for non-handle targets", async () => {
|
||||||
mockFetch.mockResolvedValue({
|
mockFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ data: [] }),
|
json: () => Promise.resolve({ data: [] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sendMessageBlueBubbles("+15559999999", "Hello", {
|
sendMessageBlueBubbles("chat_id:999", "Hello", {
|
||||||
serverUrl: "http://localhost:1234",
|
serverUrl: "http://localhost:1234",
|
||||||
password: "test",
|
password: "test",
|
||||||
}),
|
}),
|
||||||
@ -398,6 +439,57 @@ describe("send", () => {
|
|||||||
expect(body.method).toBeUndefined();
|
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 () => {
|
it("uses private-api when reply metadata is present", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
|
|||||||
@ -257,6 +257,11 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
return guid;
|
return guid;
|
||||||
}
|
}
|
||||||
if (!participantMatch && 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) =>
|
const participants = extractParticipantAddresses(chat).map((entry) =>
|
||||||
normalizeBlueBubblesHandle(entry),
|
normalizeBlueBubblesHandle(entry),
|
||||||
);
|
);
|
||||||
@ -267,9 +272,59 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return participantMatch;
|
return participantMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new chat (DM) and optionally sends an initial message.
|
||||||
|
* Requires Private API to be enabled in BlueBubbles.
|
||||||
|
*/
|
||||||
|
async function createNewChatWithMessage(params: {
|
||||||
|
baseUrl: string;
|
||||||
|
password: string;
|
||||||
|
address: string;
|
||||||
|
message: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<BlueBubblesSendResult> {
|
||||||
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
baseUrl: params.baseUrl,
|
||||||
|
path: "/api/v1/chat/new",
|
||||||
|
password: params.password,
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
addresses: [params.address],
|
||||||
|
message: params.message,
|
||||||
|
};
|
||||||
|
const res = await blueBubblesFetchWithTimeout(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
params.timeoutMs,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text();
|
||||||
|
// Check for Private API not enabled error
|
||||||
|
if (res.status === 400 || res.status === 403 || errorText.toLowerCase().includes("private api")) {
|
||||||
|
throw new Error(
|
||||||
|
`BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
|
}
|
||||||
|
const body = await res.text();
|
||||||
|
if (!body) return { messageId: "ok" };
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body) as unknown;
|
||||||
|
return { messageId: extractMessageId(parsed) };
|
||||||
|
} catch {
|
||||||
|
return { messageId: "ok" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendMessageBlueBubbles(
|
export async function sendMessageBlueBubbles(
|
||||||
to: string,
|
to: string,
|
||||||
text: string,
|
text: string,
|
||||||
@ -297,6 +352,17 @@ export async function sendMessageBlueBubbles(
|
|||||||
target,
|
target,
|
||||||
});
|
});
|
||||||
if (!chatGuid) {
|
if (!chatGuid) {
|
||||||
|
// If target is a phone number/handle and no existing chat found,
|
||||||
|
// auto-create a new DM chat using the /api/v1/chat/new endpoint
|
||||||
|
if (target.kind === "handle") {
|
||||||
|
return createNewChatWithMessage({
|
||||||
|
baseUrl,
|
||||||
|
password,
|
||||||
|
address: target.address,
|
||||||
|
message: trimmedText,
|
||||||
|
timeoutMs: opts.timeoutMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
||||||
);
|
);
|
||||||
|
|||||||
11
extensions/line/clawdbot.plugin.json
Normal file
11
extensions/line/clawdbot.plugin.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "line",
|
||||||
|
"channels": [
|
||||||
|
"line"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
extensions/line/index.ts
Normal file
20
extensions/line/index.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { linePlugin } from "./src/channel.js";
|
||||||
|
import { registerLineCardCommand } from "./src/card-command.js";
|
||||||
|
import { setLineRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "line",
|
||||||
|
name: "LINE",
|
||||||
|
description: "LINE Messaging API channel plugin",
|
||||||
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setLineRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: linePlugin });
|
||||||
|
registerLineCardCommand(api);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
29
extensions/line/package.json
Normal file
29
extensions/line/package.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/line",
|
||||||
|
"version": "2026.1.22",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot LINE channel plugin",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
],
|
||||||
|
"channel": {
|
||||||
|
"id": "line",
|
||||||
|
"label": "LINE",
|
||||||
|
"selectionLabel": "LINE (Messaging API)",
|
||||||
|
"docsPath": "/channels/line",
|
||||||
|
"docsLabel": "line",
|
||||||
|
"blurb": "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
||||||
|
"order": 75,
|
||||||
|
"quickstartAllowFrom": true
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"npmSpec": "@clawdbot/line",
|
||||||
|
"localPath": "extensions/line",
|
||||||
|
"defaultChoice": "npm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"clawdbot": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
338
extensions/line/src/card-command.ts
Normal file
338
extensions/line/src/card-command.ts
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
import type { ClawdbotPluginApi, LineChannelData, ReplyPayload } from "clawdbot/plugin-sdk";
|
||||||
|
import {
|
||||||
|
createActionCard,
|
||||||
|
createImageCard,
|
||||||
|
createInfoCard,
|
||||||
|
createListCard,
|
||||||
|
createReceiptCard,
|
||||||
|
type CardAction,
|
||||||
|
type ListItem,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
const CARD_USAGE = `Usage: /card <type> "title" "body" [options]
|
||||||
|
|
||||||
|
Types:
|
||||||
|
info "Title" "Body" ["Footer"]
|
||||||
|
image "Title" "Caption" --url <image-url>
|
||||||
|
action "Title" "Body" --actions "Btn1|url1,Btn2|text2"
|
||||||
|
list "Title" "Item1|Desc1,Item2|Desc2"
|
||||||
|
receipt "Title" "Item1:$10,Item2:$20" --total "$30"
|
||||||
|
confirm "Question?" --yes "Yes|data" --no "No|data"
|
||||||
|
buttons "Title" "Text" --actions "Btn1|url1,Btn2|data2"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
/card info "Welcome" "Thanks for joining!"
|
||||||
|
/card image "Product" "Check it out" --url https://example.com/img.jpg
|
||||||
|
/card action "Menu" "Choose an option" --actions "Order|/order,Help|/help"`;
|
||||||
|
|
||||||
|
function buildLineReply(lineData: LineChannelData): ReplyPayload {
|
||||||
|
return {
|
||||||
|
channelData: {
|
||||||
|
line: lineData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse action string format: "Label|data,Label2|data2"
|
||||||
|
* Data can be a URL (uri action) or plain text (message action) or key=value (postback)
|
||||||
|
*/
|
||||||
|
function parseActions(actionsStr: string | undefined): CardAction[] {
|
||||||
|
if (!actionsStr) return [];
|
||||||
|
|
||||||
|
const results: CardAction[] = [];
|
||||||
|
|
||||||
|
for (const part of actionsStr.split(",")) {
|
||||||
|
const [label, data] = part
|
||||||
|
.trim()
|
||||||
|
.split("|")
|
||||||
|
.map((s) => s.trim());
|
||||||
|
if (!label) continue;
|
||||||
|
|
||||||
|
const actionData = data || label;
|
||||||
|
|
||||||
|
if (actionData.startsWith("http://") || actionData.startsWith("https://")) {
|
||||||
|
results.push({
|
||||||
|
label,
|
||||||
|
action: { type: "uri", label: label.slice(0, 20), uri: actionData },
|
||||||
|
});
|
||||||
|
} else if (actionData.includes("=")) {
|
||||||
|
results.push({
|
||||||
|
label,
|
||||||
|
action: {
|
||||||
|
type: "postback",
|
||||||
|
label: label.slice(0, 20),
|
||||||
|
data: actionData.slice(0, 300),
|
||||||
|
displayText: label,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
results.push({
|
||||||
|
label,
|
||||||
|
action: { type: "message", label: label.slice(0, 20), text: actionData },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse list items format: "Item1|Subtitle1,Item2|Subtitle2"
|
||||||
|
*/
|
||||||
|
function parseListItems(itemsStr: string): ListItem[] {
|
||||||
|
return itemsStr
|
||||||
|
.split(",")
|
||||||
|
.map((part) => {
|
||||||
|
const [title, subtitle] = part
|
||||||
|
.trim()
|
||||||
|
.split("|")
|
||||||
|
.map((s) => s.trim());
|
||||||
|
return { title: title || "", subtitle };
|
||||||
|
})
|
||||||
|
.filter((item) => item.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse receipt items format: "Item1:$10,Item2:$20"
|
||||||
|
*/
|
||||||
|
function parseReceiptItems(itemsStr: string): Array<{ name: string; value: string }> {
|
||||||
|
return itemsStr
|
||||||
|
.split(",")
|
||||||
|
.map((part) => {
|
||||||
|
const colonIndex = part.lastIndexOf(":");
|
||||||
|
if (colonIndex === -1) {
|
||||||
|
return { name: part.trim(), value: "" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: part.slice(0, colonIndex).trim(),
|
||||||
|
value: part.slice(colonIndex + 1).trim(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item) => item.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse quoted arguments from command string
|
||||||
|
* Supports: /card type "arg1" "arg2" "arg3" --flag value
|
||||||
|
*/
|
||||||
|
function parseCardArgs(argsStr: string): {
|
||||||
|
type: string;
|
||||||
|
args: string[];
|
||||||
|
flags: Record<string, string>;
|
||||||
|
} {
|
||||||
|
const result: { type: string; args: string[]; flags: Record<string, string> } = {
|
||||||
|
type: "",
|
||||||
|
args: [],
|
||||||
|
flags: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract type (first word)
|
||||||
|
const typeMatch = argsStr.match(/^(\w+)/);
|
||||||
|
if (typeMatch) {
|
||||||
|
result.type = typeMatch[1].toLowerCase();
|
||||||
|
argsStr = argsStr.slice(typeMatch[0].length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract quoted arguments
|
||||||
|
const quotedRegex = /"([^"]*?)"/g;
|
||||||
|
let match;
|
||||||
|
while ((match = quotedRegex.exec(argsStr)) !== null) {
|
||||||
|
result.args.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract flags (--key value or --key "value")
|
||||||
|
const flagRegex = /--(\w+)\s+(?:"([^"]*?)"|(\S+))/g;
|
||||||
|
while ((match = flagRegex.exec(argsStr)) !== null) {
|
||||||
|
result.flags[match[1]] = match[2] ?? match[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerLineCardCommand(api: ClawdbotPluginApi): void {
|
||||||
|
api.registerCommand({
|
||||||
|
name: "card",
|
||||||
|
description: "Send a rich card message (LINE).",
|
||||||
|
acceptsArgs: true,
|
||||||
|
requireAuth: false,
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const argsStr = ctx.args?.trim() ?? "";
|
||||||
|
if (!argsStr) return { text: CARD_USAGE };
|
||||||
|
|
||||||
|
const parsed = parseCardArgs(argsStr);
|
||||||
|
const { type, args, flags } = parsed;
|
||||||
|
|
||||||
|
if (!type) return { text: CARD_USAGE };
|
||||||
|
|
||||||
|
// Only LINE supports rich cards; fallback to text elsewhere.
|
||||||
|
if (ctx.channel !== "line") {
|
||||||
|
const fallbackText = args.join(" - ");
|
||||||
|
return { text: `[${type} card] ${fallbackText}`.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case "info": {
|
||||||
|
const [title = "Info", body = "", footer] = args;
|
||||||
|
const bubble = createInfoCard(title, body, footer);
|
||||||
|
return buildLineReply({
|
||||||
|
flexMessage: {
|
||||||
|
altText: `${title}: ${body}`.slice(0, 400),
|
||||||
|
contents: bubble,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "image": {
|
||||||
|
const [title = "Image", caption = ""] = args;
|
||||||
|
const imageUrl = flags.url || flags.image;
|
||||||
|
if (!imageUrl) {
|
||||||
|
return { text: "Error: Image card requires --url <image-url>" };
|
||||||
|
}
|
||||||
|
const bubble = createImageCard(imageUrl, title, caption);
|
||||||
|
return buildLineReply({
|
||||||
|
flexMessage: {
|
||||||
|
altText: `${title}: ${caption}`.slice(0, 400),
|
||||||
|
contents: bubble,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "action": {
|
||||||
|
const [title = "Actions", body = ""] = args;
|
||||||
|
const actions = parseActions(flags.actions);
|
||||||
|
if (actions.length === 0) {
|
||||||
|
return { text: 'Error: Action card requires --actions "Label1|data1,Label2|data2"' };
|
||||||
|
}
|
||||||
|
const bubble = createActionCard(title, body, actions, {
|
||||||
|
imageUrl: flags.url || flags.image,
|
||||||
|
});
|
||||||
|
return buildLineReply({
|
||||||
|
flexMessage: {
|
||||||
|
altText: `${title}: ${body}`.slice(0, 400),
|
||||||
|
contents: bubble,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "list": {
|
||||||
|
const [title = "List", itemsStr = ""] = args;
|
||||||
|
const items = parseListItems(itemsStr || flags.items || "");
|
||||||
|
if (items.length === 0) {
|
||||||
|
return {
|
||||||
|
text:
|
||||||
|
'Error: List card requires items. Usage: /card list "Title" "Item1|Desc1,Item2|Desc2"',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const bubble = createListCard(title, items);
|
||||||
|
return buildLineReply({
|
||||||
|
flexMessage: {
|
||||||
|
altText: `${title}: ${items.map((i) => i.title).join(", ")}`.slice(0, 400),
|
||||||
|
contents: bubble,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "receipt": {
|
||||||
|
const [title = "Receipt", itemsStr = ""] = args;
|
||||||
|
const items = parseReceiptItems(itemsStr || flags.items || "");
|
||||||
|
const total = flags.total ? { label: "Total", value: flags.total } : undefined;
|
||||||
|
const footer = flags.footer;
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return {
|
||||||
|
text:
|
||||||
|
'Error: Receipt card requires items. Usage: /card receipt "Title" "Item1:$10,Item2:$20" --total "$30"',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bubble = createReceiptCard({ title, items, total, footer });
|
||||||
|
return buildLineReply({
|
||||||
|
flexMessage: {
|
||||||
|
altText: `${title}: ${items.map((i) => `${i.name} ${i.value}`).join(", ")}`.slice(
|
||||||
|
0,
|
||||||
|
400,
|
||||||
|
),
|
||||||
|
contents: bubble,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "confirm": {
|
||||||
|
const [question = "Confirm?"] = args;
|
||||||
|
const yesStr = flags.yes || "Yes|yes";
|
||||||
|
const noStr = flags.no || "No|no";
|
||||||
|
|
||||||
|
const [yesLabel, yesData] = yesStr.split("|").map((s) => s.trim());
|
||||||
|
const [noLabel, noData] = noStr.split("|").map((s) => s.trim());
|
||||||
|
|
||||||
|
return buildLineReply({
|
||||||
|
templateMessage: {
|
||||||
|
type: "confirm",
|
||||||
|
text: question,
|
||||||
|
confirmLabel: yesLabel || "Yes",
|
||||||
|
confirmData: yesData || "yes",
|
||||||
|
cancelLabel: noLabel || "No",
|
||||||
|
cancelData: noData || "no",
|
||||||
|
altText: question,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "buttons": {
|
||||||
|
const [title = "Menu", text = "Choose an option"] = args;
|
||||||
|
const actionsStr = flags.actions || "";
|
||||||
|
const actionParts = parseActions(actionsStr);
|
||||||
|
|
||||||
|
if (actionParts.length === 0) {
|
||||||
|
return { text: 'Error: Buttons card requires --actions "Label1|data1,Label2|data2"' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateActions: Array<{
|
||||||
|
type: "message" | "uri" | "postback";
|
||||||
|
label: string;
|
||||||
|
data?: string;
|
||||||
|
uri?: string;
|
||||||
|
}> = actionParts.map((a) => {
|
||||||
|
const action = a.action;
|
||||||
|
const label = action.label ?? a.label;
|
||||||
|
if (action.type === "uri") {
|
||||||
|
return { type: "uri" as const, label, uri: (action as { uri: string }).uri };
|
||||||
|
}
|
||||||
|
if (action.type === "postback") {
|
||||||
|
return {
|
||||||
|
type: "postback" as const,
|
||||||
|
label,
|
||||||
|
data: (action as { data: string }).data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "message" as const,
|
||||||
|
label,
|
||||||
|
data: (action as { text: string }).text,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildLineReply({
|
||||||
|
templateMessage: {
|
||||||
|
type: "buttons",
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
thumbnailImageUrl: flags.url || flags.image,
|
||||||
|
actions: templateActions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
text: `Unknown card type: "${type}". Available types: info, image, action, list, receipt, confirm, buttons`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return { text: `Error creating card: ${String(err)}` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
96
extensions/line/src/channel.logout.test.ts
Normal file
96
extensions/line/src/channel.logout.test.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
import { linePlugin } from "./channel.js";
|
||||||
|
import { setLineRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
const DEFAULT_ACCOUNT_ID = "default";
|
||||||
|
|
||||||
|
type LineRuntimeMocks = {
|
||||||
|
writeConfigFile: ReturnType<typeof vi.fn>;
|
||||||
|
resolveLineAccount: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
|
||||||
|
const writeConfigFile = vi.fn(async () => {});
|
||||||
|
const resolveLineAccount = vi.fn(({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string }) => {
|
||||||
|
const lineConfig = (cfg.channels?.line ?? {}) as {
|
||||||
|
tokenFile?: string;
|
||||||
|
secretFile?: string;
|
||||||
|
channelAccessToken?: string;
|
||||||
|
channelSecret?: string;
|
||||||
|
accounts?: Record<string, Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
const entry =
|
||||||
|
accountId && accountId !== DEFAULT_ACCOUNT_ID
|
||||||
|
? lineConfig.accounts?.[accountId] ?? {}
|
||||||
|
: lineConfig;
|
||||||
|
const hasToken =
|
||||||
|
Boolean((entry as any).channelAccessToken) || Boolean((entry as any).tokenFile);
|
||||||
|
const hasSecret =
|
||||||
|
Boolean((entry as any).channelSecret) || Boolean((entry as any).secretFile);
|
||||||
|
return { tokenSource: hasToken && hasSecret ? "config" : "none" };
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = {
|
||||||
|
config: { writeConfigFile },
|
||||||
|
channel: { line: { resolveLineAccount } },
|
||||||
|
} as unknown as PluginRuntime;
|
||||||
|
|
||||||
|
return { runtime, mocks: { writeConfigFile, resolveLineAccount } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("linePlugin gateway.logoutAccount", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setLineRuntime(createRuntime().runtime);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears tokenFile/secretFile on default account logout", async () => {
|
||||||
|
const { runtime, mocks } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
line: {
|
||||||
|
tokenFile: "/tmp/token",
|
||||||
|
secretFile: "/tmp/secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await linePlugin.gateway.logoutAccount({
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.cleared).toBe(true);
|
||||||
|
expect(result.loggedOut).toBe(true);
|
||||||
|
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears tokenFile/secretFile on account logout", async () => {
|
||||||
|
const { runtime, mocks } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
line: {
|
||||||
|
accounts: {
|
||||||
|
primary: {
|
||||||
|
tokenFile: "/tmp/token",
|
||||||
|
secretFile: "/tmp/secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await linePlugin.gateway.logoutAccount({
|
||||||
|
accountId: "primary",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.cleared).toBe(true);
|
||||||
|
expect(result.loggedOut).toBe(true);
|
||||||
|
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
});
|
||||||
308
extensions/line/src/channel.sendPayload.test.ts
Normal file
308
extensions/line/src/channel.sendPayload.test.ts
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
import { linePlugin } from "./channel.js";
|
||||||
|
import { setLineRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
type LineRuntimeMocks = {
|
||||||
|
pushMessageLine: ReturnType<typeof vi.fn>;
|
||||||
|
pushMessagesLine: ReturnType<typeof vi.fn>;
|
||||||
|
pushFlexMessage: ReturnType<typeof vi.fn>;
|
||||||
|
pushTemplateMessage: ReturnType<typeof vi.fn>;
|
||||||
|
pushLocationMessage: ReturnType<typeof vi.fn>;
|
||||||
|
pushTextMessageWithQuickReplies: ReturnType<typeof vi.fn>;
|
||||||
|
createQuickReplyItems: ReturnType<typeof vi.fn>;
|
||||||
|
buildTemplateMessageFromPayload: ReturnType<typeof vi.fn>;
|
||||||
|
sendMessageLine: ReturnType<typeof vi.fn>;
|
||||||
|
chunkMarkdownText: ReturnType<typeof vi.fn>;
|
||||||
|
resolveLineAccount: ReturnType<typeof vi.fn>;
|
||||||
|
resolveTextChunkLimit: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
|
||||||
|
const pushMessageLine = vi.fn(async () => ({ messageId: "m-text", chatId: "c1" }));
|
||||||
|
const pushMessagesLine = vi.fn(async () => ({ messageId: "m-batch", chatId: "c1" }));
|
||||||
|
const pushFlexMessage = vi.fn(async () => ({ messageId: "m-flex", chatId: "c1" }));
|
||||||
|
const pushTemplateMessage = vi.fn(async () => ({ messageId: "m-template", chatId: "c1" }));
|
||||||
|
const pushLocationMessage = vi.fn(async () => ({ messageId: "m-loc", chatId: "c1" }));
|
||||||
|
const pushTextMessageWithQuickReplies = vi.fn(async () => ({
|
||||||
|
messageId: "m-quick",
|
||||||
|
chatId: "c1",
|
||||||
|
}));
|
||||||
|
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
|
||||||
|
const buildTemplateMessageFromPayload = vi.fn(() => ({ type: "buttons" }));
|
||||||
|
const sendMessageLine = vi.fn(async () => ({ messageId: "m-media", chatId: "c1" }));
|
||||||
|
const chunkMarkdownText = vi.fn((text: string) => [text]);
|
||||||
|
const resolveTextChunkLimit = vi.fn(() => 123);
|
||||||
|
const resolveLineAccount = vi.fn(({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string }) => {
|
||||||
|
const resolved = accountId ?? "default";
|
||||||
|
const lineConfig = (cfg.channels?.line ?? {}) as {
|
||||||
|
accounts?: Record<string, Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
const accountConfig =
|
||||||
|
resolved !== "default" ? lineConfig.accounts?.[resolved] ?? {} : {};
|
||||||
|
return {
|
||||||
|
accountId: resolved,
|
||||||
|
config: { ...lineConfig, ...accountConfig },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = {
|
||||||
|
channel: {
|
||||||
|
line: {
|
||||||
|
pushMessageLine,
|
||||||
|
pushMessagesLine,
|
||||||
|
pushFlexMessage,
|
||||||
|
pushTemplateMessage,
|
||||||
|
pushLocationMessage,
|
||||||
|
pushTextMessageWithQuickReplies,
|
||||||
|
createQuickReplyItems,
|
||||||
|
buildTemplateMessageFromPayload,
|
||||||
|
sendMessageLine,
|
||||||
|
resolveLineAccount,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
chunkMarkdownText,
|
||||||
|
resolveTextChunkLimit,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
runtime,
|
||||||
|
mocks: {
|
||||||
|
pushMessageLine,
|
||||||
|
pushMessagesLine,
|
||||||
|
pushFlexMessage,
|
||||||
|
pushTemplateMessage,
|
||||||
|
pushLocationMessage,
|
||||||
|
pushTextMessageWithQuickReplies,
|
||||||
|
createQuickReplyItems,
|
||||||
|
buildTemplateMessageFromPayload,
|
||||||
|
sendMessageLine,
|
||||||
|
chunkMarkdownText,
|
||||||
|
resolveLineAccount,
|
||||||
|
resolveTextChunkLimit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("linePlugin outbound.sendPayload", () => {
|
||||||
|
it("sends flex message without dropping text", async () => {
|
||||||
|
const { runtime, mocks } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
text: "Now playing:",
|
||||||
|
channelData: {
|
||||||
|
line: {
|
||||||
|
flexMessage: {
|
||||||
|
altText: "Now playing",
|
||||||
|
contents: { type: "bubble" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await linePlugin.outbound.sendPayload({
|
||||||
|
to: "line:group:1",
|
||||||
|
payload,
|
||||||
|
accountId: "default",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.pushFlexMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:group:1", "Now playing:", {
|
||||||
|
verbose: false,
|
||||||
|
accountId: "default",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends template message without dropping text", async () => {
|
||||||
|
const { runtime, mocks } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
text: "Choose one:",
|
||||||
|
channelData: {
|
||||||
|
line: {
|
||||||
|
templateMessage: {
|
||||||
|
type: "confirm",
|
||||||
|
text: "Continue?",
|
||||||
|
confirmLabel: "Yes",
|
||||||
|
confirmData: "yes",
|
||||||
|
cancelLabel: "No",
|
||||||
|
cancelData: "no",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await linePlugin.outbound.sendPayload({
|
||||||
|
to: "line:user:1",
|
||||||
|
payload,
|
||||||
|
accountId: "default",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.buildTemplateMessageFromPayload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.pushTemplateMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:1", "Choose one:", {
|
||||||
|
verbose: false,
|
||||||
|
accountId: "default",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches quick replies when no text chunks are present", async () => {
|
||||||
|
const { runtime, mocks } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
channelData: {
|
||||||
|
line: {
|
||||||
|
quickReplies: ["One", "Two"],
|
||||||
|
flexMessage: {
|
||||||
|
altText: "Card",
|
||||||
|
contents: { type: "bubble" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await linePlugin.outbound.sendPayload({
|
||||||
|
to: "line:user:2",
|
||||||
|
payload,
|
||||||
|
accountId: "default",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.pushFlexMessage).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.pushMessagesLine).toHaveBeenCalledWith(
|
||||||
|
"line:user:2",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "flex",
|
||||||
|
altText: "Card",
|
||||||
|
contents: { type: "bubble" },
|
||||||
|
quickReply: { items: ["One", "Two"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ verbose: false, accountId: "default" },
|
||||||
|
);
|
||||||
|
expect(mocks.createQuickReplyItems).toHaveBeenCalledWith(["One", "Two"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends media before quick-reply text so buttons stay visible", async () => {
|
||||||
|
const { runtime, mocks } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
text: "Hello",
|
||||||
|
mediaUrl: "https://example.com/img.jpg",
|
||||||
|
channelData: {
|
||||||
|
line: {
|
||||||
|
quickReplies: ["One", "Two"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await linePlugin.outbound.sendPayload({
|
||||||
|
to: "line:user:3",
|
||||||
|
payload,
|
||||||
|
accountId: "default",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:3", "", {
|
||||||
|
verbose: false,
|
||||||
|
mediaUrl: "https://example.com/img.jpg",
|
||||||
|
accountId: "default",
|
||||||
|
});
|
||||||
|
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
|
||||||
|
"line:user:3",
|
||||||
|
"Hello",
|
||||||
|
["One", "Two"],
|
||||||
|
{ verbose: false, accountId: "default" },
|
||||||
|
);
|
||||||
|
const mediaOrder = mocks.sendMessageLine.mock.invocationCallOrder[0];
|
||||||
|
const quickReplyOrder = mocks.pushTextMessageWithQuickReplies.mock.invocationCallOrder[0];
|
||||||
|
expect(mediaOrder).toBeLessThan(quickReplyOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses configured text chunk limit for payloads", async () => {
|
||||||
|
const { runtime, mocks } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
const cfg = { channels: { line: { textChunkLimit: 123 } } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
text: "Hello world",
|
||||||
|
channelData: {
|
||||||
|
line: {
|
||||||
|
flexMessage: {
|
||||||
|
altText: "Card",
|
||||||
|
contents: { type: "bubble" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await linePlugin.outbound.sendPayload({
|
||||||
|
to: "line:user:3",
|
||||||
|
payload,
|
||||||
|
accountId: "primary",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.resolveTextChunkLimit).toHaveBeenCalledWith(
|
||||||
|
cfg,
|
||||||
|
"line",
|
||||||
|
"primary",
|
||||||
|
{ fallbackLimit: 5000 },
|
||||||
|
);
|
||||||
|
expect(mocks.chunkMarkdownText).toHaveBeenCalledWith("Hello world", 123);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("linePlugin config.formatAllowFrom", () => {
|
||||||
|
it("strips line:user: prefixes without lowercasing", () => {
|
||||||
|
const formatted = linePlugin.config.formatAllowFrom({
|
||||||
|
allowFrom: ["line:user:UABC", "line:UDEF"],
|
||||||
|
});
|
||||||
|
expect(formatted).toEqual(["UABC", "UDEF"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("linePlugin groups.resolveRequireMention", () => {
|
||||||
|
it("uses account-level group settings when provided", () => {
|
||||||
|
const { runtime } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
line: {
|
||||||
|
groups: {
|
||||||
|
"*": { requireMention: false },
|
||||||
|
},
|
||||||
|
accounts: {
|
||||||
|
primary: {
|
||||||
|
groups: {
|
||||||
|
"group-1": { requireMention: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const requireMention = linePlugin.groups.resolveRequireMention({
|
||||||
|
cfg,
|
||||||
|
accountId: "primary",
|
||||||
|
groupId: "group-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requireMention).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
773
extensions/line/src/channel.ts
Normal file
773
extensions/line/src/channel.ts
Normal file
@ -0,0 +1,773 @@
|
|||||||
|
import {
|
||||||
|
buildChannelConfigSchema,
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
LineConfigSchema,
|
||||||
|
processLineMessage,
|
||||||
|
type ChannelPlugin,
|
||||||
|
type ClawdbotConfig,
|
||||||
|
type LineConfig,
|
||||||
|
type LineChannelData,
|
||||||
|
type ResolvedLineAccount,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { getLineRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
// LINE channel metadata
|
||||||
|
const meta = {
|
||||||
|
id: "line",
|
||||||
|
label: "LINE",
|
||||||
|
selectionLabel: "LINE (Messaging API)",
|
||||||
|
detailLabel: "LINE Bot",
|
||||||
|
docsPath: "/channels/line",
|
||||||
|
docsLabel: "line",
|
||||||
|
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
||||||
|
systemImage: "message.fill",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseThreadId(threadId?: string | number | null): number | undefined {
|
||||||
|
if (threadId == null) return undefined;
|
||||||
|
if (typeof threadId === "number") {
|
||||||
|
return Number.isFinite(threadId) ? Math.trunc(threadId) : undefined;
|
||||||
|
}
|
||||||
|
const trimmed = threadId.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
const parsed = Number.parseInt(trimmed, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||||
|
id: "line",
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
quickstartAllowFrom: true,
|
||||||
|
},
|
||||||
|
pairing: {
|
||||||
|
idLabel: "lineUserId",
|
||||||
|
normalizeAllowEntry: (entry) => {
|
||||||
|
// LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:).
|
||||||
|
return entry.replace(/^line:(?:user:)?/i, "");
|
||||||
|
},
|
||||||
|
notifyApproval: async ({ cfg, id }) => {
|
||||||
|
const line = getLineRuntime().channel.line;
|
||||||
|
const account = line.resolveLineAccount({ cfg });
|
||||||
|
if (!account.channelAccessToken) {
|
||||||
|
throw new Error("LINE channel access token not configured");
|
||||||
|
}
|
||||||
|
await line.pushMessageLine(id, "Clawdbot: your access has been approved.", {
|
||||||
|
channelAccessToken: account.channelAccessToken,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct", "group"],
|
||||||
|
reactions: false,
|
||||||
|
threads: false,
|
||||||
|
media: true,
|
||||||
|
nativeCommands: false,
|
||||||
|
blockStreaming: true,
|
||||||
|
},
|
||||||
|
reload: { configPrefixes: ["channels.line"] },
|
||||||
|
configSchema: buildChannelConfigSchema(LineConfigSchema),
|
||||||
|
config: {
|
||||||
|
listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg),
|
||||||
|
resolveAccount: (cfg, accountId) =>
|
||||||
|
getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }),
|
||||||
|
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
|
||||||
|
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
||||||
|
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: {
|
||||||
|
...lineConfig,
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: {
|
||||||
|
...lineConfig,
|
||||||
|
accounts: {
|
||||||
|
...lineConfig.accounts,
|
||||||
|
[accountId]: {
|
||||||
|
...lineConfig.accounts?.[accountId],
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
deleteAccount: ({ cfg, accountId }) => {
|
||||||
|
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
const { channelAccessToken, channelSecret, tokenFile, secretFile, ...rest } = lineConfig;
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: rest,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const accounts = { ...lineConfig.accounts };
|
||||||
|
delete accounts[accountId];
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: {
|
||||||
|
...lineConfig,
|
||||||
|
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
isConfigured: (account) => Boolean(account.channelAccessToken?.trim()),
|
||||||
|
describeAccount: (account) => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: Boolean(account.channelAccessToken?.trim()),
|
||||||
|
tokenSource: account.tokenSource,
|
||||||
|
}),
|
||||||
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
|
(getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }).config.allowFrom ?? []).map(
|
||||||
|
(entry) => String(entry),
|
||||||
|
),
|
||||||
|
formatAllowFrom: ({ allowFrom }) =>
|
||||||
|
allowFrom
|
||||||
|
.map((entry) => String(entry).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((entry) => {
|
||||||
|
// LINE sender IDs are case-sensitive; keep original casing.
|
||||||
|
return entry.replace(/^line:(?:user:)?/i, "");
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||||
|
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
const useAccountPath = Boolean(
|
||||||
|
(cfg.channels?.line as LineConfig | undefined)?.accounts?.[resolvedAccountId],
|
||||||
|
);
|
||||||
|
const basePath = useAccountPath
|
||||||
|
? `channels.line.accounts.${resolvedAccountId}.`
|
||||||
|
: "channels.line.";
|
||||||
|
return {
|
||||||
|
policy: account.config.dmPolicy ?? "pairing",
|
||||||
|
allowFrom: account.config.allowFrom ?? [],
|
||||||
|
policyPath: `${basePath}dmPolicy`,
|
||||||
|
allowFromPath: basePath,
|
||||||
|
approveHint: "clawdbot pairing approve line <code>",
|
||||||
|
normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
collectWarnings: ({ account, cfg }) => {
|
||||||
|
const defaultGroupPolicy =
|
||||||
|
(cfg.channels?.defaults as { groupPolicy?: string } | undefined)?.groupPolicy;
|
||||||
|
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
|
if (groupPolicy !== "open") return [];
|
||||||
|
return [
|
||||||
|
`- LINE groups: groupPolicy="open" allows any member in groups to trigger. Set channels.line.groupPolicy="allowlist" + channels.line.groupAllowFrom to restrict senders.`,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
||||||
|
const account = getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId });
|
||||||
|
const groups = account.config.groups;
|
||||||
|
if (!groups) return false;
|
||||||
|
const groupConfig = groups[groupId] ?? groups["*"];
|
||||||
|
return groupConfig?.requireMention ?? false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
messaging: {
|
||||||
|
normalizeTarget: (target) => {
|
||||||
|
const trimmed = target.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
|
||||||
|
},
|
||||||
|
targetResolver: {
|
||||||
|
looksLikeId: (id) => {
|
||||||
|
const trimmed = id?.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
// LINE user IDs are typically U followed by 32 hex characters
|
||||||
|
// Group IDs are C followed by 32 hex characters
|
||||||
|
// Room IDs are R followed by 32 hex characters
|
||||||
|
return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed);
|
||||||
|
},
|
||||||
|
hint: "<userId|groupId|roomId>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: {
|
||||||
|
self: async () => null,
|
||||||
|
listPeers: async () => [],
|
||||||
|
listGroups: async () => [],
|
||||||
|
},
|
||||||
|
setup: {
|
||||||
|
resolveAccountId: ({ accountId }) =>
|
||||||
|
getLineRuntime().channel.line.normalizeAccountId(accountId),
|
||||||
|
applyAccountName: ({ cfg, accountId, name }) => {
|
||||||
|
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: {
|
||||||
|
...lineConfig,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: {
|
||||||
|
...lineConfig,
|
||||||
|
accounts: {
|
||||||
|
...lineConfig.accounts,
|
||||||
|
[accountId]: {
|
||||||
|
...lineConfig.accounts?.[accountId],
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
validateInput: ({ accountId, input }) => {
|
||||||
|
const typedInput = input as {
|
||||||
|
useEnv?: boolean;
|
||||||
|
channelAccessToken?: string;
|
||||||
|
channelSecret?: string;
|
||||||
|
tokenFile?: string;
|
||||||
|
secretFile?: string;
|
||||||
|
};
|
||||||
|
if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||||
|
return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.";
|
||||||
|
}
|
||||||
|
if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) {
|
||||||
|
return "LINE requires channelAccessToken or --token-file (or --use-env).";
|
||||||
|
}
|
||||||
|
if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) {
|
||||||
|
return "LINE requires channelSecret or --secret-file (or --use-env).";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||||
|
const typedInput = input as {
|
||||||
|
name?: string;
|
||||||
|
useEnv?: boolean;
|
||||||
|
channelAccessToken?: string;
|
||||||
|
channelSecret?: string;
|
||||||
|
tokenFile?: string;
|
||||||
|
secretFile?: string;
|
||||||
|
};
|
||||||
|
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||||
|
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: {
|
||||||
|
...lineConfig,
|
||||||
|
enabled: true,
|
||||||
|
...(typedInput.name ? { name: typedInput.name } : {}),
|
||||||
|
...(typedInput.useEnv
|
||||||
|
? {}
|
||||||
|
: typedInput.tokenFile
|
||||||
|
? { tokenFile: typedInput.tokenFile }
|
||||||
|
: typedInput.channelAccessToken
|
||||||
|
? { channelAccessToken: typedInput.channelAccessToken }
|
||||||
|
: {}),
|
||||||
|
...(typedInput.useEnv
|
||||||
|
? {}
|
||||||
|
: typedInput.secretFile
|
||||||
|
? { secretFile: typedInput.secretFile }
|
||||||
|
: typedInput.channelSecret
|
||||||
|
? { channelSecret: typedInput.channelSecret }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: {
|
||||||
|
...lineConfig,
|
||||||
|
enabled: true,
|
||||||
|
accounts: {
|
||||||
|
...lineConfig.accounts,
|
||||||
|
[accountId]: {
|
||||||
|
...lineConfig.accounts?.[accountId],
|
||||||
|
enabled: true,
|
||||||
|
...(typedInput.name ? { name: typedInput.name } : {}),
|
||||||
|
...(typedInput.tokenFile
|
||||||
|
? { tokenFile: typedInput.tokenFile }
|
||||||
|
: typedInput.channelAccessToken
|
||||||
|
? { channelAccessToken: typedInput.channelAccessToken }
|
||||||
|
: {}),
|
||||||
|
...(typedInput.secretFile
|
||||||
|
? { secretFile: typedInput.secretFile }
|
||||||
|
: typedInput.channelSecret
|
||||||
|
? { channelSecret: typedInput.channelSecret }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
|
textChunkLimit: 5000, // LINE allows up to 5000 characters per text message
|
||||||
|
sendPayload: async ({ to, payload, accountId, cfg }) => {
|
||||||
|
const runtime = getLineRuntime();
|
||||||
|
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
|
||||||
|
const sendText = runtime.channel.line.pushMessageLine;
|
||||||
|
const sendBatch = runtime.channel.line.pushMessagesLine;
|
||||||
|
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||||
|
const sendTemplate = runtime.channel.line.pushTemplateMessage;
|
||||||
|
const sendLocation = runtime.channel.line.pushLocationMessage;
|
||||||
|
const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies;
|
||||||
|
const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload;
|
||||||
|
const createQuickReplyItems = runtime.channel.line.createQuickReplyItems;
|
||||||
|
|
||||||
|
let lastResult: { messageId: string; chatId: string } | null = null;
|
||||||
|
const hasQuickReplies = Boolean(lineData.quickReplies?.length);
|
||||||
|
const quickReply = hasQuickReplies
|
||||||
|
? createQuickReplyItems(lineData.quickReplies!)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
for (let i = 0; i < messages.length; i += 5) {
|
||||||
|
const result = await sendBatch(to, messages.slice(i, i + 5), {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
lastResult = { messageId: result.messageId, chatId: result.chatId };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processed = payload.text
|
||||||
|
? processLineMessage(payload.text)
|
||||||
|
: { text: "", flexMessages: [] };
|
||||||
|
|
||||||
|
const chunkLimit =
|
||||||
|
runtime.channel.text.resolveTextChunkLimit?.(
|
||||||
|
cfg,
|
||||||
|
"line",
|
||||||
|
accountId ?? undefined,
|
||||||
|
{
|
||||||
|
fallbackLimit: 5000,
|
||||||
|
},
|
||||||
|
) ?? 5000;
|
||||||
|
|
||||||
|
const chunks = processed.text
|
||||||
|
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
|
||||||
|
: [];
|
||||||
|
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
|
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
|
||||||
|
|
||||||
|
if (!shouldSendQuickRepliesInline) {
|
||||||
|
if (lineData.flexMessage) {
|
||||||
|
lastResult = await sendFlex(
|
||||||
|
to,
|
||||||
|
lineData.flexMessage.altText,
|
||||||
|
lineData.flexMessage.contents,
|
||||||
|
{
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineData.templateMessage) {
|
||||||
|
const template = buildTemplate(lineData.templateMessage);
|
||||||
|
if (template) {
|
||||||
|
lastResult = await sendTemplate(to, template, {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineData.location) {
|
||||||
|
lastResult = await sendLocation(to, lineData.location, {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const flexMsg of processed.flexMessages) {
|
||||||
|
lastResult = await sendFlex(to, flexMsg.altText, flexMsg.contents, {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
|
||||||
|
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
|
||||||
|
for (const url of mediaUrls) {
|
||||||
|
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||||
|
verbose: false,
|
||||||
|
mediaUrl: url,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunks.length > 0) {
|
||||||
|
for (let i = 0; i < chunks.length; i += 1) {
|
||||||
|
const isLast = i === chunks.length - 1;
|
||||||
|
if (isLast && hasQuickReplies) {
|
||||||
|
lastResult = await sendQuickReplies(to, chunks[i]!, lineData.quickReplies!, {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
lastResult = await sendText(to, chunks[i]!, {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (shouldSendQuickRepliesInline) {
|
||||||
|
const quickReplyMessages: Array<Record<string, unknown>> = [];
|
||||||
|
if (lineData.flexMessage) {
|
||||||
|
quickReplyMessages.push({
|
||||||
|
type: "flex",
|
||||||
|
altText: lineData.flexMessage.altText.slice(0, 400),
|
||||||
|
contents: lineData.flexMessage.contents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (lineData.templateMessage) {
|
||||||
|
const template = buildTemplate(lineData.templateMessage);
|
||||||
|
if (template) {
|
||||||
|
quickReplyMessages.push(template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lineData.location) {
|
||||||
|
quickReplyMessages.push({
|
||||||
|
type: "location",
|
||||||
|
title: lineData.location.title.slice(0, 100),
|
||||||
|
address: lineData.location.address.slice(0, 100),
|
||||||
|
latitude: lineData.location.latitude,
|
||||||
|
longitude: lineData.location.longitude,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const flexMsg of processed.flexMessages) {
|
||||||
|
quickReplyMessages.push({
|
||||||
|
type: "flex",
|
||||||
|
altText: flexMsg.altText.slice(0, 400),
|
||||||
|
contents: flexMsg.contents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const url of mediaUrls) {
|
||||||
|
const trimmed = url?.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
quickReplyMessages.push({
|
||||||
|
type: "image",
|
||||||
|
originalContentUrl: trimmed,
|
||||||
|
previewImageUrl: trimmed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (quickReplyMessages.length > 0 && quickReply) {
|
||||||
|
const lastIndex = quickReplyMessages.length - 1;
|
||||||
|
quickReplyMessages[lastIndex] = {
|
||||||
|
...quickReplyMessages[lastIndex],
|
||||||
|
quickReply,
|
||||||
|
};
|
||||||
|
await sendMessageBatch(quickReplyMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
|
||||||
|
for (const url of mediaUrls) {
|
||||||
|
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||||
|
verbose: false,
|
||||||
|
mediaUrl: url,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastResult) return { channel: "line", ...lastResult };
|
||||||
|
return { channel: "line", messageId: "empty", chatId: to };
|
||||||
|
},
|
||||||
|
sendText: async ({ to, text, accountId }) => {
|
||||||
|
const runtime = getLineRuntime();
|
||||||
|
const sendText = runtime.channel.line.pushMessageLine;
|
||||||
|
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||||
|
|
||||||
|
// Process markdown: extract tables/code blocks, strip formatting
|
||||||
|
const processed = processLineMessage(text);
|
||||||
|
|
||||||
|
// Send cleaned text first (if non-empty)
|
||||||
|
let result: { messageId: string; chatId: string };
|
||||||
|
if (processed.text.trim()) {
|
||||||
|
result = await sendText(to, processed.text, {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If text is empty after processing, still need a result
|
||||||
|
result = { messageId: "processed", chatId: to };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send flex messages for tables/code blocks
|
||||||
|
for (const flexMsg of processed.flexMessages) {
|
||||||
|
await sendFlex(to, flexMsg.altText, flexMsg.contents, {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { channel: "line", ...result };
|
||||||
|
},
|
||||||
|
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
|
||||||
|
const send = getLineRuntime().channel.line.sendMessageLine;
|
||||||
|
const result = await send(to, text, {
|
||||||
|
verbose: false,
|
||||||
|
mediaUrl,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
return { channel: "line", ...result };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
defaultRuntime: {
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
running: false,
|
||||||
|
lastStartAt: null,
|
||||||
|
lastStopAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
collectStatusIssues: ({ account }) => {
|
||||||
|
const issues: Array<{ level: "error" | "warning"; message: string }> = [];
|
||||||
|
if (!account.channelAccessToken?.trim()) {
|
||||||
|
issues.push({
|
||||||
|
level: "error",
|
||||||
|
message: "LINE channel access token not configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!account.channelSecret?.trim()) {
|
||||||
|
issues.push({
|
||||||
|
level: "error",
|
||||||
|
message: "LINE channel secret not configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
},
|
||||||
|
buildChannelSummary: ({ snapshot }) => ({
|
||||||
|
configured: snapshot.configured ?? false,
|
||||||
|
tokenSource: snapshot.tokenSource ?? "none",
|
||||||
|
running: snapshot.running ?? false,
|
||||||
|
mode: snapshot.mode ?? null,
|
||||||
|
lastStartAt: snapshot.lastStartAt ?? null,
|
||||||
|
lastStopAt: snapshot.lastStopAt ?? null,
|
||||||
|
lastError: snapshot.lastError ?? null,
|
||||||
|
probe: snapshot.probe,
|
||||||
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
|
}),
|
||||||
|
probeAccount: async ({ account, timeoutMs }) =>
|
||||||
|
getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs),
|
||||||
|
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
||||||
|
const configured = Boolean(account.channelAccessToken?.trim());
|
||||||
|
return {
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured,
|
||||||
|
tokenSource: account.tokenSource,
|
||||||
|
running: runtime?.running ?? false,
|
||||||
|
lastStartAt: runtime?.lastStartAt ?? null,
|
||||||
|
lastStopAt: runtime?.lastStopAt ?? null,
|
||||||
|
lastError: runtime?.lastError ?? null,
|
||||||
|
mode: "webhook",
|
||||||
|
probe,
|
||||||
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||||
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
startAccount: async (ctx) => {
|
||||||
|
const account = ctx.account;
|
||||||
|
const token = account.channelAccessToken.trim();
|
||||||
|
const secret = account.channelSecret.trim();
|
||||||
|
|
||||||
|
let lineBotLabel = "";
|
||||||
|
try {
|
||||||
|
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
|
||||||
|
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
|
||||||
|
if (displayName) lineBotLabel = ` (${displayName})`;
|
||||||
|
} catch (err) {
|
||||||
|
if (getLineRuntime().logging.shouldLogVerbose()) {
|
||||||
|
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
|
||||||
|
|
||||||
|
return getLineRuntime().channel.line.monitorLineProvider({
|
||||||
|
channelAccessToken: token,
|
||||||
|
channelSecret: secret,
|
||||||
|
accountId: account.accountId,
|
||||||
|
config: ctx.cfg,
|
||||||
|
runtime: ctx.runtime,
|
||||||
|
abortSignal: ctx.abortSignal,
|
||||||
|
webhookPath: account.config.webhookPath,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
logoutAccount: async ({ accountId, cfg }) => {
|
||||||
|
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
|
||||||
|
const nextCfg = { ...cfg } as ClawdbotConfig;
|
||||||
|
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||||
|
const nextLine = { ...lineConfig };
|
||||||
|
let cleared = false;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
if (
|
||||||
|
nextLine.channelAccessToken ||
|
||||||
|
nextLine.channelSecret ||
|
||||||
|
nextLine.tokenFile ||
|
||||||
|
nextLine.secretFile
|
||||||
|
) {
|
||||||
|
delete nextLine.channelAccessToken;
|
||||||
|
delete nextLine.channelSecret;
|
||||||
|
delete nextLine.tokenFile;
|
||||||
|
delete nextLine.secretFile;
|
||||||
|
cleared = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = nextLine.accounts ? { ...nextLine.accounts } : undefined;
|
||||||
|
if (accounts && accountId in accounts) {
|
||||||
|
const entry = accounts[accountId];
|
||||||
|
if (entry && typeof entry === "object") {
|
||||||
|
const nextEntry = { ...entry } as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
"channelAccessToken" in nextEntry ||
|
||||||
|
"channelSecret" in nextEntry ||
|
||||||
|
"tokenFile" in nextEntry ||
|
||||||
|
"secretFile" in nextEntry
|
||||||
|
) {
|
||||||
|
cleared = true;
|
||||||
|
delete nextEntry.channelAccessToken;
|
||||||
|
delete nextEntry.channelSecret;
|
||||||
|
delete nextEntry.tokenFile;
|
||||||
|
delete nextEntry.secretFile;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (Object.keys(nextEntry).length === 0) {
|
||||||
|
delete accounts[accountId];
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
accounts[accountId] = nextEntry as typeof entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accounts) {
|
||||||
|
if (Object.keys(accounts).length === 0) {
|
||||||
|
delete nextLine.accounts;
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
nextLine.accounts = accounts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
if (Object.keys(nextLine).length > 0) {
|
||||||
|
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
|
||||||
|
} else {
|
||||||
|
const nextChannels = { ...nextCfg.channels };
|
||||||
|
delete (nextChannels as Record<string, unknown>).line;
|
||||||
|
if (Object.keys(nextChannels).length > 0) {
|
||||||
|
nextCfg.channels = nextChannels;
|
||||||
|
} else {
|
||||||
|
delete nextCfg.channels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await getLineRuntime().config.writeConfigFile(nextCfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = getLineRuntime().channel.line.resolveLineAccount({
|
||||||
|
cfg: changed ? nextCfg : cfg,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
const loggedOut = resolved.tokenSource === "none";
|
||||||
|
|
||||||
|
return { cleared, envToken: Boolean(envToken), loggedOut };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agentPrompt: {
|
||||||
|
messageToolHints: () => [
|
||||||
|
"",
|
||||||
|
"### LINE Rich Messages",
|
||||||
|
"LINE supports rich visual messages. Use these directives in your reply when appropriate:",
|
||||||
|
"",
|
||||||
|
"**Quick Replies** (bottom button suggestions):",
|
||||||
|
" [[quick_replies: Option 1, Option 2, Option 3]]",
|
||||||
|
"",
|
||||||
|
"**Location** (map pin):",
|
||||||
|
" [[location: Place Name | Address | latitude | longitude]]",
|
||||||
|
"",
|
||||||
|
"**Confirm Dialog** (yes/no prompt):",
|
||||||
|
" [[confirm: Question text? | Yes Label | No Label]]",
|
||||||
|
"",
|
||||||
|
"**Button Menu** (title + text + buttons):",
|
||||||
|
" [[buttons: Title | Description | Btn1:action1, Btn2:https://url.com]]",
|
||||||
|
"",
|
||||||
|
"**Media Player Card** (music status):",
|
||||||
|
" [[media_player: Song Title | Artist Name | Source | https://albumart.url | playing]]",
|
||||||
|
" - Status: 'playing' or 'paused' (optional)",
|
||||||
|
"",
|
||||||
|
"**Event Card** (calendar events, meetings):",
|
||||||
|
" [[event: Event Title | Date | Time | Location | Description]]",
|
||||||
|
" - Time, Location, Description are optional",
|
||||||
|
"",
|
||||||
|
"**Agenda Card** (multiple events/schedule):",
|
||||||
|
" [[agenda: Schedule Title | Event1:9:00 AM, Event2:12:00 PM, Event3:3:00 PM]]",
|
||||||
|
"",
|
||||||
|
"**Device Control Card** (smart devices, TVs, etc.):",
|
||||||
|
" [[device: Device Name | Device Type | Status | Control1:data1, Control2:data2]]",
|
||||||
|
"",
|
||||||
|
"**Apple TV Remote** (full D-pad + transport):",
|
||||||
|
" [[appletv_remote: Apple TV | Playing]]",
|
||||||
|
"",
|
||||||
|
"**Auto-converted**: Markdown tables become Flex cards, code blocks become styled cards.",
|
||||||
|
"",
|
||||||
|
"When to use rich messages:",
|
||||||
|
"- Use [[quick_replies:...]] when offering 2-4 clear options",
|
||||||
|
"- Use [[confirm:...]] for yes/no decisions",
|
||||||
|
"- Use [[buttons:...]] for menus with actions/links",
|
||||||
|
"- Use [[location:...]] when sharing a place",
|
||||||
|
"- Use [[media_player:...]] when showing what's playing",
|
||||||
|
"- Use [[event:...]] for calendar event details",
|
||||||
|
"- Use [[agenda:...]] for a day's schedule or event list",
|
||||||
|
"- Use [[device:...]] for smart device status/controls",
|
||||||
|
"- Tables/code in your response auto-convert to visual cards",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
14
extensions/line/src/runtime.ts
Normal file
14
extensions/line/src/runtime.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
export function setLineRuntime(r: PluginRuntime): void {
|
||||||
|
runtime = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLineRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("LINE runtime not initialized - plugin not registered");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.24
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
|
||||||
|
- Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls.
|
||||||
|
- Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields.
|
||||||
|
|
||||||
## 2026.1.23
|
## 2026.1.23
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@ -75,6 +75,27 @@ Notes:
|
|||||||
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
|
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
|
||||||
- `mock` is a local dev provider (no network calls).
|
- `mock` is a local dev provider (no network calls).
|
||||||
|
|
||||||
|
## TTS for calls
|
||||||
|
|
||||||
|
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
|
||||||
|
streaming speech on calls. You can override it under the plugin config with the
|
||||||
|
same shape — overrides deep-merge with `messages.tts`.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
tts: {
|
||||||
|
provider: "openai",
|
||||||
|
openai: {
|
||||||
|
voice: "alloy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Edge TTS is ignored for voice calls (telephony audio needs PCM; Edge output is unreliable).
|
||||||
|
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -99,16 +99,39 @@
|
|||||||
"label": "Media Stream Path",
|
"label": "Media Stream Path",
|
||||||
"advanced": true
|
"advanced": true
|
||||||
},
|
},
|
||||||
"tts.model": {
|
"tts.provider": {
|
||||||
"label": "TTS Model",
|
"label": "TTS Provider Override",
|
||||||
|
"help": "Deep-merges with messages.tts (Edge is ignored for calls).",
|
||||||
"advanced": true
|
"advanced": true
|
||||||
},
|
},
|
||||||
"tts.voice": {
|
"tts.openai.model": {
|
||||||
"label": "TTS Voice",
|
"label": "OpenAI TTS Model",
|
||||||
"advanced": true
|
"advanced": true
|
||||||
},
|
},
|
||||||
"tts.instructions": {
|
"tts.openai.voice": {
|
||||||
"label": "TTS Instructions",
|
"label": "OpenAI TTS Voice",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
"tts.openai.apiKey": {
|
||||||
|
"label": "OpenAI API Key",
|
||||||
|
"sensitive": true,
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
"tts.elevenlabs.modelId": {
|
||||||
|
"label": "ElevenLabs Model ID",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
"tts.elevenlabs.voiceId": {
|
||||||
|
"label": "ElevenLabs Voice ID",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
"tts.elevenlabs.apiKey": {
|
||||||
|
"label": "ElevenLabs API Key",
|
||||||
|
"sensitive": true,
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
"tts.elevenlabs.baseUrl": {
|
||||||
|
"label": "ElevenLabs Base URL",
|
||||||
"advanced": true
|
"advanced": true
|
||||||
},
|
},
|
||||||
"publicUrl": {
|
"publicUrl": {
|
||||||
@ -370,20 +393,193 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"auto": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"off",
|
||||||
|
"always",
|
||||||
|
"inbound",
|
||||||
|
"tagged"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"final",
|
||||||
|
"all"
|
||||||
|
]
|
||||||
|
},
|
||||||
"provider": {
|
"provider": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"openai"
|
"openai",
|
||||||
|
"elevenlabs",
|
||||||
|
"edge"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"summaryModel": {
|
||||||
|
"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": {
|
"model": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"voice": {
|
"voice": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"instructions": {
|
"edge": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"voice": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"outputFormat": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pitch": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"rate": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"volume": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"saveSubtitles": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"timeoutMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1000,
|
||||||
|
"maximum": 120000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"prefsPath": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maxTextLength": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"timeoutMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1000,
|
||||||
|
"maximum": 120000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -74,9 +74,26 @@ const voiceCallConfigSchema = {
|
|||||||
},
|
},
|
||||||
"streaming.sttModel": { label: "Realtime STT Model", advanced: true },
|
"streaming.sttModel": { label: "Realtime STT Model", advanced: true },
|
||||||
"streaming.streamPath": { label: "Media Stream Path", advanced: true },
|
"streaming.streamPath": { label: "Media Stream Path", advanced: true },
|
||||||
"tts.model": { label: "TTS Model", advanced: true },
|
"tts.provider": {
|
||||||
"tts.voice": { label: "TTS Voice", advanced: true },
|
label: "TTS Provider Override",
|
||||||
"tts.instructions": { label: "TTS Instructions", advanced: true },
|
help: "Deep-merges with messages.tts (Edge is ignored for calls).",
|
||||||
|
advanced: true,
|
||||||
|
},
|
||||||
|
"tts.openai.model": { label: "OpenAI TTS Model", advanced: true },
|
||||||
|
"tts.openai.voice": { label: "OpenAI TTS Voice", advanced: true },
|
||||||
|
"tts.openai.apiKey": {
|
||||||
|
label: "OpenAI API Key",
|
||||||
|
sensitive: true,
|
||||||
|
advanced: true,
|
||||||
|
},
|
||||||
|
"tts.elevenlabs.modelId": { label: "ElevenLabs Model ID", advanced: true },
|
||||||
|
"tts.elevenlabs.voiceId": { label: "ElevenLabs Voice ID", advanced: true },
|
||||||
|
"tts.elevenlabs.apiKey": {
|
||||||
|
label: "ElevenLabs API Key",
|
||||||
|
sensitive: true,
|
||||||
|
advanced: true,
|
||||||
|
},
|
||||||
|
"tts.elevenlabs.baseUrl": { label: "ElevenLabs Base URL", advanced: true },
|
||||||
publicUrl: { label: "Public Webhook URL", advanced: true },
|
publicUrl: { label: "Public Webhook URL", advanced: true },
|
||||||
skipSignatureVerification: {
|
skipSignatureVerification: {
|
||||||
label: "Skip Signature Verification",
|
label: "Skip Signature Verification",
|
||||||
@ -161,6 +178,7 @@ const voiceCallPlugin = {
|
|||||||
runtimePromise = createVoiceCallRuntime({
|
runtimePromise = createVoiceCallRuntime({
|
||||||
config: cfg,
|
config: cfg,
|
||||||
coreConfig: api.config as CoreConfig,
|
coreConfig: api.config as CoreConfig,
|
||||||
|
ttsRuntime: api.runtime.tts,
|
||||||
logger: api.logger,
|
logger: api.logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,31 +82,82 @@ export const SttConfigSchema = z
|
|||||||
.default({ provider: "openai", model: "whisper-1" });
|
.default({ provider: "openai", model: "whisper-1" });
|
||||||
export type SttConfig = z.infer<typeof SttConfigSchema>;
|
export type SttConfig = z.infer<typeof SttConfigSchema>;
|
||||||
|
|
||||||
|
export const TtsProviderSchema = z.enum(["openai", "elevenlabs", "edge"]);
|
||||||
|
export const TtsModeSchema = z.enum(["final", "all"]);
|
||||||
|
export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]);
|
||||||
|
|
||||||
export const TtsConfigSchema = z
|
export const TtsConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
/** TTS provider (currently only OpenAI supported) */
|
auto: TtsAutoSchema.optional(),
|
||||||
provider: z.literal("openai").default("openai"),
|
enabled: z.boolean().optional(),
|
||||||
/**
|
mode: TtsModeSchema.optional(),
|
||||||
* TTS model to use:
|
provider: TtsProviderSchema.optional(),
|
||||||
* - gpt-4o-mini-tts: newest, supports instructions for tone/style control (recommended)
|
summaryModel: z.string().optional(),
|
||||||
* - tts-1: lower latency
|
modelOverrides: z
|
||||||
* - tts-1-hd: higher quality
|
.object({
|
||||||
*/
|
enabled: z.boolean().optional(),
|
||||||
model: z.string().min(1).default("gpt-4o-mini-tts"),
|
allowText: z.boolean().optional(),
|
||||||
/**
|
allowProvider: z.boolean().optional(),
|
||||||
* Voice ID. For best quality, use marin or cedar.
|
allowVoice: z.boolean().optional(),
|
||||||
* All voices: alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse, marin, cedar
|
allowModelId: z.boolean().optional(),
|
||||||
*/
|
allowVoiceSettings: z.boolean().optional(),
|
||||||
voice: z.string().min(1).default("coral"),
|
allowNormalization: z.boolean().optional(),
|
||||||
/**
|
allowSeed: z.boolean().optional(),
|
||||||
* Instructions for speech style (only works with gpt-4o-mini-tts).
|
|
||||||
* Examples: "Speak in a cheerful tone", "Talk like a sympathetic customer service agent"
|
|
||||||
*/
|
|
||||||
instructions: z.string().optional(),
|
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
|
.optional(),
|
||||||
export type TtsConfig = z.infer<typeof TtsConfigSchema>;
|
elevenlabs: z
|
||||||
|
.object({
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
baseUrl: z.string().optional(),
|
||||||
|
voiceId: z.string().optional(),
|
||||||
|
modelId: z.string().optional(),
|
||||||
|
seed: z.number().int().min(0).max(4294967295).optional(),
|
||||||
|
applyTextNormalization: z.enum(["auto", "on", "off"]).optional(),
|
||||||
|
languageCode: z.string().optional(),
|
||||||
|
voiceSettings: z
|
||||||
|
.object({
|
||||||
|
stability: z.number().min(0).max(1).optional(),
|
||||||
|
similarityBoost: z.number().min(0).max(1).optional(),
|
||||||
|
style: z.number().min(0).max(1).optional(),
|
||||||
|
useSpeakerBoost: z.boolean().optional(),
|
||||||
|
speed: z.number().min(0.5).max(2).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
openai: z
|
||||||
|
.object({
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
voice: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
edge: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
voice: z.string().optional(),
|
||||||
|
lang: z.string().optional(),
|
||||||
|
outputFormat: z.string().optional(),
|
||||||
|
pitch: z.string().optional(),
|
||||||
|
rate: z.string().optional(),
|
||||||
|
volume: z.string().optional(),
|
||||||
|
saveSubtitles: z.boolean().optional(),
|
||||||
|
proxy: z.string().optional(),
|
||||||
|
timeoutMs: z.number().int().min(1000).max(120000).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
prefsPath: z.string().optional(),
|
||||||
|
maxTextLength: z.number().int().min(1).optional(),
|
||||||
|
timeoutMs: z.number().int().min(1000).max(120000).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional();
|
||||||
|
export type VoiceCallTtsConfig = z.infer<typeof TtsConfigSchema>;
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Webhook Server Configuration
|
// Webhook Server Configuration
|
||||||
@ -307,7 +358,7 @@ export const VoiceCallConfigSchema = z
|
|||||||
/** STT configuration */
|
/** STT configuration */
|
||||||
stt: SttConfigSchema,
|
stt: SttConfigSchema,
|
||||||
|
|
||||||
/** TTS configuration */
|
/** TTS override (deep-merges with core messages.tts) */
|
||||||
tts: TtsConfigSchema,
|
tts: TtsConfigSchema,
|
||||||
|
|
||||||
/** Store path for call logs */
|
/** Store path for call logs */
|
||||||
|
|||||||
@ -2,10 +2,16 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
import type { VoiceCallTtsConfig } from "./config.js";
|
||||||
|
|
||||||
export type CoreConfig = {
|
export type CoreConfig = {
|
||||||
session?: {
|
session?: {
|
||||||
store?: string;
|
store?: string;
|
||||||
};
|
};
|
||||||
|
messages?: {
|
||||||
|
tts?: VoiceCallTtsConfig;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CoreAgentDeps = {
|
type CoreAgentDeps = {
|
||||||
|
|||||||
@ -143,7 +143,7 @@ export class CallManager {
|
|||||||
// For notify mode with a message, use inline TwiML with <Say>
|
// For notify mode with a message, use inline TwiML with <Say>
|
||||||
let inlineTwiml: string | undefined;
|
let inlineTwiml: string | undefined;
|
||||||
if (mode === "notify" && initialMessage) {
|
if (mode === "notify" && initialMessage) {
|
||||||
const pollyVoice = mapVoiceToPolly(this.config.tts.voice);
|
const pollyVoice = mapVoiceToPolly(this.config.tts?.openai?.voice);
|
||||||
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
|
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
|
||||||
console.log(
|
console.log(
|
||||||
`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
|
`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
|
||||||
@ -210,11 +210,13 @@ export class CallManager {
|
|||||||
this.addTranscriptEntry(call, "bot", text);
|
this.addTranscriptEntry(call, "bot", text);
|
||||||
|
|
||||||
// Play TTS
|
// Play TTS
|
||||||
|
const voice =
|
||||||
|
this.provider?.name === "twilio" ? this.config.tts?.openai?.voice : undefined;
|
||||||
await this.provider.playTts({
|
await this.provider.playTts({
|
||||||
callId,
|
callId,
|
||||||
providerCallId: call.providerCallId,
|
providerCallId: call.providerCallId,
|
||||||
text,
|
text,
|
||||||
voice: this.config.tts.voice,
|
voice,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@ -19,4 +19,3 @@ export type CallManagerContext = {
|
|||||||
transcriptWaiters: Map<CallId, TranscriptWaiter>;
|
transcriptWaiters: Map<CallId, TranscriptWaiter>;
|
||||||
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
|
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -175,4 +175,3 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
|
|||||||
|
|
||||||
persistCallRecord(ctx.storePath, call);
|
persistCallRecord(ctx.storePath, call);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,4 +31,3 @@ export function findCall(params: {
|
|||||||
providerCallId: params.callIdOrProviderCallId,
|
providerCallId: params.callIdOrProviderCallId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -68,7 +68,7 @@ export async function initiateCall(
|
|||||||
// For notify mode with a message, use inline TwiML with <Say>.
|
// For notify mode with a message, use inline TwiML with <Say>.
|
||||||
let inlineTwiml: string | undefined;
|
let inlineTwiml: string | undefined;
|
||||||
if (mode === "notify" && initialMessage) {
|
if (mode === "notify" && initialMessage) {
|
||||||
const pollyVoice = mapVoiceToPolly(ctx.config.tts.voice);
|
const pollyVoice = mapVoiceToPolly(ctx.config.tts?.openai?.voice);
|
||||||
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
|
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
|
||||||
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
|
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
|
||||||
}
|
}
|
||||||
@ -120,11 +120,13 @@ export async function speak(
|
|||||||
|
|
||||||
addTranscriptEntry(call, "bot", text);
|
addTranscriptEntry(call, "bot", text);
|
||||||
|
|
||||||
|
const voice =
|
||||||
|
ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
|
||||||
await ctx.provider.playTts({
|
await ctx.provider.playTts({
|
||||||
callId,
|
callId,
|
||||||
providerCallId: call.providerCallId,
|
providerCallId: call.providerCallId,
|
||||||
text,
|
text,
|
||||||
voice: ctx.config.tts.voice,
|
voice,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@ -244,4 +246,3 @@ export async function endCall(
|
|||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -48,4 +48,3 @@ export function addTranscriptEntry(
|
|||||||
};
|
};
|
||||||
call.transcript.push(entry);
|
call.transcript.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -86,4 +86,3 @@ export async function getCallHistoryFromStore(
|
|||||||
|
|
||||||
return calls;
|
return calls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -84,4 +84,3 @@ export function waitForFinalTranscript(
|
|||||||
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout });
|
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,4 +7,3 @@ export function generateNotifyTwiml(message: string, voice: string): string {
|
|||||||
<Hangup/>
|
<Hangup/>
|
||||||
</Response>`;
|
</Response>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
97
extensions/voice-call/src/media-stream.test.ts
Normal file
97
extensions/voice-call/src/media-stream.test.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -29,6 +29,8 @@ export interface MediaStreamConfig {
|
|||||||
onPartialTranscript?: (callId: string, partial: string) => void;
|
onPartialTranscript?: (callId: string, partial: string) => void;
|
||||||
/** Callback when stream connects */
|
/** Callback when stream connects */
|
||||||
onConnect?: (callId: string, streamSid: string) => void;
|
onConnect?: (callId: string, streamSid: string) => void;
|
||||||
|
/** Callback when speech starts (barge-in) */
|
||||||
|
onSpeechStart?: (callId: string) => void;
|
||||||
/** Callback when stream disconnects */
|
/** Callback when stream disconnects */
|
||||||
onDisconnect?: (callId: string) => void;
|
onDisconnect?: (callId: string) => void;
|
||||||
}
|
}
|
||||||
@ -43,6 +45,13 @@ interface StreamSession {
|
|||||||
sttSession: RealtimeSTTSession;
|
sttSession: RealtimeSTTSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TtsQueueEntry = {
|
||||||
|
playFn: (signal: AbortSignal) => Promise<void>;
|
||||||
|
controller: AbortController;
|
||||||
|
resolve: () => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages WebSocket connections for Twilio media streams.
|
* Manages WebSocket connections for Twilio media streams.
|
||||||
*/
|
*/
|
||||||
@ -50,6 +59,12 @@ export class MediaStreamHandler {
|
|||||||
private wss: WebSocketServer | null = null;
|
private wss: WebSocketServer | null = null;
|
||||||
private sessions = new Map<string, StreamSession>();
|
private sessions = new Map<string, StreamSession>();
|
||||||
private config: MediaStreamConfig;
|
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) {
|
constructor(config: MediaStreamConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@ -148,6 +163,10 @@ export class MediaStreamHandler {
|
|||||||
this.config.onTranscript?.(callSid, transcript);
|
this.config.onTranscript?.(callSid, transcript);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
sttSession.onSpeechStart(() => {
|
||||||
|
this.config.onSpeechStart?.(callSid);
|
||||||
|
});
|
||||||
|
|
||||||
const session: StreamSession = {
|
const session: StreamSession = {
|
||||||
callId: callSid,
|
callId: callSid,
|
||||||
streamSid,
|
streamSid,
|
||||||
@ -177,6 +196,7 @@ export class MediaStreamHandler {
|
|||||||
private handleStop(session: StreamSession): void {
|
private handleStop(session: StreamSession): void {
|
||||||
console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
|
console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
|
||||||
|
|
||||||
|
this.clearTtsState(session.streamSid);
|
||||||
session.sttSession.close();
|
session.sttSession.close();
|
||||||
this.sessions.delete(session.streamSid);
|
this.sessions.delete(session.streamSid);
|
||||||
this.config.onDisconnect?.(session.callId);
|
this.config.onDisconnect?.(session.callId);
|
||||||
@ -228,6 +248,46 @@ export class MediaStreamHandler {
|
|||||||
this.sendToStream(streamSid, { event: "clear", streamSid });
|
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.
|
* Get active session by call ID.
|
||||||
*/
|
*/
|
||||||
@ -242,11 +302,65 @@ export class MediaStreamHandler {
|
|||||||
*/
|
*/
|
||||||
closeAll(): void {
|
closeAll(): void {
|
||||||
for (const session of this.sessions.values()) {
|
for (const session of this.sessions.values()) {
|
||||||
|
this.clearTtsState(session.streamSid);
|
||||||
session.sttSession.close();
|
session.sttSession.close();
|
||||||
session.ws.close();
|
session.ws.close();
|
||||||
}
|
}
|
||||||
this.sessions.clear();
|
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,4 +26,3 @@ describe("PlivoProvider", () => {
|
|||||||
expect(result.providerResponseBody).toContain('length="300"');
|
expect(result.providerResponseBody).toContain('length="300"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,8 @@ export interface RealtimeSTTSession {
|
|||||||
onPartial(callback: (partial: string) => void): void;
|
onPartial(callback: (partial: string) => void): void;
|
||||||
/** Set callback for final transcripts */
|
/** Set callback for final transcripts */
|
||||||
onTranscript(callback: (transcript: string) => void): void;
|
onTranscript(callback: (transcript: string) => void): void;
|
||||||
|
/** Set callback when speech starts (VAD) */
|
||||||
|
onSpeechStart(callback: () => void): void;
|
||||||
/** Close the session */
|
/** Close the session */
|
||||||
close(): void;
|
close(): void;
|
||||||
/** Check if session is connected */
|
/** Check if session is connected */
|
||||||
@ -91,6 +93,7 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
|||||||
private pendingTranscript = "";
|
private pendingTranscript = "";
|
||||||
private onTranscriptCallback: ((transcript: string) => void) | null = null;
|
private onTranscriptCallback: ((transcript: string) => void) | null = null;
|
||||||
private onPartialCallback: ((partial: string) => void) | null = null;
|
private onPartialCallback: ((partial: string) => void) | null = null;
|
||||||
|
private onSpeechStartCallback: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly apiKey: string,
|
private readonly apiKey: string,
|
||||||
@ -243,6 +246,7 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
|||||||
case "input_audio_buffer.speech_started":
|
case "input_audio_buffer.speech_started":
|
||||||
console.log("[RealtimeSTT] Speech started");
|
console.log("[RealtimeSTT] Speech started");
|
||||||
this.pendingTranscript = "";
|
this.pendingTranscript = "";
|
||||||
|
this.onSpeechStartCallback?.();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
@ -273,6 +277,10 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
|||||||
this.onTranscriptCallback = callback;
|
this.onTranscriptCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSpeechStart(callback: () => void): void {
|
||||||
|
this.onSpeechStartCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
async waitForTranscript(timeoutMs = 30000): Promise<string> {
|
async waitForTranscript(timeoutMs = 30000): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
|||||||
@ -15,9 +15,9 @@ import type {
|
|||||||
WebhookVerificationResult,
|
WebhookVerificationResult,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
|
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
|
||||||
|
import { chunkAudio } from "../telephony-audio.js";
|
||||||
|
import type { TelephonyTtsProvider } from "../telephony-tts.js";
|
||||||
import type { VoiceCallProvider } from "./base.js";
|
import type { VoiceCallProvider } from "./base.js";
|
||||||
import type { OpenAITTSProvider } from "./tts-openai.js";
|
|
||||||
import { chunkAudio } from "./tts-openai.js";
|
|
||||||
import { twilioApiRequest } from "./twilio/api.js";
|
import { twilioApiRequest } from "./twilio/api.js";
|
||||||
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
|
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
|
||||||
|
|
||||||
@ -53,8 +53,8 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
/** Current public webhook URL (set when tunnel starts or from config) */
|
/** Current public webhook URL (set when tunnel starts or from config) */
|
||||||
private currentPublicUrl: string | null = null;
|
private currentPublicUrl: string | null = null;
|
||||||
|
|
||||||
/** Optional OpenAI TTS provider for streaming TTS */
|
/** Optional telephony TTS provider for streaming TTS */
|
||||||
private ttsProvider: OpenAITTSProvider | null = null;
|
private ttsProvider: TelephonyTtsProvider | null = null;
|
||||||
|
|
||||||
/** Optional media stream handler for sending audio */
|
/** Optional media stream handler for sending audio */
|
||||||
private mediaStreamHandler: MediaStreamHandler | null = null;
|
private mediaStreamHandler: MediaStreamHandler | null = null;
|
||||||
@ -119,7 +119,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
return this.currentPublicUrl;
|
return this.currentPublicUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTTSProvider(provider: OpenAITTSProvider): void {
|
setTTSProvider(provider: TelephonyTtsProvider): void {
|
||||||
this.ttsProvider = provider;
|
this.ttsProvider = provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,6 +135,17 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
this.callStreamMap.delete(callSid);
|
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.
|
* Make an authenticated request to the Twilio API.
|
||||||
*/
|
*/
|
||||||
@ -454,13 +465,13 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
* Play TTS audio via Twilio.
|
* Play TTS audio via Twilio.
|
||||||
*
|
*
|
||||||
* Two modes:
|
* Two modes:
|
||||||
* 1. OpenAI TTS + Media Streams: If TTS provider and media stream are available,
|
* 1. Core TTS + Media Streams: If TTS provider and media stream are available,
|
||||||
* generates audio via OpenAI and streams it through WebSocket (preferred).
|
* generates audio via core TTS and streams it through WebSocket (preferred).
|
||||||
* 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices.
|
* 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices.
|
||||||
* Note: This may not work on all Twilio accounts.
|
* Note: This may not work on all Twilio accounts.
|
||||||
*/
|
*/
|
||||||
async playTts(input: PlayTtsInput): Promise<void> {
|
async playTts(input: PlayTtsInput): Promise<void> {
|
||||||
// Try OpenAI TTS via media stream first (if configured)
|
// Try telephony TTS via media stream first (if configured)
|
||||||
const streamSid = this.callStreamMap.get(input.providerCallId);
|
const streamSid = this.callStreamMap.get(input.providerCallId);
|
||||||
if (this.ttsProvider && this.mediaStreamHandler && streamSid) {
|
if (this.ttsProvider && this.mediaStreamHandler && streamSid) {
|
||||||
try {
|
try {
|
||||||
@ -468,7 +479,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[voice-call] OpenAI TTS failed, falling back to Twilio <Say>:`,
|
`[voice-call] Telephony TTS failed, falling back to Twilio <Say>:`,
|
||||||
err instanceof Error ? err.message : err,
|
err instanceof Error ? err.message : err,
|
||||||
);
|
);
|
||||||
// Fall through to TwiML <Say> fallback
|
// Fall through to TwiML <Say> fallback
|
||||||
@ -484,7 +495,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.warn(
|
console.warn(
|
||||||
"[voice-call] Using TwiML <Say> fallback - OpenAI TTS not configured or media stream not active",
|
"[voice-call] Using TwiML <Say> fallback - telephony TTS not configured or media stream not active",
|
||||||
);
|
);
|
||||||
|
|
||||||
const pollyVoice = mapVoiceToPolly(input.voice);
|
const pollyVoice = mapVoiceToPolly(input.voice);
|
||||||
@ -502,9 +513,9 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Play TTS via OpenAI and Twilio Media Streams.
|
* Play TTS via core TTS and Twilio Media Streams.
|
||||||
* Generates audio with OpenAI TTS, converts to mu-law, and streams via WebSocket.
|
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
|
||||||
* Uses a jitter buffer to smooth out timing variations.
|
* Uses a queue to serialize playback and prevent overlapping audio.
|
||||||
*/
|
*/
|
||||||
private async playTtsViaStream(
|
private async playTtsViaStream(
|
||||||
text: string,
|
text: string,
|
||||||
@ -514,22 +525,29 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
throw new Error("TTS provider and media stream handler required");
|
throw new Error("TTS provider and media stream handler required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate audio with OpenAI TTS (returns mu-law at 8kHz)
|
|
||||||
const muLawAudio = await this.ttsProvider.synthesizeForTwilio(text);
|
|
||||||
|
|
||||||
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
|
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
|
||||||
const CHUNK_SIZE = 160;
|
const CHUNK_SIZE = 160;
|
||||||
const CHUNK_DELAY_MS = 20;
|
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)) {
|
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
|
||||||
this.mediaStreamHandler.sendAudio(streamSid, chunk);
|
if (signal.aborted) break;
|
||||||
|
handler.sendAudio(streamSid, chunk);
|
||||||
|
|
||||||
// Pace the audio to match real-time playback
|
// Pace the audio to match real-time playback
|
||||||
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
|
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
|
||||||
|
if (signal.aborted) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!signal.aborted) {
|
||||||
// Send a mark to track when audio finishes
|
// Send a mark to track when audio finishes
|
||||||
this.mediaStreamHandler.sendMark(streamSid, `tts-${Date.now()}`);
|
handler.sendMark(streamSid, `tts-${Date.now()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -27,4 +27,3 @@ export function verifyTwilioProviderWebhook(params: {
|
|||||||
reason: result.reason,
|
reason: result.reason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,8 +6,9 @@ import type { VoiceCallProvider } from "./providers/base.js";
|
|||||||
import { MockProvider } from "./providers/mock.js";
|
import { MockProvider } from "./providers/mock.js";
|
||||||
import { PlivoProvider } from "./providers/plivo.js";
|
import { PlivoProvider } from "./providers/plivo.js";
|
||||||
import { TelnyxProvider } from "./providers/telnyx.js";
|
import { TelnyxProvider } from "./providers/telnyx.js";
|
||||||
import { OpenAITTSProvider } from "./providers/tts-openai.js";
|
|
||||||
import { TwilioProvider } from "./providers/twilio.js";
|
import { TwilioProvider } from "./providers/twilio.js";
|
||||||
|
import type { TelephonyTtsRuntime } from "./telephony-tts.js";
|
||||||
|
import { createTelephonyTtsProvider } from "./telephony-tts.js";
|
||||||
import { startTunnel, type TunnelResult } from "./tunnel.js";
|
import { startTunnel, type TunnelResult } from "./tunnel.js";
|
||||||
import {
|
import {
|
||||||
cleanupTailscaleExposure,
|
cleanupTailscaleExposure,
|
||||||
@ -81,9 +82,10 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
|
|||||||
export async function createVoiceCallRuntime(params: {
|
export async function createVoiceCallRuntime(params: {
|
||||||
config: VoiceCallConfig;
|
config: VoiceCallConfig;
|
||||||
coreConfig: CoreConfig;
|
coreConfig: CoreConfig;
|
||||||
|
ttsRuntime?: TelephonyTtsRuntime;
|
||||||
logger?: Logger;
|
logger?: Logger;
|
||||||
}): Promise<VoiceCallRuntime> {
|
}): Promise<VoiceCallRuntime> {
|
||||||
const { config, coreConfig, logger } = params;
|
const { config, coreConfig, ttsRuntime, logger } = params;
|
||||||
const log = logger ?? {
|
const log = logger ?? {
|
||||||
info: console.log,
|
info: console.log,
|
||||||
warn: console.warn,
|
warn: console.warn,
|
||||||
@ -149,27 +151,24 @@ export async function createVoiceCallRuntime(params: {
|
|||||||
|
|
||||||
if (provider.name === "twilio" && config.streaming?.enabled) {
|
if (provider.name === "twilio" && config.streaming?.enabled) {
|
||||||
const twilioProvider = provider as TwilioProvider;
|
const twilioProvider = provider as TwilioProvider;
|
||||||
const openaiApiKey =
|
if (ttsRuntime?.textToSpeechTelephony) {
|
||||||
config.streaming.openaiApiKey || process.env.OPENAI_API_KEY;
|
|
||||||
if (openaiApiKey) {
|
|
||||||
try {
|
try {
|
||||||
const ttsProvider = new OpenAITTSProvider({
|
const ttsProvider = createTelephonyTtsProvider({
|
||||||
apiKey: openaiApiKey,
|
coreConfig,
|
||||||
voice: config.tts.voice,
|
ttsOverride: config.tts,
|
||||||
model: config.tts.model,
|
runtime: ttsRuntime,
|
||||||
instructions: config.tts.instructions,
|
|
||||||
});
|
});
|
||||||
twilioProvider.setTTSProvider(ttsProvider);
|
twilioProvider.setTTSProvider(ttsProvider);
|
||||||
log.info("[voice-call] OpenAI TTS provider configured");
|
log.info("[voice-call] Telephony TTS provider configured");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn(
|
log.warn(
|
||||||
`[voice-call] Failed to initialize OpenAI TTS: ${
|
`[voice-call] Failed to initialize telephony TTS: ${
|
||||||
err instanceof Error ? err.message : String(err)
|
err instanceof Error ? err.message : String(err)
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.warn("[voice-call] OpenAI TTS key missing; streaming TTS disabled");
|
log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaHandler = webhookServer.getMediaStreamHandler();
|
const mediaHandler = webhookServer.getMediaStreamHandler();
|
||||||
|
|||||||
88
extensions/voice-call/src/telephony-audio.ts
Normal file
88
extensions/voice-call/src/telephony-audio.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
const TELEPHONY_SAMPLE_RATE = 8000;
|
||||||
|
|
||||||
|
function clamp16(value: number): number {
|
||||||
|
return Math.max(-32768, Math.min(32767, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resample 16-bit PCM (little-endian mono) to 8kHz using linear interpolation.
|
||||||
|
*/
|
||||||
|
export function resamplePcmTo8k(input: Buffer, inputSampleRate: number): Buffer {
|
||||||
|
if (inputSampleRate === TELEPHONY_SAMPLE_RATE) return input;
|
||||||
|
const inputSamples = Math.floor(input.length / 2);
|
||||||
|
if (inputSamples === 0) return Buffer.alloc(0);
|
||||||
|
|
||||||
|
const ratio = inputSampleRate / TELEPHONY_SAMPLE_RATE;
|
||||||
|
const outputSamples = Math.floor(inputSamples / ratio);
|
||||||
|
const output = Buffer.alloc(outputSamples * 2);
|
||||||
|
|
||||||
|
for (let i = 0; i < outputSamples; i++) {
|
||||||
|
const srcPos = i * ratio;
|
||||||
|
const srcIndex = Math.floor(srcPos);
|
||||||
|
const frac = srcPos - srcIndex;
|
||||||
|
|
||||||
|
const s0 = input.readInt16LE(srcIndex * 2);
|
||||||
|
const s1Index = Math.min(srcIndex + 1, inputSamples - 1);
|
||||||
|
const s1 = input.readInt16LE(s1Index * 2);
|
||||||
|
|
||||||
|
const sample = Math.round(s0 + frac * (s1 - s0));
|
||||||
|
output.writeInt16LE(clamp16(sample), i * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert 16-bit PCM to 8-bit mu-law (G.711).
|
||||||
|
*/
|
||||||
|
export function pcmToMulaw(pcm: Buffer): Buffer {
|
||||||
|
const samples = Math.floor(pcm.length / 2);
|
||||||
|
const mulaw = Buffer.alloc(samples);
|
||||||
|
|
||||||
|
for (let i = 0; i < samples; i++) {
|
||||||
|
const sample = pcm.readInt16LE(i * 2);
|
||||||
|
mulaw[i] = linearToMulaw(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mulaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertPcmToMulaw8k(
|
||||||
|
pcm: Buffer,
|
||||||
|
inputSampleRate: number,
|
||||||
|
): Buffer {
|
||||||
|
const pcm8k = resamplePcmTo8k(pcm, inputSampleRate);
|
||||||
|
return pcmToMulaw(pcm8k);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunk audio buffer into 20ms frames for streaming (8kHz mono mu-law).
|
||||||
|
*/
|
||||||
|
export function chunkAudio(
|
||||||
|
audio: Buffer,
|
||||||
|
chunkSize = 160,
|
||||||
|
): Generator<Buffer, void, unknown> {
|
||||||
|
return (function* () {
|
||||||
|
for (let i = 0; i < audio.length; i += chunkSize) {
|
||||||
|
yield audio.subarray(i, Math.min(i + chunkSize, audio.length));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function linearToMulaw(sample: number): number {
|
||||||
|
const BIAS = 132;
|
||||||
|
const CLIP = 32635;
|
||||||
|
|
||||||
|
const sign = sample < 0 ? 0x80 : 0;
|
||||||
|
if (sample < 0) sample = -sample;
|
||||||
|
if (sample > CLIP) sample = CLIP;
|
||||||
|
|
||||||
|
sample += BIAS;
|
||||||
|
let exponent = 7;
|
||||||
|
for (let expMask = 0x4000; (sample & expMask) === 0 && exponent > 0; exponent--) {
|
||||||
|
expMask >>= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mantissa = (sample >> (exponent + 3)) & 0x0f;
|
||||||
|
return ~(sign | (exponent << 4) | mantissa) & 0xff;
|
||||||
|
}
|
||||||
95
extensions/voice-call/src/telephony-tts.ts
Normal file
95
extensions/voice-call/src/telephony-tts.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import type { CoreConfig } from "./core-bridge.js";
|
||||||
|
import type { VoiceCallTtsConfig } from "./config.js";
|
||||||
|
import { convertPcmToMulaw8k } from "./telephony-audio.js";
|
||||||
|
|
||||||
|
export type TelephonyTtsRuntime = {
|
||||||
|
textToSpeechTelephony: (params: {
|
||||||
|
text: string;
|
||||||
|
cfg: CoreConfig;
|
||||||
|
prefsPath?: string;
|
||||||
|
}) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
audioBuffer?: Buffer;
|
||||||
|
sampleRate?: number;
|
||||||
|
provider?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TelephonyTtsProvider = {
|
||||||
|
synthesizeForTelephony: (text: string) => Promise<Buffer>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createTelephonyTtsProvider(params: {
|
||||||
|
coreConfig: CoreConfig;
|
||||||
|
ttsOverride?: VoiceCallTtsConfig;
|
||||||
|
runtime: TelephonyTtsRuntime;
|
||||||
|
}): TelephonyTtsProvider {
|
||||||
|
const { coreConfig, ttsOverride, runtime } = params;
|
||||||
|
const mergedConfig = applyTtsOverride(coreConfig, ttsOverride);
|
||||||
|
|
||||||
|
return {
|
||||||
|
synthesizeForTelephony: async (text: string) => {
|
||||||
|
const result = await runtime.textToSpeechTelephony({
|
||||||
|
text,
|
||||||
|
cfg: mergedConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success || !result.audioBuffer || !result.sampleRate) {
|
||||||
|
throw new Error(result.error ?? "TTS conversion failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertPcmToMulaw8k(result.audioBuffer, result.sampleRate);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTtsOverride(
|
||||||
|
coreConfig: CoreConfig,
|
||||||
|
override?: VoiceCallTtsConfig,
|
||||||
|
): CoreConfig {
|
||||||
|
if (!override) return coreConfig;
|
||||||
|
|
||||||
|
const base = coreConfig.messages?.tts;
|
||||||
|
const merged = mergeTtsConfig(base, override);
|
||||||
|
if (!merged) return coreConfig;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...coreConfig,
|
||||||
|
messages: {
|
||||||
|
...(coreConfig.messages ?? {}),
|
||||||
|
tts: merged,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeTtsConfig(
|
||||||
|
base?: VoiceCallTtsConfig,
|
||||||
|
override?: VoiceCallTtsConfig,
|
||||||
|
): VoiceCallTtsConfig | undefined {
|
||||||
|
if (!base && !override) return undefined;
|
||||||
|
if (!override) return base;
|
||||||
|
if (!base) return override;
|
||||||
|
return deepMerge(base, override);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepMerge<T>(base: T, override: T): T {
|
||||||
|
if (!isPlainObject(base) || !isPlainObject(override)) {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
const result: Record<string, unknown> = { ...base };
|
||||||
|
for (const [key, value] of Object.entries(override)) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
const existing = (base as Record<string, unknown>)[key];
|
||||||
|
if (isPlainObject(existing) && isPlainObject(value)) {
|
||||||
|
result[key] = deepMerge(existing, value);
|
||||||
|
} else {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@ -78,6 +78,11 @@ export class VoiceCallWebhookServer {
|
|||||||
`[voice-call] Transcript for ${providerCallId}: ${transcript}`,
|
`[voice-call] Transcript for ${providerCallId}: ${transcript}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clear TTS queue on barge-in (user started speaking, interrupt current playback)
|
||||||
|
if (this.provider.name === "twilio") {
|
||||||
|
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
|
||||||
|
}
|
||||||
|
|
||||||
// Look up our internal call ID from the provider call ID
|
// Look up our internal call ID from the provider call ID
|
||||||
const call = this.manager.getCallByProviderCallId(providerCallId);
|
const call = this.manager.getCallByProviderCallId(providerCallId);
|
||||||
if (!call) {
|
if (!call) {
|
||||||
@ -109,6 +114,11 @@ export class VoiceCallWebhookServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSpeechStart: (providerCallId) => {
|
||||||
|
if (this.provider.name === "twilio") {
|
||||||
|
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
|
||||||
|
}
|
||||||
|
},
|
||||||
onPartialTranscript: (callId, partial) => {
|
onPartialTranscript: (callId, partial) => {
|
||||||
console.log(`[voice-call] Partial for ${callId}: ${partial}`);
|
console.log(`[voice-call] Partial for ${callId}: ${partial}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -15,4 +15,3 @@ describe("zalouser outbound chunker", () => {
|
|||||||
expect(chunks.every((c) => c.length <= limit)).toBe(true);
|
expect(chunks.every((c) => c.length <= limit)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,7 @@
|
|||||||
"dist/signal/**",
|
"dist/signal/**",
|
||||||
"dist/slack/**",
|
"dist/slack/**",
|
||||||
"dist/telegram/**",
|
"dist/telegram/**",
|
||||||
|
"dist/line/**",
|
||||||
"dist/tui/**",
|
"dist/tui/**",
|
||||||
"dist/tts/**",
|
"dist/tts/**",
|
||||||
"dist/web/**",
|
"dist/web/**",
|
||||||
@ -154,6 +155,7 @@
|
|||||||
"@grammyjs/runner": "^2.0.3",
|
"@grammyjs/runner": "^2.0.3",
|
||||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||||
"@homebridge/ciao": "^1.3.4",
|
"@homebridge/ciao": "^1.3.4",
|
||||||
|
"@line/bot-sdk": "^10.6.0",
|
||||||
"@lydell/node-pty": "1.2.0-beta.3",
|
"@lydell/node-pty": "1.2.0-beta.3",
|
||||||
"@mariozechner/pi-agent-core": "0.49.3",
|
"@mariozechner/pi-agent-core": "0.49.3",
|
||||||
"@mariozechner/pi-ai": "0.49.3",
|
"@mariozechner/pi-ai": "0.49.3",
|
||||||
|
|||||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@ -34,6 +34,9 @@ importers:
|
|||||||
'@homebridge/ciao':
|
'@homebridge/ciao':
|
||||||
specifier: ^1.3.4
|
specifier: ^1.3.4
|
||||||
version: 1.3.4
|
version: 1.3.4
|
||||||
|
'@line/bot-sdk':
|
||||||
|
specifier: ^10.6.0
|
||||||
|
version: 10.6.0
|
||||||
'@lydell/node-pty':
|
'@lydell/node-pty':
|
||||||
specifier: 1.2.0-beta.3
|
specifier: 1.2.0-beta.3
|
||||||
version: 1.2.0-beta.3
|
version: 1.2.0-beta.3
|
||||||
@ -317,6 +320,12 @@ importers:
|
|||||||
|
|
||||||
extensions/imessage: {}
|
extensions/imessage: {}
|
||||||
|
|
||||||
|
extensions/line:
|
||||||
|
devDependencies:
|
||||||
|
clawdbot:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../..
|
||||||
|
|
||||||
extensions/llm-task: {}
|
extensions/llm-task: {}
|
||||||
|
|
||||||
extensions/lobster: {}
|
extensions/lobster: {}
|
||||||
@ -1311,6 +1320,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
apache-arrow: '>=15.0.0 <=18.1.0'
|
apache-arrow: '>=15.0.0 <=18.1.0'
|
||||||
|
|
||||||
|
'@line/bot-sdk@10.6.0':
|
||||||
|
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
'@lit-labs/signals@0.2.0':
|
'@lit-labs/signals@0.2.0':
|
||||||
resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==}
|
resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==}
|
||||||
|
|
||||||
@ -2717,6 +2730,9 @@ packages:
|
|||||||
'@types/node@20.19.30':
|
'@types/node@20.19.30':
|
||||||
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
||||||
|
|
||||||
|
'@types/node@24.10.9':
|
||||||
|
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
|
||||||
|
|
||||||
'@types/node@25.0.10':
|
'@types/node@25.0.10':
|
||||||
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
|
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
|
||||||
|
|
||||||
@ -6846,6 +6862,14 @@ snapshots:
|
|||||||
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
||||||
'@lancedb/lancedb-win32-x64-msvc': 0.23.0
|
'@lancedb/lancedb-win32-x64-msvc': 0.23.0
|
||||||
|
|
||||||
|
'@line/bot-sdk@10.6.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.10.9
|
||||||
|
optionalDependencies:
|
||||||
|
axios: 1.13.2(debug@4.4.3)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
'@lit-labs/signals@0.2.0':
|
'@lit-labs/signals@0.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
lit: 3.3.2
|
lit: 3.3.2
|
||||||
@ -8474,6 +8498,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/node@24.10.9':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 7.16.0
|
||||||
|
|
||||||
'@types/node@25.0.10':
|
'@types/node@25.0.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
|
|||||||
31
scripts/pre-commit/run-node-tool.sh
Executable file
31
scripts/pre-commit/run-node-tool.sh
Executable file
@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
|
||||||
|
if [[ $# -lt 1 ]]; then
|
||||||
|
echo "usage: run-node-tool.sh <tool> [args...]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
tool="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
if [[ -f "$ROOT_DIR/pnpm-lock.yaml" ]] && command -v pnpm >/dev/null 2>&1; then
|
||||||
|
exec pnpm exec "$tool" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if { [[ -f "$ROOT_DIR/bun.lockb" ]] || [[ -f "$ROOT_DIR/bun.lock" ]]; } && command -v bun >/dev/null 2>&1; then
|
||||||
|
exec bunx --bun "$tool" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v npm >/dev/null 2>&1; then
|
||||||
|
exec npm exec -- "$tool" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v npx >/dev/null 2>&1; then
|
||||||
|
exec npx "$tool" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Missing package manager: pnpm, bun, or npm required." >&2
|
||||||
|
exit 1
|
||||||
@ -28,9 +28,10 @@ const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "",
|
|||||||
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
||||||
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||||
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
||||||
// Keep worker counts predictable for local runs and for CI on macOS.
|
const macCiWorkers = isCI && isMacOS ? 1 : perRunWorkers;
|
||||||
|
// Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM.
|
||||||
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
|
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
|
||||||
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : perRunWorkers);
|
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : macCiWorkers);
|
||||||
|
|
||||||
const WARNING_SUPPRESSION_FLAGS = [
|
const WARNING_SUPPRESSION_FLAGS = [
|
||||||
"--disable-warning=ExperimentalWarning",
|
"--disable-warning=ExperimentalWarning",
|
||||||
|
|||||||
@ -30,4 +30,3 @@ export type Entry = {
|
|||||||
avatar_url: string;
|
avatar_url: string;
|
||||||
lines: number;
|
lines: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export type ModelCatalogEntry = {
|
|||||||
provider: string;
|
provider: string;
|
||||||
contextWindow?: number;
|
contextWindow?: number;
|
||||||
reasoning?: boolean;
|
reasoning?: boolean;
|
||||||
|
input?: Array<"text" | "image">;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DiscoveredModel = {
|
type DiscoveredModel = {
|
||||||
@ -16,6 +17,7 @@ type DiscoveredModel = {
|
|||||||
provider: string;
|
provider: string;
|
||||||
contextWindow?: number;
|
contextWindow?: number;
|
||||||
reasoning?: boolean;
|
reasoning?: boolean;
|
||||||
|
input?: Array<"text" | "image">;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PiSdkModule = typeof import("@mariozechner/pi-coding-agent");
|
type PiSdkModule = typeof import("@mariozechner/pi-coding-agent");
|
||||||
@ -80,7 +82,10 @@ export async function loadModelCatalog(params?: {
|
|||||||
? entry.contextWindow
|
? entry.contextWindow
|
||||||
: undefined;
|
: undefined;
|
||||||
const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined;
|
const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined;
|
||||||
models.push({ id, name, provider, contextWindow, reasoning });
|
const input = Array.isArray(entry?.input)
|
||||||
|
? (entry.input as Array<"text" | "image">)
|
||||||
|
: undefined;
|
||||||
|
models.push({ id, name, provider, contextWindow, reasoning, input });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
@ -105,3 +110,27 @@ export async function loadModelCatalog(params?: {
|
|||||||
|
|
||||||
return modelCatalogPromise;
|
return modelCatalogPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a model supports image input based on its catalog entry.
|
||||||
|
*/
|
||||||
|
export function modelSupportsVision(entry: ModelCatalogEntry | undefined): boolean {
|
||||||
|
return entry?.input?.includes("image") ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a model in the catalog by provider and model ID.
|
||||||
|
*/
|
||||||
|
export function findModelInCatalog(
|
||||||
|
catalog: ModelCatalogEntry[],
|
||||||
|
provider: string,
|
||||||
|
modelId: string,
|
||||||
|
): ModelCatalogEntry | undefined {
|
||||||
|
const normalizedProvider = provider.toLowerCase().trim();
|
||||||
|
const normalizedModelId = modelId.toLowerCase().trim();
|
||||||
|
return catalog.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.provider.toLowerCase() === normalizedProvider &&
|
||||||
|
entry.id.toLowerCase() === normalizedModelId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -27,4 +27,14 @@ describe("sanitizeUserFacingText", () => {
|
|||||||
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
|
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
|
||||||
expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded");
|
expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("collapses consecutive duplicate paragraphs", () => {
|
||||||
|
const text = "Hello there!\n\nHello there!";
|
||||||
|
expect(sanitizeUserFacingText(text)).toBe("Hello there!");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not collapse distinct paragraphs", () => {
|
||||||
|
const text = "Hello there!\n\nDifferent line.";
|
||||||
|
expect(sanitizeUserFacingText(text)).toBe(text);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -77,6 +77,29 @@ function stripFinalTagsFromText(text: string): string {
|
|||||||
return text.replace(FINAL_TAG_RE, "");
|
return text.replace(FINAL_TAG_RE, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collapseConsecutiveDuplicateBlocks(text: string): string {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return text;
|
||||||
|
const blocks = trimmed.split(/\n{2,}/);
|
||||||
|
if (blocks.length < 2) return text;
|
||||||
|
|
||||||
|
const normalizeBlock = (value: string) => value.trim().replace(/\s+/g, " ");
|
||||||
|
const result: string[] = [];
|
||||||
|
let lastNormalized: string | null = null;
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const normalized = normalizeBlock(block);
|
||||||
|
if (lastNormalized && normalized === lastNormalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push(block.trim());
|
||||||
|
lastNormalized = normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.length === blocks.length) return text;
|
||||||
|
return result.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
function isLikelyHttpErrorText(raw: string): boolean {
|
function isLikelyHttpErrorText(raw: string): boolean {
|
||||||
const match = raw.match(HTTP_STATUS_PREFIX_RE);
|
const match = raw.match(HTTP_STATUS_PREFIX_RE);
|
||||||
if (!match) return false;
|
if (!match) return false;
|
||||||
@ -321,7 +344,7 @@ export function sanitizeUserFacingText(text: string): string {
|
|||||||
return formatRawAssistantErrorForUi(trimmed);
|
return formatRawAssistantErrorForUi(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
return stripped;
|
return collapseConsecutiveDuplicateBlocks(stripped);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRateLimitAssistantError(msg: AssistantMessage | undefined): boolean {
|
export function isRateLimitAssistantError(msg: AssistantMessage | undefined): boolean {
|
||||||
|
|||||||
@ -599,7 +599,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
verboseLevel: params.verboseLevel,
|
verboseLevel: params.verboseLevel,
|
||||||
reasoningLevel: params.reasoningLevel,
|
reasoningLevel: params.reasoningLevel,
|
||||||
toolResultFormat: resolvedToolResultFormat,
|
toolResultFormat: resolvedToolResultFormat,
|
||||||
inlineToolResultsAllowed: !params.onPartialReply && !params.onToolResult,
|
inlineToolResultsAllowed: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
log.debug(
|
log.debug(
|
||||||
|
|||||||
@ -333,7 +333,13 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
name: "message",
|
name: "message",
|
||||||
description,
|
description,
|
||||||
parameters: schema,
|
parameters: schema,
|
||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args, signal) => {
|
||||||
|
// Check if already aborted before doing any work
|
||||||
|
if (signal?.aborted) {
|
||||||
|
const err = new Error("Message send aborted");
|
||||||
|
err.name = "AbortError";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const params = args as Record<string, unknown>;
|
const params = args as Record<string, unknown>;
|
||||||
const cfg = options?.config ?? loadConfig();
|
const cfg = options?.config ?? loadConfig();
|
||||||
const action = readStringParam(params, "action", {
|
const action = readStringParam(params, "action", {
|
||||||
@ -366,6 +372,9 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
currentThreadTs: options?.currentThreadTs,
|
currentThreadTs: options?.currentThreadTs,
|
||||||
replyToMode: options?.replyToMode,
|
replyToMode: options?.replyToMode,
|
||||||
hasRepliedRef: options?.hasRepliedRef,
|
hasRepliedRef: options?.hasRepliedRef,
|
||||||
|
// Direct tool invocations should not add cross-context decoration.
|
||||||
|
// The agent is composing a message, not forwarding from another chat.
|
||||||
|
skipCrossContextDecoration: true,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@ -379,6 +388,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
agentId: options?.agentSessionKey
|
agentId: options?.agentSessionKey
|
||||||
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
|
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
|
||||||
: undefined,
|
: undefined,
|
||||||
|
abortSignal: signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolResult = getToolResult(result);
|
const toolResult = getToolResult(result);
|
||||||
|
|||||||
@ -178,6 +178,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
textAlias: "/context",
|
textAlias: "/context",
|
||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
}),
|
}),
|
||||||
|
defineChatCommand({
|
||||||
|
key: "tts",
|
||||||
|
nativeName: "tts",
|
||||||
|
description: "Configure text-to-speech.",
|
||||||
|
textAlias: "/tts",
|
||||||
|
acceptsArgs: true,
|
||||||
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "whoami",
|
key: "whoami",
|
||||||
nativeName: "whoami",
|
nativeName: "whoami",
|
||||||
@ -279,27 +286,6 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
],
|
],
|
||||||
argsMenu: "auto",
|
argsMenu: "auto",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
|
||||||
key: "tts",
|
|
||||||
nativeName: "tts",
|
|
||||||
description: "Control text-to-speech (TTS).",
|
|
||||||
textAlias: "/tts",
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: "action",
|
|
||||||
description: "on | off | status | provider | limit | summary | audio | help",
|
|
||||||
type: "string",
|
|
||||||
choices: ["on", "off", "status", "provider", "limit", "summary", "audio", "help"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "value",
|
|
||||||
description: "Provider, limit, or text",
|
|
||||||
type: "string",
|
|
||||||
captureRemaining: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
argsMenu: "auto",
|
|
||||||
}),
|
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "stop",
|
key: "stop",
|
||||||
nativeName: "stop",
|
nativeName: "stop",
|
||||||
|
|||||||
@ -10,11 +10,17 @@ describe("extractModelDirective", () => {
|
|||||||
expect(result.cleaned).toBe("");
|
expect(result.cleaned).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts /models with argument", () => {
|
it("does not treat /models as a /model directive", () => {
|
||||||
const result = extractModelDirective("/models gpt-5");
|
const result = extractModelDirective("/models gpt-5");
|
||||||
expect(result.hasDirective).toBe(true);
|
expect(result.hasDirective).toBe(false);
|
||||||
expect(result.rawModel).toBe("gpt-5");
|
expect(result.rawModel).toBeUndefined();
|
||||||
expect(result.cleaned).toBe("");
|
expect(result.cleaned).toBe("/models gpt-5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not parse /models as a /model directive (no args)", () => {
|
||||||
|
const result = extractModelDirective("/models");
|
||||||
|
expect(result.hasDirective).toBe(false);
|
||||||
|
expect(result.cleaned).toBe("/models");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts /model with provider/model format", () => {
|
it("extracts /model with provider/model format", () => {
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export function extractModelDirective(
|
|||||||
if (!body) return { cleaned: "", hasDirective: false };
|
if (!body) return { cleaned: "", hasDirective: false };
|
||||||
|
|
||||||
const modelMatch = body.match(
|
const modelMatch = body.match(
|
||||||
/(?:^|\s)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
|
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
|
||||||
);
|
);
|
||||||
|
|
||||||
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
|
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
|
||||||
|
|||||||
@ -15,10 +15,12 @@ import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
|
|||||||
*/
|
*/
|
||||||
export const handlePluginCommand: CommandHandler = async (
|
export const handlePluginCommand: CommandHandler = async (
|
||||||
params,
|
params,
|
||||||
_allowTextCommands,
|
allowTextCommands,
|
||||||
): Promise<CommandHandlerResult | null> => {
|
): Promise<CommandHandlerResult | null> => {
|
||||||
const { command, cfg } = params;
|
const { command, cfg } = params;
|
||||||
|
|
||||||
|
if (!allowTextCommands) return null;
|
||||||
|
|
||||||
// Try to match a plugin command
|
// Try to match a plugin command
|
||||||
const match = matchPluginCommand(command.commandBodyNormalized);
|
const match = matchPluginCommand(command.commandBodyNormalized);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
@ -36,6 +38,6 @@ export const handlePluginCommand: CommandHandler = async (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
shouldContinue: false,
|
shouldContinue: false,
|
||||||
reply: { text: result.text },
|
reply: result,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,11 +10,26 @@ import {
|
|||||||
} from "../../agents/subagent-registry.js";
|
} from "../../agents/subagent-registry.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import * as internalHooks from "../../hooks/internal-hooks.js";
|
import * as internalHooks from "../../hooks/internal-hooks.js";
|
||||||
|
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
|
||||||
import type { MsgContext } from "../templating.js";
|
import type { MsgContext } from "../templating.js";
|
||||||
import { resetBashChatCommandForTests } from "./bash-command.js";
|
import { resetBashChatCommandForTests } from "./bash-command.js";
|
||||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||||
import { parseInlineDirectives } from "./directive-handling.js";
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
|
|
||||||
|
// Avoid expensive workspace scans during /context tests.
|
||||||
|
vi.mock("./commands-context-report.js", () => ({
|
||||||
|
buildContextReply: async (params: { command: { commandBodyNormalized: string } }) => {
|
||||||
|
const normalized = params.command.commandBodyNormalized;
|
||||||
|
if (normalized === "/context list") {
|
||||||
|
return { text: "Injected workspace files:\n- AGENTS.md" };
|
||||||
|
}
|
||||||
|
if (normalized === "/context detail") {
|
||||||
|
return { text: "Context breakdown (detailed)\nTop tools (schema size):" };
|
||||||
|
}
|
||||||
|
return { text: "/context\n- /context list\nInline shortcut" };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
let testWorkspaceDir = os.tmpdir();
|
let testWorkspaceDir = os.tmpdir();
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@ -143,6 +158,29 @@ describe("handleCommands bash alias", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("handleCommands plugin commands", () => {
|
||||||
|
it("dispatches registered plugin commands", async () => {
|
||||||
|
clearPluginCommands();
|
||||||
|
const result = registerPluginCommand("test-plugin", {
|
||||||
|
name: "card",
|
||||||
|
description: "Test card",
|
||||||
|
handler: async () => ({ text: "from plugin" }),
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const params = buildParams("/card", cfg);
|
||||||
|
const commandResult = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(commandResult.shouldContinue).toBe(false);
|
||||||
|
expect(commandResult.reply?.text).toBe("from plugin");
|
||||||
|
clearPluginCommands();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("handleCommands identity", () => {
|
describe("handleCommands identity", () => {
|
||||||
it("returns sender details for /whoami", async () => {
|
it("returns sender details for /whoami", async () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
|
|||||||
@ -138,6 +138,38 @@ describe("dispatchReplyFromConfig", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not provide onToolResult when routing cross-provider", async () => {
|
||||||
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||||
|
handled: false,
|
||||||
|
aborted: false,
|
||||||
|
});
|
||||||
|
mocks.routeReply.mockClear();
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const dispatcher = createDispatcher();
|
||||||
|
const ctx = buildTestCtx({
|
||||||
|
Provider: "slack",
|
||||||
|
OriginatingChannel: "telegram",
|
||||||
|
OriginatingTo: "telegram:999",
|
||||||
|
});
|
||||||
|
|
||||||
|
const replyResolver = async (
|
||||||
|
_ctx: MsgContext,
|
||||||
|
opts: GetReplyOptions | undefined,
|
||||||
|
_cfg: ClawdbotConfig,
|
||||||
|
) => {
|
||||||
|
expect(opts?.onToolResult).toBeUndefined();
|
||||||
|
return { text: "hi" } satisfies ReplyPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||||
|
|
||||||
|
expect(mocks.routeReply).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
payload: expect.objectContaining({ text: "hi" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("fast-aborts without calling the reply resolver", async () => {
|
it("fast-aborts without calling the reply resolver", async () => {
|
||||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||||
handled: true,
|
handled: true,
|
||||||
|
|||||||
@ -206,6 +206,7 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
const sendPayloadAsync = async (
|
const sendPayloadAsync = async (
|
||||||
payload: ReplyPayload,
|
payload: ReplyPayload,
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
|
mirror?: boolean,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
// TypeScript doesn't narrow these from the shouldRouteToOriginating check,
|
// TypeScript doesn't narrow these from the shouldRouteToOriginating check,
|
||||||
// but they're guaranteed non-null when this function is called.
|
// but they're guaranteed non-null when this function is called.
|
||||||
@ -220,6 +221,7 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
threadId: ctx.MessageThreadId,
|
threadId: ctx.MessageThreadId,
|
||||||
cfg,
|
cfg,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
mirror,
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
logVerbose(`dispatch-from-config: route-reply failed: ${result.error ?? "unknown error"}`);
|
logVerbose(`dispatch-from-config: route-reply failed: ${result.error ?? "unknown error"}`);
|
||||||
@ -268,24 +270,6 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
ctx,
|
ctx,
|
||||||
{
|
{
|
||||||
...params.replyOptions,
|
...params.replyOptions,
|
||||||
onToolResult: (payload: ReplyPayload) => {
|
|
||||||
const run = async () => {
|
|
||||||
const ttsPayload = await maybeApplyTtsToPayload({
|
|
||||||
payload,
|
|
||||||
cfg,
|
|
||||||
channel: ttsChannel,
|
|
||||||
kind: "tool",
|
|
||||||
inboundAudio,
|
|
||||||
ttsAuto: sessionTtsAuto,
|
|
||||||
});
|
|
||||||
if (shouldRouteToOriginating) {
|
|
||||||
await sendPayloadAsync(ttsPayload);
|
|
||||||
} else {
|
|
||||||
dispatcher.sendToolResult(ttsPayload);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return run();
|
|
||||||
},
|
|
||||||
onBlockReply: (payload: ReplyPayload, context) => {
|
onBlockReply: (payload: ReplyPayload, context) => {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const ttsPayload = await maybeApplyTtsToPayload({
|
const ttsPayload = await maybeApplyTtsToPayload({
|
||||||
@ -297,7 +281,7 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
ttsAuto: sessionTtsAuto,
|
ttsAuto: sessionTtsAuto,
|
||||||
});
|
});
|
||||||
if (shouldRouteToOriginating) {
|
if (shouldRouteToOriginating) {
|
||||||
await sendPayloadAsync(ttsPayload, context?.abortSignal);
|
await sendPayloadAsync(ttsPayload, context?.abortSignal, false);
|
||||||
} else {
|
} else {
|
||||||
dispatcher.sendBlockReply(ttsPayload);
|
dispatcher.sendBlockReply(ttsPayload);
|
||||||
}
|
}
|
||||||
|
|||||||
377
src/auto-reply/reply/line-directives.test.ts
Normal file
377
src/auto-reply/reply/line-directives.test.ts
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseLineDirectives, hasLineDirectives } from "./line-directives.js";
|
||||||
|
|
||||||
|
const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
|
||||||
|
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
|
||||||
|
|
||||||
|
describe("hasLineDirectives", () => {
|
||||||
|
it("detects quick_replies directive", () => {
|
||||||
|
expect(hasLineDirectives("Here are options [[quick_replies: A, B, C]]")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects location directive", () => {
|
||||||
|
expect(hasLineDirectives("[[location: Place | Address | 35.6 | 139.7]]")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects confirm directive", () => {
|
||||||
|
expect(hasLineDirectives("[[confirm: Continue? | Yes | No]]")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects buttons directive", () => {
|
||||||
|
expect(hasLineDirectives("[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for regular text", () => {
|
||||||
|
expect(hasLineDirectives("Just regular text")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for similar but invalid patterns", () => {
|
||||||
|
expect(hasLineDirectives("[[not_a_directive: something]]")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects media_player directive", () => {
|
||||||
|
expect(hasLineDirectives("[[media_player: Song | Artist | Speaker]]")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects event directive", () => {
|
||||||
|
expect(hasLineDirectives("[[event: Meeting | Jan 24 | 2pm]]")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects agenda directive", () => {
|
||||||
|
expect(hasLineDirectives("[[agenda: Today | Meeting:9am, Lunch:12pm]]")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects device directive", () => {
|
||||||
|
expect(hasLineDirectives("[[device: TV | Room]]")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects appletv_remote directive", () => {
|
||||||
|
expect(hasLineDirectives("[[appletv_remote: Apple TV | Playing]]")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseLineDirectives", () => {
|
||||||
|
describe("quick_replies", () => {
|
||||||
|
it("parses quick_replies and removes from text", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLineData(result).quickReplies).toEqual(["Option A", "Option B", "Option C"]);
|
||||||
|
expect(result.text).toBe("Choose one:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles quick_replies in middle of text", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "Before [[quick_replies: A, B]] After",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLineData(result).quickReplies).toEqual(["A", "B"]);
|
||||||
|
expect(result.text).toBe("Before After");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges with existing quickReplies", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "Text [[quick_replies: C, D]]",
|
||||||
|
channelData: { line: { quickReplies: ["A", "B"] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLineData(result).quickReplies).toEqual(["A", "B", "C", "D"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("location", () => {
|
||||||
|
it("parses location with all fields", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLineData(result).location).toEqual({
|
||||||
|
title: "Tokyo Station",
|
||||||
|
address: "Tokyo, Japan",
|
||||||
|
latitude: 35.6812,
|
||||||
|
longitude: 139.7671,
|
||||||
|
});
|
||||||
|
expect(result.text).toBe("Here's the location:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores invalid coordinates", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[location: Place | Address | invalid | 139.7]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLineData(result).location).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not override existing location", () => {
|
||||||
|
const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 };
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[location: New | New Addr | 35.6 | 139.7]]",
|
||||||
|
channelData: { line: { location: existing } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLineData(result).location).toEqual(existing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("confirm", () => {
|
||||||
|
it("parses simple confirm", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[confirm: Delete this item? | Yes | No]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLineData(result).templateMessage).toEqual({
|
||||||
|
type: "confirm",
|
||||||
|
text: "Delete this item?",
|
||||||
|
confirmLabel: "Yes",
|
||||||
|
confirmData: "yes",
|
||||||
|
cancelLabel: "No",
|
||||||
|
cancelData: "no",
|
||||||
|
altText: "Delete this item?",
|
||||||
|
});
|
||||||
|
// Text is undefined when directive consumes entire text
|
||||||
|
expect(result.text).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses confirm with custom data", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLineData(result).templateMessage).toEqual({
|
||||||
|
type: "confirm",
|
||||||
|
text: "Proceed?",
|
||||||
|
confirmLabel: "OK",
|
||||||
|
confirmData: "action=confirm",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
cancelData: "action=cancel",
|
||||||
|
altText: "Proceed?",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buttons", () => {
|
||||||
|
it("parses buttons with message actions", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLineData(result).templateMessage).toEqual({
|
||||||
|
type: "buttons",
|
||||||
|
title: "Menu",
|
||||||
|
text: "Select an option",
|
||||||
|
actions: [
|
||||||
|
{ type: "message", label: "Help", data: "/help" },
|
||||||
|
{ type: "message", label: "Status", data: "/status" },
|
||||||
|
],
|
||||||
|
altText: "Menu: Select an option",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses buttons with uri actions", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[buttons: Links | Visit us | Site:https://example.com]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateMessage = getLineData(result).templateMessage as {
|
||||||
|
type?: string;
|
||||||
|
actions?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
expect(templateMessage?.type).toBe("buttons");
|
||||||
|
if (templateMessage?.type === "buttons") {
|
||||||
|
expect(templateMessage.actions?.[0]).toEqual({
|
||||||
|
type: "uri",
|
||||||
|
label: "Site",
|
||||||
|
uri: "https://example.com",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses buttons with postback actions", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[buttons: Actions | Choose | Select:action=select&id=1]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateMessage = getLineData(result).templateMessage as {
|
||||||
|
type?: string;
|
||||||
|
actions?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
expect(templateMessage?.type).toBe("buttons");
|
||||||
|
if (templateMessage?.type === "buttons") {
|
||||||
|
expect(templateMessage.actions?.[0]).toEqual({
|
||||||
|
type: "postback",
|
||||||
|
label: "Select",
|
||||||
|
data: "action=select&id=1",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits to 4 actions", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateMessage = getLineData(result).templateMessage as {
|
||||||
|
type?: string;
|
||||||
|
actions?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
expect(templateMessage?.type).toBe("buttons");
|
||||||
|
if (templateMessage?.type === "buttons") {
|
||||||
|
expect(templateMessage.actions?.length).toBe(4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("media_player", () => {
|
||||||
|
it("parses media_player with all fields", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
const flexMessage = getLineData(result).flexMessage as {
|
||||||
|
altText?: string;
|
||||||
|
contents?: { footer?: { contents?: unknown[] } };
|
||||||
|
};
|
||||||
|
expect(flexMessage).toBeDefined();
|
||||||
|
expect(flexMessage?.altText).toBe("🎵 Bohemian Rhapsody - Queen");
|
||||||
|
const contents = flexMessage?.contents as { footer?: { contents?: unknown[] } };
|
||||||
|
expect(contents.footer?.contents?.length).toBeGreaterThan(0);
|
||||||
|
expect(result.text).toBe("Now playing:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses media_player with minimal fields", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[media_player: Unknown Track]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||||
|
expect(flexMessage).toBeDefined();
|
||||||
|
expect(flexMessage?.altText).toBe("🎵 Unknown Track");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles paused status", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[media_player: Song | Artist | Player | | paused]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
const flexMessage = getLineData(result).flexMessage as {
|
||||||
|
contents?: { body: { contents: unknown[] } };
|
||||||
|
};
|
||||||
|
expect(flexMessage).toBeDefined();
|
||||||
|
const contents = flexMessage?.contents as { body: { contents: unknown[] } };
|
||||||
|
expect(contents).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("event", () => {
|
||||||
|
it("parses event with all fields", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||||
|
expect(flexMessage).toBeDefined();
|
||||||
|
expect(flexMessage?.altText).toBe("📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses event with minimal fields", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[event: Birthday Party | March 15]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||||
|
expect(flexMessage).toBeDefined();
|
||||||
|
expect(flexMessage?.altText).toBe("📅 Birthday Party - March 15");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("agenda", () => {
|
||||||
|
it("parses agenda with multiple events", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||||
|
expect(flexMessage).toBeDefined();
|
||||||
|
expect(flexMessage?.altText).toBe("📋 Today's Schedule (3 events)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses agenda with events without times", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||||
|
expect(flexMessage).toBeDefined();
|
||||||
|
expect(flexMessage?.altText).toBe("📋 Tasks (3 events)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("device", () => {
|
||||||
|
it("parses device with controls", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||||
|
expect(flexMessage).toBeDefined();
|
||||||
|
expect(flexMessage?.altText).toBe("📱 TV: Playing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses device with minimal fields", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[device: Speaker]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||||
|
expect(flexMessage).toBeDefined();
|
||||||
|
expect(flexMessage?.altText).toBe("📱 Speaker");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("appletv_remote", () => {
|
||||||
|
it("parses appletv_remote with status", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[appletv_remote: Apple TV | Playing]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||||
|
expect(flexMessage).toBeDefined();
|
||||||
|
expect(flexMessage?.altText).toContain("Apple TV");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses appletv_remote with minimal fields", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "[[appletv_remote: Apple TV]]",
|
||||||
|
});
|
||||||
|
|
||||||
|
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||||
|
expect(flexMessage).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("combined directives", () => {
|
||||||
|
it("handles text with no directives", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "Just plain text here",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.text).toBe("Just plain text here");
|
||||||
|
expect(getLineData(result).quickReplies).toBeUndefined();
|
||||||
|
expect(getLineData(result).location).toBeUndefined();
|
||||||
|
expect(getLineData(result).templateMessage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves other payload fields", () => {
|
||||||
|
const result = parseLineDirectives({
|
||||||
|
text: "Hello [[quick_replies: A, B]]",
|
||||||
|
mediaUrl: "https://example.com/image.jpg",
|
||||||
|
replyToId: "msg123",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.mediaUrl).toBe("https://example.com/image.jpg");
|
||||||
|
expect(result.replyToId).toBe("msg123");
|
||||||
|
expect(getLineData(result).quickReplies).toEqual(["A", "B"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
336
src/auto-reply/reply/line-directives.ts
Normal file
336
src/auto-reply/reply/line-directives.ts
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
import type { ReplyPayload } from "../types.js";
|
||||||
|
import type { LineChannelData } from "../../line/types.js";
|
||||||
|
import {
|
||||||
|
createMediaPlayerCard,
|
||||||
|
createEventCard,
|
||||||
|
createAgendaCard,
|
||||||
|
createDeviceControlCard,
|
||||||
|
createAppleTvRemoteCard,
|
||||||
|
} from "../../line/flex-templates.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse LINE-specific directives from text and extract them into ReplyPayload fields.
|
||||||
|
*
|
||||||
|
* Supported directives:
|
||||||
|
* - [[quick_replies: option1, option2, option3]]
|
||||||
|
* - [[location: title | address | latitude | longitude]]
|
||||||
|
* - [[confirm: question | yes_label | no_label]]
|
||||||
|
* - [[buttons: title | text | btn1:data1, btn2:data2]]
|
||||||
|
* - [[media_player: title | artist | source | imageUrl | playing/paused]]
|
||||||
|
* - [[event: title | date | time | location | description]]
|
||||||
|
* - [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
|
||||||
|
* - [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
|
||||||
|
* - [[appletv_remote: name | status]]
|
||||||
|
*
|
||||||
|
* Returns the modified payload with directives removed from text and fields populated.
|
||||||
|
*/
|
||||||
|
export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||||
|
let text = payload.text;
|
||||||
|
if (!text) return payload;
|
||||||
|
|
||||||
|
const result: ReplyPayload = { ...payload };
|
||||||
|
const lineData: LineChannelData = {
|
||||||
|
...(result.channelData?.line as LineChannelData | undefined),
|
||||||
|
};
|
||||||
|
const toSlug = (value: string): string =>
|
||||||
|
value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "") || "device";
|
||||||
|
const lineActionData = (action: string, extras?: Record<string, string>): string => {
|
||||||
|
const base = [`line.action=${encodeURIComponent(action)}`];
|
||||||
|
if (extras) {
|
||||||
|
for (const [key, value] of Object.entries(extras)) {
|
||||||
|
base.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base.join("&");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse [[quick_replies: option1, option2, option3]]
|
||||||
|
const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i);
|
||||||
|
if (quickRepliesMatch) {
|
||||||
|
const options = quickRepliesMatch[1]
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (options.length > 0) {
|
||||||
|
lineData.quickReplies = [...(lineData.quickReplies || []), ...options];
|
||||||
|
}
|
||||||
|
text = text.replace(quickRepliesMatch[0], "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse [[location: title | address | latitude | longitude]]
|
||||||
|
const locationMatch = text.match(/\[\[location:\s*([^\]]+)\]\]/i);
|
||||||
|
if (locationMatch && !lineData.location) {
|
||||||
|
const parts = locationMatch[1].split("|").map((s) => s.trim());
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
const [title, address, latStr, lonStr] = parts;
|
||||||
|
const latitude = parseFloat(latStr);
|
||||||
|
const longitude = parseFloat(lonStr);
|
||||||
|
if (!isNaN(latitude) && !isNaN(longitude)) {
|
||||||
|
lineData.location = {
|
||||||
|
title: title || "Location",
|
||||||
|
address: address || "",
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text = text.replace(locationMatch[0], "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse [[confirm: question | yes_label | no_label]] or [[confirm: question | yes_label:yes_data | no_label:no_data]]
|
||||||
|
const confirmMatch = text.match(/\[\[confirm:\s*([^\]]+)\]\]/i);
|
||||||
|
if (confirmMatch && !lineData.templateMessage) {
|
||||||
|
const parts = confirmMatch[1].split("|").map((s) => s.trim());
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const [question, yesPart, noPart] = parts;
|
||||||
|
|
||||||
|
// Parse yes_label:yes_data format
|
||||||
|
const [yesLabel, yesData] = yesPart.includes(":")
|
||||||
|
? yesPart.split(":").map((s) => s.trim())
|
||||||
|
: [yesPart, yesPart.toLowerCase()];
|
||||||
|
|
||||||
|
const [noLabel, noData] = noPart.includes(":")
|
||||||
|
? noPart.split(":").map((s) => s.trim())
|
||||||
|
: [noPart, noPart.toLowerCase()];
|
||||||
|
|
||||||
|
lineData.templateMessage = {
|
||||||
|
type: "confirm",
|
||||||
|
text: question,
|
||||||
|
confirmLabel: yesLabel,
|
||||||
|
confirmData: yesData,
|
||||||
|
cancelLabel: noLabel,
|
||||||
|
cancelData: noData,
|
||||||
|
altText: question,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
text = text.replace(confirmMatch[0], "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse [[buttons: title | text | btn1:data1, btn2:data2]]
|
||||||
|
const buttonsMatch = text.match(/\[\[buttons:\s*([^\]]+)\]\]/i);
|
||||||
|
if (buttonsMatch && !lineData.templateMessage) {
|
||||||
|
const parts = buttonsMatch[1].split("|").map((s) => s.trim());
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const [title, bodyText, actionsStr] = parts;
|
||||||
|
|
||||||
|
const actions = actionsStr.split(",").map((actionStr) => {
|
||||||
|
const trimmed = actionStr.trim();
|
||||||
|
// Find first colon delimiter, ignoring URLs without a label.
|
||||||
|
const colonIndex = (() => {
|
||||||
|
const index = trimmed.indexOf(":");
|
||||||
|
if (index === -1) return -1;
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
if (lower.startsWith("http://") || lower.startsWith("https://")) return -1;
|
||||||
|
return index;
|
||||||
|
})();
|
||||||
|
|
||||||
|
let label: string;
|
||||||
|
let data: string;
|
||||||
|
|
||||||
|
if (colonIndex === -1) {
|
||||||
|
label = trimmed;
|
||||||
|
data = trimmed;
|
||||||
|
} else {
|
||||||
|
label = trimmed.slice(0, colonIndex).trim();
|
||||||
|
data = trimmed.slice(colonIndex + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect action type
|
||||||
|
if (data.startsWith("http://") || data.startsWith("https://")) {
|
||||||
|
return { type: "uri" as const, label, uri: data };
|
||||||
|
}
|
||||||
|
if (data.includes("=")) {
|
||||||
|
return { type: "postback" as const, label, data };
|
||||||
|
}
|
||||||
|
return { type: "message" as const, label, data: data || label };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (actions.length > 0) {
|
||||||
|
lineData.templateMessage = {
|
||||||
|
type: "buttons",
|
||||||
|
title,
|
||||||
|
text: bodyText,
|
||||||
|
actions: actions.slice(0, 4), // LINE limit
|
||||||
|
altText: `${title}: ${bodyText}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text = text.replace(buttonsMatch[0], "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse [[media_player: title | artist | source | imageUrl | playing/paused]]
|
||||||
|
const mediaPlayerMatch = text.match(/\[\[media_player:\s*([^\]]+)\]\]/i);
|
||||||
|
if (mediaPlayerMatch && !lineData.flexMessage) {
|
||||||
|
const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim());
|
||||||
|
if (parts.length >= 1) {
|
||||||
|
const [title, artist, source, imageUrl, statusStr] = parts;
|
||||||
|
const isPlaying = statusStr?.toLowerCase() === "playing";
|
||||||
|
|
||||||
|
// LINE requires HTTPS URLs for images - skip local/HTTP URLs
|
||||||
|
const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined;
|
||||||
|
|
||||||
|
const deviceKey = toSlug(source || title || "media");
|
||||||
|
const card = createMediaPlayerCard({
|
||||||
|
title: title || "Unknown Track",
|
||||||
|
subtitle: artist || undefined,
|
||||||
|
source: source || undefined,
|
||||||
|
imageUrl: validImageUrl,
|
||||||
|
isPlaying: statusStr ? isPlaying : undefined,
|
||||||
|
controls: {
|
||||||
|
previous: { data: lineActionData("previous", { "line.device": deviceKey }) },
|
||||||
|
play: { data: lineActionData("play", { "line.device": deviceKey }) },
|
||||||
|
pause: { data: lineActionData("pause", { "line.device": deviceKey }) },
|
||||||
|
next: { data: lineActionData("next", { "line.device": deviceKey }) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
lineData.flexMessage = {
|
||||||
|
altText: `🎵 ${title}${artist ? ` - ${artist}` : ""}`,
|
||||||
|
contents: card,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
text = text.replace(mediaPlayerMatch[0], "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse [[event: title | date | time | location | description]]
|
||||||
|
const eventMatch = text.match(/\[\[event:\s*([^\]]+)\]\]/i);
|
||||||
|
if (eventMatch && !lineData.flexMessage) {
|
||||||
|
const parts = eventMatch[1].split("|").map((s) => s.trim());
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const [title, date, time, location, description] = parts;
|
||||||
|
|
||||||
|
const card = createEventCard({
|
||||||
|
title: title || "Event",
|
||||||
|
date: date || "TBD",
|
||||||
|
time: time || undefined,
|
||||||
|
location: location || undefined,
|
||||||
|
description: description || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
lineData.flexMessage = {
|
||||||
|
altText: `📅 ${title} - ${date}${time ? ` ${time}` : ""}`,
|
||||||
|
contents: card,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
text = text.replace(eventMatch[0], "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse [[appletv_remote: name | status]]
|
||||||
|
const appleTvMatch = text.match(/\[\[appletv_remote:\s*([^\]]+)\]\]/i);
|
||||||
|
if (appleTvMatch && !lineData.flexMessage) {
|
||||||
|
const parts = appleTvMatch[1].split("|").map((s) => s.trim());
|
||||||
|
if (parts.length >= 1) {
|
||||||
|
const [deviceName, status] = parts;
|
||||||
|
const deviceKey = toSlug(deviceName || "apple_tv");
|
||||||
|
|
||||||
|
const card = createAppleTvRemoteCard({
|
||||||
|
deviceName: deviceName || "Apple TV",
|
||||||
|
status: status || undefined,
|
||||||
|
actionData: {
|
||||||
|
up: lineActionData("up", { "line.device": deviceKey }),
|
||||||
|
down: lineActionData("down", { "line.device": deviceKey }),
|
||||||
|
left: lineActionData("left", { "line.device": deviceKey }),
|
||||||
|
right: lineActionData("right", { "line.device": deviceKey }),
|
||||||
|
select: lineActionData("select", { "line.device": deviceKey }),
|
||||||
|
menu: lineActionData("menu", { "line.device": deviceKey }),
|
||||||
|
home: lineActionData("home", { "line.device": deviceKey }),
|
||||||
|
play: lineActionData("play", { "line.device": deviceKey }),
|
||||||
|
pause: lineActionData("pause", { "line.device": deviceKey }),
|
||||||
|
volumeUp: lineActionData("volume_up", { "line.device": deviceKey }),
|
||||||
|
volumeDown: lineActionData("volume_down", { "line.device": deviceKey }),
|
||||||
|
mute: lineActionData("mute", { "line.device": deviceKey }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
lineData.flexMessage = {
|
||||||
|
altText: `📺 ${deviceName || "Apple TV"} Remote`,
|
||||||
|
contents: card,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
text = text.replace(appleTvMatch[0], "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
|
||||||
|
const agendaMatch = text.match(/\[\[agenda:\s*([^\]]+)\]\]/i);
|
||||||
|
if (agendaMatch && !lineData.flexMessage) {
|
||||||
|
const parts = agendaMatch[1].split("|").map((s) => s.trim());
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const [title, eventsStr] = parts;
|
||||||
|
|
||||||
|
const events = eventsStr.split(",").map((eventStr) => {
|
||||||
|
const trimmed = eventStr.trim();
|
||||||
|
const colonIdx = trimmed.lastIndexOf(":");
|
||||||
|
if (colonIdx > 0) {
|
||||||
|
return {
|
||||||
|
title: trimmed.slice(0, colonIdx).trim(),
|
||||||
|
time: trimmed.slice(colonIdx + 1).trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { title: trimmed };
|
||||||
|
});
|
||||||
|
|
||||||
|
const card = createAgendaCard({
|
||||||
|
title: title || "Agenda",
|
||||||
|
events,
|
||||||
|
});
|
||||||
|
|
||||||
|
lineData.flexMessage = {
|
||||||
|
altText: `📋 ${title} (${events.length} events)`,
|
||||||
|
contents: card,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
text = text.replace(agendaMatch[0], "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
|
||||||
|
const deviceMatch = text.match(/\[\[device:\s*([^\]]+)\]\]/i);
|
||||||
|
if (deviceMatch && !lineData.flexMessage) {
|
||||||
|
const parts = deviceMatch[1].split("|").map((s) => s.trim());
|
||||||
|
if (parts.length >= 1) {
|
||||||
|
const [deviceName, deviceType, status, controlsStr] = parts;
|
||||||
|
|
||||||
|
const deviceKey = toSlug(deviceName || "device");
|
||||||
|
const controls = controlsStr
|
||||||
|
? controlsStr.split(",").map((ctrlStr) => {
|
||||||
|
const [label, data] = ctrlStr.split(":").map((s) => s.trim());
|
||||||
|
const action = data || label.toLowerCase().replace(/\s+/g, "_");
|
||||||
|
return { label, data: lineActionData(action, { "line.device": deviceKey }) };
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const card = createDeviceControlCard({
|
||||||
|
deviceName: deviceName || "Device",
|
||||||
|
deviceType: deviceType || undefined,
|
||||||
|
status: status || undefined,
|
||||||
|
controls,
|
||||||
|
});
|
||||||
|
|
||||||
|
lineData.flexMessage = {
|
||||||
|
altText: `📱 ${deviceName}${status ? `: ${status}` : ""}`,
|
||||||
|
contents: card,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
text = text.replace(deviceMatch[0], "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up multiple whitespace/newlines
|
||||||
|
text = text.replace(/\n{3,}/g, "\n\n").trim();
|
||||||
|
|
||||||
|
result.text = text || undefined;
|
||||||
|
if (Object.keys(lineData).length > 0) {
|
||||||
|
result.channelData = { ...result.channelData, line: lineData };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if text contains any LINE directives
|
||||||
|
*/
|
||||||
|
export function hasLineDirectives(text: string): boolean {
|
||||||
|
return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test(
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/auto-reply/reply/normalize-reply.test.ts
Normal file
22
src/auto-reply/reply/normalize-reply.test.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||||
|
|
||||||
|
// Keep channelData-only payloads so channel-specific replies survive normalization.
|
||||||
|
describe("normalizeReplyPayload", () => {
|
||||||
|
it("keeps channelData-only replies", () => {
|
||||||
|
const payload = {
|
||||||
|
channelData: {
|
||||||
|
line: {
|
||||||
|
flexMessage: { type: "bubble" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = normalizeReplyPayload(payload);
|
||||||
|
|
||||||
|
expect(normalized).not.toBeNull();
|
||||||
|
expect(normalized?.text).toBeUndefined();
|
||||||
|
expect(normalized?.channelData).toEqual(payload.channelData);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -6,6 +6,7 @@ import {
|
|||||||
resolveResponsePrefixTemplate,
|
resolveResponsePrefixTemplate,
|
||||||
type ResponsePrefixContext,
|
type ResponsePrefixContext,
|
||||||
} from "./response-prefix-template.js";
|
} from "./response-prefix-template.js";
|
||||||
|
import { hasLineDirectives, parseLineDirectives } from "./line-directives.js";
|
||||||
|
|
||||||
export type NormalizeReplyOptions = {
|
export type NormalizeReplyOptions = {
|
||||||
responsePrefix?: string;
|
responsePrefix?: string;
|
||||||
@ -21,13 +22,16 @@ export function normalizeReplyPayload(
|
|||||||
opts: NormalizeReplyOptions = {},
|
opts: NormalizeReplyOptions = {},
|
||||||
): ReplyPayload | null {
|
): ReplyPayload | null {
|
||||||
const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0);
|
const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0);
|
||||||
|
const hasChannelData = Boolean(
|
||||||
|
payload.channelData && Object.keys(payload.channelData).length > 0,
|
||||||
|
);
|
||||||
const trimmed = payload.text?.trim() ?? "";
|
const trimmed = payload.text?.trim() ?? "";
|
||||||
if (!trimmed && !hasMedia) return null;
|
if (!trimmed && !hasMedia && !hasChannelData) return null;
|
||||||
|
|
||||||
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
||||||
let text = payload.text ?? undefined;
|
let text = payload.text ?? undefined;
|
||||||
if (text && isSilentReplyText(text, silentToken)) {
|
if (text && isSilentReplyText(text, silentToken)) {
|
||||||
if (!hasMedia) return null;
|
if (!hasMedia && !hasChannelData) return null;
|
||||||
text = "";
|
text = "";
|
||||||
}
|
}
|
||||||
if (text && !trimmed) {
|
if (text && !trimmed) {
|
||||||
@ -39,14 +43,21 @@ export function normalizeReplyPayload(
|
|||||||
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
|
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
|
||||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||||
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
||||||
if (stripped.shouldSkip && !hasMedia) return null;
|
if (stripped.shouldSkip && !hasMedia && !hasChannelData) return null;
|
||||||
text = stripped.text;
|
text = stripped.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text) {
|
if (text) {
|
||||||
text = sanitizeUserFacingText(text);
|
text = sanitizeUserFacingText(text);
|
||||||
}
|
}
|
||||||
if (!text?.trim() && !hasMedia) return null;
|
if (!text?.trim() && !hasMedia && !hasChannelData) return null;
|
||||||
|
|
||||||
|
// Parse LINE-specific directives from text (quick_replies, location, confirm, buttons)
|
||||||
|
let enrichedPayload: ReplyPayload = { ...payload, text };
|
||||||
|
if (text && hasLineDirectives(text)) {
|
||||||
|
enrichedPayload = parseLineDirectives(enrichedPayload);
|
||||||
|
text = enrichedPayload.text;
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve template variables in responsePrefix if context is provided
|
// Resolve template variables in responsePrefix if context is provided
|
||||||
const effectivePrefix = opts.responsePrefixContext
|
const effectivePrefix = opts.responsePrefixContext
|
||||||
@ -62,5 +73,5 @@ export function normalizeReplyPayload(
|
|||||||
text = `${effectivePrefix} ${text}`;
|
text = `${effectivePrefix} ${text}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...payload, text };
|
return { ...enrichedPayload, text };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,7 +45,8 @@ export function isRenderablePayload(payload: ReplyPayload): boolean {
|
|||||||
payload.text ||
|
payload.text ||
|
||||||
payload.mediaUrl ||
|
payload.mediaUrl ||
|
||||||
(payload.mediaUrls && payload.mediaUrls.length > 0) ||
|
(payload.mediaUrls && payload.mediaUrls.length > 0) ||
|
||||||
payload.audioAsVoice,
|
payload.audioAsVoice ||
|
||||||
|
payload.channelData,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -72,6 +72,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
|||||||
providers: [],
|
providers: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpHandlers: [],
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
@ -379,6 +380,23 @@ describe("routeReply", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips mirror data when mirror is false", async () => {
|
||||||
|
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||||
|
await routeReply({
|
||||||
|
payload: { text: "hi" },
|
||||||
|
channel: "slack",
|
||||||
|
to: "channel:C123",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
mirror: false,
|
||||||
|
cfg: {} as never,
|
||||||
|
});
|
||||||
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
mirror: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const emptyRegistry = createRegistry([]);
|
const emptyRegistry = createRegistry([]);
|
||||||
|
|||||||
@ -33,6 +33,8 @@ export type RouteReplyParams = {
|
|||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
/** Optional abort signal for cooperative cancellation. */
|
/** Optional abort signal for cooperative cancellation. */
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
|
/** Mirror reply into session transcript (default: true when sessionKey is set). */
|
||||||
|
mirror?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RouteReplyResult = {
|
export type RouteReplyResult = {
|
||||||
@ -118,7 +120,8 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
|||||||
replyToId: resolvedReplyToId ?? null,
|
replyToId: resolvedReplyToId ?? null,
|
||||||
threadId: resolvedThreadId,
|
threadId: resolvedThreadId,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
mirror: params.sessionKey
|
mirror:
|
||||||
|
params.mirror !== false && params.sessionKey
|
||||||
? {
|
? {
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
|
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import {
|
|||||||
} from "../utils/usage-format.js";
|
} from "../utils/usage-format.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
|
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
|
||||||
|
import { listPluginCommands } from "../plugins/commands.js";
|
||||||
import type { SkillCommandSpec } from "../agents/skills.js";
|
import type { SkillCommandSpec } from "../agents/skills.js";
|
||||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
|
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
|
||||||
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
|
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
|
||||||
@ -473,5 +474,14 @@ export function buildCommandsMessage(
|
|||||||
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
|
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
|
||||||
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
|
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
|
||||||
}
|
}
|
||||||
|
const pluginCommands = listPluginCommands();
|
||||||
|
if (pluginCommands.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Plugin commands:");
|
||||||
|
for (const command of pluginCommands) {
|
||||||
|
const pluginLabel = command.pluginId ? ` (plugin: ${command.pluginId})` : "";
|
||||||
|
lines.push(`/${command.name}${pluginLabel} - ${command.description}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,4 +52,6 @@ export type ReplyPayload = {
|
|||||||
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
|
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
|
||||||
audioAsVoice?: boolean;
|
audioAsVoice?: boolean;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
/** Channel-specific payload data (per-channel envelope). */
|
||||||
|
channelData?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,6 +13,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
|||||||
providers: [],
|
providers: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpHandlers: [],
|
httpHandlers: [],
|
||||||
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
|
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
|
||||||
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
|
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
@ -81,6 +82,10 @@ export type ChannelOutboundContext = {
|
|||||||
deps?: OutboundSendDeps;
|
deps?: OutboundSendDeps;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChannelOutboundPayloadContext = ChannelOutboundContext & {
|
||||||
|
payload: ReplyPayload;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChannelOutboundAdapter = {
|
export type ChannelOutboundAdapter = {
|
||||||
deliveryMode: "direct" | "gateway" | "hybrid";
|
deliveryMode: "direct" | "gateway" | "hybrid";
|
||||||
chunker?: ((text: string, limit: number) => string[]) | null;
|
chunker?: ((text: string, limit: number) => string[]) | null;
|
||||||
@ -94,6 +99,7 @@ export type ChannelOutboundAdapter = {
|
|||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
mode?: ChannelOutboundTargetMode;
|
mode?: ChannelOutboundTargetMode;
|
||||||
}) => { ok: true; to: string } | { ok: false; error: Error };
|
}) => { ok: true; to: string } | { ok: false; error: Error };
|
||||||
|
sendPayload?: (ctx: ChannelOutboundPayloadContext) => Promise<OutboundDeliveryResult>;
|
||||||
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
||||||
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
||||||
sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;
|
sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user