mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-11 08:21:38 +08:00
Compare commits
133 Commits
codex/fix-
...
cache/cont
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1358cba962 | ||
|
|
6aa591ba56 | ||
|
|
d01cb5ecc6 | ||
|
|
5bea93fd63 | ||
|
|
fe72474153 | ||
|
|
411282c36d | ||
|
|
43272d27f8 | ||
|
|
bb4e54ccf7 | ||
|
|
df83374a54 | ||
|
|
236a9003b6 | ||
|
|
3a3fdf1920 | ||
|
|
ff62705206 | ||
|
|
306fe841f5 | ||
|
|
de2eaccfce | ||
|
|
0f18e44538 | ||
|
|
d02fc365b4 | ||
|
|
ab318de8b7 | ||
|
|
e4b5027c5e | ||
|
|
c19321ed9e | ||
|
|
ff607adc69 | ||
|
|
4540effd6c | ||
|
|
5fc9918a20 | ||
|
|
94fee8486f | ||
|
|
e19dce0aed | ||
|
|
1322aa2ba2 | ||
|
|
2af05ac558 | ||
|
|
3d2734185b | ||
|
|
9004ef65df | ||
|
|
3fd29e549d | ||
|
|
ccf16a25d2 | ||
|
|
cc0987f7b1 | ||
|
|
42ffdf882f | ||
|
|
393d8c7606 | ||
|
|
5f1f43af4d | ||
|
|
4d60d61dec | ||
|
|
da68fa4079 | ||
|
|
6ddc86a3d1 | ||
|
|
30fd4c6cdb | ||
|
|
01534d9bd5 | ||
|
|
381a865822 | ||
|
|
2b54ce30ae | ||
|
|
bb649de1ad | ||
|
|
4518b9ea7a | ||
|
|
32f9a7c7bc | ||
|
|
10062e8111 | ||
|
|
4fb0837220 | ||
|
|
e8e7d1fab3 | ||
|
|
037da3ce34 | ||
|
|
ee45a59b4e | ||
|
|
932379b19f | ||
|
|
be1d31fa8a | ||
|
|
9f132fc1b0 | ||
|
|
dc21e3bb1e | ||
|
|
5b29483ab1 | ||
|
|
001e0c1f65 | ||
|
|
5a94909654 | ||
|
|
8ae8a5c174 | ||
|
|
759598f737 | ||
|
|
267b6f595c | ||
|
|
361efd28c9 | ||
|
|
66dfe18c36 | ||
|
|
9b667fc534 | ||
|
|
94e170763e | ||
|
|
8343a11a6b | ||
|
|
1c5a4d01c9 | ||
|
|
eb6698002c | ||
|
|
51eb877a15 | ||
|
|
db4d0c0abc | ||
|
|
8e023ffd06 | ||
|
|
2e9cad224d | ||
|
|
cfef9bf856 | ||
|
|
0204b8dd28 | ||
|
|
5d3edb1d40 | ||
|
|
5ec53fff0c | ||
|
|
dbb0164934 | ||
|
|
40ae49effa | ||
|
|
f336f0b83c | ||
|
|
21e53aea9e | ||
|
|
f3a6d13965 | ||
|
|
183601c347 | ||
|
|
fa6e6603fa | ||
|
|
5361b5cf04 | ||
|
|
8cb3316afb | ||
|
|
63603876bf | ||
|
|
6317fce9cb | ||
|
|
8158597f84 | ||
|
|
8d557c19d5 | ||
|
|
f59c52bc16 | ||
|
|
faff198777 | ||
|
|
fde573bab2 | ||
|
|
de6997a203 | ||
|
|
ee63fdb056 | ||
|
|
93e716e775 | ||
|
|
756597e6ad | ||
|
|
328b7bee75 | ||
|
|
78022740fc | ||
|
|
4c0f51df81 | ||
|
|
b57922552e | ||
|
|
58ee283658 | ||
|
|
299ed8cb39 | ||
|
|
2a13508379 | ||
|
|
067496b129 | ||
|
|
3e0ddaf5bc | ||
|
|
d44af743db | ||
|
|
f8a0f9ffd3 | ||
|
|
d007559c38 | ||
|
|
84db697cd6 | ||
|
|
2a5fbf0fd6 | ||
|
|
7db148706a | ||
|
|
f56a9f3b3b | ||
|
|
1ff586cda1 | ||
|
|
314512ae14 | ||
|
|
2247089381 | ||
|
|
92409aa4d6 | ||
|
|
52fb51db77 | ||
|
|
3f86972e46 | ||
|
|
5942726d25 | ||
|
|
ae976a90a5 | ||
|
|
4481c41368 | ||
|
|
aa983566c4 | ||
|
|
6f8f2a012b | ||
|
|
f7f467b042 | ||
|
|
a715b83e67 | ||
|
|
6c5064b437 | ||
|
|
30e43550bb | ||
|
|
4265a59892 | ||
|
|
d9af49a7af | ||
|
|
58d6c16d12 | ||
|
|
6068497409 | ||
|
|
c8c0aeda76 | ||
|
|
489a62e788 | ||
|
|
63443acc2b | ||
|
|
0805add3a4 |
8
.github/pull_request_template.md
vendored
8
.github/pull_request_template.md
vendored
@@ -35,19 +35,17 @@ If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta
|
||||
- Related #
|
||||
- [ ] This PR fixes a bug or regression
|
||||
|
||||
## Root Cause / Regression History (if applicable)
|
||||
## Root Cause (if applicable)
|
||||
|
||||
For bug fixes or regressions, explain why this happened, not just what changed. Otherwise write `N/A`. If the cause is unclear, write `Unknown`.
|
||||
|
||||
- Root cause:
|
||||
- Missing detection / guardrail:
|
||||
- Prior context (`git blame`, prior PR, issue, or refactor if known):
|
||||
- Why this regressed now:
|
||||
- If unknown, what was ruled out:
|
||||
- Contributing context (if known):
|
||||
|
||||
## Regression Test Plan (if applicable)
|
||||
|
||||
For bug fixes or regressions, name the smallest reliable test coverage that should have caught this. Otherwise write `N/A`.
|
||||
For bug fixes or regressions, name the smallest reliable test coverage that should catch this. Otherwise write `N/A`.
|
||||
|
||||
- Coverage level that should have caught this:
|
||||
- [ ] Unit test
|
||||
|
||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -15,6 +15,9 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/browser seams: split browser and WhatsApp plugin-sdk seams into narrower browser, approval-auth, and target-helper facades so hot paths and owner tests avoid broader runtime fan-out. (#60376) Thanks @shakkernerd.
|
||||
- Tests/runtime: trim local unit-test import/runtime fan-out across browser, WhatsApp, cron, task, and reply flows so owner suites start faster with lower shared-worker overhead while preserving the same focused behavior coverage. (#60249) Thanks @shakkernerd.
|
||||
- Tests/secrets runtime: restore split secrets suite cache and env isolation cleanup so broader runs do not leak stale plugin or provider snapshot state. (#60395) Thanks @shakkernerd.
|
||||
- Providers/Ollama: add bundled Ollama Web Search provider for key-free web_search via your configured Ollama host and `ollama signin`. (#59318) Thanks @BruceMacD.
|
||||
- Plugins/install: add `openclaw plugins install --force` to overwrite existing plugin and hook-pack install targets without using the dangerous-code override flag. (#60544) Thanks @gumadeiras.
|
||||
- Providers/transport: add shared proxy/TLS/auth-aware request transport support across model-provider paths, including Anthropic and Google native transport runtimes, so provider request overrides work beyond OpenAI-family traffic.
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -28,6 +31,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/local Bot API: thread `channels.telegram.apiRoot` through buffered reply-media and album downloads so self-hosted Bot API file paths stop falling back to `api.telegram.org` and 404ing. (#59544) Thanks @SARAMALI15792.
|
||||
- Telegram/replies: preserve explicit topic targets when `replyTo` is present while still inheriting the current topic for same-chat replies without an explicit topic. (#59634) Thanks @dashhuang.
|
||||
- Telegram/native commands: clean up metadata-driven progress placeholders when replies fall back, edits fail, or local exec approval prompts are suppressed. (#59300) Thanks @jalehman.
|
||||
- Telegram/models: compare full provider/model refs in the Telegram picker so same-id models from other providers no longer show the wrong current-model checkmark. (#60384) Thanks @sfuminya.
|
||||
- Media/request overrides: resolve shared and capability-filtered media request SecretRefs correctly and expose media transport override fields to schema-driven config consumers. (#59848) Thanks @vincentkoc.
|
||||
- Providers/request overrides: stop advertising unsupported proxy and TLS transport settings on `models.providers.*.request`, and fail closed if unvalidated config tries to route LLM model-provider traffic through dead transport fields. (#59682) Thanks @vincentkoc.
|
||||
- Discord/mentions: treat `@everyone` and `@here` as valid mention-gate triggers in guild preflight so mention-required bots still respond to those broadcasts. (#60343) Thanks @geekhuashan.
|
||||
@@ -36,6 +40,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Ollama/auth: prefer real cloud auth over local marker during model auth resolution so cloud-backed Ollama auth does not get shadowed by stale local-only markers.
|
||||
- Plugins/Kimi Coding: parse tagged Kimi tool-call text into structured tool calls on the provider stream path so tools execute instead of echoing raw markup. (#60051) Thanks @obviyus.
|
||||
- Channels/passive hooks: emit passive message hooks for mention-skipped Telegram and Signal group messages when `ingest` is enabled, including wildcard/default fallback and per-group override handling. (#60018) Thanks @obviyus.
|
||||
- Providers/compat: stop forcing OpenAI-only payload defaults on proxy and custom OpenAI-compatible routes, and preserve native vendor-specific reasoning, tool, and streaming behavior for Anthropic-compatible, Moonshot, Mistral, ModelStudio, OpenRouter, xAI, Z.ai, and other routed provider paths.
|
||||
- Plugins/manifest registry: stop warning when an explicit manifest `id` intentionally differs from the discovery hint. (#59185) Thanks @samzong.
|
||||
- WhatsApp/streaming: honor `channels.whatsapp.blockStreaming` again for inbound auto-replies so progressive block replies can be enabled explicitly instead of being forced to final-only delivery. Thanks @mcaxtr.
|
||||
- Auth/failover: shorten `auth_permanent` lockouts, add dedicated config knobs for permanent-auth backoff, and downgrade ambiguous auth-ish upstream incidents to retryable auth failures so providers recover automatically after transient outages. (#60404) Thanks @extrasmall0.
|
||||
@@ -43,6 +48,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/runtime: reuse compatible active registries for `web_search` and `web_fetch` provider snapshot resolution so repeated runtime reads do not re-import the same bundled plugin set on each agent message. Related #48380.
|
||||
- Infra/tailscale: ignore `OPENCLAW_TEST_TAILSCALE_BINARY` outside explicit test environments and block it from workspace `.env`, so test-only binary overrides cannot be injected through trusted repository state. (#58468) Thanks @eleqtrizit.
|
||||
- Plugins/OpenAI: enable reference-image edits for `gpt-image-1` by routing edit calls to `/images/edits` with multipart image uploads, and update image-generation capability/docs metadata accordingly. Thanks @steipete.
|
||||
- Cache/context guard: compact newest tool results first so the cached prompt prefix stays byte-identical and avoids full re-tokenization every turn past the 75% context threshold. (#58036) Thanks @bcherny.
|
||||
- Agents/tools: include value-shape hints in missing-parameter tool errors so dropped, empty-string, and wrong-type write payloads are easier to diagnose from logs. (#55317) Thanks @priyansh19.
|
||||
- Android/assistant: keep queued App Actions prompts pending when auto-send enqueue is rejected, so transient chat-health drops do not silently lose the assistant request. Thanks @obviyus.
|
||||
- Plugins/startup: migrate legacy `tools.web.search.<provider>` config before strict startup validation, and record plugin failure phase/timestamp so degraded plugin startup is easier to diagnose from logs and `plugins list`.
|
||||
@@ -77,6 +83,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/allowlists: let explicit bundled chat channel enablement bypass `plugins.allow`, while keeping auto-enabled channel activation and startup sidecars behind restrictive allowlists. (#60233) Thanks @dorukardahan.
|
||||
- Allowlist/commands: require owner access for `/allowlist add` and `/allowlist remove` so command-authorized non-owners cannot mutate persisted allowlists. (#59836) Thanks @eleqtrizit.
|
||||
- Control UI/skills: clear stale ClawHub results immediately when the search query changes, so debounced searches cannot keep outdated install targets visible. Related #60134.
|
||||
- Fetch/redirects: normalize guarded redirect method rewriting and loop detection so SSRF-guarded requests match platform redirect behavior without missing loops back to the original URL. (#59121) Thanks @eleqtrizit.
|
||||
- Discord/ack reactions: keep automatic ACK reaction auth on the active hydrated Discord account so SecretRef-backed and non-default-account reactions stop falling back to stale default config resolution. (#60081) Thanks @FunJim.
|
||||
- Telegram/model switching: render non-default `/model` callback confirmations with HTML formatting so Telegram shows the selected model in bold instead of raw `**...**` markers. (#60042) Thanks @GitZhangChi.
|
||||
- Plugins/update: allow `openclaw plugins update` to use `--dangerously-force-unsafe-install` for built-in dangerous-code false positives during plugin updates. (#60066) Thanks @huntharo.
|
||||
@@ -85,7 +92,16 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/voice: make READY auto-join fire-and-forget while keeping the shorter initial voice-connect timeout separate from the longer playback-start wait. (#60345) Thanks @geekhuashan.
|
||||
- Agents/skills: add inherited `agents.defaults.skills` allowlists, make per-agent `agents.list[].skills` replace defaults instead of merging, and scope embedded, session, sandbox, and cron skill snapshots through the effective runtime agent. (#59992) Thanks @gumadeiras.
|
||||
- Matrix/Telegram exec approvals: recover stored same-channel account bindings even when session reply state drifted to another channel, so foreign-channel approvals route to the bound account instead of fanning out or being rejected as ambiguous. (#60417) thanks @gumadeiras.
|
||||
- Sessions/forking: fall back to transcript-estimated parent token counts when cached totals are stale or missing, so oversized thread forks start fresh instead of cloning the full parent transcript. (#60463) Thanks @jalehman.
|
||||
- Slack/app manifest: set `bot_user.always_online` to `true` in the onboarding and example Slack app manifest so the Slack app appears ready to respond.
|
||||
- Gateway/websocket auth: refresh auth on new websocket connects after secrets reload so rotated gateway tokens take effect immediately without requiring a restart. (#60323) Thanks @mappel-nv.
|
||||
- Onboarding/plugins: keep non-interactive auth-choice inference scoped to bundled and already-trusted plugins so untrusted workspace manifests cannot hijack built-in provider API-key flows. (#59120) Thanks @eleqtrizit.
|
||||
- Agents/workspace: respect `agents.defaults.workspace` for non-default agents by resolving them under the configured base path instead of falling back to `workspace-<id>`. (#59858) Thanks @joelnishanth.
|
||||
- Config/All Settings: keep the raw config view intact when sensitive fields are blank instead of corrupting or dropping the snapshot during redaction. (#28214) thanks @solodmd.
|
||||
- Plugins/runtime: honor explicit capability allowlists during fallback speech, media-understanding, and image-generation provider loading so bundled capability plugins do not bypass restrictive `plugins.allow` config. (#52262) Thanks @PerfectPan.
|
||||
- Hooks/tool policy: block tool calls when a `before_tool_call` hook crashes so hook failures fail closed instead of silently allowing execution. (#59822) Thanks @pgondhi987.
|
||||
- Matrix/media: surface a dedicated `[matrix <kind> attachment too large]` marker for oversized inbound media instead of the generic unavailable marker, and classify size-limit failures with a typed Matrix error. (#60289) Thanks @efe-arv.
|
||||
- WhatsApp/watchdog: reset watchdog timeout after reconnect so quiet channels no longer enter a tight reconnect loop from stale message timestamps carried across connection runs. (#60007) Thanks @MonkeyLeeT.
|
||||
- Agents/fallback: persist selected fallback overrides before retry attempts start, prefer persisted overrides during live-session reconciliation, and keep provider-scoped auth-profile failover from snapping retries back to stale primary selections.
|
||||
|
||||
## 2026.4.2
|
||||
|
||||
@@ -115,6 +131,10 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sandbox/security: block credential-path binds even when sandbox home paths resolve through canonical aliases, so agent containers cannot mount user secret stores through alternate home-directory paths. (#59157) Thanks @eleqtrizit.
|
||||
|
||||
## 2026.4.1-beta.1
|
||||
|
||||
- Providers/transport policy: centralize request auth, proxy, TLS, and header shaping across shared HTTP, stream, and websocket paths, block insecure TLS/runtime transport overrides, and keep proxy-hop TLS separate from target mTLS settings. (#59682) Thanks @vincentkoc.
|
||||
- Providers/OpenRouter: gate documented OpenRouter attribution to native OpenRouter endpoints or the default route so custom proxy base URLs do not inherit OpenRouter request headers.
|
||||
- Providers/Copilot: classify native GitHub Copilot API hosts in the shared provider endpoint resolver and harden token-derived proxy endpoint parsing so Copilot base URL routing stays centralized and fails closed on malformed hints. (#59644) Thanks @vincentkoc.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "1c9c9d251b760ed3234ecff741a88eb4bf42315ad6f50ac7392b187cf226c16c",
|
||||
"originHash" : "fb90e7b1977f43661ac91681d16da11f9ddd85630407ef170eaada0a6ee39972",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "axorcist",
|
||||
@@ -24,7 +24,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12279,7 +12279,7 @@
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hooks Enabled",
|
||||
"help": "Enables processing for internal hook handlers and configured entries in the internal hook runtime. Keep disabled unless internal hook handlers are intentionally configured.",
|
||||
"help": "Enables processing for internal hooks and configured entries in the internal hook runtime. Keep disabled unless internal hooks are intentionally configured.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -12345,72 +12345,6 @@
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers",
|
||||
"kind": "core",
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Handlers",
|
||||
"help": "List of internal event handlers mapping event names to modules and optional exports. Keep handler definitions explicit so event-to-code routing is auditable.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*",
|
||||
"kind": "core",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.event",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Event",
|
||||
"help": "Internal event name that triggers this handler module when emitted by the runtime. Use stable event naming conventions to avoid accidental overlap across handlers.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.export",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Export",
|
||||
"help": "Optional named export for the internal hook handler function when module default export is not used. Set this when one module ships multiple handler entrypoints.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.module",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Module",
|
||||
"help": "Safe relative module path for the internal hook handler implementation loaded at runtime. Keep module files in reviewed directories and avoid dynamic path composition.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.installs",
|
||||
"kind": "core",
|
||||
|
||||
@@ -12278,7 +12278,7 @@
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hooks Enabled",
|
||||
"help": "Enables processing for internal hook handlers and configured entries in the internal hook runtime. Keep disabled unless internal hook handlers are intentionally configured.",
|
||||
"help": "Enables processing for internal hooks and configured entries in the internal hook runtime. Keep disabled unless internal hooks are intentionally configured.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -12344,72 +12344,6 @@
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers",
|
||||
"kind": "core",
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Handlers",
|
||||
"help": "List of internal event handlers mapping event names to modules and optional exports. Keep handler definitions explicit so event-to-code routing is auditable.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*",
|
||||
"kind": "core",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.event",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Event",
|
||||
"help": "Internal event name that triggers this handler module when emitted by the runtime. Use stable event naming conventions to avoid accidental overlap across handlers.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.export",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Export",
|
||||
"help": "Optional named export for the internal hook handler function when module default export is not used. Set this when one module ships multiple handler entrypoints.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.module",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Module",
|
||||
"help": "Safe relative module path for the internal hook handler implementation loaded at runtime. Keep module files in reviewed directories and avoid dynamic path composition.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.installs",
|
||||
"kind": "core",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -215,6 +215,10 @@
|
||||
"source": "FAQ",
|
||||
"target": "常见问题"
|
||||
},
|
||||
{
|
||||
"source": "Ollama Web Search",
|
||||
"target": "Ollama Web 搜索"
|
||||
},
|
||||
{
|
||||
"source": "onboarding",
|
||||
"target": "新手引导"
|
||||
|
||||
@@ -392,7 +392,7 @@ Notes:
|
||||
## Manifest and scope checklist
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Slack app manifest example">
|
||||
<Accordion title="Slack app manifest example" defaultOpen>
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -403,7 +403,7 @@ Notes:
|
||||
"features": {
|
||||
"bot_user": {
|
||||
"display_name": "OpenClaw",
|
||||
"always_online": false
|
||||
"always_online": true
|
||||
},
|
||||
"app_home": {
|
||||
"messages_tab_enabled": true,
|
||||
|
||||
@@ -308,7 +308,7 @@ Manage extensions and their config:
|
||||
|
||||
- `openclaw plugins list` — discover plugins (use `--json` for machine output).
|
||||
- `openclaw plugins inspect <id>` — show details for a plugin (`info` is an alias).
|
||||
- `openclaw plugins install <path|.tgz|npm-spec|plugin@marketplace>` — install a plugin (or add a plugin path to `plugins.load.paths`).
|
||||
- `openclaw plugins install <path|.tgz|npm-spec|plugin@marketplace>` — install a plugin (or add a plugin path to `plugins.load.paths`; use `--force` to overwrite an existing install target).
|
||||
- `openclaw plugins marketplace list <marketplace>` — list marketplace entries before install.
|
||||
- `openclaw plugins enable <id>` / `disable <id>` — toggle `plugins.entries.<id>.enabled`.
|
||||
- `openclaw plugins doctor` — report plugin load errors.
|
||||
|
||||
@@ -48,6 +48,7 @@ capabilities.
|
||||
```bash
|
||||
openclaw plugins install <package> # ClawHub first, then npm
|
||||
openclaw plugins install clawhub:<package> # ClawHub only
|
||||
openclaw plugins install <package> --force # overwrite existing install
|
||||
openclaw plugins install <package> --pin # pin version
|
||||
openclaw plugins install <package> --dangerously-force-unsafe-install
|
||||
openclaw plugins install <path> # local path
|
||||
@@ -58,6 +59,10 @@ openclaw plugins install <plugin> --marketplace <name> # marketplace (explicit)
|
||||
Bare package names are checked against ClawHub first, then npm. Security note:
|
||||
treat plugin installs like running code. Prefer pinned versions.
|
||||
|
||||
`--force` reuses the existing install target and overwrites an already-installed
|
||||
plugin or hook pack in place. Use it when you are intentionally reinstalling
|
||||
the same id from a new local path, archive, ClawHub package, or npm artifact.
|
||||
|
||||
`--dangerously-force-unsafe-install` is a break-glass option for false positives
|
||||
in the built-in dangerous-code scanner. It allows the install to continue even
|
||||
when the built-in scanner reports `critical` findings, but it does **not**
|
||||
@@ -157,6 +162,9 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
openclaw plugins install -l ./my-plugin
|
||||
```
|
||||
|
||||
`--force` is not supported with `--link` because linked installs reuse the
|
||||
source path instead of copying over a managed install target.
|
||||
|
||||
Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in
|
||||
`plugins.installs` while keeping the default behavior unpinned.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ summary: "How OpenClaw rotates auth profiles and falls back across models"
|
||||
read_when:
|
||||
- Diagnosing auth profile rotation, cooldowns, or model fallback behavior
|
||||
- Updating failover rules for auth profiles or models
|
||||
- Understanding how session model overrides interact with fallback retries
|
||||
title: "Model Failover"
|
||||
---
|
||||
|
||||
@@ -15,6 +16,44 @@ OpenClaw handles failures in two stages:
|
||||
|
||||
This doc explains the runtime rules and the data that backs them.
|
||||
|
||||
## Runtime flow
|
||||
|
||||
For a normal text run, OpenClaw evaluates candidates in this order:
|
||||
|
||||
1. The currently selected session model.
|
||||
2. Configured `agents.defaults.model.fallbacks` in order.
|
||||
3. The configured primary model at the end when the run started from an override.
|
||||
|
||||
Inside each candidate, OpenClaw tries auth-profile failover before advancing to
|
||||
the next model candidate.
|
||||
|
||||
High-level sequence:
|
||||
|
||||
1. Resolve the active session model and auth-profile preference.
|
||||
2. Build the model candidate chain.
|
||||
3. Try the current provider with auth-profile rotation/cooldown rules.
|
||||
4. If that provider is exhausted with a failover-worthy error, move to the next
|
||||
model candidate.
|
||||
5. Persist the selected fallback override before the retry starts so other
|
||||
session readers see the same provider/model the runner is about to use.
|
||||
6. If the fallback candidate fails, roll back only the fallback-owned session
|
||||
override fields when they still match that failed candidate.
|
||||
7. If every candidate fails, throw a `FallbackSummaryError` with per-attempt
|
||||
detail and the soonest cooldown expiry when one is known.
|
||||
|
||||
This is intentionally narrower than "save and restore the whole session". The
|
||||
reply runner only persists the model-selection fields it owns for fallback:
|
||||
|
||||
- `providerOverride`
|
||||
- `modelOverride`
|
||||
- `authProfileOverride`
|
||||
- `authProfileOverrideSource`
|
||||
- `authProfileOverrideCompactionCount`
|
||||
|
||||
That prevents a failed fallback retry from overwriting newer unrelated session
|
||||
mutations such as manual `/model` changes or session rotation updates that
|
||||
happened while the attempt was running.
|
||||
|
||||
## Auth storage (keys + OAuth)
|
||||
|
||||
OpenClaw uses **auth profiles** for both API keys and OAuth tokens.
|
||||
@@ -148,6 +187,102 @@ with `auth.cooldowns.overloadedProfileRotations`,
|
||||
When a run starts with a model override (hooks or CLI), fallbacks still end at
|
||||
`agents.defaults.model.primary` after trying any configured fallbacks.
|
||||
|
||||
### Candidate chain rules
|
||||
|
||||
OpenClaw builds the candidate list from the currently requested `provider/model`
|
||||
plus configured fallbacks.
|
||||
|
||||
Rules:
|
||||
|
||||
- The requested model is always first.
|
||||
- Explicit configured fallbacks are deduplicated but not filtered by the model
|
||||
allowlist. They are treated as explicit operator intent.
|
||||
- If the current run is already on a configured fallback in the same provider
|
||||
family, OpenClaw keeps using the full configured chain.
|
||||
- If the current run is on a different provider than config and that current
|
||||
model is not already part of the configured fallback chain, OpenClaw does not
|
||||
append unrelated configured fallbacks from another provider.
|
||||
- When the run started from an override, the configured primary is appended at
|
||||
the end so the chain can settle back onto the normal default once earlier
|
||||
candidates are exhausted.
|
||||
|
||||
### Which errors advance fallback
|
||||
|
||||
Model fallback continues on:
|
||||
|
||||
- auth failures
|
||||
- rate limits and cooldown exhaustion
|
||||
- overloaded/provider-busy errors
|
||||
- timeout-shaped failover errors
|
||||
- billing disables
|
||||
- `LiveSessionModelSwitchError`, which is normalized into a failover path so a
|
||||
stale persisted model does not create an outer retry loop
|
||||
- other unrecognized errors when there are still remaining candidates
|
||||
|
||||
Model fallback does not continue on:
|
||||
|
||||
- explicit aborts that are not timeout/failover-shaped
|
||||
- context overflow errors that should stay inside compaction/retry logic
|
||||
- a final unknown error when there are no candidates left
|
||||
|
||||
### Cooldown skip vs probe behavior
|
||||
|
||||
When every auth profile for a provider is already in cooldown, OpenClaw does
|
||||
not automatically skip that provider forever. It makes a per-candidate decision:
|
||||
|
||||
- Persistent auth failures skip the whole provider immediately.
|
||||
- Billing disables usually skip, but the primary candidate can still be probed
|
||||
on a throttle so recovery is possible without restarting.
|
||||
- The primary candidate may be probed near cooldown expiry, with a per-provider
|
||||
throttle.
|
||||
- Same-provider fallback siblings can be attempted despite cooldown when the
|
||||
failure looks transient (`rate_limit`, `overloaded`, or unknown).
|
||||
- Transient cooldown probes are limited to one per provider per fallback run so
|
||||
a single provider does not stall cross-provider fallback.
|
||||
|
||||
## Session overrides and live model switching
|
||||
|
||||
Session model changes are shared state. The active runner, `/model` command,
|
||||
compaction/session updates, and live-session reconciliation all read or write
|
||||
parts of the same session entry.
|
||||
|
||||
That means fallback retries have to coordinate with live model switching:
|
||||
|
||||
- Before a fallback retry starts, the reply runner persists the selected
|
||||
fallback override fields to the session entry.
|
||||
- Live-session reconciliation prefers persisted session overrides over stale
|
||||
runtime model fields.
|
||||
- If the fallback attempt fails, the runner rolls back only the override fields
|
||||
it wrote, and only if they still match that failed candidate.
|
||||
|
||||
This prevents the classic race:
|
||||
|
||||
1. Primary fails.
|
||||
2. Fallback candidate is chosen in memory.
|
||||
3. Session store still says the old primary.
|
||||
4. Live-session reconciliation reads the stale session state.
|
||||
5. The retry gets snapped back to the old model before the fallback attempt
|
||||
starts.
|
||||
|
||||
The persisted fallback override closes that window, and the narrow rollback
|
||||
keeps newer manual or runtime session changes intact.
|
||||
|
||||
## Observability and failure summaries
|
||||
|
||||
`runWithModelFallback(...)` records per-attempt details that feed logs and
|
||||
user-facing cooldown messaging:
|
||||
|
||||
- provider/model attempted
|
||||
- reason (`rate_limit`, `overloaded`, `billing`, `auth`, `model_not_found`, and
|
||||
similar failover reasons)
|
||||
- optional status/code
|
||||
- human-readable error summary
|
||||
|
||||
When every candidate fails, OpenClaw throws `FallbackSummaryError`. The outer
|
||||
reply runner can use that to build a more specific message such as "all models
|
||||
are temporarily rate-limited" and include the soonest cooldown expiry when one
|
||||
is known.
|
||||
|
||||
## Related config
|
||||
|
||||
See [Gateway configuration](/gateway/configuration) for:
|
||||
|
||||
@@ -16,6 +16,8 @@ For model selection rules, see [/concepts/models](/concepts/models).
|
||||
- Model refs use `provider/model` (example: `opencode/claude-opus-4-6`).
|
||||
- If you set `agents.defaults.models`, it becomes the allowlist.
|
||||
- CLI helpers: `openclaw onboard`, `openclaw models list`, `openclaw models set <provider/model>`.
|
||||
- Fallback runtime rules, cooldown probes, and session-override persistence are
|
||||
documented in [/concepts/model-failover](/concepts/model-failover).
|
||||
- Provider plugins can inject model catalogs via `registerProvider({ catalog })`;
|
||||
OpenClaw merges that output into `models.providers` before writing
|
||||
`models.json`.
|
||||
|
||||
@@ -1177,6 +1177,7 @@
|
||||
"tools/gemini-search",
|
||||
"tools/grok-search",
|
||||
"tools/kimi-search",
|
||||
"tools/ollama-search",
|
||||
"tools/perplexity-search",
|
||||
"tools/searxng-search",
|
||||
"tools/tavily"
|
||||
|
||||
@@ -1410,16 +1410,24 @@ for usage/billing and raise limits as needed.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="How do I enable web search (and web fetch)?">
|
||||
`web_fetch` works without an API key. `web_search` requires a key for your
|
||||
selected provider (Brave, Gemini, Grok, Kimi, or Perplexity).
|
||||
`web_fetch` works without an API key. `web_search` depends on your selected
|
||||
provider:
|
||||
|
||||
- API-backed providers such as Brave, Exa, Firecrawl, Gemini, Grok, Kimi, Perplexity, and Tavily require their normal API key setup.
|
||||
- Ollama Web Search is key-free, but it uses your configured Ollama host and requires `ollama signin`.
|
||||
- DuckDuckGo is key-free, but it is an unofficial HTML-based integration.
|
||||
|
||||
**Recommended:** run `openclaw configure --section web` and choose a provider.
|
||||
Environment alternatives:
|
||||
|
||||
- Brave: `BRAVE_API_KEY`
|
||||
- Exa: `EXA_API_KEY`
|
||||
- Firecrawl: `FIRECRAWL_API_KEY`
|
||||
- Gemini: `GEMINI_API_KEY`
|
||||
- Grok: `XAI_API_KEY`
|
||||
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
|
||||
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||
- Tavily: `TAVILY_API_KEY`
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -2080,13 +2088,13 @@ for usage/billing and raise limits as needed.
|
||||
|
||||
1. Install Ollama from `https://ollama.com/download`
|
||||
2. Pull a local model such as `ollama pull glm-4.7-flash`
|
||||
3. If you want Ollama Cloud too, run `ollama signin`
|
||||
3. If you want cloud models too, run `ollama signin`
|
||||
4. Run `openclaw onboard` and choose `Ollama`
|
||||
5. Pick `Local` or `Cloud + Local`
|
||||
|
||||
Notes:
|
||||
|
||||
- `Cloud + Local` gives you Ollama Cloud models plus your local Ollama models
|
||||
- `Cloud + Local` gives you cloud models plus your local Ollama models
|
||||
- cloud models such as `kimi-k2.5:cloud` do not need a local pull
|
||||
- for manual switching, use `openclaw models list` and `openclaw models set ollama/<model>`
|
||||
|
||||
|
||||
@@ -235,6 +235,33 @@ To use cloud models, select **Cloud + Local** mode during setup. The wizard chec
|
||||
|
||||
You can also sign in directly at [ollama.com/signin](https://ollama.com/signin).
|
||||
|
||||
## Ollama Web Search
|
||||
|
||||
OpenClaw also supports **Ollama Web Search** as a bundled `web_search`
|
||||
provider.
|
||||
|
||||
- It uses your configured Ollama host (`models.providers.ollama.baseUrl` when
|
||||
set, otherwise `http://127.0.0.1:11434`).
|
||||
- It is key-free.
|
||||
- It requires Ollama to be running and signed in with `ollama signin`.
|
||||
|
||||
Choose **Ollama Web Search** during `openclaw onboard` or
|
||||
`openclaw configure --section web`, or set:
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "ollama",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-search).
|
||||
|
||||
## Advanced
|
||||
|
||||
### Reasoning models
|
||||
|
||||
@@ -77,13 +77,18 @@ See [Memory](/concepts/memory).
|
||||
|
||||
### 4) Web search tool
|
||||
|
||||
`web_search` uses API keys and may incur usage charges depending on your provider:
|
||||
`web_search` may incur usage charges depending on your provider:
|
||||
|
||||
- **Brave Search API**: `BRAVE_API_KEY` or `plugins.entries.brave.config.webSearch.apiKey`
|
||||
- **Exa**: `EXA_API_KEY` or `plugins.entries.exa.config.webSearch.apiKey`
|
||||
- **Firecrawl**: `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webSearch.apiKey`
|
||||
- **Gemini (Google Search)**: `GEMINI_API_KEY` or `plugins.entries.google.config.webSearch.apiKey`
|
||||
- **Grok (xAI)**: `XAI_API_KEY` or `plugins.entries.xai.config.webSearch.apiKey`
|
||||
- **Kimi (Moonshot)**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `plugins.entries.moonshot.config.webSearch.apiKey`
|
||||
- **Ollama Web Search**: key-free, but requires a reachable Ollama host plus `ollama signin`
|
||||
- **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `plugins.entries.perplexity.config.webSearch.apiKey`
|
||||
- **Tavily**: `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey`
|
||||
- **DuckDuckGo**: key-free fallback (no API billing, but unofficial and HTML-based)
|
||||
|
||||
Legacy `tools.web.search.*` provider paths still load through the temporary compatibility shim, but they are no longer the recommended config surface.
|
||||
|
||||
|
||||
@@ -100,8 +100,8 @@ For a high-level overview, see [Onboarding (CLI)](/start/wizard).
|
||||
- DM security: default is pairing. First DM sends a code; approve via `openclaw pairing approve <channel> <code>` or use allowlists.
|
||||
</Step>
|
||||
<Step title="Web search">
|
||||
- Pick a provider: Perplexity, Brave, Gemini, Grok, or Kimi (or skip).
|
||||
- Paste your API key (QuickStart auto-detects keys from env vars or existing config).
|
||||
- Pick a supported provider such as Brave, Firecrawl, Gemini, Grok, Kimi, Ollama Web Search, Perplexity, or Tavily (or skip).
|
||||
- API-backed providers can use env vars or existing config for quick setup; key-free providers use their provider-specific prerequisites instead.
|
||||
- Skip with `--skip-search`.
|
||||
- Configure later: `openclaw configure --section web`.
|
||||
</Step>
|
||||
|
||||
@@ -36,8 +36,9 @@ openclaw agents add <name>
|
||||
|
||||
<Tip>
|
||||
CLI onboarding includes a web search step where you can pick a provider
|
||||
(Perplexity, Brave, Gemini, Grok, or Kimi) and paste your API key so the agent
|
||||
can use `web_search`. You can also configure this later with
|
||||
such as Brave, Firecrawl, Gemini, Grok, Kimi, Ollama Web Search, Perplexity,
|
||||
or Tavily. Some providers require an API key, while others are key-free. You
|
||||
can also configure this later with
|
||||
`openclaw configure --section web`. Docs: [Web tools](/tools/web).
|
||||
</Tip>
|
||||
|
||||
|
||||
94
docs/tools/ollama-search.md
Normal file
94
docs/tools/ollama-search.md
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
summary: "Ollama Web Search via your configured Ollama host"
|
||||
read_when:
|
||||
- You want to use Ollama for web_search
|
||||
- You want a key-free web_search provider
|
||||
- You need Ollama Web Search setup guidance
|
||||
title: "Ollama Web Search"
|
||||
---
|
||||
|
||||
# Ollama Web Search
|
||||
|
||||
OpenClaw supports **Ollama Web Search** as a bundled `web_search` provider.
|
||||
It uses Ollama's experimental web-search API and returns structured results
|
||||
with titles, URLs, and snippets.
|
||||
|
||||
Unlike the Ollama model provider, this setup does not need an API key. It
|
||||
does require:
|
||||
|
||||
- an Ollama host that is reachable from OpenClaw
|
||||
- `ollama signin`
|
||||
|
||||
## Setup
|
||||
|
||||
<Steps>
|
||||
<Step title="Start Ollama">
|
||||
Make sure Ollama is installed and running.
|
||||
</Step>
|
||||
<Step title="Sign in">
|
||||
Run:
|
||||
|
||||
```bash
|
||||
ollama signin
|
||||
```
|
||||
|
||||
</Step>
|
||||
<Step title="Choose Ollama Web Search">
|
||||
Run:
|
||||
|
||||
```bash
|
||||
openclaw configure --section web
|
||||
```
|
||||
|
||||
Then select **Ollama Web Search** as the provider.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
If you already use Ollama for models, Ollama Web Search reuses the same
|
||||
configured host.
|
||||
|
||||
## Config
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "ollama",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Optional Ollama host override:
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://ollama-host:11434",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
If no explicit Ollama base URL is set, OpenClaw uses `http://127.0.0.1:11434`.
|
||||
|
||||
## Notes
|
||||
|
||||
- No API key field is required for this provider.
|
||||
- OpenClaw warns during setup if Ollama is unreachable or not signed in, but
|
||||
it does not block selection.
|
||||
- Runtime auto-detect can fall back to Ollama Web Search when no higher-priority
|
||||
credentialed provider is configured.
|
||||
- The provider uses Ollama's experimental `/api/experimental/web_search`
|
||||
endpoint.
|
||||
|
||||
## Related
|
||||
|
||||
- [Web Search overview](/tools/web) -- all providers and auto-detection
|
||||
- [Ollama](/providers/ollama) -- Ollama model setup and cloud/local modes
|
||||
@@ -209,6 +209,7 @@ openclaw plugins doctor # diagnostics
|
||||
|
||||
openclaw plugins install <package> # install (ClawHub first, then npm)
|
||||
openclaw plugins install clawhub:<pkg> # install from ClawHub only
|
||||
openclaw plugins install <spec> --force # overwrite existing install
|
||||
openclaw plugins install <path> # install from local path
|
||||
openclaw plugins install -l <path> # link (no copy) for dev
|
||||
openclaw plugins install <spec> --dangerously-force-unsafe-install
|
||||
@@ -220,6 +221,10 @@ openclaw plugins enable <id>
|
||||
openclaw plugins disable <id>
|
||||
```
|
||||
|
||||
`--force` overwrites an existing installed plugin or hook pack in place.
|
||||
It is not supported with `--link`, which reuses the source path instead of
|
||||
copying over a managed install target.
|
||||
|
||||
`--dangerously-force-unsafe-install` is a break-glass override for false
|
||||
positives from the built-in dangerous-code scanner. It allows plugin installs
|
||||
and plugin updates to continue past built-in `critical` findings, but it still
|
||||
|
||||
@@ -27,16 +27,18 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
|
||||
## Quick start
|
||||
|
||||
<Steps>
|
||||
<Step title="Get an API key">
|
||||
Pick a provider and get an API key. See the provider pages below for
|
||||
sign-up links.
|
||||
<Step title="Choose a provider">
|
||||
Pick a provider and complete any required setup. Some providers are
|
||||
key-free, while others use API keys. See the provider pages below for
|
||||
details.
|
||||
</Step>
|
||||
<Step title="Configure">
|
||||
```bash
|
||||
openclaw configure --section web
|
||||
```
|
||||
This stores the key and sets the provider. You can also set an env var
|
||||
(e.g. `BRAVE_API_KEY`) and skip this step.
|
||||
This stores the provider and any needed credential. You can also set an env
|
||||
var (for example `BRAVE_API_KEY`) and skip this step for API-backed
|
||||
providers.
|
||||
</Step>
|
||||
<Step title="Use it">
|
||||
The agent can now call `web_search`:
|
||||
@@ -78,6 +80,9 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
|
||||
<Card title="Kimi" icon="moon" href="/tools/kimi-search">
|
||||
AI-synthesized answers with citations via Moonshot web search.
|
||||
</Card>
|
||||
<Card title="Ollama Web Search" icon="globe" href="/tools/ollama-search">
|
||||
Key-free search via your configured Ollama host. Requires `ollama signin`.
|
||||
</Card>
|
||||
<Card title="Perplexity" icon="search" href="/tools/perplexity-search">
|
||||
Structured results with content extraction controls and domain filtering.
|
||||
</Card>
|
||||
@@ -91,18 +96,19 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
|
||||
|
||||
### Provider comparison
|
||||
|
||||
| Provider | Result style | Filters | API key |
|
||||
| -------------------------------------- | -------------------------- | ------------------------------------------------ | ------------------------------------------- |
|
||||
| [Brave](/tools/brave-search) | Structured snippets | Country, language, time, `llm-context` mode | `BRAVE_API_KEY` |
|
||||
| [DuckDuckGo](/tools/duckduckgo-search) | Structured snippets | -- | None (key-free) |
|
||||
| [Exa](/tools/exa-search) | Structured + extracted | Neural/keyword mode, date, content extraction | `EXA_API_KEY` |
|
||||
| [Firecrawl](/tools/firecrawl) | Structured snippets | Via `firecrawl_search` tool | `FIRECRAWL_API_KEY` |
|
||||
| [Gemini](/tools/gemini-search) | AI-synthesized + citations | -- | `GEMINI_API_KEY` |
|
||||
| [Grok](/tools/grok-search) | AI-synthesized + citations | -- | `XAI_API_KEY` |
|
||||
| [Kimi](/tools/kimi-search) | AI-synthesized + citations | -- | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
|
||||
| [Perplexity](/tools/perplexity-search) | Structured snippets | Country, language, time, domains, content limits | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` |
|
||||
| [SearXNG](/tools/searxng-search) | Structured snippets | Categories, language | None (self-hosted) |
|
||||
| [Tavily](/tools/tavily) | Structured snippets | Via `tavily_search` tool | `TAVILY_API_KEY` |
|
||||
| Provider | Result style | Filters | API key |
|
||||
| ----------------------------------------- | -------------------------- | ------------------------------------------------ | ------------------------------------------- |
|
||||
| [Brave](/tools/brave-search) | Structured snippets | Country, language, time, `llm-context` mode | `BRAVE_API_KEY` |
|
||||
| [DuckDuckGo](/tools/duckduckgo-search) | Structured snippets | -- | None (key-free) |
|
||||
| [Exa](/tools/exa-search) | Structured + extracted | Neural/keyword mode, date, content extraction | `EXA_API_KEY` |
|
||||
| [Firecrawl](/tools/firecrawl) | Structured snippets | Via `firecrawl_search` tool | `FIRECRAWL_API_KEY` |
|
||||
| [Gemini](/tools/gemini-search) | AI-synthesized + citations | -- | `GEMINI_API_KEY` |
|
||||
| [Grok](/tools/grok-search) | AI-synthesized + citations | -- | `XAI_API_KEY` |
|
||||
| [Kimi](/tools/kimi-search) | AI-synthesized + citations | -- | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
|
||||
| [Ollama Web Search](/tools/ollama-search) | Structured snippets | -- | None (`ollama signin` required) |
|
||||
| [Perplexity](/tools/perplexity-search) | Structured snippets | Country, language, time, domains, content limits | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` |
|
||||
| [SearXNG](/tools/searxng-search) | Structured snippets | Categories, language | None (self-hosted) |
|
||||
| [Tavily](/tools/tavily) | Structured snippets | Via `tavily_search` tool | `TAVILY_API_KEY` |
|
||||
|
||||
## Auto-detection
|
||||
|
||||
@@ -146,8 +152,8 @@ If native Codex search is enabled but the current model is not Codex-capable, Op
|
||||
Provider lists in docs and setup flows are alphabetical. Auto-detection keeps a
|
||||
separate precedence order:
|
||||
|
||||
If no `provider` is set, OpenClaw checks for API keys in this order and uses
|
||||
the first one found:
|
||||
If no `provider` is set, OpenClaw checks providers in this order and uses the
|
||||
first one that is ready:
|
||||
|
||||
1. **Brave** -- `BRAVE_API_KEY` or `plugins.entries.brave.config.webSearch.apiKey`
|
||||
2. **Gemini** -- `GEMINI_API_KEY` or `plugins.entries.google.config.webSearch.apiKey`
|
||||
@@ -155,12 +161,14 @@ the first one found:
|
||||
4. **Kimi** -- `KIMI_API_KEY` / `MOONSHOT_API_KEY` or `plugins.entries.moonshot.config.webSearch.apiKey`
|
||||
5. **Perplexity** -- `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` or `plugins.entries.perplexity.config.webSearch.apiKey`
|
||||
6. **Firecrawl** -- `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webSearch.apiKey`
|
||||
7. **Tavily** -- `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey`
|
||||
7. **Exa** -- `EXA_API_KEY` or `plugins.entries.exa.config.webSearch.apiKey`
|
||||
8. **Tavily** -- `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey`
|
||||
9. **DuckDuckGo** -- key-free HTML fallback with no account or API key
|
||||
10. **Ollama Web Search** -- key-free fallback via your configured Ollama host; requires Ollama to be reachable and signed in with `ollama signin`
|
||||
|
||||
Key-free providers are checked after API-backed providers:
|
||||
|
||||
8. **DuckDuckGo** -- no key needed (auto-detect order 100)
|
||||
9. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (auto-detect order 200)
|
||||
11. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (auto-detect order 200)
|
||||
|
||||
If no provider is detected, it falls back to Brave (you will get a missing-key
|
||||
error prompting you to configure one).
|
||||
@@ -376,3 +384,4 @@ If you use tool profiles or allowlists, add `web_search`, `x_search`, or `group:
|
||||
- [Web Fetch](/tools/web-fetch) -- fetch a URL and extract readable content
|
||||
- [Web Browser](/tools/browser) -- full browser automation for JS-heavy sites
|
||||
- [Grok Search](/tools/grok-search) -- Grok as the `web_search` provider
|
||||
- [Ollama Web Search](/tools/ollama-search) -- key-free web search through your Ollama host
|
||||
|
||||
8
extensions/anthropic/contract-api.ts
Normal file
8
extensions/anthropic/contract-api.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createAnthropicBetaHeadersWrapper,
|
||||
createAnthropicFastModeWrapper,
|
||||
createAnthropicServiceTierWrapper,
|
||||
resolveAnthropicBetas,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicServiceTier,
|
||||
} from "./stream-wrappers.js";
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
import { resolveProviderRequestCapabilities } from "openclaw/plugin-sdk/provider-http";
|
||||
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream";
|
||||
import {
|
||||
applyAnthropicPayloadPolicyToParams,
|
||||
resolveAnthropicPayloadPolicy,
|
||||
streamWithPayloadPatch,
|
||||
} from "openclaw/plugin-sdk/provider-stream";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
|
||||
const log = createSubsystemLogger("anthropic-stream");
|
||||
@@ -52,20 +55,6 @@ function isAnthropicOAuthApiKey(apiKey: unknown): boolean {
|
||||
return typeof apiKey === "string" && apiKey.includes("sk-ant-oat");
|
||||
}
|
||||
|
||||
function allowsAnthropicServiceTier(model: {
|
||||
api?: unknown;
|
||||
provider?: unknown;
|
||||
baseUrl?: unknown;
|
||||
}): boolean {
|
||||
return resolveProviderRequestCapabilities({
|
||||
provider: typeof model.provider === "string" ? model.provider : undefined,
|
||||
api: typeof model.api === "string" ? model.api : undefined,
|
||||
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
|
||||
capability: "llm",
|
||||
transport: "stream",
|
||||
}).allowsAnthropicServiceTier;
|
||||
}
|
||||
|
||||
function resolveAnthropicFastServiceTier(enabled: boolean): AnthropicServiceTier {
|
||||
return enabled ? "auto" : "standard_only";
|
||||
}
|
||||
@@ -161,15 +150,19 @@ export function createAnthropicFastModeWrapper(
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
const serviceTier = resolveAnthropicFastServiceTier(enabled);
|
||||
return (model, context, options) => {
|
||||
if (!allowsAnthropicServiceTier(model)) {
|
||||
const payloadPolicy = resolveAnthropicPayloadPolicy({
|
||||
provider: typeof model.provider === "string" ? model.provider : undefined,
|
||||
api: typeof model.api === "string" ? model.api : undefined,
|
||||
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
|
||||
serviceTier,
|
||||
});
|
||||
if (!payloadPolicy.allowsServiceTier) {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
|
||||
if (payloadObj.service_tier === undefined) {
|
||||
payloadObj.service_tier = serviceTier;
|
||||
}
|
||||
});
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) =>
|
||||
applyAnthropicPayloadPolicyToParams(payloadObj, payloadPolicy),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,15 +172,19 @@ export function createAnthropicServiceTierWrapper(
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
if (!allowsAnthropicServiceTier(model)) {
|
||||
const payloadPolicy = resolveAnthropicPayloadPolicy({
|
||||
provider: typeof model.provider === "string" ? model.provider : undefined,
|
||||
api: typeof model.api === "string" ? model.api : undefined,
|
||||
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
|
||||
serviceTier,
|
||||
});
|
||||
if (!payloadPolicy.allowsServiceTier) {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
|
||||
if (payloadObj.service_tier === undefined) {
|
||||
payloadObj.service_tier = serviceTier;
|
||||
}
|
||||
});
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) =>
|
||||
applyAnthropicPayloadPolicyToParams(payloadObj, payloadPolicy),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -245,6 +245,34 @@ describe("bluebubbles setup surface", () => {
|
||||
expect(resolved.configured).toBe(true);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted setup configured state", async () => {
|
||||
const { blueBubblesSetupWizard } = await import("./setup-surface.js");
|
||||
|
||||
const configured = await blueBubblesSetupWizard.status.resolveConfigured({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
defaultAccount: "work",
|
||||
serverUrl: "http://localhost:3000",
|
||||
password: "top-secret",
|
||||
accounts: {
|
||||
alerts: {
|
||||
serverUrl: "http://localhost:4000",
|
||||
password: "alerts-secret",
|
||||
},
|
||||
work: {
|
||||
serverUrl: "",
|
||||
password: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(configured).toBe(false);
|
||||
});
|
||||
|
||||
it('writes open policy state to the named account and preserves inherited allowFrom with "*"', async () => {
|
||||
const { blueBubblesSetupWizard } = await import("./setup-surface.js");
|
||||
|
||||
|
||||
@@ -174,10 +174,7 @@ export const blueBubblesSetupWizard: ChannelSetupWizard = {
|
||||
unconfiguredScore: 0,
|
||||
includeStatusLine: true,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
(accountId ? [accountId] : listBlueBubblesAccountIds(cfg)).some((resolvedAccountId) => {
|
||||
const account = resolveBlueBubblesAccount({ cfg, accountId: resolvedAccountId });
|
||||
return account.configured;
|
||||
}),
|
||||
resolveBlueBubblesAccount({ cfg, accountId }).configured,
|
||||
}),
|
||||
resolveSelectionHint: ({ configured }) =>
|
||||
configured ? "configured" : "iMessage via BlueBubbles app",
|
||||
|
||||
16
extensions/discord/contract-api.ts
Normal file
16
extensions/discord/contract-api.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { createThreadBindingManager } from "./src/monitor/thread-bindings.manager.js";
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-config-contract.js";
|
||||
export {
|
||||
unsupportedSecretRefSurfacePatterns,
|
||||
collectUnsupportedSecretRefConfigCandidates,
|
||||
} from "./src/security-contract.js";
|
||||
export { deriveLegacySessionChatType } from "./src/session-contract.js";
|
||||
export type {
|
||||
DiscordInteractiveHandlerContext,
|
||||
DiscordInteractiveHandlerRegistration,
|
||||
} from "./src/interactive-dispatch.js";
|
||||
export { collectDiscordSecurityAuditFindings } from "./src/security-audit.js";
|
||||
@@ -12,7 +12,7 @@ type DiscordGuild = { id: string; name: string };
|
||||
type DiscordUser = { id: string; username: string; global_name?: string; bot?: boolean };
|
||||
type DiscordMember = { user: DiscordUser; nick?: string | null };
|
||||
type DiscordChannel = { id: string; name?: string | null };
|
||||
type DiscordDirectoryAccess = { token: string; query: string };
|
||||
type DiscordDirectoryAccess = { token: string; query: string; accountId: string };
|
||||
|
||||
function normalizeQuery(value?: string | null): string {
|
||||
return value?.trim().toLowerCase() ?? "";
|
||||
@@ -30,7 +30,7 @@ function resolveDiscordDirectoryAccess(
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return { token, query: normalizeQuery(params.query) };
|
||||
return { token, query: normalizeQuery(params.query), accountId: account.accountId };
|
||||
}
|
||||
|
||||
async function listDiscordGuilds(token: string): Promise<DiscordGuild[]> {
|
||||
@@ -45,7 +45,7 @@ export async function listDiscordDirectoryGroupsLive(
|
||||
if (!access) {
|
||||
return [];
|
||||
}
|
||||
const { token, query } = access;
|
||||
const { token, query, accountId } = access;
|
||||
const guilds = await listDiscordGuilds(token);
|
||||
const rows: ChannelDirectoryEntry[] = [];
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function listDiscordDirectoryPeersLive(
|
||||
if (!access) {
|
||||
return [];
|
||||
}
|
||||
const { token, query } = access;
|
||||
const { token, query, accountId } = access;
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export async function listDiscordDirectoryPeersLive(
|
||||
continue;
|
||||
}
|
||||
rememberDiscordDirectoryUser({
|
||||
accountId: params.accountId,
|
||||
accountId,
|
||||
userId: user.id,
|
||||
handles: [
|
||||
user.username,
|
||||
|
||||
292
extensions/discord/src/doctor-contract.ts
Normal file
292
extensions/discord/src/doctor-contract.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import type {
|
||||
ChannelDoctorConfigMutation,
|
||||
ChannelDoctorLegacyConfigRule,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js";
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeDiscordStreamingAliases(params: {
|
||||
entry: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { entry: Record<string, unknown>; changed: boolean } {
|
||||
let updated = params.entry;
|
||||
const hadLegacyStreamMode = updated.streamMode !== undefined;
|
||||
const beforeStreaming = updated.streaming;
|
||||
const resolved = resolveDiscordPreviewStreamMode(updated);
|
||||
const shouldNormalize =
|
||||
hadLegacyStreamMode ||
|
||||
typeof beforeStreaming === "boolean" ||
|
||||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
|
||||
if (!shouldNormalize) {
|
||||
return { entry: updated, changed: false };
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
if (beforeStreaming !== resolved) {
|
||||
updated = { ...updated, streaming: resolved };
|
||||
changed = true;
|
||||
}
|
||||
if (hadLegacyStreamMode) {
|
||||
const { streamMode: _ignored, ...rest } = updated;
|
||||
updated = rest;
|
||||
changed = true;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
|
||||
);
|
||||
}
|
||||
if (typeof beforeStreaming === "boolean") {
|
||||
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
|
||||
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
|
||||
params.changes.push(
|
||||
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
params.pathPrefix.startsWith("channels.discord") &&
|
||||
resolved === "off" &&
|
||||
hadLegacyStreamMode
|
||||
) {
|
||||
params.changes.push(
|
||||
`${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming="partial" to opt in explicitly.`,
|
||||
);
|
||||
}
|
||||
return { entry: updated, changed };
|
||||
}
|
||||
|
||||
function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
|
||||
const entry = asObjectRecord(value);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entry.streamMode !== undefined ||
|
||||
typeof entry.streaming === "boolean" ||
|
||||
(typeof entry.streaming === "string" &&
|
||||
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
const tts = asObjectRecord(value);
|
||||
if (!tts) {
|
||||
return false;
|
||||
}
|
||||
return LEGACY_TTS_PROVIDER_KEYS.some((key) => Object.prototype.hasOwnProperty.call(tts, key));
|
||||
}
|
||||
|
||||
function hasLegacyDiscordAccountTtsProviderKeys(value: unknown): boolean {
|
||||
const accounts = asObjectRecord(value);
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(accounts).some((accountValue) => {
|
||||
const account = asObjectRecord(accountValue);
|
||||
const voice = asObjectRecord(account?.voice);
|
||||
return hasLegacyTtsProviderKeys(voice?.tts);
|
||||
});
|
||||
}
|
||||
|
||||
function mergeMissing(target: Record<string, unknown>, source: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
const existing = target[key];
|
||||
if (existing === undefined) {
|
||||
target[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
existing &&
|
||||
typeof existing === "object" &&
|
||||
!Array.isArray(existing) &&
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
mergeMissing(existing as Record<string, unknown>, value as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateTtsProviders(tts: Record<string, unknown>): Record<string, unknown> {
|
||||
const providers = asObjectRecord(tts.providers) ?? {};
|
||||
tts.providers = providers;
|
||||
return providers;
|
||||
}
|
||||
|
||||
function mergeLegacyTtsProviderConfig(
|
||||
tts: Record<string, unknown>,
|
||||
legacyKey: string,
|
||||
providerId: string,
|
||||
): boolean {
|
||||
const legacyValue = asObjectRecord(tts[legacyKey]);
|
||||
if (!legacyValue) {
|
||||
return false;
|
||||
}
|
||||
const providers = getOrCreateTtsProviders(tts);
|
||||
const existing = asObjectRecord(providers[providerId]) ?? {};
|
||||
const merged = structuredClone(existing);
|
||||
mergeMissing(merged, legacyValue);
|
||||
providers[providerId] = merged;
|
||||
delete tts[legacyKey];
|
||||
return true;
|
||||
}
|
||||
|
||||
function migrateLegacyTtsConfig(
|
||||
tts: Record<string, unknown> | null,
|
||||
pathLabel: string,
|
||||
changes: string[],
|
||||
): boolean {
|
||||
if (!tts) {
|
||||
return false;
|
||||
}
|
||||
let changed = false;
|
||||
if (mergeLegacyTtsProviderConfig(tts, "openai", "openai")) {
|
||||
changes.push(`Moved ${pathLabel}.openai → ${pathLabel}.providers.openai.`);
|
||||
changed = true;
|
||||
}
|
||||
if (mergeLegacyTtsProviderConfig(tts, "elevenlabs", "elevenlabs")) {
|
||||
changes.push(`Moved ${pathLabel}.elevenlabs → ${pathLabel}.providers.elevenlabs.`);
|
||||
changed = true;
|
||||
}
|
||||
if (mergeLegacyTtsProviderConfig(tts, "microsoft", "microsoft")) {
|
||||
changes.push(`Moved ${pathLabel}.microsoft → ${pathLabel}.providers.microsoft.`);
|
||||
changed = true;
|
||||
}
|
||||
if (mergeLegacyTtsProviderConfig(tts, "edge", "microsoft")) {
|
||||
changes.push(`Moved ${pathLabel}.edge → ${pathLabel}.providers.microsoft.`);
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "discord"],
|
||||
message:
|
||||
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
|
||||
match: hasLegacyDiscordStreamingAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "discord", "accounts"],
|
||||
message:
|
||||
"channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming.",
|
||||
match: hasLegacyDiscordAccountStreamingAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "discord", "voice", "tts"],
|
||||
message:
|
||||
"channels.discord.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.voice.tts.providers.<provider> (auto-migrated on load).",
|
||||
match: hasLegacyTtsProviderKeys,
|
||||
},
|
||||
{
|
||||
path: ["channels", "discord", "accounts"],
|
||||
message:
|
||||
"channels.discord.accounts.<id>.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.accounts.<id>.voice.tts.providers.<provider> (auto-migrated on load).",
|
||||
match: hasLegacyDiscordAccountTtsProviderKeys,
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeCompatibilityConfig({
|
||||
cfg,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.discord);
|
||||
if (!rawEntry) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
let updated = rawEntry;
|
||||
let changed = false;
|
||||
|
||||
const streaming = normalizeDiscordStreamingAliases({
|
||||
entry: updated,
|
||||
pathPrefix: "channels.discord",
|
||||
changes,
|
||||
});
|
||||
updated = streaming.entry;
|
||||
changed = changed || streaming.changed;
|
||||
|
||||
const rawAccounts = asObjectRecord(updated.accounts);
|
||||
if (rawAccounts) {
|
||||
let accountsChanged = false;
|
||||
const accounts = { ...rawAccounts };
|
||||
for (const [accountId, rawAccount] of Object.entries(rawAccounts)) {
|
||||
const account = asObjectRecord(rawAccount);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const accountStreaming = normalizeDiscordStreamingAliases({
|
||||
entry: account,
|
||||
pathPrefix: `channels.discord.accounts.${accountId}`,
|
||||
changes,
|
||||
});
|
||||
if (accountStreaming.changed) {
|
||||
accounts[accountId] = accountStreaming.entry;
|
||||
accountsChanged = true;
|
||||
}
|
||||
const accountVoice = asObjectRecord(accountStreaming.entry.voice);
|
||||
if (
|
||||
accountVoice &&
|
||||
migrateLegacyTtsConfig(
|
||||
asObjectRecord(accountVoice.tts),
|
||||
`channels.discord.accounts.${accountId}.voice.tts`,
|
||||
changes,
|
||||
)
|
||||
) {
|
||||
accounts[accountId] = {
|
||||
...accountStreaming.entry,
|
||||
voice: accountVoice,
|
||||
};
|
||||
accountsChanged = true;
|
||||
}
|
||||
}
|
||||
if (accountsChanged) {
|
||||
updated = { ...updated, accounts };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const voice = asObjectRecord(updated.voice);
|
||||
if (
|
||||
voice &&
|
||||
migrateLegacyTtsConfig(asObjectRecord(voice.tts), "channels.discord.voice.tts", changes)
|
||||
) {
|
||||
updated = { ...updated, voice };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
discord: updated,
|
||||
} as OpenClawConfig["channels"],
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import {
|
||||
type ChannelDoctorAdapter,
|
||||
type ChannelDoctorConfigMutation,
|
||||
type ChannelDoctorLegacyConfigRule,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import {
|
||||
resolveDiscordPreviewStreamMode,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
collectProviderDangerousNameMatchingScopes,
|
||||
isDiscordMutableAllowEntry,
|
||||
} from "openclaw/plugin-sdk/runtime";
|
||||
import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime";
|
||||
import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js";
|
||||
import { isDiscordMutableAllowEntry } from "./security-audit.js";
|
||||
|
||||
type DiscordNumericIdHit = { path: string; entry: number; safe: boolean };
|
||||
|
||||
@@ -517,11 +514,48 @@ function collectDiscordMutableAllowlistWarnings(cfg: OpenClawConfig): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
|
||||
const entry = asObjectRecord(value);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entry.streamMode !== undefined ||
|
||||
typeof entry.streaming === "boolean" ||
|
||||
(typeof entry.streaming === "string" &&
|
||||
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
|
||||
);
|
||||
}
|
||||
|
||||
function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean {
|
||||
const accounts = asObjectRecord(value);
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(accounts).some((account) => hasLegacyDiscordStreamingAliases(account));
|
||||
}
|
||||
|
||||
const DISCORD_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "discord"],
|
||||
message:
|
||||
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
|
||||
match: hasLegacyDiscordStreamingAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "discord", "accounts"],
|
||||
message:
|
||||
"channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming.",
|
||||
match: hasLegacyDiscordAccountStreamingAliases,
|
||||
},
|
||||
];
|
||||
|
||||
export const discordDoctor: ChannelDoctorAdapter = {
|
||||
dmAllowFromMode: "topOrNested",
|
||||
groupModel: "route",
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: false,
|
||||
legacyConfigRules: DISCORD_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig: ({ cfg }) => normalizeDiscordCompatibilityConfig(cfg),
|
||||
collectPreviewWarnings: ({ cfg, doctorFixCommand }) =>
|
||||
collectDiscordNumericIdWarnings({
|
||||
|
||||
@@ -2,9 +2,17 @@ import { ChannelType } from "@buape/carbon";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const transcribeFirstAudioMock = vi.hoisted(() => vi.fn());
|
||||
const resolveDiscordDmCommandAccessMock = vi.hoisted(() => vi.fn());
|
||||
const handleDiscordDmCommandDecisionMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock("./preflight-audio.runtime.js", () => ({
|
||||
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
|
||||
transcribeFirstAudio: transcribeFirstAudioMock,
|
||||
}));
|
||||
vi.mock("./dm-command-auth.js", () => ({
|
||||
resolveDiscordDmCommandAccess: resolveDiscordDmCommandAccessMock,
|
||||
}));
|
||||
vi.mock("./dm-command-decision.js", () => ({
|
||||
handleDiscordDmCommandDecision: handleDiscordDmCommandDecisionMock,
|
||||
}));
|
||||
import {
|
||||
__testing as sessionBindingTesting,
|
||||
@@ -261,6 +269,14 @@ describe("preflightDiscordMessage", () => {
|
||||
beforeEach(() => {
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
transcribeFirstAudioMock.mockReset();
|
||||
resolveDiscordDmCommandAccessMock.mockReset();
|
||||
resolveDiscordDmCommandAccessMock.mockResolvedValue({
|
||||
commandAuthorized: true,
|
||||
decision: "allow",
|
||||
allowMatch: { allowed: true, matchedBy: "allowFrom", value: "123" },
|
||||
});
|
||||
handleDiscordDmCommandDecisionMock.mockReset();
|
||||
handleDiscordDmCommandDecisionMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("drops bound-thread bot system messages to prevent ACP self-loop", async () => {
|
||||
@@ -349,6 +365,56 @@ describe("preflightDiscordMessage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the default discord account for omitted-account dm authorization", async () => {
|
||||
const message = createDiscordMessage({
|
||||
id: "m-dm-default-account",
|
||||
channelId: "dm-channel-default-account",
|
||||
content: "who are you",
|
||||
author: {
|
||||
id: "user-1",
|
||||
bot: false,
|
||||
username: "alice",
|
||||
},
|
||||
});
|
||||
|
||||
await preflightDiscordMessage({
|
||||
...createPreflightArgs({
|
||||
cfg: {
|
||||
...DEFAULT_PREFLIGHT_CFG,
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
default: {
|
||||
token: "token-default",
|
||||
},
|
||||
work: {
|
||||
token: "token-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
discordConfig: {
|
||||
defaultAccount: "work",
|
||||
dmPolicy: "allowlist",
|
||||
} as DiscordConfig,
|
||||
data: {
|
||||
channel_id: "dm-channel-default-account",
|
||||
author: message.author,
|
||||
message,
|
||||
} as DiscordMessageEvent,
|
||||
client: createDmClient("dm-channel-default-account"),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(resolveDiscordDmCommandAccessMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps bound-thread regular bot messages flowing when allowBots=true", async () => {
|
||||
const threadBinding = createThreadBinding({
|
||||
targetKind: "session",
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "./allow-list.js";
|
||||
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
|
||||
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
|
||||
import { resolveDefaultDiscordAccountId } from "../accounts.js";
|
||||
import {
|
||||
formatDiscordUserTag,
|
||||
resolveDiscordSystemLocation,
|
||||
@@ -386,7 +387,7 @@ export async function preflightDiscordMessage(
|
||||
|
||||
const dmPolicy = params.discordConfig?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing";
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
const resolvedAccountId = params.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const resolvedAccountId = params.accountId ?? resolveDefaultDiscordAccountId(params.cfg);
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(params.discordConfig);
|
||||
let commandAuthorized = true;
|
||||
if (isDirectMessage) {
|
||||
|
||||
@@ -16,7 +16,6 @@ import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pi
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
@@ -40,6 +39,7 @@ import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
||||
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||
import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js";
|
||||
import { createDiscordDraftStream } from "../draft-stream.js";
|
||||
import { resolveDiscordPreviewStreamMode } from "../preview-streaming.js";
|
||||
import { removeReactionDiscord } from "../send.js";
|
||||
import { editMessageDiscord } from "../send.messages.js";
|
||||
import {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import * as dispatcherModule from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import * as globalsModule from "openclaw/plugin-sdk/runtime-env";
|
||||
import * as commandTextModule from "openclaw/plugin-sdk/text-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
|
||||
import * as modelPickerModule from "./model-picker.js";
|
||||
import { createModelsProviderData as createBaseModelsProviderData } from "./model-picker.test-utils.js";
|
||||
@@ -248,12 +248,17 @@ function createDispatchSpy() {
|
||||
|
||||
describe("Discord model picker interactions", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
nativeCommandTesting.setDispatchReplyWithDispatcher(
|
||||
dispatcherModule.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("registers distinct fallback ids for button and select handlers", () => {
|
||||
const context = createModelPickerContext();
|
||||
const button = createDiscordModelPickerFallbackButton(context);
|
||||
|
||||
@@ -11,22 +11,34 @@ import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/config-runtim
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
type ConversationRuntimeModule = typeof import("openclaw/plugin-sdk/conversation-runtime");
|
||||
type ResolveConfiguredBindingRoute = ConversationRuntimeModule["resolveConfiguredBindingRoute"];
|
||||
type ConfiguredBindingRouteResult = ReturnType<ResolveConfiguredBindingRoute>;
|
||||
type EnsureConfiguredBindingRouteReady =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
|
||||
type ResolveConfiguredBindingRoute =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
|
||||
ConversationRuntimeModule["ensureConfiguredBindingRouteReady"];
|
||||
|
||||
function createUnboundConfiguredRouteResult(): ConfiguredBindingRouteResult {
|
||||
return {
|
||||
bindingResolution: null,
|
||||
route: {
|
||||
agentId: "main",
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
sessionKey: SESSION_KEY,
|
||||
mainSessionKey: SESSION_KEY,
|
||||
lastRoutePolicy: "main",
|
||||
matchedBy: "default",
|
||||
},
|
||||
};
|
||||
}
|
||||
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
|
||||
vi.fn<EnsureConfiguredBindingRouteReady>(async () => ({ ok: true })),
|
||||
);
|
||||
const resolveConfiguredBindingRouteMock = vi.hoisted(() =>
|
||||
vi.fn<ResolveConfiguredBindingRoute>(({ route }) => ({
|
||||
bindingResolution: null,
|
||||
route,
|
||||
})),
|
||||
vi.fn<ResolveConfiguredBindingRoute>(() => createUnboundConfiguredRouteResult()),
|
||||
);
|
||||
|
||||
type ConfiguredBindingRoute = ReturnType<ResolveConfiguredBindingRoute>;
|
||||
type ConfiguredBindingRoute = ConfiguredBindingRouteResult;
|
||||
type ConfiguredBindingResolution = NonNullable<ConfiguredBindingRoute["bindingResolution"]>;
|
||||
|
||||
function createConfiguredRouteResult(
|
||||
@@ -35,6 +47,11 @@ function createConfiguredRouteResult(
|
||||
return {
|
||||
bindingResolution: {
|
||||
record: {
|
||||
bindingId: "binding-1",
|
||||
targetSessionKey: SESSION_KEY,
|
||||
targetKind: "session",
|
||||
status: "active",
|
||||
boundAt: Date.now(),
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
@@ -87,10 +104,7 @@ describe("discord native /think autocomplete", () => {
|
||||
ensureConfiguredBindingRouteReadyMock.mockReset();
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
|
||||
resolveConfiguredBindingRouteMock.mockReset();
|
||||
resolveConfiguredBindingRouteMock.mockImplementation(({ route }) => ({
|
||||
bindingResolution: null,
|
||||
route,
|
||||
}));
|
||||
resolveConfiguredBindingRouteMock.mockReturnValue(createUnboundConfiguredRouteResult());
|
||||
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
STORE_PATH,
|
||||
|
||||
@@ -1063,6 +1063,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
eventQueueListenerTimeoutMs: eventQueueOpts.listenerTimeout,
|
||||
});
|
||||
|
||||
logDiscordStartupPhase({
|
||||
runtime,
|
||||
accountId: account.accountId,
|
||||
phase: "client-start",
|
||||
startAt: startupStartedAt,
|
||||
gateway: lifecycleGateway,
|
||||
});
|
||||
|
||||
const botIdentity =
|
||||
botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? "");
|
||||
runtime.log?.(
|
||||
|
||||
51
extensions/discord/src/preview-streaming.ts
Normal file
51
extensions/discord/src/preview-streaming.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type DiscordPreviewStreamMode = "off" | "partial" | "block";
|
||||
|
||||
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(
|
||||
params: {
|
||||
streamMode?: unknown;
|
||||
streaming?: unknown;
|
||||
} = {},
|
||||
): DiscordPreviewStreamMode {
|
||||
const parsedStreaming = parseDiscordPreviewStreamMode(params.streaming);
|
||||
if (parsedStreaming) {
|
||||
return parsedStreaming;
|
||||
}
|
||||
|
||||
const legacy = parseDiscordPreviewStreamMode(params.streamMode);
|
||||
if (legacy) {
|
||||
return legacy;
|
||||
}
|
||||
if (typeof params.streaming === "boolean") {
|
||||
return params.streaming ? "partial" : "off";
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
140
extensions/discord/src/secret-config-contract.ts
Normal file
140
extensions/discord/src/secret-config-contract.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
collectNestedChannelFieldAssignments,
|
||||
collectNestedChannelTtsAssignments,
|
||||
collectSimpleChannelFieldAssignments,
|
||||
getChannelSurface,
|
||||
isBaseFieldActiveForChannelSurface,
|
||||
isEnabledFlag,
|
||||
isRecord,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
type SecretTargetRegistryEntry,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
export const secretTargetRegistryEntries = [
|
||||
{
|
||||
id: "channels.discord.accounts.*.pluralkit.token",
|
||||
targetType: "channels.discord.accounts.*.pluralkit.token",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.discord.accounts.*.pluralkit.token",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.discord.accounts.*.token",
|
||||
targetType: "channels.discord.accounts.*.token",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.discord.accounts.*.token",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.discord.accounts.*.voice.tts.providers.*.apiKey",
|
||||
targetType: "channels.discord.accounts.*.voice.tts.providers.*.apiKey",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.discord.accounts.*.voice.tts.providers.*.apiKey",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
providerIdPathSegmentIndex: 6,
|
||||
},
|
||||
{
|
||||
id: "channels.discord.pluralkit.token",
|
||||
targetType: "channels.discord.pluralkit.token",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.discord.pluralkit.token",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.discord.token",
|
||||
targetType: "channels.discord.token",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.discord.token",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.discord.voice.tts.providers.*.apiKey",
|
||||
targetType: "channels.discord.voice.tts.providers.*.apiKey",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.discord.voice.tts.providers.*.apiKey",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
providerIdPathSegmentIndex: 4,
|
||||
},
|
||||
] satisfies SecretTargetRegistryEntry[];
|
||||
|
||||
export function collectRuntimeConfigAssignments(params: {
|
||||
config: { channels?: Record<string, unknown> };
|
||||
defaults: SecretDefaults | undefined;
|
||||
context: ResolverContext;
|
||||
}): void {
|
||||
const resolved = getChannelSurface(params.config, "discord");
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const { channel: discord, surface } = resolved;
|
||||
collectSimpleChannelFieldAssignments({
|
||||
channelKey: "discord",
|
||||
field: "token",
|
||||
channel: discord,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topInactiveReason: "no enabled account inherits this top-level Discord token.",
|
||||
accountInactiveReason: "Discord account is disabled.",
|
||||
});
|
||||
collectNestedChannelFieldAssignments({
|
||||
channelKey: "discord",
|
||||
nestedKey: "pluralkit",
|
||||
field: "token",
|
||||
channel: discord,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topLevelActive:
|
||||
isBaseFieldActiveForChannelSurface(surface, "pluralkit") &&
|
||||
isRecord(discord.pluralkit) &&
|
||||
isEnabledFlag(discord.pluralkit),
|
||||
topInactiveReason:
|
||||
"no enabled Discord surface inherits this top-level PluralKit config or PluralKit is disabled.",
|
||||
accountActive: ({ account, enabled }) =>
|
||||
enabled && isRecord(account.pluralkit) && isEnabledFlag(account.pluralkit),
|
||||
accountInactiveReason: "Discord account is disabled or PluralKit is disabled for this account.",
|
||||
});
|
||||
collectNestedChannelTtsAssignments({
|
||||
channelKey: "discord",
|
||||
nestedKey: "voice",
|
||||
channel: discord,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topLevelActive:
|
||||
isBaseFieldActiveForChannelSurface(surface, "voice") &&
|
||||
isRecord(discord.voice) &&
|
||||
isEnabledFlag(discord.voice),
|
||||
topInactiveReason:
|
||||
"no enabled Discord surface inherits this top-level voice config or voice is disabled.",
|
||||
accountActive: ({ account, enabled }) =>
|
||||
enabled && isRecord(account.voice) && isEnabledFlag(account.voice),
|
||||
accountInactiveReason: "Discord account is disabled or voice is disabled for this account.",
|
||||
});
|
||||
}
|
||||
@@ -21,7 +21,7 @@ function coerceNativeSetting(value: unknown): boolean | "auto" | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isDiscordMutableAllowEntry(raw: string): boolean {
|
||||
export function isDiscordMutableAllowEntry(raw: string): boolean {
|
||||
const text = raw.trim();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
|
||||
49
extensions/discord/src/security-contract.ts
Normal file
49
extensions/discord/src/security-contract.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
type UnsupportedSecretRefConfigCandidate = {
|
||||
path: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export const unsupportedSecretRefSurfacePatterns = [
|
||||
"channels.discord.threadBindings.webhookToken",
|
||||
"channels.discord.accounts.*.threadBindings.webhookToken",
|
||||
] as const;
|
||||
|
||||
export function collectUnsupportedSecretRefConfigCandidates(
|
||||
raw: unknown,
|
||||
): UnsupportedSecretRefConfigCandidate[] {
|
||||
if (!isRecord(raw)) {
|
||||
return [];
|
||||
}
|
||||
if (!isRecord(raw.channels) || !isRecord(raw.channels.discord)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates: UnsupportedSecretRefConfigCandidate[] = [];
|
||||
const discord = raw.channels.discord;
|
||||
const threadBindings = isRecord(discord.threadBindings) ? discord.threadBindings : null;
|
||||
if (threadBindings) {
|
||||
candidates.push({
|
||||
path: "channels.discord.threadBindings.webhookToken",
|
||||
value: threadBindings.webhookToken,
|
||||
});
|
||||
}
|
||||
|
||||
const accounts = isRecord(discord.accounts) ? discord.accounts : null;
|
||||
if (!accounts) {
|
||||
return candidates;
|
||||
}
|
||||
for (const [accountId, account] of Object.entries(accounts)) {
|
||||
if (!isRecord(account) || !isRecord(account.threadBindings)) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
path: `channels.discord.accounts.${accountId}.threadBindings.webhookToken`,
|
||||
value: account.threadBindings.webhookToken,
|
||||
});
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
@@ -365,15 +365,15 @@ export async function sendWebhookMessageDiscord(
|
||||
throw new Error("Discord webhook id/token are required");
|
||||
}
|
||||
|
||||
const rewrittenText = rewriteDiscordKnownMentions(text, {
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const replyTo = typeof opts.replyTo === "string" ? opts.replyTo.trim() : "";
|
||||
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
||||
const { account, proxyFetch } = resolveDiscordClientAccountContext({
|
||||
cfg: opts.cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const rewrittenText = rewriteDiscordKnownMentions(text, {
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
const response = await (proxyFetch ?? fetch)(
|
||||
resolveWebhookExecutionUrl({
|
||||
@@ -430,11 +430,16 @@ export async function sendStickerDiscord(
|
||||
stickerIds: string[],
|
||||
opts: DiscordSendOpts & { content?: string } = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
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: opts.accountId,
|
||||
accountId: accountInfo.accountId,
|
||||
})
|
||||
: undefined;
|
||||
const stickers = normalizeStickerIds(stickerIds);
|
||||
@@ -456,11 +461,16 @@ export async function sendPollDiscord(
|
||||
poll: PollInput,
|
||||
opts: DiscordSendOpts & { content?: string } = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
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: opts.accountId,
|
||||
accountId: accountInfo.accountId,
|
||||
})
|
||||
: undefined;
|
||||
if (poll.durationSeconds !== undefined) {
|
||||
|
||||
@@ -126,6 +126,40 @@ describe("sendMessageDiscord", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for cached mention rewriting when accountId is omitted", async () => {
|
||||
rememberDiscordDirectoryUser({
|
||||
accountId: "work",
|
||||
userId: "222333444555666777",
|
||||
handles: ["Alice"],
|
||||
});
|
||||
const { rest, postMock, getMock } = makeDiscordRest();
|
||||
getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
|
||||
postMock.mockResolvedValue({
|
||||
id: "msg1",
|
||||
channel_id: "789",
|
||||
});
|
||||
await sendMessageDiscord("channel:789", "ping @Alice", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
token: "Bot work-token", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("789"),
|
||||
expect.objectContaining({ body: { content: "ping <@222333444555666777>" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-creates a forum thread when target is a Forum channel", async () => {
|
||||
const { rest, postMock, getMock } = makeDiscordRest();
|
||||
// Channel type lookup returns a Forum channel.
|
||||
|
||||
3
extensions/discord/src/session-contract.ts
Normal file
3
extensions/discord/src/session-contract.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function deriveLegacySessionChatType(sessionKey: string): "channel" | undefined {
|
||||
return /^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/.test(sessionKey) ? "channel" : undefined;
|
||||
}
|
||||
@@ -109,10 +109,7 @@ export function createDiscordSetupWizardBase(handlers: {
|
||||
configuredScore: 2,
|
||||
unconfiguredScore: 1,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
(accountId ? [accountId] : listDiscordSetupAccountIds(cfg)).some((resolvedAccountId) => {
|
||||
const account = inspectDiscordSetupAccount({ cfg, accountId: resolvedAccountId });
|
||||
return account.configured;
|
||||
}),
|
||||
inspectDiscordSetupAccount({ cfg, accountId }).configured,
|
||||
}),
|
||||
credentials: [
|
||||
{
|
||||
|
||||
@@ -54,3 +54,28 @@ describe("discordSetupWizard.dmPolicy", () => {
|
||||
expect(next?.channels?.discord?.accounts?.alerts?.allowFrom).toEqual(["123", "*"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discordSetupWizard.status", () => {
|
||||
it("uses configured defaultAccount for omitted setup configured state", async () => {
|
||||
const configured = await discordSetupWizard.status.resolveConfigured({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
token: "discord-root-token",
|
||||
accounts: {
|
||||
alerts: {
|
||||
token: "discord-alerts-token",
|
||||
},
|
||||
work: {
|
||||
token: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(configured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetDiscordDirectoryCacheForTest,
|
||||
resolveDiscordDirectoryUserId,
|
||||
} from "./directory-cache.js";
|
||||
import * as directoryLive from "./directory-live.js";
|
||||
import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
@@ -76,6 +80,7 @@ describe("resolveDiscordTarget", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
__resetDiscordDirectoryCacheForTest();
|
||||
});
|
||||
|
||||
it("returns a resolved user for usernames", async () => {
|
||||
@@ -102,6 +107,33 @@ describe("resolveDiscordTarget", () => {
|
||||
).resolves.toMatchObject({ kind: "user", id: "123" });
|
||||
expect(listPeers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches username lookups under the configured default account when accountId is omitted", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
token: "discord-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive").mockResolvedValueOnce([
|
||||
{ kind: "user", id: "user:999", name: "Jane" } as const,
|
||||
]);
|
||||
|
||||
await expect(resolveDiscordTarget("jane", { cfg })).resolves.toMatchObject({
|
||||
kind: "user",
|
||||
id: "999",
|
||||
normalized: "user:999",
|
||||
});
|
||||
expect(resolveDiscordDirectoryUserId({ accountId: "work", handle: "jane" })).toBe("999");
|
||||
expect(resolveDiscordDirectoryUserId({ accountId: "default", handle: "jane" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeDiscordMessagingTarget", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type MessagingTargetParseOptions,
|
||||
} from "openclaw/plugin-sdk/channel-targets";
|
||||
import type { DirectoryConfigParams } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { rememberDiscordDirectoryUser } from "./directory-cache.js";
|
||||
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
|
||||
|
||||
@@ -100,8 +101,12 @@ export async function resolveDiscordTarget(
|
||||
if (match && match.kind === "user") {
|
||||
// Extract user ID from the directory entry (format: "user:<id>")
|
||||
const userId = match.id.replace(/^user:/, "");
|
||||
rememberDiscordDirectoryUser({
|
||||
const resolvedAccountId = resolveDiscordAccount({
|
||||
cfg: options.cfg,
|
||||
accountId: options.accountId,
|
||||
}).accountId;
|
||||
rememberDiscordDirectoryUser({
|
||||
accountId: resolvedAccountId,
|
||||
userId,
|
||||
handles: [trimmed, match.name, match.handle],
|
||||
});
|
||||
|
||||
6
extensions/feishu/contract-api.ts
Normal file
6
extensions/feishu/contract-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { createFeishuThreadBindingManager } from "./src/thread-bindings.js";
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
export { messageActionTargetAliases } from "./src/message-action-contract.js";
|
||||
@@ -1,25 +1,48 @@
|
||||
// Private runtime barrel for the bundled Feishu extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
// Keep this barrel thin and generic-only.
|
||||
|
||||
export type {
|
||||
AllowlistMatch,
|
||||
AnyAgentTool,
|
||||
BaseProbeResult,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionName,
|
||||
ChannelMeta,
|
||||
ChannelOutboundAdapter,
|
||||
OpenClawConfig as ClawdbotConfig,
|
||||
ChannelPlugin,
|
||||
HistoryEntry,
|
||||
OpenClawConfig,
|
||||
OpenClawPluginApi,
|
||||
OutboundIdentity,
|
||||
PluginRuntime,
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/feishu";
|
||||
ReplyPayload,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { OpenClawConfig as ClawdbotConfig } from "openclaw/plugin-sdk/core";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
buildChannelConfigSchema,
|
||||
buildProbeChannelStatusSummary,
|
||||
createActionGate,
|
||||
createDedupeCache,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
buildProbeChannelStatusSummary,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/feishu";
|
||||
export * from "openclaw/plugin-sdk/feishu";
|
||||
} from "openclaw/plugin-sdk/channel-status";
|
||||
export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createReplyPrefixContext } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export {
|
||||
evaluateSupplementalContextVisibility,
|
||||
filterSupplementalContextItems,
|
||||
resolveChannelContextVisibilityMode,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
|
||||
export { createPersistentDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
|
||||
export { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
|
||||
@@ -51,6 +51,7 @@ import type {
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { FeishuConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
buildFeishuModelOverrideParentCandidates,
|
||||
buildFeishuConversationId,
|
||||
parseFeishuConversationId,
|
||||
parseFeishuDirectConversationId,
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js";
|
||||
import { resolveFeishuGroupToolPolicy } from "./policy.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { collectFeishuSecurityAuditFindings } from "./security-audit.js";
|
||||
import {
|
||||
resolveFeishuParentConversationCandidates,
|
||||
resolveFeishuSessionConversation,
|
||||
@@ -146,7 +148,9 @@ function describeFeishuMessageTool({
|
||||
NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>
|
||||
>[0]): ChannelMessageToolDiscovery {
|
||||
const enabledAccounts = accountId
|
||||
? [resolveFeishuAccount({ cfg, accountId })].filter((account) => account.enabled && account.configured)
|
||||
? [resolveFeishuAccount({ cfg, accountId })].filter(
|
||||
(account) => account.enabled && account.configured,
|
||||
)
|
||||
: listEnabledFeishuAccounts(cfg);
|
||||
const enabled =
|
||||
enabledAccounts.length > 0 ||
|
||||
@@ -179,9 +183,9 @@ function describeFeishuMessageTool({
|
||||
"channel-list",
|
||||
]);
|
||||
if (
|
||||
(accountId
|
||||
accountId
|
||||
? enabledAccounts.some((account) => isFeishuReactionsActionEnabled({ cfg, account }))
|
||||
: areAnyFeishuReactionActionsEnabled(cfg))
|
||||
: areAnyFeishuReactionActionsEnabled(cfg)
|
||||
) {
|
||||
actions.add("react");
|
||||
actions.add("reactions");
|
||||
@@ -567,6 +571,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
},
|
||||
conversationBindings: {
|
||||
defaultTopLevelPlacement: "current",
|
||||
buildModelOverrideParentCandidates: ({ parentConversationId }) =>
|
||||
buildFeishuModelOverrideParentCandidates(parentConversationId),
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
|
||||
@@ -1180,6 +1186,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}>(collectFeishuSecurityWarnings),
|
||||
collectAuditFindings: ({ cfg }) => collectFeishuSecurityAuditFindings({ cfg }),
|
||||
},
|
||||
pairing: {
|
||||
text: {
|
||||
|
||||
@@ -166,3 +166,32 @@ export function parseFeishuConversationId(params: {
|
||||
scope: "group",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFeishuModelOverrideParentCandidates(
|
||||
parentConversationId?: string | null,
|
||||
): string[] {
|
||||
const rawId = normalizeText(parentConversationId);
|
||||
if (!rawId) {
|
||||
return [];
|
||||
}
|
||||
const topicSenderMatch = rawId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/i);
|
||||
if (topicSenderMatch) {
|
||||
const chatId = topicSenderMatch[1]?.trim().toLowerCase();
|
||||
const topicId = topicSenderMatch[2]?.trim().toLowerCase();
|
||||
if (chatId && topicId) {
|
||||
return [`${chatId}:topic:${topicId}`, chatId];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const topicMatch = rawId.match(/^(.+):topic:([^:]+)$/i);
|
||||
if (topicMatch) {
|
||||
const chatId = topicMatch[1]?.trim().toLowerCase();
|
||||
return chatId ? [chatId] : [];
|
||||
}
|
||||
const senderMatch = rawId.match(/^(.+):sender:([^:]+)$/i);
|
||||
if (senderMatch) {
|
||||
const chatId = senderMatch[1]?.trim().toLowerCase();
|
||||
return chatId ? [chatId] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
13
extensions/feishu/src/message-action-contract.ts
Normal file
13
extensions/feishu/src/message-action-contract.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract";
|
||||
|
||||
type MessageActionTargetAliasSpec = {
|
||||
aliases: string[];
|
||||
};
|
||||
|
||||
export const messageActionTargetAliases = {
|
||||
read: { aliases: ["messageId"] },
|
||||
pin: { aliases: ["messageId"] },
|
||||
unpin: { aliases: ["messageId"] },
|
||||
"list-pins": { aliases: ["chatId"] },
|
||||
"channel-info": { aliases: ["chatId"] },
|
||||
} satisfies Partial<Record<ChannelMessageActionName, MessageActionTargetAliasSpec>>;
|
||||
140
extensions/feishu/src/secret-contract.ts
Normal file
140
extensions/feishu/src/secret-contract.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
collectConditionalChannelFieldAssignments,
|
||||
collectSimpleChannelFieldAssignments,
|
||||
getChannelSurface,
|
||||
hasOwnProperty,
|
||||
normalizeSecretStringValue,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
type SecretTargetRegistryEntry,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
export const secretTargetRegistryEntries = [
|
||||
{
|
||||
id: "channels.feishu.accounts.*.appSecret",
|
||||
targetType: "channels.feishu.accounts.*.appSecret",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.feishu.accounts.*.appSecret",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.feishu.accounts.*.encryptKey",
|
||||
targetType: "channels.feishu.accounts.*.encryptKey",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.feishu.accounts.*.encryptKey",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.feishu.accounts.*.verificationToken",
|
||||
targetType: "channels.feishu.accounts.*.verificationToken",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.feishu.accounts.*.verificationToken",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.feishu.appSecret",
|
||||
targetType: "channels.feishu.appSecret",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.feishu.appSecret",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.feishu.encryptKey",
|
||||
targetType: "channels.feishu.encryptKey",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.feishu.encryptKey",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.feishu.verificationToken",
|
||||
targetType: "channels.feishu.verificationToken",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.feishu.verificationToken",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
] satisfies SecretTargetRegistryEntry[];
|
||||
|
||||
export function collectRuntimeConfigAssignments(params: {
|
||||
config: { channels?: Record<string, unknown> };
|
||||
defaults: SecretDefaults | undefined;
|
||||
context: ResolverContext;
|
||||
}): void {
|
||||
const resolved = getChannelSurface(params.config, "feishu");
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const { channel: feishu, surface } = resolved;
|
||||
collectSimpleChannelFieldAssignments({
|
||||
channelKey: "feishu",
|
||||
field: "appSecret",
|
||||
channel: feishu,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topInactiveReason: "no enabled account inherits this top-level Feishu appSecret.",
|
||||
accountInactiveReason: "Feishu account is disabled.",
|
||||
});
|
||||
const baseConnectionMode =
|
||||
normalizeSecretStringValue(feishu.connectionMode) === "webhook" ? "webhook" : "websocket";
|
||||
const resolveAccountMode = (account: Record<string, unknown>) =>
|
||||
hasOwnProperty(account, "connectionMode")
|
||||
? normalizeSecretStringValue(account.connectionMode)
|
||||
: baseConnectionMode;
|
||||
collectConditionalChannelFieldAssignments({
|
||||
channelKey: "feishu",
|
||||
field: "encryptKey",
|
||||
channel: feishu,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topLevelActiveWithoutAccounts: baseConnectionMode === "webhook",
|
||||
topLevelInheritedAccountActive: ({ account, enabled }) =>
|
||||
enabled &&
|
||||
!hasOwnProperty(account, "encryptKey") &&
|
||||
resolveAccountMode(account) === "webhook",
|
||||
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "webhook",
|
||||
topInactiveReason: "no enabled Feishu webhook-mode surface inherits this top-level encryptKey.",
|
||||
accountInactiveReason: "Feishu account is disabled or not running in webhook mode.",
|
||||
});
|
||||
collectConditionalChannelFieldAssignments({
|
||||
channelKey: "feishu",
|
||||
field: "verificationToken",
|
||||
channel: feishu,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topLevelActiveWithoutAccounts: baseConnectionMode === "webhook",
|
||||
topLevelInheritedAccountActive: ({ account, enabled }) =>
|
||||
enabled &&
|
||||
!hasOwnProperty(account, "verificationToken") &&
|
||||
resolveAccountMode(account) === "webhook",
|
||||
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "webhook",
|
||||
topInactiveReason:
|
||||
"no enabled Feishu webhook-mode surface inherits this top-level verificationToken.",
|
||||
accountInactiveReason: "Feishu account is disabled or not running in webhook mode.",
|
||||
});
|
||||
}
|
||||
70
extensions/feishu/src/security-audit.ts
Normal file
70
extensions/feishu/src/security-audit.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/setup";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function isFeishuDocToolEnabled(cfg: OpenClawConfig): boolean {
|
||||
const channels = asRecord(cfg.channels);
|
||||
const feishu = asRecord(channels?.feishu);
|
||||
if (!feishu || feishu.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseTools = asRecord(feishu.tools);
|
||||
const baseDocEnabled = baseTools?.doc !== false;
|
||||
const baseAppId = hasNonEmptyString(feishu.appId);
|
||||
const baseAppSecret = hasConfiguredSecretInput(feishu.appSecret, cfg.secrets?.defaults);
|
||||
const baseConfigured = baseAppId && baseAppSecret;
|
||||
|
||||
const accounts = asRecord(feishu.accounts);
|
||||
if (!accounts || Object.keys(accounts).length === 0) {
|
||||
return baseDocEnabled && baseConfigured;
|
||||
}
|
||||
|
||||
for (const accountValue of Object.values(accounts)) {
|
||||
const account = asRecord(accountValue) ?? {};
|
||||
if (account.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
const accountTools = asRecord(account.tools);
|
||||
const effectiveTools = accountTools ?? baseTools;
|
||||
const docEnabled = effectiveTools?.doc !== false;
|
||||
if (!docEnabled) {
|
||||
continue;
|
||||
}
|
||||
const accountConfigured =
|
||||
(hasNonEmptyString(account.appId) || baseAppId) &&
|
||||
(hasConfiguredSecretInput(account.appSecret, cfg.secrets?.defaults) || baseAppSecret);
|
||||
if (accountConfigured) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function collectFeishuSecurityAuditFindings(params: { cfg: OpenClawConfig }) {
|
||||
if (!isFeishuDocToolEnabled(params.cfg)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
checkId: "channels.feishu.doc_owner_open_id",
|
||||
severity: "warn" as const,
|
||||
title: "Feishu doc create can grant requester permissions",
|
||||
detail:
|
||||
'channels.feishu tools include "doc"; feishu_doc action "create" can grant document access to the trusted requesting Feishu user.',
|
||||
remediation:
|
||||
"Disable channels.feishu.tools.doc when not needed, and restrict tool access for untrusted prompts.",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -318,6 +318,34 @@ describe("feishu setup wizard status", () => {
|
||||
expect(status.statusLines).toEqual(["Feishu: needs app credentials"]);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted setup configured state", async () => {
|
||||
const status = await feishuGetStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "work",
|
||||
appId: "top_level_app",
|
||||
appSecret: "top-level-secret", // pragma: allowlist secret
|
||||
accounts: {
|
||||
alerts: {
|
||||
appId: "alerts-app",
|
||||
appSecret: "alerts-secret", // pragma: allowlist secret
|
||||
},
|
||||
work: {
|
||||
appId: "",
|
||||
appSecret: "work-secret", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
accountOverrides: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.statusLines).toEqual(["Feishu: needs app credentials"]);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted DM policy account context", async () => {
|
||||
const { feishuSetupWizard } = await import("./setup-surface.js");
|
||||
const cfg = {
|
||||
|
||||
@@ -98,8 +98,9 @@ function setFeishuGroupAllowFrom(
|
||||
return patchFeishuConfig(cfg, accountId, { groupAllowFrom });
|
||||
}
|
||||
|
||||
function isFeishuConfigured(cfg: OpenClawConfig): boolean {
|
||||
function isFeishuConfigured(cfg: OpenClawConfig, accountId?: string | null): boolean {
|
||||
const feishuCfg = ((cfg.channels?.feishu as FeishuConfig | undefined) ?? {}) as FeishuConfig;
|
||||
const resolvedAccountId = normalizeString(accountId) ?? resolveDefaultFeishuAccountId(cfg);
|
||||
|
||||
const isAppIdConfigured = (value: unknown): boolean => {
|
||||
const asString = normalizeString(value);
|
||||
@@ -122,22 +123,25 @@ function isFeishuConfigured(cfg: OpenClawConfig): boolean {
|
||||
isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret),
|
||||
);
|
||||
|
||||
const accountConfigured = Object.values(feishuCfg.accounts ?? {}).some((account) => {
|
||||
if (!account || typeof account !== "object") {
|
||||
return false;
|
||||
}
|
||||
const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId");
|
||||
const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret");
|
||||
const accountAppIdConfigured = hasOwnAppId
|
||||
? isAppIdConfigured((account as Record<string, unknown>).appId)
|
||||
: isAppIdConfigured(feishuCfg?.appId);
|
||||
const accountSecretConfigured = hasOwnAppSecret
|
||||
? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
|
||||
: hasConfiguredSecretInput(feishuCfg?.appSecret);
|
||||
return Boolean(accountAppIdConfigured && accountSecretConfigured);
|
||||
});
|
||||
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
return topLevelConfigured;
|
||||
}
|
||||
|
||||
return topLevelConfigured || accountConfigured;
|
||||
const account = feishuCfg.accounts?.[resolvedAccountId];
|
||||
if (!account || typeof account !== "object") {
|
||||
return topLevelConfigured;
|
||||
}
|
||||
|
||||
const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId");
|
||||
const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret");
|
||||
const accountAppIdConfigured = hasOwnAppId
|
||||
? isAppIdConfigured((account as Record<string, unknown>).appId)
|
||||
: isAppIdConfigured(feishuCfg?.appId);
|
||||
const accountSecretConfigured = hasOwnAppSecret
|
||||
? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
|
||||
: hasConfiguredSecretInput(feishuCfg?.appSecret);
|
||||
|
||||
return Boolean(accountAppIdConfigured && accountSecretConfigured);
|
||||
}
|
||||
|
||||
async function promptFeishuAllowFrom(params: {
|
||||
@@ -258,8 +262,7 @@ export const feishuSetupWizard: ChannelSetupWizard = {
|
||||
unconfiguredHint: "needs app creds",
|
||||
configuredScore: 2,
|
||||
unconfiguredScore: 0,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
accountId ? resolveFeishuAccount({ cfg, accountId }).configured : isFeishuConfigured(cfg),
|
||||
resolveConfigured: ({ cfg, accountId }) => isFeishuConfigured(cfg, accountId),
|
||||
resolveStatusLines: async ({ cfg, accountId, configured }) => {
|
||||
const resolvedCredentials = accountId
|
||||
? (() => {
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("feishu tool account routing", () => {
|
||||
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
||||
});
|
||||
|
||||
test("wiki tool prefers configured defaultAccount over inherited default account context", async () => {
|
||||
test("wiki tool prefers the active contextual account over configured defaultAccount", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
defaultAccount: "b",
|
||||
@@ -94,7 +94,7 @@ describe("feishu tool account routing", () => {
|
||||
const tool = resolveTool("feishu_wiki", { agentAccountId: "a" });
|
||||
await tool.execute("call", { action: "search" });
|
||||
|
||||
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
||||
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-a");
|
||||
});
|
||||
|
||||
test("drive tool registers when first account disables it and routes to agentAccountId", async () => {
|
||||
|
||||
4
extensions/googlechat/contract-api.ts
Normal file
4
extensions/googlechat/contract-api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
type OpenClawConfig,
|
||||
type ResolvedGoogleChatAccount,
|
||||
} from "./channel.deps.runtime.js";
|
||||
import { collectGoogleChatMutableAllowlistWarnings } from "./doctor.js";
|
||||
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
|
||||
import { getGoogleChatRuntime } from "./runtime.js";
|
||||
import { googlechatSetupAdapter } from "./setup-core.js";
|
||||
@@ -218,6 +219,7 @@ export const googlechatPlugin = createChatChannelPlugin({
|
||||
groupModel: "route",
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: false,
|
||||
collectMutableAllowlistWarnings: collectGoogleChatMutableAllowlistWarnings,
|
||||
},
|
||||
status: createComputedAccountStatusAdapter<ResolvedGoogleChatAccount>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
|
||||
57
extensions/googlechat/src/doctor.ts
Normal file
57
extensions/googlechat/src/doctor.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function isGoogleChatMutableAllowEntry(raw: string): boolean {
|
||||
const text = raw.trim();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const withoutPrefix = text.replace(/^(googlechat|google-chat|gchat):/i, "").trim();
|
||||
if (!withoutPrefix) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const withoutUsers = withoutPrefix.replace(/^users\//i, "");
|
||||
return withoutUsers.includes("@");
|
||||
}
|
||||
|
||||
export const collectGoogleChatMutableAllowlistWarnings =
|
||||
createDangerousNameMatchingMutableAllowlistWarningCollector({
|
||||
channel: "googlechat",
|
||||
detector: isGoogleChatMutableAllowEntry,
|
||||
collectLists: (scope) => {
|
||||
const lists = [
|
||||
{
|
||||
pathLabel: `${scope.prefix}.groupAllowFrom`,
|
||||
list: scope.account.groupAllowFrom,
|
||||
},
|
||||
];
|
||||
const dm = asObjectRecord(scope.account.dm);
|
||||
if (dm) {
|
||||
lists.push({
|
||||
pathLabel: `${scope.prefix}.dm.allowFrom`,
|
||||
list: dm.allowFrom,
|
||||
});
|
||||
}
|
||||
const groups = asObjectRecord(scope.account.groups);
|
||||
if (groups) {
|
||||
for (const [groupKey, groupRaw] of Object.entries(groups)) {
|
||||
const group = asObjectRecord(groupRaw);
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
lists.push({
|
||||
pathLabel: `${scope.prefix}.groups.${groupKey}.users`,
|
||||
list: group.users,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lists;
|
||||
},
|
||||
});
|
||||
156
extensions/googlechat/src/secret-contract.ts
Normal file
156
extensions/googlechat/src/secret-contract.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
getChannelSurface,
|
||||
hasOwnProperty,
|
||||
pushAssignment,
|
||||
pushInactiveSurfaceWarning,
|
||||
pushWarning,
|
||||
resolveChannelAccountSurface,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
type SecretTargetRegistryEntry,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
type GoogleChatAccountLike = {
|
||||
serviceAccount?: unknown;
|
||||
serviceAccountRef?: unknown;
|
||||
accounts?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const secretTargetRegistryEntries = [
|
||||
{
|
||||
id: "channels.googlechat.accounts.*.serviceAccount",
|
||||
targetType: "channels.googlechat.serviceAccount",
|
||||
targetTypeAliases: ["channels.googlechat.accounts.*.serviceAccount"],
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.googlechat.accounts.*.serviceAccount",
|
||||
refPathPattern: "channels.googlechat.accounts.*.serviceAccountRef",
|
||||
secretShape: "sibling_ref",
|
||||
expectedResolvedValue: "string-or-object",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
accountIdPathSegmentIndex: 3,
|
||||
},
|
||||
{
|
||||
id: "channels.googlechat.serviceAccount",
|
||||
targetType: "channels.googlechat.serviceAccount",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.googlechat.serviceAccount",
|
||||
refPathPattern: "channels.googlechat.serviceAccountRef",
|
||||
secretShape: "sibling_ref",
|
||||
expectedResolvedValue: "string-or-object",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
] satisfies SecretTargetRegistryEntry[];
|
||||
|
||||
function resolveSecretInputRef(params: {
|
||||
value: unknown;
|
||||
refValue?: unknown;
|
||||
defaults?: SecretDefaults;
|
||||
}) {
|
||||
const explicitRef = coerceSecretRef(params.refValue, params.defaults);
|
||||
const inlineRef = explicitRef ? null : coerceSecretRef(params.value, params.defaults);
|
||||
return {
|
||||
explicitRef,
|
||||
inlineRef,
|
||||
ref: explicitRef ?? inlineRef,
|
||||
};
|
||||
}
|
||||
|
||||
function collectGoogleChatAccountAssignment(params: {
|
||||
target: GoogleChatAccountLike;
|
||||
path: string;
|
||||
defaults: SecretDefaults | undefined;
|
||||
context: ResolverContext;
|
||||
active?: boolean;
|
||||
inactiveReason?: string;
|
||||
}): void {
|
||||
const { explicitRef, ref } = resolveSecretInputRef({
|
||||
value: params.target.serviceAccount,
|
||||
refValue: params.target.serviceAccountRef,
|
||||
defaults: params.defaults,
|
||||
});
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
if (params.active === false) {
|
||||
pushInactiveSurfaceWarning({
|
||||
context: params.context,
|
||||
path: `${params.path}.serviceAccount`,
|
||||
details: params.inactiveReason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
explicitRef &&
|
||||
params.target.serviceAccount !== undefined &&
|
||||
!coerceSecretRef(params.target.serviceAccount, params.defaults)
|
||||
) {
|
||||
pushWarning(params.context, {
|
||||
code: "SECRETS_REF_OVERRIDES_PLAINTEXT",
|
||||
path: params.path,
|
||||
message: `${params.path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`,
|
||||
});
|
||||
}
|
||||
pushAssignment(params.context, {
|
||||
ref,
|
||||
path: `${params.path}.serviceAccount`,
|
||||
expected: "string-or-object",
|
||||
apply: (value) => {
|
||||
params.target.serviceAccount = value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function collectRuntimeConfigAssignments(params: {
|
||||
config: { channels?: Record<string, unknown> };
|
||||
defaults: SecretDefaults | undefined;
|
||||
context: ResolverContext;
|
||||
}): void {
|
||||
const resolved = getChannelSurface(params.config, "googlechat");
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const googleChat = resolved.channel as GoogleChatAccountLike;
|
||||
const surface = resolveChannelAccountSurface(googleChat as Record<string, unknown>);
|
||||
const topLevelServiceAccountActive = !surface.channelEnabled
|
||||
? false
|
||||
: !surface.hasExplicitAccounts
|
||||
? true
|
||||
: surface.accounts.some(
|
||||
({ account, enabled }) =>
|
||||
enabled &&
|
||||
!hasOwnProperty(account, "serviceAccount") &&
|
||||
!hasOwnProperty(account, "serviceAccountRef"),
|
||||
);
|
||||
collectGoogleChatAccountAssignment({
|
||||
target: googleChat,
|
||||
path: "channels.googlechat",
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
active: topLevelServiceAccountActive,
|
||||
inactiveReason: "no enabled account inherits this top-level Google Chat serviceAccount.",
|
||||
});
|
||||
if (!surface.hasExplicitAccounts) {
|
||||
return;
|
||||
}
|
||||
for (const { accountId, account, enabled } of surface.accounts) {
|
||||
if (
|
||||
!hasOwnProperty(account, "serviceAccount") &&
|
||||
!hasOwnProperty(account, "serviceAccountRef")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
collectGoogleChatAccountAssignment({
|
||||
target: account as GoogleChatAccountLike,
|
||||
path: `channels.googlechat.accounts.${accountId}`,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
active: enabled,
|
||||
inactiveReason: "Google Chat account is disabled.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -104,13 +104,7 @@ export const googlechatSetupWizard: ChannelSetupWizard = {
|
||||
unconfiguredHint: "needs auth",
|
||||
includeStatusLine: true,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
accountId
|
||||
? resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none"
|
||||
: listGoogleChatAccountIds(cfg).some(
|
||||
(resolvedAccountId) =>
|
||||
resolveGoogleChatAccount({ cfg, accountId: resolvedAccountId }).credentialSource !==
|
||||
"none",
|
||||
),
|
||||
resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none",
|
||||
}),
|
||||
introNote: {
|
||||
title: "Google Chat setup",
|
||||
|
||||
@@ -211,6 +211,28 @@ describe("googlechat setup", () => {
|
||||
expect(status.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("reports configured state for the configured defaultAccount instead of any account", async () => {
|
||||
const status = await googlechatStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
googlechat: {
|
||||
defaultAccount: "alerts",
|
||||
accounts: {
|
||||
default: {
|
||||
serviceAccount: { client_email: "default@example.com" },
|
||||
},
|
||||
alerts: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountOverrides: {},
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("reports account-scoped config keys for named accounts", () => {
|
||||
expect(googlechatPlugin.setupWizard?.dmPolicy?.resolveConfigKeys?.({}, "alerts")).toEqual({
|
||||
policyKey: "channels.googlechat.accounts.alerts.dm.policy",
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./src/accounts.js";
|
||||
export * from "./src/conversation-bindings.js";
|
||||
export * from "./src/conversation-id.js";
|
||||
export * from "./src/group-policy.js";
|
||||
export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./src/normalize.js";
|
||||
export { IMESSAGE_LEGACY_OUTBOUND_SEND_DEP_KEYS } from "./src/outbound-send-deps.js";
|
||||
export * from "./src/probe.js";
|
||||
export * from "./src/target-parsing-helpers.js";
|
||||
|
||||
10
extensions/imessage/contract-api.ts
Normal file
10
extensions/imessage/contract-api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { createIMessageTestPlugin } from "./src/test-plugin.js";
|
||||
export {
|
||||
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
|
||||
} from "./src/media-contract.js";
|
||||
export {
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} from "./src/media-contract.js";
|
||||
@@ -135,6 +135,9 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
|
||||
resolveRequireMention: resolveIMessageGroupRequireMention,
|
||||
resolveToolPolicy: resolveIMessageGroupToolPolicy,
|
||||
},
|
||||
doctor: {
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
},
|
||||
conversationBindings: {
|
||||
supportsCurrentConversationBinding: true,
|
||||
createManager: ({ cfg, accountId }) =>
|
||||
@@ -195,7 +198,9 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
|
||||
dbPath: snapshot.dbPath ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
await (await loadIMessageChannelRuntime()).probeIMessageAccount({
|
||||
await (
|
||||
await loadIMessageChannelRuntime()
|
||||
).probeIMessageAccount({
|
||||
timeoutMs,
|
||||
cliPath: account.config.cliPath,
|
||||
dbPath: account.config.dbPath,
|
||||
|
||||
31
extensions/imessage/src/media-contract.ts
Normal file
31
extensions/imessage/src/media-contract.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { mergeInboundPathRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { resolveIMessageAccount } from "./accounts.js";
|
||||
|
||||
export const DEFAULT_IMESSAGE_ATTACHMENT_ROOTS = ["/Users/*/Library/Messages/Attachments"] as const;
|
||||
|
||||
export function resolveIMessageAttachmentRoots(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): string[] {
|
||||
const account = resolveIMessageAccount(params);
|
||||
return mergeInboundPathRoots(
|
||||
account.config.attachmentRoots,
|
||||
params.cfg.channels?.imessage?.attachmentRoots,
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveIMessageRemoteAttachmentRoots(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): string[] {
|
||||
const account = resolveIMessageAccount(params);
|
||||
return mergeInboundPathRoots(
|
||||
account.config.remoteAttachmentRoots,
|
||||
params.cfg.channels?.imessage?.remoteAttachmentRoots,
|
||||
account.config.attachmentRoots,
|
||||
params.cfg.channels?.imessage?.attachmentRoots,
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
|
||||
);
|
||||
}
|
||||
@@ -20,12 +20,7 @@ import {
|
||||
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime";
|
||||
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import {
|
||||
isInboundPathAllowed,
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} from "openclaw/plugin-sdk/media-runtime";
|
||||
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
clearHistoryEntriesIfEnabled,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
@@ -40,6 +35,10 @@ import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveIMessageAccount } from "../accounts.js";
|
||||
import { createIMessageRpcClient } from "../client.js";
|
||||
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js";
|
||||
import {
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} from "../media-contract.js";
|
||||
import { probeIMessage } from "../probe.js";
|
||||
import { sendMessageIMessage } from "../send.js";
|
||||
import { normalizeIMessageHandle } from "../targets.js";
|
||||
|
||||
28
extensions/imessage/src/normalize.test.ts
Normal file
28
extensions/imessage/src/normalize.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./normalize.js";
|
||||
|
||||
describe("normalizeIMessageMessagingTarget", () => {
|
||||
it("normalizes blank inputs to undefined", () => {
|
||||
expect(normalizeIMessageMessagingTarget(" ")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves service prefixes for handles", () => {
|
||||
expect(normalizeIMessageMessagingTarget("sms:+1 (555) 222-3333")).toBe("sms:+15552223333");
|
||||
});
|
||||
|
||||
it("drops service prefixes for chat targets", () => {
|
||||
expect(normalizeIMessageMessagingTarget("sms:chat_id:123")).toBe("chat_id:123");
|
||||
expect(normalizeIMessageMessagingTarget("imessage:CHAT_GUID:abc")).toBe("chat_guid:abc");
|
||||
expect(normalizeIMessageMessagingTarget("auto:ChatIdentifier:foo")).toBe("chatidentifier:foo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeIMessageTargetId", () => {
|
||||
it("detects common iMessage target forms", () => {
|
||||
expect(looksLikeIMessageTargetId("sms:+15555550123")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("chat_id:123")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("user@example.com")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("+15555550123")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -193,12 +193,7 @@ export const imessageSetupStatusBase = {
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 0,
|
||||
resolveConfigured: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) =>
|
||||
accountId
|
||||
? resolveIMessageAccount({ cfg, accountId }).configured
|
||||
: listIMessageAccountIds(cfg).some(
|
||||
(listedAccountId) =>
|
||||
resolveIMessageAccount({ cfg, accountId: listedAccountId }).configured,
|
||||
),
|
||||
resolveIMessageAccount({ cfg, accountId }).configured,
|
||||
};
|
||||
|
||||
export function createIMessageSetupWizardProxy(loadWizard: () => Promise<ChannelSetupWizard>) {
|
||||
|
||||
@@ -313,6 +313,28 @@ describe("imessage setup status", () => {
|
||||
|
||||
expect(status.statusLines).toContain("imsg: missing (/tmp/work-imsg)");
|
||||
});
|
||||
|
||||
it("does not inherit configured state from a sibling when defaultAccount is named", async () => {
|
||||
const status = await getIMessageSetupStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
imessage: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
default: {
|
||||
cliPath: "/usr/local/bin/imsg",
|
||||
},
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
accountOverrides: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.statusLines).toContain("iMessage: needs setup");
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeIMessage", () => {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
listImportedBundledPluginFacadeIds,
|
||||
resetFacadeRuntimeStateForTest,
|
||||
} from "../plugin-sdk/facade-runtime.js";
|
||||
import { createIMessageTestPlugin } from "./imessage-test-plugin.js";
|
||||
} from "../../../src/plugin-sdk/facade-runtime.js";
|
||||
import { createIMessageTestPlugin } from "./test-plugin.js";
|
||||
|
||||
afterEach(() => {
|
||||
resetFacadeRuntimeStateForTest();
|
||||
@@ -1,7 +1,37 @@
|
||||
import { normalizeIMessageHandle } from "../channels/plugins/normalize/imessage.js";
|
||||
import type { ChannelOutboundAdapter, ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { resolveOutboundSendDep } from "../infra/outbound/send-deps.js";
|
||||
import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js";
|
||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import { collectStatusIssuesFromLastError } from "openclaw/plugin-sdk/status-helpers";
|
||||
|
||||
function normalizeIMessageTestHandle(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith("imessage:")) {
|
||||
return normalizeIMessageTestHandle(trimmed.slice("imessage:".length));
|
||||
}
|
||||
if (lowered.startsWith("sms:")) {
|
||||
return normalizeIMessageTestHandle(trimmed.slice("sms:".length));
|
||||
}
|
||||
if (lowered.startsWith("auto:")) {
|
||||
return normalizeIMessageTestHandle(trimmed.slice("auto:".length));
|
||||
}
|
||||
if (/^(chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) {
|
||||
return trimmed.replace(/^(chat_id:|chat_guid:|chat_identifier:)/i, (match) =>
|
||||
match.toLowerCase(),
|
||||
);
|
||||
}
|
||||
if (trimmed.includes("@")) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
const digits = trimmed.replace(/[^\d+]/g, "");
|
||||
if (digits) {
|
||||
return digits.startsWith("+") ? `+${digits.slice(1)}` : `+${digits}`;
|
||||
}
|
||||
return trimmed.replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
const defaultIMessageOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
@@ -77,6 +107,6 @@ export const createIMessageTestPlugin = (params?: {
|
||||
},
|
||||
hint: "<handle|chat_id:ID>",
|
||||
},
|
||||
normalizeTarget: (raw) => normalizeIMessageHandle(raw),
|
||||
normalizeTarget: (raw) => normalizeIMessageTestHandle(raw),
|
||||
},
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type ResolvedIrcAccount,
|
||||
} from "./accounts.js";
|
||||
import { IrcChannelConfigSchema } from "./config-schema.js";
|
||||
import { collectIrcMutableAllowlistWarnings } from "./doctor.js";
|
||||
import { monitorIrcProvider } from "./monitor.js";
|
||||
import {
|
||||
normalizeIrcMessagingTarget,
|
||||
@@ -187,6 +188,10 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChat
|
||||
},
|
||||
}),
|
||||
},
|
||||
doctor: {
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
collectMutableAllowlistWarnings: collectIrcMutableAllowlistWarnings,
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
||||
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
|
||||
53
extensions/irc/src/doctor.ts
Normal file
53
extensions/irc/src/doctor.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function isIrcMutableAllowEntry(raw: string): boolean {
|
||||
const text = raw.trim().toLowerCase();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = text
|
||||
.replace(/^irc:/, "")
|
||||
.replace(/^user:/, "")
|
||||
.trim();
|
||||
|
||||
return !normalized.includes("!") && !normalized.includes("@");
|
||||
}
|
||||
|
||||
export const collectIrcMutableAllowlistWarnings =
|
||||
createDangerousNameMatchingMutableAllowlistWarningCollector({
|
||||
channel: "irc",
|
||||
detector: isIrcMutableAllowEntry,
|
||||
collectLists: (scope) => {
|
||||
const lists = [
|
||||
{
|
||||
pathLabel: `${scope.prefix}.allowFrom`,
|
||||
list: scope.account.allowFrom,
|
||||
},
|
||||
{
|
||||
pathLabel: `${scope.prefix}.groupAllowFrom`,
|
||||
list: scope.account.groupAllowFrom,
|
||||
},
|
||||
];
|
||||
const groups = asObjectRecord(scope.account.groups);
|
||||
if (groups) {
|
||||
for (const [groupKey, groupRaw] of Object.entries(groups)) {
|
||||
const group = asObjectRecord(groupRaw);
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
lists.push({
|
||||
pathLabel: `${scope.prefix}.groups.${groupKey}.allowFrom`,
|
||||
list: group.allowFrom,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lists;
|
||||
},
|
||||
});
|
||||
@@ -1,38 +1,51 @@
|
||||
// Private runtime barrel for the bundled IRC extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
// Keep this barrel thin and generic-only.
|
||||
|
||||
export {
|
||||
buildBaseChannelStatusSummary,
|
||||
createAccountStatusSink,
|
||||
chunkTextForOutbound,
|
||||
createChannelPairingController,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deliverFormattedTextWithAttachments,
|
||||
dispatchInboundReplyWithBase,
|
||||
getChatChannelMeta,
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
isDangerousNameMatchingEnabled,
|
||||
logInboundDrop,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveControlCommandGate,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveEffectiveAllowFromLists,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/irc";
|
||||
export type {
|
||||
BaseProbeResult,
|
||||
BlockStreamingCoalesceConfig,
|
||||
ChannelPlugin,
|
||||
OpenClawConfig,
|
||||
PluginRuntime,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
GroupToolPolicyBySenderConfig,
|
||||
GroupToolPolicyConfig,
|
||||
MarkdownConfig,
|
||||
OpenClawConfig,
|
||||
OutboundReplyPayload,
|
||||
PluginRuntime,
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/irc";
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
buildChannelConfigSchema,
|
||||
getChatChannelMeta,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
buildBaseChannelStatusSummary,
|
||||
} from "openclaw/plugin-sdk/channel-status";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
export {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveEffectiveAllowFromLists,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
|
||||
export { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export {
|
||||
deliverFormattedTextWithAttachments,
|
||||
formatTextWithAttachmentLinks,
|
||||
resolveOutboundMediaUrls,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
export {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
isDangerousNameMatchingEnabled,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
|
||||
|
||||
@@ -179,12 +179,7 @@ export const ircSetupWizard: ChannelSetupWizard = {
|
||||
unconfiguredScore: 0,
|
||||
includeStatusLine: true,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
accountId
|
||||
? resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).configured
|
||||
: listIrcAccountIds(cfg as CoreConfig).some(
|
||||
(resolvedAccountId) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId: resolvedAccountId }).configured,
|
||||
),
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).configured,
|
||||
}),
|
||||
introNote: {
|
||||
title: "IRC setup",
|
||||
|
||||
@@ -138,6 +138,32 @@ describe("irc setup", () => {
|
||||
expect(status.statusLines).toEqual(["IRC: needs host + nick"]);
|
||||
});
|
||||
|
||||
it("setup status honors the configured default account", async () => {
|
||||
const status = await ircStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
irc: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
ops: {
|
||||
host: "irc.example.com",
|
||||
nick: "ops-bot",
|
||||
},
|
||||
work: {
|
||||
host: "irc.example.com",
|
||||
nick: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
accountOverrides: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.statusLines).toEqual(["IRC: needs host + nick"]);
|
||||
});
|
||||
|
||||
it("stores nickserv and account config patches on the scoped account", () => {
|
||||
const cfg: CoreConfig = { channels: { irc: {} } };
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
export type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelPlugin,
|
||||
OpenClawConfig,
|
||||
OpenClawPluginApi,
|
||||
PluginRuntime,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
|
||||
export { clearAccountEntryFields } from "openclaw/plugin-sdk/core";
|
||||
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
export type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/line";
|
||||
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract";
|
||||
export {
|
||||
|
||||
5
extensions/line/contract-api.ts
Normal file
5
extensions/line/contract-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
listLineAccountIds,
|
||||
resolveDefaultLineAccountId,
|
||||
resolveLineAccount,
|
||||
} from "./src/accounts.js";
|
||||
@@ -2,16 +2,19 @@
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelPlugin,
|
||||
OpenClawConfig,
|
||||
OpenClawPluginApi,
|
||||
PluginRuntime,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type {
|
||||
ChannelGatewayContext,
|
||||
ChannelStatusIssue,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
export { clearAccountEntryFields } from "openclaw/plugin-sdk/core";
|
||||
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
export type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/line";
|
||||
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract";
|
||||
export type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
||||
export {
|
||||
buildComputedAccountStatusSnapshot,
|
||||
@@ -46,6 +49,7 @@ export {
|
||||
sendMessageLine,
|
||||
} from "./src/send.js";
|
||||
export { monitorLineProvider } from "./src/monitor.js";
|
||||
export { hasLineDirectives, parseLineDirectives } from "./src/reply-payload-transform.js";
|
||||
|
||||
export * from "./src/accounts.js";
|
||||
export * from "./src/bot-access.js";
|
||||
@@ -55,6 +59,7 @@ export * from "./src/download.js";
|
||||
export * from "./src/group-keys.js";
|
||||
export * from "./src/markdown-to-line.js";
|
||||
export * from "./src/probe.js";
|
||||
export * from "./src/reply-payload-transform.js";
|
||||
export * from "./src/send.js";
|
||||
export * from "./src/signature.js";
|
||||
export * from "./src/template-messages.js";
|
||||
|
||||
@@ -8,6 +8,7 @@ import { lineChannelPluginCommon } from "./channel-shared.js";
|
||||
import { lineGatewayAdapter } from "./gateway.js";
|
||||
import { resolveLineGroupRequireMention } from "./group-policy.js";
|
||||
import { lineOutboundAdapter } from "./outbound.js";
|
||||
import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
import { pushMessageLine } from "./send.js";
|
||||
import { lineSetupAdapter } from "./setup-core.js";
|
||||
@@ -74,6 +75,12 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
|
||||
},
|
||||
resolveInboundConversation: ({ to, conversationId }) =>
|
||||
resolveLineInboundConversation({ to, conversationId }),
|
||||
transformReplyPayload: ({ payload }) => {
|
||||
if (!payload.text || !hasLineDirectives(payload.text)) {
|
||||
return payload;
|
||||
}
|
||||
return parseLineDirectives(payload);
|
||||
},
|
||||
targetResolver: {
|
||||
looksLikeId: (id) => {
|
||||
const trimmed = id?.trim();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasLineDirectives, parseLineDirectives } from "./line-directives.js";
|
||||
import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js";
|
||||
|
||||
const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
|
||||
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { ReplyPayload } from "../runtime-api.js";
|
||||
import {
|
||||
createAgendaCard,
|
||||
createAppleTvRemoteCard,
|
||||
createDeviceControlCard,
|
||||
createMediaPlayerCard,
|
||||
createEventCard,
|
||||
} from "../../plugin-sdk/line.js";
|
||||
import type { LineChannelData } from "../../plugin-sdk/line.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
createMediaPlayerCard,
|
||||
} from "./flex-templates.js";
|
||||
import type { LineChannelData } from "./types.js";
|
||||
|
||||
/**
|
||||
* Parse LINE-specific directives from text and extract them into ReplyPayload fields.
|
||||
@@ -21,8 +21,6 @@ import type { ReplyPayload } from "../types.js";
|
||||
* - [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
|
||||
* - [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
|
||||
* - [[appletv_remote: name | status]]
|
||||
*
|
||||
* Returns the modified payload with directives removed from text and fields populated.
|
||||
*/
|
||||
export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
let text = payload.text;
|
||||
@@ -49,7 +47,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
return base.join("&");
|
||||
};
|
||||
|
||||
// Parse [[quick_replies: option1, option2, option3]]
|
||||
const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i);
|
||||
if (quickRepliesMatch) {
|
||||
const options = quickRepliesMatch[1]
|
||||
@@ -62,7 +59,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
text = text.replace(quickRepliesMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[location: title | address | latitude | longitude]]
|
||||
const locationMatch = text.match(/\[\[location:\s*([^\]]+)\]\]/i);
|
||||
if (locationMatch && !lineData.location) {
|
||||
const parts = locationMatch[1].split("|").map((s) => s.trim());
|
||||
@@ -82,18 +78,14 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
text = text.replace(locationMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[confirm: question | yes_label | no_label]] or [[confirm: question | yes_label:yes_data | no_label:no_data]]
|
||||
const confirmMatch = text.match(/\[\[confirm:\s*([^\]]+)\]\]/i);
|
||||
if (confirmMatch && !lineData.templateMessage) {
|
||||
const parts = confirmMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 3) {
|
||||
const [question, yesPart, noPart] = parts;
|
||||
|
||||
// Parse yes_label:yes_data format
|
||||
const [yesLabel, yesData] = yesPart.includes(":")
|
||||
? yesPart.split(":").map((s) => s.trim())
|
||||
: [yesPart, yesPart.toLowerCase()];
|
||||
|
||||
const [noLabel, noData] = noPart.includes(":")
|
||||
? noPart.split(":").map((s) => s.trim())
|
||||
: [noPart, noPart.toLowerCase()];
|
||||
@@ -111,7 +103,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
text = text.replace(confirmMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[buttons: title | text | btn1:data1, btn2:data2]]
|
||||
const buttonsMatch = text.match(/\[\[buttons:\s*([^\]]+)\]\]/i);
|
||||
if (buttonsMatch && !lineData.templateMessage) {
|
||||
const parts = buttonsMatch[1].split("|").map((s) => s.trim());
|
||||
@@ -120,7 +111,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
|
||||
const actions = actionsStr.split(",").map((actionStr) => {
|
||||
const trimmed = actionStr.trim();
|
||||
// Find first colon delimiter, ignoring URLs without a label.
|
||||
const colonIndex = (() => {
|
||||
const index = trimmed.indexOf(":");
|
||||
if (index === -1) {
|
||||
@@ -144,7 +134,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
data = trimmed.slice(colonIndex + 1).trim();
|
||||
}
|
||||
|
||||
// Detect action type
|
||||
if (data.startsWith("http://") || data.startsWith("https://")) {
|
||||
return { type: "uri" as const, label, uri: data };
|
||||
}
|
||||
@@ -159,7 +148,7 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
type: "buttons",
|
||||
title,
|
||||
text: bodyText,
|
||||
actions: actions.slice(0, 4), // LINE limit
|
||||
actions: actions.slice(0, 4),
|
||||
altText: `${title}: ${bodyText}`,
|
||||
};
|
||||
}
|
||||
@@ -167,17 +156,13 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
text = text.replace(buttonsMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[media_player: title | artist | source | imageUrl | playing/paused]]
|
||||
const mediaPlayerMatch = text.match(/\[\[media_player:\s*([^\]]+)\]\]/i);
|
||||
if (mediaPlayerMatch && !lineData.flexMessage) {
|
||||
const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 1) {
|
||||
const [title, artist, source, imageUrl, statusStr] = parts;
|
||||
const isPlaying = statusStr?.toLowerCase() === "playing";
|
||||
|
||||
// LINE requires HTTPS URLs for images - skip local/HTTP URLs
|
||||
const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined;
|
||||
|
||||
const deviceKey = toSlug(source || title || "media");
|
||||
const card = createMediaPlayerCard({
|
||||
title: title || "Unknown Track",
|
||||
@@ -201,7 +186,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
text = text.replace(mediaPlayerMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[event: title | date | time | location | description]]
|
||||
const eventMatch = text.match(/\[\[event:\s*([^\]]+)\]\]/i);
|
||||
if (eventMatch && !lineData.flexMessage) {
|
||||
const parts = eventMatch[1].split("|").map((s) => s.trim());
|
||||
@@ -224,7 +208,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
text = text.replace(eventMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[appletv_remote: name | status]]
|
||||
const appleTvMatch = text.match(/\[\[appletv_remote:\s*([^\]]+)\]\]/i);
|
||||
if (appleTvMatch && !lineData.flexMessage) {
|
||||
const parts = appleTvMatch[1].split("|").map((s) => s.trim());
|
||||
@@ -259,13 +242,11 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
text = text.replace(appleTvMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
|
||||
const agendaMatch = text.match(/\[\[agenda:\s*([^\]]+)\]\]/i);
|
||||
if (agendaMatch && !lineData.flexMessage) {
|
||||
const parts = agendaMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 2) {
|
||||
const [title, eventsStr] = parts;
|
||||
|
||||
const events = eventsStr.split(",").map((eventStr) => {
|
||||
const trimmed = eventStr.trim();
|
||||
const colonIdx = trimmed.lastIndexOf(":");
|
||||
@@ -291,13 +272,11 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
text = text.replace(agendaMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
|
||||
const deviceMatch = text.match(/\[\[device:\s*([^\]]+)\]\]/i);
|
||||
if (deviceMatch && !lineData.flexMessage) {
|
||||
const parts = deviceMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 1) {
|
||||
const [deviceName, deviceType, status, controlsStr] = parts;
|
||||
|
||||
const deviceKey = toSlug(deviceName || "device");
|
||||
const controls = controlsStr
|
||||
? controlsStr.split(",").map((ctrlStr) => {
|
||||
@@ -322,7 +301,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
text = text.replace(deviceMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Clean up multiple whitespace/newlines
|
||||
text = text.replace(/\n{3,}/g, "\n\n").trim();
|
||||
|
||||
result.text = text || undefined;
|
||||
@@ -332,9 +310,6 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains any LINE directives
|
||||
*/
|
||||
export function hasLineDirectives(text: string): boolean {
|
||||
return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test(
|
||||
text,
|
||||
@@ -265,6 +265,34 @@ describe("line setup wizard", () => {
|
||||
expect(next?.channels?.line?.accounts?.work?.dmPolicy).toBe("open");
|
||||
expect(next?.channels?.line?.accounts?.work?.allowFrom).toEqual(["Uroot", "*"]);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted setup configured state", async () => {
|
||||
const { lineSetupWizard } = await import("./setup-surface.js");
|
||||
|
||||
const configured = await lineSetupWizard.status.resolveConfigured({
|
||||
cfg: {
|
||||
channels: {
|
||||
line: {
|
||||
defaultAccount: "work",
|
||||
channelAccessToken: "root-token",
|
||||
channelSecret: "root-secret",
|
||||
accounts: {
|
||||
alerts: {
|
||||
channelAccessToken: "alerts-token",
|
||||
channelSecret: "alerts-secret",
|
||||
},
|
||||
work: {
|
||||
channelAccessToken: "",
|
||||
channelSecret: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(configured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeLineBot", () => {
|
||||
|
||||
@@ -57,8 +57,7 @@ const lineDmPolicy: ChannelSetupDmPolicy = {
|
||||
},
|
||||
getCurrent: (cfg, accountId) =>
|
||||
resolveLineAccount({ cfg, accountId: accountId ?? resolveDefaultLineAccountId(cfg) }).config
|
||||
.dmPolicy ??
|
||||
"pairing",
|
||||
.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy, accountId) =>
|
||||
patchLineAccountConfig({
|
||||
cfg,
|
||||
@@ -95,15 +94,14 @@ export const lineSetupWizard: ChannelSetupWizard = {
|
||||
unconfiguredScore: 0,
|
||||
includeStatusLine: true,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
accountId
|
||||
? isLineConfigured(cfg, accountId)
|
||||
: listLineAccountIds(cfg).some((resolvedAccountId) => isLineConfigured(cfg, resolvedAccountId)),
|
||||
isLineConfigured(cfg, accountId ?? resolveDefaultLineAccountId(cfg)),
|
||||
resolveExtraStatusLines: ({ cfg }) => [`Accounts: ${listLineAccountIds(cfg).length || 0}`],
|
||||
}),
|
||||
introNote: {
|
||||
title: "LINE Messaging API",
|
||||
lines: LINE_SETUP_HELP_LINES,
|
||||
shouldShow: ({ cfg, accountId }) => !isLineConfigured(cfg, accountId),
|
||||
shouldShow: ({ cfg, accountId }) =>
|
||||
!isLineConfigured(cfg, accountId ?? resolveDefaultLineAccountId(cfg)),
|
||||
},
|
||||
credentials: [
|
||||
{
|
||||
|
||||
16
extensions/matrix/contract-api.ts
Normal file
16
extensions/matrix/contract-api.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
createMatrixThreadBindingManager,
|
||||
resetMatrixThreadBindingsForTests,
|
||||
} from "./src/matrix/thread-bindings.js";
|
||||
export { setMatrixRuntime } from "./src/runtime.js";
|
||||
export {
|
||||
namedAccountPromotionKeys,
|
||||
resolveSingleAccountPromotionTarget,
|
||||
singleAccountKeysToMove,
|
||||
} from "./src/setup-contract.js";
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
export { matrixSetupAdapter } from "./src/setup-core.js";
|
||||
export { matrixSetupWizard } from "./src/setup-surface.js";
|
||||
@@ -30,10 +30,10 @@ export type {
|
||||
OpenClawConfig,
|
||||
PluginRuntime,
|
||||
RuntimeLogger,
|
||||
RuntimeEnv,
|
||||
WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/matrix-runtime-shared";
|
||||
export { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-runtime-shared";
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export { formatZonedTimestamp } from "openclaw/plugin-sdk/core";
|
||||
|
||||
export function chunkTextForOutbound(text: string, limit: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
|
||||
1
extensions/matrix/runtime-heavy-api.ts
Normal file
1
extensions/matrix/runtime-heavy-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src/runtime-heavy-api.js";
|
||||
@@ -1,142 +1,124 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findMatrixAccountEntry,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "./account-selection.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import { getMatrixScopedEnvVarNames } from "./env-vars.js";
|
||||
|
||||
describe("Matrix account selection topology", () => {
|
||||
it("includes a top-level default account when its auth is actually complete", () => {
|
||||
const cfg = {
|
||||
describe("matrix account selection", () => {
|
||||
it("resolves configured account ids from non-canonical account keys", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "default-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
"Team Ops": { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg)).toEqual(["team-ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
|
||||
});
|
||||
|
||||
it("matches the default account against normalized Matrix account keys", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "Team Ops",
|
||||
accounts: {
|
||||
"Ops Bot": { homeserver: "https://matrix.example.org" },
|
||||
"Team Ops": { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an explicit default when multiple Matrix accounts exist without one", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: { homeserver: "https://matrix.example.org" },
|
||||
alerts: { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(true);
|
||||
});
|
||||
|
||||
it("finds the raw Matrix account entry by normalized account id", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
"Team Ops": {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
userId: "@ops:example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
};
|
||||
|
||||
expect(findMatrixAccountEntry(cfg, "team-ops")).toEqual({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers env-backed named Matrix accounts during enumeration", () => {
|
||||
const keys = getMatrixScopedEnvVarNames("team-ops");
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
[keys.homeserver]: "https://matrix.example.org",
|
||||
[keys.accessToken]: "secret",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["team-ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("team-ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats mixed default and named env-backed Matrix accounts as multi-account", () => {
|
||||
const keys = getMatrixScopedEnvVarNames("team-ops");
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
} as NodeJS.ProcessEnv;
|
||||
MATRIX_ACCESS_TOKEN: "default-secret",
|
||||
[keys.homeserver]: "https://matrix.example.org",
|
||||
[keys.accessToken]: "team-secret",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "team-ops"]);
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not materialize a top-level default account from partial shared auth fields", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "shared-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, {} as NodeJS.ProcessEnv)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, {} as NodeJS.ProcessEnv)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from partial global auth fields", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "shared-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not materialize a top-level default account from homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@default:example.org",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, {} as NodeJS.ProcessEnv)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, {} as NodeJS.ProcessEnv)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from global homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
it("discovers default Matrix accounts backed only by global env vars", () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_USER_ID: "@default:example.org",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
MATRIX_ACCESS_TOKEN: "default-secret",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
|
||||
it("counts env-backed named accounts when shared homeserver comes from channel config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps env-backed named accounts that rely on cached credentials", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_USER_ID: "@ops:example.org",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
194
extensions/matrix/src/legacy-crypto.test.ts
Normal file
194
extensions/matrix/src/legacy-crypto.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../../test/helpers/temp-home.js";
|
||||
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "./storage-paths.js";
|
||||
import {
|
||||
MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
MATRIX_DEFAULT_DEVICE_ID,
|
||||
MATRIX_DEFAULT_USER_ID,
|
||||
MATRIX_OPS_ACCESS_TOKEN,
|
||||
MATRIX_OPS_ACCOUNT_ID,
|
||||
MATRIX_OPS_DEVICE_ID,
|
||||
MATRIX_OPS_USER_ID,
|
||||
MATRIX_TEST_HOMESERVER,
|
||||
writeFile,
|
||||
writeMatrixCredentials,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
function createDefaultMatrixConfig(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_DEFAULT_USER_ID,
|
||||
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeDefaultLegacyCryptoFixture(home: string) {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const cfg = createDefaultMatrixConfig();
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_DEFAULT_USER_ID,
|
||||
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
});
|
||||
writeFile(
|
||||
path.join(rootDir, "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: MATRIX_DEFAULT_DEVICE_ID }),
|
||||
);
|
||||
return { cfg, rootDir };
|
||||
}
|
||||
|
||||
function createOpsLegacyCryptoFixture(params: {
|
||||
home: string;
|
||||
accessToken?: string;
|
||||
includeStoredCredentials?: boolean;
|
||||
}) {
|
||||
const stateDir = path.join(params.home, ".openclaw");
|
||||
writeFile(
|
||||
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: MATRIX_OPS_DEVICE_ID }),
|
||||
);
|
||||
if (params.includeStoredCredentials) {
|
||||
writeMatrixCredentials(stateDir, {
|
||||
accountId: MATRIX_OPS_ACCOUNT_ID,
|
||||
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
|
||||
deviceId: MATRIX_OPS_DEVICE_ID,
|
||||
});
|
||||
}
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_OPS_USER_ID,
|
||||
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
|
||||
accountId: MATRIX_OPS_ACCOUNT_ID,
|
||||
});
|
||||
return { rootDir };
|
||||
}
|
||||
|
||||
describe("matrix legacy encrypted-state migration", () => {
|
||||
afterEach(() => {});
|
||||
|
||||
it("extracts a saved backup key into the new recovery-key path", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
|
||||
expect(detection.warnings).toEqual([]);
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: MATRIX_DEFAULT_DEVICE_ID,
|
||||
roomKeyCounts: { total: 12, backedUp: 12 },
|
||||
backupVersion: "1",
|
||||
decryptionKeyBase64: "YWJjZA==",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
|
||||
const recovery = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
|
||||
) as {
|
||||
privateKeyBase64: string;
|
||||
};
|
||||
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
|
||||
});
|
||||
});
|
||||
|
||||
it("skips migration when no legacy Matrix plans exist", async () => {
|
||||
await withTempHome(async () => {
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg: createDefaultMatrixConfig(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
migrated: false,
|
||||
changes: [],
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when legacy local-only room keys cannot be recovered automatically", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: MATRIX_DEFAULT_DEVICE_ID,
|
||||
roomKeyCounts: { total: 15, backedUp: 10 },
|
||||
backupVersion: null,
|
||||
decryptionKeyBase64: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toContain(
|
||||
'Legacy Matrix encrypted state for account "default" contains 5 room key(s) that were never backed up. Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.',
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
'Legacy Matrix encrypted state for account "default" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.',
|
||||
);
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
|
||||
) as { restoreStatus: string };
|
||||
expect(state.restoreStatus).toBe("manual-action-required");
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers stored credentials for named accounts when config is token-only", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { rootDir } = createOpsLegacyCryptoFixture({
|
||||
home,
|
||||
includeStoredCredentials: true,
|
||||
});
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
accessToken: MATRIX_OPS_ACCESS_TOKEN,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: MATRIX_OPS_DEVICE_ID,
|
||||
roomKeyCounts: { total: 1, backedUp: 1 },
|
||||
backupVersion: "1",
|
||||
decryptionKeyBase64: "b3Bz",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,18 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "../plugin-sdk/json-store.js";
|
||||
import {
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
} from "./matrix-config-helpers.js";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "openclaw/plugin-sdk/json-store";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { resolveConfiguredMatrixAccountIds } from "./account-selection.js";
|
||||
import {
|
||||
resolveLegacyMatrixFlatStoreTarget,
|
||||
resolveMatrixMigrationAccountTarget,
|
||||
} from "./matrix-migration-config.js";
|
||||
import {
|
||||
MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE,
|
||||
isMatrixLegacyCryptoInspectorAvailable,
|
||||
loadMatrixLegacyCryptoInspector,
|
||||
type MatrixLegacyCryptoInspector,
|
||||
} from "./matrix-plugin-helper.js";
|
||||
} from "./migration-config.js";
|
||||
import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js";
|
||||
|
||||
const MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE =
|
||||
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.";
|
||||
|
||||
type MatrixLegacyCryptoCounts = {
|
||||
total: number;
|
||||
@@ -75,6 +70,27 @@ type MatrixLegacyCryptoPrepareDeps = {
|
||||
writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoInspectorParams = {
|
||||
cryptoRootDir: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
log?: (message: string) => void;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoInspectorResult = {
|
||||
deviceId: string | null;
|
||||
roomKeyCounts: {
|
||||
total: number;
|
||||
backedUp: number;
|
||||
} | null;
|
||||
backupVersion: string | null;
|
||||
decryptionKeyBase64: string | null;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoInspector = (
|
||||
params: MatrixLegacyCryptoInspectorParams,
|
||||
) => Promise<MatrixLegacyCryptoInspectorResult>;
|
||||
|
||||
type MatrixLegacyBotSdkMetadata = {
|
||||
deviceId: string | null;
|
||||
};
|
||||
@@ -91,6 +107,15 @@ type MatrixStoredRecoveryKey = {
|
||||
};
|
||||
};
|
||||
|
||||
function isMatrixLegacyCryptoInspectorAvailable(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadMatrixLegacyCryptoInspector(): Promise<MatrixLegacyCryptoInspector> {
|
||||
const module = await import("./matrix/legacy-crypto-inspector.js");
|
||||
return module.inspectLegacyMatrixCryptoStore as MatrixLegacyCryptoInspector;
|
||||
}
|
||||
|
||||
function detectLegacyBotSdkCryptoStore(cryptoRootDir: string): {
|
||||
detected: boolean;
|
||||
warning?: string;
|
||||
@@ -301,13 +326,7 @@ export function detectLegacyMatrixCrypto(params: {
|
||||
cfg: params.cfg,
|
||||
env: params.env ?? process.env,
|
||||
});
|
||||
if (
|
||||
detection.plans.length > 0 &&
|
||||
!isMatrixLegacyCryptoInspectorAvailable({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
})
|
||||
) {
|
||||
if (detection.plans.length > 0 && !isMatrixLegacyCryptoInspectorAvailable()) {
|
||||
return {
|
||||
plans: detection.plans,
|
||||
warnings: [...detection.warnings, MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE],
|
||||
@@ -346,10 +365,7 @@ export async function autoPrepareLegacyMatrixCrypto(params: {
|
||||
let inspectLegacyStore = params.deps?.inspectLegacyStore;
|
||||
if (!inspectLegacyStore) {
|
||||
try {
|
||||
inspectLegacyStore = await loadMatrixLegacyCryptoInspector({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
});
|
||||
inspectLegacyStore = await loadMatrixLegacyCryptoInspector();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (!warnings.includes(message)) {
|
||||
86
extensions/matrix/src/legacy-state.test.ts
Normal file
86
extensions/matrix/src/legacy-state.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../../test/helpers/temp-home.js";
|
||||
import { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
|
||||
|
||||
function writeFile(filePath: string, value: string) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, value, "utf-8");
|
||||
}
|
||||
|
||||
describe("matrix legacy state migration", () => {
|
||||
it("migrates the flat legacy Matrix store into account-scoped storage", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(false);
|
||||
if (!detection || "warning" in detection) {
|
||||
throw new Error("expected a migratable Matrix legacy state plan");
|
||||
}
|
||||
|
||||
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(fs.existsSync(path.join(stateDir, "matrix", "bot-storage.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(stateDir, "matrix", "crypto"))).toBe(false);
|
||||
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
|
||||
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses cached Matrix credentials when the config no longer stores an access token", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
writeFile(
|
||||
path.join(stateDir, "credentials", "matrix", "credentials.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-from-cache",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(false);
|
||||
if (!detection || "warning" in detection) {
|
||||
throw new Error("expected cached credentials to make Matrix migration resolvable");
|
||||
}
|
||||
|
||||
expect(detection.targetRootDir).toContain("matrix.example.org__bot_example.org");
|
||||
|
||||
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-config-helpers.js";
|
||||
import { resolveLegacyMatrixFlatStoreTarget } from "./matrix-migration-config.js";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { resolveLegacyMatrixFlatStoreTarget } from "./migration-config.js";
|
||||
import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js";
|
||||
|
||||
export type MatrixLegacyStateMigrationResult = {
|
||||
migrated: boolean;
|
||||
@@ -6,4 +6,4 @@ export {
|
||||
hasActionableMatrixMigration,
|
||||
hasPendingMatrixMigration,
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
} from "openclaw/plugin-sdk/matrix-runtime-heavy";
|
||||
} from "./runtime-heavy-api.js";
|
||||
|
||||
20
extensions/matrix/src/matrix/media-errors.ts
Normal file
20
extensions/matrix/src/matrix/media-errors.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const MATRIX_MEDIA_SIZE_LIMIT_ERROR_MESSAGE = "Matrix media exceeds configured size limit";
|
||||
|
||||
export class MatrixMediaSizeLimitError extends Error {
|
||||
readonly code = "MATRIX_MEDIA_SIZE_LIMIT" as const;
|
||||
|
||||
constructor(message = MATRIX_MEDIA_SIZE_LIMIT_ERROR_MESSAGE, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "MatrixMediaSizeLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isMatrixMediaSizeLimitError(err: unknown): err is MatrixMediaSizeLimitError {
|
||||
if (err instanceof MatrixMediaSizeLimitError) {
|
||||
return true;
|
||||
}
|
||||
if (!(err instanceof Error) || err.cause === undefined) {
|
||||
return false;
|
||||
}
|
||||
return isMatrixMediaSizeLimitError(err.cause);
|
||||
}
|
||||
@@ -26,9 +26,13 @@ function resolveMatrixMediaLabel(
|
||||
|
||||
function formatMatrixAttachmentMarker(params: {
|
||||
kind?: MatrixMessageAttachmentKind;
|
||||
tooLarge?: boolean;
|
||||
unavailable?: boolean;
|
||||
}): string {
|
||||
const label = resolveMatrixMediaLabel(params.kind);
|
||||
if (params.tooLarge) {
|
||||
return `[matrix ${label} too large]`;
|
||||
}
|
||||
return params.unavailable ? `[matrix ${label} unavailable]` : `[matrix ${label}]`;
|
||||
}
|
||||
|
||||
@@ -96,6 +100,7 @@ export function resolveMatrixMessageBody(params: {
|
||||
|
||||
export function formatMatrixAttachmentText(params: {
|
||||
attachment?: MatrixMessageAttachmentSummary;
|
||||
tooLarge?: boolean;
|
||||
unavailable?: boolean;
|
||||
}): string | undefined {
|
||||
if (!params.attachment) {
|
||||
@@ -103,6 +108,7 @@ export function formatMatrixAttachmentText(params: {
|
||||
}
|
||||
return formatMatrixAttachmentMarker({
|
||||
kind: params.attachment.kind,
|
||||
tooLarge: params.tooLarge,
|
||||
unavailable: params.unavailable,
|
||||
});
|
||||
}
|
||||
@@ -110,11 +116,13 @@ export function formatMatrixAttachmentText(params: {
|
||||
export function formatMatrixMessageText(params: {
|
||||
body?: string;
|
||||
attachment?: MatrixMessageAttachmentSummary;
|
||||
tooLarge?: boolean;
|
||||
unavailable?: boolean;
|
||||
}): string | undefined {
|
||||
const body = params.body?.trim() ?? "";
|
||||
const marker = formatMatrixAttachmentText({
|
||||
attachment: params.attachment,
|
||||
tooLarge: params.tooLarge,
|
||||
unavailable: params.unavailable,
|
||||
});
|
||||
if (!marker) {
|
||||
@@ -145,3 +153,17 @@ export function formatMatrixMediaUnavailableText(params: {
|
||||
}) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
export function formatMatrixMediaTooLargeText(params: {
|
||||
body?: string;
|
||||
filename?: string;
|
||||
msgtype?: string;
|
||||
}): string {
|
||||
return (
|
||||
formatMatrixMessageText({
|
||||
body: resolveMatrixMessageBody(params),
|
||||
attachment: resolveMatrixMessageAttachment(params),
|
||||
tooLarge: true,
|
||||
}) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { installMatrixMonitorTestRuntime } from "../../test-runtime.js";
|
||||
import { MatrixMediaSizeLimitError } from "../media-errors.js";
|
||||
import {
|
||||
createMatrixHandlerTestHarness,
|
||||
createMatrixRoomMessageEvent,
|
||||
@@ -9,9 +10,13 @@ const { downloadMatrixMediaMock } = vi.hoisted(() => ({
|
||||
downloadMatrixMediaMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./media.js", () => ({
|
||||
downloadMatrixMedia: (...args: unknown[]) => downloadMatrixMediaMock(...args),
|
||||
}));
|
||||
vi.mock("./media.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./media.js")>();
|
||||
return {
|
||||
...actual,
|
||||
downloadMatrixMedia: (...args: unknown[]) => downloadMatrixMediaMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
function createMediaFailureHarness() {
|
||||
const logger = {
|
||||
@@ -211,4 +216,52 @@ describe("createMatrixRoomMessageHandler media failures", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a too-large marker when the download is rejected due to size limit", async () => {
|
||||
downloadMatrixMediaMock.mockRejectedValue(new MatrixMediaSizeLimitError());
|
||||
const { handler, recordInboundSession } = createMediaFailureHarness();
|
||||
|
||||
await handler(
|
||||
"!room:example.org",
|
||||
createImageEvent({
|
||||
msgtype: "m.image",
|
||||
body: "big-photo.jpg",
|
||||
url: "mxc://example/big-image",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(recordInboundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
RawBody: "[matrix image attachment too large]",
|
||||
CommandBody: "[matrix image attachment too large]",
|
||||
MediaPath: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a real caption while marking the attachment too large on size limit error", async () => {
|
||||
downloadMatrixMediaMock.mockRejectedValue(new MatrixMediaSizeLimitError());
|
||||
const { handler, recordInboundSession } = createMediaFailureHarness();
|
||||
|
||||
await handler(
|
||||
"!room:example.org",
|
||||
createImageEvent({
|
||||
msgtype: "m.image",
|
||||
body: "check this out",
|
||||
filename: "large-photo.jpg",
|
||||
url: "mxc://example/big-image",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(recordInboundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
RawBody: "check this out\n\n[matrix image attachment too large]",
|
||||
CommandBody: "check this out\n\n[matrix image attachment too large]",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@ import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runti
|
||||
import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime";
|
||||
import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
|
||||
import { createMatrixDraftStream } from "../draft-stream.js";
|
||||
import { isMatrixMediaSizeLimitError } from "../media-errors.js";
|
||||
import {
|
||||
formatMatrixMediaTooLargeText,
|
||||
formatMatrixMediaUnavailableText,
|
||||
formatMatrixMessageText,
|
||||
resolveMatrixMessageAttachment,
|
||||
@@ -139,6 +141,7 @@ function resolveMatrixInboundBodyText(params: {
|
||||
msgtype?: string;
|
||||
hadMediaUrl: boolean;
|
||||
mediaDownloadFailed: boolean;
|
||||
mediaSizeLimitExceeded?: boolean;
|
||||
}): string {
|
||||
if (params.mediaPlaceholder) {
|
||||
return params.rawBody || params.mediaPlaceholder;
|
||||
@@ -146,6 +149,13 @@ function resolveMatrixInboundBodyText(params: {
|
||||
if (!params.mediaDownloadFailed || !params.hadMediaUrl) {
|
||||
return params.rawBody;
|
||||
}
|
||||
if (params.mediaSizeLimitExceeded) {
|
||||
return formatMatrixMediaTooLargeText({
|
||||
body: params.rawBody,
|
||||
filename: params.filename,
|
||||
msgtype: params.msgtype,
|
||||
});
|
||||
}
|
||||
return formatMatrixMediaUnavailableText({
|
||||
body: params.rawBody,
|
||||
filename: params.filename,
|
||||
@@ -766,6 +776,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
placeholder: string;
|
||||
} | null = null;
|
||||
let mediaDownloadFailed = false;
|
||||
let mediaSizeLimitExceeded = false;
|
||||
const finalContentUrl =
|
||||
"url" in content && typeof content.url === "string" ? content.url : undefined;
|
||||
const finalContentFile =
|
||||
@@ -795,6 +806,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
});
|
||||
} catch (err) {
|
||||
mediaDownloadFailed = true;
|
||||
if (isMatrixMediaSizeLimitError(err)) {
|
||||
mediaSizeLimitExceeded = true;
|
||||
}
|
||||
const errorText = err instanceof Error ? err.message : String(err);
|
||||
logVerboseMessage(
|
||||
`matrix: media download failed room=${roomId} id=${event.event_id ?? "unknown"} type=${content.msgtype} error=${errorText}`,
|
||||
@@ -817,6 +831,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
msgtype: content.msgtype,
|
||||
hadMediaUrl: Boolean(finalMediaUrl),
|
||||
mediaDownloadFailed,
|
||||
mediaSizeLimitExceeded,
|
||||
});
|
||||
if (!bodyText) {
|
||||
await commitInboundEventIfClaimed();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../../../runtime-api.js";
|
||||
import { setMatrixRuntime } from "../../runtime.js";
|
||||
import { MatrixMediaSizeLimitError } from "../media-errors.js";
|
||||
import { downloadMatrixMedia } from "./media.js";
|
||||
|
||||
function createEncryptedClient() {
|
||||
@@ -111,12 +112,31 @@ describe("downloadMatrixMedia", () => {
|
||||
maxBytes: 1024,
|
||||
file,
|
||||
}),
|
||||
).rejects.toThrow("Matrix media exceeds configured size limit");
|
||||
).rejects.toBeInstanceOf(MatrixMediaSizeLimitError);
|
||||
|
||||
expect(decryptMedia).not.toHaveBeenCalled();
|
||||
expect(saveMediaBuffer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves typed size-limit errors from plain media downloads", async () => {
|
||||
const tooLargeError = new MatrixMediaSizeLimitError(
|
||||
"Matrix media exceeds configured size limit (8192 bytes > 4096 bytes)",
|
||||
);
|
||||
const downloadContent = vi.fn().mockRejectedValue(tooLargeError);
|
||||
const client = {
|
||||
downloadContent,
|
||||
} as unknown as import("../sdk.js").MatrixClient;
|
||||
|
||||
await expect(
|
||||
downloadMatrixMedia({
|
||||
client,
|
||||
mxcUrl: "mxc://example/file",
|
||||
contentType: "image/png",
|
||||
maxBytes: 4096,
|
||||
}),
|
||||
).rejects.toBe(tooLargeError);
|
||||
});
|
||||
|
||||
it("passes byte limits through plain media downloads", async () => {
|
||||
const downloadContent = vi.fn().mockResolvedValue(Buffer.from("plain"));
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user