Compare commits

..

2 Commits

Author SHA1 Message Date
Vignesh Natarajan
669804271e Agents: add memory flush append regression 2026-03-28 16:37:01 -07:00
HPluseven
f7c1db5445 Agents: forward memory flush append guard 2026-03-24 21:12:31 +08:00
1301 changed files with 36240 additions and 58950 deletions

View File

@@ -23,16 +23,6 @@ runs:
exit 0
fi
if ! [[ "$BASE_SHA" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
echo "::error title=ensure-base-commit invalid base sha::Refusing invalid base SHA: $BASE_SHA"
exit 2
fi
if ! git check-ref-format --branch "$FETCH_REF" >/dev/null 2>&1; then
echo "::error title=ensure-base-commit invalid fetch ref::Refusing invalid fetch ref: $FETCH_REF"
exit 2
fi
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
echo "Base commit already present: $BASE_SHA"
exit 0
@@ -40,7 +30,7 @@ runs:
for deepen_by in 25 100 300; do
echo "Base commit missing; deepening $FETCH_REF by $deepen_by."
if ! git fetch --no-tags --deepen="$deepen_by" origin -- "$FETCH_REF"; then
if ! git fetch --no-tags --deepen="$deepen_by" origin "$FETCH_REF"; then
echo "::warning title=ensure-base-commit fetch failed::Failed to deepen $FETCH_REF by $deepen_by while looking for $BASE_SHA"
fi
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
@@ -50,7 +40,7 @@ runs:
done
echo "Base commit still missing; fetching full history for $FETCH_REF."
if ! git fetch --no-tags origin -- "$FETCH_REF"; then
if ! git fetch --no-tags origin "$FETCH_REF"; then
echo "::warning title=ensure-base-commit fetch failed::Failed to fetch full history for $FETCH_REF while looking for $BASE_SHA"
fi
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then

View File

@@ -11,10 +11,6 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
permissions: {}
jobs:

View File

@@ -5,8 +5,8 @@ on:
branches: [main]
concurrency:
group: ci-bun-push-${{ github.run_id }}
cancel-in-progress: false
group: ci-bun-push-${{ github.ref_name }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -6,24 +6,22 @@ on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || format('ci-push-{0}', github.ref_name) }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
# Scope: establish the fast global truth for this revision before the
# expensive platform and platform-specific lanes fan out.
# Preflight: establish the fast global truth for this revision before the
# expensive platform and test lanes fan out.
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
# Keep this job focused on routing decisions so the rest of CI can fan out sooner.
# Run scope detection, changed-extension detection, and fast security checks in
# one visible job so operators have a single preflight box to inspect and rerun.
# Fail-safe: if detection steps are skipped, downstream outputs fall back to
# conservative defaults that keep heavy lanes enabled.
scope:
preflight:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
@@ -43,7 +41,6 @@ jobs:
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Ensure preflight base commit
@@ -104,46 +101,6 @@ jobs:
appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
EOF
# Run the fast security/SCM checks in parallel with scope detection so the
# main Node jobs do not have to wait for Python/pre-commit setup.
security-fast:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
env:
PRE_COMMIT_CACHE_KEY_SUFFIX: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Ensure security base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Prepare trusted pre-commit config
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config"
echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "false"
use-sticky-disk: "false"
- name: Setup Python
id: setup-python
uses: actions/setup-python@v6
@@ -159,17 +116,15 @@ jobs:
uses: actions/cache@v5
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}-${{ env.PRE_COMMIT_CACHE_KEY_SUFFIX }}
restore-keys: |
pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}-
key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit
run: |
python -m pip install --upgrade pip
python -m pip install pre-commit==4.2.0
python -m pip install pre-commit
- name: Detect committed private keys
run: pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files detect-private-key
run: pre-commit run --all-files detect-private-key
- name: Audit changed GitHub workflows with zizmor
env:
@@ -196,24 +151,24 @@ jobs:
fi
printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}"
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
pre-commit run zizmor --files "${workflow_files[@]}"
- name: Audit production dependencies
run: pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files pnpm-audit-prod
if: steps.docs_scope.outputs.docs_only != 'true'
run: pre-commit run --all-files pnpm-audit-prod
# Fanout: downstream lanes branch from preflight outputs instead of waiting
# on unrelated Linux checks.
# Build dist once for Node-relevant changes and share it with downstream jobs.
build-artifacts:
needs: [scope]
if: needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_node == 'true'
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Ensure secrets base commit (PR fast path)
@@ -252,15 +207,14 @@ jobs:
# Validate npm pack contents after build (only on push to main, not PRs).
release-check:
needs: [scope, build-artifacts]
if: github.event_name == 'push' && needs.scope.outputs.docs_only != 'true'
needs: [preflight, build-artifacts]
if: github.event_name == 'push' && needs.preflight.outputs.docs_only != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -278,73 +232,36 @@ jobs:
- name: Check release contents
run: pnpm release:check
checks-fast:
needs: [scope]
if: always() && needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_node == 'true'
checks:
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && needs.build-artifacts.result == 'success'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- runtime: node
task: test
shard_index: 1
shard_count: 2
command: pnpm test
- runtime: node
task: test
shard_index: 2
shard_count: 2
command: pnpm test
- runtime: node
task: extensions
command: pnpm test:extensions
- runtime: node
task: contracts-protocol
command: |
pnpm test:contracts
pnpm protocol:check
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
checks:
needs: [scope, build-artifacts]
if: always() && needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_node == 'true' && needs.build-artifacts.result == 'success'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- runtime: node
task: test
shard_index: 1
shard_count: 4
command: pnpm test
- runtime: node
task: test
shard_index: 2
shard_count: 4
command: pnpm test
- runtime: node
task: test
shard_index: 3
shard_count: 4
command: pnpm test
- runtime: node
task: test
shard_index: 4
shard_count: 4
command: pnpm test
- runtime: node
task: channels
shard_index: 1
shard_count: 3
command: pnpm test:channels
- runtime: node
task: contracts
command: pnpm test:contracts
- runtime: node
task: channels
shard_index: 2
@@ -355,6 +272,9 @@ jobs:
shard_index: 3
shard_count: 3
command: pnpm test:channels
- runtime: node
task: protocol
command: pnpm protocol:check
- runtime: node
task: compat-node22
node_version: "22.x"
@@ -376,7 +296,6 @@ jobs:
if: github.event_name != 'pull_request' || matrix.task != 'compat-node22'
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -427,18 +346,17 @@ jobs:
extension-fast:
name: "extension-fast"
needs: [scope]
if: needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_node == 'true' && needs.scope.outputs.has_changed_extensions == 'true'
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && needs.preflight.outputs.has_changed_extensions == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.scope.outputs.changed_extensions_matrix) }}
matrix: ${{ fromJson(needs.preflight.outputs.changed_extensions_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -455,15 +373,14 @@ jobs:
# Types, lint, and format check.
check:
name: "check"
needs: [scope]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && needs.scope.outputs.docs_only != 'true'
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -480,15 +397,14 @@ jobs:
check-additional:
name: "check-additional"
needs: [scope]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && needs.scope.outputs.docs_only != 'true'
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -579,15 +495,14 @@ jobs:
build-smoke:
name: "build-smoke"
needs: [scope, build-artifacts]
if: always() && needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -621,15 +536,14 @@ jobs:
# Validate docs (format, lint, broken links) only when docs files changed.
check-docs:
needs: [scope]
if: needs.scope.outputs.docs_changed == 'true'
needs: [preflight]
if: needs.preflight.outputs.docs_changed == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -642,15 +556,14 @@ jobs:
run: pnpm check:docs
skills-python:
needs: [scope]
if: needs.scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.scope.outputs.run_skills_python == 'true')
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.preflight.outputs.run_skills_python == 'true')
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Python
@@ -670,8 +583,8 @@ jobs:
run: python -m pytest -q skills
checks-windows:
needs: [scope, build-artifacts]
if: always() && needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_windows == 'true' && needs.build-artifacts.result == 'success'
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_windows == 'true' && needs.build-artifacts.result == 'success'
runs-on: blacksmith-32vcpu-windows-2025
timeout-minutes: 20
env:
@@ -689,53 +602,47 @@ jobs:
- runtime: node
task: test
shard_index: 1
shard_count: 9
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 2
shard_count: 9
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 3
shard_count: 9
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 4
shard_count: 9
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 5
shard_count: 9
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 6
shard_count: 9
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 7
shard_count: 9
shard_count: 8
command: pnpm test
- runtime: node
task: test
shard_index: 8
shard_count: 9
command: pnpm test
- runtime: node
task: test
shard_index: 9
shard_count: 9
shard_count: 8
command: pnpm test
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Try to exclude workspace from Windows Defender (best-effort)
@@ -825,15 +732,14 @@ jobs:
# running 4 separate jobs per PR (as before) starved the queue. One job
# per PR allows 5 PRs to run macOS checks simultaneously.
macos:
needs: [scope]
if: github.event_name == 'pull_request' && needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_macos == 'true'
needs: [preflight]
if: github.event_name == 'pull_request' && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_macos == 'true'
runs-on: macos-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Node environment
@@ -903,8 +809,8 @@ jobs:
exit 1
android:
needs: [scope]
if: needs.scope.outputs.docs_only != 'true' && needs.scope.outputs.run_android == 'true'
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_android == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
@@ -923,7 +829,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Java
@@ -953,7 +858,7 @@ jobs:
echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5
uses: gradle/actions/setup-gradle@v5
with:
gradle-version: 8.11.1

View File

@@ -20,7 +20,7 @@ on:
type: string
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && format('docker-release-manual-{0}', inputs.tag) || format('docker-release-push-{0}', github.run_id) }}
group: docker-release-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
cancel-in-progress: false
env:

View File

