mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 22:11:38 +08:00
Compare commits
4 Commits
codex/capa
...
325
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ded626d171 | ||
|
|
2c8697b694 | ||
|
|
f3719cef5e | ||
|
|
08019bbda0 |
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -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:
|
||||
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -361,6 +361,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"update_plan": {
|
||||
"emoji": "🗺️",
|
||||
"title": "Update Plan",
|
||||
"detailKeys": [
|
||||
"explanation",
|
||||
"plan.0.step"
|
||||
]
|
||||
},
|
||||
"gateway": {
|
||||
"emoji": "🔌",
|
||||
"title": "Gateway",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1231,7 +1231,6 @@
|
||||
"pages": [
|
||||
"providers/alibaba",
|
||||
"providers/anthropic",
|
||||
"providers/arcee",
|
||||
"providers/bedrock",
|
||||
"providers/bedrock-mantle",
|
||||
"providers/chutes",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(...)`
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
"*",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -101,6 +101,7 @@ export function registerBrowserAgentActHookRoutes(
|
||||
cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
})()
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"id": "discord",
|
||||
"channels": ["discord"],
|
||||
"channelEnvVars": {
|
||||
"discord": ["DISCORD_BOT_TOKEN"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -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:*"
|
||||
},
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-config-contract.js";
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
collectUnsupportedSecretRefConfigCandidates,
|
||||
unsupportedSecretRefSurfacePatterns,
|
||||
} from "./src/security-contract.js";
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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> }> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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!");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 : "";
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { createFirecrawlWebFetchProvider } from "./src/firecrawl-fetch-provider.js";
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user