Compare commits

..

4 Commits

Author SHA1 Message Date
Agustin Rivera
ded626d171 fix(browser): thread ssrf policy through file-chooser hook click 2026-04-06 18:28:21 +00:00
Agustin Rivera
2c8697b694 fix(browser): run post-interaction SSRF guard unconditionally 2026-04-06 18:03:12 +00:00
Agustin Rivera
f3719cef5e fix(browser): thread ssrf policy through batches 2026-04-06 17:50:16 +00:00
Agustin Rivera
08019bbda0 fix(browser): guard interaction-driven navigations 2026-04-06 16:44:49 +00:00
897 changed files with 17589 additions and 32101 deletions

4
.github/labeler.yml vendored
View File

@@ -257,10 +257,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/acpx/**"
"extensions: arcee":
- changed-files:
- any-glob-to-any-file:
- "extensions/arcee/**"
"extensions: byteplus":
- changed-files:
- any-glob-to-any-file:

View File

@@ -6,18 +6,15 @@ Docs: https://docs.openclaw.ai
### Changes
- CLI/capabilities: add a first-class `openclaw capability ...` hub for provider-backed inference workflows across model, media, web, and embedding tasks, with capability inspection, provider discovery, and consistent JSON output. Thanks @Takhoffman.
- Providers/Anthropic: restore Claude CLI as the preferred local Anthropic path in onboarding, model-auth guidance, and doctor flows again, and keep the Docker Claude CLI live lane aligned with the restored guidance.
- Plugins/webhooks: add a bundled webhook ingress plugin so external automation can create and drive bound TaskFlows through per-route shared-secret endpoints. (#61892) Thanks @mbelinky.
- Tools/media: document per-provider music and video generation capabilities, and add shared live video-to-video sweep coverage for providers that support local reference clips.
### Fixes
- CLI/capabilities: keep provider-backed capability behavior aligned with actual runtime execution by fixing explicit TTS override handling, profile-aware gateway TTS prefs resolution, per-request transcription `prompt`/`language` overrides, image output MIME/extension mismatches, configured web-search fallback behavior, and agent-vs-CLI web-search execution drift.
- Channels/secrets: keep bundled channel artifact and secret-contract loading stable under lazy loading so bundled channel secrets continue to appear in `openclaw secret`, status, and security-audit surfaces.
- Providers/xAI: recognize `api.grok.x.ai` as an xAI-native endpoint again so native xAI web-search attribution keeps working on Grok-hosted base URLs. (#61377) Thanks @jjjojoj.
- Providers/Anthropic/cache: preserve thinking blocks for Claude Opus 4.5+, Sonnet 4.5+, and newer Claude 4-family models so Anthropic prompt-cache prefixes keep matching after thinking turns. (#61793)
- Auth/OpenAI Codex OAuth: reload fresh on-disk credentials inside the locked refresh path and retry once after `refresh_token_reused` rotates only the stored refresh token, so relogin/restart recovery stops getting stuck on stale cached auth state. Thanks @owen-ever.
- Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678)
- Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07.
- Memory/vector recall: surface explicit warnings when `sqlite-vec` is unavailable or vector writes are degraded so memory indexing no longer reports false-success while semantic recall is impaired.
@@ -41,7 +38,6 @@ Docs: https://docs.openclaw.ai
- Gateway/containers: auto-bind to `0.0.0.0` during container startup for Docker and Podman compatibility, while keeping host-side status and doctor checks on the hardened loopback default when `gateway.bind` is unset. (#61818) Thanks @openperf.
- Gateway/status: probe local TLS gateways over `wss://`, forward the local cert fingerprint for self-signed loopback probes, and warn when the local TLS runtime cannot load the configured cert. (#61935) Thanks @ThanhNguyxn07.
- Slack/threading: keep legacy thread stickiness for real replies when older callers omit `isThreadReply`, while still honoring `replyToMode` for Slack's auto-created top-level `thread_ts`. (#61835) Thanks @kaonash.
- Discord/voice: re-arm DAVE receive passthrough without suppressing decrypt-failure rejoin recovery, and clear capture state before finalize teardown so rapid speaker restarts keep their next utterance. (#41536) Thanks @wit-oc.
- Providers/Google: recognize Gemma model ids in native Google forward-compat resolution, keep the requested provider when cloning fallback templates, and force Gemma reasoning off so Gemma 4 routes stop failing through the Google catalog fallback. (#61507) Thanks @eyjohn.
- Providers/Anthropic: skip `service_tier` injection for OAuth-authenticated stream wrapper requests so Claude OAuth requests stop failing with HTTP 401. (#60356) thanks @openperf.
- Providers/OpenAI: keep WebSocket text buffered until a real assistant phase arrives, even when text deltas land before a phaseless `output_item.added` announcement. (#61954) Thanks @100yenadmin.
@@ -61,13 +57,6 @@ Docs: https://docs.openclaw.ai
- Docs/i18n: relocalize final localized-page links after translation so generated locale pages stop keeping stale English-root links when targets appear later in the same run. (#61796) thanks @hxy91819.
- Docs/i18n: remove the zh-CN homepage redirect override so Mintlify can resolve the localized Chinese homepage without self-redirecting `/zh-CN/index`.
- Tools/web_fetch and web_search: fix `TypeError: fetch failed` caused by undici 8.0 enabling HTTP/2 by default; pinned SSRF-guard dispatchers now explicitly set `allowH2: false` to restore HTTP/1.1 behavior and keep the custom DNS-pinning lookup compatible. (#61738, #61777) Thanks @zozo123.
- Agents/session keys: backfill `sessionKey` from `sessionId` in the embedded PI runner when callers omit it, so hooks, LCM, and compaction receive a valid key; also normalize whitespace-only session keys to `undefined` before downstream consumers see them. (#60555) Thanks @100yenadmin.
- Plugins/provider hooks: stop recursive provider snapshot loads from overflowing the stack during plugin initialization, while still preserving cached nested provider-hook results. (#61922, #61938, #61946, #61951)
- Discord/voice: re-arm DAVE receive passthrough without suppressing decrypt-failure rejoin recovery, and clear capture state before finalize teardown so rapid speaker restarts keep their next utterance. (#41536) Thanks @wit-oc.
- Agents/exec: keep `strictInlineEval` commands blocked after approval timeouts on both gateway and node exec hosts, so timeout fallback no longer turns timed-out inline interpreter prompts into automatic execution.
- QQ Bot/media: route gateway-side attachment and fallback downloads through guarded QQ/Tencent HTTPS fetches so QQ media handling no longer follows arbitrary remote hosts.
- Exec/runtime events: mark background `notifyOnExit` summaries and ACP parent-stream relays as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text.
- Hooks/wake: queue direct and mapped wake-hook payloads as untrusted system events so external wake content no longer enters the main session as trusted input. (#62003)
## 2026.4.5
@@ -79,7 +68,6 @@ Docs: https://docs.openclaw.ai
- Agents/video generation: add the built-in `video_generate` tool so agents can create videos through configured providers and return the generated media directly in the reply.
- Agents/music generation: ignore unsupported optional hints such as `durationSeconds` with a warning instead of hard-failing requests on providers like Google Lyria.
- Providers/Arcee AI: add a bundled Arcee AI provider plugin with `ARCEEAI_API_KEY` onboarding, Trinity model catalog (mini, large-preview, large-thinking), OpenAI-compatible API support, and OpenRouter as an alternative auth path. (#62068) Thanks @arthurbr11.
- Providers/ComfyUI: add a bundled `comfy` workflow media plugin for local ComfyUI and Comfy Cloud workflows, including shared `image_generate`, `video_generate`, and workflow-backed `music_generate` support, with prompt injection, optional reference-image upload, live tests, and output download.
- Tools/music generation: add the built-in `music_generate` tool with bundled Google (Lyria) and MiniMax providers plus workflow-backed Comfy support, including async task tracking and follow-up delivery of finished audio.
- Providers: add bundled Qwen, Fireworks AI, and StepFun providers, plus MiniMax TTS, Ollama Web Search, and MiniMax Search integrations for chat, speech, and search workflows. (#60032, #55921, #59318, #54648)
@@ -961,7 +949,6 @@ Docs: https://docs.openclaw.ai
- Security/path resolution: prefer non-user-writable absolute helper binaries for OpenClaw CLI, ffmpeg, and OpenSSL resolution so PATH hijacks cannot replace trusted helpers with attacker-controlled executables.
- Security/gateway command scopes: require `operator.admin` before Telegram target writeback and Talk Voice `/voice set` config writes persist through gateway message flows.
- Security/OpenShell mirror: exclude workspace `hooks/` from mirror sync so untrusted sandbox files cannot become trusted host hooks on gateway startup.
- Exec env policy: block Mercurial config redirects, Rust compiler wrappers, and GNU make flag env vars in host exec sanitization so inherited env and request-scoped overrides cannot redirect build-tool execution.
## 2026.3.24-beta.2

View File

@@ -6,7 +6,7 @@
<title>2026.4.5</title>
<pubDate>Mon, 06 Apr 2026 04:55:17 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026040590</sparkle:version>
<sparkle:version>2026040501</sparkle:version>
<sparkle:shortVersionString>2026.4.5</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.4.5</h2>
@@ -436,4 +436,4 @@
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.1/OpenClaw-2026.4.1.zip" length="25841903" type="application/octet-stream" sparkle:edSignature="0TPiyshScmwDbgs626JU08NOUUFJmIsVFa5g0xmizfl64Fr+IoT4l/dkXarFqbZAJidtj5WN7Bff7fG8ye/7AA=="/>
</item>
</channel>
</rss>
</rss>

View File

@@ -28,8 +28,6 @@ enum HostEnvSecurityPolicy {
"CC",
"CXX",
"CARGO_BUILD_RUSTC",
"CARGO_BUILD_RUSTC_WRAPPER",
"RUSTC_WRAPPER",
"CMAKE_C_COMPILER",
"CMAKE_CXX_COMPILER",
"SHELL",
@@ -46,12 +44,9 @@ enum HostEnvSecurityPolicy {
"DOTNET_ADDITIONAL_DEPS",
"GLIBC_TUNABLES",
"MAVEN_OPTS",
"MAKEFLAGS",
"MFLAGS",
"SBT_OPTS",
"GRADLE_OPTS",
"ANT_OPTS",
"HGRCPATH"
"ANT_OPTS"
]
static let blockedOverrideKeys: Set<String> = [
@@ -88,8 +83,6 @@ enum HostEnvSecurityPolicy {
"CGO_CFLAGS",
"CGO_LDFLAGS",
"GOFLAGS",
"MAKEFLAGS",
"MFLAGS",
"CORECLR_PROFILER_PATH",
"PHPRC",
"PHP_INI_SCAN_DIR",
@@ -141,9 +134,7 @@ enum HostEnvSecurityPolicy {
"GOPRIVATE",
"GOENV",
"GOPATH",
"HGRCPATH",
"PYTHONUSERBASE",
"RUSTC_WRAPPER",
"VIRTUAL_ENV",
"LUA_PATH",
"LUA_CPATH",
@@ -151,7 +142,6 @@ enum HostEnvSecurityPolicy {
"GEM_PATH",
"BUNDLE_GEMFILE",
"COMPOSER_HOME",
"CARGO_BUILD_RUSTC_WRAPPER",
"XDG_CONFIG_HOME",
"AWS_CONFIG_FILE"
]

View File

@@ -361,6 +361,14 @@
}
}
},
"update_plan": {
"emoji": "🗺️",
"title": "Update Plan",
"detailKeys": [
"explanation",
"plan.0.step"
]
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",

View File

@@ -1,2 +1,2 @@
08615a28ed3deb20a96c9cd8fd7237a4cbb209ceec93dca03b543979304459e4 plugin-sdk-api-baseline.json
683c1249dc15529d8e79bc75e9c00484551cb74126befee507fffcf786e01833 plugin-sdk-api-baseline.jsonl
9883b1242051e830bafa7035351c9a2dd0fb84f81be28d7b5be2b69a1179e519 plugin-sdk-api-baseline.json
43dd28ba4502b207413d00471ea2e4ae5cf644922ab153387fa4bf99e540e6d1 plugin-sdk-api-baseline.jsonl

View File

@@ -75,13 +75,10 @@ self-check, and writes a Markdown report under `.artifacts/qa-e2e/`.
Private debugger UI:
```bash
pnpm qa:lab:up
pnpm qa:lab:build
pnpm openclaw qa ui
```
That one command builds the QA site, starts the Docker-backed gateway + QA Lab
stack, and prints the QA Lab URL. From that site you can pick scenarios, choose
the model lane, launch individual runs, and watch results live.
Full repo-backed QA suite:
```bash
@@ -99,10 +96,10 @@ Current scope is intentionally narrow:
- threaded routing grammar
- channel-owned message actions
- Markdown reporting
- Docker-backed QA site with run controls
Follow-up work will add:
- Dockerized OpenClaw orchestration
- provider/model matrix execution
- richer scenario discovery
- OpenClaw-native orchestration later

View File

@@ -1,116 +0,0 @@
---
summary: "Capability-first CLI for provider-backed model, media, web, and embedding workflows"
read_when:
- Adding or modifying `openclaw capability` commands
- Designing stable headless capability automation
title: "Capability CLI"
---
# Capability CLI
`openclaw capability` is the canonical headless surface for provider-backed capabilities.
It intentionally exposes capability families, not raw gateway RPC names and not raw agent tool ids.
## Command tree
```text
openclaw capability
list
inspect
model
run
list
inspect
providers
auth login
auth logout
auth status
media
image
generate
edit
describe
describe-many
providers
audio
transcribe
providers
tts
convert
voices
providers
status
enable
disable
set-provider
video
generate
describe
providers
web
search
fetch
providers
memory
embedding
create
providers
```
## Transport
Supported transport flags:
- `--local`
- `--gateway`
Default transport is implicit auto at the command-family level:
- Stateless execution commands default to local.
- Gateway-managed state commands default to gateway.
Examples:
```bash
openclaw capability model run --prompt "hello" --json
openclaw capability media image generate --prompt "friendly lobster" --json
openclaw capability media tts status --json
openclaw capability embedding create --text "hello world" --json
```
## JSON output
Capability commands normalize JSON output under a shared envelope:
```json
{
"ok": true,
"capability": "media.image.generate",
"transport": "local",
"provider": "openai",
"model": "gpt-image-1",
"attempts": [],
"outputs": []
}
```
Top-level fields are stable:
- `ok`
- `capability`
- `transport`
- `provider`
- `model`
- `attempts`
- `outputs`
- `error`
## Notes
- `model run` reuses the agent runtime so provider/model overrides behave like normal agent execution.
- `media tts status` defaults to gateway because it reflects gateway-managed TTS state.

View File

@@ -35,7 +35,6 @@ This page describes the current CLI behavior. If commands change, update this do
- [`logs`](/cli/logs)
- [`system`](/cli/system)
- [`models`](/cli/models)
- [`capability`](/cli/capability)
- [`memory`](/cli/memory)
- [`directory`](/cli/directory)
- [`nodes`](/cli/nodes)
@@ -249,16 +248,6 @@ openclaw [--dev] [--profile <name>] <command>
fallbacks list|add|remove|clear
image-fallbacks list|add|remove|clear
scan
capability
list
inspect
model run|list|inspect|providers|auth login|logout|status
media image generate|edit|describe|describe-many|providers
media audio transcribe|providers
media tts convert|voices|providers|status|enable|disable|set-provider
media video generate|describe|providers
web search|fetch|providers
embedding create|providers
auth add|login|login-github-copilot|setup-token|paste-token
auth order get|set|clear
sandbox

View File

@@ -21,21 +21,13 @@ Current pieces:
- `qa/`: repo-backed seed assets for the kickoff task and baseline QA
scenarios.
The current QA operator flow is a two-pane QA site:
The long-term goal is a two-pane QA site:
- Left: Gateway dashboard (Control UI) with the agent.
- Right: QA Lab, showing the Slack-ish transcript and scenario plan.
Run it with:
```bash
pnpm qa:lab:up
```
That builds the QA site, starts the Docker-backed gateway lane, and exposes the
QA Lab page where an operator or automation loop can give the agent a QA
mission, observe real channel behavior, and record what worked, failed, or
stayed blocked.
That lets an operator or automation loop give the agent a QA mission, observe
real channel behavior, and record what worked, failed, or stayed blocked.
## Repo-backed seeds

View File

@@ -1231,7 +1231,6 @@
"pages": [
"providers/alibaba",
"providers/anthropic",
"providers/arcee",
"providers/bedrock",
"providers/bedrock-mantle",
"providers/chutes",

View File

@@ -26,7 +26,6 @@ Most days:
- Faster local full-suite run on a roomy machine: `pnpm test:max`
- Direct Vitest watch loop: `pnpm test:watch`
- Direct file targeting now routes extension/channel paths too: `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts`
- Docker-backed QA site: `pnpm qa:lab:up`
When you touch tests or want extra confidence:
@@ -58,7 +57,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- No real keys required
- Should be fast and stable
- Projects note:
- Untargeted `pnpm test` now runs eight smaller shard configs (`core-unit-src`, `core-unit-security`, `core-unit-support`, `core-contracts`, `core-runtime`, `agentic`, `auto-reply`, `extensions`) instead of one giant native root-project process. This cuts peak RSS on loaded machines and avoids auto-reply/extension work starving unrelated suites.
- Untargeted `pnpm test` now runs five smaller shard configs (`core-unit`, `core-runtime`, `agentic`, `auto-reply`, `extensions`) instead of one giant native root-project process. This cuts peak RSS on loaded machines and avoids auto-reply/extension work starving unrelated suites.
- `pnpm test --watch` still uses the native root `vitest.config.ts` project graph, because a multi-shard watch loop is not practical.
- `pnpm test`, `pnpm test:watch`, and `pnpm test:perf:imports` route explicit file/directory targets through scoped lanes first, so `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts` avoids paying the full root project startup tax.
- `pnpm test:changed` expands changed git paths into the same scoped lanes when the diff only touches routable source/test files; config/setup edits still fall back to the broad root-project rerun.
@@ -454,7 +453,6 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Test: `src/image-generation/runtime.live.test.ts`
- Command: `pnpm test:live src/image-generation/runtime.live.test.ts`
- Harness: `pnpm test:live:media image`
- Scope:
- Enumerates every registered image-generation provider plugin
- Loads missing provider env vars from your login shell (`~/.profile`) before probing
@@ -479,7 +477,6 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Test: `extensions/music-generation-providers.live.test.ts`
- Enable: `OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/music-generation-providers.live.test.ts`
- Harness: `pnpm test:live:media music`
- Scope:
- Exercises the shared bundled music-generation provider path
- Currently covers Google and MiniMax
@@ -503,7 +500,6 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Test: `extensions/video-generation-providers.live.test.ts`
- Enable: `OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/video-generation-providers.live.test.ts`
- Harness: `pnpm test:live:media video`
- Scope:
- Exercises the shared bundled video-generation provider path
- Loads provider env vars from your login shell (`~/.profile`) before probing
@@ -511,39 +507,20 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
- Skips providers with no usable auth/profile/model
- Runs both declared runtime modes when available:
- `generate` with prompt-only input
- `imageToVideo` when the provider declares `capabilities.imageToVideo.enabled` and the selected provider/model accepts buffer-backed local image input in the shared sweep
- `imageToVideo` when the provider declares `capabilities.imageToVideo.enabled`
- `videoToVideo` when the provider declares `capabilities.videoToVideo.enabled` and the selected provider/model accepts buffer-backed local video input in the shared sweep
- Current declared-but-skipped `imageToVideo` providers in the shared sweep:
- `vydra` because bundled `veo3` is text-only and bundled `kling` requires a remote image URL
- Provider-specific Vydra coverage:
- `OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_VYDRA_VIDEO=1 pnpm test:live -- extensions/vydra/vydra.live.test.ts`
- that file runs `veo3` text-to-video plus a `kling` lane that uses a remote image URL fixture by default
- Current `videoToVideo` live coverage:
- `google`
- `openai`
- `runway` only when the selected model is `runway/gen4_aleph`
- Current declared-but-skipped `videoToVideo` providers in the shared sweep:
- `alibaba`, `qwen`, `xai` because those paths currently require remote `http(s)` / MP4 reference URLs
- `google` because the current shared Gemini/Veo lane uses local buffer-backed input and that path is not accepted in the shared sweep
- `openai` because the current shared lane lacks org-specific video inpaint/remix access guarantees
- Optional narrowing:
- `OPENCLAW_LIVE_VIDEO_GENERATION_PROVIDERS="google,openai,runway"`
- `OPENCLAW_LIVE_VIDEO_GENERATION_MODELS="google/veo-3.1-fast-generate-preview,openai/sora-2,runway/gen4_aleph"`
- Optional auth behavior:
- `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to force profile-store auth and ignore env-only overrides
## Media live harness
- Command: `pnpm test:live:media`
- Purpose:
- Runs the shared image, music, and video live suites through one repo-native entrypoint
- Auto-loads missing provider env vars from `~/.profile`
- Auto-narrows each suite to providers that currently have usable auth by default
- Reuses `scripts/test-live.mjs`, so heartbeat and quiet-mode behavior stay consistent
- Examples:
- `pnpm test:live:media`
- `pnpm test:live:media image video --providers openai,google,minimax`
- `pnpm test:live:media video --video-providers openai,runway --all-providers`
- `pnpm test:live:media music --quiet`
## Docker runners (optional "works in Linux" checks)
These Docker runners split into two buckets:

View File

@@ -609,9 +609,8 @@ conversation, and it runs after core approval handling finishes.
Provider plugins now have two layers:
- manifest metadata: `providerAuthEnvVars` for cheap provider env-auth lookup
before runtime load, `channelEnvVars` for cheap channel env/setup lookup
before runtime load, plus `providerAuthChoices` for cheap onboarding/auth-choice
- manifest metadata: `providerAuthEnvVars` for cheap env-auth lookup before
runtime load, plus `providerAuthChoices` for cheap onboarding/auth-choice
labels and CLI flag metadata before runtime load
- config-time hooks: `catalog` / legacy `discovery` plus `applyConfigDefaults`
- runtime hooks: `normalizeModelId`, `normalizeTransport`,
@@ -646,10 +645,6 @@ one-flag auth wiring without loading provider runtime. Keep provider runtime
`envVars` for operator-facing hints such as onboarding labels or OAuth
client-id/client-secret setup vars.
Use manifest `channelEnvVars` when a channel has env-driven auth or setup that
generic shell-env fallback, config/status checks, or setup prompts should see
without loading channel runtime.
### Hook order and usage
For model/provider plugins, OpenClaw calls hooks in this rough order.

View File

@@ -93,9 +93,6 @@ Those belong in your plugin code and `package.json`.
"providerAuthEnvVars": {
"openrouter": ["OPENROUTER_API_KEY"]
},
"channelEnvVars": {
"openrouter-chatops": ["OPENROUTER_CHATOPS_TOKEN"]
},
"providerAuthChoices": [
{
"provider": "openrouter",
@@ -145,7 +142,6 @@ Those belong in your plugin code and `package.json`.
| `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. |
| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. |
| `providerAuthEnvVars` | No | `Record<string, string[]>` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. |
| `channelEnvVars` | No | `Record<string, string[]>` | Cheap channel env metadata that OpenClaw can inspect without loading plugin code. Use this for env-driven channel setup or auth surfaces that generic startup/config helpers should see. |
| `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. |
| `contracts` | No | `object` | Static bundled capability snapshot for speech, realtime transcription, realtime voice, media-understanding, image-generation, music-generation, video-generation, web-fetch, web search, and tool ownership. |
| `channelConfigs` | No | `Record<string, object>` | Manifest-owned channel config metadata merged into discovery and validation surfaces before runtime loads. |
@@ -440,9 +436,6 @@ See [Configuration reference](/gateway/configuration) for the full `plugins.*` s
- `providerAuthEnvVars` is the cheap metadata path for auth probes, env-marker
validation, and similar provider-auth surfaces that should not boot plugin
runtime just to inspect env names.
- `channelEnvVars` is the cheap metadata path for shell-env fallback, setup
prompts, and similar channel surfaces that should not boot plugin runtime
just to inspect env names.
- `providerAuthChoices` is the cheap metadata path for auth-choice pickers,
`--auth-choice` resolution, preferred-provider mapping, and simple onboarding
CLI flag registration before provider runtime loads. For runtime wizard

View File

@@ -108,15 +108,9 @@ For setup specifically:
- `openclaw/plugin-sdk/channel-setup` covers the optional-install setup
builders plus a few setup-safe primitives:
`createOptionalChannelSetupSurface`, `createOptionalChannelSetupAdapter`,
If your channel supports env-driven setup or auth and generic startup/config
flows should know those env names before runtime loads, declare them in the
plugin manifest with `channelEnvVars`. Keep channel runtime `envVars` or local
constants for operator-facing copy only.
`createOptionalChannelSetupWizard`, `DEFAULT_ACCOUNT_ID`,
`createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, and
`splitSetupEntries`
`createOptionalChannelSetupWizard`, `DEFAULT_ACCOUNT_ID`,
`createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, and
`splitSetupEntries`
- use the broader `openclaw/plugin-sdk/setup` seam only when you also need the
heavier shared setup/config helpers such as
`moveSingleAccountChannelSectionToDefaultAccount(...)`

View File

@@ -114,7 +114,6 @@ explicitly promotes one as public.
| `plugin-sdk/channel-targets` | Target parsing/matching helpers |
| `plugin-sdk/channel-contract` | Channel contract types |
| `plugin-sdk/channel-feedback` | Feedback/reaction wiring |
| `plugin-sdk/channel-secret-runtime` | Narrow secret-contract helpers such as `collectSimpleChannelFieldAssignments`, `getChannelSurface`, `pushAssignment`, and secret target types |
</Accordion>
<Accordion title="Provider subpaths">
@@ -155,7 +154,6 @@ explicitly promotes one as public.
| `plugin-sdk/command-detection` | Shared command detection helpers |
| `plugin-sdk/command-surface` | Command-body normalization and command-surface helpers |
| `plugin-sdk/allow-from` | `formatAllowFromLowercase` |
| `plugin-sdk/channel-secret-runtime` | Narrow secret-contract collection helpers for channel/plugin secret surfaces |
| `plugin-sdk/security-runtime` | Shared trust, DM gating, external-content, and secret-collection helpers |
| `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers |
| `plugin-sdk/ssrf-runtime` | Pinned-dispatcher, SSRF-guarded fetch, and SSRF policy helpers |

View File

@@ -1,87 +0,0 @@
---
title: "Arcee AI"
summary: "Arcee AI setup (auth + model selection)"
read_when:
- You want to use Arcee AI with OpenClaw
- You need the API key env var or CLI auth choice
---
# Arcee AI
[Arcee AI](https://arcee.ai) provides access to the Trinity family of mixture-of-experts models through an OpenAI-compatible API. All Trinity models are Apache 2.0 licensed.
Arcee AI models can be accessed directly via the Arcee platform or through [OpenRouter](/providers/openrouter).
- Provider: `arcee`
- Auth: `ARCEEAI_API_KEY` (direct) or `OPENROUTER_API_KEY` (via OpenRouter)
- API: OpenAI-compatible
- Base URL: `https://api.arcee.ai/api/v1` (direct) or `https://openrouter.ai/api/v1` (OpenRouter)
## Quick start
1. Get an API key from [Arcee AI](https://chat.arcee.ai/) or [OpenRouter](https://openrouter.ai/keys).
2. Set the API key (recommended: store it for the Gateway):
```bash
# Direct (Arcee platform)
openclaw onboard --auth-choice arceeai-api-key
# Via OpenRouter
openclaw onboard --auth-choice arceeai-openrouter
```
3. Set a default model:
```json5
{
agents: {
defaults: {
model: { primary: "arcee/trinity-large-thinking" },
},
},
}
```
## Non-interactive example
```bash
# Direct (Arcee platform)
openclaw onboard --non-interactive \
--mode local \
--auth-choice arceeai-api-key \
--arceeai-api-key "$ARCEEAI_API_KEY"
# Via OpenRouter
openclaw onboard --non-interactive \
--mode local \
--auth-choice arceeai-openrouter \
--openrouter-api-key "$OPENROUTER_API_KEY"
```
## Environment note
If the Gateway runs as a daemon (launchd/systemd), make sure `ARCEEAI_API_KEY`
(or `OPENROUTER_API_KEY`) is available to that process (for example, in
`~/.openclaw/.env` or via `env.shellEnv`).
## Built-in catalog
OpenClaw currently ships this bundled Arcee catalog:
| Model ref | Name | Input | Context | Cost (in/out per 1M) | Notes |
| ------------------------------ | ---------------------- | ----- | ------- | -------------------- | ----------------------------------------- |
| `arcee/trinity-large-thinking` | Trinity Large Thinking | text | 256K | $0.25 / $0.90 | Default model; reasoning enabled |
| `arcee/trinity-large-preview` | Trinity Large Preview | text | 128K | $0.25 / $1.00 | General-purpose; 400B params, 13B active |
| `arcee/trinity-mini` | Trinity Mini 26B | text | 128K | $0.045 / $0.15 | Fast and cost-efficient; function calling |
The same model refs work for both direct and OpenRouter setups (for example `arcee/trinity-large-thinking`).
The onboarding preset sets `arcee/trinity-large-thinking` as the default model.
## Supported features
- Streaming
- Tool use / function calling
- Structured output (JSON mode and JSON schema)
- Extended thinking (Trinity Large Thinking)

View File

@@ -29,7 +29,6 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Alibaba Model Studio](/providers/alibaba)
- [Amazon Bedrock](/providers/bedrock)
- [Anthropic (API + Claude CLI)](/providers/anthropic)
- [Arcee AI (Trinity models)](/providers/arcee)
- [BytePlus (International)](/concepts/model-providers#byteplus-international)
- [Chutes](/providers/chutes)
- [ComfyUI](/providers/comfy)

View File

@@ -85,28 +85,8 @@ Notes:
- `vydra/veo3` is bundled as text-to-video only.
- `vydra/kling` currently requires a remote image URL reference. Local file uploads are rejected up front.
- Vydra's current `kling` HTTP route has been inconsistent about whether it requires `image_url` or `video_url`; the bundled provider maps the same remote image URL into both fields.
- The bundled plugin stays conservative and does not forward undocumented style knobs such as aspect ratio, resolution, watermark, or generated audio.
Provider-specific live coverage:
```bash
OPENCLAW_LIVE_TEST=1 \
OPENCLAW_LIVE_VYDRA_VIDEO=1 \
pnpm test:live -- extensions/vydra/vydra.live.test.ts
```
The bundled Vydra live file now covers:
- `vydra/veo3` text-to-video
- `vydra/kling` image-to-video using a remote image URL
Override the remote image fixture when needed:
```bash
export OPENCLAW_LIVE_VYDRA_KLING_IMAGE_URL="https://example.com/reference.png"
```
See [Video Generation](/tools/video-generation) for shared tool behavior.
## Speech synthesis

View File

@@ -13,7 +13,7 @@ title: "Tests"
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
- `pnpm test:coverage:changed`: Runs unit coverage only for files changed since `origin/main`.
- `pnpm test:changed`: expands changed git paths into scoped Vitest lanes when the diff only touches routable source/test files. Config/setup changes still fall back to the native root projects run so wiring edits rerun broadly when needed.
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs now execute eight sequential shard configs (`vitest.full-core-unit-src.config.ts`, `vitest.full-core-unit-security.config.ts`, `vitest.full-core-unit-support.config.ts`, `vitest.full-core-contracts.config.ts`, `vitest.full-core-runtime.config.ts`, `vitest.full-agentic.config.ts`, `vitest.full-auto-reply.config.ts`, `vitest.full-extensions.config.ts`) instead of one giant root-project process.
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs now execute five sequential shard configs (`vitest.full-core-unit.config.ts`, `vitest.full-core-runtime.config.ts`, `vitest.full-agentic.config.ts`, `vitest.full-auto-reply.config.ts`, `vitest.full-extensions.config.ts`) instead of one giant root-project process.
- Selected `plugin-sdk` and `commands` test files now route through dedicated light lanes that keep only `test/setup.ts`, leaving runtime-heavy cases on their existing lanes.
- Selected `plugin-sdk` and `commands` helper source files also map `pnpm test:changed` to explicit sibling tests in those light lanes, so small helper edits avoid rerunning the heavy runtime-backed suites.
- `auto-reply` now also splits into three dedicated configs (`core`, `top-level`, `reply`) so the reply harness does not dominate the lighter top-level status/token/helper tests.

View File

@@ -248,12 +248,6 @@ Opt-in live coverage for the shared bundled providers:
OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/music-generation-providers.live.test.ts
```
Repo wrapper:
```bash
pnpm test:live:media music
```
This live file loads missing provider env vars from `~/.profile`, prefers
live/env API keys ahead of stored auth profiles by default, and runs both
`generate` and declared `edit` coverage when the provider enables edit mode.

View File

@@ -103,20 +103,20 @@ runtime modes at runtime.
This is the explicit mode contract used by `video_generate`, contract tests,
and the shared live sweep.
| Provider | `generate` | `imageToVideo` | `videoToVideo` | Shared live lanes today |
| -------- | ---------- | -------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Alibaba | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| BytePlus | Yes | Yes | No | `generate`, `imageToVideo` |
| ComfyUI | Yes | Yes | No | Not in the shared sweep; workflow-specific coverage lives with Comfy tests |
| fal | Yes | Yes | No | `generate`, `imageToVideo` |
| Google | Yes | Yes | Yes | `generate`, `imageToVideo`; shared `videoToVideo` skipped because the current buffer-backed Gemini/Veo sweep does not accept that input |
| MiniMax | Yes | Yes | No | `generate`, `imageToVideo` |
| OpenAI | Yes | Yes | Yes | `generate`, `imageToVideo`; shared `videoToVideo` skipped because this org/input path currently needs provider-side inpaint/remix access |
| Qwen | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| Runway | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` |
| Together | Yes | Yes | No | `generate`, `imageToVideo` |
| Vydra | Yes | Yes | No | `generate`; shared `imageToVideo` skipped because bundled `veo3` is text-only and bundled `kling` requires a remote image URL |
| xAI | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL |
| Provider | `generate` | `imageToVideo` | `videoToVideo` | Shared live lanes today |
| -------- | ---------- | -------------- | -------------- | ---------------------------------------------------------------------------------------------------------- |
| Alibaba | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| BytePlus | Yes | Yes | No | `generate`, `imageToVideo` |
| ComfyUI | Yes | Yes | No | Not in the shared sweep; workflow-specific coverage lives with Comfy tests |
| fal | Yes | Yes | No | `generate`, `imageToVideo` |
| Google | Yes | Yes | Yes | `generate`, `imageToVideo`, `videoToVideo` |
| MiniMax | Yes | Yes | No | `generate`, `imageToVideo` |
| OpenAI | Yes | Yes | Yes | `generate`, `imageToVideo`, `videoToVideo` |
| Qwen | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
| Runway | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` |
| Together | Yes | Yes | No | `generate`, `imageToVideo` |
| Vydra | Yes | Yes | No | `generate`, `imageToVideo` |
| xAI | Yes | Yes | Yes | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL |
## Tool parameters
@@ -140,7 +140,7 @@ and the shared live sweep.
| Parameter | Type | Description |
| ----------------- | ------- | ------------------------------------------------------------------------ |
| `aspectRatio` | string | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` |
| `resolution` | string | `480P`, `720P`, `768P`, or `1080P` |
| `resolution` | string | `480P`, `720P`, or `1080P` |
| `durationSeconds` | number | Target duration in seconds (rounded to nearest provider-supported value) |
| `size` | string | Size hint when the provider supports it |
| `audio` | boolean | Enable generated audio when supported |
@@ -254,12 +254,6 @@ Opt-in live coverage for the shared bundled providers:
OPENCLAW_LIVE_TEST=1 pnpm test:live -- extensions/video-generation-providers.live.test.ts
```
Repo wrapper:
```bash
pnpm test:live:media video
```
This live file loads missing provider env vars from `~/.profile`, prefers
live/env API keys ahead of stored auth profiles by default, and runs the
declared modes it can exercise safely with local media:
@@ -271,6 +265,8 @@ declared modes it can exercise safely with local media:
Today the shared `videoToVideo` live lane covers:
- `google`
- `openai`
- `runway` only when you select `runway/gen4_aleph`
## Configuration

View File

@@ -17,13 +17,6 @@ vi.mock("../runtime-api.js", () => ({
},
}));
vi.mock("./runtime.js", () => ({
ACPX_BACKEND_ID: "acpx",
AcpxRuntime: class {},
createAgentRegistry: vi.fn(() => ({})),
createFileSessionStore: vi.fn(() => ({})),
}));
import { getAcpRuntimeBackend } from "../runtime-api.js";
import { createAcpxRuntimeService } from "./service.js";

View File

@@ -2,28 +2,48 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
fetchWithTimeout,
postJsonRequest,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_VIDEO_GENERATION_DURATION_SECONDS,
DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
DEFAULT_VIDEO_RESOLUTION_TO_SIZE,
buildDashscopeVideoGenerationInput,
buildDashscopeVideoGenerationParameters,
downloadDashscopeGeneratedVideos,
extractDashscopeVideoUrls,
pollDashscopeVideoTaskUntilComplete,
} from "openclaw/plugin-sdk/video-generation";
import type {
DashscopeVideoGenerationResponse,
GeneratedVideoAsset,
VideoGenerationProvider,
VideoGenerationRequest,
VideoGenerationResult,
VideoGenerationSourceAsset,
} from "openclaw/plugin-sdk/video-generation";
const DEFAULT_ALIBABA_VIDEO_BASE_URL = "https://dashscope-intl.aliyuncs.com";
const DEFAULT_ALIBABA_VIDEO_MODEL = "wan2.6-t2v";
const DEFAULT_DURATION_SECONDS = 5;
const DEFAULT_TIMEOUT_MS = 120_000;
const POLL_INTERVAL_MS = 2_500;
const MAX_POLL_ATTEMPTS = 120;
const RESOLUTION_TO_SIZE: Record<string, string> = {
"480P": "832*480",
"720P": "1280*720",
"1080P": "1920*1080",
};
type AlibabaVideoGenerationResponse = {
output?: {
task_id?: string;
task_status?: string;
submit_time?: string;
results?: Array<{
video_url?: string;
orig_prompt?: string;
actual_prompt?: string;
}>;
video_url?: string;
code?: string;
message?: string;
};
request_id?: string;
code?: string;
message?: string;
};
function resolveAlibabaVideoBaseUrl(req: VideoGenerationRequest): string {
return req.cfg?.models?.providers?.alibaba?.baseUrl?.trim() || DEFAULT_ALIBABA_VIDEO_BASE_URL;
@@ -33,6 +53,139 @@ function resolveDashscopeAigcApiBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/u, "");
}
function resolveReferenceUrls(
inputImages: VideoGenerationSourceAsset[] | undefined,
inputVideos: VideoGenerationSourceAsset[] | undefined,
): string[] {
return [...(inputImages ?? []), ...(inputVideos ?? [])]
.map((asset) => asset.url?.trim())
.filter((value): value is string => Boolean(value));
}
function assertAlibabaReferenceInputsSupported(
inputImages: VideoGenerationSourceAsset[] | undefined,
inputVideos: VideoGenerationSourceAsset[] | undefined,
): void {
const unsupported = [...(inputImages ?? []), ...(inputVideos ?? [])].some(
(asset) => !asset.url?.trim() && asset.buffer,
);
if (unsupported) {
throw new Error(
"Alibaba Wan video generation currently requires remote http(s) URLs for reference images/videos.",
);
}
}
function buildAlibabaVideoGenerationInput(req: VideoGenerationRequest): Record<string, unknown> {
assertAlibabaReferenceInputsSupported(req.inputImages, req.inputVideos);
const input: Record<string, unknown> = {
prompt: req.prompt,
};
const referenceUrls = resolveReferenceUrls(req.inputImages, req.inputVideos);
if (
referenceUrls.length === 1 &&
(req.inputImages?.length ?? 0) === 1 &&
!req.inputVideos?.length
) {
input.img_url = referenceUrls[0];
} else if (referenceUrls.length > 0) {
input.reference_urls = referenceUrls;
}
return input;
}
function buildAlibabaVideoGenerationParameters(
req: VideoGenerationRequest,
): Record<string, unknown> | undefined {
const parameters: Record<string, unknown> = {};
const size =
req.size?.trim() || (req.resolution ? RESOLUTION_TO_SIZE[req.resolution] : undefined);
if (size) {
parameters.size = size;
}
if (req.aspectRatio?.trim()) {
parameters.aspect_ratio = req.aspectRatio.trim();
}
if (typeof req.durationSeconds === "number" && Number.isFinite(req.durationSeconds)) {
parameters.duration = Math.max(1, Math.round(req.durationSeconds));
}
if (typeof req.audio === "boolean") {
parameters.enable_audio = req.audio;
}
if (typeof req.watermark === "boolean") {
parameters.watermark = req.watermark;
}
return Object.keys(parameters).length > 0 ? parameters : undefined;
}
function extractVideoUrls(payload: AlibabaVideoGenerationResponse): string[] {
const urls = [
...(payload.output?.results?.map((entry) => entry.video_url).filter(Boolean) ?? []),
payload.output?.video_url,
].filter((value): value is string => typeof value === "string" && value.trim().length > 0);
return [...new Set(urls)];
}
async function pollTaskUntilComplete(params: {
taskId: string;
headers: Headers;
timeoutMs?: number;
fetchFn: typeof fetch;
baseUrl: string;
}): Promise<AlibabaVideoGenerationResponse> {
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
const response = await fetchWithTimeout(
`${params.baseUrl}/api/v1/tasks/${params.taskId}`,
{
method: "GET",
headers: params.headers,
},
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
params.fetchFn,
);
await assertOkOrThrowHttpError(response, "Alibaba Wan video-generation task poll failed");
const payload = (await response.json()) as AlibabaVideoGenerationResponse;
const status = payload.output?.task_status?.trim().toUpperCase();
if (status === "SUCCEEDED") {
return payload;
}
if (status === "FAILED" || status === "CANCELED") {
throw new Error(
payload.output?.message?.trim() ||
payload.message?.trim() ||
`Alibaba Wan video generation task ${params.taskId} ${status?.toLowerCase()}`,
);
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
throw new Error(`Alibaba Wan video generation task ${params.taskId} did not finish in time`);
}
async function downloadGeneratedVideos(params: {
urls: string[];
timeoutMs?: number;
fetchFn: typeof fetch;
}): Promise<GeneratedVideoAsset[]> {
const videos: GeneratedVideoAsset[] = [];
for (const [index, url] of params.urls.entries()) {
const response = await fetchWithTimeout(
url,
{ method: "GET" },
params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
params.fetchFn,
);
await assertOkOrThrowHttpError(response, "Alibaba Wan generated video download failed");
const arrayBuffer = await response.arrayBuffer();
videos.push({
buffer: Buffer.from(arrayBuffer),
mimeType: response.headers.get("content-type")?.trim() || "video/mp4",
fileName: `video-${index + 1}.mp4`,
metadata: { sourceUrl: url },
});
}
return videos;
}
export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
return {
id: "alibaba",
@@ -110,17 +263,11 @@ export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
headers,
body: {
model,
input: buildDashscopeVideoGenerationInput({
providerLabel: "Alibaba Wan",
req,
input: buildAlibabaVideoGenerationInput(req),
parameters: buildAlibabaVideoGenerationParameters({
...req,
durationSeconds: req.durationSeconds ?? DEFAULT_DURATION_SECONDS,
}),
parameters: buildDashscopeVideoGenerationParameters(
{
...req,
durationSeconds: req.durationSeconds ?? DEFAULT_VIDEO_GENERATION_DURATION_SECONDS,
},
DEFAULT_VIDEO_RESOLUTION_TO_SIZE,
),
},
timeoutMs: req.timeoutMs,
fetchFn,
@@ -130,30 +277,26 @@ export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
try {
await assertOkOrThrowHttpError(response, "Alibaba Wan video generation failed");
const submitted = (await response.json()) as DashscopeVideoGenerationResponse;
const submitted = (await response.json()) as AlibabaVideoGenerationResponse;
const taskId = submitted.output?.task_id?.trim();
if (!taskId) {
throw new Error("Alibaba Wan video generation response missing task_id");
}
const completed = await pollDashscopeVideoTaskUntilComplete({
providerLabel: "Alibaba Wan",
const completed = await pollTaskUntilComplete({
taskId,
headers,
timeoutMs: req.timeoutMs,
fetchFn,
baseUrl: resolveDashscopeAigcApiBaseUrl(baseUrl),
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
});
const urls = extractDashscopeVideoUrls(completed);
const urls = extractVideoUrls(completed);
if (urls.length === 0) {
throw new Error("Alibaba Wan video generation completed without output video URLs");
}
const videos = await downloadDashscopeGeneratedVideos({
providerLabel: "Alibaba Wan",
const videos = await downloadGeneratedVideos({
urls,
timeoutMs: req.timeoutMs,
fetchFn,
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
});
return {
videos,

View File

@@ -1,8 +0,0 @@
export { buildArceeModelDefinition, ARCEE_BASE_URL, ARCEE_MODEL_CATALOG } from "./models.js";
export { buildArceeProvider, buildArceeOpenRouterProvider } from "./provider-catalog.js";
export {
applyArceeConfig,
applyArceeOpenRouterConfig,
ARCEE_DEFAULT_MODEL_REF,
ARCEE_OPENROUTER_DEFAULT_MODEL_REF,
} from "./onboard.js";

View File

@@ -1,167 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveProviderPluginChoice } from "../../src/plugins/provider-auth-choice.runtime.js";
import { resolveProviderAuthEnvVarCandidates } from "../../src/secrets/provider-env-vars.js";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
import arceePlugin from "./index.js";
describe("arcee provider plugin", () => {
it("registers Arcee AI with direct and OpenRouter auth choices", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
expect(provider.id).toBe("arcee");
expect(provider.label).toBe("Arcee AI");
expect(provider.envVars).toEqual(["ARCEEAI_API_KEY", "OPENROUTER_API_KEY"]);
expect(provider.auth).toHaveLength(2);
const directChoice = resolveProviderPluginChoice({
providers: [provider],
choice: "arceeai-api-key",
});
expect(directChoice).not.toBeNull();
expect(directChoice?.provider.id).toBe("arcee");
expect(directChoice?.method.id).toBe("arcee-platform");
const orChoice = resolveProviderPluginChoice({
providers: [provider],
choice: "arceeai-openrouter",
});
expect(orChoice).not.toBeNull();
expect(orChoice?.provider.id).toBe("arcee");
expect(orChoice?.method.id).toBe("openrouter");
});
it("stores the OpenRouter onboarding path under the OpenRouter auth profile", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
const openRouterMethod = provider.auth?.find((method) => method.id === "openrouter");
if (!openRouterMethod?.runNonInteractive) {
throw new Error("expected OpenRouter non-interactive auth");
}
const config = await openRouterMethod.runNonInteractive({
config: {},
opts: {},
env: {},
runtime: {
error: () => {},
exit: () => {},
log: () => {},
},
resolveApiKey: async () => ({
key: "sk-or-test",
source: "profile",
}),
toApiKeyCredential: () => null,
} as never);
expect(config?.auth?.profiles?.["openrouter:default"]).toMatchObject({
provider: "openrouter",
mode: "api_key",
});
expect(config?.models?.providers?.arcee).toMatchObject({
baseUrl: "https://openrouter.ai/api/v1",
api: "openai-completions",
});
expect(config?.models?.providers?.arcee?.models?.map((model) => model.id)).toEqual([
"arcee/trinity-mini",
"arcee/trinity-large-preview",
"arcee/trinity-large-thinking",
]);
});
it("keeps direct Arcee auth env candidates separate from OpenRouter", () => {
const candidates = resolveProviderAuthEnvVarCandidates();
expect(candidates.arcee).toEqual(["ARCEEAI_API_KEY"]);
expect(candidates.openrouter).toEqual(["OPENROUTER_API_KEY"]);
});
it("builds the direct Arcee AI model catalog", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
expect(provider.catalog).toBeDefined();
const catalog = await provider.catalog!.run({
config: {},
env: {},
resolveProviderApiKey: (id: string) =>
id === "arcee" ? { apiKey: "test-key" } : { apiKey: undefined },
resolveProviderAuth: () => ({
apiKey: "test-key",
mode: "api_key",
source: "env",
}),
} as never);
expect(catalog && "provider" in catalog).toBe(true);
if (!catalog || !("provider" in catalog)) {
throw new Error("expected single-provider catalog");
}
expect(catalog.provider.api).toBe("openai-completions");
expect(catalog.provider.baseUrl).toBe("https://api.arcee.ai/api/v1");
expect(catalog.provider.models?.map((model) => model.id)).toEqual([
"trinity-mini",
"trinity-large-preview",
"trinity-large-thinking",
]);
});
it("builds the OpenRouter-backed Arcee AI model catalog", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
const catalog = await provider.catalog!.run({
config: {},
env: {},
resolveProviderApiKey: (id: string) =>
id === "openrouter" ? { apiKey: "sk-or-test" } : { apiKey: undefined },
resolveProviderAuth: () => ({
apiKey: "sk-or-test",
mode: "api_key",
source: "env",
}),
} as never);
expect(catalog && "provider" in catalog).toBe(true);
if (!catalog || !("provider" in catalog)) {
throw new Error("expected single-provider catalog");
}
expect(catalog.provider.baseUrl).toBe("https://openrouter.ai/api/v1");
expect(catalog.provider.models?.map((model) => model.id)).toEqual([
"arcee/trinity-mini",
"arcee/trinity-large-preview",
"arcee/trinity-large-thinking",
]);
});
it("normalizes Arcee OpenRouter models to vendor-prefixed runtime ids", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
expect(
provider.normalizeResolvedModel?.({
modelId: "arcee/trinity-large-thinking",
model: {
provider: "arcee",
id: "trinity-large-thinking",
name: "Trinity Large Thinking",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
},
} as never),
).toMatchObject({
id: "arcee/trinity-large-thinking",
});
expect(
provider.normalizeResolvedModel?.({
modelId: "arcee/trinity-large-thinking",
model: {
provider: "arcee",
id: "trinity-large-thinking",
name: "Trinity Large Thinking",
api: "openai-completions",
baseUrl: "https://api.arcee.ai/api/v1",
},
} as never),
).toBeUndefined();
});
});

View File

@@ -1,128 +0,0 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import {
readConfiguredProviderCatalogEntries,
type ProviderCatalogContext,
} from "openclaw/plugin-sdk/provider-catalog-shared";
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
import {
applyArceeConfig,
applyArceeOpenRouterConfig,
ARCEE_DEFAULT_MODEL_REF,
ARCEE_OPENROUTER_DEFAULT_MODEL_REF,
} from "./onboard.js";
import {
buildArceeProvider,
buildArceeOpenRouterProvider,
isArceeOpenRouterBaseUrl,
toArceeOpenRouterModelId,
} from "./provider-catalog.js";
const PROVIDER_ID = "arcee";
const OPENAI_COMPATIBLE_REPLAY_HOOKS = buildProviderReplayFamilyHooks({
family: "openai-compatible",
});
const ARCEE_WIZARD_GROUP = {
groupId: "arcee",
groupLabel: "Arcee AI",
groupHint: "Direct API or OpenRouter",
} as const;
function buildArceeAuthMethods() {
return [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "arcee-platform",
label: "Arcee AI API key",
hint: "Direct access to Arcee platform",
optionKey: "arceeaiApiKey",
flagName: "--arceeai-api-key",
envVar: "ARCEEAI_API_KEY",
promptMessage: "Enter Arcee AI API key",
defaultModel: ARCEE_DEFAULT_MODEL_REF,
expectedProviders: [PROVIDER_ID],
applyConfig: (cfg) => applyArceeConfig(cfg),
wizard: {
choiceId: "arceeai-api-key",
choiceLabel: "Arcee AI API key",
choiceHint: "Direct (chat.arcee.ai)",
...ARCEE_WIZARD_GROUP,
},
}),
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "openrouter",
label: "OpenRouter API key",
hint: "Access Arcee models via OpenRouter",
optionKey: "openrouterApiKey",
flagName: "--openrouter-api-key",
envVar: "OPENROUTER_API_KEY",
promptMessage: "Enter OpenRouter API key",
profileId: "openrouter:default",
defaultModel: ARCEE_OPENROUTER_DEFAULT_MODEL_REF,
expectedProviders: [PROVIDER_ID, "openrouter"],
applyConfig: (cfg) => applyArceeOpenRouterConfig(cfg),
wizard: {
choiceId: "arceeai-openrouter",
choiceLabel: "OpenRouter API key",
choiceHint: "Via OpenRouter (openrouter.ai)",
...ARCEE_WIZARD_GROUP,
},
}),
];
}
function readConfiguredArceeCatalogEntries(config: OpenClawConfig | undefined) {
return readConfiguredProviderCatalogEntries({
config,
providerId: PROVIDER_ID,
});
}
async function resolveArceeCatalog(ctx: ProviderCatalogContext) {
const directKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
if (directKey) {
return { provider: { ...buildArceeProvider(), apiKey: directKey } };
}
const openRouterKey = ctx.resolveProviderApiKey("openrouter").apiKey;
if (openRouterKey) {
return { provider: { ...buildArceeOpenRouterProvider(), apiKey: openRouterKey } };
}
return null;
}
function normalizeArceeResolvedModel<T extends { baseUrl?: string; id: string }>(
model: T,
): T | undefined {
if (!isArceeOpenRouterBaseUrl(model.baseUrl)) {
return undefined;
}
return {
...model,
id: toArceeOpenRouterModelId(model.id),
};
}
export default definePluginEntry({
id: PROVIDER_ID,
name: "Arcee AI Provider",
description: "Bundled Arcee AI provider plugin",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "Arcee AI",
docsPath: "/providers/arcee",
envVars: ["ARCEEAI_API_KEY", "OPENROUTER_API_KEY"],
auth: buildArceeAuthMethods(),
catalog: {
run: resolveArceeCatalog,
},
augmentModelCatalog: ({ config }) => readConfiguredArceeCatalogEntries(config),
normalizeResolvedModel: ({ model }) => normalizeArceeResolvedModel(model),
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
});
},
});

View File

@@ -1,67 +0,0 @@
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
export const ARCEE_BASE_URL = "https://api.arcee.ai/api/v1";
export const ARCEE_MODEL_CATALOG: ModelDefinitionConfig[] = [
{
id: "trinity-mini",
name: "Trinity Mini 26B",
reasoning: false,
input: ["text"],
contextWindow: 131072,
maxTokens: 80000,
cost: {
input: 0.045,
output: 0.15,
cacheRead: 0.045,
cacheWrite: 0.045,
},
},
{
id: "trinity-large-preview",
name: "Trinity Large Preview",
reasoning: false,
input: ["text"],
contextWindow: 131072,
maxTokens: 16384,
cost: {
input: 0.25,
output: 1.0,
cacheRead: 0.25,
cacheWrite: 0.25,
},
},
{
id: "trinity-large-thinking",
name: "Trinity Large Thinking",
reasoning: true,
input: ["text"],
contextWindow: 262144,
maxTokens: 80000,
cost: {
input: 0.25,
output: 0.9,
cacheRead: 0.25,
cacheWrite: 0.25,
},
compat: {
supportsReasoningEffort: false,
},
},
];
export function buildArceeModelDefinition(
model: (typeof ARCEE_MODEL_CATALOG)[number],
): ModelDefinitionConfig {
return {
id: model.id,
name: model.name,
api: "openai-completions",
reasoning: model.reasoning,
input: model.input,
cost: model.cost,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
...(model.compat ? { compat: model.compat } : {}),
};
}

View File

@@ -1,47 +0,0 @@
import {
createModelCatalogPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { ARCEE_BASE_URL } from "./api.js";
import {
buildArceeCatalogModels,
buildArceeOpenRouterCatalogModels,
OPENROUTER_BASE_URL,
} from "./provider-catalog.js";
export const ARCEE_DEFAULT_MODEL_REF = "arcee/trinity-large-thinking";
export const ARCEE_OPENROUTER_DEFAULT_MODEL_REF = "arcee/trinity-large-thinking";
const arceePresetAppliers = createModelCatalogPresetAppliers({
primaryModelRef: ARCEE_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => ({
providerId: "arcee",
api: "openai-completions",
baseUrl: ARCEE_BASE_URL,
catalogModels: buildArceeCatalogModels(),
aliases: [{ modelRef: ARCEE_DEFAULT_MODEL_REF, alias: "Arcee AI" }],
}),
});
const arceeOpenRouterPresetAppliers = createModelCatalogPresetAppliers({
primaryModelRef: ARCEE_OPENROUTER_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => ({
providerId: "arcee",
api: "openai-completions",
baseUrl: OPENROUTER_BASE_URL,
catalogModels: buildArceeOpenRouterCatalogModels(),
aliases: [{ modelRef: ARCEE_OPENROUTER_DEFAULT_MODEL_REF, alias: "Arcee AI (OpenRouter)" }],
}),
});
export function applyArceeProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return arceePresetAppliers.applyProviderConfig(cfg);
}
export function applyArceeConfig(cfg: OpenClawConfig): OpenClawConfig {
return arceePresetAppliers.applyConfig(cfg);
}
export function applyArceeOpenRouterConfig(cfg: OpenClawConfig): OpenClawConfig {
return arceeOpenRouterPresetAppliers.applyConfig(cfg);
}

View File

@@ -1,43 +0,0 @@
{
"id": "arcee",
"enabledByDefault": true,
"providers": ["arcee"],
"providerAuthEnvVars": {
"arcee": ["ARCEEAI_API_KEY"]
},
"providerAuthChoices": [
{
"provider": "arcee",
"method": "arcee-platform",
"choiceId": "arceeai-api-key",
"choiceLabel": "Arcee AI API key",
"choiceHint": "Direct (chat.arcee.ai)",
"groupId": "arcee",
"groupLabel": "Arcee AI",
"groupHint": "Direct API or OpenRouter",
"optionKey": "arceeaiApiKey",
"cliFlag": "--arceeai-api-key",
"cliOption": "--arceeai-api-key <key>",
"cliDescription": "Arcee AI API key"
},
{
"provider": "arcee",
"method": "openrouter",
"choiceId": "arceeai-openrouter",
"choiceLabel": "OpenRouter API key",
"choiceHint": "Via OpenRouter (openrouter.ai)",
"groupId": "arcee",
"groupLabel": "Arcee AI",
"groupHint": "Direct API or OpenRouter",
"optionKey": "openrouterApiKey",
"cliFlag": "--openrouter-api-key",
"cliOption": "--openrouter-api-key <key>",
"cliDescription": "OpenRouter API key for Arcee AI models"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -1,12 +0,0 @@
{
"name": "@openclaw/arcee-provider",
"version": "2026.4.4",
"private": true,
"description": "OpenClaw Arcee provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -1,49 +0,0 @@
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { buildArceeModelDefinition, ARCEE_BASE_URL, ARCEE_MODEL_CATALOG } from "./api.js";
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
function normalizeBaseUrl(baseUrl: string | undefined): string {
return String(baseUrl ?? "")
.trim()
.replace(/\/+$/, "");
}
export function isArceeOpenRouterBaseUrl(baseUrl: string | undefined): boolean {
return normalizeBaseUrl(baseUrl) === OPENROUTER_BASE_URL;
}
export function toArceeOpenRouterModelId(modelId: string): string {
const normalized = modelId.trim();
if (!normalized || normalized.startsWith("arcee/")) {
return normalized;
}
return `arcee/${normalized}`;
}
export function buildArceeCatalogModels(): NonNullable<ModelProviderConfig["models"]> {
return ARCEE_MODEL_CATALOG.map(buildArceeModelDefinition);
}
export function buildArceeOpenRouterCatalogModels(): NonNullable<ModelProviderConfig["models"]> {
return buildArceeCatalogModels().map((model) => ({
...model,
id: toArceeOpenRouterModelId(model.id),
}));
}
export function buildArceeProvider(): ModelProviderConfig {
return {
baseUrl: ARCEE_BASE_URL,
api: "openai-completions",
models: buildArceeCatalogModels(),
};
}
export function buildArceeOpenRouterProvider(): ModelProviderConfig {
return {
baseUrl: OPENROUTER_BASE_URL,
api: "openai-completions",
models: buildArceeOpenRouterCatalogModels(),
};
}

View File

@@ -1,4 +0,0 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -8,10 +8,6 @@ type BlueBubblesConfigPatch = {
};
type AccountEnabledMode = boolean | "preserve-or-true";
type BlueBubblesAccountEntry = {
enabled?: boolean;
[key: string]: unknown;
};
function normalizePatch(
patch: BlueBubblesConfigPatch,
@@ -55,9 +51,7 @@ export function applyBlueBubblesConnectionConfig(params: {
};
}
const currentAccount = params.cfg.channels?.bluebubbles?.accounts?.[params.accountId] as
| BlueBubblesAccountEntry
| undefined;
const currentAccount = params.cfg.channels?.bluebubbles?.accounts?.[params.accountId];
const enabled =
params.accountEnabled === "preserve-or-true"
? (currentAccount?.enabled ?? true)

View File

@@ -27,13 +27,7 @@ describe("bluebubbles doctor", () => {
expect(result.config.channels?.bluebubbles?.network).toEqual({
dangerouslyAllowPrivateNetwork: true,
});
expect(
(
result.config.channels?.bluebubbles?.accounts?.default as {
network?: { dangerouslyAllowPrivateNetwork?: boolean };
}
)?.network,
).toEqual({
expect(result.config.channels?.bluebubbles?.accounts?.default?.network).toEqual({
dangerouslyAllowPrivateNetwork: false,
});
});

View File

@@ -201,8 +201,8 @@ export async function sendBlueBubblesMedia(params: {
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
(cfg.channels?.bluebubbles?.accounts?.[accountId] as { mediaMaxMb?: number } | undefined)
?.mediaMaxMb ?? cfg.channels?.bluebubbles?.mediaMaxMb,
cfg.channels?.bluebubbles?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.bluebubbles?.mediaMaxMb,
accountId,
});
const mediaLocalRoots = resolveMediaLocalRoots({ cfg, accountId });

View File

@@ -4,7 +4,7 @@ import {
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/channel-secret-runtime";
} from "openclaw/plugin-sdk/security-runtime";
export const secretTargetRegistryEntries = [
{

View File

@@ -21,11 +21,8 @@ export function setBlueBubblesDmPolicy(
const existingAllowFrom =
resolvedAccountId === "default"
? cfg.channels?.bluebubbles?.allowFrom
: ((
cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId] as
| { allowFrom?: ReadonlyArray<string | number> }
| undefined
)?.allowFrom ?? cfg.channels?.bluebubbles?.allowFrom);
: (cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId]?.allowFrom ??
cfg.channels?.bluebubbles?.allowFrom);
return patchScopedAccountConfig({
cfg,
channelKey: channel,

View File

@@ -36,9 +36,6 @@ async function createBlueBubblesConfigureAdapter() {
docsPath: "/channels/bluebubbles",
blurb: "iMessage via BlueBubbles",
},
capabilities: {
chatTypes: ["direct", "group"],
},
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
@@ -216,13 +213,8 @@ describe("bluebubbles setup surface", () => {
});
const next = blueBubblesSetupWizard.dmPolicy?.setPolicy(cfg, "open");
const workAccount = next?.channels?.bluebubbles?.accounts?.work as
| {
dmPolicy?: string;
}
| undefined;
expect(next?.channels?.bluebubbles?.dmPolicy).toBe("disabled");
expect(workAccount?.dmPolicy).toBe("open");
expect(next?.channels?.bluebubbles?.accounts?.work?.dmPolicy).toBe("open");
});
it("uses configured defaultAccount when accountId is omitted in account resolution", async () => {
@@ -302,15 +294,12 @@ describe("bluebubbles setup surface", () => {
"work",
);
const workAccount = next?.channels?.bluebubbles?.accounts?.work as
| {
dmPolicy?: string;
allowFrom?: string[];
}
| undefined;
expect(next?.channels?.bluebubbles?.dmPolicy).toBeUndefined();
expect(workAccount?.dmPolicy).toBe("open");
expect(workAccount?.allowFrom).toEqual(["user@example.com", "*"]);
expect(next?.channels?.bluebubbles?.accounts?.work?.dmPolicy).toBe("open");
expect(next?.channels?.bluebubbles?.accounts?.work?.allowFrom).toEqual([
"user@example.com",
"*",
]);
});
});

View File

@@ -1,367 +0,0 @@
import type { SearchConfigRecord } from "openclaw/plugin-sdk/provider-web-search";
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
formatCliCommand,
normalizeFreshness,
parseIsoDateRange,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
resolveSiteName,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import {
type BraveLlmContextResponse,
mapBraveLlmContextResults,
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveConfig,
resolveBraveMode,
} from "./brave-web-search-provider.shared.js";
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
type BraveSearchResult = {
title?: string;
url?: string;
description?: string;
age?: string;
};
type BraveSearchResponse = {
web?: {
results?: BraveSearchResult[];
};
};
function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined {
return (
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
readProviderEnvValue(["BRAVE_API_KEY"])
);
}
function missingBraveKeyPayload() {
return {
error: "missing_brave_api_key",
message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
docs: "https://docs.openclaw.ai/tools/web",
};
}
async function runBraveLlmContextSearch(params: {
query: string;
apiKey: string;
timeoutSeconds: number;
country?: string;
search_lang?: string;
freshness?: string;
}): Promise<{
results: Array<{
url: string;
title: string;
snippets: string[];
siteName?: string;
}>;
sources?: BraveLlmContextResponse["sources"];
}> {
const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
url.searchParams.set("q", params.query);
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
}
return withTrustedWebSearchEndpoint(
{
url: url.toString(),
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
},
},
async (response) => {
if (!response.ok) {
const detail = await response.text();
throw new Error(
`Brave LLM Context API error (${response.status}): ${detail || response.statusText}`,
);
}
const data = (await response.json()) as BraveLlmContextResponse;
return { results: mapBraveLlmContextResults(data), sources: data.sources };
},
);
}
async function runBraveWebSearch(params: {
query: string;
count: number;
apiKey: string;
timeoutSeconds: number;
country?: string;
search_lang?: string;
ui_lang?: string;
freshness?: string;
dateAfter?: string;
dateBefore?: string;
}): Promise<Array<Record<string, unknown>>> {
const url = new URL(BRAVE_SEARCH_ENDPOINT);
url.searchParams.set("q", params.query);
url.searchParams.set("count", String(params.count));
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
if (params.ui_lang) {
url.searchParams.set("ui_lang", params.ui_lang);
}
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
} else if (params.dateAfter && params.dateBefore) {
url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`);
} else if (params.dateAfter) {
url.searchParams.set(
"freshness",
`${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`,
);
} else if (params.dateBefore) {
url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`);
}
return withTrustedWebSearchEndpoint(
{
url: url.toString(),
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
},
},
async (response) => {
if (!response.ok) {
const detail = await response.text();
throw new Error(
`Brave Search API error (${response.status}): ${detail || response.statusText}`,
);
}
const data = (await response.json()) as BraveSearchResponse;
const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : [];
return results.map((entry) => {
const description = entry.description ?? "";
const title = entry.title ?? "";
const url = entry.url ?? "";
return {
title: title ? wrapWebContent(title, "web_search") : "",
url,
description: description ? wrapWebContent(description, "web_search") : "",
published: entry.age || undefined,
siteName: resolveSiteName(url) || undefined,
};
});
},
);
}
export async function executeBraveSearch(
args: Record<string, unknown>,
searchConfig?: SearchConfigRecord,
): Promise<Record<string, unknown>> {
const apiKey = resolveBraveApiKey(searchConfig);
if (!apiKey) {
return missingBraveKeyPayload();
}
const braveConfig = resolveBraveConfig(searchConfig);
const braveMode = resolveBraveMode(braveConfig);
const query = readStringParam(args, "query", { required: true });
const count =
readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
const country = normalizeBraveCountry(readStringParam(args, "country"));
const language = readStringParam(args, "language");
const search_lang = readStringParam(args, "search_lang");
const ui_lang = readStringParam(args, "ui_lang");
const normalizedLanguage = normalizeBraveLanguageParams({
search_lang: search_lang || language,
ui_lang,
});
if (normalizedLanguage.invalidField === "search_lang") {
return {
error: "invalid_search_lang",
message:
"search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (normalizedLanguage.invalidField === "ui_lang") {
return {
error: "invalid_ui_lang",
message: "ui_lang must be a language-region locale like 'en-US'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (normalizedLanguage.ui_lang && braveMode === "llm-context") {
return {
error: "unsupported_ui_lang",
message:
"ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const rawFreshness = readStringParam(args, "freshness");
if (rawFreshness && braveMode === "llm-context") {
return {
error: "unsupported_freshness",
message:
"freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "brave") : undefined;
if (rawFreshness && !freshness) {
return {
error: "invalid_freshness",
message: "freshness must be day, week, month, or year.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const rawDateAfter = readStringParam(args, "date_after");
const rawDateBefore = readStringParam(args, "date_before");
if (rawFreshness && (rawDateAfter || rawDateBefore)) {
return {
error: "conflicting_time_filters",
message:
"freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if ((rawDateAfter || rawDateBefore) && braveMode === "llm-context") {
return {
error: "unsupported_date_filter",
message:
"date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.",
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, dateBefore } = parsedDateRange;
const cacheKey = buildSearchCacheKey([
"brave",
braveMode,
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
country,
normalizedLanguage.search_lang,
normalizedLanguage.ui_lang,
freshness,
dateAfter,
dateBefore,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
if (braveMode === "llm-context") {
const { results, sources } = await runBraveLlmContextSearch({
query,
apiKey,
timeoutSeconds,
country: country ?? undefined,
search_lang: normalizedLanguage.search_lang,
freshness,
});
const payload = {
query,
provider: "brave",
mode: "llm-context" as const,
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "brave",
wrapped: true,
},
results: results.map((entry) => ({
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
url: entry.url,
snippets: entry.snippets.map((snippet) => wrapWebContent(snippet, "web_search")),
siteName: entry.siteName,
})),
sources,
};
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
return payload;
}
const results = await runBraveWebSearch({
query,
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
apiKey,
timeoutSeconds,
country: country ?? undefined,
search_lang: normalizedLanguage.search_lang,
ui_lang: normalizedLanguage.ui_lang,
freshness,
dateAfter,
dateBefore,
});
const payload = {
query,
provider: "brave",
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "brave",
wrapped: true,
},
results,
};
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
return payload;
}

View File

@@ -1,274 +0,0 @@
import { Type } from "@sinclair/typebox";
export type BraveConfig = {
mode?: string;
};
export type BraveLlmContextResult = { url: string; title: string; snippets: string[] };
export type BraveLlmContextResponse = {
grounding: { generic?: BraveLlmContextResult[] };
sources?: { url?: string; hostname?: string; date?: string }[];
};
const BRAVE_COUNTRY_CODES = new Set([
"AR",
"AU",
"AT",
"BE",
"BR",
"CA",
"CL",
"DK",
"FI",
"FR",
"DE",
"GR",
"HK",
"IN",
"ID",
"IT",
"JP",
"KR",
"MY",
"MX",
"NL",
"NZ",
"NO",
"CN",
"PL",
"PT",
"PH",
"RU",
"SA",
"ZA",
"ES",
"SE",
"CH",
"TW",
"TR",
"GB",
"US",
"ALL",
]);
const BRAVE_SEARCH_LANG_CODES = new Set([
"ar",
"eu",
"bn",
"bg",
"ca",
"zh-hans",
"zh-hant",
"hr",
"cs",
"da",
"nl",
"en",
"en-gb",
"et",
"fi",
"fr",
"gl",
"de",
"el",
"gu",
"he",
"hi",
"hu",
"is",
"it",
"jp",
"kn",
"ko",
"lv",
"lt",
"ms",
"ml",
"mr",
"nb",
"pl",
"pt-br",
"pt-pt",
"pa",
"ro",
"ru",
"sr",
"sk",
"sl",
"es",
"sv",
"ta",
"te",
"th",
"tr",
"uk",
"vi",
]);
const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
ja: "jp",
zh: "zh-hans",
"zh-cn": "zh-hans",
"zh-hk": "zh-hant",
"zh-sg": "zh-hans",
"zh-tw": "zh-hant",
};
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
const MAX_BRAVE_SEARCH_COUNT = 10;
function normalizeBraveSearchLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const canonical = BRAVE_SEARCH_LANG_ALIASES[trimmed.toLowerCase()] ?? trimmed.toLowerCase();
if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) {
return undefined;
}
return canonical;
}
export function normalizeBraveCountry(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const canonical = trimmed.toUpperCase();
return BRAVE_COUNTRY_CODES.has(canonical) ? canonical : "ALL";
}
function normalizeBraveUiLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const match = trimmed.match(BRAVE_UI_LANG_LOCALE);
if (!match) {
return undefined;
}
const [, language, region] = match;
return `${language.toLowerCase()}-${region.toUpperCase()}`;
}
export function resolveBraveConfig(searchConfig?: Record<string, unknown>): BraveConfig {
const brave = searchConfig?.brave;
return brave && typeof brave === "object" && !Array.isArray(brave) ? (brave as BraveConfig) : {};
}
export function resolveBraveMode(brave?: BraveConfig): "web" | "llm-context" {
return brave?.mode === "llm-context" ? "llm-context" : "web";
}
export function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): {
search_lang?: string;
ui_lang?: string;
invalidField?: "search_lang" | "ui_lang";
} {
const rawSearchLang = params.search_lang?.trim() || undefined;
const rawUiLang = params.ui_lang?.trim() || undefined;
let searchLangCandidate = rawSearchLang;
let uiLangCandidate = rawUiLang;
if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) {
searchLangCandidate = rawUiLang;
uiLangCandidate = rawSearchLang;
}
const search_lang = normalizeBraveSearchLang(searchLangCandidate);
if (searchLangCandidate && !search_lang) {
return { invalidField: "search_lang" };
}
const ui_lang = normalizeBraveUiLang(uiLangCandidate);
if (uiLangCandidate && !ui_lang) {
return { invalidField: "ui_lang" };
}
return { search_lang, ui_lang };
}
function resolveSiteName(url: string | undefined): string | undefined {
if (!url) {
return undefined;
}
try {
return new URL(url).hostname;
} catch {
return undefined;
}
}
export function mapBraveLlmContextResults(
data: BraveLlmContextResponse,
): { url: string; title: string; snippets: string[]; siteName?: string }[] {
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
return genericResults.map((entry) => ({
url: entry.url ?? "",
title: entry.title ?? "",
snippets: (entry.snippets ?? []).filter(
(snippet) => typeof snippet === "string" && snippet.length > 0,
),
siteName: resolveSiteName(entry.url) || undefined,
}));
}
export function createBraveSchema() {
return Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: MAX_BRAVE_SEARCH_COUNT,
}),
),
country: Type.Optional(
Type.String({
description:
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
}),
),
language: Type.Optional(
Type.String({
description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
}),
),
freshness: Type.Optional(
Type.String({
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
}),
),
date_after: Type.Optional(
Type.String({
description: "Only results published after this date (YYYY-MM-DD).",
}),
),
date_before: Type.Optional(
Type.String({
description: "Only results published before this date (YYYY-MM-DD).",
}),
),
search_lang: Type.Optional(
Type.String({
description:
"Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').",
}),
),
ui_lang: Type.Optional(
Type.String({
description:
"Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.",
}),
),
});
}

View File

@@ -1,106 +1,449 @@
import type {
SearchConfigRecord,
WebSearchProviderPlugin,
WebSearchProviderToolDefinition,
} from "openclaw/plugin-sdk/provider-web-search";
import { Type } from "@sinclair/typebox";
import {
createBraveSchema,
mapBraveLlmContextResults,
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveConfig,
resolveBraveMode,
} from "./brave-web-search-provider.shared.js";
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
MAX_SEARCH_COUNT,
formatCliCommand,
mergeScopedSearchConfig,
normalizeFreshness,
parseIsoDateRange,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
resolveSiteName,
setTopLevelCredentialValue,
setProviderWebSearchPluginConfigValue,
type SearchConfigRecord,
type WebSearchProviderPlugin,
type WebSearchProviderToolDefinition,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
type ConfigInput = Parameters<
NonNullable<WebSearchProviderPlugin["getConfiguredCredentialValue"]>
>[0];
type ConfigTarget = Parameters<
NonNullable<WebSearchProviderPlugin["setConfiguredCredentialValue"]>
>[0];
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
// Mirror Brave's documented country enum so unsupported locale guesses can collapse to ALL.
const BRAVE_COUNTRY_CODES = new Set([
"AR",
"AU",
"AT",
"BE",
"BR",
"CA",
"CL",
"DK",
"FI",
"FR",
"DE",
"GR",
"HK",
"IN",
"ID",
"IT",
"JP",
"KR",
"MY",
"MX",
"NL",
"NZ",
"NO",
"CN",
"PL",
"PT",
"PH",
"RU",
"SA",
"ZA",
"ES",
"SE",
"CH",
"TW",
"TR",
"GB",
"US",
"ALL",
]);
const BRAVE_SEARCH_LANG_CODES = new Set([
"ar",
"eu",
"bn",
"bg",
"ca",
"zh-hans",
"zh-hant",
"hr",
"cs",
"da",
"nl",
"en",
"en-gb",
"et",
"fi",
"fr",
"gl",
"de",
"el",
"gu",
"he",
"hi",
"hu",
"is",
"it",
"jp",
"kn",
"ko",
"lv",
"lt",
"ms",
"ml",
"mr",
"nb",
"pl",
"pt-br",
"pt-pt",
"pa",
"ro",
"ru",
"sr",
"sk",
"sl",
"es",
"sv",
"ta",
"te",
"th",
"tr",
"uk",
"vi",
]);
const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
ja: "jp",
zh: "zh-hans",
"zh-cn": "zh-hans",
"zh-hk": "zh-hant",
"zh-sg": "zh-hans",
"zh-tw": "zh-hant",
};
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
type BraveConfig = {
mode?: string;
};
type BraveSearchResult = {
title?: string;
url?: string;
description?: string;
age?: string;
};
type BraveSearchResponse = {
web?: {
results?: BraveSearchResult[];
};
};
type BraveLlmContextResult = { url: string; title: string; snippets: string[] };
type BraveLlmContextResponse = {
grounding: { generic?: BraveLlmContextResult[] };
sources?: { url?: string; hostname?: string; date?: string }[];
};
function resolveBraveConfig(searchConfig?: SearchConfigRecord): BraveConfig {
const brave = searchConfig?.brave;
return brave && typeof brave === "object" && !Array.isArray(brave) ? (brave as BraveConfig) : {};
}
function resolveProviderWebSearchPluginConfig(
config: ConfigInput,
pluginId: string,
): Record<string, unknown> | undefined {
if (!isRecord(config)) {
function resolveBraveMode(brave?: BraveConfig): "web" | "llm-context" {
return brave?.mode === "llm-context" ? "llm-context" : "web";
}
function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined {
return (
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
readProviderEnvValue(["BRAVE_API_KEY"])
);
}
function normalizeBraveSearchLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const plugins = isRecord(config.plugins) ? config.plugins : undefined;
const entries = isRecord(plugins?.entries) ? plugins.entries : undefined;
const entry = isRecord(entries?.[pluginId]) ? entries[pluginId] : undefined;
const pluginConfig = isRecord(entry?.config) ? entry.config : undefined;
return isRecord(pluginConfig?.webSearch) ? pluginConfig.webSearch : undefined;
}
function ensureObject(target: Record<string, unknown>, key: string): Record<string, unknown> {
const current = target[key];
if (isRecord(current)) {
return current;
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const next: Record<string, unknown> = {};
target[key] = next;
return next;
}
function setProviderWebSearchPluginConfigValue(
configTarget: ConfigTarget,
pluginId: string,
key: string,
value: unknown,
): void {
const plugins = ensureObject(configTarget as Record<string, unknown>, "plugins");
const entries = ensureObject(plugins, "entries");
const entry = ensureObject(entries, pluginId);
if (entry.enabled === undefined) {
entry.enabled = true;
const canonical = BRAVE_SEARCH_LANG_ALIASES[trimmed.toLowerCase()] ?? trimmed.toLowerCase();
if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) {
return undefined;
}
const config = ensureObject(entry, "config");
const webSearch = ensureObject(config, "webSearch");
webSearch[key] = value;
return canonical;
}
function setTopLevelCredentialValue(
searchConfigTarget: Record<string, unknown>,
value: unknown,
): void {
searchConfigTarget.apiKey = value;
function normalizeBraveCountry(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const canonical = trimmed.toUpperCase();
return BRAVE_COUNTRY_CODES.has(canonical) ? canonical : "ALL";
}
function mergeScopedSearchConfig(
searchConfig: Record<string, unknown> | undefined,
key: string,
pluginConfig: Record<string, unknown> | undefined,
options?: { mirrorApiKeyToTopLevel?: boolean },
): Record<string, unknown> | undefined {
if (!pluginConfig) {
return searchConfig;
function normalizeBraveUiLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const match = trimmed.match(BRAVE_UI_LANG_LOCALE);
if (!match) {
return undefined;
}
const [, language, region] = match;
return `${language.toLowerCase()}-${region.toUpperCase()}`;
}
function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): {
search_lang?: string;
ui_lang?: string;
invalidField?: "search_lang" | "ui_lang";
} {
const rawSearchLang = params.search_lang?.trim() || undefined;
const rawUiLang = params.ui_lang?.trim() || undefined;
let searchLangCandidate = rawSearchLang;
let uiLangCandidate = rawUiLang;
if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) {
searchLangCandidate = rawUiLang;
uiLangCandidate = rawSearchLang;
}
const currentScoped = isRecord(searchConfig?.[key]) ? searchConfig?.[key] : {};
const next: Record<string, unknown> = {
...searchConfig,
[key]: {
...currentScoped,
...pluginConfig,
const search_lang = normalizeBraveSearchLang(searchLangCandidate);
if (searchLangCandidate && !search_lang) {
return { invalidField: "search_lang" };
}
const ui_lang = normalizeBraveUiLang(uiLangCandidate);
if (uiLangCandidate && !ui_lang) {
return { invalidField: "ui_lang" };
}
return { search_lang, ui_lang };
}
function mapBraveLlmContextResults(
data: BraveLlmContextResponse,
): { url: string; title: string; snippets: string[]; siteName?: string }[] {
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
return genericResults.map((entry) => ({
url: entry.url ?? "",
title: entry.title ?? "",
snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0),
siteName: resolveSiteName(entry.url) || undefined,
}));
}
async function runBraveLlmContextSearch(params: {
query: string;
apiKey: string;
timeoutSeconds: number;
country?: string;
search_lang?: string;
freshness?: string;
}): Promise<{
results: Array<{
url: string;
title: string;
snippets: string[];
siteName?: string;
}>;
sources?: BraveLlmContextResponse["sources"];
}> {
const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
url.searchParams.set("q", params.query);
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
}
return withTrustedWebSearchEndpoint(
{
url: url.toString(),
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
},
},
};
async (res) => {
if (!res.ok) {
const detail = await res.text();
throw new Error(`Brave LLM Context API error (${res.status}): ${detail || res.statusText}`);
}
if (options?.mirrorApiKeyToTopLevel && pluginConfig.apiKey !== undefined) {
next.apiKey = pluginConfig.apiKey;
const data = (await res.json()) as BraveLlmContextResponse;
return { results: mapBraveLlmContextResults(data), sources: data.sources };
},
);
}
async function runBraveWebSearch(params: {
query: string;
count: number;
apiKey: string;
timeoutSeconds: number;
country?: string;
search_lang?: string;
ui_lang?: string;
freshness?: string;
dateAfter?: string;
dateBefore?: string;
}): Promise<Array<Record<string, unknown>>> {
const url = new URL(BRAVE_SEARCH_ENDPOINT);
url.searchParams.set("q", params.query);
url.searchParams.set("count", String(params.count));
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
if (params.ui_lang) {
url.searchParams.set("ui_lang", params.ui_lang);
}
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
} else if (params.dateAfter && params.dateBefore) {
url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`);
} else if (params.dateAfter) {
url.searchParams.set(
"freshness",
`${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`,
);
} else if (params.dateBefore) {
url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`);
}
return next;
return withTrustedWebSearchEndpoint(
{
url: url.toString(),
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
},
},
async (res) => {
if (!res.ok) {
const detail = await res.text();
throw new Error(`Brave Search API error (${res.status}): ${detail || res.statusText}`);
}
const data = (await res.json()) as BraveSearchResponse;
const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : [];
return results.map((entry) => {
const description = entry.description ?? "";
const title = entry.title ?? "";
const url = entry.url ?? "";
return {
title: title ? wrapWebContent(title, "web_search") : "",
url,
description: description ? wrapWebContent(description, "web_search") : "",
published: entry.age || undefined,
siteName: resolveSiteName(url) || undefined,
};
});
},
);
}
function createBraveSchema() {
return Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: MAX_SEARCH_COUNT,
}),
),
country: Type.Optional(
Type.String({
description:
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
}),
),
language: Type.Optional(
Type.String({
description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
}),
),
freshness: Type.Optional(
Type.String({
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
}),
),
date_after: Type.Optional(
Type.String({
description: "Only results published after this date (YYYY-MM-DD).",
}),
),
date_before: Type.Optional(
Type.String({
description: "Only results published before this date (YYYY-MM-DD).",
}),
),
search_lang: Type.Optional(
Type.String({
description:
"Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').",
}),
),
ui_lang: Type.Optional(
Type.String({
description:
"Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.",
}),
),
});
}
function missingBraveKeyPayload() {
return {
error: "missing_brave_api_key",
message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
docs: "https://docs.openclaw.ai/tools/web",
};
}
function createBraveToolDefinition(
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
const braveMode = resolveBraveMode(resolveBraveConfig(searchConfig));
const braveConfig = resolveBraveConfig(searchConfig);
const braveMode = resolveBraveMode(braveConfig);
return {
description:
@@ -109,8 +452,178 @@ function createBraveToolDefinition(
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
parameters: createBraveSchema(),
execute: async (args) => {
const { executeBraveSearch } = await import("./brave-web-search-provider.runtime.js");
return await executeBraveSearch(args, searchConfig);
const apiKey = resolveBraveApiKey(searchConfig);
if (!apiKey) {
return missingBraveKeyPayload();
}
const params = args;
const query = readStringParam(params, "query", { required: true });
const count =
readNumberParam(params, "count", { integer: true }) ??
searchConfig?.maxResults ??
undefined;
const country = normalizeBraveCountry(readStringParam(params, "country"));
const language = readStringParam(params, "language");
const search_lang = readStringParam(params, "search_lang");
const ui_lang = readStringParam(params, "ui_lang");
const normalizedLanguage = normalizeBraveLanguageParams({
search_lang: search_lang || language,
ui_lang,
});
if (normalizedLanguage.invalidField === "search_lang") {
return {
error: "invalid_search_lang",
message:
"search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (normalizedLanguage.invalidField === "ui_lang") {
return {
error: "invalid_ui_lang",
message: "ui_lang must be a language-region locale like 'en-US'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (normalizedLanguage.ui_lang && braveMode === "llm-context") {
return {
error: "unsupported_ui_lang",
message:
"ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const rawFreshness = readStringParam(params, "freshness");
if (rawFreshness && braveMode === "llm-context") {
return {
error: "unsupported_freshness",
message:
"freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "brave") : undefined;
if (rawFreshness && !freshness) {
return {
error: "invalid_freshness",
message: "freshness must be day, week, month, or year.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const rawDateAfter = readStringParam(params, "date_after");
const rawDateBefore = readStringParam(params, "date_before");
if (rawFreshness && (rawDateAfter || rawDateBefore)) {
return {
error: "conflicting_time_filters",
message:
"freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if ((rawDateAfter || rawDateBefore) && braveMode === "llm-context") {
return {
error: "unsupported_date_filter",
message:
"date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.",
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, dateBefore } = parsedDateRange;
const cacheKey = buildSearchCacheKey([
"brave",
braveMode,
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
country,
normalizedLanguage.search_lang,
normalizedLanguage.ui_lang,
freshness,
dateAfter,
dateBefore,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
if (braveMode === "llm-context") {
const { results, sources } = await runBraveLlmContextSearch({
query,
apiKey,
timeoutSeconds,
country: country ?? undefined,
search_lang: normalizedLanguage.search_lang,
freshness,
});
const payload = {
query,
provider: "brave",
mode: "llm-context" as const,
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "brave",
wrapped: true,
},
results: results.map((entry) => ({
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
url: entry.url,
snippets: entry.snippets.map((snippet) => wrapWebContent(snippet, "web_search")),
siteName: entry.siteName,
})),
sources,
};
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
return payload;
}
const results = await runBraveWebSearch({
query,
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
apiKey,
timeoutSeconds,
country: country ?? undefined,
search_lang: normalizedLanguage.search_lang,
ui_lang: normalizedLanguage.ui_lang,
freshness,
dateAfter,
dateBefore,
});
const payload = {
query,
provider: "brave",
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "brave",
wrapped: true,
},
results,
};
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
return payload;
},
};
}
@@ -149,6 +662,7 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
}
export const __testing = {
normalizeFreshness,
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveMode,

View File

@@ -0,0 +1,186 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const pageState = vi.hoisted(() => ({
page: null as Record<string, unknown> | null,
locator: null as Record<string, unknown> | null,
}));
const sessionMocks = vi.hoisted(() => ({
assertPageNavigationCompletedSafely: vi.fn(async () => {}),
ensurePageState: vi.fn(() => ({})),
forceDisconnectPlaywrightForTarget: vi.fn(async () => {}),
getPageForTargetId: vi.fn(async () => {
if (!pageState.page) {
throw new Error("missing page");
}
return pageState.page;
}),
gotoPageWithNavigationGuard: vi.fn(async () => null),
refLocator: vi.fn(() => {
if (!pageState.locator) {
throw new Error("missing locator");
}
return pageState.locator;
}),
restoreRoleRefsForTarget: vi.fn(() => {}),
storeRoleRefsForTarget: vi.fn(() => {}),
}));
const pageCdpMocks = vi.hoisted(() => ({
withPageScopedCdpClient: vi.fn(
async ({ fn }: { fn: (send: () => Promise<unknown>) => unknown }) =>
await fn(async () => ({ nodes: [] })),
),
}));
vi.mock("./pw-session.js", () => sessionMocks);
vi.mock("./pw-session.page-cdp.js", () => pageCdpMocks);
const interactions = await import("./pw-tools-core.interactions.js");
const snapshots = await import("./pw-tools-core.snapshot.js");
describe("pw-tools-core browser SSRF guards", () => {
beforeEach(() => {
pageState.page = null;
pageState.locator = null;
for (const fn of Object.values(sessionMocks)) {
fn.mockClear();
}
for (const fn of Object.values(pageCdpMocks)) {
fn.mockClear();
}
});
it("re-checks click-triggered navigations with the session safety helper", async () => {
pageState.page = { url: vi.fn(() => "https://example.com") };
pageState.locator = { click: vi.fn(async () => {}) };
await interactions.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "tab-1",
ref: "1",
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18792",
page: pageState.page,
response: null,
ssrfPolicy: { allowPrivateNetwork: false },
targetId: "tab-1",
});
});
it("re-checks click-triggered navigations even when no ssrfPolicy is provided", async () => {
pageState.page = { url: vi.fn(() => "https://example.com") };
pageState.locator = { click: vi.fn(async () => {}) };
await interactions.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "tab-1",
ref: "1",
// no ssrfPolicy — guard must still run to enforce default-deny
});
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18792",
page: pageState.page,
response: null,
ssrfPolicy: undefined,
targetId: "tab-1",
});
});
it("re-checks batched click-triggered navigations with the session safety helper", async () => {
pageState.page = { url: vi.fn(() => "https://example.com") };
pageState.locator = { click: vi.fn(async () => {}) };
await interactions.batchViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "tab-1",
actions: [{ kind: "click", ref: "1" }],
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18792",
page: pageState.page,
response: null,
ssrfPolicy: { allowPrivateNetwork: false },
targetId: "tab-1",
});
});
it("re-checks current page URL before snapshotting AI content", async () => {
const snapshotForAI = vi.fn(async () => ({ full: 'button "Save"' }));
pageState.page = {
_snapshotForAI: snapshotForAI,
url: vi.fn(() => "https://example.com"),
};
await snapshots.snapshotAiViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "tab-1",
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18792",
page: pageState.page,
response: null,
ssrfPolicy: { allowPrivateNetwork: false },
targetId: "tab-1",
});
expect(
sessionMocks.assertPageNavigationCompletedSafely.mock.invocationCallOrder[0],
).toBeLessThan(snapshotForAI.mock.invocationCallOrder[0]);
});
it("re-checks current page URL before role snapshots", async () => {
const ariaSnapshot = vi.fn(async () => "");
pageState.page = {
locator: vi.fn(() => ({ ariaSnapshot })),
url: vi.fn(() => "https://example.com"),
};
await snapshots.snapshotRoleViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "tab-1",
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18792",
page: pageState.page,
response: null,
ssrfPolicy: { allowPrivateNetwork: false },
targetId: "tab-1",
});
expect(
sessionMocks.assertPageNavigationCompletedSafely.mock.invocationCallOrder[0],
).toBeLessThan(ariaSnapshot.mock.invocationCallOrder[0]);
});
it("re-checks current page URL before aria snapshots", async () => {
pageState.page = {
url: vi.fn(() => "https://example.com"),
};
await snapshots.snapshotAriaViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "tab-1",
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(sessionMocks.assertPageNavigationCompletedSafely).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18792",
page: pageState.page,
response: null,
ssrfPolicy: { allowPrivateNetwork: false },
targetId: "tab-1",
});
expect(
sessionMocks.assertPageNavigationCompletedSafely.mock.invocationCallOrder[0],
).toBeLessThan(pageCdpMocks.withPageScopedCdpClient.mock.invocationCallOrder[0]);
});
});

View File

@@ -1,7 +1,9 @@
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import type { BrowserActRequest, BrowserFormField } from "./client-actions-core.js";
import { DEFAULT_FILL_FIELD_TYPE } from "./form-fields.js";
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
import {
assertPageNavigationCompletedSafely,
ensurePageState,
forceDisconnectPlaywrightForTarget,
getPageForTargetId,
@@ -63,6 +65,26 @@ async function awaitEvalWithAbort<T>(
}
}
async function assertPostInteractionNavigationSafe(opts: {
cdpUrl: string;
page: Awaited<ReturnType<typeof getPageForTargetId>>;
ssrfPolicy?: SsrFPolicy;
targetId?: string;
}): Promise<void> {
// Run unconditionally: assertPageNavigationCompletedSafely enforces the
// default-deny private-network policy even when no explicit ssrfPolicy is
// provided, so callers that omit ssrfPolicy (e.g. file-chooser hook click,
// batch actions without a threaded policy) still fail closed on
// interaction-triggered navigations to blocked destinations.
await assertPageNavigationCompletedSafely({
cdpUrl: opts.cdpUrl,
page: opts.page,
response: null,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
}
export async function highlightViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
@@ -87,6 +109,7 @@ export async function clickViaPlaywright(opts: {
modifiers?: Array<"Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift">;
delayMs?: number;
timeoutMs?: number;
ssrfPolicy?: SsrFPolicy;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const page = await getRestoredPageForTarget(opts);
@@ -114,6 +137,12 @@ export async function clickViaPlaywright(opts: {
modifiers: opts.modifiers,
});
}
await assertPostInteractionNavigationSafe({
cdpUrl: opts.cdpUrl,
page,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
} catch (err) {
throw toAIFriendlyError(err, label);
}
@@ -201,6 +230,7 @@ export async function pressKeyViaPlaywright(opts: {
targetId?: string;
key: string;
delayMs?: number;
ssrfPolicy?: SsrFPolicy;
}): Promise<void> {
const key = String(opts.key ?? "").trim();
if (!key) {
@@ -211,6 +241,12 @@ export async function pressKeyViaPlaywright(opts: {
await page.keyboard.press(key, {
delay: Math.max(0, Math.floor(opts.delayMs ?? 0)),
});
await assertPostInteractionNavigationSafe({
cdpUrl: opts.cdpUrl,
page,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
}
export async function typeViaPlaywright(opts: {
@@ -222,6 +258,7 @@ export async function typeViaPlaywright(opts: {
submit?: boolean;
slowly?: boolean;
timeoutMs?: number;
ssrfPolicy?: SsrFPolicy;
}): Promise<void> {
const resolved = requireRefOrSelector(opts.ref, opts.selector);
const text = String(opts.text ?? "");
@@ -240,6 +277,12 @@ export async function typeViaPlaywright(opts: {
}
if (opts.submit) {
await locator.press("Enter", { timeout });
await assertPostInteractionNavigationSafe({
cdpUrl: opts.cdpUrl,
page,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
}
} catch (err) {
throw toAIFriendlyError(err, label);
@@ -712,6 +755,7 @@ async function executeSingleAction(
cdpUrl: string,
targetId?: string,
evaluateEnabled?: boolean,
ssrfPolicy?: SsrFPolicy,
depth = 0,
): Promise<void> {
if (depth > MAX_BATCH_DEPTH) {
@@ -732,6 +776,7 @@ async function executeSingleAction(
>,
delayMs: action.delayMs,
timeoutMs: action.timeoutMs,
ssrfPolicy,
});
break;
case "type":
@@ -744,6 +789,7 @@ async function executeSingleAction(
submit: action.submit,
slowly: action.slowly,
timeoutMs: action.timeoutMs,
ssrfPolicy,
});
break;
case "press":
@@ -752,6 +798,7 @@ async function executeSingleAction(
targetId: effectiveTargetId,
key: action.key,
delayMs: action.delayMs,
ssrfPolicy,
});
break;
case "hover":
@@ -851,6 +898,7 @@ async function executeSingleAction(
actions: action.actions,
stopOnError: action.stopOnError,
evaluateEnabled,
ssrfPolicy,
depth: depth + 1,
});
break;
@@ -865,6 +913,7 @@ export async function batchViaPlaywright(opts: {
actions: BrowserActRequest[];
stopOnError?: boolean;
evaluateEnabled?: boolean;
ssrfPolicy?: SsrFPolicy;
depth?: number;
}): Promise<{ results: Array<{ ok: boolean; error?: string }> }> {
const depth = opts.depth ?? 0;
@@ -877,7 +926,14 @@ export async function batchViaPlaywright(opts: {
const results: Array<{ ok: boolean; error?: string }> = [];
for (const action of opts.actions) {
try {
await executeSingleAction(action, opts.cdpUrl, opts.targetId, opts.evaluateEnabled, depth);
await executeSingleAction(
action,
opts.cdpUrl,
opts.targetId,
opts.evaluateEnabled,
opts.ssrfPolicy,
depth,
);
results.push({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);

View File

@@ -23,6 +23,7 @@ export async function snapshotAriaViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
limit?: number;
ssrfPolicy?: SsrFPolicy;
}): Promise<{ nodes: AriaSnapshotNode[] }> {
const limit = Math.max(1, Math.min(2000, Math.floor(opts.limit ?? 500)));
const page = await getPageForTargetId({
@@ -30,6 +31,13 @@ export async function snapshotAriaViaPlaywright(opts: {
targetId: opts.targetId,
});
ensurePageState(page);
await assertPageNavigationCompletedSafely({
cdpUrl: opts.cdpUrl,
page,
response: null,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
const res = (await withPageScopedCdpClient({
cdpUrl: opts.cdpUrl,
page,
@@ -52,12 +60,20 @@ export async function snapshotAiViaPlaywright(opts: {
targetId?: string;
timeoutMs?: number;
maxChars?: number;
ssrfPolicy?: SsrFPolicy;
}): Promise<{ snapshot: string; truncated?: boolean; refs: RoleRefMap }> {
const page = await getPageForTargetId({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
});
ensurePageState(page);
await assertPageNavigationCompletedSafely({
cdpUrl: opts.cdpUrl,
page,
response: null,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
const maybe = page as unknown as WithSnapshotForAI;
if (!maybe._snapshotForAI) {
@@ -98,6 +114,7 @@ export async function snapshotRoleViaPlaywright(opts: {
frameSelector?: string;
refsMode?: "role" | "aria";
options?: RoleSnapshotOptions;
ssrfPolicy?: SsrFPolicy;
}): Promise<{
snapshot: string;
refs: Record<string, { role: string; name?: string; nth?: number }>;
@@ -108,6 +125,13 @@ export async function snapshotRoleViaPlaywright(opts: {
targetId: opts.targetId,
});
ensurePageState(page);
await assertPageNavigationCompletedSafely({
cdpUrl: opts.cdpUrl,
page,
response: null,
ssrfPolicy: opts.ssrfPolicy,
targetId: opts.targetId,
});
if (opts.refsMode === "aria") {
if (opts.selector?.trim() || opts.frameSelector?.trim()) {

View File

@@ -101,6 +101,7 @@ export function registerBrowserAgentActHookRoutes(
cdpUrl,
targetId: tab.targetId,
ref,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
}
}

View File

@@ -481,6 +481,7 @@ export function registerBrowserAgentActRoutes(
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
const ssrfPolicy = ctx.state().resolved.ssrfPolicy;
const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
const profileName = profileCtx.profile.name;
@@ -538,6 +539,7 @@ export function registerBrowserAgentActRoutes(
cdpUrl,
targetId: tab.targetId,
doubleClick,
ssrfPolicy,
};
if (ref) {
clickRequest.ref = ref;
@@ -615,6 +617,7 @@ export function registerBrowserAgentActRoutes(
text,
submit,
slowly,
ssrfPolicy,
};
if (ref) {
typeRequest.ref = ref;
@@ -655,6 +658,7 @@ export function registerBrowserAgentActRoutes(
targetId: tab.targetId,
key,
delayMs: delayMs ?? undefined,
ssrfPolicy,
});
return res.json({ ok: true, targetId: tab.targetId });
}
@@ -1104,6 +1108,7 @@ export function registerBrowserAgentActRoutes(
actions,
stopOnError,
evaluateEnabled,
ssrfPolicy,
});
return res.json({ ok: true, targetId: tab.targetId, results: result.results });
}

View File

@@ -498,6 +498,7 @@ export function registerBrowserAgentSnapshotRoutes(
selector: plan.selectorValue,
frameSelector: plan.frameSelectorValue,
refsMode: plan.refsMode,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
options: {
interactive: plan.interactive ?? undefined,
compact: plan.compact ?? undefined,
@@ -511,6 +512,7 @@ export function registerBrowserAgentSnapshotRoutes(
.snapshotAiViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
...(typeof plan.resolvedMaxChars === "number"
? { maxChars: plan.resolvedMaxChars }
: {}),
@@ -579,6 +581,7 @@ export function registerBrowserAgentSnapshotRoutes(
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
limit: plan.limit,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
});
})()

View File

@@ -43,6 +43,9 @@ describe("browser control server", () => {
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const snapAiZero = (await realFetch(`${base}/snapshot?format=ai&maxChars=0`).then((r) =>
@@ -54,6 +57,9 @@ describe("browser control server", () => {
expect(lastCall).toEqual({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
});
@@ -91,6 +97,9 @@ describe("browser control server", () => {
doubleClick: false,
button: "left",
modifiers: ["Shift"],
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const clickSelector = await realFetch(`${base}/act`, {
@@ -105,6 +114,9 @@ describe("browser control server", () => {
targetId: "abcd1234",
selector: "button.save",
doubleClick: false,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const type = await postJson<{ ok: boolean }>(`${base}/act`, {
@@ -120,6 +132,9 @@ describe("browser control server", () => {
text: "",
submit: false,
slowly: false,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const press = await postJson<{ ok: boolean }>(`${base}/act`, {
@@ -131,6 +146,9 @@ describe("browser control server", () => {
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
key: "Enter",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const hover = await postJson<{ ok: boolean }>(`${base}/act`, {

View File

@@ -1,10 +1,74 @@
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import {
buildVolcModelDefinition,
VOLC_MODEL_GLM_4_7,
VOLC_MODEL_KIMI_K2_5,
VOLC_SHARED_CODING_MODEL_CATALOG,
} from "openclaw/plugin-sdk/volc-model-catalog-shared";
type VolcModelCatalogEntry = {
id: string;
name: string;
reasoning: boolean;
input: ReadonlyArray<ModelDefinitionConfig["input"][number]>;
contextWindow: number;
maxTokens: number;
};
const VOLC_MODEL_KIMI_K2_5 = {
id: "kimi-k2-5-260127",
name: "Kimi K2.5",
reasoning: false,
input: ["text", "image"] as const,
contextWindow: 256000,
maxTokens: 4096,
} as const;
const VOLC_MODEL_GLM_4_7 = {
id: "glm-4-7-251222",
name: "GLM 4.7",
reasoning: false,
input: ["text", "image"] as const,
contextWindow: 200000,
maxTokens: 4096,
} as const;
const VOLC_SHARED_CODING_MODEL_CATALOG = [
{
id: "ark-code-latest",
name: "Ark Coding Plan",
reasoning: false,
input: ["text"] as const,
contextWindow: 256000,
maxTokens: 4096,
},
{
id: "doubao-seed-code",
name: "Doubao Seed Code",
reasoning: false,
input: ["text"] as const,
contextWindow: 256000,
maxTokens: 4096,
},
{
id: "glm-4.7",
name: "GLM 4.7 Coding",
reasoning: false,
input: ["text"] as const,
contextWindow: 200000,
maxTokens: 4096,
},
{
id: "kimi-k2-thinking",
name: "Kimi K2 Thinking",
reasoning: false,
input: ["text"] as const,
contextWindow: 256000,
maxTokens: 4096,
},
{
id: "kimi-k2.5",
name: "Kimi K2.5 Coding",
reasoning: false,
input: ["text"] as const,
contextWindow: 256000,
maxTokens: 4096,
},
] as const;
export const BYTEPLUS_BASE_URL = "https://ark.ap-southeast.bytepluses.com/api/v3";
export const BYTEPLUS_CODING_BASE_URL = "https://ark.ap-southeast.bytepluses.com/api/coding/v3";
@@ -37,6 +101,21 @@ export const BYTEPLUS_CODING_MODEL_CATALOG = VOLC_SHARED_CODING_MODEL_CATALOG;
export type BytePlusCatalogEntry = (typeof BYTEPLUS_MODEL_CATALOG)[number];
export type BytePlusCodingCatalogEntry = (typeof BYTEPLUS_CODING_MODEL_CATALOG)[number];
function buildVolcModelDefinition(
entry: VolcModelCatalogEntry,
cost: ModelDefinitionConfig["cost"],
): ModelDefinitionConfig {
return {
id: entry.id,
name: entry.name,
reasoning: entry.reasoning,
input: [...entry.input],
cost,
contextWindow: entry.contextWindow,
maxTokens: entry.maxTokens,
};
}
export function buildBytePlusModelDefinition(
entry: BytePlusCatalogEntry | BytePlusCodingCatalogEntry,
): ModelDefinitionConfig {

View File

@@ -50,39 +50,4 @@ describe("deepseek provider plugin", () => {
catalog.provider.models?.find((model) => model.id === "deepseek-reasoner")?.reasoning,
).toBe(true);
});
it("publishes configured DeepSeek models through plugin-owned catalog augmentation", async () => {
const provider = await registerSingleProviderPlugin(deepseekPlugin);
expect(
provider.augmentModelCatalog?.({
config: {
models: {
providers: {
deepseek: {
models: [
{
id: "deepseek-chat",
name: "DeepSeek Chat",
input: ["text"],
reasoning: false,
contextWindow: 65536,
},
],
},
},
},
},
} as never),
).toEqual([
{
provider: "deepseek",
id: "deepseek-chat",
name: "DeepSeek Chat",
input: ["text"],
reasoning: false,
contextWindow: 65536,
},
]);
});
});

View File

@@ -1,4 +1,3 @@
import { readConfiguredProviderCatalogEntries } from "openclaw/plugin-sdk/provider-catalog-shared";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { applyDeepSeekConfig, DEEPSEEK_DEFAULT_MODEL_REF } from "./onboard.js";
import { buildDeepSeekProvider } from "./provider-catalog.js";
@@ -35,11 +34,6 @@ export default defineSingleProviderPluginEntry({
catalog: {
buildProvider: buildDeepSeekProvider,
},
augmentModelCatalog: ({ config }) =>
readConfiguredProviderCatalogEntries({
config,
providerId: PROVIDER_ID,
}),
matchesContextOverflowError: ({ errorMessage }) =>
/\bdeepseek\b.*(?:input.*too long|context.*exceed)/i.test(errorMessage),
},

View File

@@ -1 +0,0 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";

View File

@@ -1,9 +1,6 @@
{
"id": "discord",
"channels": ["discord"],
"channelEnvVars": {
"discord": ["DISCORD_BOT_TOKEN"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -6,14 +6,10 @@
"dependencies": {
"@buape/carbon": "0.0.0-beta-20260406003433",
"@discordjs/voice": "^0.19.2",
"@snazzah/davey": "^0.1.11",
"discord-api-types": "^0.38.44",
"https-proxy-agent": "^9.0.0",
"opusscript": "^0.1.1"
},
"optionalDependencies": {
"@discordjs/opus": "^0.10.0"
},
"devDependencies": {
"openclaw": "workspace:*"
},

View File

@@ -1,4 +0,0 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-config-contract.js";

View File

@@ -1,4 +0,0 @@
export {
collectUnsupportedSecretRefConfigCandidates,
unsupportedSecretRefSurfacePatterns,
} from "./src/security-contract.js";

View File

@@ -45,7 +45,6 @@ import {
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
} from "./group-policy.js";
import { isLikelyDiscordVideoMedia } from "./media-detection.js";
import {
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
@@ -127,6 +126,33 @@ function loadDiscordCarbonModule() {
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000;
const DISCORD_VIDEO_MEDIA_EXTENSIONS = new Set([".avi", ".m4v", ".mkv", ".mov", ".mp4", ".webm"]);
function normalizeMediaPathForExtension(mediaUrl: string): string {
const trimmed = mediaUrl.trim();
if (!trimmed) {
return "";
}
try {
const parsed = new URL(trimmed);
return parsed.pathname.toLowerCase();
} catch {
const withoutHash = trimmed.split("#", 1)[0] ?? trimmed;
const withoutQuery = withoutHash.split("?", 1)[0] ?? withoutHash;
return withoutQuery.toLowerCase();
}
}
function isLikelyDiscordVideoMedia(mediaUrl: string): boolean {
const normalized = normalizeMediaPathForExtension(mediaUrl);
for (const ext of DISCORD_VIDEO_MEDIA_EXTENSIONS) {
if (normalized.endsWith(ext)) {
return true;
}
}
return false;
}
function resolveDiscordAttachedOutboundTarget(params: {
to: string;
threadId?: string | number | null;

View File

@@ -4,7 +4,6 @@ import type {
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
hasLegacyAccountStreamingAliases,
hasLegacyStreamingAliases,
normalizeLegacyDmAliases,
normalizeLegacyStreamingAliases,
@@ -21,6 +20,14 @@ function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
return hasLegacyStreamingAliases(value, { includePreviewChunk: true });
}
function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacyDiscordStreamingAliases(account));
}
const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const;
function hasLegacyTtsProviderKeys(value: unknown): boolean {
@@ -129,7 +136,7 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
match: (value) => hasLegacyAccountStreamingAliases(value, hasLegacyDiscordStreamingAliases),
match: hasLegacyDiscordAccountStreamingAliases,
},
{
path: ["channels", "discord", "voice", "tts"],

View File

@@ -3,7 +3,6 @@ import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime-doctor";
import { normalizeCompatibilityConfig as normalizeDiscordCompatibilityConfig } from "./doctor-contract.js";
import { DISCORD_LEGACY_CONFIG_RULES } from "./doctor-shared.js";
import { isDiscordMutableAllowEntry } from "./security-doctor.js";
type DiscordNumericIdHit = { path: string; entry: number; safe: boolean };
@@ -23,6 +22,27 @@ function sanitizeForLog(value: string): string {
return value.replace(/\p{Cc}+/gu, " ").trim();
}
function isDiscordMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const maybeMentionId = text.replace(/^<@!?/, "").replace(/>$/, "");
if (/^\d+$/.test(maybeMentionId)) {
return false;
}
for (const prefix of ["discord:", "user:", "pk:"]) {
if (!text.startsWith(prefix)) {
continue;
}
return text.slice(prefix.length).trim().length === 0;
}
return true;
}
function collectDiscordAccountScopes(
cfg: OpenClawConfig,
): Array<{ prefix: string; account: Record<string, unknown> }> {

View File

@@ -1,26 +0,0 @@
const DISCORD_VIDEO_MEDIA_EXTENSIONS = new Set([".avi", ".m4v", ".mkv", ".mov", ".mp4", ".webm"]);
function normalizeMediaPathForExtension(mediaUrl: string): string {
const trimmed = mediaUrl.trim();
if (!trimmed) {
return "";
}
try {
const parsed = new URL(trimmed);
return parsed.pathname.toLowerCase();
} catch {
const withoutHash = trimmed.split("#", 1)[0] ?? trimmed;
const withoutQuery = withoutHash.split("?", 1)[0] ?? withoutHash;
return withoutQuery.toLowerCase();
}
}
export function isLikelyDiscordVideoMedia(mediaUrl: string): boolean {
const normalized = normalizeMediaPathForExtension(mediaUrl);
for (const ext of DISCORD_VIDEO_MEDIA_EXTENSIONS) {
if (normalized.endsWith(ext)) {
return true;
}
}
return false;
}

View File

@@ -20,7 +20,6 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordAccount } from "../accounts.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import { isLikelyDiscordVideoMedia } from "../media-detection.js";
import { createDiscordRetryRunner } from "../retry.js";
import { sendMessageDiscord, sendVoiceMessageDiscord, sendWebhookMessageDiscord } from "../send.js";
import { sendDiscordText } from "../send.shared.js";
@@ -41,6 +40,8 @@ export type DiscordThreadBindingLookup = {
type ResolvedRetryConfig = Required<RetryConfig>;
const DISCORD_VIDEO_MEDIA_EXTENSIONS = new Set([".avi", ".m4v", ".mkv", ".mov", ".mp4", ".webm"]);
const DISCORD_DELIVERY_RETRY_DEFAULTS: ResolvedRetryConfig = {
attempts: 3,
minDelayMs: 1000,
@@ -76,6 +77,31 @@ function resolveDeliveryRetryConfig(retry?: RetryConfig): ResolvedRetryConfig {
return resolveRetryConfig(DISCORD_DELIVERY_RETRY_DEFAULTS, retry);
}
function normalizeMediaPathForExtension(mediaUrl: string): string {
const trimmed = mediaUrl.trim();
if (!trimmed) {
return "";
}
try {
const parsed = new URL(trimmed);
return parsed.pathname.toLowerCase();
} catch {
const withoutHash = trimmed.split("#", 1)[0] ?? trimmed;
const withoutQuery = withoutHash.split("?", 1)[0] ?? withoutHash;
return withoutQuery.toLowerCase();
}
}
function isLikelyDiscordVideoMedia(mediaUrl: string): boolean {
const normalized = normalizeMediaPathForExtension(mediaUrl);
for (const ext of DISCORD_VIDEO_MEDIA_EXTENSIONS) {
if (normalized.endsWith(ext)) {
return true;
}
}
return false;
}
async function sendWithRetry(
fn: () => Promise<unknown>,
retryConfig: ResolvedRetryConfig,
@@ -87,114 +113,6 @@ async function sendWithRetry(
});
}
async function sendDiscordMediaOnly(params: {
target: string;
cfg: OpenClawConfig;
token: string;
rest?: RequestClient;
mediaUrl: string;
accountId?: string;
mediaLocalRoots?: readonly string[];
replyTo?: string;
retryConfig: ResolvedRetryConfig;
}): Promise<void> {
await sendWithRetry(
() =>
sendMessageDiscord(params.target, "", {
cfg: params.cfg,
token: params.token,
rest: params.rest,
mediaUrl: params.mediaUrl,
accountId: params.accountId,
mediaLocalRoots: params.mediaLocalRoots,
replyTo: params.replyTo,
}),
params.retryConfig,
);
}
async function sendDiscordMediaBatch(params: {
target: string;
cfg: OpenClawConfig;
token: string;
rest?: RequestClient;
mediaUrls: string[];
accountId?: string;
mediaLocalRoots?: readonly string[];
replyTo: () => string | undefined;
retryConfig: ResolvedRetryConfig;
}): Promise<void> {
await sendMediaWithLeadingCaption({
mediaUrls: params.mediaUrls,
caption: "",
send: async ({ mediaUrl }) => {
await sendDiscordMediaOnly({
target: params.target,
cfg: params.cfg,
token: params.token,
rest: params.rest,
mediaUrl,
accountId: params.accountId,
mediaLocalRoots: params.mediaLocalRoots,
replyTo: params.replyTo(),
retryConfig: params.retryConfig,
});
},
});
}
async function sendDiscordPayloadText(params: {
cfg: OpenClawConfig;
target: string;
text: string;
token: string;
rest?: RequestClient;
accountId?: string;
textLimit?: number;
maxLinesPerMessage?: number;
binding?: DiscordThreadBindingLookupRecord;
chunkMode?: ChunkMode;
username?: string;
avatarUrl?: string;
channelId?: string;
request?: RetryRunner;
retryConfig: ResolvedRetryConfig;
resolveReplyTo: () => string | undefined;
}): Promise<void> {
const mode = params.chunkMode ?? "length";
const chunkLimit = Math.min(params.textLimit ?? 2000, 2000);
const chunks = resolveTextChunksWithFallback(
params.text,
chunkDiscordTextWithMode(params.text, {
maxChars: chunkLimit,
maxLines: params.maxLinesPerMessage,
chunkMode: mode,
}),
);
for (const chunk of chunks) {
if (!chunk.trim()) {
continue;
}
await sendDiscordChunkWithFallback({
cfg: params.cfg,
target: params.target,
text: chunk,
token: params.token,
rest: params.rest,
accountId: params.accountId,
maxLinesPerMessage: params.maxLinesPerMessage,
replyTo: params.resolveReplyTo(),
binding: params.binding,
chunkMode: params.chunkMode,
username: params.username,
avatarUrl: params.avatarUrl,
channelId: params.channelId,
request: params.request,
retryConfig: params.retryConfig,
});
}
}
function resolveTargetChannelId(target: string): string | undefined {
if (!target.startsWith("channel:")) {
return undefined;
@@ -370,6 +288,7 @@ export async function deliverDiscordReply(params: {
threadBindings?: DiscordThreadBindingLookup;
mediaLocalRoots?: readonly string[];
}) {
const chunkLimit = Math.min(params.textLimit, 2000);
const replyTo = params.replyToId?.trim() || undefined;
const replyToMode = params.replyToMode ?? "all";
const replyOnce = isSingleUseReplyToMode(replyToMode);
@@ -416,40 +335,38 @@ export async function deliverDiscordReply(params: {
if (!reply.hasContent) {
continue;
}
const sendReplyText = async () =>
sendDiscordPayloadText({
cfg: params.cfg,
target: params.target,
text: reply.text,
token: params.token,
rest: params.rest,
accountId: params.accountId,
textLimit: params.textLimit,
maxLinesPerMessage: params.maxLinesPerMessage,
resolveReplyTo: resolvePayloadReplyTo,
binding,
chunkMode: params.chunkMode,
username: persona.username,
avatarUrl: persona.avatarUrl,
channelId,
request,
retryConfig,
});
const sendReplyMediaBatch = async (mediaUrls: string[]) =>
sendDiscordMediaBatch({
target: params.target,
cfg: params.cfg,
token: params.token,
rest: params.rest,
mediaUrls,
accountId: params.accountId,
mediaLocalRoots: params.mediaLocalRoots,
replyTo: resolvePayloadReplyTo,
retryConfig,
});
if (!reply.hasMedia) {
await sendReplyText();
if (reply.text.trim()) {
const mode = params.chunkMode ?? "length";
const chunks = resolveTextChunksWithFallback(
reply.text,
chunkDiscordTextWithMode(reply.text, {
maxChars: chunkLimit,
maxLines: params.maxLinesPerMessage,
chunkMode: mode,
}),
);
for (const chunk of chunks) {
if (!chunk.trim()) {
continue;
}
const replyTo = resolvePayloadReplyTo();
await sendDiscordChunkWithFallback({
cfg: params.cfg,
target: params.target,
text: chunk,
token: params.token,
rest: params.rest,
accountId: params.accountId,
maxLinesPerMessage: params.maxLinesPerMessage,
replyTo,
binding,
chunkMode: params.chunkMode,
username: persona.username,
avatarUrl: persona.avatarUrl,
channelId,
request,
retryConfig,
});
deliveredAny = true;
}
continue;
@@ -471,9 +388,44 @@ export async function deliverDiscordReply(params: {
});
deliveredAny = true;
// Voice messages cannot include text; send remaining text separately if present.
await sendReplyText();
await sendDiscordChunkWithFallback({
cfg: params.cfg,
target: params.target,
text: reply.text,
token: params.token,
rest: params.rest,
accountId: params.accountId,
maxLinesPerMessage: params.maxLinesPerMessage,
replyTo: resolvePayloadReplyTo(),
binding,
chunkMode: params.chunkMode,
username: persona.username,
avatarUrl: persona.avatarUrl,
channelId,
request,
retryConfig,
});
// Additional media items are sent as regular attachments (voice is single-file only).
await sendReplyMediaBatch(reply.mediaUrls.slice(1));
await sendMediaWithLeadingCaption({
mediaUrls: reply.mediaUrls.slice(1),
caption: "",
send: async ({ mediaUrl }) => {
const replyTo = resolvePayloadReplyTo();
await sendWithRetry(
() =>
sendMessageDiscord(params.target, "", {
cfg: params.cfg,
token: params.token,
rest: params.rest,
mediaUrl,
accountId: params.accountId,
mediaLocalRoots: params.mediaLocalRoots,
replyTo,
}),
retryConfig,
);
},
});
continue;
}
@@ -481,8 +433,43 @@ export async function deliverDiscordReply(params: {
reply.text.trim().length > 0 &&
reply.mediaUrls.some((mediaUrl) => isLikelyDiscordVideoMedia(mediaUrl));
if (shouldSplitVideoMediaReply) {
await sendReplyText();
await sendReplyMediaBatch(reply.mediaUrls);
await sendDiscordChunkWithFallback({
cfg: params.cfg,
target: params.target,
text: reply.text,
token: params.token,
rest: params.rest,
accountId: params.accountId,
maxLinesPerMessage: params.maxLinesPerMessage,
replyTo: resolvePayloadReplyTo(),
binding,
chunkMode: params.chunkMode,
username: persona.username,
avatarUrl: persona.avatarUrl,
channelId,
request,
retryConfig,
});
await sendMediaWithLeadingCaption({
mediaUrls: reply.mediaUrls,
caption: "",
send: async ({ mediaUrl }) => {
const replyTo = resolvePayloadReplyTo();
await sendWithRetry(
() =>
sendMessageDiscord(params.target, "", {
cfg: params.cfg,
token: params.token,
rest: params.rest,
mediaUrl,
accountId: params.accountId,
mediaLocalRoots: params.mediaLocalRoots,
replyTo,
}),
retryConfig,
);
},
});
deliveredAny = true;
continue;
}

View File

@@ -1,5 +1,6 @@
import { readAcpSessionEntry, type AcpSessionStoreEntry } from "openclaw/plugin-sdk/acp-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { parseDiscordTarget } from "../targets.js";
import { resolveChannelIdForBinding } from "./thread-bindings.discord-api.js";
import { getThreadBindingManager } from "./thread-bindings.manager.js";
@@ -7,19 +8,17 @@ import {
resolveThreadBindingIntroText,
resolveThreadBindingThreadName,
} from "./thread-bindings.messages.js";
import { resolveBindingIdsForTargetSession } from "./thread-bindings.session-shared.js";
export {
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
} from "./thread-bindings.session-updates.js";
import {
BINDINGS_BY_THREAD_ID,
MANAGERS_BY_ACCOUNT_ID,
ensureBindingsLoaded,
getThreadBindingToken,
normalizeThreadId,
rememberRecentUnboundWebhookEcho,
removeBindingRecord,
resolveBindingIdsForSession,
saveBindingsToDisk,
setBindingRecord,
shouldPersistBindingMutations,
} from "./thread-bindings.state.js";
import type { ThreadBindingRecord, ThreadBindingTargetKind } from "./thread-bindings.types.js";
@@ -74,6 +73,55 @@ async function mapWithConcurrency<TItem, TResult>(params: {
return params.items.map((_item, index) => resultsByIndex.get(index)!);
}
function normalizeNonNegativeMs(raw: number): number {
if (!Number.isFinite(raw)) {
return 0;
}
return Math.max(0, Math.floor(raw));
}
function resolveBindingIdsForTargetSession(params: {
targetSessionKey: string;
accountId?: string;
targetKind?: ThreadBindingTargetKind;
}) {
ensureBindingsLoaded();
const targetSessionKey = params.targetSessionKey.trim();
if (!targetSessionKey) {
return [];
}
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
return resolveBindingIdsForSession({
targetSessionKey,
accountId,
targetKind: params.targetKind,
});
}
function updateBindingsForTargetSession(
ids: string[],
update: (existing: ThreadBindingRecord, now: number) => ThreadBindingRecord,
) {
if (ids.length === 0) {
return [];
}
const now = Date.now();
const updated: ThreadBindingRecord[] = [];
for (const bindingKey of ids) {
const existing = BINDINGS_BY_THREAD_ID.get(bindingKey);
if (!existing) {
continue;
}
const nextRecord = update(existing, now);
setBindingRecord(nextRecord);
updated.push(nextRecord);
}
if (updated.length > 0 && shouldPersistBindingMutations()) {
saveBindingsToDisk({ force: true });
}
return updated;
}
export function listThreadBindingsForAccount(accountId?: string): ThreadBindingRecord[] {
const manager = getThreadBindingManager(accountId);
if (!manager) {
@@ -219,6 +267,35 @@ export function unbindThreadBindingsBySessionKey(params: {
return removed;
}
export function setThreadBindingIdleTimeoutBySessionKey(params: {
targetSessionKey: string;
accountId?: string;
idleTimeoutMs: number;
}): ThreadBindingRecord[] {
const ids = resolveBindingIdsForTargetSession(params);
const idleTimeoutMs = normalizeNonNegativeMs(params.idleTimeoutMs);
return updateBindingsForTargetSession(ids, (existing, now) => ({
...existing,
idleTimeoutMs,
lastActivityAt: now,
}));
}
export function setThreadBindingMaxAgeBySessionKey(params: {
targetSessionKey: string;
accountId?: string;
maxAgeMs: number;
}): ThreadBindingRecord[] {
const ids = resolveBindingIdsForTargetSession(params);
const maxAgeMs = normalizeNonNegativeMs(params.maxAgeMs);
return updateBindingsForTargetSession(ids, (existing, now) => ({
...existing,
maxAgeMs,
boundAt: now,
lastActivityAt: now,
}));
}
function resolveStoredAcpBindingHealth(params: {
session: AcpSessionStoreEntry;
}): AcpThreadBindingHealthStatus {

View File

@@ -1,59 +0,0 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
BINDINGS_BY_THREAD_ID,
ensureBindingsLoaded,
resolveBindingIdsForSession,
saveBindingsToDisk,
setBindingRecord,
shouldPersistBindingMutations,
} from "./thread-bindings.state.js";
import type { ThreadBindingRecord, ThreadBindingTargetKind } from "./thread-bindings.types.js";
export function normalizeNonNegativeMs(raw: number): number {
if (!Number.isFinite(raw)) {
return 0;
}
return Math.max(0, Math.floor(raw));
}
export function resolveBindingIdsForTargetSession(params: {
targetSessionKey: string;
accountId?: string;
targetKind?: ThreadBindingTargetKind;
}) {
ensureBindingsLoaded();
const targetSessionKey = params.targetSessionKey.trim();
if (!targetSessionKey) {
return [];
}
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
return resolveBindingIdsForSession({
targetSessionKey,
accountId,
targetKind: params.targetKind,
});
}
export function updateBindingsForTargetSession(
ids: string[],
update: (existing: ThreadBindingRecord, now: number) => ThreadBindingRecord,
) {
if (ids.length === 0) {
return [];
}
const now = Date.now();
const updated: ThreadBindingRecord[] = [];
for (const bindingKey of ids) {
const existing = BINDINGS_BY_THREAD_ID.get(bindingKey);
if (!existing) {
continue;
}
const nextRecord = update(existing, now);
setBindingRecord(nextRecord);
updated.push(nextRecord);
}
if (updated.length > 0 && shouldPersistBindingMutations()) {
saveBindingsToDisk({ force: true });
}
return updated;
}

View File

@@ -1,9 +1,62 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
normalizeNonNegativeMs,
resolveBindingIdsForTargetSession,
updateBindingsForTargetSession,
} from "./thread-bindings.session-shared.js";
import type { ThreadBindingRecord } from "./thread-bindings.types.js";
BINDINGS_BY_THREAD_ID,
ensureBindingsLoaded,
resolveBindingIdsForSession,
saveBindingsToDisk,
setBindingRecord,
shouldPersistBindingMutations,
} from "./thread-bindings.state.js";
import type { ThreadBindingRecord, ThreadBindingTargetKind } from "./thread-bindings.types.js";
function normalizeNonNegativeMs(raw: number): number {
if (!Number.isFinite(raw)) {
return 0;
}
return Math.max(0, Math.floor(raw));
}
function resolveBindingIdsForTargetSession(params: {
targetSessionKey: string;
accountId?: string;
targetKind?: ThreadBindingTargetKind;
}) {
ensureBindingsLoaded();
const targetSessionKey = params.targetSessionKey.trim();
if (!targetSessionKey) {
return [];
}
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
return resolveBindingIdsForSession({
targetSessionKey,
accountId,
targetKind: params.targetKind,
});
}
function updateBindingsForTargetSession(
ids: string[],
update: (existing: ThreadBindingRecord, now: number) => ThreadBindingRecord,
) {
if (ids.length === 0) {
return [];
}
const now = Date.now();
const updated: ThreadBindingRecord[] = [];
for (const bindingKey of ids) {
const existing = BINDINGS_BY_THREAD_ID.get(bindingKey);
if (!existing) {
continue;
}
const nextRecord = update(existing, now);
setBindingRecord(nextRecord);
updated.push(nextRecord);
}
if (updated.length > 0 && shouldPersistBindingMutations()) {
saveBindingsToDisk({ force: true });
}
return updated;
}
export function setThreadBindingIdleTimeoutBySessionKey(params: {
targetSessionKey: string;

View File

@@ -1,7 +1,34 @@
import { getChannelStreamingConfigObject } from "openclaw/plugin-sdk/channel-streaming";
export type DiscordPreviewStreamMode = "off" | "partial" | "block";
function parsePreviewStreamingMode(value: unknown): DiscordPreviewStreamMode | undefined {
return value === "off" || value === "partial" || value === "block" ? value : undefined;
function normalizeStreamingMode(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
return normalized || null;
}
function parseStreamingMode(value: unknown): "off" | "partial" | "block" | "progress" | null {
const normalized = normalizeStreamingMode(value);
if (
normalized === "off" ||
normalized === "partial" ||
normalized === "block" ||
normalized === "progress"
) {
return normalized;
}
return null;
}
function parseDiscordPreviewStreamMode(value: unknown): DiscordPreviewStreamMode | null {
const parsed = parseStreamingMode(value);
if (!parsed) {
return null;
}
return parsed === "progress" ? "partial" : parsed;
}
export function resolveDiscordPreviewStreamMode(
@@ -10,18 +37,14 @@ export function resolveDiscordPreviewStreamMode(
streaming?: unknown;
} = {},
): DiscordPreviewStreamMode {
const parsedStreaming =
params.streaming && typeof params.streaming === "object" && !Array.isArray(params.streaming)
? parsePreviewStreamingMode(
(params.streaming as Record<string, unknown>).mode ??
(params.streaming as Record<string, unknown>).streaming,
)
: parsePreviewStreamingMode(params.streaming);
const parsedStreaming = parseDiscordPreviewStreamMode(
getChannelStreamingConfigObject(params)?.mode ?? params.streaming,
);
if (parsedStreaming) {
return parsedStreaming;
}
const legacy = parsePreviewStreamingMode(params.streamMode);
const legacy = parseDiscordPreviewStreamMode(params.streamMode);
if (legacy) {
return legacy;
}

View File

@@ -9,7 +9,7 @@ import {
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/channel-secret-runtime";
} from "openclaw/plugin-sdk/security-runtime";
export const secretTargetRegistryEntries = [
{

View File

@@ -1,4 +1,3 @@
import { coerceNativeSetting, normalizeAllowFromList } from "openclaw/plugin-sdk/channel-policy";
import {
isDangerousNameMatchingEnabled,
resolveNativeCommandsEnabled,
@@ -7,7 +6,41 @@ import {
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
import type { ResolvedDiscordAccount } from "./accounts.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { isDiscordMutableAllowEntry } from "./security-doctor.js";
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
if (!Array.isArray(list)) {
return [];
}
return list.map((value) => String(value).trim()).filter(Boolean);
}
function coerceNativeSetting(value: unknown): boolean | "auto" | undefined {
if (value === true || value === false || value === "auto") {
return value;
}
return undefined;
}
export function isDiscordMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const maybeMentionId = text.replace(/^<@!?/, "").replace(/>$/, "");
if (/^\d+$/.test(maybeMentionId)) {
return false;
}
for (const prefix of ["discord:", "user:", "pk:"]) {
if (!text.startsWith(prefix)) {
continue;
}
return text.slice(prefix.length).trim().length === 0;
}
return true;
}
function addDiscordNameBasedEntries(params: {
target: Set<string>;

View File

@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { isDiscordMutableAllowEntry } from "./security-doctor.js";
describe("discord security doctor helpers", () => {
it("rejects stable ids and wildcard forms", () => {
expect(isDiscordMutableAllowEntry("*")).toBe(false);
expect(isDiscordMutableAllowEntry("123456789")).toBe(false);
expect(isDiscordMutableAllowEntry("<@123456789>")).toBe(false);
expect(isDiscordMutableAllowEntry("user:123456789")).toBe(false);
expect(isDiscordMutableAllowEntry("pk:123456789")).toBe(false);
});
it("flags freeform names but not prefixed stable-id namespaces", () => {
expect(isDiscordMutableAllowEntry("alice")).toBe(true);
expect(isDiscordMutableAllowEntry("discord:alice")).toBe(false);
expect(isDiscordMutableAllowEntry("user:alice")).toBe(false);
expect(isDiscordMutableAllowEntry("pk:alice")).toBe(false);
});
it("treats empty prefixed entries as mutable placeholders", () => {
expect(isDiscordMutableAllowEntry("discord:")).toBe(true);
expect(isDiscordMutableAllowEntry("user:")).toBe(true);
expect(isDiscordMutableAllowEntry("pk:")).toBe(true);
});
});

View File

@@ -1,20 +0,0 @@
export function isDiscordMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const maybeMentionId = text.replace(/^<@!?/, "").replace(/>$/, "");
if (/^\d+$/.test(maybeMentionId)) {
return false;
}
for (const prefix of ["discord:", "user:", "pk:"]) {
if (!text.startsWith(prefix)) {
continue;
}
return text.slice(prefix.length).trim().length === 0;
}
return true;
}

View File

@@ -432,10 +432,18 @@ export async function sendStickerDiscord(
stickerIds: string[],
opts: DiscordSendOpts & { content?: string } = {},
): Promise<DiscordSendResult> {
const { rest, request, channelId, rewrittenContent } = await resolveDiscordStructuredSendContext(
to,
opts,
);
const cfg = opts.cfg ?? loadConfig();
const accountInfo = resolveDiscordAccount({
cfg,
accountId: opts.accountId,
});
const { rest, request, channelId } = await resolveDiscordSendTarget(to, opts);
const content = opts.content?.trim();
const rewrittenContent = content
? rewriteDiscordKnownMentions(content, {
accountId: accountInfo.accountId,
})
: undefined;
const stickers = normalizeStickerIds(stickerIds);
const res = (await request(
() =>
@@ -455,10 +463,18 @@ export async function sendPollDiscord(
poll: PollInput,
opts: DiscordSendOpts & { content?: string } = {},
): Promise<DiscordSendResult> {
const { rest, request, channelId, rewrittenContent } = await resolveDiscordStructuredSendContext(
to,
opts,
);
const cfg = opts.cfg ?? loadConfig();
const accountInfo = resolveDiscordAccount({
cfg,
accountId: opts.accountId,
});
const { rest, request, channelId } = await resolveDiscordSendTarget(to, opts);
const content = opts.content?.trim();
const rewrittenContent = content
? rewriteDiscordKnownMentions(content, {
accountId: accountInfo.accountId,
})
: undefined;
if (poll.durationSeconds !== undefined) {
throw new Error("Discord polls do not support durationSeconds; use durationHours");
}
@@ -478,30 +494,6 @@ export async function sendPollDiscord(
return toDiscordSendResult(res, channelId);
}
async function resolveDiscordStructuredSendContext(
to: string,
opts: DiscordSendOpts & { content?: string },
): Promise<{
rest: RequestClient;
request: DiscordClientRequest;
channelId: string;
rewrittenContent?: string;
}> {
const cfg = opts.cfg ?? loadConfig();
const accountInfo = resolveDiscordAccount({
cfg,
accountId: opts.accountId,
});
const { rest, request, channelId } = await resolveDiscordSendTarget(to, opts);
const content = opts.content?.trim();
const rewrittenContent = content
? rewriteDiscordKnownMentions(content, {
accountId: accountInfo.accountId,
})
: undefined;
return { rest, request, channelId, rewrittenContent };
}
type VoiceMessageOpts = {
cfg?: OpenClawConfig;
token?: string;

View File

@@ -1,48 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
beginVoiceCapture,
clearVoiceCaptureFinalizeTimer,
createVoiceCaptureState,
finishVoiceCapture,
scheduleVoiceCaptureFinalize,
} from "./capture-state.js";
describe("voice capture state", () => {
it("increments generations per speaker", () => {
const state = createVoiceCaptureState();
const first = beginVoiceCapture(state, "u1", { destroy: vi.fn() } as never);
finishVoiceCapture(state, "u1", first);
const second = beginVoiceCapture(state, "u1", { destroy: vi.fn() } as never);
expect(first).toBe(1);
expect(second).toBe(2);
});
it("clears active speaker state before destroying a finalized capture", async () => {
vi.useFakeTimers();
try {
const state = createVoiceCaptureState();
const destroy = vi.fn(() => {
expect(state.activeSpeakers.has("u1")).toBe(false);
expect(state.activeCaptureStreams.has("u1")).toBe(false);
});
beginVoiceCapture(state, "u1", { destroy } as never);
expect(scheduleVoiceCaptureFinalize({ state, userId: "u1", delayMs: 1_200 })).toBe(true);
await vi.advanceTimersByTimeAsync(1_200);
expect(destroy).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("lets a pending finalize be canceled for the same generation", () => {
const state = createVoiceCaptureState();
const generation = beginVoiceCapture(state, "u1", { destroy: vi.fn() } as never);
expect(scheduleVoiceCaptureFinalize({ state, userId: "u1", delayMs: 1_200 })).toBe(true);
expect(clearVoiceCaptureFinalizeTimer(state, "u1", generation)).toBe(true);
expect(state.captureFinalizeTimers.has("u1")).toBe(false);
});
});

View File

@@ -1,120 +0,0 @@
import type { Readable } from "node:stream";
export type VoiceCaptureEntry = {
generation: number;
stream: Readable;
};
export type VoiceCaptureFinalizeTimer = {
generation: number;
timer: ReturnType<typeof setTimeout>;
};
export type VoiceCaptureState = {
activeSpeakers: Set<string>;
activeCaptureStreams: Map<string, VoiceCaptureEntry>;
captureFinalizeTimers: Map<string, VoiceCaptureFinalizeTimer>;
captureGenerations: Map<string, number>;
};
export function createVoiceCaptureState(): VoiceCaptureState {
return {
activeSpeakers: new Set(),
activeCaptureStreams: new Map(),
captureFinalizeTimers: new Map(),
captureGenerations: new Map(),
};
}
export function stopVoiceCaptureState(state: VoiceCaptureState): void {
for (const { timer } of state.captureFinalizeTimers.values()) {
clearTimeout(timer);
}
state.captureFinalizeTimers.clear();
for (const { stream } of state.activeCaptureStreams.values()) {
stream.destroy();
}
state.activeCaptureStreams.clear();
state.captureGenerations.clear();
state.activeSpeakers.clear();
}
export function getActiveVoiceCapture(
state: VoiceCaptureState,
userId: string,
): VoiceCaptureEntry | undefined {
return state.activeCaptureStreams.get(userId);
}
export function isVoiceCaptureActive(state: VoiceCaptureState, userId: string): boolean {
return state.activeSpeakers.has(userId);
}
export function clearVoiceCaptureFinalizeTimer(
state: VoiceCaptureState,
userId: string,
generation?: number,
): boolean {
const scheduled = state.captureFinalizeTimers.get(userId);
if (!scheduled || (generation !== undefined && scheduled.generation !== generation)) {
return false;
}
clearTimeout(scheduled.timer);
state.captureFinalizeTimers.delete(userId);
return true;
}
export function beginVoiceCapture(
state: VoiceCaptureState,
userId: string,
stream: Readable,
): number {
const generation = (state.captureGenerations.get(userId) ?? 0) + 1;
state.captureGenerations.set(userId, generation);
state.activeSpeakers.add(userId);
state.activeCaptureStreams.set(userId, { generation, stream });
clearVoiceCaptureFinalizeTimer(state, userId, generation);
return generation;
}
export function finishVoiceCapture(
state: VoiceCaptureState,
userId: string,
generation: number,
): boolean {
clearVoiceCaptureFinalizeTimer(state, userId, generation);
const activeCapture = state.activeCaptureStreams.get(userId);
if (activeCapture?.generation !== generation) {
return false;
}
state.activeCaptureStreams.delete(userId);
state.activeSpeakers.delete(userId);
return true;
}
export function scheduleVoiceCaptureFinalize(params: {
state: VoiceCaptureState;
userId: string;
delayMs: number;
onFinalize?: (capture: VoiceCaptureEntry) => void;
}): boolean {
const { state, userId, delayMs, onFinalize } = params;
const capture = state.activeCaptureStreams.get(userId);
if (!capture) {
return false;
}
clearVoiceCaptureFinalizeTimer(state, userId, capture.generation);
const timer = setTimeout(() => {
const activeCapture = state.activeCaptureStreams.get(userId);
if (!activeCapture || activeCapture.generation !== capture.generation) {
return;
}
state.captureFinalizeTimers.delete(userId);
state.activeCaptureStreams.delete(userId);
state.activeSpeakers.delete(userId);
onFinalize?.(activeCapture);
activeCapture.stream.destroy();
}, delayMs);
state.captureFinalizeTimers.set(userId, { generation: capture.generation, timer });
return true;
}

View File

@@ -1,7 +1,5 @@
import { ChannelType } from "@buape/carbon";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createVoiceCaptureState } from "./capture-state.js";
import { createVoiceReceiveRecoveryState } from "./receive-recovery.js";
const {
createConnectionMock,
@@ -26,26 +24,11 @@ const {
};
subscribe: ReturnType<typeof vi.fn>;
};
state: {
status: string;
networking: {
state: {
code: string;
dave: {
session: {
setPassthroughMode: ReturnType<typeof vi.fn>;
};
};
};
};
};
daveSetPassthroughMode: ReturnType<typeof vi.fn>;
handlers: Map<string, EventHandler>;
};
const createConnectionMock = (): MockConnection => {
const handlers = new Map<string, EventHandler>();
const daveSetPassthroughMode = vi.fn();
const connection: MockConnection = {
destroy: vi.fn(),
subscribe: vi.fn(),
@@ -60,24 +43,9 @@ const {
},
subscribe: vi.fn(() => ({
on: vi.fn(),
destroy: vi.fn(),
[Symbol.asyncIterator]: async function* () {},
})),
},
state: {
status: "ready",
networking: {
state: {
code: "networking-ready",
dave: {
session: {
setPassthroughMode: daveSetPassthroughMode,
},
},
},
},
},
daveSetPassthroughMode,
handlers,
};
return connection;
@@ -106,8 +74,7 @@ const {
vi.mock("./sdk-runtime.js", () => ({
loadDiscordVoiceSdk: () => ({
AudioPlayerStatus: { Playing: "playing", Idle: "idle" },
EndBehaviorType: { AfterSilence: "AfterSilence", Manual: "Manual" },
NetworkingStatusCode: { Ready: "networking-ready", Resuming: "networking-resuming" },
EndBehaviorType: { AfterSilence: "AfterSilence" },
VoiceConnectionStatus: {
Ready: "ready",
Disconnected: "disconnected",
@@ -261,12 +228,6 @@ describe("DiscordVoiceManager", () => {
guildId: "g1",
channelId: "1001",
route: { sessionKey: "discord:g1:1001", agentId: "agent-1" },
connection: createConnectionMock(),
player: createAudioPlayerMock(),
playbackQueue: Promise.resolve(),
processingQueue: Promise.resolve(),
capture: createVoiceCaptureState(),
receiveRecovery: createVoiceReceiveRecoveryState(),
},
wavPath: "/tmp/test.wav",
userId,
@@ -323,7 +284,6 @@ describe("DiscordVoiceManager", () => {
const player = createAudioPlayerMock.mock.results[0]?.value;
expect(connection.receiver.speaking.off).toHaveBeenCalledWith("start", expect.any(Function));
expect(connection.receiver.speaking.off).toHaveBeenCalledWith("end", expect.any(Function));
expect(connection.off).toHaveBeenCalledWith("disconnected", expect.any(Function));
expect(connection.off).toHaveBeenCalledWith("destroyed", expect.any(Function));
expect(player.off).toHaveBeenCalledWith("error", expect.any(Function));
@@ -368,100 +328,20 @@ describe("DiscordVoiceManager", () => {
expect(entry?.guildName).toBe("Guild One");
});
it("enables DAVE receive passthrough after join", async () => {
const connection = createConnectionMock();
joinVoiceChannelMock.mockReturnValueOnce(connection);
it("attempts rejoin after repeated decrypt failures", async () => {
const manager = createManager();
await manager.join({ guildId: "g1", channelId: "1001" });
expect(connection.daveSetPassthroughMode).toHaveBeenCalledWith(true, 30);
});
it("re-arms passthrough but still rejoin-recovers after repeated decrypt failures", async () => {
const connection = createConnectionMock();
joinVoiceChannelMock
.mockReturnValueOnce(connection)
.mockReturnValueOnce(createConnectionMock());
const manager = createManager();
await manager.join({ guildId: "g1", channelId: "1001" });
connection.daveSetPassthroughMode.mockClear();
emitDecryptFailure(manager);
emitDecryptFailure(manager);
emitDecryptFailure(manager);
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(connection.daveSetPassthroughMode).toHaveBeenCalledWith(true, 15);
expect(joinVoiceChannelMock).toHaveBeenCalledTimes(2);
});
it("allows the same speaker to restart after finalize fires", async () => {
vi.useFakeTimers();
try {
const connection = createConnectionMock();
joinVoiceChannelMock.mockReturnValueOnce(connection);
const manager = createManager();
await manager.join({ guildId: "g1", channelId: "1001" });
const entry = (manager as unknown as { sessions: Map<string, unknown> }).sessions.get("g1") as
| {
guildId: string;
channelId: string;
capture: {
activeSpeakers: Set<string>;
activeCaptureStreams: Map<
string,
{ generation: number; stream: { destroy: () => void } }
>;
captureFinalizeTimers: Map<string, unknown>;
captureGenerations: Map<string, number>;
};
}
| undefined;
expect(entry).toBeDefined();
const firstStream = { destroy: vi.fn() };
entry?.capture.activeSpeakers.add("u1");
entry?.capture.captureGenerations.set("u1", 1);
entry?.capture.activeCaptureStreams.set("u1", { generation: 1, stream: firstStream });
(
manager as unknown as {
scheduleCaptureFinalize: (entry: unknown, userId: string, reason: string) => void;
}
).scheduleCaptureFinalize(entry, "u1", "test");
await vi.advanceTimersByTimeAsync(1_200);
expect(firstStream.destroy).toHaveBeenCalledTimes(1);
expect(entry?.capture.activeSpeakers.has("u1")).toBe(false);
const secondStream = {
on: vi.fn(),
destroy: vi.fn(),
async *[Symbol.asyncIterator]() {},
};
connection.receiver.subscribe.mockReturnValueOnce(secondStream);
await (
manager as unknown as {
handleSpeakingStart: (entry: unknown, userId: string) => Promise<void>;
}
).handleSpeakingStart(entry, "u1");
expect(connection.receiver.subscribe).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ end: { behavior: "Manual" } }),
);
} finally {
vi.useRealTimers();
}
});
it("passes senderIsOwner=true for allowlisted voice speakers", async () => {
const client = createClient();
client.fetchMember.mockResolvedValue({

View File

@@ -22,30 +22,6 @@ import { normalizeDiscordSlug, resolveDiscordOwnerAccess } from "../monitor/allo
import { formatDiscordUserTag } from "../monitor/format.js";
import { getDiscordRuntime } from "../runtime.js";
import { authorizeDiscordVoiceIngress } from "./access.js";
import {
beginVoiceCapture,
clearVoiceCaptureFinalizeTimer,
createVoiceCaptureState,
finishVoiceCapture,
getActiveVoiceCapture,
isVoiceCaptureActive,
scheduleVoiceCaptureFinalize,
stopVoiceCaptureState,
type VoiceCaptureState,
} from "./capture-state.js";
import { formatVoiceIngressPrompt } from "./prompt.js";
import {
analyzeVoiceReceiveError,
createVoiceReceiveRecoveryState,
DAVE_RECEIVE_PASSTHROUGH_INITIAL_EXPIRY_SECONDS,
DAVE_RECEIVE_PASSTHROUGH_REARM_EXPIRY_SECONDS,
enableDaveReceivePassthrough as tryEnableDaveReceivePassthrough,
finishVoiceDecryptRecovery,
noteVoiceDecryptFailure,
resetVoiceReceiveRecoveryState,
type VoiceReceiveRecoveryState,
} from "./receive-recovery.js";
import { sanitizeVoiceReplyTextForSpeech } from "./sanitize.js";
import { loadDiscordVoiceSdk } from "./sdk-runtime.js";
const require = createRequire(import.meta.url);
@@ -54,10 +30,13 @@ const SAMPLE_RATE = 48_000;
const CHANNELS = 2;
const BIT_DEPTH = 16;
const MIN_SEGMENT_SECONDS = 0.35;
const CAPTURE_FINALIZE_GRACE_MS = 1_200;
const SILENCE_DURATION_MS = 1_000;
const VOICE_CONNECT_READY_TIMEOUT_MS = 15_000;
const PLAYBACK_READY_TIMEOUT_MS = 60_000;
const SPEAKING_READY_TIMEOUT_MS = 60_000;
const DECRYPT_FAILURE_WINDOW_MS = 30_000;
const DECRYPT_FAILURE_RECONNECT_THRESHOLD = 3;
const DECRYPT_FAILURE_PATTERN = /DecryptionFailed\(/;
const SPEAKER_CONTEXT_CACHE_TTL_MS = 60_000;
const logger = createSubsystemLogger("discord/voice");
@@ -84,8 +63,10 @@ type VoiceSessionEntry = {
player: import("@discordjs/voice").AudioPlayer;
playbackQueue: Promise<void>;
processingQueue: Promise<void>;
capture: VoiceCaptureState;
receiveRecovery: VoiceReceiveRecoveryState;
activeSpeakers: Set<string>;
decryptFailureCount: number;
lastDecryptFailureAt: number;
decryptRecoveryInFlight: boolean;
stop: () => void;
};
@@ -165,75 +146,25 @@ type OpusDecoder = {
decode: (buffer: Buffer) => Buffer;
};
type OpusDecoderFactory = {
load: () => OpusDecoder;
name: string;
};
let warnedOpusMissing = false;
let cachedOpusDecoderFactory: OpusDecoderFactory | null | "unresolved" = "unresolved";
function resolveOpusDecoderFactory(): OpusDecoderFactory | null {
const factories: OpusDecoderFactory[] = [
{
name: "@discordjs/opus",
load: () => {
const DiscordOpus = require("@discordjs/opus") as {
OpusEncoder: new (
sampleRate: number,
channels: number,
) => {
decode: (buffer: Buffer) => Buffer;
};
};
return new DiscordOpus.OpusEncoder(SAMPLE_RATE, CHANNELS);
},
},
{
name: "opusscript",
load: () => {
const OpusScript = require("opusscript") as {
new (sampleRate: number, channels: number, application: number): OpusDecoder;
Application: { AUDIO: number };
};
return new OpusScript(SAMPLE_RATE, CHANNELS, OpusScript.Application.AUDIO);
},
},
];
const failures: string[] = [];
for (const factory of factories) {
try {
factory.load();
return factory;
} catch (err) {
failures.push(`${factory.name}: ${formatErrorMessage(err)}`);
}
}
if (!warnedOpusMissing) {
warnedOpusMissing = true;
logger.warn(
`discord voice: no usable opus decoder available (${failures.join("; ")}); cannot decode voice audio`,
);
}
return null;
}
function createOpusDecoder(): { decoder: OpusDecoder; name: string } | null {
const factory = getOrCreateOpusDecoderFactory();
if (!factory) {
return null;
try {
const OpusScript = require("opusscript") as {
new (sampleRate: number, channels: number, application: number): OpusDecoder;
Application: { AUDIO: number };
};
const decoder = new OpusScript(SAMPLE_RATE, CHANNELS, OpusScript.Application.AUDIO);
return { decoder, name: "opusscript" };
} catch (err) {
if (!warnedOpusMissing) {
warnedOpusMissing = true;
logger.warn(
`discord voice: opusscript unavailable (${formatErrorMessage(err)}); cannot decode voice audio`,
);
}
}
return { decoder: factory.load(), name: factory.name };
}
function getOrCreateOpusDecoderFactory(): OpusDecoderFactory | null {
if (cachedOpusDecoderFactory !== "unresolved") {
return cachedOpusDecoderFactory;
}
cachedOpusDecoderFactory = resolveOpusDecoderFactory();
return cachedOpusDecoderFactory;
return null;
}
async function decodeOpusStream(stream: Readable): Promise<Buffer> {
@@ -485,7 +416,6 @@ export class DiscordVoiceManager {
connection.subscribe(player);
let speakingHandler: ((userId: string) => void) | undefined;
let speakingEndHandler: ((userId: string) => void) | undefined;
let disconnectedHandler: (() => Promise<void>) | undefined;
let destroyedHandler: (() => void) | undefined;
let playerErrorHandler: ((err: Error) => void) | undefined;
@@ -516,16 +446,14 @@ export class DiscordVoiceManager {
player,
playbackQueue: Promise.resolve(),
processingQueue: Promise.resolve(),
capture: createVoiceCaptureState(),
receiveRecovery: createVoiceReceiveRecoveryState(),
activeSpeakers: new Set(),
decryptFailureCount: 0,
lastDecryptFailureAt: 0,
decryptRecoveryInFlight: false,
stop: () => {
if (speakingHandler) {
connection.receiver.speaking.off("start", speakingHandler);
}
if (speakingEndHandler) {
connection.receiver.speaking.off("end", speakingEndHandler);
}
stopVoiceCaptureState(entry.capture);
if (disconnectedHandler) {
connection.off(voiceSdk.VoiceConnectionStatus.Disconnected, disconnectedHandler);
}
@@ -545,9 +473,6 @@ export class DiscordVoiceManager {
logger.warn(`discord voice: capture failed: ${formatErrorMessage(err)}`);
});
};
speakingEndHandler = (userId: string) => {
this.scheduleCaptureFinalize(entry, userId, "speaker end");
};
disconnectedHandler = async () => {
try {
@@ -567,13 +492,7 @@ export class DiscordVoiceManager {
logger.warn(`discord voice: playback error: ${formatErrorMessage(err)}`);
};
this.enableDaveReceivePassthrough(
entry,
"post-join warmup",
DAVE_RECEIVE_PASSTHROUGH_INITIAL_EXPIRY_SECONDS,
);
connection.receiver.speaking.on("start", speakingHandler);
connection.receiver.speaking.on("end", speakingEndHandler);
connection.on(voiceSdk.VoiceConnectionStatus.Disconnected, disconnectedHandler);
connection.on(voiceSdk.VoiceConnectionStatus.Destroyed, destroyedHandler);
player.on("error", playerErrorHandler);
@@ -627,63 +546,30 @@ export class DiscordVoiceManager {
.catch((err) => logger.warn(`discord voice: playback failed: ${formatErrorMessage(err)}`));
}
private clearCaptureFinalizeTimer(entry: VoiceSessionEntry, userId: string, generation?: number) {
return clearVoiceCaptureFinalizeTimer(entry.capture, userId, generation);
}
private scheduleCaptureFinalize(entry: VoiceSessionEntry, userId: string, reason: string) {
scheduleVoiceCaptureFinalize({
state: entry.capture,
userId,
delayMs: CAPTURE_FINALIZE_GRACE_MS,
onFinalize: () => {
logVoiceVerbose(
`capture finalize: guild ${entry.guildId} channel ${entry.channelId} user ${userId} reason=${reason} grace=${CAPTURE_FINALIZE_GRACE_MS}ms`,
);
},
});
}
private async handleSpeakingStart(entry: VoiceSessionEntry, userId: string) {
if (!userId) {
if (!userId || entry.activeSpeakers.has(userId)) {
return;
}
if (this.botUserId && userId === this.botUserId) {
return;
}
if (isVoiceCaptureActive(entry.capture, userId)) {
const activeCapture = getActiveVoiceCapture(entry.capture, userId);
const extended = activeCapture
? this.clearCaptureFinalizeTimer(entry, userId, activeCapture.generation)
: false;
logVoiceVerbose(
`capture start ignored (already active): guild ${entry.guildId} channel ${entry.channelId} user ${userId}${extended ? " (finalize canceled)" : ""}`,
);
return;
}
entry.activeSpeakers.add(userId);
logVoiceVerbose(
`capture start: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`,
);
const voiceSdk = loadDiscordVoiceSdk();
this.enableDaveReceivePassthrough(
entry,
`speaker ${userId} start`,
DAVE_RECEIVE_PASSTHROUGH_REARM_EXPIRY_SECONDS,
);
if (entry.player.state.status === voiceSdk.AudioPlayerStatus.Playing) {
entry.player.stop(true);
}
const stream = entry.connection.receiver.subscribe(userId, {
end: {
behavior: voiceSdk.EndBehaviorType.Manual,
behavior: voiceSdk.EndBehaviorType.AfterSilence,
duration: SILENCE_DURATION_MS,
},
});
const generation = beginVoiceCapture(entry.capture, userId, stream);
let streamAborted = false;
stream.on("error", (err) => {
streamAborted = analyzeVoiceReceiveError(err).isAbortLike;
this.handleReceiveError(entry, err);
});
@@ -697,8 +583,7 @@ export class DiscordVoiceManager {
}
this.resetDecryptFailureState(entry);
const { path: wavPath, durationSeconds } = await writeWavFile(pcm);
const minimumDurationSeconds = streamAborted ? 0.2 : MIN_SEGMENT_SECONDS;
if (durationSeconds < minimumDurationSeconds) {
if (durationSeconds < MIN_SEGMENT_SECONDS) {
logVoiceVerbose(
`capture too short (${durationSeconds.toFixed(2)}s): guild ${entry.guildId} channel ${entry.channelId} user ${userId}`,
);
@@ -711,7 +596,7 @@ export class DiscordVoiceManager {
await this.processSegment({ entry, wavPath, userId, durationSeconds });
});
} finally {
finishVoiceCapture(entry.capture, userId, generation);
entry.activeSpeakers.delete(userId);
}
}
@@ -770,7 +655,7 @@ export class DiscordVoiceManager {
`transcription ok (${transcript.length} chars): guild ${entry.guildId} channel ${entry.channelId}`,
);
const prompt = formatVoiceIngressPrompt(transcript, speaker.label);
const prompt = speaker.label ? `${speaker.label}: ${transcript}` : transcript;
const result = await agentCommandFromIngress(
{
@@ -809,8 +694,7 @@ export class DiscordVoiceManager {
cfg: ttsCfg,
providerConfigs: ttsConfig.providerConfigs,
});
const rawSpeakText = directive.overrides.ttsText ?? directive.cleanedText.trim();
const speakText = sanitizeVoiceReplyTextForSpeech(rawSpeakText, speaker.label);
const speakText = directive.overrides.ttsText ?? directive.cleanedText.trim();
if (!speakText) {
logVoiceVerbose(
`tts skipped (empty): guild ${entry.guildId} channel ${entry.channelId} user ${userId}`,
@@ -851,80 +735,42 @@ export class DiscordVoiceManager {
}
private handleReceiveError(entry: VoiceSessionEntry, err: unknown) {
const analysis = analyzeVoiceReceiveError(err);
logger.warn(`discord voice: receive error: ${analysis.message}`);
if (analysis.shouldAttemptPassthrough) {
this.enableDaveReceivePassthrough(
entry,
"receive decrypt error",
DAVE_RECEIVE_PASSTHROUGH_REARM_EXPIRY_SECONDS,
);
}
if (!analysis.countsAsDecryptFailure) {
const message = formatErrorMessage(err);
logger.warn(`discord voice: receive error: ${message}`);
if (!DECRYPT_FAILURE_PATTERN.test(message)) {
return;
}
const decryptFailure = noteVoiceDecryptFailure(entry.receiveRecovery);
if (decryptFailure.firstFailure) {
const now = Date.now();
if (now - entry.lastDecryptFailureAt > DECRYPT_FAILURE_WINDOW_MS) {
entry.decryptFailureCount = 0;
}
entry.lastDecryptFailureAt = now;
entry.decryptFailureCount += 1;
if (entry.decryptFailureCount === 1) {
logger.warn(
"discord voice: DAVE decrypt failures detected; voice receive may be unstable (upstream: discordjs/discord.js#11419)",
);
}
if (!decryptFailure.shouldRecover) {
if (
entry.decryptFailureCount < DECRYPT_FAILURE_RECONNECT_THRESHOLD ||
entry.decryptRecoveryInFlight
) {
return;
}
entry.decryptRecoveryInFlight = true;
this.resetDecryptFailureState(entry);
void this.recoverFromDecryptFailures(entry)
.catch((recoverErr) =>
logger.warn(`discord voice: decrypt recovery failed: ${formatErrorMessage(recoverErr)}`),
)
.finally(() => {
finishVoiceDecryptRecovery(entry.receiveRecovery);
entry.decryptRecoveryInFlight = false;
});
}
private enableDaveReceivePassthrough(
entry: Pick<VoiceSessionEntry, "guildId" | "channelId" | "connection">,
reason: string,
expirySeconds: number,
): boolean {
const voiceSdk = loadDiscordVoiceSdk();
return tryEnableDaveReceivePassthrough({
target: {
guildId: entry.guildId,
channelId: entry.channelId,
connection: entry.connection as {
state: {
status: unknown;
networking?: {
state?: {
code?: unknown;
dave?: {
session?: {
setPassthroughMode: (passthrough: boolean, expirySeconds: number) => void;
};
};
};
};
};
},
},
sdk: {
VoiceConnectionStatus: {
Ready: voiceSdk.VoiceConnectionStatus.Ready,
},
NetworkingStatusCode: {
Ready: voiceSdk.NetworkingStatusCode.Ready,
Resuming: voiceSdk.NetworkingStatusCode.Resuming,
},
},
reason,
expirySeconds,
onVerbose: logVoiceVerbose,
onWarn: (message) => logger.warn(message),
});
}
private resetDecryptFailureState(entry: VoiceSessionEntry) {
resetVoiceReceiveRecoveryState(entry.receiveRecovery);
entry.decryptFailureCount = 0;
entry.lastDecryptFailureAt = 0;
}
private async recoverFromDecryptFailures(entry: VoiceSessionEntry) {

View File

@@ -1,14 +0,0 @@
import { describe, expect, it } from "vitest";
import { formatVoiceIngressPrompt } from "./prompt.js";
describe("formatVoiceIngressPrompt", () => {
it("formats speaker-labeled voice input without imperative-looking prefixes", () => {
expect(formatVoiceIngressPrompt("hello there", "speaker-1")).toBe(
'Voice transcript from speaker "speaker-1":\nhello there',
);
});
it("returns the bare transcript when no speaker label exists", () => {
expect(formatVoiceIngressPrompt("hello there")).toBe("hello there");
});
});

View File

@@ -1,8 +0,0 @@
export function formatVoiceIngressPrompt(transcript: string, speakerLabel?: string): string {
const cleanedTranscript = transcript.trim();
const cleanedLabel = speakerLabel?.trim();
if (!cleanedLabel) {
return cleanedTranscript;
}
return [`Voice transcript from speaker "${cleanedLabel}":`, cleanedTranscript].join("\n");
}

View File

@@ -1,79 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
analyzeVoiceReceiveError,
createVoiceReceiveRecoveryState,
enableDaveReceivePassthrough,
noteVoiceDecryptFailure,
} from "./receive-recovery.js";
describe("voice receive recovery", () => {
it("treats passthrough-disabled decrypt errors as decrypt failures", () => {
expect(
analyzeVoiceReceiveError(
new Error("Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)"),
),
).toMatchObject({
shouldAttemptPassthrough: true,
countsAsDecryptFailure: true,
});
});
it("gates recovery after repeated decrypt failures in the same window", () => {
const state = createVoiceReceiveRecoveryState();
expect(noteVoiceDecryptFailure(state, 1_000)).toEqual({
firstFailure: true,
shouldRecover: false,
});
expect(noteVoiceDecryptFailure(state, 2_000)).toEqual({
firstFailure: false,
shouldRecover: false,
});
expect(noteVoiceDecryptFailure(state, 3_000)).toEqual({
firstFailure: false,
shouldRecover: true,
});
});
it("enables passthrough only for ready DAVE sessions", () => {
const setPassthroughMode = vi.fn();
const onVerbose = vi.fn();
const onWarn = vi.fn();
expect(
enableDaveReceivePassthrough({
target: {
guildId: "g1",
channelId: "c1",
connection: {
state: {
status: "ready",
networking: {
state: {
code: "networking-ready",
dave: {
session: {
setPassthroughMode,
},
},
},
},
},
},
},
sdk: {
VoiceConnectionStatus: { Ready: "ready" },
NetworkingStatusCode: { Ready: "networking-ready", Resuming: "networking-resuming" },
},
reason: "test",
expirySeconds: 15,
onVerbose,
onWarn,
}),
).toBe(true);
expect(setPassthroughMode).toHaveBeenCalledWith(true, 15);
expect(onVerbose).toHaveBeenCalled();
expect(onWarn).not.toHaveBeenCalled();
});
});

View File

@@ -1,159 +0,0 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
const DECRYPT_FAILURE_WINDOW_MS = 30_000;
const DECRYPT_FAILURE_RECONNECT_THRESHOLD = 3;
const DECRYPT_FAILURE_PATTERN = /DecryptionFailed\(/;
const DAVE_PASSTHROUGH_DISABLED_PATTERN = /UnencryptedWhenPassthroughDisabled/;
export const DAVE_RECEIVE_PASSTHROUGH_INITIAL_EXPIRY_SECONDS = 30;
export const DAVE_RECEIVE_PASSTHROUGH_REARM_EXPIRY_SECONDS = 15;
export type VoiceReceiveRecoveryState = {
decryptFailureCount: number;
lastDecryptFailureAt: number;
decryptRecoveryInFlight: boolean;
};
export type VoiceReceiveErrorAnalysis = {
message: string;
isAbortLike: boolean;
shouldAttemptPassthrough: boolean;
countsAsDecryptFailure: boolean;
};
type DavePassthroughTarget = {
guildId: string;
channelId: string;
connection: {
state: {
status: unknown;
networking?: {
state?: {
code?: unknown;
dave?: {
session?: {
setPassthroughMode: (passthrough: boolean, expirySeconds: number) => void;
};
};
};
};
};
};
};
type DavePassthroughSdk = {
VoiceConnectionStatus: {
Ready: unknown;
};
NetworkingStatusCode: {
Ready: unknown;
Resuming: unknown;
};
};
export function createVoiceReceiveRecoveryState(): VoiceReceiveRecoveryState {
return {
decryptFailureCount: 0,
lastDecryptFailureAt: 0,
decryptRecoveryInFlight: false,
};
}
export function isAbortLikeReceiveError(err: unknown): boolean {
if (!err || typeof err !== "object") {
return false;
}
const name =
"name" in err && typeof (err as { name?: unknown }).name === "string"
? (err as { name: string }).name
: "";
const message =
"message" in err && typeof (err as { message?: unknown }).message === "string"
? (err as { message: string }).message
: "";
return (
name === "AbortError" ||
message.includes("The operation was aborted") ||
message.includes("aborted")
);
}
export function analyzeVoiceReceiveError(err: unknown): VoiceReceiveErrorAnalysis {
const message = formatErrorMessage(err);
const shouldAttemptPassthrough = DAVE_PASSTHROUGH_DISABLED_PATTERN.test(message);
return {
message,
isAbortLike: isAbortLikeReceiveError(err),
shouldAttemptPassthrough,
countsAsDecryptFailure: DECRYPT_FAILURE_PATTERN.test(message) || shouldAttemptPassthrough,
};
}
export function noteVoiceDecryptFailure(
state: VoiceReceiveRecoveryState,
now: number = Date.now(),
): {
firstFailure: boolean;
shouldRecover: boolean;
} {
if (now - state.lastDecryptFailureAt > DECRYPT_FAILURE_WINDOW_MS) {
state.decryptFailureCount = 0;
}
state.lastDecryptFailureAt = now;
state.decryptFailureCount += 1;
const firstFailure = state.decryptFailureCount === 1;
if (
state.decryptFailureCount < DECRYPT_FAILURE_RECONNECT_THRESHOLD ||
state.decryptRecoveryInFlight
) {
return { firstFailure, shouldRecover: false };
}
state.decryptRecoveryInFlight = true;
resetVoiceReceiveRecoveryState(state);
return { firstFailure, shouldRecover: true };
}
export function resetVoiceReceiveRecoveryState(state: VoiceReceiveRecoveryState): void {
state.decryptFailureCount = 0;
state.lastDecryptFailureAt = 0;
}
export function finishVoiceDecryptRecovery(state: VoiceReceiveRecoveryState): void {
state.decryptRecoveryInFlight = false;
}
export function enableDaveReceivePassthrough(params: {
target: DavePassthroughTarget;
sdk: DavePassthroughSdk;
reason: string;
expirySeconds: number;
onVerbose: (message: string) => void;
onWarn: (message: string) => void;
}): boolean {
const { target, sdk, reason, expirySeconds, onVerbose, onWarn } = params;
const networkingState = target.connection.state.networking?.state;
if (
target.connection.state.status !== sdk.VoiceConnectionStatus.Ready ||
!networkingState ||
(networkingState.code !== sdk.NetworkingStatusCode.Ready &&
networkingState.code !== sdk.NetworkingStatusCode.Resuming)
) {
return false;
}
const daveSession = networkingState.dave?.session;
if (!daveSession) {
return false;
}
try {
daveSession.setPassthroughMode(true, expirySeconds);
onVerbose(
`enabled DAVE receive passthrough: guild ${target.guildId} channel ${target.channelId} expiry=${expirySeconds}s reason=${reason}`,
);
return true;
} catch (err) {
onWarn(
`discord voice: failed to enable DAVE passthrough guild=${target.guildId} channel=${target.channelId} reason=${reason}: ${formatErrorMessage(err)}`,
);
return false;
}
}

View File

@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { sanitizeVoiceReplyTextForSpeech } from "./sanitize.js";
describe("sanitizeVoiceReplyTextForSpeech", () => {
it("strips reply tags before speech", () => {
expect(sanitizeVoiceReplyTextForSpeech("[[reply_to_current]] hello there")).toBe("hello there");
});
it("strips the current speaker label prefix before speech", () => {
expect(sanitizeVoiceReplyTextForSpeech("speaker-1: hello there", "speaker-1")).toBe(
"hello there",
);
});
it("keeps other prefixes intact", () => {
expect(sanitizeVoiceReplyTextForSpeech("speaker-2: hello there", "speaker-1")).toBe(
"speaker-2: hello there",
);
});
it("handles reply tags and speaker prefixes together", () => {
expect(
sanitizeVoiceReplyTextForSpeech("[[reply_to_current]] speaker-1: hello there", "speaker-1"),
).toBe("hello there");
});
it("strips decorative emoji before speech", () => {
expect(sanitizeVoiceReplyTextForSpeech("😀 hello there 🎉", "speaker-1")).toBe("hello there");
});
it("keeps punctuation sane after emoji stripping", () => {
expect(sanitizeVoiceReplyTextForSpeech("✅ done!", "speaker-1")).toBe("done!");
});
});

View File

@@ -1,32 +0,0 @@
import { stripInlineDirectiveTagsForDisplay } from "openclaw/plugin-sdk/text-runtime";
const SPEECH_EMOJI_RE =
/(?:\p{Extended_Pictographic}(?:\uFE0F|\u200D|\p{Extended_Pictographic}|\p{Emoji_Modifier})*)+/gu;
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function stripEmojiForSpeech(text: string): string {
return text
.replace(SPEECH_EMOJI_RE, " ")
.replace(/\s+([?!.,:;])/g, "$1")
.replace(/[ \t]{2,}/g, " ")
.replace(/ *\n */g, "\n")
.trim();
}
export function sanitizeVoiceReplyTextForSpeech(text: string, speakerLabel?: string): string {
let cleaned = stripInlineDirectiveTagsForDisplay(text).text.trim();
if (!cleaned) {
return "";
}
const label = speakerLabel?.trim();
if (label) {
const prefix = new RegExp(`^${escapeRegExp(label)}\\s*:\\s*`, "i");
cleaned = cleaned.replace(prefix, "").trim();
}
return stripEmojiForSpeech(cleaned);
}