@@ -8,7 +8,7 @@ on:
workflow_dispatch:
concurrency:
group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }}
group: ${{ github.event_name == 'pull_request' && format('install-smoke-pr-{0}', github.event.pull_request.number) || format('install-smoke-push-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:

View File

@@ -19,10 +19,6 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
permissions: {}
jobs:

View File

@@ -15,7 +15,7 @@ on:
- scripts/sandbox-common-setup.sh
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:

View File

@@ -7,7 +7,7 @@ on:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:

View File

@@ -8,72 +8,21 @@ Docs: https://docs.openclaw.ai
### Changes
### Fixes
## 2026.3.24-beta.1
### Breaking
### Changes
- Gateway/OpenAI compatibility: add `/v1/models` and `/v1/embeddings`, and forward explicit model overrides through `/v1/chat/completions` and `/v1/responses` for broader client and RAG compatibility. Thanks @vincentkoc.
- Agents/tools: make `/tools` show the tools the current agent can actually use right now, add a compact default view with an optional detailed mode, and add a live “Available Right Now” section in the Control UI so it is easier to see what will work before you ask.
- Microsoft Teams: migrate to the official Teams SDK and add AI-agent UX best practices including streaming 1:1 replies, welcome cards with prompt starters, feedback/reflection, informative status updates, typing indicators, and native AI labeling. (#51808)
- Microsoft Teams: add message edit and delete support for sent messages, including in-thread fallbacks when no explicit target is provided. (#49925)
- Skills/install metadata: add one-click install recipes to bundled skills (coding-agent, gh-issues, openai-whisper-api, session-logs, tmux, trello, weather) so the CLI and Control UI can offer dependency installation when requirements are missing. (#53411) Thanks @BunsDev.
- Control UI/skills: add status-filter tabs (All / Ready / Needs Setup / Disabled) with counts, replace inline skill cards with a click-to-detail dialog showing requirements, toggle switch, install action, API key entry, source metadata, and homepage link. (#53411) Thanks @BunsDev.
- Slack/interactive replies: restore rich reply parity for direct deliveries, auto-render simple trailing `Options:` lines as buttons/selects, improve Slack interactive setup defaults, and isolate reply controls from plugin interactive handlers. (#53389) Thanks @vincentkoc.
- CLI/containers: add `--container` and `OPENCLAW_CONTAINER` to run `openclaw` commands inside a running Docker or Podman OpenClaw container. (#52651) Thanks @sallyom.
- Discord/auto threads: add optional `autoThreadName: "generated"` naming so new auto-created threads can be renamed asynchronously with concise LLM-generated titles while keeping the existing message-based naming as the default. (#43366) Thanks @davidguttman.
- Plugins/hooks: add `before_dispatch` with canonical inbound metadata and route handled replies through the normal final-delivery path, preserving TTS and routed delivery semantics. (#50444) Thanks @gfzhx.
- Control UI/agents: convert agent workspace file rows to expandable `<details>` with lazy-loaded inline markdown preview, and add comprehensive `.sidebar-markdown` styles for headings, lists, code blocks, tables, blockquotes, and details/summary elements. (#53411) Thanks @BunsDev.
- Control UI/markdown preview: restyle the agent workspace file preview dialog with a frosted backdrop, sized panel, and styled header, and integrate `@create-markdown/preview` v2 system theme for rich markdown rendering (headings, tables, code blocks, callouts, blockquotes) that auto-adapts to the app's light/dark design tokens. (#53411) Thanks @BunsDev.
- macOS app/config: replace horizontal pill-based subsection navigation with a collapsible tree sidebar using disclosure chevrons and indented subsection rows. (#53411) Thanks @BunsDev.
- Skills/install metadata: add one-click install recipes to bundled skills (coding-agent, gh-issues, openai-whisper-api, session-logs, tmux, trello, weather) so the CLI and Control UI can offer dependency installation when requirements are missing. (#53411) Thanks @BunsDev.
- CLI/skills: soften missing-requirements label from "missing" to "needs setup" and surface API key setup guidance (where to get a key, CLI save command, storage path) in `openclaw skills info` output. (#53411) Thanks @BunsDev.
- macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev.
- Control UI/skills: add status-filter tabs (All / Ready / Needs Setup / Disabled) with counts, replace inline skill cards with a click-to-detail dialog showing requirements, toggle switch, install action, API key entry, source metadata, and homepage link. (#53411) Thanks @BunsDev.
- Control UI/agents: convert agent workspace file rows to expandable `<details>` with lazy-loaded inline markdown preview, and add comprehensive `.sidebar-markdown` styles for headings, lists, code blocks, tables, blockquotes, and details/summary elements. (#53411) Thanks @BunsDev.
- Control UI/agents: add a "Not set" placeholder to the default agent model selector dropdown. (#53411) Thanks @BunsDev.
- macOS app/config: replace horizontal pill-based subsection navigation with a collapsible tree sidebar using disclosure chevrons and indented subsection rows. (#53411) Thanks @BunsDev.
- macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev.
### Fixes
- Security/sandbox media dispatch: close the `mediaUrl`/`fileUrl` alias bypass so outbound tool and message actions cannot escape media-root restrictions. (#54034)
- Gateway/restart sentinel: wake the interrupted agent session via heartbeat after restart instead of only sending a best-effort restart note, retry outbound delivery once on transient failure, and preserve explicit thread/topic routing through the wake path so replies land in the correct Telegram topic or Slack thread. (#53940) Thanks @VACInc.
- Docker/setup: avoid the pre-start `openclaw-cli` shared-network namespace loop by routing setup-time onboard/config writes through `openclaw-gateway`, so fresh Docker installs stop failing before the gateway comes up. (#53385) Thanks @amsminn.
- Gateway/channels: keep channel startup sequential while isolating per-channel boot failures, so one broken channel no longer blocks later channels from starting. (#54215) Thanks @JonathanJing.
- Embedded runs/secrets: stop unresolved `SecretRef` config from crashing embedded agent runs by falling back to the resolved runtime snapshot when needed. Fixes #45838.
- WhatsApp/groups: track recent gateway-sent message IDs and suppress only matching group echoes, preserving owner `/status`, `/new`, and `/activation` commands from linked-account `fromMe` traffic. (#53624) Thanks @w-sss.
- WhatsApp/reply-to-bot detection: restore implicit group reply detection by unwrapping `botInvokeMessage` payloads and reading `selfLid` from `creds.json`, so reply-based mentions reach the bot again in linked-account group chats.
- Telegram/forum topics: recover `#General` topic `1` routing when Telegram omits forum metadata, including native commands, interactive callbacks, inbound message context, and fallback error replies. (#53699) thanks @huntharo
- Discord/gateway supervision: centralize gateway error handling behind a lifetime-owned supervisor so early, active, and late-teardown Carbon gateway errors stay classified consistently and stop surfacing as process-killing teardown crashes.
- Discord/timeouts: send a visible timeout reply when the inbound Discord worker times out before a final reply starts, including created auto-thread targets and queued-run ordering. (#53823) Thanks @Kimbo7870.
- ACP/direct chats: always deliver a terminal ACP result when final TTS does not yield audio, even if block text already streamed earlier, and skip redundant empty-text final synthesis. (#53692) Thanks @w-sss.
- Telegram/outbound errors: preserve actionable 403 membership/block/kick details and treat `bot not a member` as a permanent delivery failure so Telegram sends stop retrying doomed chats. (#53635) Thanks @w-sss.
- Telegram/photos: preflight Telegram photo dimension and aspect-ratio rules, and fall back to document sends when image metadata is invalid or unavailable so photo uploads stop failing with `PHOTO_INVALID_DIMENSIONS`. (#52545) Thanks @hnshah.
- Slack/runtime defaults: trim Slack DM reply overhead, restore Codex auto transport, and tighten Slack/web-search runtime defaults around DM preview threading, cache scoping, warning dedupe, and explicit web-search opt-in. (#53957) Thanks @vincentkoc.
- Doctor/image generation: seed migrated legacy Nano Banana Google provider config with the `/v1beta` API root and an empty model list so `openclaw doctor --fix` completes and the migrated native Google image path keeps hitting the correct endpoint. (#53757) Thanks @mahopan.
- Models/google: normalize bare Google Generative AI API roots for custom provider names, and keep built-in Google model-id rewrites working when `api` is declared only on individual models, so custom Google lanes and older configs stop missing `/v1beta` or preview-id normalization. (#44969) Thanks @Kathie-yu.
- Feishu/startup: treat unresolved `SecretRef` app credentials as not configured during account resolution so CLI startup and read-only Feishu config surfaces stop crashing before runtime-backed secret resolution is available. (#53675) Thanks @hpt.
- Feishu/groups: when `groupPolicy` is `open`, stop implicitly requiring @mentions for unset `requireMention`, so image, file, audio, and other non-text group messages reach the bot unless operators explicitly keep mention gating on. (#54058) Thanks @byungsker.
- Feishu/startup: keep `requireMention` enforcement strict when bot identity startup probes fail, raise the startup bot-info timeout to 30s, and add cancellable background identity recovery so mention-gated groups recover without noisy fallback. (#43788) Thanks @lefarcen.
- Feishu/MSTeams message tool: keep provider-native `card` payloads optional in merged tool schemas so media-only sends stop failing validation before channel runtime dispatch. (#53715) Thanks @lndyzwdxhs.
- Feishu/docx block ordering: preserve the document tree order from `docx.document.convert` when inserting blocks, fixing heading/paragraph/list misordering in newly written Feishu documents. (#40524) Thanks @TaoXieSZ.
- Telegram/native commands: run native slash-command execution against the resolved runtime snapshot so DM commands still reply when fresh config reads surface unresolved SecretRefs. (#53179) Thanks @nimbleenigma.
- Gateway/ports: parse Docker Compose-style `OPENCLAW_GATEWAY_PORT` host publish values correctly without reviving the legacy `CLAWDBOT_GATEWAY_PORT` override. (#44083) Thanks @bebule.
- Plugins/memory-lancedb: bootstrap the env-configured HTTP/HTTPS proxy dispatcher before OpenAI embeddings requests so memory capture and recall work in proxy-required environments again. (#54119) Thanks @neeravmakwana.
- Runtime/build: stabilize long-lived lazy `dist` runtime entry paths and harden bundled plugin npm staging so local rebuilds stop breaking on missing hashed chunks or broken shell `npm` shims. (#53855) Thanks @vincentkoc.
- Security/skills: validate skill installer metadata against strict regex allowlists per package manager, sanitize skill metadata for terminal output, add URL protocol allowlisting in markdown preview and skill homepage links, warn on non-bundled skill install sources, and remove unsafe `file://` workspace links. (#53471) Thanks @BunsDev.
- Memory/builtin sqlite: cut redundant sync and status query churn by snapshotting file state once per source, reusing sync statements, and consolidating status aggregation reads, which reduces builtin memory overhead on sync/status/doctor-style paths. Thanks @vincentkoc.
- TUI/chat: preserve pending user messages when a slow local run emits an empty final event, but still defer and flush the needed history reload after the newer active run finishes so silent/tool-only runs do not stay incomplete. (#53130) Thanks @joelnishanth.
- DeepSeek/pricing: replace the zero-cost DeepSeek catalog rates with the current DeepSeek V3.2 pricing so usage totals stop showing `$0.00` for DeepSeek sessions. (#54143) Thanks @arkyu2077.
- CLI/logging: make pretty log timestamps always include an explicit timezone offset in default UTC and `--local-time` modes, so incident triage no longer mixes ambiguous clock displays. (#38904) Thanks @sahilsatralkar.
- Browser/default detection: recognize macOS LaunchServices Edge bundle ids so default Chromium detection stops falling back to Chrome when Edge is the system default. (#48561) Thanks @zoherghadyali.
- CLI/Telegram topics: route `message thread create` through Telegram `topic-create` with the required topic `name` field so Telegram forum topic creation works from the CLI again. (#54336) Thanks @andyliu.
- Telegram/pairing: render pairing codes and approval commands as Telegram-only code blocks while keeping shared pairing replies plain text for other channels. (#52784) Thanks @sumukhj1219.
- Feishu/docx block ordering: preserve the document tree order from `docx.document.convert` when inserting blocks, fixing heading/paragraph/list misordering in newly written Feishu documents. (#40524) Thanks @TaoXieSZ.
- Agents/cron: suppress the default heartbeat system prompt for cron-triggered embedded runs even when they target non-cron session keys, so cron tasks stop reading `HEARTBEAT.md` and polluting unrelated threads. (#53152) Thanks @Protocol-zero-0.
- Agents/cron: mark best-effort announce runs as not delivered when any payload fails, and log those partial delivery failures instead of silently reporting success. (#42535) Thanks @MoerAI.
- Plugins: enforce terminal hook decision semantics for tool/message guards (#54241) Thanks @joshavant.
- Marketplace/agents: correct the ClawHub skill URL in agent docs and stream marketplace archive downloads to disk so installs avoid excess memory use and fail cleanly on empty responses. (#54160) Thanks @QuinnH496.
- Discord/config types: add missing `autoArchiveDuration` to `DiscordGuildChannelConfig` so TypeScript config definitions match the existing schema and runtime support. (#43427) Thanks @davidguttman.
- Docs/IRC: fix five `json55` code-fence typos in the IRC channel examples so Mintlify applies JSON5 syntax highlighting correctly. (#50842) Thanks @Hollychou924.
- TUI/chat: preserve pending user messages when a slow local run emits an empty final event, but still defer and flush the needed history reload after the newer active run finishes so silent/tool-only runs do not stay incomplete. (#53130) Thanks @joelnishanth.
## 2026.3.23
@@ -134,8 +83,6 @@ Docs: https://docs.openclaw.ai
- Gateway/supervision: stop lock conflicts from crash-looping under launchd and systemd by keeping the duplicate process in a retry wait instead of exiting as a failure while another healthy gateway still owns the lock. Fixes #52922. Thanks @vincentkoc.
- Gateway/auth: require auth for canvas routes and admin scope for agent session reset, so anonymous canvas access and non-admin reset requests fail closed.
- Release/install: keep previously released bundled plugins and Control UI assets in published openclaw npm installs, and fail release checks when those shipped artifacts are missing. Thanks @vincentkoc.
- WhatsApp/outbound sends: keep the active Web listener on a direct process-global symbol so split runtime chunks keep sharing the connected Baileys session and `openclaw message send --channel whatsapp` stops failing after connect. Fixes #52574. Thanks @MonkeyLeeT.
- Agents/process: fail loud when `send-keys` tries cursor-sensitive keys before a background PTY reports its cursor mode, so startup races no longer silently send the wrong arrow/Home/End sequences. (#51490) Thanks @liuy.
## 2026.3.22
@@ -460,10 +407,6 @@ Docs: https://docs.openclaw.ai
- Slack/embedded delivery: suppress transcript-only `delivery-mirror` assistant messages before embedded re-delivery and raise the default Slack chunk fallback so messages just over 4000 characters stay in a single post. (#45489) Thanks @theo674.
- Slack/embedded delivery: suppress transcript-only `delivery-mirror` assistant messages before embedded re-delivery and raise the default Slack chunk fallback so messages just over 4000 characters stay in a single post. (#45489) Thanks @theo674.
### Fixes
- Agents/edit tool: accept common path/text alias spellings, show current file contents on exact-match failures, and avoid false edit failures after successful writes. (#52516) thanks @mbelinky.
## 2026.3.13
### Changes

View File

@@ -1,7 +1,7 @@
// Shared iOS version defaults.
// Generated overrides live in build/Version.xcconfig (git-ignored).
OPENCLAW_GATEWAY_VERSION = 2026.3.24-beta.1
OPENCLAW_GATEWAY_VERSION = 2026.3.24
OPENCLAW_MARKETING_VERSION = 2026.3.24
OPENCLAW_BUILD_VERSION = 202603240

View File

@@ -46,13 +46,6 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
_ = try await self.gateway.request(method: "sessions.reset", paramsJSON: json, timeoutSeconds: 10)
}
func compactSession(sessionKey: String) async throws {
struct Params: Codable { var key: String }
let data = try JSONEncoder().encode(Params(key: sessionKey))
let json = String(data: data, encoding: .utf8)
_ = try await self.gateway.request(method: "sessions.compact", paramsJSON: json, timeoutSeconds: 10)
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
struct Params: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))

View File

@@ -126,13 +126,6 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
timeoutMs: 10000)
}
func compactSession(sessionKey: String) async throws {
_ = try await GatewayConnection.shared.request(
method: "sessions.compact",
params: ["key": AnyCodable(sessionKey)],
timeoutMs: 10000)
}
func events() -> AsyncStream<OpenClawChatTransportEvent> {
AsyncStream { continuation in
let task = Task {

View File

@@ -2764,110 +2764,6 @@ public struct ToolsCatalogResult: Codable, Sendable {
}
}
public struct ToolsEffectiveParams: Codable, Sendable {
public let agentid: String?
public let sessionkey: String
public init(
agentid: String?,
sessionkey: String)
{
self.agentid = agentid
self.sessionkey = sessionkey
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case sessionkey = "sessionKey"
}
}
public struct ToolsEffectiveEntry: Codable, Sendable {
public let id: String
public let label: String
public let description: String
public let rawdescription: String
public let source: AnyCodable
public let pluginid: String?
public let channelid: String?
public init(
id: String,
label: String,
description: String,
rawdescription: String,
source: AnyCodable,
pluginid: String?,
channelid: String?)
{
self.id = id
self.label = label
self.description = description
self.rawdescription = rawdescription
self.source = source
self.pluginid = pluginid
self.channelid = channelid
}
private enum CodingKeys: String, CodingKey {
case id
case label
case description
case rawdescription = "rawDescription"
case source
case pluginid = "pluginId"
case channelid = "channelId"
}
}
public struct ToolsEffectiveGroup: Codable, Sendable {
public let id: AnyCodable
public let label: String
public let source: AnyCodable
public let tools: [ToolsEffectiveEntry]
public init(
id: AnyCodable,
label: String,
source: AnyCodable,
tools: [ToolsEffectiveEntry])
{
self.id = id
self.label = label
self.source = source
self.tools = tools
}
private enum CodingKeys: String, CodingKey {
case id
case label
case source
case tools
}
}
public struct ToolsEffectiveResult: Codable, Sendable {
public let agentid: String
public let profile: String
public let groups: [ToolsEffectiveGroup]
public init(
agentid: String,
profile: String,
groups: [ToolsEffectiveGroup])
{
self.agentid = agentid
self.profile = profile
self.groups = groups
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case profile
case groups
}
}
public struct SkillsBinsParams: Codable, Sendable {}
public struct SkillsBinsResult: Codable, Sendable {

View File

@@ -196,7 +196,7 @@ struct OpenClawConfigFileTests {
#expect(clobberedPath != nil)
if let clobberedPath {
let preserved = try String(contentsOfFile: clobberedPath, encoding: .utf8)
#expect(preserved == clobbered)
#expect(preserved == "\(clobbered)\n")
}
}
}

View File

@@ -28,7 +28,6 @@ public protocol OpenClawChatTransport: Sendable {
func setActiveSessionKey(_ sessionKey: String) async throws
func resetSession(sessionKey: String) async throws
func compactSession(sessionKey: String) async throws
}
extension OpenClawChatTransport {
@@ -41,13 +40,6 @@ extension OpenClawChatTransport {
userInfo: [NSLocalizedDescriptionKey: "sessions.reset not supported by this transport"])
}
public func compactSession(sessionKey _: String) async throws {
throw NSError(
domain: "OpenClawChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.compact not supported by this transport"])
}
public func abortRun(sessionKey _: String, runId _: String) async throws {
throw NSError(
domain: "OpenClawChatTransport",

View File

@@ -60,9 +60,6 @@ public final class OpenClawChatViewModel {
private var nextThinkingSelectionRequestID: UInt64 = 0
private var latestThinkingSelectionRequestIDsBySession: [String: UInt64] = [:]
private var latestThinkingLevelsBySession: [String: String] = [:]
private var isCompacting = false
private var lastCompactAt: Date?
private let compactCooldown: TimeInterval = 60
private var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] {
didSet {
@@ -468,7 +465,6 @@ public final class OpenClawChatViewModel {
}
private static let resetTriggers: Set<String> = ["/new", "/reset", "/clear"]
private static let compactTriggers: Set<String> = ["/compact"]
private func performSend() async {
guard !self.isSending else { return }
@@ -480,11 +476,6 @@ public final class OpenClawChatViewModel {
await self.performReset()
return
}
if Self.compactTriggers.contains(trimmed.lowercased()) {
self.input = ""
await self.performCompact()
return
}
let sessionKey = self.sessionKey
@@ -632,42 +623,6 @@ public final class OpenClawChatViewModel {
await self.bootstrap()
}
private func performCompact() async {
guard !self.isCompacting else { return }
guard !self.isSending, self.pendingRuns.isEmpty, !self.isAborting else {
self.errorText = "Wait for the current response before compacting the session."
return
}
if let lastCompactAt,
Date().timeIntervalSince(lastCompactAt) < self.compactCooldown
{
self.errorText = "Please wait before compacting this session again."
return
}
self.isCompacting = true
self.isLoading = true
self.errorText = nil
defer {
self.isLoading = false
self.isCompacting = false
}
do {
try await self.transport.compactSession(sessionKey: self.sessionKey)
} catch {
self.errorText = "Unable to compact the session. Please try again."
let nsError = error as NSError
chatUILogger.error(
"session compact failed domain=\(nsError.domain, privacy: .public) code=\(nsError.code, privacy: .public) details=\(String(describing: error), privacy: .private)"
)
return
}
self.lastCompactAt = Date()
await self.bootstrap()
}
private func performSelectThinkingLevel(_ level: String) async {
let next = Self.normalizedThinkingLevel(level) ?? "off"
guard next != self.thinkingLevel else { return }

View File

@@ -2764,110 +2764,6 @@ public struct ToolsCatalogResult: Codable, Sendable {
}
}
public struct ToolsEffectiveParams: Codable, Sendable {
public let agentid: String?
public let sessionkey: String
public init(
agentid: String?,
sessionkey: String)
{
self.agentid = agentid
self.sessionkey = sessionkey
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case sessionkey = "sessionKey"
}
}
public struct ToolsEffectiveEntry: Codable, Sendable {
public let id: String
public let label: String
public let description: String
public let rawdescription: String
public let source: AnyCodable
public let pluginid: String?
public let channelid: String?
public init(
id: String,
label: String,
description: String,
rawdescription: String,
source: AnyCodable,
pluginid: String?,
channelid: String?)
{
self.id = id
self.label = label
self.description = description
self.rawdescription = rawdescription
self.source = source
self.pluginid = pluginid
self.channelid = channelid
}
private enum CodingKeys: String, CodingKey {
case id
case label
case description
case rawdescription = "rawDescription"
case source
case pluginid = "pluginId"
case channelid = "channelId"
}
}
public struct ToolsEffectiveGroup: Codable, Sendable {
public let id: AnyCodable
public let label: String
public let source: AnyCodable
public let tools: [ToolsEffectiveEntry]
public init(
id: AnyCodable,
label: String,
source: AnyCodable,
tools: [ToolsEffectiveEntry])
{
self.id = id
self.label = label
self.source = source
self.tools = tools
}
private enum CodingKeys: String, CodingKey {
case id
case label
case source
case tools
}
}
public struct ToolsEffectiveResult: Codable, Sendable {
public let agentid: String
public let profile: String
public let groups: [ToolsEffectiveGroup]
public init(
agentid: String,
profile: String,
groups: [ToolsEffectiveGroup])
{
self.agentid = agentid
self.profile = profile
self.groups = groups
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case profile
case groups
}
}
public struct SkillsBinsParams: Codable, Sendable {}
public struct SkillsBinsResult: Codable, Sendable {

View File

@@ -84,7 +84,6 @@ private func makeViewModel(
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
resetSessionHook: (@Sendable (String) async throws -> Void)? = nil,
compactSessionHook: (@Sendable (String) async throws -> Void)? = nil,
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
initialThinkingLevel: String? = nil,
@@ -96,7 +95,6 @@ private func makeViewModel(
sessionsResponses: sessionsResponses,
modelResponses: modelResponses,
resetSessionHook: resetSessionHook,
compactSessionHook: compactSessionHook,
setSessionModelHook: setSessionModelHook,
setSessionThinkingHook: setSessionThinkingHook)
let vm = await MainActor.run {
@@ -221,25 +219,11 @@ private actor AsyncGate {
}
}
private actor AsyncCounter {
private var value: Int
init(_ initialValue: Int = 0) {
self.value = initialValue
}
func increment() -> Int {
self.value += 1
return self.value
}
}
private actor TestChatTransportState {
var historyCallCount: Int = 0
var sessionsCallCount: Int = 0
var modelsCallCount: Int = 0
var resetSessionKeys: [String] = []
var compactSessionKeys: [String] = []
var sentRunIds: [String] = []
var sentThinkingLevels: [String] = []
var abortedRunIds: [String] = []
@@ -253,7 +237,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
private let sessionsResponses: [OpenClawChatSessionsListResponse]
private let modelResponses: [[OpenClawChatModelChoice]]
private let resetSessionHook: (@Sendable (String) async throws -> Void)?
private let compactSessionHook: (@Sendable (String) async throws -> Void)?
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
@@ -265,7 +248,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
modelResponses: [[OpenClawChatModelChoice]] = [],
resetSessionHook: (@Sendable (String) async throws -> Void)? = nil,
compactSessionHook: (@Sendable (String) async throws -> Void)? = nil,
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil)
{
@@ -273,7 +255,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
self.sessionsResponses = sessionsResponses
self.modelResponses = modelResponses
self.resetSessionHook = resetSessionHook
self.compactSessionHook = compactSessionHook
self.setSessionModelHook = setSessionModelHook
self.setSessionThinkingHook = setSessionThinkingHook
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
@@ -355,13 +336,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
}
}
func compactSession(sessionKey: String) async throws {
await self.state.compactSessionKeysAppend(sessionKey)
if let compactSessionHook = self.compactSessionHook {
try await compactSessionHook(sessionKey)
}
}
func setSessionThinking(sessionKey _: String, thinkingLevel: String) async throws {
await self.state.patchedThinkingLevelsAppend(thinkingLevel)
if let setSessionThinkingHook = self.setSessionThinkingHook {
@@ -401,10 +375,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
func resetSessionKeys() async -> [String] {
await self.state.resetSessionKeys
}
func compactSessionKeys() async -> [String] {
await self.state.compactSessionKeys
}
}
extension TestChatTransportState {
@@ -443,10 +413,6 @@ extension TestChatTransportState {
fileprivate func resetSessionKeysAppend(_ v: String) {
self.resetSessionKeys.append(v)
}
fileprivate func compactSessionKeysAppend(_ v: String) {
self.compactSessionKeys.append(v)
}
}
@Suite struct ChatViewModelTests {
@@ -949,140 +915,6 @@ extension TestChatTransportState {
#expect(await transport.lastSentRunId() == nil)
}
@Test func compactTriggerCompactsSessionAndReloadsHistory() async throws {
let before = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "before compact", timestamp: 1),
])
let after = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "after compact", timestamp: 2),
])
let (transport, vm) = await makeViewModel(historyResponses: [before, after])
try await loadAndWaitBootstrap(vm: vm)
try await waitUntil("initial history loaded") {
await MainActor.run { vm.messages.first?.content.first?.text == "before compact" }
}
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await waitUntil("compact called") {
await transport.compactSessionKeys() == ["main"]
}
try await waitUntil("history reloaded") {
await MainActor.run { vm.messages.first?.content.first?.text == "after compact" }
}
#expect(await transport.lastSentRunId() == nil)
}
@Test func compactTriggerShowsGenericErrorMessageOnFailure() async throws {
let history = historyPayload()
let (transport, vm) = await makeViewModel(
historyResponses: [history],
compactSessionHook: { _ in
throw NSError(
domain: "TestCompact",
code: 42,
userInfo: [NSLocalizedDescriptionKey: "backend details should not leak"])
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await waitUntil("compact attempted") {
await transport.compactSessionKeys() == ["main"]
}
#expect(await MainActor.run { vm.errorText } == "Unable to compact the session. Please try again.")
}
@Test func compactTriggerIgnoresConcurrentAndImmediateRepeatRequests() async throws {
let before = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "before compact", timestamp: 1),
])
let after = historyPayload(
messages: [
chatTextMessage(role: "assistant", text: "after compact", timestamp: 2),
])
let gate = AsyncGate()
let (transport, vm) = await makeViewModel(
historyResponses: [before, after],
compactSessionHook: { _ in
await gate.wait()
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.input = "/compact"
vm.send()
vm.input = "/compact"
vm.send()
}
try await waitUntil("single compact request issued") {
await transport.compactSessionKeys() == ["main"]
}
#expect(await MainActor.run { vm.errorText } == nil)
await gate.open()
try await waitUntil("history reloaded after compact") {
await MainActor.run { vm.messages.first?.content.first?.text == "after compact" }
}
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await Task.sleep(for: .milliseconds(50))
#expect(await transport.compactSessionKeys() == ["main"])
#expect(await MainActor.run { vm.errorText } == "Please wait before compacting this session again.")
}
@Test func compactTriggerAllowsImmediateRetryAfterFailure() async throws {
let history = historyPayload()
let attemptCount = AsyncCounter()
let (transport, vm) = await makeViewModel(
historyResponses: [history],
compactSessionHook: { _ in
let next = await attemptCount.increment()
if next == 1 {
throw NSError(
domain: "TestCompact",
code: 42,
userInfo: [NSLocalizedDescriptionKey: "temporary failure"])
}
})
try await loadAndWaitBootstrap(vm: vm)
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await waitUntil("first compact attempted") {
await transport.compactSessionKeys() == ["main"]
}
#expect(await MainActor.run { vm.errorText } == "Unable to compact the session. Please try again.")
await MainActor.run {
vm.input = "/compact"
vm.send()
}
try await waitUntil("second compact attempted") {
await transport.compactSessionKeys() == ["main", "main"]
}
#expect(await MainActor.run { vm.errorText } == nil)
}
@Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws {
let now = Date().timeIntervalSince1970 * 1000
let history = historyPayload()

View File

@@ -7530,16 +7530,6 @@
"tags": [],
"hasChildren": true
},
{
"path": "auth.profiles.*.displayName",
"kind": "core",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "auth.profiles.*.email",
"kind": "core",
@@ -10392,20 +10382,6 @@
"tags": [],
"hasChildren": false
},
{
"path": "channels.discord.accounts.*.guilds.*.channels.*.autoThreadName",
"kind": "channel",
"type": "string",
"required": false,
"enumValues": [
"message",
"generated"
],
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.discord.accounts.*.guilds.*.channels.*.enabled",
"kind": "channel",
@@ -13338,20 +13314,6 @@
"tags": [],
"hasChildren": false
},
{
"path": "channels.discord.guilds.*.channels.*.autoThreadName",
"kind": "channel",
"type": "string",
"required": false,
"enumValues": [
"message",
"generated"
],
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.discord.guilds.*.channels.*.enabled",
"kind": "channel",
@@ -17117,7 +17079,8 @@
"path": "channels.feishu.requireMention",
"kind": "channel",
"type": "boolean",
"required": false,
"required": true,
"defaultValue": true,
"deprecated": false,
"sensitive": false,
"tags": [],

View File

@@ -1,4 +1,4 @@
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5631}
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5628}
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -669,7 +669,6 @@
{"recordType":"path","path":"auth.order.*.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"auth.profiles","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["access","auth","storage"],"label":"Auth Profiles","help":"Named auth profiles (provider + mode + optional email).","hasChildren":true}
{"recordType":"path","path":"auth.profiles.*","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"auth.profiles.*.displayName","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"auth.profiles.*.email","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"auth.profiles.*.mode","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"auth.profiles.*.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -917,7 +916,6 @@
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.autoArchiveDuration","kind":"channel","type":["number","string"],"required":false,"enumValues":["60","1440","4320","10080"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.autoThread","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.autoThreadName","kind":"channel","type":"string","required":false,"enumValues":["message","generated"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.ignoreOtherMentions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.channels.*.includeThreadStarter","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1184,7 +1182,6 @@
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.autoArchiveDuration","kind":"channel","type":["number","string"],"required":false,"enumValues":["60","1440","4320","10080"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.autoThread","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.autoThreadName","kind":"channel","type":"string","required":false,"enumValues":["message","generated"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.ignoreOtherMentions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.discord.guilds.*.channels.*.includeThreadStarter","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1519,7 +1516,7 @@
{"recordType":"path","path":"channels.feishu.reactionNotifications","kind":"channel","type":"string","required":true,"enumValues":["off","own","all"],"defaultValue":"own","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.renderMode","kind":"channel","type":"string","required":false,"enumValues":["auto","raw","card"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.resolveSenderNames","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.streaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.feishu.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}

View File

@@ -19,7 +19,7 @@
"exportName": "buildGoogleImageGenerationProvider",
"kind": "function",
"source": {
"line": 98,
"line": 95,
"path": "extensions/google/image-generation-provider.ts"
}
},
@@ -2359,7 +2359,7 @@
"exportName": "buildCommandsMessage",
"kind": "function",
"source": {
"line": 1049,
"line": 961,
"path": "src/auto-reply/status.ts"
}
},
@@ -2368,7 +2368,7 @@
"exportName": "buildCommandsMessagePaginated",
"kind": "function",
"source": {
"line": 1058,
"line": 970,
"path": "src/auto-reply/status.ts"
}
},
@@ -2377,7 +2377,7 @@
"exportName": "buildCommandsPaginationKeyboard",
"kind": "function",
"source": {
"line": 196,
"line": 89,
"path": "src/auto-reply/reply/commands-info.ts"
}
},
@@ -2404,7 +2404,7 @@
"exportName": "buildHelpMessage",
"kind": "function",
"source": {
"line": 844,
"line": 841,
"path": "src/auto-reply/status.ts"
}
},
@@ -3009,7 +3009,7 @@
"exportName": "buildChannelOutboundSessionRoute",
"kind": "function",
"source": {
"line": 163,
"line": 162,
"path": "src/plugin-sdk/core.ts"
}
},
@@ -3045,7 +3045,7 @@
"exportName": "createChannelPluginBase",
"kind": "function",
"source": {
"line": 437,
"line": 436,
"path": "src/plugin-sdk/core.ts"
}
},
@@ -3054,7 +3054,7 @@
"exportName": "createChatChannelPlugin",
"kind": "function",
"source": {
"line": 414,
"line": 413,
"path": "src/plugin-sdk/core.ts"
}
},
@@ -3063,7 +3063,7 @@
"exportName": "defineChannelPluginEntry",
"kind": "function",
"source": {
"line": 246,
"line": 245,
"path": "src/plugin-sdk/core.ts"
}
},
@@ -3081,7 +3081,7 @@
"exportName": "defineSetupPluginEntry",
"kind": "function",
"source": {
"line": 277,
"line": 276,
"path": "src/plugin-sdk/core.ts"
}
},
@@ -3135,7 +3135,7 @@
"exportName": "getChatChannelMeta",
"kind": "function",
"source": {
"line": 155,
"line": 158,
"path": "src/channels/registry.ts"
}
},
@@ -3229,15 +3229,6 @@
"path": "src/shared/gateway-bind-url.ts"
}
},
{
"declaration": "export function resolveGatewayPort(cfg?: OpenClawConfig | undefined, env?: ProcessEnv): number;",
"exportName": "resolveGatewayPort",
"kind": "function",
"source": {
"line": 285,
"path": "src/config/paths.ts"
}
},
{
"declaration": "export function resolveTailnetHostWithRunner(runCommandWithTimeout?: TailscaleStatusCommandRunner | undefined): Promise<string | null>;",
"exportName": "resolveTailnetHostWithRunner",
@@ -3279,7 +3270,7 @@
"exportName": "stripChannelTargetPrefix",
"kind": "function",
"source": {
"line": 143,
"line": 142,
"path": "src/plugin-sdk/core.ts"
}
},
@@ -3288,7 +3279,7 @@
"exportName": "stripTargetKindPrefix",
"kind": "function",
"source": {
"line": 155,
"line": 154,
"path": "src/plugin-sdk/core.ts"
}
},
@@ -3360,7 +3351,7 @@
"exportName": "ChannelOutboundSessionRouteParams",
"kind": "type",
"source": {
"line": 138,
"line": 137,
"path": "src/plugin-sdk/core.ts"
}
},

View File

@@ -1,6 +1,6 @@
{"category":"legacy","entrypoint":"index","importSpecifier":"openclaw/plugin-sdk","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/index.ts"}
{"declaration":"export function buildFalImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildFalImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":190,"sourcePath":"extensions/fal/image-generation-provider.ts"}
{"declaration":"export function buildGoogleImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildGoogleImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":98,"sourcePath":"extensions/google/image-generation-provider.ts"}
{"declaration":"export function buildGoogleImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildGoogleImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":95,"sourcePath":"extensions/google/image-generation-provider.ts"}
{"declaration":"export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildOpenAIImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":22,"sourcePath":"extensions/openai/image-generation-provider.ts"}
{"declaration":"export function delegateCompactionToRuntime(params: { sessionId: string; sessionKey?: string | undefined; sessionFile: string; tokenBudget?: number | undefined; force?: boolean | undefined; currentTokenCount?: number | undefined; compactionTarget?: \"budget\" | ... 1 more ... | undefined; customInstructions?: string | undefined; runtimeContext?: ContextEngineRuntimeContext | undefined; }): Promise<...>;","entrypoint":"index","exportName":"delegateCompactionToRuntime","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/context-engine/delegate.ts"}
{"declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"index","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":13,"sourcePath":"src/plugins/config-schema.ts"}
@@ -258,12 +258,12 @@
{"declaration":"export type ChannelSetupWizard = ChannelSetupWizard;","entrypoint":"channel-setup","exportName":"ChannelSetupWizard","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":247,"sourcePath":"src/channels/plugins/setup-wizard.ts"}
{"declaration":"export type OptionalChannelSetupSurface = OptionalChannelSetupSurface;","entrypoint":"channel-setup","exportName":"OptionalChannelSetupSurface","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":29,"sourcePath":"src/plugin-sdk/channel-setup.ts"}
{"category":"channel","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/command-auth.ts"}
{"declaration":"export function buildCommandsMessage(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandsMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":1049,"sourcePath":"src/auto-reply/status.ts"}
{"declaration":"export function buildCommandsMessagePaginated(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): CommandsMessageResult;","entrypoint":"command-auth","exportName":"buildCommandsMessagePaginated","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":1058,"sourcePath":"src/auto-reply/status.ts"}
{"declaration":"export function buildCommandsPaginationKeyboard(currentPage: number, totalPages: number, agentId?: string | undefined): { text: string; callback_data: string; }[][];","entrypoint":"command-auth","exportName":"buildCommandsPaginationKeyboard","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":196,"sourcePath":"src/auto-reply/reply/commands-info.ts"}
{"declaration":"export function buildCommandsMessage(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandsMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":961,"sourcePath":"src/auto-reply/status.ts"}
{"declaration":"export function buildCommandsMessagePaginated(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): CommandsMessageResult;","entrypoint":"command-auth","exportName":"buildCommandsMessagePaginated","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":970,"sourcePath":"src/auto-reply/status.ts"}
{"declaration":"export function buildCommandsPaginationKeyboard(currentPage: number, totalPages: number, agentId?: string | undefined): { text: string; callback_data: string; }[][];","entrypoint":"command-auth","exportName":"buildCommandsPaginationKeyboard","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":89,"sourcePath":"src/auto-reply/reply/commands-info.ts"}
{"declaration":"export function buildCommandText(commandName: string, args?: string | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandText","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":199,"sourcePath":"src/auto-reply/commands-registry.ts"}
{"declaration":"export function buildCommandTextFromArgs(command: ChatCommandDefinition, args?: CommandArgs | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandTextFromArgs","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":291,"sourcePath":"src/auto-reply/commands-registry.ts"}
{"declaration":"export function buildHelpMessage(cfg?: OpenClawConfig | undefined): string;","entrypoint":"command-auth","exportName":"buildHelpMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":844,"sourcePath":"src/auto-reply/status.ts"}
{"declaration":"export function buildHelpMessage(cfg?: OpenClawConfig | undefined): string;","entrypoint":"command-auth","exportName":"buildHelpMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":841,"sourcePath":"src/auto-reply/status.ts"}
{"declaration":"export function buildModelsProviderData(cfg: OpenClawConfig, agentId?: string | undefined): Promise<ModelsProviderData>;","entrypoint":"command-auth","exportName":"buildModelsProviderData","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":37,"sourcePath":"src/auto-reply/reply/commands-models.ts"}
{"declaration":"export function createPreCryptoDirectDmAuthorizer(params: { resolveAccess: (senderId: string) => Promise<ResolvedInboundDirectDmAccess | Pick<ResolvedInboundDirectDmAccess, \"access\">>; issuePairingChallenge?: ((params: { ...; }) => Promise<...>) | undefined; onBlocked?: ((params: { ...; }) => void) | undefined; }): (input: { ...; }) => Promise<...>;","entrypoint":"command-auth","exportName":"createPreCryptoDirectDmAuthorizer","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":105,"sourcePath":"src/plugin-sdk/direct-dm.ts"}
{"declaration":"export function findCommandByNativeName(name: string, provider?: string | undefined): ChatCommandDefinition | undefined;","entrypoint":"command-auth","exportName":"findCommandByNativeName","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":187,"sourcePath":"src/auto-reply/commands-registry.ts"}
@@ -330,21 +330,21 @@
{"declaration":"export function applyAccountNameToChannelSection(params: { cfg: OpenClawConfig; channelKey: string; accountId: string; name?: string | undefined; alwaysUseAccounts?: boolean | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"applyAccountNameToChannelSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":33,"sourcePath":"src/channels/plugins/setup-helpers.ts"}
{"declaration":"export function buildAgentSessionKey(params: { agentId: string; channel: string; accountId?: string | null | undefined; peer?: RoutePeer | null | undefined; dmScope?: \"main\" | \"per-peer\" | \"per-channel-peer\" | \"per-account-channel-peer\" | undefined; identityLinks?: Record<...> | undefined; }): string;","entrypoint":"core","exportName":"buildAgentSessionKey","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":91,"sourcePath":"src/routing/resolve-route.ts"}
{"declaration":"export function buildChannelConfigSchema(schema: ZodType<unknown, unknown, $ZodTypeInternals<unknown, unknown>>): ChannelConfigSchema;","entrypoint":"core","exportName":"buildChannelConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":35,"sourcePath":"src/channels/plugins/config-schema.ts"}
{"declaration":"export function buildChannelOutboundSessionRoute(params: { cfg: OpenClawConfig; agentId: string; channel: string; accountId?: string | null | undefined; peer: { kind: \"direct\" | \"group\" | \"channel\"; id: string; }; chatType: \"direct\" | \"group\" | \"channel\"; from: string; to: string; threadId?: string | ... 1 more ... | undefined; }): ChannelOutboundSessionRoute;","entrypoint":"core","exportName":"buildChannelOutboundSessionRoute","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":163,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function buildChannelOutboundSessionRoute(params: { cfg: OpenClawConfig; agentId: string; channel: string; accountId?: string | null | undefined; peer: { kind: \"direct\" | \"group\" | \"channel\"; id: string; }; chatType: \"direct\" | \"group\" | \"channel\"; from: string; to: string; threadId?: string | ... 1 more ... | undefined; }): ChannelOutboundSessionRoute;","entrypoint":"core","exportName":"buildChannelOutboundSessionRoute","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":162,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function channelTargetSchema(options?: { description?: string | undefined; } | undefined): TString;","entrypoint":"core","exportName":"channelTargetSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":38,"sourcePath":"src/agents/schema/typebox.ts"}
{"declaration":"export function channelTargetsSchema(options?: { description?: string | undefined; } | undefined): TArray<TString>;","entrypoint":"core","exportName":"channelTargetsSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":44,"sourcePath":"src/agents/schema/typebox.ts"}
{"declaration":"export function clearAccountEntryFields<TAccountEntry extends object>(params: { accounts?: Record<string, TAccountEntry> | undefined; accountId: string; fields: string[]; isValueSet?: ((value: unknown) => boolean) | undefined; markClearedOnFieldPresence?: boolean | undefined; }): { ...; };","entrypoint":"core","exportName":"clearAccountEntryFields","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":122,"sourcePath":"src/channels/plugins/config-helpers.ts"}
{"declaration":"export function createChannelPluginBase<TResolvedAccount>(params: CreateChannelPluginBaseOptions<TResolvedAccount>): CreatedChannelPluginBase<TResolvedAccount>;","entrypoint":"core","exportName":"createChannelPluginBase","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":437,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function createChatChannelPlugin<TResolvedAccount extends { accountId?: string | null; }, Probe = unknown, Audit = unknown>(params: { base: ChatChannelPluginBase<TResolvedAccount, Probe, Audit>; security?: ChannelSecurityAdapter<TResolvedAccount> | ChatChannelSecurityOptions<...> | undefined; pairing?: ChannelPairingAdapter | ... 1 more ... | undefined; threading?: ChannelThreadingAdapter | ... 1 more ... | undefined; outbound?: ChannelOutboundAdapter | ... 1 more ... | undefined; }): ChannelPlugin<...>;","entrypoint":"core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":414,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function defineChannelPluginEntry<TPlugin>({ id, name, description, plugin, configSchema, setRuntime, registerFull, }: DefineChannelPluginEntryOptions<TPlugin>): DefinedPluginEntry;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":246,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function createChannelPluginBase<TResolvedAccount>(params: CreateChannelPluginBaseOptions<TResolvedAccount>): CreatedChannelPluginBase<TResolvedAccount>;","entrypoint":"core","exportName":"createChannelPluginBase","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":436,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function createChatChannelPlugin<TResolvedAccount extends { accountId?: string | null; }, Probe = unknown, Audit = unknown>(params: { base: ChatChannelPluginBase<TResolvedAccount, Probe, Audit>; security?: ChannelSecurityAdapter<TResolvedAccount> | ChatChannelSecurityOptions<...> | undefined; pairing?: ChannelPairingAdapter | ... 1 more ... | undefined; threading?: ChannelThreadingAdapter | ... 1 more ... | undefined; outbound?: ChannelOutboundAdapter | ... 1 more ... | undefined; }): ChannelPlugin<...>;","entrypoint":"core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":413,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function defineChannelPluginEntry<TPlugin>({ id, name, description, plugin, configSchema, setRuntime, registerFull, }: DefineChannelPluginEntryOptions<TPlugin>): DefinedPluginEntry;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":245,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"core","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":88,"sourcePath":"src/plugin-sdk/plugin-entry.ts"}
{"declaration":"export function defineSetupPluginEntry<TPlugin>(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":277,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function defineSetupPluginEntry<TPlugin>(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":276,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function delegateCompactionToRuntime(params: { sessionId: string; sessionKey?: string | undefined; sessionFile: string; tokenBudget?: number | undefined; force?: boolean | undefined; currentTokenCount?: number | undefined; compactionTarget?: \"budget\" | ... 1 more ... | undefined; customInstructions?: string | undefined; runtimeContext?: ContextEngineRuntimeContext | undefined; }): Promise<...>;","entrypoint":"core","exportName":"delegateCompactionToRuntime","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/context-engine/delegate.ts"}
{"declaration":"export function deleteAccountFromConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; accountId: string; clearBaseFields?: string[] | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"deleteAccountFromConfigSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":60,"sourcePath":"src/channels/plugins/config-helpers.ts"}
{"declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"core","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":13,"sourcePath":"src/plugins/config-schema.ts"}
{"declaration":"export function enqueueKeyedTask<T>(params: { tails: Map<string, Promise<void>>; key: string; task: () => Promise<T>; hooks?: KeyedAsyncQueueHooks | undefined; }): Promise<...>;","entrypoint":"core","exportName":"enqueueKeyedTask","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":7,"sourcePath":"src/plugin-sdk/keyed-async-queue.ts"}
{"declaration":"export function formatPairingApproveHint(channelId: string): string;","entrypoint":"core","exportName":"formatPairingApproveHint","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":17,"sourcePath":"src/channels/plugins/helpers.ts"}
{"declaration":"export function getChatChannelMeta(id: \"telegram\" | \"whatsapp\" | \"discord\" | \"irc\" | \"googlechat\" | \"slack\" | \"signal\" | \"imessage\" | \"line\"): ChannelMeta;","entrypoint":"core","exportName":"getChatChannelMeta","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":155,"sourcePath":"src/channels/registry.ts"}
{"declaration":"export function getChatChannelMeta(id: \"telegram\" | \"whatsapp\" | \"discord\" | \"irc\" | \"googlechat\" | \"slack\" | \"signal\" | \"imessage\" | \"line\"): ChannelMeta;","entrypoint":"core","exportName":"getChatChannelMeta","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":158,"sourcePath":"src/channels/registry.ts"}
{"declaration":"export function isSecretRef(value: unknown): value is SecretRef;","entrypoint":"core","exportName":"isSecretRef","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":34,"sourcePath":"src/config/types.secrets.ts"}
{"declaration":"export function loadSecretFileSync(filePath: string, label: string, options?: SecretFileReadOptions): SecretFileReadResult;","entrypoint":"core","exportName":"loadSecretFileSync","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":29,"sourcePath":"src/infra/secret-file.ts"}
{"declaration":"export function migrateBaseNameToDefaultAccount(params: { cfg: OpenClawConfig; channelKey: string; alwaysUseAccounts?: boolean | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"migrateBaseNameToDefaultAccount","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":92,"sourcePath":"src/channels/plugins/setup-helpers.ts"}
@@ -355,13 +355,12 @@
{"declaration":"export function parseOptionalDelimitedEntries(value?: string | undefined): string[] | undefined;","entrypoint":"core","exportName":"parseOptionalDelimitedEntries","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":23,"sourcePath":"src/channels/plugins/helpers.ts"}
{"declaration":"export function readSecretFileSync(filePath: string, label: string, options?: SecretFileReadOptions): string;","entrypoint":"core","exportName":"readSecretFileSync","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":118,"sourcePath":"src/infra/secret-file.ts"}
{"declaration":"export function resolveGatewayBindUrl(params: { bind?: string | undefined; customBindHost?: string | undefined; scheme: \"ws\" | \"wss\"; port: number; pickTailnetHost: () => string | null; pickLanHost: () => string | null; }): GatewayBindUrlResult;","entrypoint":"core","exportName":"resolveGatewayBindUrl","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":11,"sourcePath":"src/shared/gateway-bind-url.ts"}
{"declaration":"export function resolveGatewayPort(cfg?: OpenClawConfig | undefined, env?: ProcessEnv): number;","entrypoint":"core","exportName":"resolveGatewayPort","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":285,"sourcePath":"src/config/paths.ts"}
{"declaration":"export function resolveTailnetHostWithRunner(runCommandWithTimeout?: TailscaleStatusCommandRunner | undefined): Promise<string | null>;","entrypoint":"core","exportName":"resolveTailnetHostWithRunner","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":43,"sourcePath":"src/shared/tailscale-status.ts"}
{"declaration":"export function resolveThreadSessionKeys(params: { baseSessionKey: string; threadId?: string | null | undefined; parentSessionKey?: string | undefined; useSuffix?: boolean | undefined; normalizeThreadId?: ((threadId: string) => string) | undefined; }): { ...; };","entrypoint":"core","exportName":"resolveThreadSessionKeys","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":234,"sourcePath":"src/routing/session-key.ts"}
{"declaration":"export function setAccountEnabledInConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; accountId: string; enabled: boolean; allowTopLevel?: boolean | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"setAccountEnabledInConfigSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/channels/plugins/config-helpers.ts"}
{"declaration":"export function stringEnum<T extends readonly string[]>(values: T, options?: StringEnumOptions<T>): TUnsafe<T[number]>;","entrypoint":"core","exportName":"stringEnum","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":15,"sourcePath":"src/agents/schema/typebox.ts"}
{"declaration":"export function stripChannelTargetPrefix(raw: string, ...providers: string[]): string;","entrypoint":"core","exportName":"stripChannelTargetPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":143,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function stripTargetKindPrefix(raw: string): string;","entrypoint":"core","exportName":"stripTargetKindPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":155,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function stripChannelTargetPrefix(raw: string, ...providers: string[]): string;","entrypoint":"core","exportName":"stripChannelTargetPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":142,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function stripTargetKindPrefix(raw: string): string;","entrypoint":"core","exportName":"stripTargetKindPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":154,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export function tryReadSecretFileSync(filePath: string | undefined, label: string, options?: SecretFileReadOptions): string | undefined;","entrypoint":"core","exportName":"tryReadSecretFileSync","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":130,"sourcePath":"src/infra/secret-file.ts"}
{"declaration":"export const DEFAULT_ACCOUNT_ID: \"default\";","entrypoint":"core","exportName":"DEFAULT_ACCOUNT_ID","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export","sourceLine":3,"sourcePath":"src/routing/account-id.ts"}
{"declaration":"export const DEFAULT_SECRET_FILE_MAX_BYTES: number;","entrypoint":"core","exportName":"DEFAULT_SECRET_FILE_MAX_BYTES","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export","sourceLine":5,"sourcePath":"src/infra/secret-file.ts"}
@@ -369,7 +368,7 @@
{"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"core","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":482,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelMessagingAdapter = ChannelMessagingAdapter;","entrypoint":"core","exportName":"ChannelMessagingAdapter","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":395,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelOutboundSessionRoute = ChannelOutboundSessionRoute;","entrypoint":"core","exportName":"ChannelOutboundSessionRoute","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":309,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelOutboundSessionRouteParams = { cfg: OpenClawConfig; agentId: string; accountId?: string | null; target: string; resolvedTarget?: { to: string; kind: import(\"src/channels/plugins/types.core\").ChannelDirectoryEntryKind | \"channel\"; display?: string; source: \"normalized\" | \"directory\"; }; replyToId?: string | null; threadId?: string | number | null;};","entrypoint":"core","exportName":"ChannelOutboundSessionRouteParams","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":138,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export type ChannelOutboundSessionRouteParams = { cfg: OpenClawConfig; agentId: string; accountId?: string | null; target: string; resolvedTarget?: { to: string; kind: import(\"src/channels/plugins/types.core\").ChannelDirectoryEntryKind | \"channel\"; display?: string; source: \"normalized\" | \"directory\"; }; replyToId?: string | null; threadId?: string | number | null;};","entrypoint":"core","exportName":"ChannelOutboundSessionRouteParams","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":137,"sourcePath":"src/plugin-sdk/core.ts"}
{"declaration":"export type ChannelPlugin = ChannelPlugin<ResolvedAccount, Probe, Audit>;","entrypoint":"core","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":55,"sourcePath":"src/channels/plugins/types.plugin.ts"}
{"declaration":"export type GatewayBindUrlResult = GatewayBindUrlResult;","entrypoint":"core","exportName":"GatewayBindUrlResult","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1,"sourcePath":"src/shared/gateway-bind-url.ts"}
{"declaration":"export type GatewayRequestHandlerOptions = GatewayRequestHandlerOptions;","entrypoint":"core","exportName":"GatewayRequestHandlerOptions","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":112,"sourcePath":"src/gateway/server-methods/types.ts"}

View File

@@ -275,68 +275,6 @@ Triggered when the gateway starts:
- **`gateway:startup`**: After channels start and hooks are loaded
### Session Patch Events
Triggered when session properties are modified:
- **`session:patch`**: When a session is updated
#### Session Event Context
Session events include rich context about the session and changes:
```typescript
{
sessionEntry: SessionEntry, // The complete updated session entry
patch: { // The patch object (only changed fields)
// Session identity & labeling
label?: string | null, // Human-readable session label
// AI model configuration
model?: string | null, // Model override (e.g., "claude-opus-4-5")
thinkingLevel?: string | null, // Thinking level ("off"|"low"|"med"|"high")
verboseLevel?: string | null, // Verbose output level
reasoningLevel?: string | null, // Reasoning mode override
elevatedLevel?: string | null, // Elevated mode override
responseUsage?: "off" | "tokens" | "full" | null, // Usage display mode
// Tool execution settings
execHost?: string | null, // Exec host (sandbox|gateway|node)
execSecurity?: string | null, // Security mode (deny|allowlist|full)
execAsk?: string | null, // Approval mode (off|on-miss|always)
execNode?: string | null, // Node ID for host=node
// Subagent coordination
spawnedBy?: string | null, // Parent session key (for subagents)
spawnDepth?: number | null, // Nesting depth (0 = root)
// Communication policies
sendPolicy?: "allow" | "deny" | null, // Message send policy
groupActivation?: "mention" | "always" | null, // Group chat activation
},
cfg: OpenClawConfig // Current gateway config
}
```
**Security note:** Only privileged clients (including the Control UI) can trigger `session:patch` events. Standard WebChat clients are blocked from patching sessions (see PR #20800), so the hook will not fire from those connections.
See `SessionsPatchParamsSchema` in `src/gateway/protocol/schema/sessions.ts` for the complete type definition.
#### Example: Session Patch Logger Hook
```typescript
const handler = async (event) => {
if (event.type !== "session" || event.action !== "patch") {
return;
}
const { patch } = event.context;
console.log(`[session-patch] Session updated: ${event.sessionKey}`);
console.log(`[session-patch] Changes:`, patch);
};
export default handler;
```
### Message Events
Triggered when messages are received or sent:

View File

@@ -316,43 +316,41 @@ After approval, you can chat normally.
**1. Group policy** (`channels.feishu.groupPolicy`):
- `"open"` = allow everyone in groups
- `"open"` = allow everyone in groups (default)
- `"allowlist"` = only allow `groupAllowFrom`
- `"disabled"` = disable group messages
Default: `allowlist`
**2. Mention requirement** (`channels.feishu.groups.<chat_id>.requireMention`):
**2. Mention requirement** (`channels.feishu.requireMention`, overridable via `channels.feishu.groups.<chat_id>.requireMention`):
- explicit `true` = require @mention
- explicit `false` = respond without mentions
- when unset and `groupPolicy: "open"` = default to `false`
- when unset and `groupPolicy` is not `"open"` = default to `true`
- `true` = require @mention (default)
- `false` = respond without mentions
---
## Group configuration examples
### Allow all groups, no @mention required (default for open groups)
### Allow all groups, require @mention (default)
```json5
{
channels: {
feishu: {
groupPolicy: "open",
// Default requireMention: true
},
},
}
```
### Allow all groups, but still require @mention
### Allow all groups, no @mention required
```json5
{
channels: {
feishu: {
groupPolicy: "open",
requireMention: true,
groups: {
oc_xxx: { requireMention: false },
},
},
},
}
@@ -682,10 +680,9 @@ Key options:
| `channels.feishu.accounts.<id>.domain` | Per-account API domain override | `feishu` |
| `channels.feishu.dmPolicy` | DM policy | `pairing` |
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - |
| `channels.feishu.groupPolicy` | Group policy | `allowlist` |
| `channels.feishu.groupPolicy` | Group policy | `open` |
| `channels.feishu.groupAllowFrom` | Group allowlist | - |
| `channels.feishu.requireMention` | Default require @mention | conditional |
| `channels.feishu.groups.<chat_id>.requireMention` | Per-group require @mention override | inherited |
| `channels.feishu.groups.<chat_id>.requireMention` | Require @mention | `true` |
| `channels.feishu.groups.<chat_id>.enabled` | Enable group | `true` |
| `channels.feishu.textChunkLimit` | Message chunk size | `2000` |
| `channels.feishu.mediaMaxMb` | Media size limit | `30` |

View File

@@ -74,7 +74,7 @@ If you see logs like:
Example (allow anyone in `#tuirc-dev` to talk to the bot):
```json5
```json55
{
channels: {
irc: {
@@ -95,7 +95,7 @@ That means you may see logs like `drop channel … (missing-mention)` unless the
To make the bot reply in an IRC channel **without needing a mention**, disable mention gating for that channel:
```json5
```json55
{
channels: {
irc: {
@@ -113,7 +113,7 @@ To make the bot reply in an IRC channel **without needing a mention**, disable m
Or to allow **all** IRC channels (no per-channel allowlist) and still reply without mentions:
```json5
```json55
{
channels: {
irc: {
@@ -133,7 +133,7 @@ To reduce risk, restrict tools for that channel.
### Same tools for everyone in the channel
```json5
```json55
{
channels: {
irc: {
@@ -154,7 +154,7 @@ To reduce risk, restrict tools for that channel.
Use `toolsBySender` to apply a stricter policy to `"*"` and a looser one to your nick:
```json5
```json55
{
channels: {
irc: {

View File

@@ -92,13 +92,6 @@ These run inside the agent loop or gateway pipeline:
- **`session_start` / `session_end`**: session lifecycle boundaries.
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
Hook decision rules for outbound/tool guards:
- `before_tool_call`: `{ block: true }` is terminal and stops lower-priority handlers.
- `before_tool_call`: `{ block: false }` is a no-op and does not clear a prior block.
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
- `message_sending`: `{ cancel: false }` is a no-op and does not clear a prior cancel.
See [Plugin hooks](/plugins/architecture#provider-runtime-hooks) for the hook API and registration details.
## Streaming + partial replies

View File

@@ -70,35 +70,11 @@ Default mode is `gateway.reload.mode="hybrid"`.
- One always-on process for routing, control plane, and channel connections.
- Single multiplexed port for:
- WebSocket control/RPC
- HTTP APIs, OpenAI compatible (`/v1/models`, `/v1/embeddings`, `/v1/chat/completions`, `/v1/responses`, `/tools/invoke`)
- HTTP APIs (OpenAI-compatible, Responses, tools invoke)
- Control UI and hooks
- Default bind mode: `loopback`.
- Auth is required by default (`gateway.auth.token` / `gateway.auth.password`, or `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`).
## OpenAI-compatible endpoints
OpenClaws highest-leverage compatibility surface is now:
- `GET /v1/models`
- `GET /v1/models/{id}`
- `POST /v1/embeddings`
- `POST /v1/chat/completions`
- `POST /v1/responses`
Why this set matters:
- Most Open WebUI, LobeChat, and LibreChat integrations probe `/v1/models` first.
- Many RAG and memory pipelines expect `/v1/embeddings`.
- Agent-native clients increasingly prefer `/v1/responses`.
Planning note:
- `/v1/models` is agent-first: it returns `openclaw`, `openclaw/default`, and `openclaw/<agentId>`.
- `openclaw/default` is the stable alias that always maps to the configured default agent.
- Use `x-openclaw-model` when you want a backend provider/model override; otherwise the selected agent's normal model and embedding setup stays in control.
All of these run on the main Gateway port and use the same trusted operator auth boundary as the rest of the Gateway HTTP API.
### Port and bind precedence
| Setting | Resolution order |

View File

@@ -14,13 +14,6 @@ This endpoint is **disabled by default**. Enable it in config first.
- `POST /v1/chat/completions`
- Same port as the Gateway (WS + HTTP multiplex): `http://<gateway-host>:<port>/v1/chat/completions`
When the Gateways OpenAI-compatible HTTP surface is enabled, it also serves:
- `GET /v1/models`
- `GET /v1/models/{id}`
- `POST /v1/embeddings`
- `POST /v1/responses`
Under the hood, requests are executed as a normal Gateway agent run (same codepath as `openclaw agent`), so routing/permissions/config match your Gateway.
## Authentication
@@ -48,25 +41,20 @@ Treat this endpoint as a **full operator-access** surface for the gateway instan
See [Security](/gateway/security) and [Remote access](/gateway/remote).
## Agent-first model contract
## Choosing an agent
OpenClaw treats the OpenAI `model` field as an **agent target**, not a raw provider model id.
No custom headers required: encode the agent id in the OpenAI `model` field:
- `model: "openclaw"` routes to the configured default agent.
- `model: "openclaw/default"` also routes to the configured default agent.
- `model: "openclaw/<agentId>"` routes to a specific agent.
- `model: "openclaw:<agentId>"` (example: `"openclaw:main"`, `"openclaw:beta"`)
- `model: "agent:<agentId>"` (alias)
Optional request headers:
Or target a specific OpenClaw agent by header:
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent.
- `x-openclaw-agent-id: <agentId>` remains supported as a compatibility override.
- `x-openclaw-session-key: <sessionKey>` fully controls session routing.
- `x-openclaw-message-channel: <channel>` sets the synthetic ingress channel context for channel-aware prompts and policies.
- `x-openclaw-agent-id: <agentId>` (default: `main`)
Compatibility aliases still accepted:
Advanced:
- `model: "openclaw:<agentId>"`
- `model: "agent:<agentId>"`
- `x-openclaw-session-key: <sessionKey>` to fully control session routing.
## Enabling the endpoint
@@ -106,57 +94,6 @@ By default the endpoint is **stateless per request** (a new session key is gener
If the request includes an OpenAI `user` string, the Gateway derives a stable session key from it, so repeated calls can share an agent session.
## Why this surface matters
This is the highest-leverage compatibility set for self-hosted frontends and tooling:
- Most Open WebUI, LobeChat, and LibreChat setups expect `/v1/models`.
- Many RAG systems expect `/v1/embeddings`.
- Existing OpenAI chat clients can usually start with `/v1/chat/completions`.
- More agent-native clients increasingly prefer `/v1/responses`.
## Model list and agent routing
<AccordionGroup>
<Accordion title="What does `/v1/models` return?">
An OpenClaw agent-target list.
The returned ids are `openclaw`, `openclaw/default`, and `openclaw/<agentId>` entries.
Use them directly as OpenAI `model` values.
</Accordion>
<Accordion title="Does `/v1/models` list agents or sub-agents?">
It lists top-level agent targets, not backend provider models and not sub-agents.
Sub-agents remain internal execution topology. They do not appear as pseudo-models.
</Accordion>
<Accordion title="Why is `openclaw/default` included?">
`openclaw/default` is the stable alias for the configured default agent.
That means clients can keep using one predictable id even if the real default agent id changes between environments.
</Accordion>
<Accordion title="How do I override the backend model?">
Use `x-openclaw-model`.
Examples:
`x-openclaw-model: openai/gpt-5.4`
`x-openclaw-model: gpt-5.4`
If you omit it, the selected agent runs with its normal configured model choice.
</Accordion>
<Accordion title="How do embeddings fit this contract?">
`/v1/embeddings` uses the same agent-target `model` ids.
Use `model: "openclaw/default"` or `model: "openclaw/<agentId>"`.
When you need a specific embedding model, send it in `x-openclaw-model`.
Without that header, the request passes through to the selected agent's normal embedding setup.
</Accordion>
</AccordionGroup>
## Streaming (SSE)
Set `stream: true` to receive Server-Sent Events (SSE):
@@ -173,8 +110,9 @@ Non-streaming:
curl -sS http://127.0.0.1:18789/v1/chat/completions \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-openclaw-agent-id: main' \
-d '{
"model": "openclaw/default",
"model": "openclaw",
"messages": [{"role":"user","content":"hi"}]
}'
```
@@ -185,44 +123,10 @@ Streaming:
curl -N http://127.0.0.1:18789/v1/chat/completions \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-openclaw-model: openai/gpt-5.4' \
-H 'x-openclaw-agent-id: main' \
-d '{
"model": "openclaw/research",
"model": "openclaw",
"stream": true,
"messages": [{"role":"user","content":"hi"}]
}'
```
List models:
```bash
curl -sS http://127.0.0.1:18789/v1/models \
-H 'Authorization: Bearer YOUR_TOKEN'
```
Fetch one model:
```bash
curl -sS http://127.0.0.1:18789/v1/models/openclaw%2Fdefault \
-H 'Authorization: Bearer YOUR_TOKEN'
```
Create embeddings:
```bash
curl -sS http://127.0.0.1:18789/v1/embeddings \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-openclaw-model: openai/text-embedding-3-small' \
-d '{
"model": "openclaw/default",
"input": ["alpha", "beta"]
}'
```
Notes:
- `/v1/models` returns OpenClaw agent targets, not raw provider catalogs.
- `openclaw/default` is always present so one stable id works across environments.
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field.
- `/v1/embeddings` supports `input` as a string or array of strings.

View File

@@ -24,22 +24,11 @@ Operational behavior matches [OpenAI Chat Completions](/gateway/openai-http-api)
- use `Authorization: Bearer <token>` with the normal Gateway auth config
- treat the endpoint as full operator access for the gateway instance
- select agents with `model: "openclaw"`, `model: "openclaw/default"`, `model: "openclaw/<agentId>"`, or `x-openclaw-agent-id`
- use `x-openclaw-model` when you want to override the selected agent's backend model
- select agents with `model: "openclaw:<agentId>"`, `model: "agent:<agentId>"`, or `x-openclaw-agent-id`
- use `x-openclaw-session-key` for explicit session routing
- use `x-openclaw-message-channel` when you want a non-default synthetic ingress channel context
Enable or disable this endpoint with `gateway.http.endpoints.responses.enabled`.
The same compatibility surface also includes:
- `GET /v1/models`
- `GET /v1/models/{id}`
- `POST /v1/embeddings`
- `POST /v1/chat/completions`
For the canonical explanation of how agent-target models, `openclaw/default`, embeddings pass-through, and backend model overrides fit together, see [OpenAI Chat Completions](/gateway/openai-http-api#agent-first-model-contract) and [Model list and agent routing](/gateway/openai-http-api#model-list-and-agent-routing).
## Session behavior
By default the endpoint is **stateless per request** (a new session key is generated each call).
@@ -65,12 +54,9 @@ Accepted but **currently ignored**:
- `reasoning`
- `metadata`
- `store`
- `previous_response_id`
- `truncation`
Supported:
- `previous_response_id`: OpenClaw reuses the earlier response session when the request stays within the same agent/user/requested-session scope.
## Items (input)
### `message`

View File

@@ -181,13 +181,6 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- `source`: `core` or `plugin`
- `pluginId`: plugin owner when `source="plugin"`
- `optional`: whether a plugin tool is optional
- Operators may call `tools.effective` (`operator.read`) to fetch the runtime-effective tool
inventory for a session.
- `sessionKey` is required.
- The gateway derives trusted runtime context from the session server-side instead of accepting
caller-supplied auth or delivery context.
- The response is session-scoped and reflects what the active conversation can use right now,
including core, plugin, and channel tools.
## Exec approvals

View File

@@ -55,10 +55,6 @@ Docker is **optional**. Use it only if you want a containerized gateway or to va
- generate a gateway token and write it to `.env`
- start the gateway via Docker Compose
During setup, pre-start onboarding and config writes run through
`openclaw-gateway` directly. `openclaw-cli` is for commands you run after
the gateway container already exists.
</Step>
<Step title="Open the Control UI">
@@ -98,15 +94,7 @@ If you prefer to run each step yourself instead of using the setup script:
```bash
docker build -t openclaw:local -f Dockerfile .
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
dist/index.js onboard --mode local --no-install-daemon
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
dist/index.js config set gateway.mode local
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
dist/index.js config set gateway.bind lan
docker compose run --rm --no-deps --entrypoint node openclaw-gateway \
dist/index.js config set gateway.controlUi.allowedOrigins \
'["http://localhost:18789","http://127.0.0.1:18789"]' --strict-json
docker compose run --rm openclaw-cli onboard
docker compose up -d openclaw-gateway
```
@@ -116,13 +104,6 @@ or `OPENCLAW_HOME_VOLUME`, the setup script writes `docker-compose.extra.yml`;
include it with `-f docker-compose.yml -f docker-compose.extra.yml`.
</Note>
<Note>
Because `openclaw-cli` shares `openclaw-gateway`'s network namespace, it is a
post-start tool. Before `docker compose up -d openclaw-gateway`, run onboarding
and setup-time config writes through `openclaw-gateway` with
`--no-deps --entrypoint node`.
</Note>
### Environment variables
The setup script accepts these optional environment variables:

View File

@@ -144,15 +144,6 @@ A single plugin can register any number of capabilities via the `api` object:
For the full registration API, see [SDK Overview](/plugins/sdk-overview#registration-api).
Hook guard semantics to keep in mind:
- `before_tool_call`: `{ block: true }` is terminal and stops lower-priority handlers.
- `before_tool_call`: `{ block: false }` is treated as no decision.
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
- `message_sending`: `{ cancel: false }` is treated as no decision.
See [SDK Overview hook decision semantics](/plugins/sdk-overview#hook-decision-semantics) for details.
## Registering agent tools
Tools are typed functions the LLM can call. They can be required (always

View File

@@ -152,13 +152,6 @@ methods:
| `api.on(hookName, handler, opts?)` | Typed lifecycle hook |
| `api.onConversationBindingResolved(handler)` | Conversation binding callback |
### Hook decision semantics
- `before_tool_call`: returning `{ block: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.
- `before_tool_call`: returning `{ block: false }` is treated as no decision (same as omitting `block`), not as an override.
- `message_sending`: returning `{ cancel: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.
- `message_sending`: returning `{ cancel: false }` is treated as no decision (same as omitting `cancel`), not as an override.
### API object fields
| Field | Type | Description |

View File

@@ -258,15 +258,6 @@ Common registration methods:
| `registerContextEngine` | Context engine |
| `registerService` | Background service |
Hook guard behavior for typed lifecycle hooks:
- `before_tool_call`: `{ block: true }` is terminal; lower-priority handlers are skipped.
- `before_tool_call`: `{ block: false }` is a no-op and does not clear an earlier block.
- `message_sending`: `{ cancel: true }` is terminal; lower-priority handlers are skipped.
- `message_sending`: `{ cancel: false }` is a no-op and does not clear an earlier cancel.
For full typed hook behavior, see [SDK Overview](/plugins/sdk-overview#hook-decision-semantics).
## Related
- [Building Plugins](/plugins/building-plugins) — create your own plugin

View File

@@ -75,7 +75,6 @@ Text + native (when enabled):
- `/help`
- `/commands`
- `/tools [compact|verbose]` (show what the current agent can use right now; `verbose` adds descriptions)
- `/skill <name> [input]` (run a skill by name)
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/allowlist` (list/add/remove allowlist entries)
@@ -158,22 +157,6 @@ Notes:
- Example: `/prose` (OpenProse plugin) — see [OpenProse](/prose).
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
## `/tools`
`/tools` answers a runtime question, not a config question: **what this agent can use right now in
this conversation**.
- Default `/tools` is compact and optimized for quick scanning.
- `/tools verbose` adds short descriptions.
- Native-command surfaces that support arguments expose the same mode switch as `compact|verbose`.
- Results are session-scoped, so changing agent, channel, thread, sender authorization, or model can
change the output.
- `/tools` includes tools that are actually reachable at runtime, including core tools, connected
plugin tools, and channel-owned tools.
For profile and override editing, use the Control UI Tools panel or config/catalog surfaces instead
of treating `/tools` as a static catalog.
## Usage surfaces (what shows where)
- **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` for the current model provider when usage tracking is enabled.

View File

@@ -10,7 +10,7 @@ title: "Text-to-Speech"
# Text-to-speech (TTS)
OpenClaw can convert outbound replies into audio using ElevenLabs, Microsoft, or OpenAI.
It works anywhere OpenClaw can send audio.
It works anywhere OpenClaw can send audio; Telegram gets a round voice-note bubble.
## Supported services
@@ -170,7 +170,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
}
```
### Only reply with audio after an inbound voice message
### Only reply with audio after an inbound voice note
```json5
{
@@ -203,7 +203,7 @@ Then run:
### Notes on fields
- `auto`: autoTTS mode (`off`, `always`, `inbound`, `tagged`).
- `inbound` only sends audio after an inbound voice message.
- `inbound` only sends audio after an inbound voice note.
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
@@ -319,18 +319,18 @@ These override `messages.tts.*` for that host.
## Output formats (fixed)
- **Feishu / Matrix / Telegram / WhatsApp**: Opus voice message (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
- 48kHz / 64kbps is a good voice message tradeoff.
- **Telegram**: Opus voice note (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
- 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble.
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
- 44.1kHz / 128kbps is the default balance for speech clarity.
- **Microsoft**: uses `microsoft.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
- The bundled transport accepts an `outputFormat`, but not all formats are available from the service.
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus).
- Telegram `sendVoice` accepts OGG/MP3/M4A; use OpenAI/ElevenLabs if you need
guaranteed Opus voice messages.
guaranteed Opus voice notes. citeturn1search1
- If the configured Microsoft output format fails, OpenClaw retries with MP3.
OpenAI/ElevenLabs output formats are fixed per channel (see above).
OpenAI/ElevenLabs formats are fixed; Telegram expects Opus for voice-note UX.
## Auto-TTS behavior
@@ -391,8 +391,8 @@ Notes:
## Agent tool
The `tts` tool converts text to speech and returns an audio attachment for
reply delivery. When the channel is Feishu, Matrix, Telegram, or WhatsApp,
the audio is delivered as a voice message rather than a file attachment.
reply delivery. When the result is Telegram-compatible, OpenClaw marks it for
voice-bubble delivery.
## Gateway RPC

View File

@@ -10,7 +10,7 @@ title: "Text-to-Speech (legacy path)"
# Text-to-speech (TTS)
OpenClaw can convert outbound replies into audio using ElevenLabs, Microsoft, or OpenAI.
It works anywhere OpenClaw can send audio.
It works anywhere OpenClaw can send audio; Telegram gets a round voice-note bubble.
## Supported services
@@ -170,7 +170,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
}
```
### Only reply with audio after an inbound voice message
### Only reply with audio after an inbound voice note
```json5
{
@@ -203,7 +203,7 @@ Then run:
### Notes on fields
- `auto`: autoTTS mode (`off`, `always`, `inbound`, `tagged`).
- `inbound` only sends audio after an inbound voice message.
- `inbound` only sends audio after an inbound voice note.
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
@@ -319,18 +319,18 @@ These override `messages.tts.*` for that host.
## Output formats (fixed)
- **Feishu / Matrix / Telegram / WhatsApp**: Opus voice message (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
- 48kHz / 64kbps is a good voice message tradeoff.
- **Telegram**: Opus voice note (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
- 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble.
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
- 44.1kHz / 128kbps is the default balance for speech clarity.
- **Microsoft**: uses `microsoft.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
- The bundled transport accepts an `outputFormat`, but not all formats are available from the service.
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus).
- Telegram `sendVoice` accepts OGG/MP3/M4A; use OpenAI/ElevenLabs if you need
guaranteed Opus voice messages.
guaranteed Opus voice notes. citeturn1search1
- If the configured Microsoft output format fails, OpenClaw retries with MP3.
OpenAI/ElevenLabs output formats are fixed per channel (see above).
OpenAI/ElevenLabs formats are fixed; Telegram expects Opus for voice-note UX.
## Auto-TTS behavior
@@ -391,8 +391,8 @@ Notes:
## Agent tool
The `tts` tool converts text to speech and returns an audio attachment for
reply delivery. When the channel is Feishu, Matrix, Telegram, or WhatsApp,
the audio is delivered as a voice message rather than a file attachment.
reply delivery. When the result is Telegram-compatible, OpenClaw marks it for
voice-bubble delivery.
## Gateway RPC

View File

@@ -33,14 +33,10 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
## Control UI agents tools panel
- The Control UI `/agents` Tools panel has two separate views:
- **Available Right Now** uses `tools.effective(sessionKey=...)` and shows what the current
session can actually use at runtime, including core, plugin, and channel-owned tools.
- **Tool Configuration** uses `tools.catalog` and stays focused on profiles, overrides, and
catalog semantics.
- Runtime availability is session-scoped. Switching sessions on the same agent can change the
**Available Right Now** list.
- The config editor does not imply runtime availability; effective access still follows policy
- The Control UI `/agents` Tools panel fetches a runtime catalog via `tools.catalog` and labels each
tool as `core` or `plugin:<id>` (plus `optional` for optional plugin tools).
- If `tools.catalog` is unavailable, the panel falls back to a built-in static list.
- The panel edits profile and override config, but effective runtime access still follows policy
precedence (`allow`/`deny`, per-agent and provider/channel overrides).
## Remote use

View File

@@ -1,557 +0,0 @@
# Plugin SDK Namespaces Plan
## TL;DR
OpenClaw should introduce a few clear SDK namespaces like `plugin`, `channel`,
and `provider`, instead of keeping so much of the public surface flat.
The safe way to do that is:
- add thin ESM facade entrypoints, not TypeScript `namespace`
- keep the root `openclaw/plugin-sdk` surface small
- replace flat registration methods on `OpenClawPluginApi` with namespace groups
- ship the cutover in one coordinated release instead of dragging old flat APIs
along
- forbid leaf modules from importing back through namespace facades
That gives plugin authors a cleaner SDK that feels closer to VS Code, without
turning the SDK into a giant barrel or creating circular import problems.
## Goal
Introduce public namespaces to the OpenClaw Plugin SDK so the surface feels
closer to the VS Code extension API, while keeping the implementation tight,
isolated, and resistant to circular imports.
This plan is about the public SDK shape. It is not a proposal to merge
everything into one giant barrel.
## Why This Is Worth Doing
Today the Plugin SDK has three visible problems:
- The public package export surface is large and mostly flat.
- `src/plugin-sdk/core.ts` and `src/plugin-sdk/index.ts` carry too many
unrelated meanings.
- `OpenClawPluginApi` is still a flat registration API even though
`api.runtime` already proves grouped namespaces work well.
The result is harder docs, harder discovery, and too many helper names that
look equally important even when they are not.
## Current Facts In The Repo
- Package exports are generated from a flat entrypoint list in
`src/plugin-sdk/entrypoints.ts` and `scripts/lib/plugin-sdk-entrypoints.json`.
- The root `openclaw/plugin-sdk` entry is intentionally tiny in
`src/plugin-sdk/index.ts`.
- `api.runtime` is already a successful namespace model. It groups behavior as
`agent`, `subagent`, `media`, `imageGeneration`, `webSearch`, `tools`,
`channel`, `events`, `logging`, `state`, `tts`, `mediaUnderstanding`, and
`modelAuth` in `src/plugins/runtime/index.ts`.
- The main plugin registration API is still flat in `OpenClawPluginApi` in
`src/plugins/types.ts`.
- The concrete API object is assembled in `src/plugins/registry.ts`, and a
second partial copy exists in `src/plugins/captured-registration.ts`.
Those facts suggest a path that is low-risk:
- keep leaf modules as the source of truth
- add namespace facades on top
- cut docs, examples, and templates over in the same release as the namespace
model
## Design Principles
### 1. Do Not Use TypeScript `namespace`
Use normal ESM modules and package exports.
The SDK already ships as package export subpaths. The namespace model should be
implemented as public facade modules, not TypeScript `namespace` syntax.
### 2. Keep The Root Tiny
Do not turn `openclaw/plugin-sdk` into a giant VS Code-style monolith.
The closest safe equivalent is:
- a tiny root for shared types and a few universal values
- a small number of explicit namespace entrypoints
- optional ergonomic aggregation only after the namespace surfaces settle
### 3. Namespace Facades Must Be Thin
Namespace entrypoints should contain no real business logic.
They should only:
- re-export stable leaves
- assemble small namespace objects
That keeps cycles and accidental coupling down.
### 4. Types Stay Direct And Easy To Import
Like VS Code, namespaces should mostly group behavior. Common types should stay
directly importable from the root or the owning domain surface.
Examples:
- `ChannelPlugin`
- `ProviderPlugin`
- `OpenClawPluginApi`
- `PluginRuntime`
### 5. Do Not Namespace Everything At Once
Only namespace areas that already have a clear public identity.
Phase 1 should focus on:
- `plugin`
- `channel`
- `provider`
`runtime` already has a good public namespace shape on `api.runtime` and should
not be reopened as a giant package-export family in the first pass.
## Proposed Public Model
### Namespace Entry Points
Canonical public entrypoints:
- `openclaw/plugin-sdk/plugin`
- `openclaw/plugin-sdk/channel`
- `openclaw/plugin-sdk/provider`
- `openclaw/plugin-sdk/runtime`
- `openclaw/plugin-sdk/testing`
What each should mean:
- `plugin`
- plugin entry helpers
- shared plugin definition helpers
- plugin-facing config schema helpers that are truly universal
- `channel`
- channel entry helpers
- chat-channel builders
- stable channel-facing contracts and helpers
- `provider`
- provider entry helpers
- auth, catalog, models, onboard, stream, usage, and provider registration helpers
- `runtime`
- the existing `api.runtime` story and runtime-related public helpers that are
truly stable
- `testing`
- plugin author testing helpers
### Nested Leaves
Under those namespaces, the long-term canonical leaves should become nested:
- `openclaw/plugin-sdk/channel/setup`
- `openclaw/plugin-sdk/channel/pairing`
- `openclaw/plugin-sdk/channel/reply-pipeline`
- `openclaw/plugin-sdk/channel/contract`
- `openclaw/plugin-sdk/channel/targets`
- `openclaw/plugin-sdk/channel/actions`
- `openclaw/plugin-sdk/channel/inbound`
- `openclaw/plugin-sdk/channel/lifecycle`
- `openclaw/plugin-sdk/channel/policy`
- `openclaw/plugin-sdk/channel/feedback`
- `openclaw/plugin-sdk/channel/config-schema`
- `openclaw/plugin-sdk/channel/config-helpers`
- `openclaw/plugin-sdk/provider/auth`
- `openclaw/plugin-sdk/provider/catalog`
- `openclaw/plugin-sdk/provider/models`
- `openclaw/plugin-sdk/provider/onboard`
- `openclaw/plugin-sdk/provider/stream`
- `openclaw/plugin-sdk/provider/usage`
- `openclaw/plugin-sdk/provider/web-search`
Not every current flat subpath needs a namespaced replacement. The goal is to
promote the stable public domains, not to preserve every current export forever.
## What Happens To `core`
`core` is overloaded today. In a namespace model it should shrink, not grow.
Target split:
- plugin-wide entry helpers move toward `plugin`
- channel builders and channel-oriented shared helpers move toward `channel`
- `core` stops being a first-class public destination and shrinks to the
smallest possible remaining shared surface
Rule: no new public API should be added to `core` once namespace entrypoints
exist.
## Proposed `OpenClawPluginApi` Shape
Keep context fields flat:
- `id`
- `name`
- `version`
- `description`
- `source`
- `rootDir`
- `registrationMode`
- `config`
- `pluginConfig`
- `runtime`
- `logger`
- `resolvePath`
Move registration behavior behind namespaces:
| Current flat method | Proposed namespace location |
| ------------------------------------ | ----------------------------------------- |
| `registerTool` | `api.tool.register` |
| `registerHook` | `api.hook.register` |
| `on` | `api.hook.on` |
| `registerHttpRoute` | `api.http.registerRoute` |
| `registerChannel` | `api.channel.register` |
| `registerProvider` | `api.provider.register` |
| `registerSpeechProvider` | `api.provider.registerSpeech` |
| `registerMediaUnderstandingProvider` | `api.provider.registerMediaUnderstanding` |
| `registerImageGenerationProvider` | `api.provider.registerImageGeneration` |
| `registerWebSearchProvider` | `api.provider.registerWebSearch` |
| `registerGatewayMethod` | `api.gateway.registerMethod` |
| `registerCli` | `api.cli.register` |
| `registerService` | `api.service.register` |
| `registerInteractiveHandler` | `api.interactive.register` |
| `registerCommand` | `api.command.register` |
| `registerContextEngine` | `api.contextEngine.register` |
| `registerMemoryPromptSection` | `api.memory.registerPromptSection` |
The cutover should replace the flat methods in one coordinated change.
That gives plugin authors a clearer public shape and avoids carrying two public
registration models at the same time.
## Example Public Usage
Proposed style:
```ts
import { definePluginEntry } from "openclaw/plugin-sdk/plugin";
import { channel } from "openclaw/plugin-sdk/channel";
import { provider } from "openclaw/plugin-sdk/provider";
import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk";
const chatPlugin: ChannelPlugin = channel.createChatPlugin({
id: "demo",
/* ... */
});
export default definePluginEntry({
id: "demo",
register(api: OpenClawPluginApi) {
api.channel.register(chatPlugin);
api.command.register({
name: "status",
description: "Show plugin status",
run: async () => ({ text: "ok" }),
});
},
});
```
This is close to the VS Code mental model:
- grouped behavior
- direct types
- obvious public areas
without requiring a single monolithic root import.
## Optional Ergonomic Surface
If the project later wants the closest possible VS Code feel, add a dedicated
opt-in facade such as `openclaw/plugin-sdk/sdk`.
That facade can assemble:
- `plugin`
- `channel`
- `provider`
- `runtime`
- `testing`
It should not be phase 1.
Why:
- it is the highest-risk barrel from a cycle and weight perspective
- it is easier to add once the namespace surfaces already exist
- it preserves the root `openclaw/plugin-sdk` entry as a small type-oriented
surface
## Internal Implementation Rules
These rules are the important part. Without them, namespaces will rot into
barrels and cycles.
### Rule 1: Namespace Facades Are One-Way
Namespace entrypoints may import leaf modules.
Leaf modules may not import their namespace entrypoint.
Examples:
- allowed: `src/plugin-sdk/channel.ts` importing `./channel-setup.ts`
- forbidden: `src/plugin-sdk/channel-setup.ts` importing `./channel.ts`
### Rule 1A: Allowed Dependency Directions Must Be Explicit
The allowed directions should be:
- namespace facade -> leaves in the same namespace
- leaf -> local implementation helpers
- leaf -> dedicated shared internal leaf
- leaf -> another leaf in the same namespace only by direct relative import,
never through the namespace facade
The forbidden directions should be:
- leaf -> its own namespace facade
- leaf -> another namespace facade
- namespace facade -> another namespace facade
- channel leaf -> provider leaf, or provider leaf -> channel leaf, unless the
dependency is first extracted into a shared internal leaf
Short version:
- facades point downward
- leaves never point back upward
- cross-namespace sharing must go sideways through a shared internal leaf, not
directly through another public namespace
### Rule 1B: If Two Namespaces Need Each Other, Extract A Shared Leaf
If `channel` and `provider` start needing each other directly, that is the sign
that the seam is wrong.
Do not allow:
- `src/plugin-sdk/channel/*` importing from `src/plugin-sdk/provider/*`
- `src/plugin-sdk/provider/*` importing from `src/plugin-sdk/channel/*`
Instead:
- extract the shared logic into a dedicated internal leaf
- let both sides depend on that leaf
- keep the public namespaces separate
This is the main cycle-prevention rule. Shared logic moves to a lower layer
before it creates a back-edge.
### Rule 2: No Public-Specifier Self-Imports Inside The SDK
Files inside `src/plugin-sdk/**` should never import from
`openclaw/plugin-sdk/...`.
They should import local source files directly.
### Rule 3: Shared Code Lives In Shared Leaves
If `channel` and `provider` need the same implementation detail, move that code
to a shared leaf instead of importing one namespace from the other.
Good shared homes:
- a dedicated internal shared leaf
- a very small shared core leaf only if it has a precise, stable reason to
exist
- existing domain-neutral helpers
Bad pattern:
- `provider/*` importing from `channel/index`
- `channel/*` importing from `provider/index`
### Rule 4: Assemble The API Surface Once
`OpenClawPluginApi` should be built by one canonical factory.
`src/plugins/registry.ts` and `src/plugins/captured-registration.ts` should stop
hand-building separate versions of the API object.
That factory can expose:
- the namespaced shape only
from the same underlying implementation.
### Rule 5: Namespace Entry Files Stay Small
Namespace facades should stay close to pure exports. If a namespace file grows
real orchestration logic, split that logic back into leaf modules.
### Dependency Shape
The intended import graph is:
```text
public facade
-> same-namespace leaves
-> local helpers
-> shared internal leaves
```
Not this:
```text
channel facade -> provider facade
channel leaf -> channel facade
provider leaf -> channel leaf
```
Concrete examples:
- allowed: `src/plugin-sdk/channel.ts` -> `./channel/setup.ts`
- allowed: `src/plugin-sdk/channel/setup.ts` -> `./_internal/channel-shared.ts`
- allowed: `src/plugin-sdk/provider/auth.ts` -> `../_internal/provider-shared.ts`
- forbidden: `src/plugin-sdk/channel/setup.ts` -> `./channel.ts`
- forbidden: `src/plugin-sdk/channel/setup.ts` -> `../provider/index.ts`
- forbidden: `src/plugin-sdk/channel.ts` -> `./provider.ts`
## Migration Strategy
This should be a cutover, not a long overlap period.
That means:
- one coordinated release
- one migration guide
- one docs/templates/test update
- one public SDK shape after the release
## Phase 1: Extract The Canonical API Builder
Do this first, before changing the public surface.
Why:
- it removes duplicated API assembly
- it gives one place to switch the public shape
- it reduces cutover risk
Implementation:
- extract one canonical API builder from `src/plugins/registry.ts` and
`src/plugins/captured-registration.ts`
- make that builder assemble the new namespaced registration API
## Phase 2: Add Canonical Namespace Entrypoints
Add:
- `plugin`
- `channel`
- `provider`
as thin public facades over existing flat leaves.
Implementation detail:
- the first pass can re-export current flat files
- do not move source layout and package exports in the same commit if it can be
avoided
Examples:
- `src/plugin-sdk/channel/setup.ts` can initially re-export from
`../channel-setup.js`
- `src/plugin-sdk/provider/auth.ts` can initially re-export from
`../provider-auth.js`
This lets the public namespace story land before the internal source move,
without forcing all implementation files to move in the same commit.
## Phase 3: Cut Public API, Docs, And Templates Together
In the same release:
- docs prefer namespaced entrypoints
- templates prefer namespaced imports
- tests and examples switch to the namespaced shape
- `OpenClawPluginApi` changes to the namespaced registration model
- flat registration methods are removed instead of carried as aliases
## Phase 4: Remove The Old Public Story
After the cutover release lands:
- stop documenting superseded flat leaves as public API
- keep only the namespace model in author-facing docs
- remove any leftover flat registration surface that survived only as
transitional scaffolding during implementation
## What Should Not Be Namespaced In Phase 1
To keep the refactor tight, do not force these into the first milestone:
- every `*-runtime` helper subpath
- extension-branded public subpaths
- one-off utilities that do not yet have a stable domain home
- the root `openclaw/plugin-sdk` barrel
If a subpath is only public because history leaked it, namespace work should not
promote it.
## Guardrails And Validation
The namespace rollout should ship with explicit checks.
### Existing Checks To Reuse
- `src/plugin-sdk/subpaths.test.ts`
- `src/plugin-sdk/runtime-api-guardrails.test.ts`
- `pnpm build` for `[CIRCULAR_REEXPORT]` warnings
- `pnpm plugin-sdk:api:check`
### New Checks To Add
- namespace facade files may only re-export or compose approved leaves
- leaf files under a namespace may not import their parent `index` facade
- leaf files under one namespace may not import another namespace facade
- cross-namespace leaf imports should fail unless the target is an approved
shared internal leaf
- namespace facades may not import other namespace facades
- no new API should be added to `core` once namespace facades exist
- `OpenClawPluginApi` must not expose both flat and namespaced registration
methods after cutover
## Recommended End State
The elegant end state is:
- a tiny root
- a few first-class namespaces
- direct types
- a grouped `api` registration surface
- stable leaves under each namespace
- no reverse imports from leaves back into namespace facades
That gives OpenClaw a VS Code-like feel where the public SDK has clear domains,
but still respects the repo's existing build, lazy-loading, and package-boundary
constraints.
## Short Recommendation
If this work starts soon, the first implementation step should be:
1. extract one canonical `OpenClawPluginApi` builder
2. switch that builder to the namespaced registration shape
3. add `plugin`, `channel`, and `provider` facade entrypoints
4. cut docs, templates, and examples over in the same release
5. remove the old flat registration story instead of maintaining dual public APIs
That sequence keeps the refactor elegant and minimizes the chance that
namespaces become another layer of accidental coupling.

View File

@@ -38,53 +38,6 @@ afterAll(async () => {
await cleanupMockRuntimeFixtures();
});
async function expectSessionEnsureFallback(params: {
sessionKey: string;
env?: Record<string, string>;
expectNewAfterStatus: boolean;
expectedRecordId?: string;
}) {
const previousEnv = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(params.env ?? {})) {
previousEnv.set(key, process.env[key]);
process.env[key] = value;
}
try {
const { runtime, logPath } = await createMockRuntimeFixture();
const handle = await runtime.ensureSession({
sessionKey: params.sessionKey,
agent: "codex",
mode: "persistent",
});
expect(handle.backend).toBe("acpx");
if (params.expectedRecordId) {
expect(handle.acpxRecordId).toBe(params.expectedRecordId);
}
const logs = await readMockRuntimeLogEntries(logPath);
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
const newIndex = logs.findIndex((entry) => entry.kind === "new");
expect(ensureIndex).toBeGreaterThanOrEqual(0);
expect(statusIndex).toBeGreaterThan(ensureIndex);
if (params.expectNewAfterStatus) {
expect(newIndex).toBeGreaterThan(statusIndex);
} else {
expect(newIndex).toBe(-1);
}
} finally {
for (const [key, value] of previousEnv.entries()) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
describe("AcpxRuntime", () => {
it("passes the shared ACP adapter contract suite", async () => {
const fixture = await createMockRuntimeFixture();
@@ -202,38 +155,87 @@ describe("AcpxRuntime", () => {
});
it("replaces dead named sessions returned by sessions ensure", async () => {
await expectSessionEnsureFallback({
sessionKey: "agent:codex:acp:dead-session",
env: {
MOCK_ACPX_STATUS_STATUS: "dead",
MOCK_ACPX_STATUS_SUMMARY: "queue owner unavailable",
},
expectNewAfterStatus: true,
});
process.env.MOCK_ACPX_STATUS_STATUS = "dead";
process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable";
try {
const { runtime, logPath } = await createMockRuntimeFixture();
const sessionKey = "agent:codex:acp:dead-session";
const handle = await runtime.ensureSession({
sessionKey,
agent: "codex",
mode: "persistent",
});
expect(handle.backend).toBe("acpx");
const logs = await readMockRuntimeLogEntries(logPath);
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
const newIndex = logs.findIndex((entry) => entry.kind === "new");
expect(ensureIndex).toBeGreaterThanOrEqual(0);
expect(statusIndex).toBeGreaterThan(ensureIndex);
expect(newIndex).toBeGreaterThan(statusIndex);
} finally {
delete process.env.MOCK_ACPX_STATUS_STATUS;
delete process.env.MOCK_ACPX_STATUS_SUMMARY;
}
});
it("reuses a live named session when sessions ensure exits before returning identifiers", async () => {
await expectSessionEnsureFallback({
sessionKey: "agent:codex:acp:ensure-fallback-alive",
env: {
MOCK_ACPX_ENSURE_EXIT_1: "1",
MOCK_ACPX_STATUS_STATUS: "alive",
},
expectNewAfterStatus: false,
expectedRecordId: "rec-agent:codex:acp:ensure-fallback-alive",
});
process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1";
process.env.MOCK_ACPX_STATUS_STATUS = "alive";
try {
const { runtime, logPath } = await createMockRuntimeFixture();
const sessionKey = "agent:codex:acp:ensure-fallback-alive";
const handle = await runtime.ensureSession({
sessionKey,
agent: "codex",
mode: "persistent",
});
expect(handle.backend).toBe("acpx");
expect(handle.acpxRecordId).toBe("rec-" + sessionKey);
const logs = await readMockRuntimeLogEntries(logPath);
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
const newIndex = logs.findIndex((entry) => entry.kind === "new");
expect(ensureIndex).toBeGreaterThanOrEqual(0);
expect(statusIndex).toBeGreaterThan(ensureIndex);
expect(newIndex).toBe(-1);
} finally {
delete process.env.MOCK_ACPX_ENSURE_EXIT_1;
delete process.env.MOCK_ACPX_STATUS_STATUS;
}
});
it("creates a fresh named session when sessions ensure exits and status is dead", async () => {
await expectSessionEnsureFallback({
sessionKey: "agent:codex:acp:ensure-fallback-dead",
env: {
MOCK_ACPX_ENSURE_EXIT_1: "1",
MOCK_ACPX_STATUS_STATUS: "dead",
MOCK_ACPX_STATUS_SUMMARY: "queue owner unavailable",
},
expectNewAfterStatus: true,
});
process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1";
process.env.MOCK_ACPX_STATUS_STATUS = "dead";
process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable";
try {
const { runtime, logPath } = await createMockRuntimeFixture();
const sessionKey = "agent:codex:acp:ensure-fallback-dead";
const handle = await runtime.ensureSession({
sessionKey,
agent: "codex",
mode: "persistent",
});
expect(handle.backend).toBe("acpx");
const logs = await readMockRuntimeLogEntries(logPath);
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
const newIndex = logs.findIndex((entry) => entry.kind === "new");
expect(ensureIndex).toBeGreaterThanOrEqual(0);
expect(statusIndex).toBeGreaterThan(ensureIndex);
expect(newIndex).toBeGreaterThan(statusIndex);
} finally {
delete process.env.MOCK_ACPX_ENSURE_EXIT_1;
delete process.env.MOCK_ACPX_STATUS_STATUS;
delete process.env.MOCK_ACPX_STATUS_SUMMARY;
}
});
it("serializes text plus image attachments into ACP prompt blocks", async () => {

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { resolveBlueBubblesAccount } from "./accounts.js";
describe("resolveBlueBubblesAccount", () => {
it("treats SecretRef passwords as configured when serverUrl exists", () => {
const resolved = resolveBlueBubblesAccount({
cfg: {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://localhost:1234",
password: {
source: "env",
provider: "default",
id: "BLUEBUBBLES_PASSWORD",
},
},
},
},
});
expect(resolved.configured).toBe(true);
expect(resolved.baseUrl).toBe("http://localhost:1234");
});
});

View File

@@ -1,69 +0,0 @@
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import {
listBlueBubblesAccountIds,
type ResolvedBlueBubblesAccount,
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import type { ChannelPlugin } from "./runtime-api.js";
import { normalizeBlueBubblesHandle } from "./targets.js";
export const bluebubblesMeta = {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles (macOS app)",
detailLabel: "BlueBubbles",
docsPath: "/channels/bluebubbles",
docsLabel: "bluebubbles",
blurb: "iMessage via the BlueBubbles mac app + REST API.",
systemImage: "bubble.left.and.text.bubble.right",
aliases: ["bb"],
order: 75,
preferOver: ["imessage"],
};
export const bluebubblesCapabilities: ChannelPlugin<ResolvedBlueBubblesAccount>["capabilities"] = {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
edit: true,
unsend: true,
reply: true,
effects: true,
groupManagement: true,
};
export const bluebubblesReload = { configPrefixes: ["channels.bluebubbles"] };
export const bluebubblesConfigSchema = buildChannelConfigSchema(BlueBubblesConfigSchema);
export const bluebubblesConfigAdapter =
createScopedChannelConfigAdapter<ResolvedBlueBubblesAccount>({
sectionKey: "bluebubbles",
listAccountIds: listBlueBubblesAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount),
defaultAccountId: resolveDefaultBlueBubblesAccountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
}),
});
export function describeBlueBubblesAccount(account: ResolvedBlueBubblesAccount) {
return describeAccountSnapshot({
account,
configured: account.configured,
extra: {
baseUrl: account.baseUrl,
},
});
}

View File

@@ -1,31 +1,81 @@
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { type ResolvedBlueBubblesAccount } from "./accounts.js";
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import {
bluebubblesCapabilities,
bluebubblesConfigAdapter,
bluebubblesConfigSchema,
bluebubblesMeta,
bluebubblesReload,
describeBlueBubblesAccount,
} from "./channel-shared.js";
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import {
listBlueBubblesAccountIds,
type ResolvedBlueBubblesAccount,
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import { blueBubblesSetupAdapter } from "./setup-core.js";
import { blueBubblesSetupWizard } from "./setup-surface.js";
import { normalizeBlueBubblesHandle } from "./targets.js";
const meta = {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles (macOS app)",
detailLabel: "BlueBubbles",
docsPath: "/channels/bluebubbles",
docsLabel: "bluebubbles",
blurb: "iMessage via the BlueBubbles mac app + REST API.",
systemImage: "bubble.left.and.text.bubble.right",
aliases: ["bb"],
order: 75,
preferOver: ["imessage"],
} as const;
const bluebubblesConfigAdapter = createScopedChannelConfigAdapter<ResolvedBlueBubblesAccount>({
sectionKey: "bluebubbles",
listAccountIds: listBlueBubblesAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount),
defaultAccountId: resolveDefaultBlueBubblesAccountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
}),
});
export const bluebubblesSetupPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
id: "bluebubbles",
meta: {
...bluebubblesMeta,
aliases: [...bluebubblesMeta.aliases],
preferOver: [...bluebubblesMeta.preferOver],
...meta,
aliases: [...meta.aliases],
preferOver: [...meta.preferOver],
},
capabilities: bluebubblesCapabilities,
reload: bluebubblesReload,
configSchema: bluebubblesConfigSchema,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
edit: true,
unsend: true,
reply: true,
effects: true,
groupManagement: true,
},
reload: { configPrefixes: ["channels.bluebubbles"] },
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
setupWizard: blueBubblesSetupWizard,
config: {
...bluebubblesConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) => describeBlueBubblesAccount(account),
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
extra: {
baseUrl: account.baseUrl,
},
}),
},
setup: blueBubblesSetupAdapter,
};

View File

@@ -1,4 +1,10 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import {
@@ -19,21 +25,15 @@ import {
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { bluebubblesMessageActions } from "./actions.js";
import {
bluebubblesCapabilities,
bluebubblesConfigAdapter,
bluebubblesConfigSchema,
bluebubblesMeta as meta,
bluebubblesReload,
describeBlueBubblesAccount,
} from "./channel-shared.js";
import type { BlueBubblesProbe } from "./channel.runtime.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./group-policy.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "./runtime-api.js";
import {
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
collectBlueBubblesStatusIssues,
DEFAULT_ACCOUNT_ID,
@@ -57,6 +57,20 @@ const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport(
"blueBubblesChannelRuntime",
);
const bluebubblesConfigAdapter = createScopedChannelConfigAdapter<ResolvedBlueBubblesAccount>({
sectionKey: "bluebubbles",
listAccountIds: listBlueBubblesAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveBlueBubblesAccount),
defaultAccountId: resolveDefaultBlueBubblesAccountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
}),
});
const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver<ResolvedBlueBubblesAccount>({
channelKey: "bluebubbles",
resolvePolicy: (account) => account.config.dmPolicy,
@@ -76,23 +90,53 @@ const collectBlueBubblesSecurityWarnings =
mentionGated: false,
});
const meta = {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles (macOS app)",
detailLabel: "BlueBubbles",
docsPath: "/channels/bluebubbles",
docsLabel: "bluebubbles",
blurb: "iMessage via the BlueBubbles mac app + REST API.",
systemImage: "bubble.left.and.text.bubble.right",
aliases: ["bb"],
order: 75,
preferOver: ["imessage"],
};
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBubblesProbe> =
createChatChannelPlugin<ResolvedBlueBubblesAccount, BlueBubblesProbe>({
base: {
id: "bluebubbles",
meta,
capabilities: bluebubblesCapabilities,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
edit: true,
unsend: true,
reply: true,
effects: true,
groupManagement: true,
},
groups: {
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
resolveToolPolicy: resolveBlueBubblesGroupToolPolicy,
},
reload: bluebubblesReload,
configSchema: bluebubblesConfigSchema,
reload: { configPrefixes: ["channels.bluebubbles"] },
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
setupWizard: blueBubblesSetupWizard,
config: {
...bluebubblesConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account): ChannelAccountSnapshot => describeBlueBubblesAccount(account),
describeAccount: (account): ChannelAccountSnapshot =>
describeAccountSnapshot({
account,
configured: account.configured,
extra: {
baseUrl: account.baseUrl,
},
}),
},
actions: bluebubblesMessageActions,
messaging: {

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { BlueBubblesConfigSchema } from "./config-schema.js";
describe("BlueBubblesConfigSchema", () => {
it("accepts account config when serverUrl and password are both set", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
password: "secret", // pragma: allowlist secret
});
expect(parsed.success).toBe(true);
});
it("accepts SecretRef password when serverUrl is set", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
password: {
source: "env",
provider: "default",
id: "BLUEBUBBLES_PASSWORD",
},
});
expect(parsed.success).toBe(true);
});
it("requires password when top-level serverUrl is configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
});
expect(parsed.success).toBe(false);
if (parsed.success) {
return;
}
expect(parsed.error.issues[0]?.path).toEqual(["password"]);
expect(parsed.error.issues[0]?.message).toBe(
"password is required when serverUrl is configured",
);
});
it("requires password when account serverUrl is configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
accounts: {
work: {
serverUrl: "http://localhost:1234",
},
},
});
expect(parsed.success).toBe(false);
if (parsed.success) {
return;
}
expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]);
expect(parsed.error.issues[0]?.message).toBe(
"password is required when serverUrl is configured",
);
});
it("allows password omission when serverUrl is not configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
accounts: {
work: {
name: "Work iMessage",
},
},
});
expect(parsed.success).toBe(true);
});
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./group-policy.js";
describe("bluebubbles group policy", () => {
it("uses generic channel group policy helpers", () => {
const cfg = {
channels: {
bluebubbles: {
groups: {
"chat:primary": {
requireMention: false,
tools: { deny: ["exec"] },
},
"*": {
requireMention: true,
tools: { allow: ["message.send"] },
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false);
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:primary" })).toEqual({
deny: ["exec"],
});
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({
allow: ["message.send"],
});
});
});

View File

@@ -8,20 +8,6 @@ import {
type WizardPrompter,
} from "../../../test/helpers/extensions/setup-wizard.js";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./group-policy.js";
import {
inferBlueBubblesTargetChatType,
isAllowedBlueBubblesSender,
looksLikeBlueBubblesExplicitTargetId,
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesMessagingTarget,
parseBlueBubblesAllowTarget,
parseBlueBubblesTarget,
} from "./targets.js";
import { DEFAULT_WEBHOOK_PATH } from "./webhook-shared.js";
async function createBlueBubblesConfigureAdapter() {
@@ -152,337 +138,3 @@ describe("bluebubbles setup surface", () => {
expect(next?.channels?.bluebubbles?.enabled).toBe(false);
});
});
describe("resolveBlueBubblesAccount", () => {
it("treats SecretRef passwords as configured when serverUrl exists", () => {
const resolved = resolveBlueBubblesAccount({
cfg: {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://localhost:1234",
password: {
source: "env",
provider: "default",
id: "BLUEBUBBLES_PASSWORD",
},
},
},
},
});
expect(resolved.configured).toBe(true);
expect(resolved.baseUrl).toBe("http://localhost:1234");
});
});
describe("BlueBubblesConfigSchema", () => {
it("accepts account config when serverUrl and password are both set", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
password: "secret", // pragma: allowlist secret
});
expect(parsed.success).toBe(true);
});
it("accepts SecretRef password when serverUrl is set", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
password: {
source: "env",
provider: "default",
id: "BLUEBUBBLES_PASSWORD",
},
});
expect(parsed.success).toBe(true);
});
it("requires password when top-level serverUrl is configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
serverUrl: "http://localhost:1234",
});
expect(parsed.success).toBe(false);
if (parsed.success) {
return;
}
expect(parsed.error.issues[0]?.path).toEqual(["password"]);
expect(parsed.error.issues[0]?.message).toBe(
"password is required when serverUrl is configured",
);
});
it("requires password when account serverUrl is configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
accounts: {
work: {
serverUrl: "http://localhost:1234",
},
},
});
expect(parsed.success).toBe(false);
if (parsed.success) {
return;
}
expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]);
expect(parsed.error.issues[0]?.message).toBe(
"password is required when serverUrl is configured",
);
});
it("allows password omission when serverUrl is not configured", () => {
const parsed = BlueBubblesConfigSchema.safeParse({
accounts: {
work: {
name: "Work iMessage",
},
},
});
expect(parsed.success).toBe(true);
});
});
describe("bluebubbles group policy", () => {
it("uses generic channel group policy helpers", () => {
const cfg = {
channels: {
bluebubbles: {
groups: {
"chat:primary": {
requireMention: false,
tools: { deny: ["exec"] },
},
"*": {
requireMention: true,
tools: { allow: ["message.send"] },
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false);
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:primary" })).toEqual({
deny: ["exec"],
});
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({
allow: ["message.send"],
});
});
});
describe("normalizeBlueBubblesMessagingTarget", () => {
it("normalizes chat_guid targets", () => {
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
});
it("normalizes group numeric targets to chat_id", () => {
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
});
it("strips provider prefix and normalizes handles", () => {
expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
"imessage:user@example.com",
);
});
it("extracts handle from DM chat_guid for cross-context matching", () => {
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe(
"+19257864429",
);
expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe(
"+15551234567",
);
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe(
"user@example.com",
);
});
it("preserves group chat_guid format", () => {
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe(
"chat_guid:iMessage;+;chat123456789",
);
});
it("normalizes raw chat_guid values", () => {
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
"chat_guid:iMessage;+;chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
});
it("normalizes chat<digits> pattern to chat_identifier format", () => {
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
"chat_identifier:chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123");
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789");
});
it("normalizes UUID/hex chat identifiers", () => {
expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(
"chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc",
);
expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(
"chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678",
);
});
});
describe("looksLikeBlueBubblesTargetId", () => {
it("accepts chat targets", () => {
expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true);
});
it("accepts email handles", () => {
expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true);
});
it("accepts phone numbers with punctuation", () => {
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
});
it("accepts raw chat_guid values", () => {
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
});
it("accepts chat<digits> pattern as chat_id", () => {
expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
});
it("accepts UUID/hex chat identifiers", () => {
expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true);
expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true);
});
it("rejects display names", () => {
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
});
});
describe("looksLikeBlueBubblesExplicitTargetId", () => {
it("treats explicit chat targets as immediate ids", () => {
expect(looksLikeBlueBubblesExplicitTargetId("chat_guid:ABC-123")).toBe(true);
expect(looksLikeBlueBubblesExplicitTargetId("imessage:+15551234567")).toBe(true);
});
it("prefers directory fallback for bare handles and phone numbers", () => {
expect(looksLikeBlueBubblesExplicitTargetId("+1 (555) 123-4567")).toBe(false);
expect(looksLikeBlueBubblesExplicitTargetId("user@example.com")).toBe(false);
});
});
describe("inferBlueBubblesTargetChatType", () => {
it("infers direct chat for handles and dm chat_guids", () => {
expect(inferBlueBubblesTargetChatType("+15551234567")).toBe("direct");
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;-;+15551234567")).toBe("direct");
});
it("infers group chat for explicit group targets", () => {
expect(inferBlueBubblesTargetChatType("chat_id:123")).toBe("group");
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;+;chat123")).toBe("group");
});
});
describe("parseBlueBubblesTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
});
expect(parseBlueBubblesTarget("chat123")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat123",
});
expect(parseBlueBubblesTarget("Chat456789")).toEqual({
kind: "chat_identifier",
chatIdentifier: "Chat456789",
});
});
it("parses UUID/hex chat identifiers as chat_identifier", () => {
expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesTarget("+19257864429")).toEqual({
kind: "handle",
to: "+19257864429",
service: "auto",
});
});
it("parses raw chat_guid format", () => {
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
kind: "chat_guid",
chatGuid: "iMessage;+;chat660250192681427962",
});
});
});
describe("parseBlueBubblesAllowTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
});
expect(parseBlueBubblesAllowTarget("chat123")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat123",
});
});
it("parses UUID/hex chat identifiers as chat_identifier", () => {
expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
kind: "handle",
handle: "+19257864429",
});
});
});
describe("isAllowedBlueBubblesSender", () => {
it("denies when allowFrom is empty", () => {
const allowed = isAllowedBlueBubblesSender({
allowFrom: [],
sender: "+15551234567",
});
expect(allowed).toBe(false);
});
it("allows wildcard entries", () => {
const allowed = isAllowedBlueBubblesSender({
allowFrom: ["*"],
sender: "+15551234567",
});
expect(allowed).toBe(true);
});
});

View File

@@ -0,0 +1,228 @@
import { describe, expect, it } from "vitest";
import {
inferBlueBubblesTargetChatType,
looksLikeBlueBubblesExplicitTargetId,
isAllowedBlueBubblesSender,
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesMessagingTarget,
parseBlueBubblesTarget,
parseBlueBubblesAllowTarget,
} from "./targets.js";
describe("normalizeBlueBubblesMessagingTarget", () => {
it("normalizes chat_guid targets", () => {
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
});
it("normalizes group numeric targets to chat_id", () => {
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
});
it("strips provider prefix and normalizes handles", () => {
expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
"imessage:user@example.com",
);
});
it("extracts handle from DM chat_guid for cross-context matching", () => {
// DM format: service;-;handle
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe(
"+19257864429",
);
expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe(
"+15551234567",
);
// Email handles
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe(
"user@example.com",
);
});
it("preserves group chat_guid format", () => {
// Group format: service;+;groupId
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe(
"chat_guid:iMessage;+;chat123456789",
);
});
it("normalizes raw chat_guid values", () => {
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
"chat_guid:iMessage;+;chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
});
it("normalizes chat<digits> pattern to chat_identifier format", () => {
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
"chat_identifier:chat660250192681427962",
);
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123");
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789");
});
it("normalizes UUID/hex chat identifiers", () => {
expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(
"chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc",
);
expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(
"chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678",
);
});
});
describe("looksLikeBlueBubblesTargetId", () => {
it("accepts chat targets", () => {
expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true);
});
it("accepts email handles", () => {
expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true);
});
it("accepts phone numbers with punctuation", () => {
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
});
it("accepts raw chat_guid values", () => {
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
});
it("accepts chat<digits> pattern as chat_id", () => {
expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
});
it("accepts UUID/hex chat identifiers", () => {
expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true);
expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true);
});
it("rejects display names", () => {
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
});
});
describe("looksLikeBlueBubblesExplicitTargetId", () => {
it("treats explicit chat targets as immediate ids", () => {
expect(looksLikeBlueBubblesExplicitTargetId("chat_guid:ABC-123")).toBe(true);
expect(looksLikeBlueBubblesExplicitTargetId("imessage:+15551234567")).toBe(true);
});
it("prefers directory fallback for bare handles and phone numbers", () => {
expect(looksLikeBlueBubblesExplicitTargetId("+1 (555) 123-4567")).toBe(false);
expect(looksLikeBlueBubblesExplicitTargetId("user@example.com")).toBe(false);
});
});
describe("inferBlueBubblesTargetChatType", () => {
it("infers direct chat for handles and dm chat_guids", () => {
expect(inferBlueBubblesTargetChatType("+15551234567")).toBe("direct");
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;-;+15551234567")).toBe("direct");
});
it("infers group chat for explicit group targets", () => {
expect(inferBlueBubblesTargetChatType("chat_id:123")).toBe("group");
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;+;chat123")).toBe("group");
});
});
describe("parseBlueBubblesTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
});
expect(parseBlueBubblesTarget("chat123")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat123",
});
expect(parseBlueBubblesTarget("Chat456789")).toEqual({
kind: "chat_identifier",
chatIdentifier: "Chat456789",
});
});
it("parses UUID/hex chat identifiers as chat_identifier", () => {
expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesTarget("+19257864429")).toEqual({
kind: "handle",
to: "+19257864429",
service: "auto",
});
});
it("parses raw chat_guid format", () => {
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
kind: "chat_guid",
chatGuid: "iMessage;+;chat660250192681427962",
});
});
});
describe("parseBlueBubblesAllowTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
});
expect(parseBlueBubblesAllowTarget("chat123")).toEqual({
kind: "chat_identifier",
chatIdentifier: "chat123",
});
});
it("parses UUID/hex chat identifiers as chat_identifier", () => {
expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
kind: "chat_identifier",
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
});
expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
kind: "chat_identifier",
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
});
});
it("parses explicit chat_id: prefix", () => {
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
});
it("parses phone numbers as handles", () => {
expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
kind: "handle",
handle: "+19257864429",
});
});
});
describe("isAllowedBlueBubblesSender", () => {
it("denies when allowFrom is empty", () => {
const allowed = isAllowedBlueBubblesSender({
allowFrom: [],
sender: "+15551234567",
});
expect(allowed).toBe(false);
});
it("allows wildcard entries", () => {
const allowed = isAllowedBlueBubblesSender({
allowFrom: ["*"],
sender: "+15551234567",
});
expect(allowed).toBe(true);
});
});

View File

@@ -1,11 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { __testing, createBraveWebSearchProvider } from "./brave-web-search-provider.js";
import { describe, expect, it } from "vitest";
import { __testing } from "./brave-web-search-provider.js";
describe("brave web search provider", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("normalizes brave language parameters and swaps reversed ui/search inputs", () => {
expect(
__testing.normalizeBraveLanguageParams({
@@ -53,29 +49,4 @@ describe("brave web search provider", () => {
},
]);
});
it("returns validation errors for invalid date ranges", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "BSA...",
brave: { apiKey: "BSA..." },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({
query: "latest gpu news",
date_after: "2026-03-20",
date_before: "2026-03-01",
});
expect(result).toMatchObject({
error: "invalid_date_range",
});
});
});

View File

@@ -6,7 +6,7 @@ import {
formatCliCommand,
mergeScopedSearchConfig,
normalizeFreshness,
parseIsoDateRange,
normalizeToIsoDate,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
@@ -478,17 +478,29 @@ function createBraveToolDefinition(
docs: "https://docs.openclaw.ai/tools/web",
};
}
const parsedDateRange = parseIsoDateRange({
rawDateAfter,
rawDateBefore,
invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.",
invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.",
invalidDateRangeMessage: "date_after must be before date_before.",
});
if ("error" in parsedDateRange) {
return parsedDateRange;
const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined;
if (rawDateAfter && !dateAfter) {
return {
error: "invalid_date",
message: "date_after must be YYYY-MM-DD format.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined;
if (rawDateBefore && !dateBefore) {
return {
error: "invalid_date",
message: "date_before must be YYYY-MM-DD format.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (dateAfter && dateBefore && dateAfter > dateBefore) {
return {
error: "invalid_date_range",
message: "date_after must be before date_before.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const { dateAfter, dateBefore } = parsedDateRange;
const cacheKey = buildSearchCacheKey([
"brave",

View File

@@ -8,11 +8,7 @@ export {
type DeviceBootstrapProfile,
} from "openclaw/plugin-sdk/device-bootstrap";
export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
export {
resolveGatewayBindUrl,
resolveGatewayPort,
resolveTailnetHostWithRunner,
} from "openclaw/plugin-sdk/core";
export { resolveGatewayBindUrl, resolveTailnetHostWithRunner } from "openclaw/plugin-sdk/core";
export {
resolvePreferredOpenClawTmpDir,
runPluginCommandWithTimeout,

View File

@@ -8,7 +8,6 @@ import type {
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
import type { OpenClawPluginApi } from "./api.js";
import type { PendingPairingRequest } from "./notify.ts";
const pluginApiMocks = vi.hoisted(() => ({
clearDeviceBootstrapTokens: vi.fn(async () => ({ removed: 2 })),
@@ -18,7 +17,6 @@ const pluginApiMocks = vi.hoisted(() => ({
})),
revokeDeviceBootstrapToken: vi.fn(async () => ({ removed: true })),
renderQrPngBase64: vi.fn(async () => "ZmFrZXBuZw=="),
resolveGatewayPort: vi.fn(() => 18789),
resolvePreferredOpenClawTmpDir: vi.fn(() => path.join(os.tmpdir(), "openclaw-device-pair-tests")),
}));
@@ -37,7 +35,6 @@ vi.mock("./api.js", () => {
revokeDeviceBootstrapToken: pluginApiMocks.revokeDeviceBootstrapToken,
resolvePreferredOpenClawTmpDir: pluginApiMocks.resolvePreferredOpenClawTmpDir,
resolveGatewayBindUrl: vi.fn(),
resolveGatewayPort: pluginApiMocks.resolveGatewayPort,
resolveTailnetHostWithRunner: vi.fn(),
runPluginCommandWithTimeout: vi.fn(),
};
@@ -386,49 +383,6 @@ describe("device-pair /pair qr", () => {
});
});
describe("device-pair notify pending formatting", () => {
it("includes role and scopes for pending requests", async () => {
const { formatPendingRequests } =
await vi.importActual<typeof import("./notify.ts")>("./notify.ts");
const pending: PendingPairingRequest[] = [
{
requestId: "req-1",
deviceId: "device-1",
displayName: "dev one",
platform: "ios",
role: "operator",
scopes: ["operator.admin", "operator.read"],
remoteIp: "198.51.100.2",
},
];
const text = formatPendingRequests(pending);
expect(text).toContain("Pending device pairing requests:");
expect(text).toContain("name=dev one");
expect(text).toContain("platform=ios");
expect(text).toContain("role=operator");
expect(text).toContain("scopes=operator.admin, operator.read");
expect(text).toContain("ip=198.51.100.2");
});
it("falls back to roles list and no scopes when role/scopes are absent", async () => {
const { formatPendingRequests } =
await vi.importActual<typeof import("./notify.ts")>("./notify.ts");
const pending: PendingPairingRequest[] = [
{
requestId: "req-2",
deviceId: "device-2",
roles: ["node", "operator"],
scopes: [],
},
];
const text = formatPendingRequests(pending);
expect(text).toContain("role=node, operator");
expect(text).toContain("scopes=none");
});
});
describe("device-pair /pair approve", () => {
it("rejects internal gateway callers without operator.pairing", async () => {
vi.mocked(listDevicePairing).mockResolvedValueOnce({

View File

@@ -11,10 +11,9 @@ import {
renderQrPngBase64,
revokeDeviceBootstrapToken,
resolveGatewayBindUrl,
resolveGatewayPort,
resolvePreferredOpenClawTmpDir,
runPluginCommandWithTimeout,
resolveTailnetHostWithRunner,
runPluginCommandWithTimeout,
type OpenClawPluginApi,
} from "./api.js";
import {
@@ -44,6 +43,8 @@ function formatDurationMinutes(expiresAtMs: number): string {
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
}
const DEFAULT_GATEWAY_PORT = 18789;
type DevicePairPluginConfig = {
publicUrl?: string;
};
@@ -174,6 +175,26 @@ function parseNormalizedGatewayUrl(raw: string): string | null {
}
}
function parsePositiveInteger(raw: string | undefined): number | null {
if (!raw) {
return null;
}
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function resolveGatewayPort(cfg: OpenClawPluginApi["config"]): number {
const envPort = parsePositiveInteger(process.env.OPENCLAW_GATEWAY_PORT?.trim());
if (envPort) {
return envPort;
}
const configPort = cfg.gateway?.port;
if (typeof configPort === "number" && Number.isFinite(configPort) && configPort > 0) {
return configPort;
}
return DEFAULT_GATEWAY_PORT;
}
function resolveScheme(
cfg: OpenClawPluginApi["config"],
opts?: { forceSecure?: boolean },

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { formatPendingRequests, type PendingPairingRequest } from "./notify.ts";
describe("device-pair notify pending formatting", () => {
it("includes role and scopes for pending requests", () => {
const pending: PendingPairingRequest[] = [
{
requestId: "req-1",
deviceId: "device-1",
displayName: "dev one",
platform: "ios",
role: "operator",
scopes: ["operator.admin", "operator.read"],
remoteIp: "198.51.100.2",
},
];
const text = formatPendingRequests(pending);
expect(text).toContain("Pending device pairing requests:");
expect(text).toContain("name=dev one");
expect(text).toContain("platform=ios");
expect(text).toContain("role=operator");
expect(text).toContain("scopes=operator.admin, operator.read");
expect(text).toContain("ip=198.51.100.2");
});
it("falls back to roles list and no scopes when role/scopes are absent", () => {
const pending: PendingPairingRequest[] = [
{
requestId: "req-2",
deviceId: "device-2",
roles: ["node", "operator"],
scopes: [],
},
];
const text = formatPendingRequests(pending);
expect(text).toContain("role=node, operator");
expect(text).toContain("scopes=none");
});
});

View File

@@ -1 +1,54 @@
export { renderQrPngBase64 } from "openclaw/plugin-sdk/media-runtime";
import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime";
import QRCodeModule from "qrcode-terminal/vendor/QRCode/index.js";
import QRErrorCorrectLevelModule from "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js";
type QRCodeConstructor = new (
typeNumber: number,
errorCorrectLevel: unknown,
) => {
addData: (data: string) => void;
make: () => void;
getModuleCount: () => number;
isDark: (row: number, col: number) => boolean;
};
const QRCode = QRCodeModule as QRCodeConstructor;
const QRErrorCorrectLevel = QRErrorCorrectLevelModule;
function createQrMatrix(input: string) {
const qr = new QRCode(-1, QRErrorCorrectLevel.L);
qr.addData(input);
qr.make();
return qr;
}
export async function renderQrPngBase64(
input: string,
opts: { scale?: number; marginModules?: number } = {},
): Promise<string> {
const { scale = 6, marginModules = 4 } = opts;
const qr = createQrMatrix(input);
const modules = qr.getModuleCount();
const size = (modules + marginModules * 2) * scale;
const buf = Buffer.alloc(size * size * 4, 255);
for (let row = 0; row < modules; row += 1) {
for (let col = 0; col < modules; col += 1) {
if (!qr.isDark(row, col)) {
continue;
}
const startX = (col + marginModules) * scale;
const startY = (row + marginModules) * scale;
for (let y = 0; y < scale; y += 1) {
const pixelY = startY + y;
for (let x = 0; x < scale; x += 1) {
const pixelX = startX + x;
fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255);
}
}
}
}
const png = encodePngRgba(buf, size, size);
return png.toString("base64");
}

View File

@@ -0,0 +1,140 @@
import type { IncomingMessage } from "node:http";
import { describe, expect, it, vi } from "vitest";
import { createMockServerResponse } from "../../test/helpers/extensions/mock-http-response.js";
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "./api.js";
import plugin from "./index.js";
describe("diffs plugin registration", () => {
it("registers the tool, http route, and system-prompt guidance hook", async () => {
const registerTool = vi.fn();
const registerHttpRoute = vi.fn();
const on = vi.fn();
plugin.register?.(
createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {},
runtime: {} as never,
registerTool,
registerHttpRoute,
on,
}),
);
expect(registerTool).toHaveBeenCalledTimes(1);
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({
path: "/plugins/diffs",
auth: "plugin",
match: "prefix",
});
expect(on).toHaveBeenCalledTimes(1);
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
const beforePromptBuild = on.mock.calls[0]?.[1];
const result = await beforePromptBuild?.({}, {});
expect(result).toMatchObject({
prependSystemContext: expect.stringContaining("prefer the `diffs` tool"),
});
expect(result?.prependContext).toBeUndefined();
});
it("applies plugin-config defaults through registered tool and viewer handler", async () => {
type RegisteredTool = {
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
};
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
let registeredToolFactory:
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
| undefined;
let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined;
const api = createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
pluginConfig: {
defaults: {
mode: "view",
theme: "light",
background: false,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
lineSpacing: 2,
},
},
runtime: {} as never,
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
},
registerHttpRoute(params: RegisteredHttpRouteParams) {
registeredHttpRouteHandler = params.handler;
},
});
plugin.register?.(api as unknown as OpenClawPluginApi);
const registeredTool = registeredToolFactory?.({
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
}) as RegisteredTool | undefined;
const result = await registeredTool?.execute?.("tool-1", {
before: "one\n",
after: "two\n",
});
const viewerPath = String(
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
);
const res = createMockServerResponse();
const handled = await registeredHttpRouteHandler?.(
localReq({
method: "GET",
url: viewerPath,
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain('body data-theme="light"');
expect(String(res.body)).toContain('"backgroundEnabled":false');
expect(String(res.body)).toContain('"diffStyle":"split"');
expect(String(res.body)).toContain('"disableLineNumbers":true');
expect(String(res.body)).toContain('"diffIndicators":"classic"');
expect(String(res.body)).toContain("--diffs-line-height: 30px;");
expect((result as { details?: Record<string, unknown> } | undefined)?.details?.context).toEqual(
{
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
},
);
});
});
function localReq(input: {
method: string;
url: string;
headers?: IncomingMessage["headers"];
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
}

View File

@@ -1,21 +1,13 @@
import fs from "node:fs/promises";
import type { IncomingMessage } from "node:http";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js";
import { createTestPluginApi } from "../../../test/helpers/extensions/plugin-api.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../api.js";
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
import plugin from "../index.js";
import { createTempDiffRoot } from "./test-helpers.js";
const { launchMock } = vi.hoisted(() => ({
launchMock: vi.fn(),
}));
let PlaywrightDiffScreenshotter: typeof import("./browser.js").PlaywrightDiffScreenshotter;
let resetSharedBrowserStateForTests: typeof import("./browser.js").resetSharedBrowserStateForTests;
vi.mock("playwright-core", () => ({
chromium: {
launch: launchMock,
@@ -27,22 +19,18 @@ describe("PlaywrightDiffScreenshotter", () => {
let outputPath: string;
let cleanupRootDir: () => Promise<void>;
beforeAll(async () => {
vi.resetModules();
({ PlaywrightDiffScreenshotter, resetSharedBrowserStateForTests } =
await import("./browser.js"));
});
beforeEach(async () => {
vi.useFakeTimers();
({ rootDir, cleanup: cleanupRootDir } = await createTempDiffRoot("openclaw-diffs-browser-"));
outputPath = path.join(rootDir, "preview.png");
launchMock.mockReset();
await resetSharedBrowserStateForTests();
const browserModule = await import("./browser.js");
await browserModule.resetSharedBrowserStateForTests();
});
afterEach(async () => {
await resetSharedBrowserStateForTests();
const browserModule = await import("./browser.js");
await browserModule.resetSharedBrowserStateForTests();
vi.useRealTimers();
await cleanupRootDir();
});
@@ -143,6 +131,8 @@ describe("PlaywrightDiffScreenshotter", () => {
boundingBox: { x: 40, y: 40, width: 960, height: 60_000 },
});
launchMock.mockResolvedValue(browser);
const { PlaywrightDiffScreenshotter } = await import("./browser.js");
const screenshotter = new PlaywrightDiffScreenshotter({
config: createConfig(),
browserIdleMs: 1_000,
@@ -192,128 +182,6 @@ describe("PlaywrightDiffScreenshotter", () => {
});
});
describe("diffs plugin registration", () => {
it("registers the tool, http route, and system-prompt guidance hook", async () => {
const registerTool = vi.fn();
const registerHttpRoute = vi.fn();
const on = vi.fn();
plugin.register?.(
createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {},
runtime: {} as never,
registerTool,
registerHttpRoute,
on,
}),
);
expect(registerTool).toHaveBeenCalledTimes(1);
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({
path: "/plugins/diffs",
auth: "plugin",
match: "prefix",
});
expect(on).toHaveBeenCalledTimes(1);
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
const beforePromptBuild = on.mock.calls[0]?.[1];
const result = await beforePromptBuild?.({}, {});
expect(result).toMatchObject({
prependSystemContext: expect.stringContaining("prefer the `diffs` tool"),
});
expect(result?.prependContext).toBeUndefined();
});
it("applies plugin-config defaults through registered tool and viewer handler", async () => {
type RegisteredTool = {
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
};
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
let registeredToolFactory:
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
| undefined;
let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined;
const api = createTestPluginApi({
id: "diffs",
name: "Diffs",
description: "Diffs",
source: "test",
config: {
gateway: {
port: 18789,
bind: "loopback",
},
},
pluginConfig: {
defaults: {
mode: "view",
theme: "light",
background: false,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
lineSpacing: 2,
},
},
runtime: {} as never,
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
},
registerHttpRoute(params: RegisteredHttpRouteParams) {
registeredHttpRouteHandler = params.handler;
},
});
plugin.register?.(api as unknown as OpenClawPluginApi);
const registeredTool = registeredToolFactory?.({
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
}) as RegisteredTool | undefined;
const result = await registeredTool?.execute?.("tool-1", {
before: "one\n",
after: "two\n",
});
const viewerPath = String(
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
);
const res = createMockServerResponse();
const handled = await registeredHttpRouteHandler?.(
localReq({
method: "GET",
url: viewerPath,
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain('body data-theme="light"');
expect(String(res.body)).toContain('"backgroundEnabled":false');
expect(String(res.body)).toContain('"diffStyle":"split"');
expect(String(res.body)).toContain('"disableLineNumbers":true');
expect(String(res.body)).toContain('"diffIndicators":"classic"');
expect(String(res.body)).toContain("--diffs-line-height: 30px;");
expect((result as { details?: Record<string, unknown> } | undefined)?.details?.context).toEqual(
{
agentId: "main",
sessionId: "session-123",
messageChannel: "discord",
agentAccountId: "default",
},
);
});
});
function createConfig(): OpenClawConfig {
return {
browser: {
@@ -322,18 +190,6 @@ function createConfig(): OpenClawConfig {
} as OpenClawConfig;
}
function localReq(input: {
method: string;
url: string;
headers?: IncomingMessage["headers"];
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
}
async function createScreenshotterHarness(options?: {
boundingBox?: { x: number; y: number; width: number; height: number };
}) {
@@ -344,6 +200,7 @@ async function createScreenshotterHarness(options?: {
}> = [];
const browser = createMockBrowser(pages, options);
launchMock.mockResolvedValue(browser);
const { PlaywrightDiffScreenshotter } = await import("./browser.js");
const screenshotter = new PlaywrightDiffScreenshotter({
config: createConfig(),
browserIdleMs: 1_000,

View File

@@ -8,10 +8,6 @@ import {
resolveDiffsPluginDefaults,
resolveDiffsPluginSecurity,
} from "./config.js";
import { renderDiffDocument } from "./render.js";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js";
import { parseViewerPayloadJson } from "./viewer-payload.js";
const FULL_DEFAULTS = {
fontFamily: "JetBrains Mono",
@@ -181,232 +177,3 @@ describe("diffs plugin schema surfaces", () => {
expect(diffsPluginConfigSchema.jsonSchema).toEqual(manifest.configSchema);
});
});
describe("diffs viewer URL helpers", () => {
it("defaults to loopback for lan/tailnet bind modes", () => {
expect(
buildViewerUrl({
config: { gateway: { bind: "lan", port: 18789 } },
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token");
expect(
buildViewerUrl({
config: { gateway: { bind: "tailnet", port: 24444 } },
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token");
});
it("uses custom bind host when provided", () => {
expect(
buildViewerUrl({
config: {
gateway: {
bind: "custom",
customBindHost: "gateway.example.com",
port: 443,
tls: { enabled: true },
},
},
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("https://gateway.example.com/plugins/diffs/view/id/token");
});
it("joins viewer path under baseUrl pathname", () => {
expect(
buildViewerUrl({
config: {},
baseUrl: "https://example.com/openclaw",
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
});
it("rejects base URLs with query/hash", () => {
expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
"baseUrl must not include query/hash",
);
expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow(
"baseUrl must not include query/hash",
);
});
});
describe("renderDiffDocument", () => {
it("renders before/after input into a complete viewer document", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "const value = 1;\n",
after: "const value = 2;\n",
path: "src/example.ts",
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
);
expect(rendered.title).toBe("src/example.ts");
expect(rendered.fileCount).toBe(1);
expect(rendered.html).toContain("data-openclaw-diff-root");
expect(rendered.html).toContain("src/example.ts");
expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js");
expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js");
expect(rendered.imageHtml).toContain("max-width: 960px;");
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
expect(rendered.html).toContain("min-height: 100vh;");
expect(rendered.html).toContain('"diffIndicators":"bars"');
expect(rendered.html).toContain('"disableLineNumbers":false');
expect(rendered.html).toContain("--diffs-line-height: 24px;");
expect(rendered.html).toContain("--diffs-font-size: 15px;");
expect(rendered.html).not.toContain("fonts.googleapis.com");
});
it("renders multi-file patch input", async () => {
const patch = [
"diff --git a/a.ts b/a.ts",
"--- a/a.ts",
"+++ b/a.ts",
"@@ -1 +1 @@",
"-const a = 1;",
"+const a = 2;",
"diff --git a/b.ts b/b.ts",
"--- a/b.ts",
"+++ b/b.ts",
"@@ -1 +1 @@",
"-const b = 1;",
"+const b = 2;",
].join("\n");
const rendered = await renderDiffDocument(
{
kind: "patch",
patch,
title: "Workspace patch",
},
{
presentation: {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
layout: "split",
theme: "dark",
},
image: resolveDiffImageRenderOptions({
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
fileQuality: "hq",
fileMaxWidth: 1180,
}),
expandUnchanged: true,
},
);
expect(rendered.title).toBe("Workspace patch");
expect(rendered.fileCount).toBe(2);
expect(rendered.html).toContain("Workspace patch");
expect(rendered.imageHtml).toContain("max-width: 1180px;");
});
it("rejects patches that exceed file-count limits", async () => {
const patch = Array.from({ length: 129 }, (_, i) => {
return [
`diff --git a/f${i}.ts b/f${i}.ts`,
`--- a/f${i}.ts`,
`+++ b/f${i}.ts`,
"@@ -1 +1 @@",
"-const x = 1;",
"+const x = 2;",
].join("\n");
}).join("\n");
await expect(
renderDiffDocument(
{
kind: "patch",
patch,
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
),
).rejects.toThrow("too many files");
});
});
describe("viewer assets", () => {
it("serves a stable loader that points at the current runtime bundle", async () => {
const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);
expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
expect(String(loader?.body)).toContain(`${VIEWER_RUNTIME_PATH}?v=`);
});
it("serves the runtime bundle body", async () => {
const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);
expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
expect(String(runtime?.body)).toContain("openclawDiffsReady");
});
it("returns null for unknown asset paths", async () => {
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
});
});
describe("parseViewerPayloadJson", () => {
function buildValidPayload(): Record<string, unknown> {
return {
prerenderedHTML: "<div>ok</div>",
langs: ["text"],
oldFile: {
name: "README.md",
contents: "before",
},
newFile: {
name: "README.md",
contents: "after",
},
options: {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: "unified",
diffIndicators: "bars",
disableLineNumbers: false,
expandUnchanged: false,
themeType: "dark",
backgroundEnabled: true,
overflow: "wrap",
unsafeCSS: ":host{}",
},
};
}
it("accepts valid payload JSON", () => {
const parsed = parseViewerPayloadJson(JSON.stringify(buildValidPayload()));
expect(parsed.options.diffStyle).toBe("unified");
expect(parsed.options.diffIndicators).toBe("bars");
});
it("rejects payloads with invalid shape", () => {
const broken = buildValidPayload();
broken.options = {
...(broken.options as Record<string, unknown>),
diffIndicators: "invalid",
};
expect(() => parseViewerPayloadJson(JSON.stringify(broken))).toThrow(
"Diff payload has invalid shape.",
);
});
it("rejects invalid JSON", () => {
expect(() => parseViewerPayloadJson("{not-json")).toThrow("Diff payload is not valid JSON.");
});
});

View File

@@ -0,0 +1,206 @@
import type { IncomingMessage } from "node:http";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js";
import { createDiffsHttpHandler } from "./http.js";
import { DiffArtifactStore } from "./store.js";
import { createDiffStoreHarness } from "./test-helpers.js";
describe("createDiffsHttpHandler", () => {
let store: DiffArtifactStore;
let cleanupRootDir: () => Promise<void>;
async function handleLocalGet(url: string) {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url,
}),
res,
);
return { handled, res };
}
beforeEach(async () => {
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-"));
});
afterEach(async () => {
await cleanupRootDir();
});
it("serves a stored diff document", async () => {
const artifact = await createViewerArtifact(store);
const { handled, res } = await handleLocalGet(artifact.viewerPath);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("<html>viewer</html>");
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
});
it("rejects invalid tokens", async () => {
const artifact = await createViewerArtifact(store);
const { handled, res } = await handleLocalGet(
artifact.viewerPath.replace(artifact.token, "bad-token"),
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
it("rejects malformed artifact ids before reading from disk", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
it("serves the shared viewer asset", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url: "/plugins/diffs/assets/viewer.js",
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v=");
});
it("serves the shared viewer runtime asset", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url: "/plugins/diffs/assets/viewer-runtime.js",
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain("openclawDiffsReady");
});
it.each([
{
name: "blocks non-loopback viewer access by default",
request: remoteReq,
allowRemoteViewer: false,
expectedStatusCode: 404,
},
{
name: "blocks loopback requests that carry proxy forwarding headers by default",
request: localReq,
headers: { "x-forwarded-for": "203.0.113.10" },
allowRemoteViewer: false,
expectedStatusCode: 404,
},
{
name: "allows remote access when allowRemoteViewer is enabled",
request: remoteReq,
allowRemoteViewer: true,
expectedStatusCode: 200,
},
{
name: "allows proxied loopback requests when allowRemoteViewer is enabled",
request: localReq,
headers: { "x-forwarded-for": "203.0.113.10" },
allowRemoteViewer: true,
expectedStatusCode: 200,
},
])("$name", async ({ request, headers, allowRemoteViewer, expectedStatusCode }) => {
const artifact = await createViewerArtifact(store);
const handler = createDiffsHttpHandler({ store, allowRemoteViewer });
const res = createMockServerResponse();
const handled = await handler(
request({
method: "GET",
url: artifact.viewerPath,
headers,
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(expectedStatusCode);
if (expectedStatusCode === 200) {
expect(res.body).toBe("<html>viewer</html>");
}
});
it("rate-limits repeated remote misses", async () => {
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
for (let i = 0; i < 40; i++) {
const miss = createMockServerResponse();
await handler(
remoteReq({
method: "GET",
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
}),
miss,
);
expect(miss.statusCode).toBe(404);
}
const limited = createMockServerResponse();
await handler(
remoteReq({
method: "GET",
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
}),
limited,
);
expect(limited.statusCode).toBe(429);
});
});
async function createViewerArtifact(store: DiffArtifactStore) {
return await store.createArtifact({
html: "<html>viewer</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
}
function localReq(input: {
method: string;
url: string;
headers?: Record<string, string>;
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
}
function remoteReq(input: {
method: string;
url: string;
headers?: Record<string, string>;
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "203.0.113.10" },
} as unknown as IncomingMessage;
}

View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
import { renderDiffDocument } from "./render.js";
describe("renderDiffDocument", () => {
it("renders before/after input into a complete viewer document", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "const value = 1;\n",
after: "const value = 2;\n",
path: "src/example.ts",
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
);
expect(rendered.title).toBe("src/example.ts");
expect(rendered.fileCount).toBe(1);
expect(rendered.html).toContain("data-openclaw-diff-root");
expect(rendered.html).toContain("src/example.ts");
expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js");
expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js");
expect(rendered.imageHtml).toContain("max-width: 960px;");
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
expect(rendered.html).toContain("min-height: 100vh;");
expect(rendered.html).toContain('"diffIndicators":"bars"');
expect(rendered.html).toContain('"disableLineNumbers":false');
expect(rendered.html).toContain("--diffs-line-height: 24px;");
expect(rendered.html).toContain("--diffs-font-size: 15px;");
expect(rendered.html).not.toContain("fonts.googleapis.com");
});
it("renders multi-file patch input", async () => {
const patch = [
"diff --git a/a.ts b/a.ts",
"--- a/a.ts",
"+++ b/a.ts",
"@@ -1 +1 @@",
"-const a = 1;",
"+const a = 2;",
"diff --git a/b.ts b/b.ts",
"--- a/b.ts",
"+++ b/b.ts",
"@@ -1 +1 @@",
"-const b = 1;",
"+const b = 2;",
].join("\n");
const rendered = await renderDiffDocument(
{
kind: "patch",
patch,
title: "Workspace patch",
},
{
presentation: {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
layout: "split",
theme: "dark",
},
image: resolveDiffImageRenderOptions({
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
fileQuality: "hq",
fileMaxWidth: 1180,
}),
expandUnchanged: true,
},
);
expect(rendered.title).toBe("Workspace patch");
expect(rendered.fileCount).toBe(2);
expect(rendered.html).toContain("Workspace patch");
expect(rendered.imageHtml).toContain("max-width: 1180px;");
});
it("rejects patches that exceed file-count limits", async () => {
const patch = Array.from({ length: 129 }, (_, i) => {
return [
`diff --git a/f${i}.ts b/f${i}.ts`,
`--- a/f${i}.ts`,
`+++ b/f${i}.ts`,
"@@ -1 +1 @@",
"-const x = 1;",
"+const x = 2;",
].join("\n");
}).join("\n");
await expect(
renderDiffDocument(
{
kind: "patch",
patch,
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
),
).rejects.toThrow("too many files");
});
});

View File

@@ -1,9 +1,6 @@
import fs from "node:fs/promises";
import type { IncomingMessage } from "node:http";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js";
import { createDiffsHttpHandler } from "./http.js";
import { DiffArtifactStore } from "./store.js";
import { createDiffStoreHarness } from "./test-helpers.js";
@@ -214,203 +211,3 @@ describe("DiffArtifactStore", () => {
expect(cleanupSpy).toHaveBeenCalledTimes(2);
});
});
describe("createDiffsHttpHandler", () => {
let store: DiffArtifactStore;
let cleanupRootDir: () => Promise<void>;
async function handleLocalGet(url: string) {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url,
}),
res,
);
return { handled, res };
}
beforeEach(async () => {
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-"));
});
afterEach(async () => {
await cleanupRootDir();
});
it("serves a stored diff document", async () => {
const artifact = await createViewerArtifact(store);
const { handled, res } = await handleLocalGet(artifact.viewerPath);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("<html>viewer</html>");
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
});
it("rejects invalid tokens", async () => {
const artifact = await createViewerArtifact(store);
const { handled, res } = await handleLocalGet(
artifact.viewerPath.replace(artifact.token, "bad-token"),
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
it("rejects malformed artifact ids before reading from disk", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
it("serves the shared viewer asset", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url: "/plugins/diffs/assets/viewer.js",
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v=");
});
it("serves the shared viewer runtime asset", async () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
localReq({
method: "GET",
url: "/plugins/diffs/assets/viewer-runtime.js",
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain("openclawDiffsReady");
});
it.each([
{
name: "blocks non-loopback viewer access by default",
request: remoteReq,
allowRemoteViewer: false,
expectedStatusCode: 404,
},
{
name: "blocks loopback requests that carry proxy forwarding headers by default",
request: localReq,
headers: { "x-forwarded-for": "203.0.113.10" },
allowRemoteViewer: false,
expectedStatusCode: 404,
},
{
name: "allows remote access when allowRemoteViewer is enabled",
request: remoteReq,
allowRemoteViewer: true,
expectedStatusCode: 200,
},
{
name: "allows proxied loopback requests when allowRemoteViewer is enabled",
request: localReq,
headers: { "x-forwarded-for": "203.0.113.10" },
allowRemoteViewer: true,
expectedStatusCode: 200,
},
])("$name", async ({ request, headers, allowRemoteViewer, expectedStatusCode }) => {
const artifact = await createViewerArtifact(store);
const handler = createDiffsHttpHandler({ store, allowRemoteViewer });
const res = createMockServerResponse();
const handled = await handler(
request({
method: "GET",
url: artifact.viewerPath,
headers,
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(expectedStatusCode);
if (expectedStatusCode === 200) {
expect(res.body).toBe("<html>viewer</html>");
}
});
it("rate-limits repeated remote misses", async () => {
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
for (let i = 0; i < 40; i++) {
const miss = createMockServerResponse();
await handler(
remoteReq({
method: "GET",
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
}),
miss,
);
expect(miss.statusCode).toBe(404);
}
const limited = createMockServerResponse();
await handler(
remoteReq({
method: "GET",
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
}),
limited,
);
expect(limited.statusCode).toBe(429);
});
});
async function createViewerArtifact(store: DiffArtifactStore) {
return await store.createArtifact({
html: "<html>viewer</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
}
function localReq(input: {
method: string;
url: string;
headers?: Record<string, string>;
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
}
function remoteReq(input: {
method: string;
url: string;
headers?: Record<string, string>;
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "203.0.113.10" },
} as unknown as IncomingMessage;
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
describe("diffs viewer URL helpers", () => {
it("defaults to loopback for lan/tailnet bind modes", () => {
expect(
buildViewerUrl({
config: { gateway: { bind: "lan", port: 18789 } },
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token");
expect(
buildViewerUrl({
config: { gateway: { bind: "tailnet", port: 24444 } },
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token");
});
it("uses custom bind host when provided", () => {
expect(
buildViewerUrl({
config: {
gateway: {
bind: "custom",
customBindHost: "gateway.example.com",
port: 443,
tls: { enabled: true },
},
},
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("https://gateway.example.com/plugins/diffs/view/id/token");
});
it("joins viewer path under baseUrl pathname", () => {
expect(
buildViewerUrl({
config: {},
baseUrl: "https://example.com/openclaw",
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
});
it("rejects base URLs with query/hash", () => {
expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
"baseUrl must not include query/hash",
);
expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow(
"baseUrl must not include query/hash",
);
});
});

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js";
describe("viewer assets", () => {
it("serves a stable loader that points at the current runtime bundle", async () => {
const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);
expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
expect(String(loader?.body)).toContain(`${VIEWER_RUNTIME_PATH}?v=`);
});
it("serves the runtime bundle body", async () => {
const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);
expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
expect(String(runtime?.body)).toContain("openclawDiffsReady");
});
it("returns null for unknown asset paths", async () => {
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { parseViewerPayloadJson } from "./viewer-payload.js";
function buildValidPayload(): Record<string, unknown> {
return {
prerenderedHTML: "<div>ok</div>",
langs: ["text"],
oldFile: {
name: "README.md",
contents: "before",
},
newFile: {
name: "README.md",
contents: "after",
},
options: {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: "unified",
diffIndicators: "bars",
disableLineNumbers: false,
expandUnchanged: false,
themeType: "dark",
backgroundEnabled: true,
overflow: "wrap",
unsafeCSS: ":host{}",
},
};
}
describe("parseViewerPayloadJson", () => {
it("accepts valid payload JSON", () => {
const parsed = parseViewerPayloadJson(JSON.stringify(buildValidPayload()));
expect(parsed.options.diffStyle).toBe("unified");
expect(parsed.options.diffIndicators).toBe("bars");
});
it("rejects payloads with invalid shape", () => {
const broken = buildValidPayload();
broken.options = {
...(broken.options as Record<string, unknown>),
diffIndicators: "invalid",
};
expect(() => parseViewerPayloadJson(JSON.stringify(broken))).toThrow(
"Diff payload has invalid shape.",
);
});
it("rejects invalid JSON", () => {
expect(() => parseViewerPayloadJson("{not-json")).toThrow("Diff payload is not valid JSON.");
});
});

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
import type { ResolvedDiscordAccount } from "./accounts.js";
@@ -77,10 +77,7 @@ afterEach(() => {
beforeEach(async () => {
vi.useRealTimers();
installDiscordRuntime({});
});
beforeAll(async () => {
vi.resetModules();
({ discordPlugin } = await import("./channel.js"));
({ setDiscordRuntime } = await import("./runtime.js"));
});

View File

@@ -1,5 +1,5 @@
import { MessageFlags } from "discord-api-types/v10";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
let clearDiscordComponentEntries: typeof import("./components-registry.js").clearDiscordComponentEntries;
let registerDiscordComponentEntries: typeof import("./components-registry.js").registerDiscordComponentEntries;
@@ -9,7 +9,8 @@ let buildDiscordComponentMessage: typeof import("./components.js").buildDiscordC
let buildDiscordComponentMessageFlags: typeof import("./components.js").buildDiscordComponentMessageFlags;
let readDiscordComponentSpec: typeof import("./components.js").readDiscordComponentSpec;
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
({
clearDiscordComponentEntries,
registerDiscordComponentEntries,

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
} from "./group-policy.js";
describe("discord group policy", () => {
it("prefers channel policy, then guild policy, with sender-specific overrides", () => {
const discordCfg = {
channels: {
discord: {
token: "discord-test",
guilds: {
guild1: {
requireMention: false,
tools: { allow: ["message.guild"] },
toolsBySender: {
"id:user:guild-admin": { allow: ["sessions.list"] },
},
channels: {
"123": {
requireMention: true,
tools: { allow: ["message.channel"] },
toolsBySender: {
"id:user:channel-admin": { deny: ["exec"] },
},
},
},
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(
resolveDiscordGroupRequireMention({ cfg: discordCfg, groupSpace: "guild1", groupId: "123" }),
).toBe(true);
expect(
resolveDiscordGroupRequireMention({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "missing",
}),
).toBe(false);
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "123",
senderId: "user:channel-admin",
}),
).toEqual({ deny: ["exec"] });
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "123",
senderId: "user:someone",
}),
).toEqual({ allow: ["message.channel"] });
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "missing",
senderId: "user:guild-admin",
}),
).toEqual({ allow: ["sessions.list"] });
expect(
resolveDiscordGroupToolPolicy({
cfg: discordCfg,
groupSpace: "guild1",
groupId: "missing",
senderId: "user:someone",
}),
).toEqual({ allow: ["message.guild"] });
});
});

View File

@@ -1,114 +1,87 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import { waitForDiscordGatewayStop } from "./monitor.gateway.js";
import type { DiscordGatewayEvent } from "./monitor/gateway-supervisor.js";
function createGatewayEvent(
type: DiscordGatewayEvent["type"],
message: string,
): DiscordGatewayEvent {
const err = new Error(message);
return {
type,
err,
message: String(err),
shouldStopLifecycle: type !== "other",
};
}
function createGatewayWaitHarness() {
let lifecycleHandler: ((event: DiscordGatewayEvent) => void) | undefined;
const emitter = new EventEmitter();
const disconnect = vi.fn();
const abort = new AbortController();
const attachLifecycle = vi.fn((handler: (event: DiscordGatewayEvent) => void) => {
lifecycleHandler = handler;
});
const detachLifecycle = vi.fn(() => {
lifecycleHandler = undefined;
});
return {
abort,
attachLifecycle,
detachLifecycle,
disconnect,
emitGatewayEvent: (event: DiscordGatewayEvent) => {
lifecycleHandler?.(event);
},
gatewaySupervisor: {
attachLifecycle,
detachLifecycle,
},
};
return { emitter, disconnect, abort };
}
function startGatewayWait(params?: {
disconnect?: () => void;
onGatewayEvent?: (event: DiscordGatewayEvent) => "continue" | "stop";
onGatewayError?: (error: unknown) => void;
shouldStopOnError?: (error: unknown) => boolean;
registerForceStop?: (fn: (error: unknown) => void) => void;
}) {
const harness = createGatewayWaitHarness();
if (params?.disconnect) {
harness.disconnect.mockImplementation(params.disconnect);
}
const promise = waitForDiscordGatewayStop({
gateway: { disconnect: harness.disconnect },
gateway: { emitter: harness.emitter, disconnect: harness.disconnect },
abortSignal: harness.abort.signal,
gatewaySupervisor: harness.gatewaySupervisor,
...(params?.onGatewayEvent ? { onGatewayEvent: params.onGatewayEvent } : {}),
...(params?.onGatewayError ? { onGatewayError: params.onGatewayError } : {}),
...(params?.shouldStopOnError ? { shouldStopOnError: params.shouldStopOnError } : {}),
...(params?.registerForceStop ? { registerForceStop: params.registerForceStop } : {}),
});
return { ...harness, promise };
}
async function expectAbortToResolve(params: {
abort: AbortController;
attachLifecycle: ReturnType<typeof vi.fn>;
detachLifecycle: ReturnType<typeof vi.fn>;
emitter: EventEmitter;
disconnect: ReturnType<typeof vi.fn>;
abort: AbortController;
promise: Promise<void>;
expectedDisconnectBeforeAbort?: number;
}) {
if (params.expectedDisconnectBeforeAbort !== undefined) {
expect(params.disconnect).toHaveBeenCalledTimes(params.expectedDisconnectBeforeAbort);
}
expect(params.attachLifecycle).toHaveBeenCalledTimes(1);
expect(params.emitter.listenerCount("error")).toBe(1);
params.abort.abort();
await expect(params.promise).resolves.toBeUndefined();
expect(params.disconnect).toHaveBeenCalledTimes(1);
expect(params.detachLifecycle).toHaveBeenCalledTimes(1);
expect(params.emitter.listenerCount("error")).toBe(0);
}
describe("waitForDiscordGatewayStop", () => {
it("resolves on abort and disconnects gateway", async () => {
const { abort, attachLifecycle, detachLifecycle, disconnect, promise } = startGatewayWait();
await expectAbortToResolve({ abort, attachLifecycle, detachLifecycle, disconnect, promise });
const { emitter, disconnect, abort, promise } = startGatewayWait();
await expectAbortToResolve({ emitter, disconnect, abort, promise });
});
it("rejects on lifecycle stop events and disconnects", async () => {
const fatalEvent = createGatewayEvent("fatal", "boom");
const { detachLifecycle, disconnect, emitGatewayEvent, promise } = startGatewayWait();
it("rejects on gateway error and disconnects", async () => {
const onGatewayError = vi.fn();
const err = new Error("boom");
emitGatewayEvent(fatalEvent);
const { emitter, disconnect, abort, promise } = startGatewayWait({
onGatewayError,
});
emitter.emit("error", err);
await expect(promise).rejects.toThrow("boom");
expect(onGatewayError).toHaveBeenCalledWith(err);
expect(disconnect).toHaveBeenCalledTimes(1);
expect(emitter.listenerCount("error")).toBe(0);
abort.abort();
expect(disconnect).toHaveBeenCalledTimes(1);
expect(detachLifecycle).toHaveBeenCalledTimes(1);
});
it("ignores transient gateway events when instructed", async () => {
const transientEvent = createGatewayEvent("other", "transient");
const onGatewayEvent = vi.fn(() => "continue" as const);
const { abort, attachLifecycle, detachLifecycle, disconnect, emitGatewayEvent, promise } =
startGatewayWait({
onGatewayEvent,
});
it("ignores gateway errors when instructed", async () => {
const onGatewayError = vi.fn();
const err = new Error("transient");
emitGatewayEvent(transientEvent);
expect(onGatewayEvent).toHaveBeenCalledWith(transientEvent);
const { emitter, disconnect, abort, promise } = startGatewayWait({
onGatewayError,
shouldStopOnError: () => false,
});
emitter.emit("error", err);
expect(onGatewayError).toHaveBeenCalledWith(err);
await expectAbortToResolve({
abort,
attachLifecycle,
detachLifecycle,
emitter,
disconnect,
abort,
promise,
expectedDisconnectBeforeAbort: 0,
});
@@ -116,6 +89,7 @@ describe("waitForDiscordGatewayStop", () => {
it("resolves on abort without a gateway", async () => {
const abort = new AbortController();
const promise = waitForDiscordGatewayStop({
abortSignal: abort.signal,
});
@@ -128,7 +102,7 @@ describe("waitForDiscordGatewayStop", () => {
it("rejects via registerForceStop and disconnects gateway", async () => {
let forceStop: ((err: unknown) => void) | undefined;
const { detachLifecycle, disconnect, promise } = startGatewayWait({
const { emitter, disconnect, promise } = startGatewayWait({
registerForceStop: (fn) => {
forceStop = fn;
},
@@ -141,7 +115,7 @@ describe("waitForDiscordGatewayStop", () => {
await expect(promise).rejects.toThrow("reconnect watchdog timeout");
expect(disconnect).toHaveBeenCalledTimes(1);
expect(detachLifecycle).toHaveBeenCalledTimes(1);
expect(emitter.listenerCount("error")).toBe(0);
});
it("ignores forceStop after promise already settled", async () => {
@@ -159,49 +133,4 @@ describe("waitForDiscordGatewayStop", () => {
forceStop?.(new Error("too late"));
expect(disconnect).toHaveBeenCalledTimes(1);
});
it("keeps the lifecycle handler active until disconnect returns on abort", async () => {
const onGatewayEvent = vi.fn(() => "stop" as const);
const fatalEvent = createGatewayEvent("fatal", "disconnect emitted error");
let emitFromDisconnect: ((event: DiscordGatewayEvent) => void) | undefined;
const { abort, detachLifecycle, disconnect, emitGatewayEvent, promise } = startGatewayWait({
onGatewayEvent,
disconnect: () => {
emitFromDisconnect?.(fatalEvent);
},
});
emitFromDisconnect = emitGatewayEvent;
abort.abort();
await expect(promise).resolves.toBeUndefined();
expect(onGatewayEvent).toHaveBeenCalledWith(fatalEvent);
expect(disconnect).toHaveBeenCalledTimes(1);
expect(detachLifecycle).toHaveBeenCalledTimes(1);
});
it("keeps the original rejection when disconnect emits another stop event", async () => {
const firstEvent = createGatewayEvent("fatal", "first failure");
const secondEvent = createGatewayEvent("fatal", "second failure");
const seenEvents: DiscordGatewayEvent[] = [];
let emitFromDisconnect: ((event: DiscordGatewayEvent) => void) | undefined;
const { emitGatewayEvent, promise } = startGatewayWait({
onGatewayEvent: (event) => {
seenEvents.push(event);
return "stop";
},
disconnect: () => {
emitFromDisconnect?.(secondEvent);
},
});
emitFromDisconnect = emitGatewayEvent;
emitGatewayEvent(firstEvent);
await expect(promise).rejects.toThrow("first failure");
expect(seenEvents.map((event) => event.message)).toEqual([
firstEvent.message,
secondEvent.message,
]);
});
});

View File

@@ -1,18 +1,15 @@
import type { EventEmitter } from "node:events";
import type {
DiscordGatewayEvent,
DiscordGatewaySupervisor,
} from "./monitor/gateway-supervisor.js";
export type DiscordGatewayHandle = {
emitter?: Pick<EventEmitter, "on" | "removeListener">;
disconnect?: () => void;
};
export type WaitForDiscordGatewayStopParams = {
gateway?: DiscordGatewayHandle;
abortSignal?: AbortSignal;
gatewaySupervisor?: Pick<DiscordGatewaySupervisor, "attachLifecycle" | "detachLifecycle">;
onGatewayEvent?: (event: DiscordGatewayEvent) => "continue" | "stop";
onGatewayError?: (err: unknown) => void;
shouldStopOnError?: (err: unknown) => boolean;
registerForceStop?: (forceStop: (err: unknown) => void) => void;
};
@@ -23,24 +20,23 @@ export function getDiscordGatewayEmitter(gateway?: unknown): EventEmitter | unde
export async function waitForDiscordGatewayStop(
params: WaitForDiscordGatewayStopParams,
): Promise<void> {
const { gateway, abortSignal } = params;
const { gateway, abortSignal, onGatewayError, shouldStopOnError } = params;
const emitter = gateway?.emitter;
return await new Promise<void>((resolve, reject) => {
let settled = false;
const cleanup = () => {
abortSignal?.removeEventListener("abort", onAbort);
params.gatewaySupervisor?.detachLifecycle();
emitter?.removeListener("error", onGatewayErrorEvent);
};
const finishResolve = () => {
if (settled) {
return;
}
settled = true;
cleanup();
try {
gateway?.disconnect?.();
} finally {
// remove listeners after disconnect so late "error" events emitted
// during disconnect are still handled instead of becoming uncaught
cleanup();
resolve();
}
};
@@ -49,20 +45,21 @@ export async function waitForDiscordGatewayStop(
return;
}
settled = true;
cleanup();
try {
gateway?.disconnect?.();
} finally {
cleanup();
reject(err);
}
};
const onAbort = () => {
finishResolve();
};
const onGatewayEvent = (event: DiscordGatewayEvent) => {
const shouldStop = (params.onGatewayEvent?.(event) ?? "stop") === "stop";
const onGatewayErrorEvent = (err: unknown) => {
onGatewayError?.(err);
const shouldStop = shouldStopOnError?.(err) ?? true;
if (shouldStop) {
finishReject(event.err);
finishReject(err);
}
};
const onForceStop = (err: unknown) => {
@@ -75,7 +72,7 @@ export async function waitForDiscordGatewayStop(
}
abortSignal?.addEventListener("abort", onAbort, { once: true });
params.gatewaySupervisor?.attachLifecycle(onGatewayEvent);
emitter?.on("error", onGatewayErrorEvent);
params.registerForceStop?.(onForceStop);
});
}

View File

@@ -1,7 +1,6 @@
import type { Client } from "@buape/carbon";
import { MessageType } from "@buape/carbon";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { expectPairingReplyText } from "../../../test/helpers/pairing-reply.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
dispatchMock,
loadConfigMock,
@@ -17,14 +16,12 @@ let createDiscordMessageHandler: typeof import("./monitor/message-handler.js").c
let __resetDiscordChannelInfoCacheForTest: typeof import("./monitor/message-utils.js").__resetDiscordChannelInfoCacheForTest;
let createNoopThreadBindingManager: typeof import("./monitor/thread-bindings.js").createNoopThreadBindingManager;
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
({ ChannelType } = await import("@buape/carbon"));
({ createDiscordMessageHandler } = await import("./monitor/message-handler.js"));
({ __resetDiscordChannelInfoCacheForTest } = await import("./monitor/message-utils.js"));
({ createNoopThreadBindingManager } = await import("./monitor/thread-bindings.js"));
});
beforeEach(async () => {
__resetDiscordChannelInfoCacheForTest();
sendMock.mockClear().mockResolvedValue(undefined);
updateLastRouteMock.mockClear();
@@ -237,10 +234,7 @@ describe("discord tool result dispatch", () => {
expect(dispatchMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalled();
expect(sendMock).toHaveBeenCalledTimes(1);
expectPairingReplyText(String(sendMock.mock.calls[0]?.[1] ?? ""), {
channel: "discord",
idLine: "Your Discord user id: u2",
code: "PAIRCODE",
});
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain("Your Discord user id: u2");
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain("Pairing code: PAIRCODE");
}, 10000);
});

View File

@@ -1,4 +1,4 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
let buildDiscordComponentCustomId: typeof import("../components.js").buildDiscordComponentCustomId;
let buildDiscordModalCustomId: typeof import("../components.js").buildDiscordModalCustomId;
@@ -10,7 +10,8 @@ let createDiscordComponentRoleSelect: typeof import("./agent-components.js").cre
let createDiscordComponentStringSelect: typeof import("./agent-components.js").createDiscordComponentStringSelect;
let createDiscordComponentUserSelect: typeof import("./agent-components.js").createDiscordComponentUserSelect;
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
({ buildDiscordComponentCustomId, buildDiscordModalCustomId } = await import("../components.js"));
({
createDiscordComponentButton,

View File

@@ -29,7 +29,6 @@ type DiscordChannelOverrideConfig = {
systemPrompt?: string;
includeThreadStarter?: boolean;
autoThread?: boolean;
autoThreadName?: "message" | "generated";
autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080;
};
@@ -404,7 +403,6 @@ function resolveDiscordChannelConfigEntry(
systemPrompt: entry.systemPrompt,
includeThreadStarter: entry.includeThreadStarter,
autoThread: entry.autoThread,
autoThreadName: entry.autoThreadName,
autoArchiveDuration: entry.autoArchiveDuration,
};
return resolved;

View File

@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import type { ButtonInteraction, ComponentData } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions.js";
import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js";
@@ -273,7 +273,8 @@ beforeEach(() => {
mockCreateOperatorApprovalsGatewayClient.mockReset();
});
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
({
buildExecApprovalCustomId,
extractDiscordChannelId,

View File

@@ -0,0 +1,33 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import { attachEarlyGatewayErrorGuard } from "./gateway-error-guard.js";
describe("attachEarlyGatewayErrorGuard", () => {
it("captures gateway errors until released", () => {
const emitter = new EventEmitter();
const fallbackErrorListener = vi.fn();
emitter.on("error", fallbackErrorListener);
const client = {
getPlugin: vi.fn(() => ({ emitter })),
};
const guard = attachEarlyGatewayErrorGuard(client as never);
emitter.emit("error", new Error("Fatal Gateway error: 4014"));
expect(guard.pendingErrors).toHaveLength(1);
guard.release();
emitter.emit("error", new Error("Fatal Gateway error: 4000"));
expect(guard.pendingErrors).toHaveLength(1);
expect(fallbackErrorListener).toHaveBeenCalledTimes(2);
});
it("returns noop guard when gateway emitter is unavailable", () => {
const client = {
getPlugin: vi.fn(() => undefined),
};
const guard = attachEarlyGatewayErrorGuard(client as never);
expect(guard.pendingErrors).toEqual([]);
expect(() => guard.release()).not.toThrow();
});
});

View File

@@ -0,0 +1,36 @@
import type { Client } from "@buape/carbon";
import { getDiscordGatewayEmitter } from "../monitor.gateway.js";
export type EarlyGatewayErrorGuard = {
pendingErrors: unknown[];
release: () => void;
};
export function attachEarlyGatewayErrorGuard(client: Client): EarlyGatewayErrorGuard {
const pendingErrors: unknown[] = [];
const gateway = client.getPlugin("gateway");
const emitter = getDiscordGatewayEmitter(gateway);
if (!emitter) {
return {
pendingErrors,
release: () => {},
};
}
let released = false;
const onGatewayError = (err: unknown) => {
pendingErrors.push(err);
};
emitter.on("error", onGatewayError);
return {
pendingErrors,
release: () => {
if (released) {
return;
}
released = true;
emitter.removeListener("error", onGatewayError);
},
};
}

View File

@@ -1,88 +0,0 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import {
classifyDiscordGatewayEvent,
createDiscordGatewaySupervisor,
} from "./gateway-supervisor.js";
describe("classifyDiscordGatewayEvent", () => {
it("maps raw gateway errors onto domain events", () => {
const reconnectEvent = classifyDiscordGatewayEvent({
err: new Error("Max reconnect attempts (0) reached after code 1006"),
isDisallowedIntentsError: () => false,
});
const fatalEvent = classifyDiscordGatewayEvent({
err: new Error("Fatal Gateway error: 4000"),
isDisallowedIntentsError: () => false,
});
const disallowedEvent = classifyDiscordGatewayEvent({
err: new Error("Fatal Gateway error: 4014"),
isDisallowedIntentsError: (err) => String(err).includes("4014"),
});
const transientEvent = classifyDiscordGatewayEvent({
err: new Error("transient"),
isDisallowedIntentsError: () => false,
});
expect(reconnectEvent.type).toBe("reconnect-exhausted");
expect(reconnectEvent.shouldStopLifecycle).toBe(true);
expect(fatalEvent.type).toBe("fatal");
expect(disallowedEvent.type).toBe("disallowed-intents");
expect(transientEvent.type).toBe("other");
expect(transientEvent.shouldStopLifecycle).toBe(false);
});
});
describe("createDiscordGatewaySupervisor", () => {
it("buffers early errors, routes active ones, and logs late teardown errors", () => {
const emitter = new EventEmitter();
const runtime = {
error: vi.fn(),
};
const supervisor = createDiscordGatewaySupervisor({
client: {
getPlugin: vi.fn(() => ({ emitter })),
} as never,
isDisallowedIntentsError: (err) => String(err).includes("4014"),
runtime: runtime as never,
});
const seen: string[] = [];
emitter.emit("error", new Error("Fatal Gateway error: 4014"));
expect(
supervisor.drainPending((event) => {
seen.push(event.type);
return "continue";
}),
).toBe("continue");
supervisor.attachLifecycle((event) => {
seen.push(event.type);
});
emitter.emit("error", new Error("Fatal Gateway error: 4000"));
supervisor.detachLifecycle();
emitter.emit("error", new Error("Max reconnect attempts (0) reached after code 1006"));
expect(seen).toEqual(["disallowed-intents", "fatal"]);
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("suppressed late gateway reconnect-exhausted error during teardown"),
);
});
it("is idempotent on dispose and noops without an emitter", () => {
const supervisor = createDiscordGatewaySupervisor({
client: {
getPlugin: vi.fn(() => undefined),
} as never,
isDisallowedIntentsError: () => false,
runtime: { error: vi.fn() } as never,
});
expect(supervisor.drainPending(() => "continue")).toBe("continue");
expect(() => supervisor.attachLifecycle(() => {})).not.toThrow();
expect(() => supervisor.detachLifecycle()).not.toThrow();
expect(() => supervisor.dispose()).not.toThrow();
expect(() => supervisor.dispose()).not.toThrow();
});
});

View File

@@ -1,151 +0,0 @@
import type { EventEmitter } from "node:events";
import type { Client } from "@buape/carbon";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { getDiscordGatewayEmitter } from "../monitor.gateway.js";
export type DiscordGatewayEventType =
| "disallowed-intents"
| "fatal"
| "other"
| "reconnect-exhausted";
export type DiscordGatewayEvent = {
type: DiscordGatewayEventType;
err: unknown;
message: string;
shouldStopLifecycle: boolean;
};
export type DiscordGatewaySupervisor = {
emitter?: EventEmitter;
attachLifecycle: (handler: (event: DiscordGatewayEvent) => void) => void;
detachLifecycle: () => void;
drainPending: (
handler: (event: DiscordGatewayEvent) => "continue" | "stop",
) => "continue" | "stop";
dispose: () => void;
};
type GatewaySupervisorPhase = "active" | "buffering" | "disposed" | "teardown";
export function classifyDiscordGatewayEvent(params: {
err: unknown;
isDisallowedIntentsError: (err: unknown) => boolean;
}): DiscordGatewayEvent {
const message = String(params.err);
if (params.isDisallowedIntentsError(params.err)) {
return {
type: "disallowed-intents",
err: params.err,
message,
shouldStopLifecycle: true,
};
}
if (message.includes("Max reconnect attempts")) {
return {
type: "reconnect-exhausted",
err: params.err,
message,
shouldStopLifecycle: true,
};
}
if (message.includes("Fatal Gateway error")) {
return {
type: "fatal",
err: params.err,
message,
shouldStopLifecycle: true,
};
}
return {
type: "other",
err: params.err,
message,
shouldStopLifecycle: false,
};
}
export function createDiscordGatewaySupervisor(params: {
client: Client;
isDisallowedIntentsError: (err: unknown) => boolean;
runtime: RuntimeEnv;
}): DiscordGatewaySupervisor {
const gateway = params.client.getPlugin("gateway");
const emitter = getDiscordGatewayEmitter(gateway);
const pending: DiscordGatewayEvent[] = [];
if (!emitter) {
return {
attachLifecycle: () => {},
detachLifecycle: () => {},
drainPending: () => "continue",
dispose: () => {},
emitter,
};
}
let lifecycleHandler: ((event: DiscordGatewayEvent) => void) | undefined;
let phase: GatewaySupervisorPhase = "buffering";
let disposed = false;
const logLateTeardownEvent = (event: DiscordGatewayEvent) => {
params.runtime.error?.(
danger(
`discord: suppressed late gateway ${event.type} error during teardown: ${event.message}`,
),
);
};
const onGatewayError = (err: unknown) => {
if (disposed) {
return;
}
const event = classifyDiscordGatewayEvent({
err,
isDisallowedIntentsError: params.isDisallowedIntentsError,
});
if (phase === "active" && lifecycleHandler) {
lifecycleHandler(event);
return;
}
if (phase === "teardown") {
logLateTeardownEvent(event);
return;
}
pending.push(event);
};
emitter.on("error", onGatewayError);
return {
emitter,
attachLifecycle: (handler) => {
lifecycleHandler = handler;
phase = "active";
},
detachLifecycle: () => {
lifecycleHandler = undefined;
phase = "teardown";
},
drainPending: (handler) => {
if (pending.length === 0) {
return "continue";
}
const queued = [...pending];
pending.length = 0;
for (const event of queued) {
if (handler(event) === "stop") {
return "stop";
}
}
return "continue";
},
dispose: () => {
if (disposed) {
return;
}
disposed = true;
lifecycleHandler = undefined;
phase = "disposed";
pending.length = 0;
emitter.removeListener("error", onGatewayError);
},
};
}

View File

@@ -5,9 +5,7 @@ import { danger } from "openclaw/plugin-sdk/runtime-env";
import { materializeDiscordInboundJob, type DiscordInboundJob } from "./inbound-job.js";
import type { RuntimeEnv } from "./message-handler.preflight.types.js";
import { processDiscordMessage } from "./message-handler.process.js";
import { deliverDiscordReply } from "./reply-delivery.js";
import type { DiscordMonitorStatusSink } from "./status.js";
import { resolveDiscordReplyDeliveryPlan } from "./threading.js";
import { normalizeDiscordInboundWorkerTimeoutMs, runDiscordTaskWithTimeout } from "./timeouts.js";
type DiscordInboundWorkerParams = {
@@ -43,27 +41,13 @@ async function processDiscordInboundJob(params: {
}) {
const timeoutMs = normalizeDiscordInboundWorkerTimeoutMs(params.runTimeoutMs);
const contextSuffix = formatDiscordRunContextSuffix(params.job);
let finalReplyStarted = false;
let createdThreadId: string | undefined;
let sessionKey: string | undefined;
await runDiscordTaskWithTimeout({
run: async (abortSignal) => {
await processDiscordMessage(materializeDiscordInboundJob(params.job, abortSignal), {
onFinalReplyStart: () => {
finalReplyStarted = true;
},
onFinalReplyDelivered: () => {
finalReplyStarted = true;
},
onReplyPlanResolved: (resolved) => {
createdThreadId = resolved.createdThreadId?.trim() || undefined;
sessionKey = resolved.sessionKey?.trim() || undefined;
},
});
await processDiscordMessage(materializeDiscordInboundJob(params.job, abortSignal));
},
timeoutMs,
abortSignals: [params.job.runtime.abortSignal, params.lifecycleSignal],
onTimeout: async (resolvedTimeoutMs) => {
onTimeout: (resolvedTimeoutMs) => {
params.runtime.error?.(
danger(
`discord inbound worker timed out after ${formatDurationSeconds(resolvedTimeoutMs, {
@@ -72,16 +56,6 @@ async function processDiscordInboundJob(params: {
})}${contextSuffix}`,
),
);
if (finalReplyStarted) {
return;
}
await sendDiscordInboundWorkerTimeoutReply({
job: params.job,
runtime: params.runtime,
contextSuffix,
createdThreadId,
sessionKey,
});
},
onErrorAfterTimeout: (error) => {
params.runtime.error?.(
@@ -91,60 +65,6 @@ async function processDiscordInboundJob(params: {
});
}
async function sendDiscordInboundWorkerTimeoutReply(params: {
job: DiscordInboundJob;
runtime: RuntimeEnv;
contextSuffix: string;
createdThreadId?: string;
sessionKey?: string;
}) {
const messageChannelId = params.job.payload.messageChannelId?.trim();
const messageId = params.job.payload.message?.id?.trim();
const token = params.job.payload.token?.trim();
if (!messageChannelId || !messageId || !token) {
params.runtime.error?.(
danger(
`discord inbound worker timeout reply skipped: missing reply target${params.contextSuffix}`,
),
);
return;
}
const deliveryPlan = resolveDiscordReplyDeliveryPlan({
replyTarget: `channel:${params.job.payload.threadChannel?.id ?? messageChannelId}`,
replyToMode: params.job.payload.replyToMode,
messageId,
threadChannel: params.job.payload.threadChannel,
createdThreadId: params.createdThreadId,
});
try {
await deliverDiscordReply({
cfg: params.job.payload.cfg,
replies: [{ text: "Discord inbound worker timed out.", isError: true }],
target: deliveryPlan.deliverTarget,
token,
accountId: params.job.payload.accountId,
runtime: params.runtime,
textLimit: params.job.payload.textLimit,
maxLinesPerMessage: params.job.payload.discordConfig?.maxLinesPerMessage,
replyToId: deliveryPlan.replyReference.use(),
replyToMode: params.job.payload.replyToMode,
sessionKey:
params.sessionKey ??
params.job.payload.route.sessionKey ??
params.job.payload.baseSessionKey,
threadBindings: params.job.runtime.threadBindings,
});
} catch (error) {
params.runtime.error?.(
danger(
`discord inbound worker timeout reply failed: ${String(error)}${params.contextSuffix}`,
),
);
}
}
export function createDiscordInboundWorker(
params: DiscordInboundWorkerParams,
): DiscordInboundWorker {

View File

@@ -1,8 +1,9 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
let DiscordMessageListener: typeof import("./listeners.js").DiscordMessageListener;
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
({ DiscordMessageListener } = await import("./listeners.js"));
});

View File

@@ -3,7 +3,6 @@ import { vi } from "vitest";
export const preflightDiscordMessageMock: MockFn = vi.fn();
export const processDiscordMessageMock: MockFn = vi.fn();
export const deliverDiscordReplyMock: MockFn = vi.fn(async () => undefined);
vi.mock("./message-handler.preflight.js", () => ({
preflightDiscordMessage: preflightDiscordMessageMock,
@@ -13,8 +12,4 @@ vi.mock("./message-handler.process.js", () => ({
processDiscordMessage: processDiscordMessageMock,
}));
vi.mock("./reply-delivery.js", () => ({
deliverDiscordReply: deliverDiscordReplyMock,
}));
export const { createDiscordMessageHandler } = await import("./message-handler.js");

View File

@@ -1,17 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createConfiguredBindingConversationRuntimeModuleMock } from "../../../../test/helpers/extensions/configured-binding-runtime.js";
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn());
const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
return await createConfiguredBindingConversationRuntimeModuleMock(
{
ensureConfiguredBindingRouteReadyMock,
resolveConfiguredBindingRouteMock,
},
importOriginal,
);
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
ensureConfiguredBindingRouteReadyMock(...args),
resolveConfiguredBindingRoute: (...args: unknown[]) =>
resolveConfiguredBindingRouteMock(...args),
};
});
import { __testing as sessionBindingTesting } from "../../../../src/infra/outbound/session-binding-service.js";
@@ -166,62 +166,6 @@ function createBasePreflightParams(overrides?: Record<string, unknown>) {
} satisfies Parameters<typeof preflightDiscordMessage>[0];
}
function createAllowedGuildEntries(requireMention = false) {
return {
[GUILD_ID]: {
id: GUILD_ID,
channels: {
[CHANNEL_ID]: {
allow: true,
enabled: true,
requireMention,
},
},
},
};
}
function createHydratedGuildClient(restPayload: Record<string, unknown>) {
const restGet = vi.fn(async () => restPayload);
const client = {
...createGuildTextClient(CHANNEL_ID),
rest: {
get: restGet,
},
} as unknown as Parameters<typeof preflightDiscordMessage>[0]["client"];
return { client, restGet };
}
async function runRestHydrationPreflight(params: {
messageId: string;
restPayload: Record<string, unknown>;
}) {
const message = createDiscordMessage({
id: params.messageId,
channelId: CHANNEL_ID,
content: "",
author: {
id: "user-1",
bot: false,
username: "alice",
},
});
const { client, restGet } = createHydratedGuildClient(params.restPayload);
const result = await preflightDiscordMessage(
createBasePreflightParams({
client,
data: createGuildEvent({
channelId: CHANNEL_ID,
guildId: GUILD_ID,
author: message.author,
message,
}),
guildEntries: createAllowedGuildEntries(false),
}),
);
return { result, restGet };
}
describe("preflightDiscordMessage configured ACP bindings", () => {
beforeEach(() => {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
@@ -298,7 +242,18 @@ describe("preflightDiscordMessage configured ACP bindings", () => {
author: message.author,
message,
}),
guildEntries: createAllowedGuildEntries(true),
guildEntries: {
[GUILD_ID]: {
id: GUILD_ID,
channels: {
[CHANNEL_ID]: {
allow: true,
enabled: true,
requireMention: true,
},
},
},
},
}),
);
@@ -308,22 +263,59 @@ describe("preflightDiscordMessage configured ACP bindings", () => {
});
it("hydrates empty guild message payloads from REST before ensuring configured ACP bindings", async () => {
const { result, restGet } = await runRestHydrationPreflight({
messageId: "m-rest",
restPayload: {
id: "m-rest",
content: "hello from rest",
attachments: [],
embeds: [],
mentions: [],
mention_roles: [],
mention_everyone: false,
author: {
id: "user-1",
username: "alice",
},
const message = createDiscordMessage({
id: "m-rest",
channelId: CHANNEL_ID,
content: "",
author: {
id: "user-1",
bot: false,
username: "alice",
},
});
const restGet = vi.fn(async () => ({
id: "m-rest",
content: "hello from rest",
attachments: [],
embeds: [],
mentions: [],
mention_roles: [],
mention_everyone: false,
author: {
id: "user-1",
username: "alice",
},
}));
const client = {
...createGuildTextClient(CHANNEL_ID),
rest: {
get: restGet,
},
} as unknown as Parameters<typeof preflightDiscordMessage>[0]["client"];
const result = await preflightDiscordMessage(
createBasePreflightParams({
client,
data: createGuildEvent({
channelId: CHANNEL_ID,
guildId: GUILD_ID,
author: message.author,
message,
}),
guildEntries: {
[GUILD_ID]: {
id: GUILD_ID,
channels: {
[CHANNEL_ID]: {
allow: true,
enabled: true,
requireMention: false,
},
},
},
},
}),
);
expect(restGet).toHaveBeenCalledTimes(1);
expect(result?.messageText).toBe("hello from rest");
@@ -332,28 +324,65 @@ describe("preflightDiscordMessage configured ACP bindings", () => {
});
it("hydrates sticker-only guild message payloads from REST before ensuring configured ACP bindings", async () => {
const { result, restGet } = await runRestHydrationPreflight({
messageId: "m-rest-sticker",
restPayload: {
id: "m-rest-sticker",
content: "",
attachments: [],
embeds: [],
mentions: [],
mention_roles: [],
mention_everyone: false,
sticker_items: [
{
id: "sticker-1",
name: "wave",
},
],
author: {
id: "user-1",
username: "alice",
},
const message = createDiscordMessage({
id: "m-rest-sticker",
channelId: CHANNEL_ID,
content: "",
author: {
id: "user-1",
bot: false,
username: "alice",
},
});
const restGet = vi.fn(async () => ({
id: "m-rest-sticker",
content: "",
attachments: [],
embeds: [],
mentions: [],
mention_roles: [],
mention_everyone: false,
sticker_items: [
{
id: "sticker-1",
name: "wave",
},
],
author: {
id: "user-1",
username: "alice",
},
}));
const client = {
...createGuildTextClient(CHANNEL_ID),
rest: {
get: restGet,
},
} as unknown as Parameters<typeof preflightDiscordMessage>[0]["client"];
const result = await preflightDiscordMessage(
createBasePreflightParams({
client,
data: createGuildEvent({
channelId: CHANNEL_ID,
guildId: GUILD_ID,
author: message.author,
message,
}),
guildEntries: {
[GUILD_ID]: {
id: GUILD_ID,
channels: {
[CHANNEL_ID]: {
allow: true,
enabled: true,
requireMention: false,
},
},
},
},
}),
);
expect(restGet).toHaveBeenCalledTimes(1);
expect(result?.messageText).toBe("<media:sticker> (1 sticker)");

View File

@@ -1,5 +1,5 @@
import { ChannelType } from "@buape/carbon";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const transcribeFirstAudioMock = vi.hoisted(() => vi.fn());
@@ -26,7 +26,8 @@ let shouldIgnoreBoundThreadWebhookMessage: typeof import("./message-handler.pref
let threadBindingTesting: typeof import("./thread-bindings.js").__testing;
let createThreadBindingManager: typeof import("./thread-bindings.js").createThreadBindingManager;
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
({
preflightDiscordMessage,
resolvePreflightMentionRequirement,

View File

@@ -1,4 +1,4 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_EMOJIS } from "../../../../src/channels/status-reactions.js";
const sendMocks = vi.hoisted(() => ({
@@ -169,17 +169,14 @@ async function processStreamOffDiscordMessage() {
await processDiscordMessage(ctx as any);
}
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
vi.useRealTimers();
({ createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } =
await import("./message-handler.test-harness.js"));
({ __testing: threadBindingTesting, createThreadBindingManager } =
await import("./thread-bindings.js"));
({ processDiscordMessage } = await import("./message-handler.process.js"));
});
beforeEach(() => {
vi.useRealTimers();
sendMocks.reactMessageDiscord.mockClear();
sendMocks.removeReactionDiscord.mockClear();
editMessageDiscord.mockClear();

View File

@@ -69,16 +69,7 @@ function isProcessAborted(abortSignal?: AbortSignal): boolean {
return Boolean(abortSignal?.aborted);
}
type DiscordMessageProcessObserver = {
onFinalReplyStart?: () => void;
onFinalReplyDelivered?: () => void;
onReplyPlanResolved?: (params: { createdThreadId?: string; sessionKey?: string }) => void;
};
export async function processDiscordMessage(
ctx: DiscordMessagePreflightContext,
observer?: DiscordMessageProcessObserver,
) {
export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) {
const {
cfg,
discordConfig,
@@ -330,14 +321,11 @@ export async function processDiscordMessage(
channelConfig,
threadChannel,
channelType: channelInfo?.type,
channelName: channelInfo?.name,
channelDescription: channelInfo?.topic,
baseText: baseText ?? "",
combinedBody,
replyToMode,
agentId: route.agentId,
channel: route.channel,
cfg,
});
const deliverTarget = replyPlan.deliverTarget;
const replyTarget = replyPlan.replyTarget;
@@ -406,10 +394,6 @@ export async function processDiscordMessage(
OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget,
});
const persistedSessionKey = ctxPayload.SessionKey ?? route.sessionKey;
observer?.onReplyPlanResolved?.({
createdThreadId: replyPlan.createdThreadId,
sessionKey: persistedSessionKey,
});
await recordInboundSession({
storePath,
@@ -610,14 +594,6 @@ export async function processDiscordMessage(
// When draft streaming is active, suppress block streaming to avoid double-streaming.
const disableBlockStreamingForDraft = draftStream ? true : undefined;
let finalReplyStartNotified = false;
const notifyFinalReplyStart = () => {
if (finalReplyStartNotified) {
return;
}
finalReplyStartNotified = true;
observer?.onFinalReplyStart?.();
};
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
createReplyDispatcherWithTyping({
@@ -654,7 +630,6 @@ export async function processDiscordMessage(
return;
}
try {
notifyFinalReplyStart();
await editMessageDiscord(
deliverChannelId,
previewMessageId,
@@ -663,7 +638,6 @@ export async function processDiscordMessage(
);
finalizedViaPreviewMessage = true;
replyReference.markSent();
observer?.onFinalReplyDelivered?.();
return;
} catch (err) {
logVerbose(
@@ -686,7 +660,6 @@ export async function processDiscordMessage(
!payload.isError
) {
try {
notifyFinalReplyStart();
await editMessageDiscord(
deliverChannelId,
messageIdAfterStop,
@@ -695,7 +668,6 @@ export async function processDiscordMessage(
);
finalizedViaPreviewMessage = true;
replyReference.markSent();
observer?.onFinalReplyDelivered?.();
return;
} catch (err) {
logVerbose(
@@ -715,9 +687,6 @@ export async function processDiscordMessage(
}
const replyToId = replyReference.use();
if (isFinal) {
notifyFinalReplyStart();
}
await deliverDiscordReply({
cfg,
replies: [payload],
@@ -737,9 +706,6 @@ export async function processDiscordMessage(
mediaLocalRoots,
});
replyReference.markSent();
if (isFinal) {
observer?.onFinalReplyDelivered?.();
}
},
onError: (err, info) => {
runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`));

View File

@@ -1,4 +1,4 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createDiscordHandlerParams,
createDiscordPreflightContext,
@@ -6,10 +6,10 @@ import {
let createDiscordMessageHandler: typeof import("./message-handler.module-test-helpers.js").createDiscordMessageHandler;
let preflightDiscordMessageMock: typeof import("./message-handler.module-test-helpers.js").preflightDiscordMessageMock;
let processDiscordMessageMock: typeof import("./message-handler.module-test-helpers.js").processDiscordMessageMock;
let deliverDiscordReplyMock: typeof import("./message-handler.module-test-helpers.js").deliverDiscordReplyMock;
const eventualReplyDeliveredMock = vi.hoisted(() => vi.fn());
type SetStatusFn = (patch: Record<string, unknown>) => void;
function createDeferred<T = void>() {
let resolve: (value: T | PromiseLike<T>) => void = () => {};
const promise = new Promise<T>((innerResolve) => {
@@ -33,18 +33,7 @@ function createMessageData(messageId: string, channelId = "ch-1") {
}
function createPreflightContext(channelId = "ch-1") {
return {
...createDiscordPreflightContext(channelId),
accountId: "default",
token: "test-token",
textLimit: 2_000,
replyToMode: "off" as const,
discordConfig: {
enabled: true,
token: "test-token",
groupPolicy: "allowlist" as const,
},
};
return createDiscordPreflightContext(channelId);
}
function createHandlerWithDefaultPreflight(overrides?: {
@@ -57,38 +46,6 @@ function createHandlerWithDefaultPreflight(overrides?: {
return createDiscordMessageHandler(createDiscordHandlerParams(overrides));
}
function installDefaultDiscordPreflight() {
preflightDiscordMessageMock.mockImplementation(async (params: { data: { channel_id: string } }) =>
createPreflightContext(params.data.channel_id),
);
}
async function runSingleMessageTimeout(params: {
processImpl: Parameters<typeof processDiscordMessageMock.mockImplementationOnce>[0];
workerRunTimeoutMs?: number;
}) {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
deliverDiscordReplyMock.mockClear();
processDiscordMessageMock.mockImplementationOnce(params.processImpl);
installDefaultDiscordPreflight();
const handlerParams = createDiscordHandlerParams({
workerRunTimeoutMs: params.workerRunTimeoutMs ?? 50,
});
const handler = createDiscordMessageHandler(handlerParams);
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
await vi.advanceTimersByTimeAsync(60);
await Promise.resolve();
expect(handlerParams.runtime.error).toHaveBeenCalledWith(
expect.stringContaining("discord inbound worker timed out after"),
);
return handlerParams;
}
async function createLifecycleStopScenario(params: {
createHandler: (status: SetStatusFn) => {
handler: (data: never, opts: never) => Promise<void>;
@@ -127,13 +84,10 @@ async function createLifecycleStopScenario(params: {
}
describe("createDiscordMessageHandler queue behavior", () => {
beforeAll(async () => {
({
createDiscordMessageHandler,
preflightDiscordMessageMock,
processDiscordMessageMock,
deliverDiscordReplyMock,
} = await import("./message-handler.module-test-helpers.js"));
beforeEach(async () => {
vi.resetModules();
({ createDiscordMessageHandler, preflightDiscordMessageMock, processDiscordMessageMock } =
await import("./message-handler.module-test-helpers.js"));
});
it("resets busy counters when the handler is created", () => {
@@ -227,7 +181,6 @@ describe("createDiscordMessageHandler queue behavior", () => {
try {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
deliverDiscordReplyMock.mockClear();
processDiscordMessageMock
.mockImplementationOnce(async (ctx: { abortSignal?: AbortSignal }) => {
@@ -266,197 +219,6 @@ describe("createDiscordMessageHandler queue behavior", () => {
expect(params.runtime.error).toHaveBeenCalledWith(
expect.stringContaining("discord inbound worker timed out after"),
);
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock).toHaveBeenCalledWith(
expect.objectContaining({
target: "channel:ch-1",
token: "test-token",
replies: [
expect.objectContaining({
isError: true,
text: "Discord inbound worker timed out.",
}),
],
}),
);
} finally {
vi.useRealTimers();
}
});
it("waits for the timeout fallback reply before starting the next queued run", async () => {
vi.useFakeTimers();
try {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
deliverDiscordReplyMock.mockReset();
const deliverTimeoutReply = createDeferred();
deliverDiscordReplyMock.mockImplementationOnce(async () => {
await deliverTimeoutReply.promise;
});
processDiscordMessageMock
.mockImplementationOnce(async (ctx: { abortSignal?: AbortSignal }) => {
await new Promise<void>((resolve) => {
if (ctx.abortSignal?.aborted) {
resolve();
return;
}
ctx.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
});
})
.mockImplementationOnce(async () => undefined);
preflightDiscordMessageMock.mockImplementation(
async (preflightParams: { data: { channel_id: string } }) =>
createPreflightContext(preflightParams.data.channel_id),
);
const handler = createDiscordMessageHandler(
createDiscordHandlerParams({ workerRunTimeoutMs: 50 }),
);
await expect(
handler(createMessageData("m-1") as never, {} as never),
).resolves.toBeUndefined();
await expect(
handler(createMessageData("m-2") as never, {} as never),
).resolves.toBeUndefined();
await vi.advanceTimersByTimeAsync(60);
await vi.waitFor(() => {
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
});
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
deliverTimeoutReply.resolve();
await deliverTimeoutReply.promise;
await vi.waitFor(() => {
expect(processDiscordMessageMock).toHaveBeenCalledTimes(2);
});
} finally {
vi.useRealTimers();
}
});
it("does not send the timeout fallback when a final reply already went out", async () => {
vi.useFakeTimers();
try {
await runSingleMessageTimeout({
processImpl: async (
ctx: { abortSignal?: AbortSignal },
observer?: { onFinalReplyStart?: () => void; onFinalReplyDelivered?: () => void },
) => {
observer?.onFinalReplyStart?.();
observer?.onFinalReplyDelivered?.();
await new Promise<void>((resolve) => {
if (ctx.abortSignal?.aborted) {
resolve();
return;
}
ctx.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
});
},
});
expect(deliverDiscordReplyMock).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("routes the timeout fallback to the created auto-thread target", async () => {
vi.useFakeTimers();
try {
await runSingleMessageTimeout({
processImpl: async (
ctx: { abortSignal?: AbortSignal },
observer?: {
onReplyPlanResolved?: (params: {
createdThreadId?: string;
sessionKey?: string;
}) => void;
},
) => {
observer?.onReplyPlanResolved?.({
createdThreadId: "thread-1",
sessionKey: "agent:main:discord:channel:thread-1",
});
await new Promise<void>((resolve) => {
if (ctx.abortSignal?.aborted) {
resolve();
return;
}
ctx.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
});
},
});
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock).toHaveBeenCalledWith(
expect.objectContaining({
target: "channel:thread-1",
sessionKey: "agent:main:discord:channel:thread-1",
replies: [
expect.objectContaining({
isError: true,
text: "Discord inbound worker timed out.",
}),
],
}),
);
} finally {
vi.useRealTimers();
}
});
it("does not send the timeout fallback when final reply delivery is already in flight", async () => {
vi.useFakeTimers();
try {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
deliverDiscordReplyMock.mockClear();
const finishFinalReply = createDeferred();
processDiscordMessageMock.mockImplementationOnce(
async (
_ctx: { abortSignal?: AbortSignal },
observer?: { onFinalReplyStart?: () => void; onFinalReplyDelivered?: () => void },
) => {
observer?.onFinalReplyStart?.();
await finishFinalReply.promise;
observer?.onFinalReplyDelivered?.();
},
);
preflightDiscordMessageMock.mockImplementation(
async (params: { data: { channel_id: string } }) =>
createPreflightContext(params.data.channel_id),
);
const params = createDiscordHandlerParams({ workerRunTimeoutMs: 50 });
const handler = createDiscordMessageHandler(params);
await expect(
handler(createMessageData("m-1") as never, {} as never),
).resolves.toBeUndefined();
await vi.waitFor(() => {
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
});
await vi.advanceTimersByTimeAsync(60);
await Promise.resolve();
expect(params.runtime.error).toHaveBeenCalledWith(
expect.stringContaining("discord inbound worker timed out after"),
);
expect(deliverDiscordReplyMock).not.toHaveBeenCalled();
finishFinalReply.resolve();
await finishFinalReply.promise;
await Promise.resolve();
expect(deliverDiscordReplyMock).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}

View File

@@ -1,6 +1,6 @@
import { ChannelType, type Client, type Message } from "@buape/carbon";
import { StickerFormatType } from "discord-api-types/v10";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const fetchRemoteMedia = vi.fn();
const saveMediaBuffer = vi.fn();
@@ -28,7 +28,7 @@ let resolveDiscordMessageText: typeof import("./message-utils.js").resolveDiscor
let resolveForwardedMediaList: typeof import("./message-utils.js").resolveForwardedMediaList;
let resolveMediaList: typeof import("./message-utils.js").resolveMediaList;
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
({
__resetDiscordChannelInfoCacheForTest,

View File

@@ -4,14 +4,44 @@ import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { peekSystemEvents, resetSystemEventsForTest } from "../../../../src/infra/system-events.ts";
import {
readAllowFromStoreMock,
resetDiscordComponentRuntimeMocks,
upsertPairingRequestMock,
} from "../../../../test/helpers/extensions/discord-component-runtime.js";
import { expectPairingReplyText } from "../../../../test/helpers/pairing-reply.js";
import { createAgentComponentButton, createAgentSelectMenu } from "./agent-components.js";
const readAllowFromStoreMock = vi.hoisted(() => vi.fn());
const upsertPairingRequestMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
return {
...actual,
readStoreAllowFromForDmPolicy: async (params: {
provider: string;
accountId: string;
dmPolicy?: string | null;
shouldRead?: boolean | null;
}) => {
if (params.shouldRead === false || params.dmPolicy === "allowlist") {
return [];
}
return await readAllowFromStoreMock(params.provider, params.accountId);
},
};
});
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/conversation-runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
};
});
describe("agent components", () => {
const defaultDmSessionKey = buildAgentSessionKey({
agentId: "main",
@@ -59,7 +89,8 @@ describe("agent components", () => {
};
beforeEach(() => {
resetDiscordComponentRuntimeMocks();
readAllowFromStoreMock.mockClear().mockResolvedValue([]);
upsertPairingRequestMock.mockClear().mockResolvedValue({ code: "PAIRCODE", created: true });
resetSystemEventsForTest();
});
@@ -76,10 +107,9 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledTimes(1);
const pairingText = String(reply.mock.calls[0]?.[0]?.content ?? "");
const code = expectPairingReplyText(pairingText, {
channel: "discord",
idLine: "Your Discord user id: 123456789",
});
expect(pairingText).toContain("Pairing code:");
const code = pairingText.match(/Pairing code:\s*([A-Z2-9]{8})/)?.[1];
expect(code).toBeDefined();
expect(pairingText).toContain(`openclaw pairing approve discord ${code}`);
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([]);
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");

View File

@@ -11,14 +11,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { buildPluginBindingApprovalCustomId } from "openclaw/plugin-sdk/conversation-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildPluginBindingResolvedTextMock,
readAllowFromStoreMock,
recordInboundSessionMock,
resetDiscordComponentRuntimeMocks,
resolvePluginConversationBindingApprovalMock,
upsertPairingRequestMock,
} from "../../../../test/helpers/extensions/discord-component-runtime.js";
import {
clearDiscordComponentEntries,
registerDiscordComponentEntries,
@@ -53,25 +45,75 @@ import {
resolveDiscordReplyDeliveryPlan,
} from "./threading.js";
const readAllowFromStoreMock = vi.hoisted(() => vi.fn());
const upsertPairingRequestMock = vi.hoisted(() => vi.fn());
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
const dispatchReplyMock = vi.hoisted(() => vi.fn());
const recordInboundSessionMock = vi.hoisted(() => vi.fn());
const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
const resolveStorePathMock = vi.hoisted(() => vi.fn());
const dispatchPluginInteractiveHandlerMock = vi.hoisted(() => vi.fn());
const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn());
const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn());
let lastDispatchCtx: Record<string, unknown> | undefined;
async function createInfraRuntimeMock(
importOriginal: () => Promise<typeof import("openclaw/plugin-sdk/infra-runtime")>,
) {
const actual = await importOriginal();
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
return {
...actual,
readStoreAllowFromForDmPolicy: async (params: {
provider: string;
accountId: string;
dmPolicy?: string | null;
shouldRead?: boolean | null;
}) => {
if (params.shouldRead === false || params.dmPolicy === "allowlist") {
return [];
}
return await readAllowFromStoreMock(params.provider, params.accountId);
},
};
});
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
resolvePluginConversationBindingApproval: (...args: unknown[]) =>
resolvePluginConversationBindingApprovalMock(...args),
buildPluginBindingResolvedText: (...args: unknown[]) =>
buildPluginBindingResolvedTextMock(...args),
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/conversation-runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
resolvePluginConversationBindingApproval: (...args: unknown[]) =>
resolvePluginConversationBindingApprovalMock(...args),
buildPluginBindingResolvedText: (...args: unknown[]) =>
buildPluginBindingResolvedTextMock(...args),
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
};
}
vi.mock("openclaw/plugin-sdk/infra-runtime", createInfraRuntimeMock);
vi.mock("openclaw/plugin-sdk/infra-runtime.js", createInfraRuntimeMock);
});
vi.mock("openclaw/plugin-sdk/infra-runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
@@ -255,58 +297,6 @@ describe("discord component interactions", () => {
...overrides,
});
const createGuildPluginButton = (allowFrom: string[]) =>
createDiscordComponentButton(
createComponentContext({
cfg: {
commands: { useAccessGroups: true },
channels: { discord: { replyToMode: "first" } },
} as OpenClawConfig,
allowFrom,
}),
);
const createGuildPluginButtonInteraction = (interactionId: string) =>
createComponentButtonInteraction({
rawData: {
channel_id: "guild-channel",
guild_id: "guild-1",
id: interactionId,
member: { roles: [] },
} as unknown as ButtonInteraction["rawData"],
guild: { id: "guild-1", name: "Test Guild" } as unknown as ButtonInteraction["guild"],
});
async function expectPluginGuildInteractionAuth(params: {
allowFrom: string[];
interactionId: string;
isAuthorizedSender: boolean;
}) {
registerDiscordComponentEntries({
entries: [createButtonEntry({ callbackData: "codex:approve" })],
modals: [],
});
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
matched: true,
handled: true,
duplicate: false,
});
const button = createGuildPluginButton(params.allowFrom);
const { interaction } = createGuildPluginButtonInteraction(params.interactionId);
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith(
expect.objectContaining({
ctx: expect.objectContaining({
auth: { isAuthorizedSender: params.isAuthorizedSender },
}),
}),
);
expect(dispatchReplyMock).not.toHaveBeenCalled();
}
beforeEach(() => {
editDiscordComponentMessageMock = vi
.spyOn(sendComponents, "editDiscordComponentMessage")
@@ -315,8 +305,9 @@ describe("discord component interactions", () => {
channelId: "dm-channel",
});
clearDiscordComponentEntries();
resetDiscordComponentRuntimeMocks();
lastDispatchCtx = undefined;
readAllowFromStoreMock.mockClear().mockResolvedValue([]);
upsertPairingRequestMock.mockClear().mockResolvedValue({ code: "PAIRCODE", created: true });
enqueueSystemEventMock.mockClear();
dispatchReplyMock.mockClear().mockImplementation(async (params: DispatchParams) => {
lastDispatchCtx = params.ctx;
@@ -330,6 +321,33 @@ describe("discord component interactions", () => {
handled: false,
duplicate: false,
});
resolvePluginConversationBindingApprovalMock.mockReset().mockResolvedValue({
status: "approved",
binding: {
bindingId: "binding-1",
pluginId: "openclaw-codex-app-server",
pluginName: "OpenClaw App Server",
pluginRoot: "/plugins/codex",
channel: "discord",
accountId: "default",
conversationId: "user:123456789",
boundAt: Date.now(),
},
request: {
id: "approval-1",
pluginId: "openclaw-codex-app-server",
pluginName: "OpenClaw App Server",
pluginRoot: "/plugins/codex",
requestedAt: Date.now(),
conversation: {
channel: "discord",
accountId: "default",
conversationId: "user:123456789",
},
},
decision: "allow-once",
});
buildPluginBindingResolvedTextMock.mockReset().mockReturnValue("Binding approved.");
});
it("routes button clicks with reply references", async () => {
@@ -534,19 +552,87 @@ describe("discord component interactions", () => {
});
it("passes false auth to plugin Discord interactions for non-allowlisted guild users", async () => {
await expectPluginGuildInteractionAuth({
allowFrom: ["owner-1"],
interactionId: "interaction-guild-plugin-1",
isAuthorizedSender: false,
registerDiscordComponentEntries({
entries: [createButtonEntry({ callbackData: "codex:approve" })],
modals: [],
});
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
matched: true,
handled: true,
duplicate: false,
});
const button = createDiscordComponentButton(
createComponentContext({
cfg: {
commands: { useAccessGroups: true },
channels: { discord: { replyToMode: "first" } },
} as OpenClawConfig,
allowFrom: ["owner-1"],
}),
);
const { interaction } = createComponentButtonInteraction({
rawData: {
channel_id: "guild-channel",
guild_id: "guild-1",
id: "interaction-guild-plugin-1",
member: { roles: [] },
} as unknown as ButtonInteraction["rawData"],
guild: { id: "guild-1", name: "Test Guild" } as unknown as ButtonInteraction["guild"],
});
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith(
expect.objectContaining({
ctx: expect.objectContaining({
auth: { isAuthorizedSender: false },
}),
}),
);
expect(dispatchReplyMock).not.toHaveBeenCalled();
});
it("passes true auth to plugin Discord interactions for allowlisted guild users", async () => {
await expectPluginGuildInteractionAuth({
allowFrom: ["123456789"],
interactionId: "interaction-guild-plugin-2",
isAuthorizedSender: true,
registerDiscordComponentEntries({
entries: [createButtonEntry({ callbackData: "codex:approve" })],
modals: [],
});
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
matched: true,
handled: true,
duplicate: false,
});
const button = createDiscordComponentButton(
createComponentContext({
cfg: {
commands: { useAccessGroups: true },
channels: { discord: { replyToMode: "first" } },
} as OpenClawConfig,
allowFrom: ["123456789"],
}),
);
const { interaction } = createComponentButtonInteraction({
rawData: {
channel_id: "guild-channel",
guild_id: "guild-1",
id: "interaction-guild-plugin-2",
member: { roles: [] },
} as unknown as ButtonInteraction["rawData"],
guild: { id: "guild-1", name: "Test Guild" } as unknown as ButtonInteraction["guild"],
});
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith(
expect.objectContaining({
ctx: expect.objectContaining({
auth: { isAuthorizedSender: true },
}),
}),
);
expect(dispatchReplyMock).not.toHaveBeenCalled();
});
it("routes plugin Discord interactions in group DMs by channel id instead of sender id", async () => {

View File

@@ -1,89 +0,0 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
ensureConfiguredBindingRouteReady,
resolveConfiguredBindingRoute,
} from "openclaw/plugin-sdk/conversation-runtime";
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import {
resolveDiscordBoundConversationRoute,
resolveDiscordEffectiveRoute,
} from "./route-resolution.js";
import type { ThreadBindingRecord } from "./thread-bindings.js";
type ResolvedConfiguredBindingRoute = ReturnType<typeof resolveConfiguredBindingRoute>;
type ConfiguredBindingResolution = NonNullable<
NonNullable<ResolvedConfiguredBindingRoute>["bindingResolution"]
>;
export type DiscordNativeInteractionRouteState = {
route: ResolvedAgentRoute;
effectiveRoute: ResolvedAgentRoute;
boundSessionKey?: string;
configuredRoute: ResolvedConfiguredBindingRoute | null;
configuredBinding: ConfiguredBindingResolution | null;
bindingReadiness: Awaited<ReturnType<typeof ensureConfiguredBindingRouteReady>> | null;
};
export async function resolveDiscordNativeInteractionRouteState(params: {
cfg: OpenClawConfig;
accountId: string;
guildId?: string;
memberRoleIds?: string[];
isDirectMessage: boolean;
isGroupDm: boolean;
directUserId?: string;
conversationId: string;
parentConversationId?: string;
threadBinding?: ThreadBindingRecord;
enforceConfiguredBindingReadiness?: boolean;
}): Promise<DiscordNativeInteractionRouteState> {
const route = resolveDiscordBoundConversationRoute({
cfg: params.cfg,
accountId: params.accountId,
guildId: params.guildId,
memberRoleIds: params.memberRoleIds,
isDirectMessage: params.isDirectMessage,
isGroupDm: params.isGroupDm,
directUserId: params.directUserId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
});
const configuredRoute =
params.threadBinding == null
? resolveConfiguredBindingRoute({
cfg: params.cfg,
route,
conversation: {
channel: "discord",
accountId: params.accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
},
})
: null;
const configuredBinding = configuredRoute?.bindingResolution ?? null;
const configuredBoundSessionKey = configuredRoute?.boundSessionKey?.trim() || undefined;
const boundSessionKey =
params.threadBinding?.targetSessionKey?.trim() || configuredBoundSessionKey;
const effectiveRoute = resolveDiscordEffectiveRoute({
route,
boundSessionKey,
configuredRoute,
matchedBy: configuredBinding ? "binding.channel" : undefined,
});
const bindingReadiness =
params.enforceConfiguredBindingReadiness && configuredBinding
? await ensureConfiguredBindingRouteReady({
cfg: params.cfg,
bindingResolution: configuredBinding,
})
: null;
return {
route,
effectiveRoute,
boundSessionKey,
configuredRoute,
configuredBinding,
bindingReadiness,
};
}

View File

@@ -5,14 +5,12 @@ import {
Row,
StringSelectMenu,
TextDisplay,
type AutocompleteInteraction,
type ButtonInteraction,
type CommandInteraction,
type ComponentData,
type StringSelectMenuInteraction,
} from "@buape/carbon";
import { ButtonStyle } from "discord-api-types/v10";
import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
import {
buildCommandTextFromArgs,
findCommandByNativeName,
@@ -30,7 +28,7 @@ import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-r
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { chunkItems, withTimeout } from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry } from "./allow-list.js";
import { normalizeDiscordSlug } from "./allow-list.js";
import { resolveDiscordChannelInfo } from "./message-utils.js";
import {
readDiscordModelPickerRecentModels,
@@ -47,7 +45,7 @@ import {
toDiscordModelPickerMessagePayload,
type DiscordModelPickerCommandContext,
} from "./model-picker.js";
import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js";
import { resolveDiscordBoundConversationRoute } from "./route-resolution.js";
import type { ThreadBindingManager } from "./thread-bindings.js";
import { resolveDiscordThreadParentInfo } from "./threading.js";
@@ -219,16 +217,11 @@ function buildDiscordModelPickerNoticePayload(message: string): { components: Co
};
}
async function resolveDiscordModelPickerRouteState(params: {
interaction:
| CommandInteraction
| ButtonInteraction
| StringSelectMenuInteraction
| AutocompleteInteraction;
async function resolveDiscordModelPickerRoute(params: {
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
cfg: ReturnType<typeof loadConfig>;
accountId: string;
threadBindings: ThreadBindingManager;
enforceConfiguredBindingReadiness?: boolean;
}) {
const { interaction, cfg, accountId } = params;
const channel = interaction.channel;
@@ -262,7 +255,7 @@ async function resolveDiscordModelPickerRouteState(params: {
const threadBinding = isThreadChannel
? params.threadBindings.getByThreadId(rawChannelId)
: undefined;
return await resolveDiscordNativeInteractionRouteState({
return resolveDiscordBoundConversationRoute({
cfg,
accountId,
guildId: interaction.guild?.id ?? undefined,
@@ -272,72 +265,10 @@ async function resolveDiscordModelPickerRouteState(params: {
directUserId: interaction.user?.id ?? rawChannelId,
conversationId: rawChannelId,
parentConversationId: threadParentId,
threadBinding,
enforceConfiguredBindingReadiness: params.enforceConfiguredBindingReadiness,
boundSessionKey: threadBinding?.targetSessionKey,
});
}
async function resolveDiscordModelPickerRoute(params: {
interaction:
| CommandInteraction
| ButtonInteraction
| StringSelectMenuInteraction
| AutocompleteInteraction;
cfg: ReturnType<typeof loadConfig>;
accountId: string;
threadBindings: ThreadBindingManager;
}) {
const resolved = await resolveDiscordModelPickerRouteState(params);
return resolved.effectiveRoute;
}
export async function resolveDiscordNativeChoiceContext(params: {
interaction: AutocompleteInteraction;
cfg: ReturnType<typeof loadConfig>;
accountId: string;
threadBindings: ThreadBindingManager;
}): Promise<{ provider?: string; model?: string } | null> {
try {
const resolved = await resolveDiscordModelPickerRouteState({
interaction: params.interaction,
cfg: params.cfg,
accountId: params.accountId,
threadBindings: params.threadBindings,
enforceConfiguredBindingReadiness: true,
});
if (resolved.bindingReadiness && !resolved.bindingReadiness.ok) {
return null;
}
const route = resolved.effectiveRoute;
const fallback = resolveDefaultModelForAgent({
cfg: params.cfg,
agentId: route.agentId,
});
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: route.agentId,
});
const sessionStore = loadSessionStore(storePath);
const sessionEntry = sessionStore[route.sessionKey];
const override = resolveStoredModelOverride({
sessionEntry,
sessionStore,
sessionKey: route.sessionKey,
});
if (!override?.model) {
return {
provider: fallback.provider,
model: fallback.model,
};
}
return {
provider: override.provider || fallback.provider,
model: override.model,
};
} catch {
return null;
}
}
function resolveDiscordModelPickerCurrentModel(params: {
cfg: ReturnType<typeof loadConfig>;
route: ResolvedAgentRoute;

View File

@@ -13,7 +13,6 @@ import * as timeoutModule from "../../../../src/utils/with-timeout.js";
import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
import * as modelPickerModule from "./model-picker.js";
import { createModelsProviderData as createBaseModelsProviderData } from "./model-picker.test-utils.js";
import { replyWithDiscordModelPickerProviders } from "./native-command-ui.js";
import {
createDiscordModelPickerFallbackButton,
createDiscordModelPickerFallbackSelect,
@@ -30,8 +29,8 @@ type PickerSelectData = Parameters<PickerSelect["run"]>[1];
type MockInteraction = {
user: { id: string; username: string; globalName: string };
channel: { type: ChannelType; id: string; name?: string; parentId?: string };
guild: { id: string } | null;
channel: { type: ChannelType; id: string };
guild: null;
rawData: { id: string; member: { roles: string[] } };
values?: string[];
reply: ReturnType<typeof vi.fn>;
@@ -460,38 +459,4 @@ describe("Discord model picker interactions", () => {
)?.[0];
expect(mismatchLog).toContain("session key agent:worker:subagent:bound");
});
it("loads model picker data from the effective bound route", async () => {
const context = createModelPickerContext();
context.threadBindings = createBoundThreadBindingManager({
accountId: "default",
threadId: "thread-bound",
targetSessionKey: "agent:worker:subagent:bound",
agentId: "worker",
});
const loadSpy = vi
.spyOn(modelPickerModule, "loadDiscordModelPickerData")
.mockResolvedValue(createDefaultModelPickerData());
const interaction = createInteraction({ userId: "owner" });
interaction.guild = { id: "guild-1" };
interaction.channel = {
type: ChannelType.PublicThread,
id: "thread-bound",
name: "bound-thread",
parentId: "parent-1",
};
await replyWithDiscordModelPickerProviders({
interaction: interaction as never,
cfg: context.cfg,
command: "model",
userId: "owner",
accountId: context.accountId,
threadBindings: context.threadBindings,
preferFollowUp: false,
safeInteractionCall: async (_label, fn) => await fn(),
});
expect(loadSpy).toHaveBeenCalledWith(context.cfg, "worker");
});
});

View File

@@ -1,5 +1,4 @@
import { ChannelType } from "discord-api-types/v10";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, loadConfig } from "../../../../src/config/config.js";
let listNativeCommandSpecs: typeof import("../../../../src/auto-reply/commands-registry.js").listNativeCommandSpecs;
let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand;
@@ -7,10 +6,6 @@ let createNoopThreadBindingManager: typeof import("./thread-bindings.js").create
function createNativeCommand(
name: string,
opts?: {
cfg?: ReturnType<typeof loadConfig>;
discordConfig?: NonNullable<OpenClawConfig["channels"]>["discord"];
},
): ReturnType<typeof import("./native-command.js").createDiscordNativeCommand> {
const command = listNativeCommandSpecs({ provider: "discord" }).find(
(entry) => entry.name === name,
@@ -18,10 +13,8 @@ function createNativeCommand(
if (!command) {
throw new Error(`missing native command: ${name}`);
}
const cfg = (opts?.cfg ?? {}) as ReturnType<typeof loadConfig>;
const discordConfig = (opts?.discordConfig ?? {}) as NonNullable<
OpenClawConfig["channels"]
>["discord"];
const cfg = {} as ReturnType<typeof loadConfig>;
const discordConfig = {} as NonNullable<OpenClawConfig["channels"]>["discord"];
return createDiscordNativeCommand({
command,
cfg,
@@ -71,7 +64,8 @@ function readChoices(option: CommandOption | undefined): unknown[] | undefined {
}
describe("createDiscordNativeCommand option wiring", () => {
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
({ listNativeCommandSpecs } = await import("../../../../src/auto-reply/commands-registry.js"));
({ createDiscordNativeCommand } = await import("./native-command.js"));
({ createNoopThreadBindingManager } = await import("./thread-bindings.js"));
@@ -88,22 +82,10 @@ describe("createDiscordNativeCommand option wiring", () => {
expect(readChoices(action)).toBeUndefined();
await autocomplete({
user: {
id: "owner",
username: "tester",
globalName: "Tester",
},
channel: {
type: ChannelType.DM,
id: "dm-1",
},
guild: undefined,
rawData: {},
options: {
getFocused: () => ({ value: "st" }),
},
respond,
client: {},
} as never);
expect(respond).toHaveBeenCalledWith([
{ name: "steer", value: "steer" },
@@ -124,48 +106,4 @@ describe("createDiscordNativeCommand option wiring", () => {
]),
);
});
it("returns no autocomplete choices for unauthorized users", async () => {
const command = createNativeCommand("think", {
cfg: {
commands: {
allowFrom: {
discord: ["user:allowed-user"],
},
},
} as ReturnType<typeof loadConfig>,
});
const level = requireOption(command, "level");
const autocomplete = readAutocomplete(level);
if (typeof autocomplete !== "function") {
throw new Error("think level option did not wire autocomplete");
}
const respond = vi.fn(async (_choices: unknown[]) => undefined);
await autocomplete({
user: {
id: "blocked-user",
username: "blocked",
globalName: "Blocked",
},
channel: {
type: ChannelType.GuildText,
id: "channel-1",
name: "general",
},
guild: {
id: "guild-1",
},
rawData: {
member: { roles: [] },
},
options: {
getFocused: () => ({ value: "xh" }),
},
respond,
client: {},
} as never);
expect(respond).toHaveBeenCalledWith([]);
});
});

View File

@@ -1,5 +1,5 @@
import { ChannelType } from "discord-api-types/v10";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js";
import { setDefaultChannelPluginRegistryForTests } from "../../../../src/commands/channel-test-helpers.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
@@ -13,8 +13,6 @@ import { createNoopThreadBindingManager } from "./thread-bindings.js";
type EnsureConfiguredBindingRouteReadyFn =
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand;
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
ok: true,
@@ -83,80 +81,13 @@ function createConfig(): OpenClawConfig {
} as OpenClawConfig;
}
function createConfiguredAcpBinding(params: {
channelId: string;
peerKind: "channel" | "direct";
agentId?: string;
}) {
return {
type: "acp",
agentId: params.agentId ?? "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: params.peerKind, id: params.channelId },
},
acp: {
mode: "persistent",
},
} as const;
}
function createConfiguredAcpCase(params: {
channelType: ChannelType;
channelId: string;
peerKind: "channel" | "direct";
guildId?: string;
guildName?: string;
includeChannelAccess?: boolean;
agentId?: string;
}) {
return {
cfg: {
commands: {
useAccessGroups: false,
},
...(params.includeChannelAccess === false
? {}
: params.channelType === ChannelType.DM
? {
channels: {
discord: {
dm: { enabled: true, policy: "open" },
},
},
}
: {
channels: {
discord: {
guilds: {
[params.guildId!]: {
channels: {
[params.channelId]: { allow: true, requireMention: false },
},
},
},
},
},
}),
bindings: [
createConfiguredAcpBinding({
channelId: params.channelId,
peerKind: params.peerKind,
agentId: params.agentId,
}),
],
} as OpenClawConfig,
interaction: createInteraction({
channelType: params.channelType,
channelId: params.channelId,
guildId: params.guildId,
guildName: params.guildName,
}),
};
async function loadCreateDiscordNativeCommand() {
vi.resetModules();
return (await import("./native-command.js")).createDiscordNativeCommand;
}
async function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec) {
const createDiscordNativeCommand = await loadCreateDiscordNativeCommand();
return createDiscordNativeCommand({
command: commandSpec,
cfg,
@@ -169,6 +100,7 @@ async function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeComma
}
async function createPluginCommand(params: { cfg: OpenClawConfig; name: string }) {
const createDiscordNativeCommand = await loadCreateDiscordNativeCommand();
return createDiscordNativeCommand({
command: {
name: params.name,
@@ -282,10 +214,6 @@ async function expectBoundStatusCommandDispatch(params: {
}
describe("Discord native plugin command dispatch", () => {
beforeAll(async () => {
({ createDiscordNativeCommand } = await import("./native-command.js"));
});
beforeEach(async () => {
vi.clearAllMocks();
clearPluginCommands();
@@ -442,11 +370,42 @@ describe("Discord native plugin command dispatch", () => {
});
it("routes native slash commands through configured ACP Discord channel bindings", async () => {
const { cfg, interaction } = createConfiguredAcpCase({
const guildId = "1459246755253325866";
const channelId = "1478836151241412759";
const cfg = {
commands: {
useAccessGroups: false,
},
channels: {
discord: {
guilds: {
[guildId]: {
channels: {
[channelId]: { allow: true, requireMention: false },
},
},
},
},
},
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: channelId },
},
acp: {
mode: "persistent",
},
},
],
} as OpenClawConfig;
const interaction = createInteraction({
channelType: ChannelType.GuildText,
channelId: "1478836151241412759",
peerKind: "channel",
guildId: "1459246755253325866",
channelId,
guildId,
guildName: "Ops",
});
@@ -512,10 +471,34 @@ describe("Discord native plugin command dispatch", () => {
});
it("routes Discord DM native slash commands through configured ACP bindings", async () => {
const { cfg, interaction } = createConfiguredAcpCase({
const channelId = "dm-1";
const cfg = {
commands: {
useAccessGroups: false,
},
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "direct", id: channelId },
},
acp: {
mode: "persistent",
},
},
],
channels: {
discord: {
dm: { enabled: true, policy: "open" },
},
},
} as OpenClawConfig;
const interaction = createInteraction({
channelType: ChannelType.DM,
channelId: "dm-1",
peerKind: "direct",
channelId,
});
await expectBoundStatusCommandDispatch({
@@ -526,13 +509,32 @@ describe("Discord native plugin command dispatch", () => {
});
it("allows recovery commands through configured ACP bindings even when ensure fails", async () => {
const { cfg, interaction } = createConfiguredAcpCase({
const guildId = "1459246755253325866";
const channelId = "1479098716916023408";
const cfg = {
commands: {
useAccessGroups: false,
},
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: channelId },
},
acp: {
mode: "persistent",
},
},
],
} as OpenClawConfig;
const interaction = createInteraction({
channelType: ChannelType.GuildText,
channelId: "1479098716916023408",
peerKind: "channel",
guildId: "1459246755253325866",
channelId,
guildId,
guildName: "Ops",
includeChannelAccess: false,
});
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
ok: false,

View File

@@ -1,210 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { ChannelType, type AutocompleteInteraction } from "@buape/carbon";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
findCommandByNativeName,
resolveCommandArgChoices,
} from "../../../../src/auto-reply/commands-registry.js";
import type { OpenClawConfig, loadConfig } from "../../../../src/config/config.js";
import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions/store.js";
import { createConfiguredBindingConversationRuntimeModuleMock } from "../../../../test/helpers/extensions/configured-binding-runtime.js";
import { resolveDiscordNativeChoiceContext } from "./native-command-ui.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
vi.fn<() => Promise<{ ok: boolean; error?: string }>>(async () => ({ ok: true })),
);
const resolveConfiguredBindingRouteMock = vi.hoisted(() =>
vi.fn<
() => {
bindingResolution: {
record: {
conversation: {
channel: string;
accountId: string;
conversationId: string;
};
};
};
boundSessionKey: string;
route: {
agentId: string;
sessionKey: string;
};
} | null
>(() => null),
);
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
return await createConfiguredBindingConversationRuntimeModuleMock(
{
ensureConfiguredBindingRouteReadyMock,
resolveConfiguredBindingRouteMock,
},
importOriginal,
);
});
const STORE_PATH = path.join(
os.tmpdir(),
`openclaw-discord-think-autocomplete-${process.pid}.json`,
);
const SESSION_KEY = "agent:main:main";
describe("discord native /think autocomplete", () => {
beforeEach(() => {
clearSessionStoreCacheForTest();
ensureConfiguredBindingRouteReadyMock.mockReset();
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
resolveConfiguredBindingRouteMock.mockReset();
resolveConfiguredBindingRouteMock.mockReturnValue(null);
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
fs.writeFileSync(
STORE_PATH,
JSON.stringify({
[SESSION_KEY]: {
updatedAt: Date.now(),
providerOverride: "openai-codex",
modelOverride: "gpt-5.4",
},
}),
"utf8",
);
});
afterEach(() => {
clearSessionStoreCacheForTest();
try {
fs.unlinkSync(STORE_PATH);
} catch {}
});
function createConfig() {
return {
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4.5",
},
},
},
session: {
store: STORE_PATH,
},
} as ReturnType<typeof loadConfig>;
}
it("uses the session override context for /think choices", async () => {
const cfg = createConfig();
const interaction = {
options: {
getFocused: () => ({ value: "xh" }),
},
respond: async (_choices: Array<{ name: string; value: string }>) => {},
rawData: {},
channel: { id: "D1", type: ChannelType.DM },
user: { id: "U1" },
guild: undefined,
client: {},
} as unknown as AutocompleteInteraction & {
respond: (choices: Array<{ name: string; value: string }>) => Promise<void>;
};
const command = findCommandByNativeName("think", "discord");
expect(command).toBeTruthy();
const levelArg = command?.args?.find((entry) => entry.name === "level");
expect(levelArg).toBeTruthy();
if (!command || !levelArg) {
return;
}
const context = await resolveDiscordNativeChoiceContext({
interaction,
cfg,
accountId: "default",
threadBindings: createNoopThreadBindingManager("default"),
});
expect(context).toEqual({
provider: "openai-codex",
model: "gpt-5.4",
});
const choices = resolveCommandArgChoices({
command,
arg: levelArg,
cfg,
provider: context?.provider,
model: context?.model,
});
const values = choices.map((choice) => choice.value);
expect(values).toContain("xhigh");
});
it("falls back when a configured binding is unavailable", async () => {
const cfg = createConfig();
resolveConfiguredBindingRouteMock.mockReturnValue({
bindingResolution: {
record: {
conversation: {
channel: "discord",
accountId: "default",
conversationId: "C1",
},
},
},
boundSessionKey: SESSION_KEY,
route: {
agentId: "main",
sessionKey: SESSION_KEY,
},
});
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
ok: false,
error: "acpx exited",
});
const interaction = {
options: {
getFocused: () => ({ value: "xh" }),
},
respond: async (_choices: Array<{ name: string; value: string }>) => {},
rawData: {
member: { roles: [] },
},
channel: { id: "C1", type: ChannelType.GuildText },
user: { id: "U1" },
guild: { id: "G1" },
client: {},
} as unknown as AutocompleteInteraction & {
respond: (choices: Array<{ name: string; value: string }>) => Promise<void>;
};
const context = await resolveDiscordNativeChoiceContext({
interaction,
cfg,
accountId: "default",
threadBindings: createNoopThreadBindingManager("default"),
});
expect(context).toBeNull();
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
const command = findCommandByNativeName("think", "discord");
const levelArg = command?.args?.find((entry) => entry.name === "level");
expect(command).toBeTruthy();
expect(levelArg).toBeTruthy();
if (!command || !levelArg) {
return;
}
const choices = resolveCommandArgChoices({
command,
arg: levelArg,
cfg,
provider: context?.provider,
model: context?.model,
});
const values = choices.map((choice) => choice.value);
expect(values).not.toContain("xhigh");
});
});

View File

@@ -34,6 +34,10 @@ import {
import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
import {
ensureConfiguredBindingRouteReady,
resolveConfiguredBindingRoute,
} from "openclaw/plugin-sdk/conversation-runtime";
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { executePluginCommand, matchPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
@@ -63,18 +67,20 @@ import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
import { resolveDiscordChannelInfo } from "./message-utils.js";
import { buildDiscordNativeCommandContext } from "./native-command-context.js";
import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js";
import {
buildDiscordCommandArgMenu,
createDiscordCommandArgFallbackButton as createDiscordCommandArgFallbackButtonUi,
createDiscordModelPickerFallbackButton as createDiscordModelPickerFallbackButtonUi,
createDiscordModelPickerFallbackSelect as createDiscordModelPickerFallbackSelectUi,
replyWithDiscordModelPickerProviders,
resolveDiscordNativeChoiceContext,
shouldOpenDiscordModelPickerFromCommand,
type DiscordCommandArgContext,
type DiscordModelPickerContext,
} from "./native-command-ui.js";
import {
resolveDiscordBoundConversationRoute,
resolveDiscordEffectiveRoute,
} from "./route-resolution.js";
import { resolveDiscordSenderIdentity } from "./sender-identity.js";
import type { ThreadBindingManager } from "./thread-bindings.js";
import { resolveDiscordThreadParentInfo } from "./threading.js";
@@ -118,12 +124,8 @@ function resolveDiscordNativeCommandAllowlistAccess(params: {
function buildDiscordCommandOptions(params: {
command: ChatCommandDefinition;
cfg: ReturnType<typeof loadConfig>;
authorizeChoiceContext?: (interaction: AutocompleteInteraction) => Promise<boolean>;
resolveChoiceContext?: (
interaction: AutocompleteInteraction,
) => Promise<{ provider?: string; model?: string } | null>;
}): CommandOptions | undefined {
const { command, cfg, authorizeChoiceContext, resolveChoiceContext } = params;
const { command, cfg } = params;
const args = command.args;
if (!args || args.length === 0) {
return undefined;
@@ -153,29 +155,10 @@ function buildDiscordCommandOptions(params: {
(typeof arg.choices === "function" || resolvedChoices.length > 25));
const autocomplete = shouldAutocomplete
? async (interaction: AutocompleteInteraction) => {
if (
typeof arg.choices === "function" &&
resolveChoiceContext &&
authorizeChoiceContext &&
!(await authorizeChoiceContext(interaction))
) {
await interaction.respond([]);
return;
}
const focused = interaction.options.getFocused();
const focusValue =
typeof focused?.value === "string" ? focused.value.trim().toLowerCase() : "";
const context =
typeof arg.choices === "function" && resolveChoiceContext
? await resolveChoiceContext(interaction)
: null;
const choices = resolveCommandArgChoices({
command,
arg,
cfg,
provider: context?.provider,
model: context?.model,
});
const choices = resolveCommandArgChoices({ command, arg, cfg });
const filtered = focusValue
? choices.filter((choice) => choice.label.toLowerCase().includes(focusValue))
: choices;
@@ -206,179 +189,6 @@ function shouldBypassConfiguredAcpEnsure(commandName: string): boolean {
return normalized === "acp" || normalized === "new" || normalized === "reset";
}
async function resolveDiscordNativeAutocompleteAuthorized(params: {
interaction: AutocompleteInteraction;
cfg: ReturnType<typeof loadConfig>;
discordConfig: DiscordConfig;
accountId: string;
}): Promise<boolean> {
const { interaction, cfg, discordConfig, accountId } = params;
const user = interaction.user;
if (!user) {
return false;
}
const sender = resolveDiscordSenderIdentity({ author: user, pluralkitInfo: null });
const channel = interaction.channel;
const channelType = channel?.type;
const isDirectMessage = channelType === ChannelType.DM;
const isGroupDm = channelType === ChannelType.GroupDM;
const isThreadChannel =
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread;
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const rawChannelId = channel?.id ?? "";
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
const allowNameMatching = isDangerousNameMatchingEnabled(discordConfig);
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({
allowFrom: discordConfig?.allowFrom ?? discordConfig?.dm?.allowFrom ?? [],
sender: {
id: sender.id,
name: sender.name,
tag: sender.tag,
},
allowNameMatching,
});
const commandsAllowFromAccess = resolveDiscordNativeCommandAllowlistAccess({
cfg,
accountId,
sender: {
id: sender.id,
name: sender.name,
tag: sender.tag,
},
chatType: isDirectMessage
? "direct"
: isThreadChannel
? "thread"
: interaction.guild
? "channel"
: "group",
conversationId: rawChannelId || undefined,
});
const guildInfo = resolveDiscordGuildEntry({
guild: interaction.guild ?? undefined,
guildId: interaction.guild?.id ?? undefined,
guildEntries: discordConfig?.guilds,
});
let threadParentId: string | undefined;
let threadParentName: string | undefined;
let threadParentSlug = "";
if (interaction.guild && channel && isThreadChannel && rawChannelId) {
const channelInfo = await resolveDiscordChannelInfo(interaction.client, rawChannelId);
const parentInfo = await resolveDiscordThreadParentInfo({
client: interaction.client,
threadChannel: {
id: rawChannelId,
name: channelName,
parentId: "parentId" in channel ? (channel.parentId ?? undefined) : undefined,
parent: undefined,
},
channelInfo,
});
threadParentId = parentInfo.id;
threadParentName = parentInfo.name;
threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : "";
}
const channelConfig = interaction.guild
? resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: rawChannelId,
channelName,
channelSlug,
parentId: threadParentId,
parentName: threadParentName,
parentSlug: threadParentSlug,
scope: isThreadChannel ? "thread" : "channel",
})
: null;
if (channelConfig?.enabled === false) {
return false;
}
if (interaction.guild && channelConfig?.allowed === false) {
return false;
}
if (useAccessGroups && interaction.guild) {
const channelAllowlistConfigured =
Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false;
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.discord !== undefined,
groupPolicy: discordConfig?.groupPolicy,
defaultGroupPolicy: cfg.channels?.defaults?.groupPolicy,
});
const allowByPolicy = isDiscordGroupAllowedByPolicy({
groupPolicy,
guildAllowlisted: Boolean(guildInfo),
channelAllowlistConfigured,
channelAllowed,
});
if (!allowByPolicy) {
return false;
}
}
const dmEnabled = discordConfig?.dm?.enabled ?? true;
const dmPolicy = discordConfig?.dmPolicy ?? discordConfig?.dm?.policy ?? "pairing";
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") {
return false;
}
const dmAccess = await resolveDiscordDmCommandAccess({
accountId,
dmPolicy,
configuredAllowFrom: discordConfig?.allowFrom ?? discordConfig?.dm?.allowFrom ?? [],
sender: {
id: sender.id,
name: sender.name,
tag: sender.tag,
},
allowNameMatching,
useAccessGroups,
});
if (dmAccess.decision !== "allow") {
return false;
}
}
if (isGroupDm && discordConfig?.dm?.groupEnabled === false) {
return false;
}
if (!isDirectMessage) {
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds,
sender,
allowNameMatching,
});
const authorizers = useAccessGroups
? [
{
configured: commandsAllowFromAccess.configured,
allowed: commandsAllowFromAccess.allowed,
},
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: hasAccessRestrictions, allowed: memberAllowed },
]
: [
{
configured: commandsAllowFromAccess.configured,
allowed: commandsAllowFromAccess.allowed,
},
{ configured: hasAccessRestrictions, allowed: memberAllowed },
];
return resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers,
modeWhenAccessGroupsOff: "configured",
});
}
return true;
}
function readDiscordCommandArgs(
interaction: CommandInteraction,
definitions?: CommandArgDefinition[],
@@ -487,20 +297,6 @@ export function createDiscordNativeCommand(params: {
const commandOptions = buildDiscordCommandOptions({
command: commandDefinition,
cfg,
authorizeChoiceContext: async (interaction) =>
await resolveDiscordNativeAutocompleteAuthorized({
interaction,
cfg,
discordConfig,
accountId,
}),
resolveChoiceContext: async (interaction) =>
resolveDiscordNativeChoiceContext({
interaction,
cfg,
accountId,
threadBindings,
}),
});
const options = commandOptions
? (commandOptions satisfies CommandOptions)
@@ -890,9 +686,7 @@ async function dispatchDiscordCommandInteraction(params: {
const isGuild = Boolean(interaction.guild);
const channelId = rawChannelId || "unknown";
const interactionId = interaction.rawData.id;
const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined;
const commandName = command.nativeName ?? command.key;
const routeState = await resolveDiscordNativeInteractionRouteState({
const route = resolveDiscordBoundConversationRoute({
cfg,
accountId,
guildId: interaction.guild?.id ?? undefined,
@@ -902,21 +696,46 @@ async function dispatchDiscordCommandInteraction(params: {
directUserId: user.id,
conversationId: channelId,
parentConversationId: threadParentId,
threadBinding,
enforceConfiguredBindingReadiness: !shouldBypassConfiguredAcpEnsure(commandName),
// Configured ACP routes apply after raw route resolution, so do not pass
// bound/configured overrides here.
});
if (routeState.bindingReadiness && !routeState.bindingReadiness.ok) {
const configuredBinding = routeState.configuredBinding;
if (configuredBinding) {
const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined;
const configuredRoute =
threadBinding == null
? resolveConfiguredBindingRoute({
cfg,
route,
conversation: {
channel: "discord",
accountId,
conversationId: channelId,
parentConversationId: threadParentId,
},
})
: null;
const configuredBinding = configuredRoute?.bindingResolution ?? null;
const commandName = command.nativeName ?? command.key;
if (configuredBinding && !shouldBypassConfiguredAcpEnsure(commandName)) {
const ensured = await ensureConfiguredBindingRouteReady({
cfg,
bindingResolution: configuredBinding,
});
if (!ensured.ok) {
logVerbose(
`discord native command: configured ACP binding unavailable for channel ${configuredBinding.record.conversation.conversationId}: ${routeState.bindingReadiness.error}`,
`discord native command: configured ACP binding unavailable for channel ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`,
);
await respond("Configured ACP binding is unavailable right now. Please try again.");
return;
}
}
const boundSessionKey = routeState.boundSessionKey;
const effectiveRoute = routeState.effectiveRoute;
const configuredBoundSessionKey = configuredRoute?.boundSessionKey?.trim() || undefined;
const boundSessionKey = threadBinding?.targetSessionKey?.trim() || configuredBoundSessionKey;
const effectiveRoute = resolveDiscordEffectiveRoute({
route,
boundSessionKey,
configuredRoute,
matchedBy: configuredBinding ? "binding.channel" : undefined,
});
const { sessionKey, commandTargetSessionKey } = resolveNativeCommandSessionTargets({
agentId: effectiveRoute.agentId,
sessionPrefix,

View File

@@ -3,7 +3,6 @@ import type { Client } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../../../src/runtime.js";
import type { WaitForDiscordGatewayStopParams } from "../monitor.gateway.js";
import type { DiscordGatewayEvent } from "./gateway-supervisor.js";
const {
attachDiscordGatewayLoggingMock,
@@ -56,7 +55,7 @@ describe("runDiscordGatewayLifecycle", () => {
start?: () => Promise<void>;
stop?: () => Promise<void>;
isDisallowedIntentsError?: (err: unknown) => boolean;
pendingGatewayEvents?: DiscordGatewayEvent[];
pendingGatewayErrors?: unknown[];
gateway?: {
isConnected?: boolean;
options?: Record<string, unknown>;
@@ -77,26 +76,7 @@ describe("runDiscordGatewayLifecycle", () => {
const runtimeLog = vi.fn();
const runtimeError = vi.fn();
const runtimeExit = vi.fn();
const pendingGatewayEvents = params?.pendingGatewayEvents ?? [];
const gatewaySupervisor = {
attachLifecycle: vi.fn(),
detachLifecycle: vi.fn(),
drainPending: vi.fn((handler: (event: DiscordGatewayEvent) => "continue" | "stop") => {
if (pendingGatewayEvents.length === 0) {
return "continue";
}
const queued = [...pendingGatewayEvents];
pendingGatewayEvents.length = 0;
for (const event of queued) {
if (handler(event) === "stop") {
return "stop";
}
}
return "continue";
}),
dispose: vi.fn(),
emitter: params?.gateway?.emitter,
};
const releaseEarlyGatewayErrorGuard = vi.fn();
const statusSink = vi.fn();
const runtime: RuntimeEnv = {
log: runtimeLog,
@@ -109,7 +89,7 @@ describe("runDiscordGatewayLifecycle", () => {
threadStop,
runtimeLog,
runtimeError,
gatewaySupervisor,
releaseEarlyGatewayErrorGuard,
statusSink,
lifecycleParams: {
accountId: params?.accountId ?? "default",
@@ -122,7 +102,8 @@ describe("runDiscordGatewayLifecycle", () => {
voiceManagerRef: { current: null },
execApprovalsHandler: { start, stop },
threadBindings: { stop: threadStop },
gatewaySupervisor,
pendingGatewayErrors: params?.pendingGatewayErrors,
releaseEarlyGatewayErrorGuard,
statusSink,
abortSignal: undefined as AbortSignal | undefined,
},
@@ -134,7 +115,7 @@ describe("runDiscordGatewayLifecycle", () => {
stop: ReturnType<typeof vi.fn>;
threadStop: ReturnType<typeof vi.fn>;
waitCalls: number;
gatewaySupervisor: { detachLifecycle: ReturnType<typeof vi.fn> };
releaseEarlyGatewayErrorGuard: ReturnType<typeof vi.fn>;
}) {
expect(params.start).toHaveBeenCalledTimes(1);
expect(params.stop).toHaveBeenCalledTimes(1);
@@ -142,7 +123,7 @@ describe("runDiscordGatewayLifecycle", () => {
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
expect(params.threadStop).toHaveBeenCalledTimes(1);
expect(params.gatewaySupervisor.detachLifecycle).toHaveBeenCalledTimes(1);
expect(params.releaseEarlyGatewayErrorGuard).toHaveBeenCalledTimes(1);
}
function createGatewayHarness(params?: {
@@ -171,26 +152,14 @@ describe("runDiscordGatewayLifecycle", () => {
await vi.advanceTimersByTimeAsync(delayMs);
}
function createGatewayEvent(
type: DiscordGatewayEvent["type"],
message: string,
): DiscordGatewayEvent {
const err = new Error(message);
return {
type,
err,
message: String(err),
shouldStopLifecycle: type !== "other",
};
}
it("cleans up thread bindings when exec approvals startup fails", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } = createLifecycleHarness({
start: async () => {
throw new Error("startup failed");
},
});
const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
createLifecycleHarness({
start: async () => {
throw new Error("startup failed");
},
});
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow("startup failed");
@@ -199,14 +168,14 @@ describe("runDiscordGatewayLifecycle", () => {
stop,
threadStop,
waitCalls: 0,
gatewaySupervisor,
releaseEarlyGatewayErrorGuard,
});
});
it("cleans up when gateway wait fails after startup", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("gateway wait failed"));
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } =
const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
createLifecycleHarness();
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow(
@@ -218,13 +187,13 @@ describe("runDiscordGatewayLifecycle", () => {
stop,
threadStop,
waitCalls: 1,
gatewaySupervisor,
releaseEarlyGatewayErrorGuard,
});
});
it("cleans up after successful gateway wait", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } =
const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
createLifecycleHarness();
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
@@ -234,7 +203,7 @@ describe("runDiscordGatewayLifecycle", () => {
stop,
threadStop,
waitCalls: 1,
gatewaySupervisor,
releaseEarlyGatewayErrorGuard,
});
});
@@ -295,7 +264,7 @@ describe("runDiscordGatewayLifecycle", () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
const { emitter, gateway } = createGatewayHarness();
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } =
const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
createLifecycleHarness({ gateway });
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
@@ -313,7 +282,7 @@ describe("runDiscordGatewayLifecycle", () => {
stop,
threadStop,
waitCalls: 0,
gatewaySupervisor,
releaseEarlyGatewayErrorGuard,
});
} finally {
vi.useRealTimers();
@@ -322,13 +291,17 @@ describe("runDiscordGatewayLifecycle", () => {
it("handles queued disallowed intents errors without waiting for gateway events", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
const { lifecycleParams, start, stop, threadStop, runtimeError, gatewaySupervisor } =
createLifecycleHarness({
pendingGatewayEvents: [
createGatewayEvent("disallowed-intents", "Fatal Gateway error: 4014"),
],
isDisallowedIntentsError: (err) => String(err).includes("4014"),
});
const {
lifecycleParams,
start,
stop,
threadStop,
runtimeError,
releaseEarlyGatewayErrorGuard,
} = createLifecycleHarness({
pendingGatewayErrors: [new Error("Fatal Gateway error: 4014")],
isDisallowedIntentsError: (err) => String(err).includes("4014"),
});
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
@@ -340,36 +313,16 @@ describe("runDiscordGatewayLifecycle", () => {
stop,
threadStop,
waitCalls: 0,
gatewaySupervisor,
});
});
it("logs queued non-fatal startup gateway errors and continues", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
const { lifecycleParams, start, stop, threadStop, runtimeError, gatewaySupervisor } =
createLifecycleHarness({
pendingGatewayEvents: [createGatewayEvent("other", "transient startup error")],
});
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
expect(runtimeError).toHaveBeenCalledWith(
expect.stringContaining("discord gateway error: Error: transient startup error"),
);
expectLifecycleCleanup({
start,
stop,
threadStop,
waitCalls: 1,
gatewaySupervisor,
releaseEarlyGatewayErrorGuard,
});
});
it("throws queued non-disallowed fatal gateway errors", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } = createLifecycleHarness({
pendingGatewayEvents: [createGatewayEvent("fatal", "Fatal Gateway error: 4000")],
});
const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
createLifecycleHarness({
pendingGatewayErrors: [new Error("Fatal Gateway error: 4000")],
});
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow(
"Fatal Gateway error: 4000",
@@ -380,7 +333,7 @@ describe("runDiscordGatewayLifecycle", () => {
stop,
threadStop,
waitCalls: 0,
gatewaySupervisor,
releaseEarlyGatewayErrorGuard,
});
});
@@ -388,17 +341,23 @@ describe("runDiscordGatewayLifecycle", () => {
vi.useFakeTimers();
try {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
const pendingGatewayEvents: DiscordGatewayEvent[] = [];
const pendingGatewayErrors: unknown[] = [];
const { emitter, gateway } = createGatewayHarness();
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
const { lifecycleParams, start, stop, threadStop, runtimeError, gatewaySupervisor } =
createLifecycleHarness({
gateway,
pendingGatewayEvents,
});
const {
lifecycleParams,
start,
stop,
threadStop,
runtimeError,
releaseEarlyGatewayErrorGuard,
} = createLifecycleHarness({
gateway,
pendingGatewayErrors,
});
setTimeout(() => {
pendingGatewayEvents.push(createGatewayEvent("fatal", "Fatal Gateway error: 4001"));
pendingGatewayErrors.push(new Error("Fatal Gateway error: 4001"));
}, 1_000);
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
@@ -416,7 +375,7 @@ describe("runDiscordGatewayLifecycle", () => {
stop,
threadStop,
waitCalls: 0,
gatewaySupervisor,
releaseEarlyGatewayErrorGuard,
});
} finally {
vi.useRealTimers();

View File

@@ -8,7 +8,6 @@ import { attachDiscordGatewayLogging } from "../gateway-logging.js";
import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js";
import type { DiscordVoiceManager } from "../voice/manager.js";
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
import type { DiscordGatewayEvent, DiscordGatewaySupervisor } from "./gateway-supervisor.js";
import type { DiscordMonitorStatusSink } from "./status.js";
type ExecApprovalsHandler = {
@@ -57,7 +56,8 @@ export async function runDiscordGatewayLifecycle(params: {
voiceManagerRef: { current: DiscordVoiceManager | null };
execApprovalsHandler: ExecApprovalsHandler | null;
threadBindings: { stop: () => void };
gatewaySupervisor: DiscordGatewaySupervisor;
pendingGatewayErrors?: unknown[];
releaseEarlyGatewayErrorGuard?: () => void;
statusSink?: DiscordMonitorStatusSink;
}) {
const HELLO_TIMEOUT_MS = 30000;
@@ -68,7 +68,7 @@ export async function runDiscordGatewayLifecycle(params: {
if (gateway) {
registerGateway(params.accountId, gateway);
}
const gatewayEmitter = params.gatewaySupervisor.emitter ?? getDiscordGatewayEmitter(gateway);
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
const stopGatewayLogging = attachDiscordGatewayLogging({
emitter: gatewayEmitter,
runtime: params.runtime,
@@ -128,6 +128,7 @@ export async function runDiscordGatewayLifecycle(params: {
if (!gateway) {
return;
}
gatewayEmitter?.once("error", () => {});
gateway.options.reconnect = { maxAttempts: 0 };
gateway.disconnect();
};
@@ -273,30 +274,45 @@ export async function runDiscordGatewayLifecycle(params: {
gatewayEmitter?.on("debug", onGatewayDebug);
let sawDisallowedIntents = false;
const handleGatewayEvent = (event: DiscordGatewayEvent): "continue" | "stop" => {
if (event.type === "disallowed-intents") {
const logGatewayError = (err: unknown) => {
if (params.isDisallowedIntentsError(err)) {
sawDisallowedIntents = true;
params.runtime.error?.(
danger(
"discord: gateway closed with code 4014 (missing privileged gateway intents). Enable the required intents in the Discord Developer Portal or disable them in config.",
),
);
return "stop";
return;
}
params.runtime.error?.(danger(`discord gateway error: ${event.message}`));
return event.shouldStopLifecycle ? "stop" : "continue";
params.runtime.error?.(danger(`discord gateway error: ${String(err)}`));
};
const drainPendingGatewayErrors = (): "continue" | "stop" =>
params.gatewaySupervisor.drainPending((event) => {
const decision = handleGatewayEvent(event);
if (decision !== "stop") {
return "continue";
const shouldStopOnGatewayError = (err: unknown) => {
const message = String(err);
return (
message.includes("Max reconnect attempts") ||
message.includes("Fatal Gateway error") ||
params.isDisallowedIntentsError(err)
);
};
const drainPendingGatewayErrors = (): "continue" | "stop" => {
const pendingGatewayErrors = params.pendingGatewayErrors ?? [];
if (pendingGatewayErrors.length === 0) {
return "continue";
}
const queuedErrors = [...pendingGatewayErrors];
pendingGatewayErrors.length = 0;
for (const err of queuedErrors) {
logGatewayError(err);
if (!shouldStopOnGatewayError(err)) {
continue;
}
if (event.type === "disallowed-intents") {
if (params.isDisallowedIntentsError(err)) {
return "stop";
}
throw event.err;
});
throw err;
}
return "continue";
};
try {
if (params.execApprovalsHandler) {
await params.execApprovalsHandler.start();
@@ -379,19 +395,16 @@ export async function runDiscordGatewayLifecycle(params: {
});
}
if (drainPendingGatewayErrors() === "stop") {
return;
}
await waitForDiscordGatewayStop({
gateway: gateway
? {
emitter: gatewayEmitter,
disconnect: () => gateway.disconnect(),
}
: undefined,
abortSignal: params.abortSignal,
gatewaySupervisor: params.gatewaySupervisor,
onGatewayEvent: handleGatewayEvent,
onGatewayError: logGatewayError,
shouldStopOnError: shouldStopOnGatewayError,
registerForceStop: (forceStop) => {
forceStopHandler = forceStop;
if (queuedForceStopError !== undefined) {
@@ -407,7 +420,7 @@ export async function runDiscordGatewayLifecycle(params: {
}
} finally {
lifecycleStopping = true;
params.gatewaySupervisor.detachLifecycle();
params.releaseEarlyGatewayErrorGuard?.();
unregisterGateway(params.accountId);
stopGatewayLogging();
reconnectStallWatchdog.stop();

View File

@@ -1,9 +1,10 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
let __testing: typeof import("./provider.js").__testing;
describe("resolveThreadBindingsEnabled", () => {
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
({ __testing } = await import("./provider.js"));
});

View File

@@ -1,5 +1,5 @@
import { EventEmitter } from "node:events";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AcpRuntimeError } from "../../../../src/acp/runtime/errors.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import {
@@ -36,8 +36,6 @@ const {
voiceRuntimeModuleLoadedMock,
} = getProviderMonitorTestMocks();
let monitorDiscordProvider: typeof import("./provider.js").monitorDiscordProvider;
function createConfigWithDiscordAccount(overrides: Record<string, unknown> = {}): OpenClawConfig {
return {
channels: {
@@ -118,7 +116,9 @@ describe("monitorDiscordProvider", () => {
return reconcileParams.healthProbe as NonNullable<ReconcileStartupParams["healthProbe"]>;
};
beforeAll(async () => {
beforeEach(() => {
vi.resetModules();
resetDiscordProviderMonitorMocks();
vi.doMock("../accounts.js", () => ({
resolveDiscordAccount: (...args: Parameters<typeof resolveDiscordAccountMock>) =>
resolveDiscordAccountMock(...args),
@@ -129,14 +129,10 @@ describe("monitorDiscordProvider", () => {
vi.doMock("../token.js", () => ({
normalizeDiscordToken: (value?: string) => value,
}));
({ monitorDiscordProvider } = await import("./provider.js"));
});
beforeEach(() => {
resetDiscordProviderMonitorMocks();
});
it("stops thread bindings when startup fails before lifecycle begins", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
createDiscordNativeCommandMock.mockImplementation(() => {
throw new Error("native command boom");
});
@@ -154,6 +150,8 @@ describe("monitorDiscordProvider", () => {
});
it("does not double-stop thread bindings when lifecycle performs cleanup", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
@@ -166,6 +164,8 @@ describe("monitorDiscordProvider", () => {
});
it("does not load the Discord voice runtime when voice is disabled", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
@@ -185,6 +185,7 @@ describe("monitorDiscordProvider", () => {
execApprovals: { enabled: false },
},
});
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: baseConfig(),
@@ -195,6 +196,7 @@ describe("monitorDiscordProvider", () => {
});
it("treats ACP error status as uncertain during startup thread-binding probes", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
getAcpSessionStatusMock.mockResolvedValue({ state: "error" });
await monitorDiscordProvider({
@@ -222,6 +224,7 @@ describe("monitorDiscordProvider", () => {
});
it("classifies typed ACP session init failures as stale", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
getAcpSessionStatusMock.mockRejectedValue(
new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "missing ACP metadata"),
);
@@ -251,6 +254,7 @@ describe("monitorDiscordProvider", () => {
});
it("classifies typed non-init ACP errors as uncertain when not stale-running", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
getAcpSessionStatusMock.mockRejectedValue(
new AcpRuntimeError("ACP_BACKEND_UNAVAILABLE", "runtime unavailable"),
);
@@ -282,6 +286,7 @@ describe("monitorDiscordProvider", () => {
it("aborts timed-out ACP status probes during startup thread-binding health checks", async () => {
vi.useFakeTimers();
try {
const { monitorDiscordProvider } = await import("./provider.js");
getAcpSessionStatusMock.mockImplementation(
({ signal }: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
@@ -326,6 +331,7 @@ describe("monitorDiscordProvider", () => {
});
it("falls back to legacy missing-session message classification", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
getAcpSessionStatusMock.mockRejectedValue(new Error("ACP session metadata missing"));
await monitorDiscordProvider({
@@ -353,27 +359,11 @@ describe("monitorDiscordProvider", () => {
});
it("captures gateway errors emitted before lifecycle wait starts", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
const emitter = new EventEmitter();
const drained: Array<{ message: string; type: string }> = [];
clientGetPluginMock.mockImplementation((name: string) =>
name === "gateway" ? { emitter, disconnect: vi.fn() } : undefined,
);
monitorLifecycleMock.mockImplementationOnce(async (params) => {
(
params as {
gatewaySupervisor?: {
drainPending: (
handler: (event: { message: string; type: string }) => "continue" | "stop",
) => "continue" | "stop";
};
threadBindings: { stop: () => void };
}
).gatewaySupervisor?.drainPending((event) => {
drained.push(event);
return "continue";
});
params.threadBindings.stop();
});
clientFetchUserMock.mockImplementationOnce(async () => {
emitter.emit("error", new Error("Fatal Gateway error: 4014"));
return { id: "bot-1" };
@@ -385,12 +375,16 @@ describe("monitorDiscordProvider", () => {
});
expect(monitorLifecycleMock).toHaveBeenCalledTimes(1);
expect(drained).toHaveLength(1);
expect(drained[0]?.type).toBe("disallowed-intents");
expect(drained[0]?.message).toContain("4014");
const lifecycleArgs = monitorLifecycleMock.mock.calls[0]?.[0] as {
pendingGatewayErrors?: unknown[];
};
expect(lifecycleArgs.pendingGatewayErrors).toHaveLength(1);
expect(String(lifecycleArgs.pendingGatewayErrors?.[0])).toContain("4014");
});
it("passes default eventQueue.listenerTimeout of 120s to Carbon Client", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
@@ -412,6 +406,7 @@ describe("monitorDiscordProvider", () => {
eventQueue: { listenerTimeout: 300_000 },
},
});
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: baseConfig(),
@@ -423,6 +418,8 @@ describe("monitorDiscordProvider", () => {
});
it("does not reuse eventQueue.listenerTimeout as the queued inbound worker timeout", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: createConfigWithDiscordAccount({
eventQueue: { listenerTimeout: 50_000 },
@@ -450,6 +447,7 @@ describe("monitorDiscordProvider", () => {
inboundWorker: { runTimeoutMs: 300_000 },
},
});
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: baseConfig(),
@@ -463,6 +461,7 @@ describe("monitorDiscordProvider", () => {
});
it("registers plugin commands as native Discord commands", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
listNativeCommandSpecsForConfigMock.mockReturnValue([
{ name: "cmd", description: "built-in", acceptsArgs: false },
]);
@@ -487,6 +486,7 @@ describe("monitorDiscordProvider", () => {
const { clearPluginCommands, getPluginCommandSpecs, registerPluginCommand } =
await import("../../../../src/plugins/commands.js");
clearPluginCommands();
const { monitorDiscordProvider } = await import("./provider.js");
listNativeCommandSpecsForConfigMock.mockReturnValue([
{ name: "status", description: "Status", acceptsArgs: false },
]);
@@ -521,6 +521,7 @@ describe("monitorDiscordProvider", () => {
it("continues startup when Discord daily slash-command create quota is exhausted", async () => {
const { RateLimitError } = await import("@buape/carbon");
const { monitorDiscordProvider } = await import("./provider.js");
const runtime = baseRuntime();
const rateLimitError = new RateLimitError(
new Response(null, {
@@ -553,6 +554,8 @@ describe("monitorDiscordProvider", () => {
});
it("configures Carbon native deploy by default", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
@@ -563,6 +566,7 @@ describe("monitorDiscordProvider", () => {
});
it("reports connected status on startup and shutdown", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
const setStatus = vi.fn();
clientGetPluginMock.mockImplementation((name: string) =>
name === "gateway" ? { isConnected: true } : undefined,
@@ -579,6 +583,7 @@ describe("monitorDiscordProvider", () => {
});
it("logs Discord startup phases and early gateway debug events", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
const runtime = baseRuntime();
const emitter = new EventEmitter();
const gateway = { emitter, isConnected: true, reconnectAttempts: 0 };
@@ -611,6 +616,7 @@ describe("monitorDiscordProvider", () => {
});
it("keeps Discord startup chatter quiet by default", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
const runtime = baseRuntime();
await monitorDiscordProvider({

Some files were not shown because too many files have changed in this diff Show More