mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-10 16:03:47 +08:00
Compare commits
36 Commits
fix-models
...
fix/cron-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c93143c4a | ||
|
|
11a6f6db2e | ||
|
|
19e0b701cf | ||
|
|
015c256984 | ||
|
|
5a21722f32 | ||
|
|
6110514606 | ||
|
|
7a5e103a6a | ||
|
|
4e23b7f654 | ||
|
|
7253bf398d | ||
|
|
026def686e | ||
|
|
003fff067a | ||
|
|
8f3da653b0 | ||
|
|
0f5f7ec22a | ||
|
|
2fcbed2111 | ||
|
|
c96ffa7186 | ||
|
|
101d0f451f | ||
|
|
875b018ea1 | ||
|
|
b6581e77f6 | ||
|
|
81e915110e | ||
|
|
7e9aa3c275 | ||
|
|
65e2d939e1 | ||
|
|
67db63ba05 | ||
|
|
bbefb2e5a5 | ||
|
|
50f233d16d | ||
|
|
48aea87028 | ||
|
|
612a27f3dd | ||
|
|
737037129e | ||
|
|
f29f51569a | ||
|
|
bfa57aae44 | ||
|
|
98cecc9c56 | ||
|
|
6cc1f5abb8 | ||
|
|
9fbee08590 | ||
|
|
0f662c2935 | ||
|
|
32bcd291d5 | ||
|
|
5f9863098b | ||
|
|
fdecf5c59a |
@@ -7,6 +7,10 @@
|
||||
[exclude-files]
|
||||
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
||||
pattern = (^|/)pnpm-lock\.yaml$
|
||||
# Generated output and vendored assets.
|
||||
pattern = (^|/)(dist|vendor)/
|
||||
# Local config file with allowlist patterns.
|
||||
pattern = (^|/)\.detect-secrets\.cfg$
|
||||
|
||||
[exclude-lines]
|
||||
# Fastlane checks for private key marker; not a real key.
|
||||
|
||||
17
.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
|
||||
52
.github/workflows/ci.yml
vendored
52
.github/workflows/ci.yml
vendored
@@ -32,20 +32,21 @@ jobs:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
run_install: false
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies (frozen)
|
||||
env:
|
||||
CI: true
|
||||
@@ -108,6 +109,12 @@ jobs:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
@@ -118,16 +125,11 @@ jobs:
|
||||
node -v
|
||||
npm -v
|
||||
bun -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
@@ -212,6 +214,12 @@ jobs:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
@@ -222,16 +230,11 @@ jobs:
|
||||
node -v
|
||||
npm -v
|
||||
bun -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
@@ -279,20 +282,21 @@ jobs:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
run_install: false
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
|
||||
7
.github/workflows/install-smoke.yml
vendored
7
.github/workflows/install-smoke.yml
vendored
@@ -14,11 +14,10 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
version: 10.23.0
|
||||
run_install: false
|
||||
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
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
|
||||
@@ -23,7 +23,7 @@
|
||||
# Whitespace
|
||||
--trimwhitespace always
|
||||
--emptybraces no-space
|
||||
--nospaceoperators ...,..<
|
||||
--nospaceoperators ...,..<
|
||||
--ranges no-space
|
||||
--someAny true
|
||||
--voidtype void
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
## Build, Test, and Development Commands
|
||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||
- Install deps: `pnpm install`
|
||||
- Pre-commit hooks: `prek install` (runs same checks as CI)
|
||||
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||
|
||||
56
CHANGELOG.md
56
CHANGELOG.md
@@ -5,50 +5,64 @@ Docs: https://docs.clawd.bot
|
||||
## 2026.1.24
|
||||
|
||||
### Highlights
|
||||
- Ollama: provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama
|
||||
- Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. https://docs.clawd.bot/providers/venice
|
||||
- Providers: Ollama discovery + docs; Venice guide upgrades + cross-links. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama https://docs.clawd.bot/providers/venice
|
||||
- Channels: LINE plugin (Messaging API) with rich replies + quick replies. (#1630) Thanks @plum-dawg.
|
||||
- TTS: Edge fallback (keyless) + `/tts` auto modes. (#1668, #1667) Thanks @steipete, @sebslight. https://docs.clawd.bot/tts
|
||||
- Exec approvals: approve in-chat via `/approve` across all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||
- Telegram: DM topics as separate sessions + outbound link preview toggle. (#1597, #1700) Thanks @rohannagpal, @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||
|
||||
### Changes
|
||||
- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.
|
||||
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
|
||||
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
||||
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
||||
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
||||
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
||||
- UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg.
|
||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
|
||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||
- Docs: add verbose installer troubleshooting guidance.
|
||||
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
||||
- Docs: update Fly.io guide notes.
|
||||
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
||||
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
|
||||
- Docs: update Fly.io guide notes.
|
||||
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
|
||||
|
||||
### Fixes
|
||||
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
|
||||
- Auto-reply: don't treat `/models` as a `/model` directive. (#1753) Thanks @uos-status.
|
||||
- Heartbeat: normalize target identifiers for consistent routing.
|
||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||
- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete.
|
||||
- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.
|
||||
- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles
|
||||
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
||||
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
|
||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||
- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.
|
||||
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||
- Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido.
|
||||
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||
- Google Chat: normalize space targets without double `spaces/` prefix.
|
||||
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||
- Agents: use the active auth profile for auto-compaction recovery.
|
||||
- Agents: let cron isolated runs inherit subagent allowlists from the parent agent. (#1771) Thanks @Noctivoro.
|
||||
- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.
|
||||
- Models: default missing custom provider fields so minimal configs are accepted.
|
||||
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
||||
- Heartbeat: normalize target identifiers for consistent routing.
|
||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||
- Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19.
|
||||
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
|
||||
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
||||
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
|
||||
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
|
||||
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
|
||||
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||
- Google Chat: normalize space targets without double `spaces/` prefix.
|
||||
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
||||
- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.
|
||||
- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||
- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||
|
||||
@@ -40,3 +40,13 @@ Please include in your PR:
|
||||
- [ ] Confirm you understand what the code does
|
||||
|
||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
|
||||
|
||||
## Current Focus & Roadmap 🗺
|
||||
|
||||
We are currently prioritizing:
|
||||
- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram).
|
||||
- **UX**: Improving the onboarding wizard and error messages.
|
||||
- **Skills**: Expanding the library of bundled skills and improving the Skill Creation developer experience.
|
||||
- **Performance**: Optimizing token usage and compaction logic.
|
||||
|
||||
Check the [GitHub Issues](https://github.com/clawdbot/clawdbot/issues) for "good first issue" labels!
|
||||
|
||||
@@ -459,7 +459,7 @@ Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
|
||||
## Clawd
|
||||
|
||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
||||
by Peter Steinberger and the community.
|
||||
|
||||
- [clawd.me](https://clawd.me)
|
||||
@@ -468,7 +468,7 @@ by Peter Steinberger and the community.
|
||||
|
||||
## Community
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||
|
||||
@@ -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:
|
||||
|
||||
- `https://docs.clawd.bot/gateway/security`
|
||||
|
||||
|
||||
@@ -212,4 +212,4 @@
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
|
||||
@@ -12,4 +12,3 @@ data class CameraHudState(
|
||||
val kind: CameraHudKind,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
|
||||
@@ -12,4 +12,3 @@ enum class VoiceWakeMode(val rawValue: String) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ class SmsManager(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Send an SMS message.
|
||||
*
|
||||
*
|
||||
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
|
||||
* @return SendResult indicating success or failure
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0A0A0A</color>
|
||||
</resources>
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Clawdbot Node</string>
|
||||
</resources>
|
||||
|
||||
|
||||
@@ -23,4 +23,3 @@ class VoiceWakeCommandExtractorTest {
|
||||
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,4 +16,3 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "ClawdbotNodeAndroid"
|
||||
include(":app")
|
||||
|
||||
|
||||
@@ -3,4 +3,3 @@ parent_config: ../../.swiftlint.yml
|
||||
included:
|
||||
- Sources
|
||||
- ../shared/ClawdisNodeKit/Sources
|
||||
|
||||
|
||||
@@ -33,4 +33,4 @@
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
SOFTWARE.
|
||||
|
||||
@@ -173,4 +173,4 @@
|
||||
"iPod5,1": "iPod touch (5th generation)",
|
||||
"iPod7,1": "iPod touch (6th generation)",
|
||||
"iPod9,1": "iPod touch (7th generation)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,4 +211,4 @@
|
||||
"Mac Pro (2019)",
|
||||
"Mac Pro (Rack, 2019)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,46 +574,22 @@ public actor GatewayChannelActor {
|
||||
params: [String: AnyCodable]?,
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
throw self.wrap(error, context: "gateway connect")
|
||||
}
|
||||
let id = UUID().uuidString
|
||||
try await self.connectOrThrow(context: "gateway connect")
|
||||
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
|
||||
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
|
||||
let paramsObject: ProtoAnyCodable? = params.map { entries in
|
||||
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
|
||||
dict[entry.key] = ProtoAnyCodable(entry.value.value)
|
||||
}
|
||||
return ProtoAnyCodable(dict)
|
||||
}
|
||||
let frame = RequestFrame(
|
||||
type: "req",
|
||||
id: id,
|
||||
method: method,
|
||||
params: paramsObject)
|
||||
let data: Data
|
||||
do {
|
||||
data = try self.encoder.encode(frame)
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"gateway request encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
let payload = try self.encodeRequest(method: method, params: params, kind: "request")
|
||||
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
||||
self.pending[id] = cont
|
||||
self.pending[payload.id] = cont
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
|
||||
await self.timeoutRequest(id: id, timeoutMs: effectiveTimeout)
|
||||
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout)
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
try await self.task?.send(.data(data))
|
||||
try await self.task?.send(.data(payload.data))
|
||||
} catch {
|
||||
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.
|
||||
self.connected = false
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
@@ -643,6 +619,29 @@ public actor GatewayChannelActor {
|
||||
return Data() // Should not happen, but tolerate empty payloads.
|
||||
}
|
||||
|
||||
public func send(method: String, params: [String: AnyCodable]?) async throws {
|
||||
try await self.connectOrThrow(context: "gateway connect")
|
||||
let payload = try self.encodeRequest(method: method, params: params, kind: "send")
|
||||
guard let task = self.task else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 5,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway socket unavailable"])
|
||||
}
|
||||
do {
|
||||
try await task.send(.data(payload.data))
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
||||
self.connected = false
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
throw wrapped
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
|
||||
private func wrap(_ error: Error, context: String) -> Error {
|
||||
if let urlError = error as? URLError {
|
||||
@@ -657,6 +656,42 @@ public actor GatewayChannelActor {
|
||||
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
|
||||
}
|
||||
|
||||
private func connectOrThrow(context: String) async throws {
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
throw self.wrap(error, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
private func encodeRequest(
|
||||
method: String,
|
||||
params: [String: AnyCodable]?,
|
||||
kind: String) throws -> (id: String, data: Data)
|
||||
{
|
||||
let id = UUID().uuidString
|
||||
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
|
||||
let paramsObject: ProtoAnyCodable? = params.map { entries in
|
||||
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
|
||||
dict[entry.key] = ProtoAnyCodable(entry.value.value)
|
||||
}
|
||||
return ProtoAnyCodable(dict)
|
||||
}
|
||||
let frame = RequestFrame(
|
||||
type: "req",
|
||||
id: id,
|
||||
method: method,
|
||||
params: paramsObject)
|
||||
do {
|
||||
let data = try self.encoder.encode(frame)
|
||||
return (id: id, data: data)
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func failPending(_ error: Error) async {
|
||||
let waiters = self.pending
|
||||
self.pending.removeAll()
|
||||
|
||||
@@ -143,7 +143,7 @@ public actor GatewayNodeSession {
|
||||
"payloadJSON": AnyCodable(payloadJSON ?? NSNull()),
|
||||
]
|
||||
do {
|
||||
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 8000)
|
||||
try await channel.send(method: "node.event", params: params)
|
||||
} catch {
|
||||
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
@@ -224,7 +224,7 @@ public actor GatewayNodeSession {
|
||||
])
|
||||
}
|
||||
do {
|
||||
_ = try await channel.request(method: "node.invoke.result", params: params, timeoutMs: 15000)
|
||||
try await channel.send(method: "node.invoke.result", params: params)
|
||||
} catch {
|
||||
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>
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="any" />
|
||||
<script type="module" crossorigin src="./assets/index-DsXRcnEw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BvhR9FCb.css">
|
||||
<script type="module" crossorigin src="./assets/index-DQcOTEYz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css">
|
||||
</head>
|
||||
<body>
|
||||
<clawdbot-app></clawdbot-app>
|
||||
|
||||
@@ -213,6 +213,7 @@ Prefer `chat_guid` for stable routing:
|
||||
- `chat_id:123`
|
||||
- `chat_identifier:...`
|
||||
- Direct handles: `+15555550123`, `user@example.com`
|
||||
- If a direct handle does not have an existing DM chat, Clawdbot will create one via `POST /api/v1/chat/new`. This requires the BlueBubbles Private API to be enabled.
|
||||
|
||||
## Security
|
||||
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
||||
|
||||
@@ -26,7 +26,7 @@ Minimal config:
|
||||
```
|
||||
|
||||
### Setup
|
||||
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
||||
1) Create a Slack app (From scratch) in https://api.slack.com/apps.
|
||||
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
||||
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||
4) Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`).
|
||||
@@ -245,29 +245,29 @@ If you enable native commands, add one `slash_commands` entry per command you wa
|
||||
## Scopes (current vs optional)
|
||||
Slack's Conversations API is type-scoped: you only need the scopes for the
|
||||
conversation types you actually touch (channels, groups, im, mpim). See
|
||||
https://api.channels.slack.com/docs/conversations-api for the overview.
|
||||
https://docs.slack.dev/apis/web-api/using-the-conversations-api/ for the overview.
|
||||
|
||||
### Bot token scopes (required)
|
||||
- `chat:write` (send/update/delete messages via `chat.postMessage`)
|
||||
https://api.channels.slack.com/methods/chat.postMessage
|
||||
https://docs.slack.dev/reference/methods/chat.postMessage
|
||||
- `im:write` (open DMs via `conversations.open` for user DMs)
|
||||
https://api.channels.slack.com/methods/conversations.open
|
||||
https://docs.slack.dev/reference/methods/conversations.open
|
||||
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
|
||||
https://api.channels.slack.com/methods/conversations.history
|
||||
https://docs.slack.dev/reference/methods/conversations.history
|
||||
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
|
||||
https://api.channels.slack.com/methods/conversations.info
|
||||
https://docs.slack.dev/reference/methods/conversations.info
|
||||
- `users:read` (user lookup)
|
||||
https://api.channels.slack.com/methods/users.info
|
||||
https://docs.slack.dev/reference/methods/users.info
|
||||
- `reactions:read`, `reactions:write` (`reactions.get` / `reactions.add`)
|
||||
https://api.channels.slack.com/methods/reactions.get
|
||||
https://api.channels.slack.com/methods/reactions.add
|
||||
https://docs.slack.dev/reference/methods/reactions.get
|
||||
https://docs.slack.dev/reference/methods/reactions.add
|
||||
- `pins:read`, `pins:write` (`pins.list` / `pins.add` / `pins.remove`)
|
||||
https://api.channels.slack.com/scopes/pins:read
|
||||
https://api.channels.slack.com/scopes/pins:write
|
||||
https://docs.slack.dev/reference/scopes/pins.read
|
||||
https://docs.slack.dev/reference/scopes/pins.write
|
||||
- `emoji:read` (`emoji.list`)
|
||||
https://api.channels.slack.com/scopes/emoji:read
|
||||
https://docs.slack.dev/reference/scopes/emoji.read
|
||||
- `files:write` (uploads via `files.uploadV2`)
|
||||
https://api.channels.slack.com/messaging/files/uploading
|
||||
https://docs.slack.dev/messaging/working-with-files/#upload
|
||||
|
||||
### User token scopes (optional, read-only by default)
|
||||
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
|
||||
@@ -284,9 +284,9 @@ Add these under **User Token Scopes** if you configure `channels.slack.userToken
|
||||
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`)
|
||||
- `groups:write` (only if we add private-channel management: create/rename/invite/archive)
|
||||
- `chat:write.public` (only if we want to post to channels the bot isn't in)
|
||||
https://api.channels.slack.com/scopes/chat:write.public
|
||||
https://docs.slack.dev/reference/scopes/chat.write.public
|
||||
- `users:read.email` (only if we need email fields from `users.info`)
|
||||
https://api.channels.slack.com/changelog/2017-04-narrowing-email-access
|
||||
https://docs.slack.dev/changelog/2017-04-narrowing-email-access
|
||||
- `files:read` (only if we start listing/reading file metadata)
|
||||
|
||||
## Config
|
||||
|
||||
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.
|
||||
@@ -2847,8 +2847,9 @@ Control UI base path:
|
||||
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
|
||||
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
|
||||
- Default: root (`/`) (unchanged).
|
||||
- `gateway.controlUi.allowInsecureAuth` allows token-only auth over **HTTP** (no device identity).
|
||||
Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`.
|
||||
- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI and skips
|
||||
device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS
|
||||
(Tailscale Serve) or `127.0.0.1`.
|
||||
|
||||
Related docs:
|
||||
- [Control UI](/web/control-ui)
|
||||
|
||||
@@ -58,7 +58,7 @@ When the audit prints findings, treat this as a priority order:
|
||||
|
||||
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
|
||||
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
|
||||
to **token-only auth** on plain HTTP and skips device pairing. This is a security
|
||||
to **token-only auth** and skips device pairing (even on HTTPS). This is a security
|
||||
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
|
||||
|
||||
`clawdbot security audit` warns when this setting is enabled.
|
||||
|
||||
@@ -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)
|
||||
- [What do Clawd, Flawd, and Krill use for models?](#what-do-clawd-flawd-and-krill-use-for-models)
|
||||
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
||||
- [Can I use GPT 5.2 for daily tasks and Codex 5.2 for coding](#can-i-use-gpt-52-for-daily-tasks-and-codex-52-for-coding)
|
||||
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
|
||||
- [Why do I see “Unknown model: minimax/MiniMax-M2.1”?](#why-do-i-see-unknown-model-minimaxminimaxm21)
|
||||
- [Can I use MiniMax as my default and OpenAI for complex tasks?](#can-i-use-minimax-as-my-default-and-openai-for-complex-tasks)
|
||||
@@ -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>`).
|
||||
Use `/model status` to confirm which auth profile is active.
|
||||
|
||||
### Can I use GPT 5.2 for daily tasks and Codex 5.2 for coding
|
||||
|
||||
Yes. Set one as default and switch as needed:
|
||||
|
||||
- **Quick switch (per session):** `/model gpt-5.2` for daily tasks, `/model gpt-5.2-codex` for coding.
|
||||
- **Default + switch:** set `agents.defaults.model.primary` to `openai-codex/gpt-5.2`, then switch to `openai-codex/gpt-5.2-codex` when coding (or the other way around).
|
||||
- **Sub-agents:** route coding tasks to sub-agents with a different default model.
|
||||
|
||||
See [Models](/concepts/models) and [Slash commands](/tools/slash-commands).
|
||||
|
||||
### Why do I see Model is not allowed and then no reply
|
||||
|
||||
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any
|
||||
|
||||
@@ -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
|
||||
|
||||
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 |
|
||||
| `--allow-unconfigured` | Starts without a config file (you'll create one after) |
|
||||
| `internal_port = 3000` | Must match `--port 3000` (or `CLAWDBOT_GATEWAY_PORT`) for Fly health checks |
|
||||
| `memory = "2048mb"` | 512MB is too small; 2GB recommended |
|
||||
| `CLAWDBOT_STATE_DIR = "/data"` | Persists state on the volume |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
### Health checks failing / connection refused
|
||||
|
||||
Fly can't reach the gateway on the configured port.
|
||||
|
||||
**Fix:** Ensure `internal_port` matches the gateway port (set `--port 3000` or `CLAWDBOT_GATEWAY_PORT=3000`).
|
||||
|
||||
### OOM / Memory Issues
|
||||
|
||||
Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts.
|
||||
@@ -268,11 +275,11 @@ The lock file is at `/data/gateway.*.lock` (not in a subdirectory).
|
||||
|
||||
### Config Not Being Read
|
||||
|
||||
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/.clawdbot/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:
|
||||
```bash
|
||||
fly ssh console --command "cat /data/.clawdbot/clawdbot.json"
|
||||
fly ssh console --command "cat /data/clawdbot.json"
|
||||
```
|
||||
|
||||
### Writing Config via SSH
|
||||
@@ -281,18 +288,24 @@ The `fly ssh console -C` command doesn't support shell redirection. To write a c
|
||||
|
||||
```bash
|
||||
# Use echo + tee (pipe from local to remote)
|
||||
echo '{"your":"config"}' | fly ssh console -C "tee /data/.clawdbot/clawdbot.json"
|
||||
echo '{"your":"config"}' | fly ssh console -C "tee /data/clawdbot.json"
|
||||
|
||||
# Or use sftp
|
||||
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:
|
||||
```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
|
||||
|
||||
```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
|
||||
- For WhatsApp/Telegram onboarding, use `fly ssh console`
|
||||
- Persistent data lives on the volume at `/data`
|
||||
- Signal requires Java + signal-cli; use a custom image and keep memory at 2GB+.
|
||||
|
||||
## Cost
|
||||
|
||||
|
||||
41
docs/tools/creating-skills.md
Normal file
41
docs/tools/creating-skills.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Creating Custom Skills 🛠
|
||||
|
||||
Clawdbot is designed to be easily extensible. "Skills" are the primary way to add new capabilities to your assistant.
|
||||
|
||||
## What is a Skill?
|
||||
A skill is a directory containing a `SKILL.md` file (which provides instructions and tool definitions to the LLM) and optionally some scripts or resources.
|
||||
|
||||
## Step-by-Step: Your First Skill
|
||||
|
||||
### 1. Create the Directory
|
||||
Skills live in your workspace, usually `~/clawd/skills/`. Create a new folder for your skill:
|
||||
```bash
|
||||
mkdir -p ~/clawd/skills/hello-world
|
||||
```
|
||||
|
||||
### 2. Define the `SKILL.md`
|
||||
Create a `SKILL.md` file in that directory. This file uses YAML frontmatter for metadata and Markdown for instructions.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: hello_world
|
||||
description: A simple skill that says hello.
|
||||
---
|
||||
|
||||
# Hello World Skill
|
||||
When the user asks for a greeting, use the `echo` tool to say "Hello from your custom skill!".
|
||||
```
|
||||
|
||||
### 3. Add Tools (Optional)
|
||||
You can define custom tools in the frontmatter or instruct the agent to use existing system tools (like `bash` or `browser`).
|
||||
|
||||
### 4. Refresh Clawdbot
|
||||
Ask your agent to "refresh skills" or restart the gateway. Clawdbot will discover the new directory and index the `SKILL.md`.
|
||||
|
||||
## Best Practices
|
||||
- **Be Concise**: Instruct the model on *what* to do, not how to be an AI.
|
||||
- **Safety First**: If your skill uses `bash`, ensure the prompts don't allow arbitrary command injection from untrusted user input.
|
||||
- **Test Locally**: Use `clawdbot agent --message "use my new skill"` to test.
|
||||
|
||||
## Shared Skills
|
||||
You can also browse and contribute skills to [ClawdHub](https://clawdhub.com).
|
||||
@@ -108,8 +108,8 @@ Clawdbot **blocks** Control UI connections without device identity.
|
||||
}
|
||||
```
|
||||
|
||||
This disables device identity + pairing for the Control UI. Use only if you
|
||||
trust the network.
|
||||
This disables device identity + pairing for the Control UI (even on HTTPS). Use
|
||||
only if you trust the network.
|
||||
|
||||
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
|
||||
|
||||
|
||||
@@ -25,9 +25,11 @@ import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
||||
import { sendMessageBlueBubbles } from "./send.js";
|
||||
import {
|
||||
extractHandleFromChatGuid,
|
||||
looksLikeBlueBubblesTargetId,
|
||||
normalizeBlueBubblesHandle,
|
||||
normalizeBlueBubblesMessagingTarget,
|
||||
parseBlueBubblesTarget,
|
||||
} from "./targets.js";
|
||||
import { bluebubblesMessageActions } from "./actions.js";
|
||||
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
||||
@@ -148,6 +150,58 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
looksLikeId: looksLikeBlueBubblesTargetId,
|
||||
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
|
||||
},
|
||||
formatTargetDisplay: ({ target, display }) => {
|
||||
const shouldParseDisplay = (value: string): boolean => {
|
||||
if (looksLikeBlueBubblesTargetId(value)) return true;
|
||||
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
|
||||
};
|
||||
|
||||
// Helper to extract a clean handle from any BlueBubbles target format
|
||||
const extractCleanDisplay = (value: string | undefined): string | null => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = parseBlueBubblesTarget(trimmed);
|
||||
if (parsed.kind === "chat_guid") {
|
||||
const handle = extractHandleFromChatGuid(parsed.chatGuid);
|
||||
if (handle) return handle;
|
||||
}
|
||||
if (parsed.kind === "handle") {
|
||||
return normalizeBlueBubblesHandle(parsed.to);
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
// Strip common prefixes and try raw extraction
|
||||
const stripped = trimmed
|
||||
.replace(/^bluebubbles:/i, "")
|
||||
.replace(/^chat_guid:/i, "")
|
||||
.replace(/^chat_id:/i, "")
|
||||
.replace(/^chat_identifier:/i, "");
|
||||
const handle = extractHandleFromChatGuid(stripped);
|
||||
if (handle) return handle;
|
||||
// Don't return raw chat_guid formats - they contain internal routing info
|
||||
if (stripped.includes(";-;") || stripped.includes(";+;")) return null;
|
||||
return stripped;
|
||||
};
|
||||
|
||||
// Try to get a clean display from the display parameter first
|
||||
const trimmedDisplay = display?.trim();
|
||||
if (trimmedDisplay) {
|
||||
if (!shouldParseDisplay(trimmedDisplay)) {
|
||||
return trimmedDisplay;
|
||||
}
|
||||
const cleanDisplay = extractCleanDisplay(trimmedDisplay);
|
||||
if (cleanDisplay) return cleanDisplay;
|
||||
}
|
||||
|
||||
// Fall back to extracting from target
|
||||
const cleanTarget = extractCleanDisplay(target);
|
||||
if (cleanTarget) return cleanTarget;
|
||||
|
||||
// Last resort: return display or target as-is
|
||||
return display?.trim() || target?.trim() || "";
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
|
||||
@@ -187,6 +187,47 @@ describe("send", () => {
|
||||
expect(result).toBe("iMessage;-;+15551234567");
|
||||
});
|
||||
|
||||
it("returns null when handle only exists in group chat (not DM)", async () => {
|
||||
// This is the critical fix: if a phone number only exists as a participant in a group chat
|
||||
// (no direct DM chat), we should NOT send to that group. Return null instead.
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;+;group-the-council",
|
||||
participants: [
|
||||
{ address: "+12622102921" },
|
||||
{ address: "+15550001111" },
|
||||
{ address: "+15550002222" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
// Empty second page to stop pagination
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
});
|
||||
|
||||
const target: BlueBubblesSendTarget = {
|
||||
kind: "handle",
|
||||
address: "+12622102921",
|
||||
service: "imessage",
|
||||
};
|
||||
const result = await resolveChatGuidForTarget({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
target,
|
||||
});
|
||||
|
||||
// Should return null, NOT the group chat GUID
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when chat not found", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -344,14 +385,14 @@ describe("send", () => {
|
||||
).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({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendMessageBlueBubbles("+15559999999", "Hello", {
|
||||
sendMessageBlueBubbles("chat_id:999", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
@@ -398,6 +439,57 @@ describe("send", () => {
|
||||
expect(body.method).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates a new chat when handle target is missing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "new-msg-guid" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("new-msg-guid");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
const createCall = mockFetch.mock.calls[1];
|
||||
expect(createCall[0]).toContain("/api/v1/chat/new");
|
||||
const body = JSON.parse(createCall[1].body);
|
||||
expect(body.addresses).toEqual(["+15550009999"]);
|
||||
expect(body.message).toBe("Hello new chat");
|
||||
});
|
||||
|
||||
it("throws when creating a new chat requires Private API", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: () => Promise.resolve("Private API not enabled"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendMessageBlueBubbles("+15550008888", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("Private API must be enabled");
|
||||
});
|
||||
|
||||
it("uses private-api when reply metadata is present", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -257,11 +257,17 @@ export async function resolveChatGuidForTarget(params: {
|
||||
return guid;
|
||||
}
|
||||
if (!participantMatch && guid) {
|
||||
const participants = extractParticipantAddresses(chat).map((entry) =>
|
||||
normalizeBlueBubblesHandle(entry),
|
||||
);
|
||||
if (participants.includes(normalizedHandle)) {
|
||||
participantMatch = guid;
|
||||
// Only consider DM chats (`;-;` separator) as participant matches.
|
||||
// Group chats (`;+;` separator) should never match when searching by handle/phone.
|
||||
// This prevents routing "send to +1234567890" to a group chat that contains that number.
|
||||
const isDmChat = guid.includes(";-;");
|
||||
if (isDmChat) {
|
||||
const participants = extractParticipantAddresses(chat).map((entry) =>
|
||||
normalizeBlueBubblesHandle(entry),
|
||||
);
|
||||
if (participants.includes(normalizedHandle)) {
|
||||
participantMatch = guid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,6 +276,55 @@ export async function resolveChatGuidForTarget(params: {
|
||||
return participantMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new chat (DM) and optionally sends an initial message.
|
||||
* Requires Private API to be enabled in BlueBubbles.
|
||||
*/
|
||||
async function createNewChatWithMessage(params: {
|
||||
baseUrl: string;
|
||||
password: string;
|
||||
address: string;
|
||||
message: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BlueBubblesSendResult> {
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl: params.baseUrl,
|
||||
path: "/api/v1/chat/new",
|
||||
password: params.password,
|
||||
});
|
||||
const payload = {
|
||||
addresses: [params.address],
|
||||
message: params.message,
|
||||
};
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
params.timeoutMs,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
// Check for Private API not enabled error
|
||||
if (res.status === 400 || res.status === 403 || errorText.toLowerCase().includes("private api")) {
|
||||
throw new Error(
|
||||
`BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`,
|
||||
);
|
||||
}
|
||||
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
const body = await res.text();
|
||||
if (!body) return { messageId: "ok" };
|
||||
try {
|
||||
const parsed = JSON.parse(body) as unknown;
|
||||
return { messageId: extractMessageId(parsed) };
|
||||
} catch {
|
||||
return { messageId: "ok" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMessageBlueBubbles(
|
||||
to: string,
|
||||
text: string,
|
||||
@@ -297,6 +352,17 @@ export async function sendMessageBlueBubbles(
|
||||
target,
|
||||
});
|
||||
if (!chatGuid) {
|
||||
// If target is a phone number/handle and no existing chat found,
|
||||
// auto-create a new DM chat using the /api/v1/chat/new endpoint
|
||||
if (target.kind === "handle") {
|
||||
return createNewChatWithMessage({
|
||||
baseUrl,
|
||||
password,
|
||||
address: target.address,
|
||||
message: trimmedText,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
||||
);
|
||||
|
||||
11
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;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function registerMatrixAutoJoin(params: {
|
||||
// For "allowlist" mode, handle invites manually
|
||||
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
|
||||
if (autoJoin !== "allowlist") return;
|
||||
|
||||
|
||||
// Get room alias if available
|
||||
let alias: string | undefined;
|
||||
let altAliases: string[] = [];
|
||||
|
||||
@@ -329,16 +329,20 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType =
|
||||
"info" in content && content.info && "mimetype" in content.info
|
||||
? (content.info as { mimetype?: string }).mimetype
|
||||
const contentInfo =
|
||||
"info" in content && content.info && typeof content.info === "object"
|
||||
? (content.info as { mimetype?: string; size?: number })
|
||||
: undefined;
|
||||
const contentType = contentInfo?.mimetype;
|
||||
const contentSize =
|
||||
typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
|
||||
if (mediaUrl?.startsWith("mxc://")) {
|
||||
try {
|
||||
media = await downloadMatrixMedia({
|
||||
client,
|
||||
mxcUrl: mediaUrl,
|
||||
contentType,
|
||||
sizeBytes: contentSize,
|
||||
maxBytes: mediaMaxBytes,
|
||||
file: contentFile,
|
||||
});
|
||||
|
||||
@@ -25,10 +25,8 @@ describe("downloadMatrixMedia", () => {
|
||||
|
||||
it("decrypts encrypted media when file payloads are present", async () => {
|
||||
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
||||
const downloadContent = vi.fn().mockResolvedValue(Buffer.from("encrypted"));
|
||||
|
||||
const client = {
|
||||
downloadContent,
|
||||
crypto: { decryptMedia },
|
||||
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
||||
} as unknown as import("matrix-bot-sdk").MatrixClient;
|
||||
@@ -55,7 +53,8 @@ describe("downloadMatrixMedia", () => {
|
||||
file,
|
||||
});
|
||||
|
||||
expect(decryptMedia).toHaveBeenCalled();
|
||||
// decryptMedia should be called with just the file object (it handles download internally)
|
||||
expect(decryptMedia).toHaveBeenCalledWith(file);
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||
Buffer.from("decrypted"),
|
||||
"image/png",
|
||||
@@ -64,4 +63,41 @@ describe("downloadMatrixMedia", () => {
|
||||
);
|
||||
expect(result?.path).toBe("/tmp/media");
|
||||
});
|
||||
|
||||
it("rejects encrypted media that exceeds maxBytes before decrypting", async () => {
|
||||
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
||||
|
||||
const client = {
|
||||
crypto: { decryptMedia },
|
||||
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
||||
} as unknown as import("matrix-bot-sdk").MatrixClient;
|
||||
|
||||
const file = {
|
||||
url: "mxc://example/file",
|
||||
key: {
|
||||
kty: "oct",
|
||||
key_ops: ["encrypt", "decrypt"],
|
||||
alg: "A256CTR",
|
||||
k: "secret",
|
||||
ext: true,
|
||||
},
|
||||
iv: "iv",
|
||||
hashes: { sha256: "hash" },
|
||||
v: "v2",
|
||||
};
|
||||
|
||||
await expect(
|
||||
downloadMatrixMedia({
|
||||
client,
|
||||
mxcUrl: "mxc://example/file",
|
||||
contentType: "image/png",
|
||||
sizeBytes: 2048,
|
||||
maxBytes: 1024,
|
||||
file,
|
||||
}),
|
||||
).rejects.toThrow("Matrix media exceeds configured size limit");
|
||||
|
||||
expect(decryptMedia).not.toHaveBeenCalled();
|
||||
expect(saveMediaBuffer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ async function fetchMatrixMediaBuffer(params: {
|
||||
// matrix-bot-sdk provides mxcToHttp helper
|
||||
const url = params.client.mxcToHttp(params.mxcUrl);
|
||||
if (!url) return null;
|
||||
|
||||
|
||||
// Use the client's download method which handles auth
|
||||
try {
|
||||
const buffer = await params.client.downloadContent(params.mxcUrl);
|
||||
@@ -40,6 +40,7 @@ async function fetchMatrixMediaBuffer(params: {
|
||||
|
||||
/**
|
||||
* Download and decrypt encrypted media from a Matrix room.
|
||||
* Uses matrix-bot-sdk's decryptMedia which handles both download and decryption.
|
||||
*/
|
||||
async function fetchEncryptedMediaBuffer(params: {
|
||||
client: MatrixClient;
|
||||
@@ -50,18 +51,13 @@ async function fetchEncryptedMediaBuffer(params: {
|
||||
throw new Error("Cannot decrypt media: crypto not enabled");
|
||||
}
|
||||
|
||||
// Download the encrypted content
|
||||
const encryptedBuffer = await params.client.downloadContent(params.file.url);
|
||||
if (encryptedBuffer.byteLength > params.maxBytes) {
|
||||
// decryptMedia handles downloading and decrypting the encrypted content internally
|
||||
const decrypted = await params.client.crypto.decryptMedia(params.file);
|
||||
|
||||
if (decrypted.byteLength > params.maxBytes) {
|
||||
throw new Error("Matrix media exceeds configured size limit");
|
||||
}
|
||||
|
||||
// Decrypt using matrix-bot-sdk crypto
|
||||
const decrypted = await params.client.crypto.decryptMedia(
|
||||
Buffer.from(encryptedBuffer),
|
||||
params.file,
|
||||
);
|
||||
|
||||
return { buffer: decrypted };
|
||||
}
|
||||
|
||||
@@ -69,6 +65,7 @@ export async function downloadMatrixMedia(params: {
|
||||
client: MatrixClient;
|
||||
mxcUrl: string;
|
||||
contentType?: string;
|
||||
sizeBytes?: number;
|
||||
maxBytes: number;
|
||||
file?: EncryptedFile;
|
||||
}): Promise<{
|
||||
@@ -77,7 +74,13 @@ export async function downloadMatrixMedia(params: {
|
||||
placeholder: string;
|
||||
} | null> {
|
||||
let fetched: { buffer: Buffer; headerType?: string } | null;
|
||||
|
||||
if (
|
||||
typeof params.sizeBytes === "number" &&
|
||||
params.sizeBytes > params.maxBytes
|
||||
) {
|
||||
throw new Error("Matrix media exceeds configured size limit");
|
||||
}
|
||||
|
||||
if (params.file) {
|
||||
// Encrypted media
|
||||
fetched = await fetchEncryptedMediaBuffer({
|
||||
@@ -93,7 +96,7 @@ export async function downloadMatrixMedia(params: {
|
||||
maxBytes: params.maxBytes,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (!fetched) return null;
|
||||
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
||||
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
||||
|
||||
@@ -29,6 +29,7 @@ export type RoomMessageEventContent = MessageEventContent & {
|
||||
file?: EncryptedFile;
|
||||
info?: {
|
||||
mimetype?: string;
|
||||
size?: number;
|
||||
};
|
||||
"m.relates_to"?: {
|
||||
rel_type?: string;
|
||||
|
||||
@@ -11,4 +11,4 @@ export function resolveMattermostGroupRequireMention(
|
||||
});
|
||||
if (typeof account.requireMention === "boolean") return account.requireMention;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,4 +112,4 @@ export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMatt
|
||||
return listMattermostAccountIds(cfg)
|
||||
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,4 +205,4 @@ export async function uploadMattermostFile(
|
||||
throw new Error("Mattermost file upload failed");
|
||||
}
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,4 +147,4 @@ export function resolveThreadSessionKeys(params: {
|
||||
? `${params.baseSessionKey}:thread:${threadId}`
|
||||
: params.baseSessionKey;
|
||||
return { sessionKey, parentSessionKey: params.parentSessionKey };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,4 +67,4 @@ export async function probeMattermost(
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,4 +39,4 @@ export async function promptAccountId(params: PromptAccountIdParams): Promise<st
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,4 +184,4 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
mattermost: { ...cfg.channels?.mattermost, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,11 +22,11 @@ parallel:
|
||||
security = session: security_expert
|
||||
prompt: "Perform a deep security audit of the changes. Look for OWASP top 10 issues."
|
||||
context: overview
|
||||
|
||||
|
||||
perf = session: performance_expert
|
||||
prompt: "Analyze the performance implications. Identify potential bottlenecks or regressions."
|
||||
context: overview
|
||||
|
||||
|
||||
style = session: reviewer
|
||||
prompt: "Review for code style, maintainability, and adherence to best practices."
|
||||
context: overview
|
||||
|
||||
@@ -19,4 +19,3 @@ export type CallManagerContext = {
|
||||
transcriptWaiters: Map<CallId, TranscriptWaiter>;
|
||||
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
|
||||
};
|
||||
|
||||
|
||||
@@ -175,4 +175,3 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
|
||||
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,4 +31,3 @@ export function findCall(params: {
|
||||
providerCallId: params.callIdOrProviderCallId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -48,4 +48,3 @@ export function addTranscriptEntry(
|
||||
};
|
||||
call.transcript.push(entry);
|
||||
}
|
||||
|
||||
|
||||
@@ -86,4 +86,3 @@ export async function getCallHistoryFromStore(
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
|
||||
@@ -84,4 +84,3 @@ export function waitForFinalTranscript(
|
||||
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,4 +7,3 @@ export function generateNotifyTwiml(message: string, voice: string): string {
|
||||
<Hangup/>
|
||||
</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;
|
||||
/** Callback when stream connects */
|
||||
onConnect?: (callId: string, streamSid: string) => void;
|
||||
/** Callback when speech starts (barge-in) */
|
||||
onSpeechStart?: (callId: string) => void;
|
||||
/** Callback when stream disconnects */
|
||||
onDisconnect?: (callId: string) => void;
|
||||
}
|
||||
@@ -43,6 +45,13 @@ interface StreamSession {
|
||||
sttSession: RealtimeSTTSession;
|
||||
}
|
||||
|
||||
type TtsQueueEntry = {
|
||||
playFn: (signal: AbortSignal) => Promise<void>;
|
||||
controller: AbortController;
|
||||
resolve: () => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages WebSocket connections for Twilio media streams.
|
||||
*/
|
||||
@@ -50,6 +59,12 @@ export class MediaStreamHandler {
|
||||
private wss: WebSocketServer | null = null;
|
||||
private sessions = new Map<string, StreamSession>();
|
||||
private config: MediaStreamConfig;
|
||||
/** TTS playback queues per stream (serialize audio to prevent overlap) */
|
||||
private ttsQueues = new Map<string, TtsQueueEntry[]>();
|
||||
/** Whether TTS is currently playing per stream */
|
||||
private ttsPlaying = new Map<string, boolean>();
|
||||
/** Active TTS playback controllers per stream */
|
||||
private ttsActiveControllers = new Map<string, AbortController>();
|
||||
|
||||
constructor(config: MediaStreamConfig) {
|
||||
this.config = config;
|
||||
@@ -148,6 +163,10 @@ export class MediaStreamHandler {
|
||||
this.config.onTranscript?.(callSid, transcript);
|
||||
});
|
||||
|
||||
sttSession.onSpeechStart(() => {
|
||||
this.config.onSpeechStart?.(callSid);
|
||||
});
|
||||
|
||||
const session: StreamSession = {
|
||||
callId: callSid,
|
||||
streamSid,
|
||||
@@ -177,6 +196,7 @@ export class MediaStreamHandler {
|
||||
private handleStop(session: StreamSession): void {
|
||||
console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
|
||||
|
||||
this.clearTtsState(session.streamSid);
|
||||
session.sttSession.close();
|
||||
this.sessions.delete(session.streamSid);
|
||||
this.config.onDisconnect?.(session.callId);
|
||||
@@ -228,6 +248,46 @@ export class MediaStreamHandler {
|
||||
this.sendToStream(streamSid, { event: "clear", streamSid });
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a TTS operation for sequential playback.
|
||||
* Only one TTS operation plays at a time per stream to prevent overlap.
|
||||
*/
|
||||
async queueTts(
|
||||
streamSid: string,
|
||||
playFn: (signal: AbortSignal) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const queue = this.getTtsQueue(streamSid);
|
||||
let resolveEntry: () => void;
|
||||
let rejectEntry: (error: unknown) => void;
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
resolveEntry = resolve;
|
||||
rejectEntry = reject;
|
||||
});
|
||||
|
||||
queue.push({
|
||||
playFn,
|
||||
controller: new AbortController(),
|
||||
resolve: resolveEntry!,
|
||||
reject: rejectEntry!,
|
||||
});
|
||||
|
||||
if (!this.ttsPlaying.get(streamSid)) {
|
||||
void this.processQueue(streamSid);
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear TTS queue and interrupt current playback (barge-in).
|
||||
*/
|
||||
clearTtsQueue(streamSid: string): void {
|
||||
const queue = this.getTtsQueue(streamSid);
|
||||
queue.length = 0;
|
||||
this.ttsActiveControllers.get(streamSid)?.abort();
|
||||
this.clearAudio(streamSid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active session by call ID.
|
||||
*/
|
||||
@@ -242,11 +302,65 @@ export class MediaStreamHandler {
|
||||
*/
|
||||
closeAll(): void {
|
||||
for (const session of this.sessions.values()) {
|
||||
this.clearTtsState(session.streamSid);
|
||||
session.sttSession.close();
|
||||
session.ws.close();
|
||||
}
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
private getTtsQueue(streamSid: string): TtsQueueEntry[] {
|
||||
const existing = this.ttsQueues.get(streamSid);
|
||||
if (existing) return existing;
|
||||
const queue: TtsQueueEntry[] = [];
|
||||
this.ttsQueues.set(streamSid, queue);
|
||||
return queue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the TTS queue for a stream.
|
||||
* Uses iterative approach to avoid stack accumulation from recursion.
|
||||
*/
|
||||
private async processQueue(streamSid: string): Promise<void> {
|
||||
this.ttsPlaying.set(streamSid, true);
|
||||
|
||||
while (true) {
|
||||
const queue = this.ttsQueues.get(streamSid);
|
||||
if (!queue || queue.length === 0) {
|
||||
this.ttsPlaying.set(streamSid, false);
|
||||
this.ttsActiveControllers.delete(streamSid);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = queue.shift()!;
|
||||
this.ttsActiveControllers.set(streamSid, entry.controller);
|
||||
|
||||
try {
|
||||
await entry.playFn(entry.controller.signal);
|
||||
entry.resolve();
|
||||
} catch (error) {
|
||||
if (entry.controller.signal.aborted) {
|
||||
entry.resolve();
|
||||
} else {
|
||||
console.error("[MediaStream] TTS playback error:", error);
|
||||
entry.reject(error);
|
||||
}
|
||||
} finally {
|
||||
if (this.ttsActiveControllers.get(streamSid) === entry.controller) {
|
||||
this.ttsActiveControllers.delete(streamSid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearTtsState(streamSid: string): void {
|
||||
const queue = this.ttsQueues.get(streamSid);
|
||||
if (queue) queue.length = 0;
|
||||
this.ttsActiveControllers.get(streamSid)?.abort();
|
||||
this.ttsActiveControllers.delete(streamSid);
|
||||
this.ttsPlaying.delete(streamSid);
|
||||
this.ttsQueues.delete(streamSid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,4 +26,3 @@ describe("PlivoProvider", () => {
|
||||
expect(result.providerResponseBody).toContain('length="300"');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface RealtimeSTTSession {
|
||||
onPartial(callback: (partial: string) => void): void;
|
||||
/** Set callback for final transcripts */
|
||||
onTranscript(callback: (transcript: string) => void): void;
|
||||
/** Set callback when speech starts (VAD) */
|
||||
onSpeechStart(callback: () => void): void;
|
||||
/** Close the session */
|
||||
close(): void;
|
||||
/** Check if session is connected */
|
||||
@@ -91,6 +93,7 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
||||
private pendingTranscript = "";
|
||||
private onTranscriptCallback: ((transcript: string) => void) | null = null;
|
||||
private onPartialCallback: ((partial: string) => void) | null = null;
|
||||
private onSpeechStartCallback: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
@@ -243,6 +246,7 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
||||
case "input_audio_buffer.speech_started":
|
||||
console.log("[RealtimeSTT] Speech started");
|
||||
this.pendingTranscript = "";
|
||||
this.onSpeechStartCallback?.();
|
||||
break;
|
||||
|
||||
case "error":
|
||||
@@ -273,6 +277,10 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
||||
this.onTranscriptCallback = callback;
|
||||
}
|
||||
|
||||
onSpeechStart(callback: () => void): void {
|
||||
this.onSpeechStartCallback = callback;
|
||||
}
|
||||
|
||||
async waitForTranscript(timeoutMs = 30000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
|
||||
@@ -135,6 +135,17 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
this.callStreamMap.delete(callSid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear TTS queue for a call (barge-in).
|
||||
* Used when user starts speaking to interrupt current TTS playback.
|
||||
*/
|
||||
clearTtsQueue(callSid: string): void {
|
||||
const streamSid = this.callStreamMap.get(callSid);
|
||||
if (streamSid && this.mediaStreamHandler) {
|
||||
this.mediaStreamHandler.clearTtsQueue(streamSid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to the Twilio API.
|
||||
*/
|
||||
@@ -504,7 +515,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
/**
|
||||
* Play TTS via core TTS and Twilio Media Streams.
|
||||
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
|
||||
* Uses a jitter buffer to smooth out timing variations.
|
||||
* Uses a queue to serialize playback and prevent overlapping audio.
|
||||
*/
|
||||
private async playTtsViaStream(
|
||||
text: string,
|
||||
@@ -514,22 +525,29 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
throw new Error("TTS provider and media stream handler required");
|
||||
}
|
||||
|
||||
// Generate audio with core TTS (returns mu-law at 8kHz)
|
||||
const muLawAudio = await this.ttsProvider.synthesizeForTelephony(text);
|
||||
|
||||
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
|
||||
const CHUNK_SIZE = 160;
|
||||
const CHUNK_DELAY_MS = 20;
|
||||
|
||||
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
|
||||
this.mediaStreamHandler.sendAudio(streamSid, chunk);
|
||||
const handler = this.mediaStreamHandler;
|
||||
const ttsProvider = this.ttsProvider;
|
||||
await handler.queueTts(streamSid, async (signal) => {
|
||||
// Generate audio with core TTS (returns mu-law at 8kHz)
|
||||
const muLawAudio = await ttsProvider.synthesizeForTelephony(text);
|
||||
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
|
||||
if (signal.aborted) break;
|
||||
handler.sendAudio(streamSid, chunk);
|
||||
|
||||
// Pace the audio to match real-time playback
|
||||
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
|
||||
}
|
||||
// Pace the audio to match real-time playback
|
||||
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
|
||||
if (signal.aborted) break;
|
||||
}
|
||||
|
||||
// Send a mark to track when audio finishes
|
||||
this.mediaStreamHandler.sendMark(streamSid, `tts-${Date.now()}`);
|
||||
if (!signal.aborted) {
|
||||
// Send a mark to track when audio finishes
|
||||
handler.sendMark(streamSid, `tts-${Date.now()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,4 +27,3 @@ export function verifyTwilioProviderWebhook(params: {
|
||||
reason: result.reason,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,11 @@ export class VoiceCallWebhookServer {
|
||||
`[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
|
||||
const call = this.manager.getCallByProviderCallId(providerCallId);
|
||||
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) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"dist/signal/**",
|
||||
"dist/slack/**",
|
||||
"dist/telegram/**",
|
||||
"dist/line/**",
|
||||
"dist/tui/**",
|
||||
"dist/tts/**",
|
||||
"dist/web/**",
|
||||
@@ -154,6 +155,7 @@
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
"@homebridge/ciao": "^1.3.4",
|
||||
"@line/bot-sdk": "^10.6.0",
|
||||
"@lydell/node-pty": "1.2.0-beta.3",
|
||||
"@mariozechner/pi-agent-core": "0.49.3",
|
||||
"@mariozechner/pi-ai": "0.49.3",
|
||||
|
||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@@ -34,6 +34,9 @@ importers:
|
||||
'@homebridge/ciao':
|
||||
specifier: ^1.3.4
|
||||
version: 1.3.4
|
||||
'@line/bot-sdk':
|
||||
specifier: ^10.6.0
|
||||
version: 10.6.0
|
||||
'@lydell/node-pty':
|
||||
specifier: 1.2.0-beta.3
|
||||
version: 1.2.0-beta.3
|
||||
@@ -317,6 +320,12 @@ importers:
|
||||
|
||||
extensions/imessage: {}
|
||||
|
||||
extensions/line:
|
||||
devDependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/llm-task: {}
|
||||
|
||||
extensions/lobster: {}
|
||||
@@ -1260,6 +1269,10 @@ packages:
|
||||
peerDependencies:
|
||||
apache-arrow: '>=15.0.0 <=18.1.0'
|
||||
|
||||
'@line/bot-sdk@10.6.0':
|
||||
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@lit-labs/signals@0.2.0':
|
||||
resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==}
|
||||
|
||||
@@ -2647,6 +2660,9 @@ packages:
|
||||
'@types/node@20.19.30':
|
||||
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
||||
|
||||
'@types/node@24.10.9':
|
||||
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
|
||||
|
||||
'@types/node@25.0.10':
|
||||
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
|
||||
|
||||
@@ -6721,6 +6737,14 @@ snapshots:
|
||||
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
||||
'@lancedb/lancedb-win32-x64-msvc': 0.23.0
|
||||
|
||||
'@line/bot-sdk@10.6.0':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
optionalDependencies:
|
||||
axios: 1.13.2(debug@4.4.3)
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
'@lit-labs/signals@0.2.0':
|
||||
dependencies:
|
||||
lit: 3.3.2
|
||||
@@ -8298,6 +8322,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@24.10.9':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
'@types/node@25.0.10':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
@@ -124,7 +124,7 @@ EOF
|
||||
# Function to list categories
|
||||
list_categories() {
|
||||
echo -e "${BLUE}Fetching VibeTunnel log categories from the last hour...${NC}\n"
|
||||
|
||||
|
||||
# Get unique categories from recent logs
|
||||
log show --predicate "subsystem == \"$SUBSYSTEM\"" --last 1h 2>/dev/null | \
|
||||
grep -E "category: \"[^\"]+\"" | \
|
||||
@@ -133,7 +133,7 @@ list_categories() {
|
||||
while read -r cat; do
|
||||
echo " • $cat"
|
||||
done
|
||||
|
||||
|
||||
echo -e "\n${YELLOW}Note: Only categories with recent activity are shown${NC}"
|
||||
}
|
||||
|
||||
@@ -230,29 +230,29 @@ fi
|
||||
if [[ "$STREAM_MODE" == true ]]; then
|
||||
# Streaming mode
|
||||
CMD="sudo log stream --predicate '$PREDICATE' --level $LOG_LEVEL --info"
|
||||
|
||||
|
||||
echo -e "${GREEN}Streaming VibeTunnel logs continuously...${NC}"
|
||||
echo -e "${YELLOW}Press Ctrl+C to stop${NC}\n"
|
||||
else
|
||||
# Show mode
|
||||
CMD="sudo log show --predicate '$PREDICATE'"
|
||||
|
||||
|
||||
# Add log level for show command
|
||||
if [[ "$LOG_LEVEL" == "debug" ]]; then
|
||||
CMD="$CMD --debug"
|
||||
else
|
||||
CMD="$CMD --info"
|
||||
fi
|
||||
|
||||
|
||||
# Add time range
|
||||
CMD="$CMD --last $TIME_RANGE"
|
||||
|
||||
|
||||
if [[ "$SHOW_TAIL" == true ]]; then
|
||||
echo -e "${GREEN}Showing last $TAIL_LINES log lines from the past $TIME_RANGE${NC}"
|
||||
else
|
||||
echo -e "${GREEN}Showing all logs from the past $TIME_RANGE${NC}"
|
||||
fi
|
||||
|
||||
|
||||
# Show applied filters
|
||||
if [[ "$ERRORS_ONLY" == true ]]; then
|
||||
echo -e "${RED}Filter: Errors only${NC}"
|
||||
@@ -277,14 +277,14 @@ if [[ -n "$OUTPUT_FILE" ]]; then
|
||||
if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then
|
||||
handle_sudo_error
|
||||
fi
|
||||
|
||||
|
||||
echo -e "${BLUE}Exporting logs to: $OUTPUT_FILE${NC}\n"
|
||||
if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then
|
||||
eval "$CMD" 2>&1 | tail -n "$TAIL_LINES" > "$OUTPUT_FILE"
|
||||
else
|
||||
eval "$CMD" > "$OUTPUT_FILE" 2>&1
|
||||
fi
|
||||
|
||||
|
||||
# Check if file was created and has content
|
||||
if [[ -s "$OUTPUT_FILE" ]]; then
|
||||
LINE_COUNT=$(wc -l < "$OUTPUT_FILE" | tr -d ' ')
|
||||
@@ -298,7 +298,7 @@ else
|
||||
if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then
|
||||
handle_sudo_error
|
||||
fi
|
||||
|
||||
|
||||
if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then
|
||||
# Apply tail for non-streaming mode
|
||||
eval "$CMD" 2>&1 | tail -n "$TAIL_LINES"
|
||||
|
||||
@@ -102,12 +102,12 @@ ws.send(
|
||||
);
|
||||
const connectRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"c1\");
|
||||
if (!connectRes.ok) throw new Error(\"connect failed: \" + (connectRes.error?.message ?? \"unknown\"));
|
||||
|
||||
|
||||
ws.send(JSON.stringify({ type: \"req\", id: \"h1\", method: \"health\" }));
|
||||
const healthRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"h1\", 10000);
|
||||
if (!healthRes.ok) throw new Error(\"health failed: \" + (healthRes.error?.message ?? \"unknown\"));
|
||||
if (healthRes.payload?.ok !== true) throw new Error(\"unexpected health payload\");
|
||||
|
||||
|
||||
ws.close();
|
||||
console.log(\"ok\");
|
||||
NODE"
|
||||
|
||||
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 localWorkers = Math.max(4, Math.min(16, os.cpus().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.
|
||||
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : perRunWorkers);
|
||||
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : macCiWorkers);
|
||||
|
||||
const WARNING_SUPPRESSION_FLAGS = [
|
||||
"--disable-warning=ExperimentalWarning",
|
||||
|
||||
@@ -30,4 +30,3 @@ export type Entry = {
|
||||
avatar_url: string;
|
||||
lines: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ curl http://127.0.0.1:8000/places/{place_id}
|
||||
"open_now": true
|
||||
}
|
||||
],
|
||||
"next_page_token": "..."
|
||||
"next_page_token": "..."
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -157,4 +157,44 @@ describe("agents_list", () => {
|
||||
const research = agents?.find((agent) => agent.id === "research");
|
||||
expect(research?.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("uses requesterAgentIdOverride when resolving allowlists", async () => {
|
||||
configOverride = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "cron-owner",
|
||||
subagents: {
|
||||
allowAgents: ["research"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
name: "Research",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const tool = createClawdbotTools({
|
||||
agentSessionKey: "cron:job-1",
|
||||
requesterAgentIdOverride: "cron-owner",
|
||||
}).find((candidate) => candidate.name === "agents_list");
|
||||
if (!tool) throw new Error("missing agents_list tool");
|
||||
|
||||
const result = await tool.execute("call5", {});
|
||||
const agents = (
|
||||
result.details as {
|
||||
agents?: Array<{ id: string }>;
|
||||
}
|
||||
).agents;
|
||||
expect(agents?.map((agent) => agent.id)).toEqual(["cron-owner", "research"]);
|
||||
expect(result.details).toMatchObject({
|
||||
requester: "cron-owner",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,52 @@ describe("clawdbot-tools: subagents", () => {
|
||||
});
|
||||
expect(childSessionKey?.startsWith("agent:research:subagent:")).toBe(true);
|
||||
});
|
||||
|
||||
it("sessions_spawn honors requesterAgentIdOverride for cron sessions", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
configOverride = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "cron-owner",
|
||||
subagents: {
|
||||
allowAgents: ["research"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: unknown };
|
||||
if (request.method === "agent") {
|
||||
return { runId: "run-2", status: "accepted", acceptedAt: 5200 };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools({
|
||||
agentSessionKey: "cron:job-1",
|
||||
requesterAgentIdOverride: "cron-owner",
|
||||
agentChannel: "whatsapp",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) throw new Error("missing sessions_spawn tool");
|
||||
|
||||
const result = await tool.execute("call11", {
|
||||
task: "do thing",
|
||||
agentId: "research",
|
||||
});
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
status: "accepted",
|
||||
runId: "run-2",
|
||||
});
|
||||
});
|
||||
it("sessions_spawn forbids cross-agent spawning when not allowed", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
|
||||
@@ -54,6 +54,8 @@ export function createClawdbotTools(options?: {
|
||||
hasRepliedRef?: { value: boolean };
|
||||
/** If true, the model has native vision capability */
|
||||
modelHasVision?: boolean;
|
||||
/** Explicit agent ID override for cron/hook sessions. */
|
||||
requesterAgentIdOverride?: string;
|
||||
}): AnyAgentTool[] {
|
||||
const imageTool = options?.agentDir?.trim()
|
||||
? createImageTool({
|
||||
@@ -105,7 +107,10 @@ export function createClawdbotTools(options?: {
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
}),
|
||||
createAgentsListTool({ agentSessionKey: options?.agentSessionKey }),
|
||||
createAgentsListTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
requesterAgentIdOverride: options?.requesterAgentIdOverride,
|
||||
}),
|
||||
createSessionsListTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
sandboxed: options?.sandboxed,
|
||||
@@ -129,6 +134,7 @@ export function createClawdbotTools(options?: {
|
||||
agentGroupChannel: options?.agentGroupChannel,
|
||||
agentGroupSpace: options?.agentGroupSpace,
|
||||
sandboxed: options?.sandboxed,
|
||||
requesterAgentIdOverride: options?.requesterAgentIdOverride,
|
||||
}),
|
||||
createSessionStatusTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
@@ -144,10 +150,12 @@ export function createClawdbotTools(options?: {
|
||||
config: options?.config,
|
||||
workspaceDir: options?.workspaceDir,
|
||||
agentDir: options?.agentDir,
|
||||
agentId: resolveSessionAgentId({
|
||||
sessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
}),
|
||||
agentId:
|
||||
options?.requesterAgentIdOverride ??
|
||||
resolveSessionAgentId({
|
||||
sessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
}),
|
||||
sessionKey: options?.agentSessionKey,
|
||||
messageChannel: options?.agentChannel,
|
||||
agentAccountId: options?.agentAccountId,
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ModelCatalogEntry = {
|
||||
provider: string;
|
||||
contextWindow?: number;
|
||||
reasoning?: boolean;
|
||||
input?: Array<"text" | "image">;
|
||||
};
|
||||
|
||||
type DiscoveredModel = {
|
||||
@@ -16,6 +17,7 @@ type DiscoveredModel = {
|
||||
provider: string;
|
||||
contextWindow?: number;
|
||||
reasoning?: boolean;
|
||||
input?: Array<"text" | "image">;
|
||||
};
|
||||
|
||||
type PiSdkModule = typeof import("@mariozechner/pi-coding-agent");
|
||||
@@ -80,7 +82,10 @@ export async function loadModelCatalog(params?: {
|
||||
? entry.contextWindow
|
||||
: undefined;
|
||||
const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined;
|
||||
models.push({ id, name, provider, contextWindow, reasoning });
|
||||
const input = Array.isArray(entry?.input)
|
||||
? (entry.input as Array<"text" | "image">)
|
||||
: undefined;
|
||||
models.push({ id, name, provider, contextWindow, reasoning, input });
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
@@ -105,3 +110,27 @@ export async function loadModelCatalog(params?: {
|
||||
|
||||
return modelCatalogPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model supports image input based on its catalog entry.
|
||||
*/
|
||||
export function modelSupportsVision(entry: ModelCatalogEntry | undefined): boolean {
|
||||
return entry?.input?.includes("image") ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a model in the catalog by provider and model ID.
|
||||
*/
|
||||
export function findModelInCatalog(
|
||||
catalog: ModelCatalogEntry[],
|
||||
provider: string,
|
||||
modelId: string,
|
||||
): ModelCatalogEntry | undefined {
|
||||
const normalizedProvider = provider.toLowerCase().trim();
|
||||
const normalizedModelId = modelId.toLowerCase().trim();
|
||||
return catalog.find(
|
||||
(entry) =>
|
||||
entry.provider.toLowerCase() === normalizedProvider &&
|
||||
entry.id.toLowerCase() === normalizedModelId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,252 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
buildAllowedModelSet,
|
||||
modelKey,
|
||||
parseModelRef,
|
||||
resolveAllowedModelRef,
|
||||
resolveHooksGmailModel,
|
||||
resolveModelRefFromString,
|
||||
resolveConfiguredModelRef,
|
||||
buildModelAliasIndex,
|
||||
normalizeProviderId,
|
||||
modelKey,
|
||||
} from "./model-selection.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
|
||||
const catalog = [
|
||||
{
|
||||
provider: "openai",
|
||||
id: "gpt-4",
|
||||
name: "GPT-4",
|
||||
},
|
||||
];
|
||||
|
||||
describe("buildAllowedModelSet", () => {
|
||||
it("always allows the configured default model", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-4": { alias: "gpt4" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: "claude-cli",
|
||||
defaultModel: "opus-4.5",
|
||||
});
|
||||
|
||||
expect(allowed.allowAny).toBe(false);
|
||||
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
|
||||
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true);
|
||||
});
|
||||
|
||||
it("includes the default model when no allowlist is set", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: {} },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: "claude-cli",
|
||||
defaultModel: "opus-4.5",
|
||||
});
|
||||
|
||||
expect(allowed.allowAny).toBe(true);
|
||||
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
|
||||
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows explicit custom providers from models.providers", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"moonshot/kimi-k2-0905-preview": { alias: "kimi" },
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
moonshot: {
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
apiKey: "x",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "kimi-k2-0905-preview", name: "Kimi" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog: [],
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
});
|
||||
|
||||
expect(allowed.allowAny).toBe(false);
|
||||
expect(allowed.allowedKeys.has(modelKey("moonshot", "kimi-k2-0905-preview"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseModelRef", () => {
|
||||
it("normalizes anthropic/opus-4.5 to claude-opus-4-5", () => {
|
||||
const ref = parseModelRef("anthropic/opus-4.5", "anthropic");
|
||||
expect(ref).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
describe("model-selection", () => {
|
||||
describe("normalizeProviderId", () => {
|
||||
it("should normalize provider names", () => {
|
||||
expect(normalizeProviderId("Anthropic")).toBe("anthropic");
|
||||
expect(normalizeProviderId("Z.ai")).toBe("zai");
|
||||
expect(normalizeProviderId("z-ai")).toBe("zai");
|
||||
expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode");
|
||||
expect(normalizeProviderId("qwen")).toBe("qwen-portal");
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes google gemini 3 models to preview ids", () => {
|
||||
expect(parseModelRef("google/gemini-3-pro", "anthropic")).toEqual({
|
||||
provider: "google",
|
||||
model: "gemini-3-pro-preview",
|
||||
});
|
||||
expect(parseModelRef("google/gemini-3-flash", "anthropic")).toEqual({
|
||||
provider: "google",
|
||||
model: "gemini-3-flash-preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes default-provider google models", () => {
|
||||
expect(parseModelRef("gemini-3-pro", "google")).toEqual({
|
||||
provider: "google",
|
||||
model: "gemini-3-pro-preview",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHooksGmailModel", () => {
|
||||
it("returns null when hooks.gmail.model is not set", () => {
|
||||
const cfg = {} satisfies ClawdbotConfig;
|
||||
const result = resolveHooksGmailModel({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when hooks.gmail.model is empty", () => {
|
||||
const cfg = {
|
||||
hooks: { gmail: { model: "" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
const result = resolveHooksGmailModel({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("parses provider/model from hooks.gmail.model", () => {
|
||||
const cfg = {
|
||||
hooks: { gmail: { model: "openrouter/meta-llama/llama-3.3-70b:free" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
const result = resolveHooksGmailModel({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
provider: "openrouter",
|
||||
model: "meta-llama/llama-3.3-70b:free",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves alias from agent.models", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: { gmail: { model: "Sonnet" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
const result = resolveHooksGmailModel({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses default provider when model omits provider", () => {
|
||||
const cfg = {
|
||||
hooks: { gmail: { model: "claude-haiku-3-5" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
const result = resolveHooksGmailModel({
|
||||
cfg,
|
||||
defaultProvider: "anthropic",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-haiku-3-5",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAllowedModelRef", () => {
|
||||
it("resolves aliases when allowed", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ClawdbotConfig;
|
||||
const resolved = resolveAllowedModelRef({
|
||||
cfg,
|
||||
catalog: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4-1",
|
||||
name: "Sonnet",
|
||||
},
|
||||
],
|
||||
raw: "Sonnet",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
});
|
||||
expect("error" in resolved).toBe(false);
|
||||
if ("ref" in resolved) {
|
||||
expect(resolved.ref).toEqual({
|
||||
describe("parseModelRef", () => {
|
||||
it("should parse full model refs", () => {
|
||||
expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-1",
|
||||
model: "claude-3-5-sonnet",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should use default provider if none specified", () => {
|
||||
expect(parseModelRef("claude-3-5-sonnet", "anthropic")).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-3-5-sonnet",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null for empty strings", () => {
|
||||
expect(parseModelRef("", "anthropic")).toBeNull();
|
||||
expect(parseModelRef(" ", "anthropic")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle invalid slash usage", () => {
|
||||
expect(parseModelRef("/", "anthropic")).toBeNull();
|
||||
expect(parseModelRef("anthropic/", "anthropic")).toBeNull();
|
||||
expect(parseModelRef("/model", "anthropic")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects disallowed models", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-4": { alias: "GPT4" },
|
||||
describe("buildModelAliasIndex", () => {
|
||||
it("should build alias index from config", () => {
|
||||
const cfg: Partial<ClawdbotConfig> = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-3-5-sonnet": { alias: "fast" },
|
||||
"openai/gpt-4o": { alias: "smart" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ClawdbotConfig;
|
||||
const resolved = resolveAllowedModelRef({
|
||||
cfg,
|
||||
catalog: [
|
||||
{ provider: "openai", id: "gpt-4", name: "GPT-4" },
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-1", name: "Sonnet" },
|
||||
],
|
||||
raw: "anthropic/claude-sonnet-4-1",
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-4",
|
||||
};
|
||||
|
||||
const index = buildModelAliasIndex({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
defaultProvider: "anthropic",
|
||||
});
|
||||
|
||||
expect(index.byAlias.get("fast")?.ref).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-3-5-sonnet",
|
||||
});
|
||||
expect(index.byAlias.get("smart")?.ref).toEqual({ provider: "openai", model: "gpt-4o" });
|
||||
expect(index.byKey.get(modelKey("anthropic", "claude-3-5-sonnet"))).toEqual(["fast"]);
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
error: "model not allowed: anthropic/claude-sonnet-4-1",
|
||||
});
|
||||
|
||||
describe("resolveModelRefFromString", () => {
|
||||
it("should resolve from string with alias", () => {
|
||||
const index = {
|
||||
byAlias: new Map([
|
||||
["fast", { alias: "fast", ref: { provider: "anthropic", model: "sonnet" } }],
|
||||
]),
|
||||
byKey: new Map(),
|
||||
};
|
||||
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: "fast",
|
||||
defaultProvider: "openai",
|
||||
aliasIndex: index,
|
||||
});
|
||||
|
||||
expect(resolved?.ref).toEqual({ provider: "anthropic", model: "sonnet" });
|
||||
expect(resolved?.alias).toBe("fast");
|
||||
});
|
||||
|
||||
it("should resolve direct ref if no alias match", () => {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: "openai/gpt-4",
|
||||
defaultProvider: "anthropic",
|
||||
});
|
||||
expect(resolved?.ref).toEqual({ provider: "openai", model: "gpt-4" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConfiguredModelRef", () => {
|
||||
it("should fall back to anthropic and warn if provider is missing for non-alias", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const cfg: Partial<ClawdbotConfig> = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "claude-3-5-sonnet",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolveConfiguredModelRef({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
defaultProvider: "google",
|
||||
defaultModel: "gemini-pro",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ provider: "anthropic", model: "claude-3-5-sonnet" });
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Falling back to "anthropic/claude-3-5-sonnet"'),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should use default provider/model if config is empty", () => {
|
||||
const cfg: Partial<ClawdbotConfig> = {};
|
||||
const result = resolveConfiguredModelRef({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-4",
|
||||
});
|
||||
expect(result).toEqual({ provider: "openai", model: "gpt-4" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user