View File

@@ -1,10 +0,0 @@
export const DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io";
export function isValidElevenLabsVoiceId(voiceId: string): boolean {
return /^[a-zA-Z0-9]{10,40}$/.test(voiceId);
}
export function normalizeElevenLabsBaseUrl(baseUrl?: string): string {
const trimmed = baseUrl?.trim();
return trimmed?.replace(/\/+$/, "") || DEFAULT_ELEVENLABS_BASE_URL;
}

View File

@@ -7,18 +7,15 @@ import type {
SpeechVoiceOption,
} from "openclaw/plugin-sdk/speech";
import {
asBoolean,
asFiniteNumber,
asObject,
normalizeApplyTextNormalization,
normalizeLanguageCode,
normalizeSeed,
requireInRange,
trimToUndefined,
} from "openclaw/plugin-sdk/speech";
import { resolveElevenLabsApiKeyWithProfileFallback } from "./config-api.js";
import { isValidElevenLabsVoiceId, normalizeElevenLabsBaseUrl } from "./shared.js";
import { elevenLabsTTS } from "./tts.js";
const DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io";
const DEFAULT_ELEVENLABS_VOICE_ID = "pMsXgVXv3BLzUgSXRplE";
const DEFAULT_ELEVENLABS_MODEL_ID = "eleven_multilingual_v2";
const DEFAULT_ELEVENLABS_VOICE_SETTINGS = {
@@ -52,6 +49,24 @@ type ElevenLabsProviderConfig = {
};
};
function trimToUndefined(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function asBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function asObject(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function parseBooleanValue(value: string): boolean | undefined {
const normalized = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(normalized)) {
@@ -68,7 +83,14 @@ function parseNumberValue(value: string): number | undefined {
return Number.isFinite(parsed) ? parsed : undefined;
}
export const isValidVoiceId = isValidElevenLabsVoiceId;
export function isValidVoiceId(voiceId: string): boolean {
return /^[a-zA-Z0-9]{10,40}$/.test(voiceId);
}
function normalizeElevenLabsBaseUrl(baseUrl: string | undefined): string {
const trimmed = baseUrl?.trim();
return trimmed?.replace(/\/+$/, "") || DEFAULT_ELEVENLABS_BASE_URL;
}
function normalizeElevenLabsProviderConfig(
rawConfig: Record<string, unknown>,
@@ -84,7 +106,7 @@ function normalizeElevenLabsProviderConfig(
baseUrl: normalizeElevenLabsBaseUrl(trimToUndefined(raw?.baseUrl)),
voiceId: trimToUndefined(raw?.voiceId) ?? DEFAULT_ELEVENLABS_VOICE_ID,
modelId: trimToUndefined(raw?.modelId) ?? DEFAULT_ELEVENLABS_MODEL_ID,
seed: asFiniteNumber(raw?.seed),
seed: asNumber(raw?.seed),
applyTextNormalization: trimToUndefined(raw?.applyTextNormalization) as
| "auto"
| "on"
@@ -93,15 +115,15 @@ function normalizeElevenLabsProviderConfig(
languageCode: trimToUndefined(raw?.languageCode),
voiceSettings: {
stability:
asFiniteNumber(rawVoiceSettings?.stability) ?? DEFAULT_ELEVENLABS_VOICE_SETTINGS.stability,
asNumber(rawVoiceSettings?.stability) ?? DEFAULT_ELEVENLABS_VOICE_SETTINGS.stability,
similarityBoost:
asFiniteNumber(rawVoiceSettings?.similarityBoost) ??
asNumber(rawVoiceSettings?.similarityBoost) ??
DEFAULT_ELEVENLABS_VOICE_SETTINGS.similarityBoost,
style: asFiniteNumber(rawVoiceSettings?.style) ?? DEFAULT_ELEVENLABS_VOICE_SETTINGS.style,
style: asNumber(rawVoiceSettings?.style) ?? DEFAULT_ELEVENLABS_VOICE_SETTINGS.style,
useSpeakerBoost:
asBoolean(rawVoiceSettings?.useSpeakerBoost) ??
DEFAULT_ELEVENLABS_VOICE_SETTINGS.useSpeakerBoost,
speed: asFiniteNumber(rawVoiceSettings?.speed) ?? DEFAULT_ELEVENLABS_VOICE_SETTINGS.speed,
speed: asNumber(rawVoiceSettings?.speed) ?? DEFAULT_ELEVENLABS_VOICE_SETTINGS.speed,
},
};
}
@@ -114,19 +136,19 @@ function readElevenLabsProviderConfig(config: SpeechProviderConfig): ElevenLabsP
baseUrl: normalizeElevenLabsBaseUrl(trimToUndefined(config.baseUrl) ?? defaults.baseUrl),
voiceId: trimToUndefined(config.voiceId) ?? defaults.voiceId,
modelId: trimToUndefined(config.modelId) ?? defaults.modelId,
seed: asFiniteNumber(config.seed) ?? defaults.seed,
seed: asNumber(config.seed) ?? defaults.seed,
applyTextNormalization:
(trimToUndefined(config.applyTextNormalization) as "auto" | "on" | "off" | undefined) ??
defaults.applyTextNormalization,
languageCode: trimToUndefined(config.languageCode) ?? defaults.languageCode,
voiceSettings: {
stability: asFiniteNumber(voiceSettings?.stability) ?? defaults.voiceSettings.stability,
stability: asNumber(voiceSettings?.stability) ?? defaults.voiceSettings.stability,
similarityBoost:
asFiniteNumber(voiceSettings?.similarityBoost) ?? defaults.voiceSettings.similarityBoost,
style: asFiniteNumber(voiceSettings?.style) ?? defaults.voiceSettings.style,
asNumber(voiceSettings?.similarityBoost) ?? defaults.voiceSettings.similarityBoost,
style: asNumber(voiceSettings?.style) ?? defaults.voiceSettings.style,
useSpeakerBoost:
asBoolean(voiceSettings?.useSpeakerBoost) ?? defaults.voiceSettings.useSpeakerBoost,
speed: asFiniteNumber(voiceSettings?.speed) ?? defaults.voiceSettings.speed,
speed: asNumber(voiceSettings?.speed) ?? defaults.voiceSettings.speed,
},
};
}
@@ -154,7 +176,7 @@ function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext) {
if (!ctx.policy.allowVoice) {
return { handled: true };
}
if (!isValidElevenLabsVoiceId(ctx.value)) {
if (!isValidVoiceId(ctx.value)) {
return { handled: true, warnings: [`invalid ElevenLabs voiceId "${ctx.value}"`] };
}
return {
@@ -348,9 +370,9 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
...(trimToUndefined(talkProviderConfig.modelId) == null
? {}
: { modelId: trimToUndefined(talkProviderConfig.modelId) }),
...(asFiniteNumber(talkProviderConfig.seed) == null
...(asNumber(talkProviderConfig.seed) == null
? {}
: { seed: asFiniteNumber(talkProviderConfig.seed) }),
: { seed: asNumber(talkProviderConfig.seed) }),
...(trimToUndefined(talkProviderConfig.applyTextNormalization) == null
? {}
: {
@@ -365,37 +387,35 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
}),
voiceSettings: {
...base.voiceSettings,
...(asFiniteNumber(talkVoiceSettings?.stability) == null
...(asNumber(talkVoiceSettings?.stability) == null
? {}
: { stability: asFiniteNumber(talkVoiceSettings?.stability) }),
...(asFiniteNumber(talkVoiceSettings?.similarityBoost) == null
: { stability: asNumber(talkVoiceSettings?.stability) }),
...(asNumber(talkVoiceSettings?.similarityBoost) == null
? {}
: { similarityBoost: asFiniteNumber(talkVoiceSettings?.similarityBoost) }),
...(asFiniteNumber(talkVoiceSettings?.style) == null
: { similarityBoost: asNumber(talkVoiceSettings?.similarityBoost) }),
...(asNumber(talkVoiceSettings?.style) == null
? {}
: { style: asFiniteNumber(talkVoiceSettings?.style) }),
: { style: asNumber(talkVoiceSettings?.style) }),
...(asBoolean(talkVoiceSettings?.useSpeakerBoost) == null
? {}
: { useSpeakerBoost: asBoolean(talkVoiceSettings?.useSpeakerBoost) }),
...(asFiniteNumber(talkVoiceSettings?.speed) == null
...(asNumber(talkVoiceSettings?.speed) == null
? {}
: { speed: asFiniteNumber(talkVoiceSettings?.speed) }),
: { speed: asNumber(talkVoiceSettings?.speed) }),
},
};
},
resolveTalkOverrides: ({ params }) => {
const normalize = trimToUndefined(params.normalize);
const language = trimToUndefined(params.language)?.toLowerCase();
const latencyTier = asFiniteNumber(params.latencyTier);
const latencyTier = asNumber(params.latencyTier);
const voiceSettings = {
...(asFiniteNumber(params.speed) == null ? {} : { speed: asFiniteNumber(params.speed) }),
...(asFiniteNumber(params.stability) == null
...(asNumber(params.speed) == null ? {} : { speed: asNumber(params.speed) }),
...(asNumber(params.stability) == null ? {} : { stability: asNumber(params.stability) }),
...(asNumber(params.similarity) == null
? {}
: { stability: asFiniteNumber(params.stability) }),
...(asFiniteNumber(params.similarity) == null
? {}
: { similarityBoost: asFiniteNumber(params.similarity) }),
...(asFiniteNumber(params.style) == null ? {} : { style: asFiniteNumber(params.style) }),
: { similarityBoost: asNumber(params.similarity) }),
...(asNumber(params.style) == null ? {} : { style: asNumber(params.style) }),
...(asBoolean(params.speakerBoost) == null
? {}
: { useSpeakerBoost: asBoolean(params.speakerBoost) }),
@@ -410,7 +430,7 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
...(trimToUndefined(params.outputFormat) == null
? {}
: { outputFormat: trimToUndefined(params.outputFormat) }),
...(asFiniteNumber(params.seed) == null ? {} : { seed: asFiniteNumber(params.seed) }),
...(asNumber(params.seed) == null ? {} : { seed: asNumber(params.seed) }),
...(normalize == null
? {}
: { applyTextNormalization: normalizeApplyTextNormalization(normalize) }),
@@ -454,7 +474,7 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
trimToUndefined(overrides.outputFormat) ??
(req.target === "voice-note" ? "opus_48000_64" : "mp3_44100_128");
const overrideVoiceSettings = asObject(overrides.voiceSettings);
const latencyTier = asFiniteNumber(overrides.latencyTier);
const latencyTier = asNumber(overrides.latencyTier);
const audioBuffer = await elevenLabsTTS({
text: req.text,
apiKey,
@@ -462,7 +482,7 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
voiceId: trimToUndefined(overrides.voiceId) ?? config.voiceId,
modelId: trimToUndefined(overrides.modelId) ?? config.modelId,
outputFormat,
seed: asFiniteNumber(overrides.seed) ?? config.seed,
seed: asNumber(overrides.seed) ?? config.seed,
applyTextNormalization:
(trimToUndefined(overrides.applyTextNormalization) as
| "auto"
@@ -473,21 +493,21 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
latencyTier,
voiceSettings: {
...config.voiceSettings,
...(asFiniteNumber(overrideVoiceSettings?.stability) == null
...(asNumber(overrideVoiceSettings?.stability) == null
? {}
: { stability: asFiniteNumber(overrideVoiceSettings?.stability) }),
...(asFiniteNumber(overrideVoiceSettings?.similarityBoost) == null
: { stability: asNumber(overrideVoiceSettings?.stability) }),
...(asNumber(overrideVoiceSettings?.similarityBoost) == null
? {}
: { similarityBoost: asFiniteNumber(overrideVoiceSettings?.similarityBoost) }),
...(asFiniteNumber(overrideVoiceSettings?.style) == null
: { similarityBoost: asNumber(overrideVoiceSettings?.similarityBoost) }),
...(asNumber(overrideVoiceSettings?.style) == null
? {}
: { style: asFiniteNumber(overrideVoiceSettings?.style) }),
: { style: asNumber(overrideVoiceSettings?.style) }),
...(asBoolean(overrideVoiceSettings?.useSpeakerBoost) == null
? {}
: { useSpeakerBoost: asBoolean(overrideVoiceSettings?.useSpeakerBoost) }),
...(asFiniteNumber(overrideVoiceSettings?.speed) == null
...(asNumber(overrideVoiceSettings?.speed) == null
? {}
: { speed: asFiniteNumber(overrideVoiceSettings?.speed) }),
: { speed: asNumber(overrideVoiceSettings?.speed) }),
},
timeoutMs: req.timeoutMs,
});

View File

@@ -8,7 +8,20 @@ import {
trimToUndefined,
truncateErrorDetail,
} from "openclaw/plugin-sdk/speech";
import { isValidElevenLabsVoiceId, normalizeElevenLabsBaseUrl } from "./shared.js";
const DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io";
function isValidVoiceId(voiceId: string): boolean {
return /^[a-zA-Z0-9]{10,40}$/.test(voiceId);
}
function normalizeElevenLabsBaseUrl(baseUrl?: string): string {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return DEFAULT_ELEVENLABS_BASE_URL;
}
return trimmed.replace(/\/+$/, "");
}
function formatElevenLabsErrorPayload(payload: unknown): string | undefined {
const root = asObject(payload);
@@ -96,7 +109,7 @@ export async function elevenLabsTTS(params: {
voiceSettings,
timeoutMs,
} = params;
if (!isValidElevenLabsVoiceId(voiceId)) {
if (!isValidVoiceId(voiceId)) {
throw new Error("Invalid voiceId format");
}
assertElevenLabsVoiceSettings(voiceSettings);

View File

@@ -1,14 +1,6 @@
{
"id": "feishu",
"channels": ["feishu"],
"channelEnvVars": {
"feishu": [
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"FEISHU_VERIFICATION_TOKEN",
"FEISHU_ENCRYPT_KEY"
]
},
"skills": ["./skills"],
"configSchema": {
"type": "object",

View File

@@ -1,4 +0,0 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -1,5 +1,3 @@
import { isRecord } from "./comment-shared.js";
export const FEISHU_CARD_INTERACTION_VERSION = "ocf1";
export type FeishuCardInteractionKind = "button" | "quick" | "meta";
@@ -55,6 +53,10 @@ export type DecodedFeishuCardAction =
reason: FeishuCardInteractionReason;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isInteractionKind(value: unknown): value is FeishuCardInteractionKind {
return value === "button" || value === "quick" || value === "meta";
}

View File

@@ -49,7 +49,6 @@ import {
PAIRING_APPROVED_MESSAGE,
} from "./channel-runtime-api.js";
import { createFeishuClient } from "./client.js";
import { isRecord } from "./comment-shared.js";
import { FeishuConfigSchema } from "./config-schema.js";
import {
buildFeishuConversationId,
@@ -78,6 +77,10 @@ function readFeishuMediaParam(params: Record<string, unknown>): string | undefin
return media.trim() ? media : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function hasLegacyFeishuCardCommandValue(actionValue: unknown): boolean {
return (
isRecord(actionValue) &&

View File

@@ -1,79 +0,0 @@
export function encodeQuery(params: Record<string, string | undefined>): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
const trimmed = value?.trim();
if (trimmed) {
query.set(key, trimmed);
}
}
const queryString = query.toString();
return queryString ? `?${queryString}` : "";
}
export function readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
export function extractCommentElementText(element: unknown): string | undefined {
if (!isRecord(element)) {
return undefined;
}
const type = readString(element.type)?.trim();
if (type === "text_run" && isRecord(element.text_run)) {
return (
readString(element.text_run.content)?.trim() ||
readString(element.text_run.text)?.trim() ||
undefined
);
}
if (type === "mention") {
const mention = isRecord(element.mention) ? element.mention : undefined;
const mentionName =
readString(mention?.name)?.trim() ||
readString(mention?.display_name)?.trim() ||
readString(element.name)?.trim();
return mentionName ? `@${mentionName}` : "@mention";
}
if (type === "docs_link") {
const docsLink = isRecord(element.docs_link) ? element.docs_link : undefined;
return (
readString(docsLink?.text)?.trim() ||
readString(docsLink?.url)?.trim() ||
readString(element.text)?.trim() ||
readString(element.url)?.trim() ||
undefined
);
}
return (
readString(element.text)?.trim() ||
readString(element.content)?.trim() ||
readString(element.name)?.trim() ||
undefined
);
}
export function extractReplyText(
reply: { content?: { elements?: unknown[] } } | undefined,
): string | undefined {
if (!reply || !isRecord(reply.content)) {
return undefined;
}
const elements = Array.isArray(reply.content.elements) ? reply.content.elements : [];
const text = elements
.map(extractCommentElementText)
.filter((part): part is string => Boolean(part && part.trim()))
.join("")
.trim();
return text || undefined;
}

View File

@@ -1,7 +1,6 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { encodeQuery, extractReplyText, isRecord, readString } from "./comment-shared.js";
import { parseFeishuCommentTarget, type CommentFileType } from "./comment-target.js";
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
@@ -112,6 +111,77 @@ function getDriveInternalClient(client: Lark.Client): FeishuDriveInternalClient
return client as FeishuDriveInternalClient;
}
function encodeQuery(params: Record<string, string | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
const trimmed = value?.trim();
if (trimmed) {
search.set(key, trimmed);
}
}
const query = search.toString();
return query ? `?${query}` : "";
}
function readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function extractCommentElementText(element: unknown): string | undefined {
if (!isRecord(element)) {
return undefined;
}
const type = readString(element.type)?.trim();
if (type === "text_run" && isRecord(element.text_run)) {
return (
readString(element.text_run.content)?.trim() ||
readString(element.text_run.text)?.trim() ||
undefined
);
}
if (type === "mention") {
const mention = isRecord(element.mention) ? element.mention : undefined;
const mentionName =
readString(mention?.name)?.trim() ||
readString(mention?.display_name)?.trim() ||
readString(element.name)?.trim();
return mentionName ? `@${mentionName}` : "@mention";
}
if (type === "docs_link") {
const docsLink = isRecord(element.docs_link) ? element.docs_link : undefined;
return (
readString(docsLink?.text)?.trim() ||
readString(docsLink?.url)?.trim() ||
readString(element.text)?.trim() ||
readString(element.url)?.trim() ||
undefined
);
}
return (
readString(element.text)?.trim() ||
readString(element.content)?.trim() ||
readString(element.name)?.trim() ||
undefined
);
}
function extractReplyText(reply: FeishuDriveCommentReply | undefined): string | undefined {
if (!reply || !isRecord(reply.content)) {
return undefined;
}
const elements = Array.isArray(reply.content.elements) ? reply.content.elements : [];
const text = elements
.map(extractCommentElementText)
.filter((part): part is string => Boolean(part && part.trim()))
.join("")
.trim();
return text || undefined;
}
function buildReplyElements(content: string) {
return [{ type: "text", text: content }];
}

View File

@@ -13,7 +13,6 @@ import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-actio
import { maybeHandleFeishuQuickActionMenu } from "./card-ux-launcher.js";
import { createEventDispatcher } from "./client.js";
import { handleFeishuCommentEvent } from "./comment-handler.js";
import { isRecord, readString } from "./comment-shared.js";
import {
hasProcessedFeishuMessage,
recordProcessedFeishuMessage,
@@ -171,6 +170,14 @@ type FeishuBotMenuEvent = {
};
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function readStringOrNumber(value: unknown): string | number | undefined {
return typeof value === "string" || typeof value === "number" ? value : undefined;
}

View File

@@ -2,7 +2,6 @@ import type { ClawdbotConfig } from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import { raceWithTimeoutAndAbort } from "./async.js";
import { createFeishuClient } from "./client.js";
import { encodeQuery, extractReplyText, isRecord, readString } from "./comment-shared.js";
import { normalizeCommentFileType, type CommentFileType } from "./comment-target.js";
import type { ResolvedFeishuAccount } from "./types.js";
@@ -117,6 +116,14 @@ type FeishuDriveCommentRepliesListResponse = FeishuOpenApiResponse<{
page_token?: string;
}>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function readBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
@@ -140,6 +147,18 @@ function summarizeCommentRepliesForLog(replies: FeishuDriveCommentReply[]): stri
);
}
function encodeQuery(params: Record<string, string | undefined>): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
const trimmed = value?.trim();
if (trimmed) {
query.set(key, trimmed);
}
}
const queryString = query.toString();
return queryString ? `?${queryString}` : "";
}
async function delayMs(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -230,6 +249,57 @@ async function requestFeishuOpenApi<T>(params: {
return result;
}
function extractCommentElementText(element: unknown): string | undefined {
if (!isRecord(element)) {
return undefined;
}
const type = readString(element.type)?.trim();
if (type === "text_run" && isRecord(element.text_run)) {
return (
readString(element.text_run.content)?.trim() ||
readString(element.text_run.text)?.trim() ||
undefined
);
}
if (type === "mention") {
const mention = isRecord(element.mention) ? element.mention : undefined;
const mentionName =
readString(mention?.name)?.trim() ||
readString(mention?.display_name)?.trim() ||
readString(element.name)?.trim();
return mentionName ? `@${mentionName}` : "@mention";
}
if (type === "docs_link") {
const docsLink = isRecord(element.docs_link) ? element.docs_link : undefined;
return (
readString(docsLink?.text)?.trim() ||
readString(docsLink?.url)?.trim() ||
readString(element.text)?.trim() ||
readString(element.url)?.trim() ||
undefined
);
}
return (
readString(element.text)?.trim() ||
readString(element.content)?.trim() ||
readString(element.name)?.trim() ||
undefined
);
}
function extractReplyText(reply: FeishuDriveCommentReply | undefined): string | undefined {
if (!reply || !isRecord(reply.content)) {
return undefined;
}
const elements = Array.isArray(reply.content.elements) ? reply.content.elements : [];
const text = elements
.map(extractCommentElementText)
.filter((part): part is string => Boolean(part && part.trim()))
.join("")
.trim();
return text || undefined;
}
async function fetchDriveCommentReplies(params: {
client: FeishuRequestClient;
fileToken: string;

View File

@@ -1,4 +1,3 @@
import { isRecord } from "./comment-shared.js";
import { normalizeFeishuExternalKey } from "./external-keys.js";
const FALLBACK_POST_TEXT = "[Rich text message]";
@@ -16,6 +15,10 @@ type PostPayload = {
content: unknown[];
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function toStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value : "";
}

View File

@@ -7,7 +7,7 @@ import {
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/channel-secret-runtime";
} from "openclaw/plugin-sdk/security-runtime";
export const secretTargetRegistryEntries = [
{

View File

@@ -1,6 +1,12 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/setup";
import { asRecord } from "./comment-shared.js";
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;

View File

@@ -371,17 +371,11 @@ describe("feishu setup wizard status", () => {
});
const next = feishuSetupWizard.dmPolicy?.setPolicy?.(cfg as never, "open");
const workAccount = next?.channels?.feishu?.accounts?.work as
| {
dmPolicy?: string;
allowFrom?: string[];
}
| undefined;
expect(next?.channels?.feishu?.dmPolicy).toBeUndefined();
expect(next?.channels?.feishu?.allowFrom).toEqual(["ou_root"]);
expect(workAccount?.dmPolicy).toBe("open");
expect(workAccount?.allowFrom).toEqual(["ou_work", "*"]);
expect(next?.channels?.feishu?.accounts?.work?.dmPolicy).toBe("open");
expect(next?.channels?.feishu?.accounts?.work?.allowFrom).toEqual(["ou_work", "*"]);
});
it("treats env SecretRef appId as not configured when env var is missing", async () => {

View File

@@ -1 +0,0 @@
export { createFirecrawlWebFetchProvider } from "./src/firecrawl-fetch-provider.js";

View File

@@ -66,28 +66,26 @@ function extractTracks(params: { payload: GoogleGenerateMusicResponse; model: st
} {
const lyrics: string[] = [];
const tracks: GeneratedMusicAsset[] = [];
for (const candidate of params.payload.candidates ?? []) {
for (const part of candidate.content?.parts ?? []) {
if (part.text?.trim()) {
lyrics.push(part.text.trim());
continue;
}
const inline = part.inlineData ?? part.inline_data;
const data = inline?.data?.trim();
if (!data) {
continue;
}
const mimeType = inline?.mimeType?.trim() || inline?.mime_type?.trim() || "audio/mpeg";
tracks.push({
buffer: Buffer.from(data, "base64"),
mimeType,
fileName: resolveTrackFileName({
index: tracks.length,
mimeType,
model: params.model,
}),
});
for (const part of params.payload.candidates?.[0]?.content?.parts ?? []) {
if (part.text?.trim()) {
lyrics.push(part.text.trim());
continue;
}
const inline = part.inlineData ?? part.inline_data;
const data = inline?.data?.trim();
if (!data) {
continue;
}
const mimeType = inline?.mimeType?.trim() || inline?.mime_type?.trim() || "audio/mpeg";
tracks.push({
buffer: Buffer.from(data, "base64"),
mimeType,
fileName: resolveTrackFileName({
index: tracks.length,
mimeType,
model: params.model,
}),
});
}
return { tracks, lyrics };
}

View File

@@ -25,23 +25,6 @@ function resolveConfiguredGoogleVideoBaseUrl(req: VideoGenerationRequest): strin
return configured ? normalizeGoogleApiBaseUrl(configured) : undefined;
}
function parseVideoSize(size: string | undefined): { width: number; height: number } | undefined {
const trimmed = size?.trim();
if (!trimmed) {
return undefined;
}
const match = /^(\d+)x(\d+)$/u.exec(trimmed);
if (!match) {
return undefined;
}
const width = Number.parseInt(match[1] ?? "", 10);
const height = Number.parseInt(match[2] ?? "", 10);
if (!Number.isFinite(width) || !Number.isFinite(height)) {
return undefined;
}
return { width, height };
}
function resolveAspectRatio(params: {
aspectRatio?: string;
size?: string;
@@ -50,11 +33,20 @@ function resolveAspectRatio(params: {
if (direct === "16:9" || direct === "9:16") {
return direct;
}
const parsedSize = parseVideoSize(params.size);
if (!parsedSize) {
const size = params.size?.trim();
if (!size) {
return undefined;
}
return parsedSize.width >= parsedSize.height ? "16:9" : "9:16";
const match = /^(\d+)x(\d+)$/u.exec(size);
if (!match) {
return undefined;
}
const width = Number.parseInt(match[1] ?? "", 10);
const height = Number.parseInt(match[2] ?? "", 10);
if (!Number.isFinite(width) || !Number.isFinite(height)) {
return undefined;
}
return width >= height ? "16:9" : "9:16";
}
function resolveResolution(params: {
@@ -67,11 +59,17 @@ function resolveResolution(params: {
if (params.resolution === "1080P") {
return "1080p";
}
const parsedSize = parseVideoSize(params.size);
if (!parsedSize) {
const size = params.size?.trim();
if (!size) {
return undefined;
}
const maxEdge = Math.max(parsedSize.width, parsedSize.height);
const match = /^(\d+)x(\d+)$/u.exec(size);
if (!match) {
return undefined;
}
const width = Number.parseInt(match[1] ?? "", 10);
const height = Number.parseInt(match[2] ?? "", 10);
const maxEdge = Math.max(width, height);
return maxEdge >= 1920 ? "1080p" : maxEdge >= 1280 ? "720p" : undefined;
}

View File

@@ -1,9 +1,6 @@
{
"id": "googlechat",
"channels": ["googlechat"],
"channelEnvVars": {
"googlechat": ["GOOGLE_CHAT_SERVICE_ACCOUNT", "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

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