mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-15 10:38:52 +08:00
Compare commits
183 Commits
codex/host
...
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 | ||
|
|
a18167a2cb | ||
|
|
f5ec0e429f | ||
|
|
1fbf863f53 | ||
|
|
e286ba2bab | ||
|
|
ee5113b1ae | ||
|
|
6a465611d8 | ||
|
|
6286ef55da | ||
|
|
9224afca3d | ||
|
|
cc1881a838 | ||
|
|
0273062dfd | ||
|
|
b361667f98 | ||
|
|
24afd52fcd | ||
|
|
1d4fcb6a01 | ||
|
|
724dd5ca3d | ||
|
|
c7554d3072 | ||
|
|
0bbacca828 | ||
|
|
37de88181b | ||
|
|
2d2fe2bf47 | ||
|
|
5f17362667 | ||
|
|
4578351488 | ||
|
|
811efa2db0 | ||
|
|
35a9eeb857 | ||
|
|
4e27e22663 | ||
|
|
ffba320a2c | ||
|
|
0464435777 | ||
|
|
fa5ea4529a | ||
|
|
e57b6be85f | ||
|
|
516e9054de | ||
|
|
5e204df0bf | ||
|
|
88d3b73c6d | ||
|
|
4b71a94450 | ||
|
|
c9dfc35dfd | ||
|
|
9215ff0615 | ||
|
|
7c25af83e4 | ||
|
|
03aea06321 | ||
|
|
a301e2ef87 | ||
|
|
961d8eb095 | ||
|
|
5e9ae0bfd4 | ||
|
|
4948760c65 | ||
|
|
1c66a050c2 | ||
|
|
f007082e06 | ||
|
|
eecb36eff4 | ||
|
|
1420b3bad7 | ||
|
|
1c470c2736 | ||
|
|
d305a80acd | ||
|
|
bc23db501b | ||
|
|
6115a9498c | ||
|
|
8e8f8d0745 | ||
|
|
d8458a1481 | ||
|
|
fcec417d7d |
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,6 +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.
|
||||
- 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
|
||||
|
||||
@@ -114,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.
|
||||
@@ -194,6 +215,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/local Bot API: preserve media MIME types for absolute-path downloads so local audio files still trigger transcription and other MIME-based handling. (#54603) Thanks @jzakirov
|
||||
- Channels/WhatsApp: pass inbound message timestamp to model context so the AI can see when WhatsApp messages were sent. (#58590) Thanks @Maninae
|
||||
- QQBot/voice: lazy-load `silk-wasm` in `audio-convert.ts` so qqbot still starts when the optional voice dependency is missing, while voice encode/decode degrades gracefully instead of crashing at module load time. (#58829) Thanks @WideLee.
|
||||
- WhatsApp/groups: fix bot waking up on self-number quoted replies in groups with `selfChatMode` enabled. (#60148) Thanks @lurebat
|
||||
|
||||
## 2026.3.31
|
||||
|
||||
|
||||
@@ -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": "新手引导"
|
||||
|
||||
@@ -654,7 +654,7 @@ Matrix can act as an exec approval client for a Matrix account.
|
||||
- `channels.matrix.execApprovals.agentFilter`
|
||||
- `channels.matrix.execApprovals.sessionFilter`
|
||||
|
||||
Matrix becomes an exec approval client when `enabled` is true and at least one approver can be resolved. Approvers must be Matrix user IDs such as `@owner:example.org`.
|
||||
Approvers must be Matrix user IDs such as `@owner:example.org`. Matrix auto-enables native exec approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved, either from `execApprovals.approvers` or from `channels.matrix.dm.allowFrom`. Set `enabled: false` to disable Matrix as a native approval client explicitly. Approval requests otherwise fall back to other configured approval routes or the exec approval fallback policy.
|
||||
|
||||
Delivery rules:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -111,7 +111,8 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush.
|
||||
|
||||
- `skills/` (optional)
|
||||
- Workspace-specific skills.
|
||||
- Overrides managed/bundled skills when names collide.
|
||||
- Highest-precedence skill location for that workspace.
|
||||
- Overrides project agent skills, personal agent skills, managed skills, bundled skills, and `skills.load.extraDirs` when names collide.
|
||||
|
||||
- `canvas/` (optional)
|
||||
- Canvas UI files for node displays (for example `canvas/index.html`).
|
||||
|
||||
@@ -55,11 +55,14 @@ guidance for how _you_ want them used.
|
||||
|
||||
## Skills
|
||||
|
||||
OpenClaw loads skills from three locations (workspace wins on name conflict):
|
||||
OpenClaw loads skills from these locations (highest precedence first):
|
||||
|
||||
- Bundled (shipped with the install)
|
||||
- Managed/local: `~/.openclaw/skills`
|
||||
- Workspace: `<workspace>/skills`
|
||||
- Project agent skills: `<workspace>/.agents/skills`
|
||||
- Personal agent skills: `~/.agents/skills`
|
||||
- Managed/local: `~/.openclaw/skills`
|
||||
- Bundled (shipped with the install)
|
||||
- Extra skill folders: `skills.load.extraDirs`
|
||||
|
||||
Skills can be gated by config/env (see `skills` in [Gateway configuration](/gateway/configuration)).
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -946,11 +946,11 @@ for usage/billing and raise limits as needed.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="How do I customize skills without keeping the repo dirty?">
|
||||
Use managed overrides instead of editing the repo copy. Put your changes in `~/.openclaw/skills/<name>/SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.openclaw/openclaw.json`). Precedence is `<workspace>/skills` > `~/.openclaw/skills` > bundled, so managed overrides win without touching git. If you need the skill installed globally but only visible to some agents, keep the shared copy in `~/.openclaw/skills` and control visibility with `agents.defaults.skills` and `agents.list[].skills`. Only upstream-worthy edits should live in the repo and go out as PRs.
|
||||
Use managed overrides instead of editing the repo copy. Put your changes in `~/.openclaw/skills/<name>/SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.openclaw/openclaw.json`). Precedence is `<workspace>/skills` → `<workspace>/.agents/skills` → `~/.agents/skills` → `~/.openclaw/skills` → bundled → `skills.load.extraDirs`, so managed overrides still win over bundled skills without touching git. If you need the skill installed globally but only visible to some agents, keep the shared copy in `~/.openclaw/skills` and control visibility with `agents.defaults.skills` and `agents.list[].skills`. Only upstream-worthy edits should live in the repo and go out as PRs.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Can I load skills from a custom folder?">
|
||||
Yes. Add extra directories via `skills.load.extraDirs` in `~/.openclaw/openclaw.json` (lowest precedence). Default precedence remains: `<workspace>/skills` → `~/.openclaw/skills` → bundled → `skills.load.extraDirs`. `clawhub` installs into `./skills` by default, which OpenClaw treats as `<workspace>/skills` on the next session. If the skill should only be visible to certain agents, pair that with `agents.defaults.skills` or `agents.list[].skills`.
|
||||
Yes. Add extra directories via `skills.load.extraDirs` in `~/.openclaw/openclaw.json` (lowest precedence). Default precedence is `<workspace>/skills` → `<workspace>/.agents/skills` → `~/.agents/skills` → `~/.openclaw/skills` → bundled → `skills.load.extraDirs`. `clawhub` installs into `./skills` by default, which OpenClaw treats as `<workspace>/skills` on the next session. If the skill should only be visible to certain agents, pair that with `agents.defaults.skills` or `agents.list[].skills`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="How can I use different models for different tasks?">
|
||||
@@ -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>`
|
||||
|
||||
|
||||
@@ -1,814 +0,0 @@
|
||||
---
|
||||
title: feat: Add host-owned lane-oriented rich channel interface
|
||||
type: feat
|
||||
status: completed
|
||||
date: 2026-04-02
|
||||
---
|
||||
|
||||
# feat: Add host-owned lane-oriented rich channel interface
|
||||
|
||||
## Overview
|
||||
|
||||
Add an additive, host-owned channel interface so plugin authors can express
|
||||
conversation intent once and let OpenClaw own the vendor-specific transport and
|
||||
UI projection:
|
||||
|
||||
- reply in the same lane that produced the inbound event
|
||||
- send a DM to the sender on that same channel
|
||||
- declare rich experiences once and let OpenClaw render buttons, blocks, cards,
|
||||
or selects when a channel supports them
|
||||
- fall back to text-only guidance or command invocation when a rich surface is
|
||||
unavailable or disabled, without asking the plugin to author per-channel
|
||||
rescue paths
|
||||
- keep Telegram Bot API, Discord Bot API, Slack Web API, Teams Graph details,
|
||||
and Feishu/Lark request shapes out of third-party plugin code entirely
|
||||
|
||||
The design should sit on top of the existing `DeliveryContext`,
|
||||
`ChannelOutboundAdapter`, and channel plugin contracts rather than replacing the
|
||||
transport layer. Rich rendering and vendor-specific fallbacks should be owned by
|
||||
OpenClaw channel adapters, not by plugins such as
|
||||
`openclaw-codex-app-server`.
|
||||
|
||||
Implementation note: completed on `codex/host-owned-channel-interface`. The
|
||||
suggested file lists in this plan were directional; the shipped work used a few
|
||||
adjacent seams where that produced a smaller, safer change while preserving the
|
||||
host-owned lane, DM, and semantic interaction goals.
|
||||
|
||||
## Problem Frame
|
||||
|
||||
OpenClaw already has the beginnings of a generic conversation model:
|
||||
|
||||
- `src/utils/delivery-context.ts` records lane identity as
|
||||
`{ channel, to, accountId, threadId }`
|
||||
- `src/infra/outbound/targets.ts` routes replies back to the current lane
|
||||
- `src/channels/plugins/types.adapters.ts` defines a generic outbound adapter
|
||||
with `sendPayload`, `sendText`, `sendMedia`, and `sendPoll`
|
||||
- `src/interactive/payload.ts` already defines normalized interactive reply
|
||||
blocks
|
||||
- channels such as Slack, MS Teams, and Feishu/Lark already advertise richer
|
||||
UI capabilities in their OpenClaw channel plugins
|
||||
|
||||
The notes in
|
||||
`https://github.com/pwrdrvr/openclaw-codex-app-server/issues/76`
|
||||
clarify that the current direction is still not strong enough. A plugin boundary
|
||||
that says "use OpenClaw when possible, but call Telegram directly for the rest"
|
||||
is still a broken boundary.
|
||||
|
||||
Today, the public plugin surface still nudges authors toward channel-specific
|
||||
thinking, and some plugin integrations still end up owning vendor transport
|
||||
knowledge:
|
||||
|
||||
- `src/plugins/runtime/types-channel.ts` exposes channel-namespaced runtime
|
||||
helpers such as `discord`, `slack`, `signal`, and `line`
|
||||
- `src/plugins/types.ts` models interactive handlers as channel-specific unions
|
||||
with different response APIs for Telegram, Discord, and Slack
|
||||
- plugins that want to say "reply here", "DM the sender", or "offer these
|
||||
choices" still need to reason about channel ids, sender ids, per-channel
|
||||
helper shapes, or which channels have buttons vs cards vs selects
|
||||
- fallback behavior can drift into plugin-owned controller code, which leaks
|
||||
token lookup, endpoint names, and raw request payloads across the host/plugin
|
||||
boundary
|
||||
|
||||
This creates two mismatches:
|
||||
|
||||
- the intended plugin boundary is "talk to OpenClaw", but the actual experience
|
||||
still leaks channel-specific transport concerns
|
||||
- the intended UX is "rich when possible, graceful fallback otherwise", but the
|
||||
current surfaces are optimized around channel-specific buttons rather than a
|
||||
cross-channel intent model
|
||||
|
||||
The goal of this plan is therefore stronger than the original version:
|
||||
|
||||
- make the generic lane model the default plugin contract
|
||||
- make the rich interaction contract host-owned, semantic-action-based, and
|
||||
capability-driven
|
||||
- ensure third-party plugins never need direct vendor bot or webhook API logic
|
||||
for common messaging and interaction flows
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
- R1. Plugins can reply to the exact inbound lane without using
|
||||
channel-specific runtime methods.
|
||||
- R2. Plugins can request a DM to the current sender through a generic contract
|
||||
when the channel supports it.
|
||||
- R3. Plugins can describe rich interaction intent through a normalized payload
|
||||
and capability model instead of channel-specific button, block, or card APIs.
|
||||
- R4. OpenClaw can project that rich intent to native channel UI when available
|
||||
and degrade gracefully to text-only or command-driven flows when it is not.
|
||||
- R4a. Plugins declare semantic actions and fallback affordances once, and
|
||||
OpenClaw decides whether those become native buttons/cards/selects or
|
||||
text-and-command guidance on each channel.
|
||||
- R5. Third-party plugins do not need to know vendor endpoint names, token
|
||||
lookup rules, raw request payloads, or fallback precedence for Telegram,
|
||||
Discord, Slack, MS Teams, Feishu/Lark, or similar channels.
|
||||
- R6. The new surface is additive and backwards compatible for bundled and
|
||||
third-party plugins.
|
||||
- R7. The interface is designed around cross-channel viability, with Slack, MS
|
||||
Teams, Feishu/Lark, Telegram, and Discord as representative target channels.
|
||||
- R8. Public SDK exports, docs, API baselines, and contract tests stay aligned
|
||||
with the new surface.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- Do not require identical native UX on every channel; capability-aware
|
||||
degradation is part of the design.
|
||||
- Do not redesign the underlying `ChannelPlugin` transport model or replace
|
||||
`ChannelOutboundAdapter` with a completely new transport abstraction.
|
||||
- Do not move vendor-specific HTTP or SDK logic into third-party plugin code or
|
||||
repo-local plugin controllers as a fallback strategy.
|
||||
- Do not require plugins to choose a renderer family such as "Telegram buttons"
|
||||
or "Teams cards" as part of normal authoring; renderer choice is host-owned.
|
||||
- Do not force every advanced channel-specific admin or moderation action into
|
||||
the new generic surface; those can remain channel-owned operations inside
|
||||
OpenClaw.
|
||||
- Do not change end-user message semantics beyond making the plugin authoring
|
||||
surface more consistent and the fallback behavior more predictable.
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant Code and Patterns
|
||||
|
||||
- `src/utils/delivery-context.ts` is the current normalized route identity and
|
||||
should remain the source of truth for lane resolution.
|
||||
- `src/infra/outbound/targets.ts` already protects same-lane reply routing
|
||||
across shared-session cases, especially for `dmScope=main`.
|
||||
- `src/channels/plugins/types.adapters.ts` already has a generic outbound
|
||||
contract worth preserving and lifting into a better plugin-facing API.
|
||||
- `src/interactive/payload.ts` already provides normalized `text`, `buttons`,
|
||||
and `select` blocks, which is a strong starting point for a richer
|
||||
channel-neutral interaction contract.
|
||||
- `src/channels/plugins/types.core.ts` already carries static channel
|
||||
capabilities and message-tool discovery capabilities, which can become the
|
||||
basis for capability negotiation rather than channel-name branching.
|
||||
- `src/plugins/types.ts` shows that plugin commands are already lane-oriented in
|
||||
spirit because they return `ReplyPayload` and core routes the reply on the
|
||||
active conversation.
|
||||
- `src/plugins/interactive.ts` and `src/plugins/interactive.test.ts` show the
|
||||
existing channel-specific interaction dispatch layer that will need a
|
||||
compatibility wrapper rather than a flag-day rewrite.
|
||||
- `src/plugin-sdk/conversation-runtime.ts`,
|
||||
`src/plugin-sdk/outbound-runtime.ts`, and
|
||||
`src/plugin-sdk/interactive-runtime.ts` are existing focused public subpaths
|
||||
and are a better evolution point than expanding the legacy
|
||||
`src/plugin-sdk/channel-runtime.ts` shim.
|
||||
- `extensions/slack/src/shared.ts` already documents Slack-native interactive
|
||||
replies and text fallback guidance.
|
||||
- `extensions/msteams/src/channel.ts` already advertises card capabilities and
|
||||
same-lane reply targeting hints.
|
||||
- `extensions/feishu/src/channel.ts` already exposes rich delivery hints and
|
||||
channel-specific capabilities for cards, editing, threading, and reactions.
|
||||
- `src/plugin-sdk/AGENTS.md` and `src/channels/AGENTS.md` both reinforce that
|
||||
new seams should flow through `openclaw/plugin-sdk/*` instead of direct
|
||||
`src/channels/**` exposure.
|
||||
|
||||
### Institutional Learnings
|
||||
|
||||
- No `docs/solutions/` knowledge base was present in this repo, so there were
|
||||
no institutional learnings to carry forward for this topic.
|
||||
|
||||
### External References
|
||||
|
||||
- External research was intentionally skipped. The repo already has strong
|
||||
local patterns for plugin runtime surfaces, channel contracts, and SDK export
|
||||
discipline, and the main open question is architectural fit within OpenClaw's
|
||||
existing boundaries rather than third-party framework behavior.
|
||||
- The notes in `pwrdrvr/openclaw-codex-app-server#76` materially change the
|
||||
design constraint: the plugin/controller boundary must not leak Telegram Bot
|
||||
API, Discord Bot API, or similar vendor-specific transport details.
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- Add a host-owned rich interaction contract on top of `DeliveryContext`,
|
||||
`ReplyPayload`, and `ChannelOutboundAdapter` rather than replacing channel
|
||||
transports.
|
||||
Rationale: the transport layer is already generic enough; the real gap is a
|
||||
plugin-facing contract for lane routing, actor targeting, rich interaction
|
||||
intent, and fallback semantics.
|
||||
|
||||
- Keep vendor-specific transport logic entirely inside OpenClaw channel
|
||||
adapters.
|
||||
Rationale: issue `#76` makes this explicit. A third-party plugin should not
|
||||
need Telegram endpoint names, Discord route shapes, Slack block transport
|
||||
details, or token resolution rules. If raw vendor fallback exists, it belongs
|
||||
in OpenClaw's own channel implementations, not in plugin controller code.
|
||||
|
||||
- Evolve focused SDK subpaths such as `conversation-runtime`,
|
||||
`outbound-runtime`, and `interactive-runtime` instead of widening the legacy
|
||||
`channel-runtime` shim.
|
||||
Rationale: `src/plugin-sdk/AGENTS.md` prefers narrow, purpose-built subpaths
|
||||
and explicitly warns against broad convenience surfaces.
|
||||
|
||||
- Introduce a normalized lane/actor vocabulary plus a rich reply/fallback
|
||||
vocabulary in the public contract.
|
||||
Rationale: "reply here", "DM this sender", "offer these choices", and "fall
|
||||
back to this text or command" are intent-level operations. Plugins should not
|
||||
need to manually juggle `to`, `threadId`, callback ids, cards vs blocks vs
|
||||
inline buttons, or provider-specific route strings to express those intents.
|
||||
|
||||
- Represent rich interactions around semantic actions with host-owned fallback
|
||||
affordances.
|
||||
Rationale: the stable cross-channel unit is not "Telegram inline button" or
|
||||
"Teams Adaptive Card" but "user can choose action X". Labels, descriptions,
|
||||
confirmation text, and fallback commands can project into many renderer
|
||||
families while preserving one plugin-owned intent model.
|
||||
|
||||
- Model channel differences as capabilities and renderers, not as plugin-facing
|
||||
vendor APIs.
|
||||
Rationale: Slack, MS Teams, Feishu/Lark, Telegram, and Discord differ in what
|
||||
they can render, but plugin authors should only choose among normalized
|
||||
capabilities and fallback behavior, not channel-specific HTTP or SDK methods.
|
||||
|
||||
- Keep existing raw channel-specific runtime namespaces only as legacy
|
||||
compatibility surfaces, not as the recommended design center.
|
||||
Rationale: additive migration still matters, but docs and first-party example
|
||||
code should stop steering plugin authors toward channel namespaces for common
|
||||
flows.
|
||||
|
||||
- Migrate bundled plugins and docs to the new surface before considering any
|
||||
future tightening of legacy plugin-facing helpers.
|
||||
Rationale: OpenClaw should prove the new interface on its own rich channels
|
||||
before asking external plugins to adopt it.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved During Planning
|
||||
|
||||
- Should this be additive or breaking?
|
||||
Resolution: additive only. Existing plugin shims stay working; the new
|
||||
interface becomes the preferred path.
|
||||
|
||||
- Should the generic model replace `DeliveryContext`?
|
||||
Resolution: no. `DeliveryContext` remains the canonical route identity; the
|
||||
new interface should wrap it with a better authoring model.
|
||||
|
||||
- Should third-party plugins ever own raw vendor API fallbacks?
|
||||
Resolution: no. If vendor-specific transport fallback is needed, it belongs
|
||||
inside OpenClaw channel adapters, not in plugin controller code.
|
||||
|
||||
- Should the new interface live in a broad catch-all SDK file?
|
||||
Resolution: no. Use focused `openclaw/plugin-sdk/*` subpaths and
|
||||
`api.runtime.channel` helpers instead of widening `channel-runtime`.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- Exact type and export names for the normalized lane, actor, and rich reply
|
||||
objects.
|
||||
This is a naming decision that should follow the final call sites once the
|
||||
implementation touches the existing facades.
|
||||
|
||||
- Whether the rich reply contract should extend the existing
|
||||
`InteractiveReply` shape directly or introduce a new richer wrapper that
|
||||
embeds `InteractiveReply` plus fallback metadata.
|
||||
This depends on how much of the current `ReplyPayload` structure can be reused
|
||||
cleanly without overloading it.
|
||||
|
||||
- Whether the generic interaction API lands as a new registration method,
|
||||
a new helper that wraps `registerInteractiveHandler`, or both.
|
||||
The answer depends on which option yields the cleanest migration with the
|
||||
least duplication in dispatch wiring.
|
||||
|
||||
- Which rich primitives should be part of the cross-channel baseline for v1:
|
||||
text, buttons, select menus, cards, command invocations, or some subset.
|
||||
This should be finalized against the actual Slack, MS Teams, Feishu/Lark, and
|
||||
Telegram/Discord renderer constraints during implementation.
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
> _This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce._
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A["Inbound event<br/>message / slash command / button"] --> B["Normalize context<br/>lane ref + sender ref + capability snapshot"]
|
||||
B --> C["Plugin-facing API<br/>replyHere(richReply)<br/>dmSender(richReply)<br/>handleInteraction(action)"]
|
||||
C --> D["Core projection layer<br/>capability negotiation + fallback planning"]
|
||||
D --> E["Channel renderers<br/>blocks / cards / inline buttons / text+commands"]
|
||||
E --> F["Slack / MS Teams / Feishu-Lark / Telegram / Discord / ..."]
|
||||
D --> G["Fallback path<br/>text-only guidance or command invocation"]
|
||||
```
|
||||
|
||||
The key shape change is that plugin code should act on three normalized
|
||||
concepts:
|
||||
|
||||
- **Lane ref**: the current conversation lane, derived from the ambient inbound
|
||||
context or an explicit conversation binding
|
||||
- **Actor ref**: the sender or target actor within a channel, with an optional
|
||||
DM route when the channel can resolve one
|
||||
- **Rich reply intent**: structured content plus explicit fallback behavior that
|
||||
OpenClaw can project to channel-native UI or plain text
|
||||
- **Semantic action**: a stable plugin-defined action id plus presentation
|
||||
metadata that can arrive from a native interaction callback or a fallback
|
||||
command invocation
|
||||
|
||||
Those normalized concepts then flow through generic runtime helpers and
|
||||
capability checks instead of channel-specific senders or plugin-owned vendor API
|
||||
fallbacks.
|
||||
|
||||
## Implementation Units
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
U1["Unit 1<br/>Define lane, actor, and rich reply contracts"]
|
||||
U2["Unit 2<br/>Add capability projection and fallback planning"]
|
||||
U3["Unit 3<br/>Wire host-owned lane reply and DM helpers"]
|
||||
U4["Unit 4<br/>Normalize interaction return path"]
|
||||
U5["Unit 5<br/>Migrate docs and first-party examples"]
|
||||
U6["Unit 6<br/>Lock SDK drift and compatibility"]
|
||||
U1 --> U2
|
||||
U1 --> U3
|
||||
U1 --> U4
|
||||
U2 --> U5
|
||||
U3 --> U5
|
||||
U4 --> U5
|
||||
U5 --> U6
|
||||
```
|
||||
|
||||
- [x] **Unit 1: Define normalized lane, actor, and rich reply contracts**
|
||||
|
||||
**Goal:** Introduce the public vocabulary for "reply here", "DM this sender",
|
||||
and "offer this rich experience with fallback" without exposing raw channel
|
||||
implementation details.
|
||||
|
||||
**Requirements:** R1, R2, R3, R4, R5, R7
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/interactive/payload.ts`
|
||||
- Modify: `src/plugins/types.ts`
|
||||
- Modify: `src/plugins/runtime/types-channel.ts`
|
||||
- Modify: `src/channels/plugins/types.adapters.ts`
|
||||
- Modify: `src/channels/plugins/types.core.ts`
|
||||
- Modify: `src/channels/plugins/types.plugin.ts`
|
||||
- Modify: `src/plugin-sdk/channel-contract.ts`
|
||||
- Modify: `src/plugin-sdk/conversation-runtime.ts`
|
||||
- Modify: `src/plugin-sdk/interactive-runtime.ts`
|
||||
- Test: `src/plugins/interactive.test.ts`
|
||||
- Test: `src/channels/plugins/contracts/plugins-core.contract.test.ts`
|
||||
|
||||
**Approach:**
|
||||
|
||||
- Add normalized public types for lane refs, actor refs, rich reply intent, and
|
||||
channel capability snapshots to the plugin-facing contract.
|
||||
- Add a semantic-action type that describes the action id, user-facing label,
|
||||
optional description, and fallback command/text affordance without committing
|
||||
to a specific renderer family.
|
||||
- Define a channel-owned resolver seam for sender-DM routing so generic plugin
|
||||
code does not guess how to derive a DM target from provider-specific sender
|
||||
ids.
|
||||
- Extend the current interactive payload model so it can carry fallback intent
|
||||
such as "render buttons if available, otherwise render this text and/or this
|
||||
command affordance".
|
||||
- Keep the transport-facing adapter model intact; the new types should wrap
|
||||
existing route and reply primitives rather than replacing them.
|
||||
- Extend `channel-contract`, `conversation-runtime`, and
|
||||
`interactive-runtime` with the narrowest set of public types/helpers needed
|
||||
for plugin authors and tests.
|
||||
|
||||
**Patterns to follow:**
|
||||
|
||||
- `src/utils/delivery-context.ts`
|
||||
- `src/interactive/payload.ts`
|
||||
- `src/channels/plugins/types.adapters.ts`
|
||||
- `src/plugin-sdk/channel-contract.ts`
|
||||
- `src/plugin-sdk/conversation-runtime.ts`
|
||||
|
||||
**Test scenarios:**
|
||||
|
||||
- Happy path: a normalized lane ref can represent a direct chat and a threaded
|
||||
group chat without losing `accountId` or `threadId`.
|
||||
- Happy path: an actor ref can carry sender identity plus a channel-resolved DM
|
||||
target when the channel supports direct messaging.
|
||||
- Happy path: a rich reply intent can describe structured choices plus a text or
|
||||
command fallback without encoding any Telegram-, Discord-, Slack-, or Teams-
|
||||
specific payload shape.
|
||||
- Happy path: a semantic action can be represented once and later projected as
|
||||
a button, card action, select option, or fallback command without changing
|
||||
the plugin-authored action id.
|
||||
- Edge case: channels that cannot derive a DM target report unsupported state
|
||||
explicitly instead of returning an invalid route.
|
||||
- Edge case: a channel that lacks rich interaction support can still consume the
|
||||
fallback fields without losing the core message intent.
|
||||
- Error path: malformed or incomplete route inputs are rejected at the
|
||||
normalization layer rather than propagating broken targets to delivery.
|
||||
- Integration: existing channel plugin contracts still typecheck when they do
|
||||
not adopt the new normalized surfaces yet.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- The new public types can be consumed from `openclaw/plugin-sdk/*` without
|
||||
reaching into `src/channels/**`.
|
||||
- The contract can describe cross-channel rich/fallback intent without exposing
|
||||
vendor endpoint names, token concepts, or raw request payloads.
|
||||
|
||||
- [x] **Unit 2: Add capability projection and fallback planning**
|
||||
|
||||
**Goal:** Let OpenClaw project rich reply intent into channel-native UI when
|
||||
available and into text or command-driven fallback when it is not.
|
||||
|
||||
**Requirements:** R3, R4, R5, R7
|
||||
|
||||
**Dependencies:** Unit 1
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/interactive/payload.ts`
|
||||
- Modify: `src/channels/plugins/outbound/interactive.ts`
|
||||
- Modify: `src/channels/plugins/types.core.ts`
|
||||
- Modify: `src/channels/plugins/types.adapters.ts`
|
||||
- Modify: `extensions/slack/src/blocks-render.ts`
|
||||
- Modify: `extensions/msteams/src/channel.ts`
|
||||
- Modify: `extensions/feishu/src/channel.ts`
|
||||
- Modify: `extensions/telegram/src/button-types.ts`
|
||||
- Modify: `extensions/discord/src/shared-interactive.ts`
|
||||
- Test: `src/channels/plugins/outbound/interactive.test.ts`
|
||||
- Test: `extensions/slack/src/blocks-render.test.ts`
|
||||
- Test: `extensions/telegram/src/button-types.test.ts`
|
||||
|
||||
**Approach:**
|
||||
|
||||
- Add a host-owned projection layer that inspects channel capabilities and
|
||||
chooses the best renderer for a rich reply intent.
|
||||
- Keep the renderer choice inside OpenClaw so plugins provide intent and
|
||||
fallback metadata, not vendor-specific block/card/button payloads.
|
||||
- Expand the channel capability vocabulary so projection can distinguish between
|
||||
coarse transport support and richer affordances such as buttons, single
|
||||
select, cards, editable interactive messages, interaction acknowledgements,
|
||||
and fallback command support.
|
||||
- Support a minimum fallback contract that can degrade to plain text guidance or
|
||||
a command-like invocation path when a channel lacks native rich UI.
|
||||
- Treat Slack, MS Teams, and Feishu/Lark as proof points for richer renderers,
|
||||
while Telegram/Discord remain important compatibility and interaction-return
|
||||
channels.
|
||||
|
||||
**Patterns to follow:**
|
||||
|
||||
- `src/interactive/payload.ts`
|
||||
- `src/channels/plugins/outbound/interactive.ts`
|
||||
- `extensions/slack/src/blocks-render.ts`
|
||||
- `extensions/msteams/src/channel.ts`
|
||||
- `extensions/feishu/src/channel.ts`
|
||||
|
||||
**Test scenarios:**
|
||||
|
||||
- Happy path: the same rich reply intent renders as Slack blocks when Slack
|
||||
interactive replies are enabled.
|
||||
- Happy path: the same rich reply intent renders as an MS Teams card when Teams
|
||||
advertises cards support.
|
||||
- Happy path: the same rich reply intent renders as a Feishu/Lark card or other
|
||||
supported rich surface when that capability is available.
|
||||
- Edge case: when a channel lacks rich rendering support, the projection layer
|
||||
chooses the declared text fallback rather than silently dropping interaction
|
||||
intent.
|
||||
- Edge case: when only a subset of rich primitives is supported, unsupported
|
||||
blocks degrade cleanly without losing the core text content.
|
||||
- Edge case: when a channel supports replies but not native interaction widgets,
|
||||
the projection layer emits the declared command/text fallback rather than
|
||||
pretending the action is unavailable.
|
||||
- Error path: invalid or incomplete fallback metadata is rejected before it
|
||||
reaches a channel renderer.
|
||||
- Integration: renderer selection depends on advertised channel capabilities,
|
||||
not channel-name branching in plugin code.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- A plugin can describe one rich interaction and rely on OpenClaw to render it
|
||||
appropriately across Slack, MS Teams, Feishu/Lark, Telegram, and Discord
|
||||
paths.
|
||||
- Rich replies can degrade to usable text or command flows without plugin-owned
|
||||
vendor logic.
|
||||
|
||||
- [x] **Unit 3: Wire host-owned lane reply and sender-DM helpers**
|
||||
|
||||
**Goal:** Make `api.runtime.channel` capable of delivering generic lane replies
|
||||
and sender-targeted DMs through the existing outbound stack, with all vendor
|
||||
transport logic remaining inside OpenClaw.
|
||||
|
||||
**Requirements:** R1, R2, R5, R6
|
||||
|
||||
**Dependencies:** Unit 1
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/plugins/runtime/runtime-channel.ts`
|
||||
- Modify: `src/plugins/runtime/types-channel.ts`
|
||||
- Modify: `src/utils/delivery-context.ts`
|
||||
- Modify: `src/infra/outbound/targets.ts`
|
||||
- Modify: `src/infra/outbound/deliver.ts`
|
||||
- Modify: `src/plugin-sdk/outbound-runtime.ts`
|
||||
- Modify: `src/plugin-sdk/conversation-runtime.ts`
|
||||
- Test: `src/utils/delivery-context.test.ts`
|
||||
- Test: `src/infra/outbound/message.channels.test.ts`
|
||||
- Test: `src/infra/outbound/targets.test.ts`
|
||||
- Test: `src/plugins/runtime.channel-pin.test.ts`
|
||||
|
||||
**Approach:**
|
||||
|
||||
- Add runtime helpers that accept a normalized lane ref or actor ref and route
|
||||
through the existing outbound adapter loader and target resolution path.
|
||||
- Treat same-lane reply as the default operation, so plugin code does not need
|
||||
to rebuild `to`, `accountId`, or `threadId` from scratch.
|
||||
- Resolve sender DMs through channel-owned target resolution logic instead of
|
||||
generic string manipulation or plugin-owned vendor API rules.
|
||||
- Keep `DeliveryContext` as the canonical persisted route, with the new runtime
|
||||
helpers acting as thin, intention-revealing wrappers around it.
|
||||
|
||||
**Execution note:** Start with characterization coverage around current
|
||||
same-lane routing and thread preservation before introducing the new helper
|
||||
entry points.
|
||||
|
||||
**Patterns to follow:**
|
||||
|
||||
- `src/infra/outbound/targets.ts`
|
||||
- `src/infra/outbound/deliver.ts`
|
||||
- `src/plugins/runtime/runtime-channel.ts`
|
||||
- `src/plugin-sdk/outbound-runtime.ts`
|
||||
|
||||
**Test scenarios:**
|
||||
|
||||
- Happy path: a plugin runtime helper replies to the exact inbound lane for a
|
||||
Telegram topic, a Slack thread, and a Discord channel without provider-
|
||||
specific branching.
|
||||
- Happy path: a sender-DM helper resolves a DM target for a channel that
|
||||
supports direct messaging and delivers through the existing outbound adapter.
|
||||
- Edge case: a same-lane reply in a shared `main` session still honors the
|
||||
original turn source and does not leak into another channel's `lastChannel`.
|
||||
- Error path: a sender-DM request on a channel without DM support returns a
|
||||
structured unsupported result instead of a transport error.
|
||||
- Integration: the runtime helper path still uses `loadChannelOutboundAdapter`
|
||||
and preserves existing outbound chunking, payload normalization, and delivery
|
||||
metadata.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- Generic runtime helpers can deliver replies and DMs through the existing
|
||||
outbound stack without channel-specific runtime shims.
|
||||
- A third-party plugin no longer needs any Telegram Bot API, Discord Bot API,
|
||||
or similar vendor transport fallback to cover common reply and DM flows.
|
||||
- Existing route-safety guarantees around `turnSourceChannel` and persisted
|
||||
`deliveryContext` remain intact.
|
||||
|
||||
- [x] **Unit 4: Normalize the interaction return path**
|
||||
|
||||
**Goal:** Provide a channel-neutral inbound interaction context so native rich
|
||||
surfaces and text/command fallbacks can converge on the same plugin-facing
|
||||
semantic action model.
|
||||
|
||||
**Requirements:** R3, R4, R5, R7
|
||||
|
||||
**Dependencies:** Unit 1
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/plugins/types.ts`
|
||||
- Modify: `src/plugins/interactive.ts`
|
||||
- Modify: `src/plugins/commands.ts`
|
||||
- Modify: `src/plugin-sdk/interactive-runtime.ts`
|
||||
- Modify: `src/channels/plugins/types.core.ts`
|
||||
- Modify: `src/channels/plugins/types.adapters.ts`
|
||||
- Test: `src/plugins/interactive.test.ts`
|
||||
- Test: `src/plugins/captured-registration.test.ts`
|
||||
|
||||
**Approach:**
|
||||
|
||||
- Add a normalized interaction context that exposes lane ref, actor ref,
|
||||
interaction payload, capability snapshot, and generic response helpers such as
|
||||
`reply`, `edit`, `clearInteractive`, and `acknowledge` where supported.
|
||||
- Keep legacy per-channel handler registrations as compatibility wrappers that
|
||||
adapt into the new normalized context.
|
||||
- Define a shared semantic action return path so native rich UI clicks and
|
||||
text/command fallback invocations can both deliver the same action payload to
|
||||
plugin code.
|
||||
- Model channel-specific response differences as capabilities rather than as
|
||||
distinct context unions wherever possible.
|
||||
- Keep any truly channel-only interaction features behind host-owned adapter
|
||||
seams rather than expanding the plugin-facing contract with new vendor APIs.
|
||||
|
||||
**Patterns to follow:**
|
||||
|
||||
- `src/plugins/interactive.ts`
|
||||
- `src/plugins/types.ts`
|
||||
- `src/plugin-sdk/interactive-runtime.ts`
|
||||
- `src/interactive/payload.ts`
|
||||
|
||||
**Test scenarios:**
|
||||
|
||||
- Happy path: a normalized interaction handler can reply and edit buttons for a
|
||||
Telegram callback, a Discord button interaction, and a Slack button action
|
||||
through one shared response contract.
|
||||
- Happy path: a text-only fallback command or invocation path can resolve to the
|
||||
same semantic action payload as the native rich button/select path.
|
||||
- Happy path: the same semantic action id is observed by plugin code whether it
|
||||
originated from Slack, MS Teams, Feishu/Lark, Telegram, Discord, or a
|
||||
text-command fallback path.
|
||||
- Happy path: capability metadata correctly advertises whether `acknowledge`,
|
||||
`edit`, `clearInteractive`, or follow-up responses are available.
|
||||
- Edge case: a channel whose interaction model lacks one response action marks
|
||||
it unsupported without breaking other response methods.
|
||||
- Error path: duplicate callback/interactions still dedupe correctly after the
|
||||
compatibility wrapper adapts into the normalized surface.
|
||||
- Integration: legacy `registerInteractiveHandler` registrations continue to
|
||||
dispatch correctly through the adapter layer, and fallback command invocations
|
||||
can enter the same semantic action pipeline.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- Bundled plugins can adopt a normalized interactive surface incrementally.
|
||||
- The core dispatch layer still honors existing dedupe and conversation-binding
|
||||
behavior across both native rich interactions and fallback command paths.
|
||||
|
||||
- [x] **Unit 5: Migrate docs and first-party examples to the new default**
|
||||
|
||||
**Goal:** Make the host-owned lane and rich reply interface the documented and
|
||||
demonstrated primary path for plugin development.
|
||||
|
||||
**Requirements:** R1, R2, R3, R4, R5, R7, R8
|
||||
|
||||
**Dependencies:** Units 2, 3, and 4
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/plugins/sdk-runtime.md`
|
||||
- Modify: `docs/plugins/sdk-overview.md`
|
||||
- Modify: `docs/plugins/sdk-channel-plugins.md`
|
||||
- Modify: `docs/plugins/architecture.md`
|
||||
- Modify: `docs/plugins/sdk-migration.md`
|
||||
- Modify: `src/plugin-sdk/channel-runtime.ts`
|
||||
- Modify: `src/plugin-sdk/conversation-runtime.ts`
|
||||
- Modify: `src/plugin-sdk/outbound-runtime.ts`
|
||||
- Modify: `src/plugin-sdk/interactive-runtime.ts`
|
||||
- Modify: `extensions/slack/src/channel.ts`
|
||||
- Modify: `extensions/msteams/src/channel.ts`
|
||||
- Modify: `extensions/feishu/src/channel.ts`
|
||||
- Test: `test/channel-outbounds.ts`
|
||||
|
||||
**Approach:**
|
||||
|
||||
- Update docs to show `reply here`, `DM sender`, and normalized interaction
|
||||
handling as the preferred plugin path.
|
||||
- Update docs and examples to show rich intent plus graceful fallback rather
|
||||
than channel-specific button helper usage.
|
||||
- Update docs and examples to show semantic actions plus fallback guidance
|
||||
rather than channel-specific callback payload handling.
|
||||
- Reframe `channel-runtime` as legacy compatibility only; do not add new
|
||||
examples there.
|
||||
- Use Slack, MS Teams, and Feishu/Lark as first-class example channels for rich
|
||||
UI, and Telegram/Discord as important interaction-return and compatibility
|
||||
channels.
|
||||
- Add migration guidance for external plugins that explicitly says vendor bot or
|
||||
webhook API fallback should not live in plugin code.
|
||||
|
||||
**Patterns to follow:**
|
||||
|
||||
- `docs/plugins/sdk-migration.md`
|
||||
- `docs/plugins/sdk-overview.md`
|
||||
- `src/plugin-sdk/AGENTS.md`
|
||||
|
||||
**Test scenarios:**
|
||||
|
||||
- Happy path: the documentation examples compile conceptually against the new
|
||||
public surface and no longer require channel-specific send helpers for common
|
||||
reply paths.
|
||||
- Integration: representative rich channels can switch to the new helpers
|
||||
without changing the user-visible delivery behavior for supported rich flows.
|
||||
- Edge case: channels without a given rich surface are documented as graceful
|
||||
fallback targets rather than as unsupported or plugin-owned transport escape
|
||||
hatches.
|
||||
- Test expectation: none -- this unit is primarily documentation and targeted
|
||||
adoption, with behavior proven by the runtime and contract suites above.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- The preferred docs path for new plugins uses the host-owned lane and rich
|
||||
reply interface.
|
||||
- At least one real rich channel example demonstrates channel-native rendering
|
||||
and one fallback example demonstrates text/command degradation.
|
||||
|
||||
- [x] **Unit 6: Lock SDK drift, compatibility, and rollout safety**
|
||||
|
||||
**Goal:** Make the new interface durable by aligning exports, generated
|
||||
baselines, and compatibility coverage with the new public contract.
|
||||
|
||||
**Requirements:** R6, R7, R8
|
||||
|
||||
**Dependencies:** Units 1 through 5
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `package.json`
|
||||
- Modify: `scripts/lib/plugin-sdk-entrypoints.json`
|
||||
- Modify: `src/plugin-sdk/entrypoints.ts`
|
||||
- Modify: `src/plugin-sdk/api-baseline.ts`
|
||||
- Modify: `docs/.generated/plugin-sdk-api-baseline.json`
|
||||
- Modify: `docs/.generated/plugin-sdk-api-baseline.jsonl`
|
||||
- Test: `src/channels/plugins/contracts/plugins-core.contract.test.ts`
|
||||
- Test: `src/plugins/interactive.test.ts`
|
||||
- Test: `src/utils/delivery-context.test.ts`
|
||||
|
||||
**Approach:**
|
||||
|
||||
- Publish any new or widened public SDK subpaths through the normal plugin-sdk
|
||||
export pipeline and baseline checks.
|
||||
- Add compatibility-focused tests that prove existing channel-specific helpers
|
||||
still load while the new lane-oriented contract remains available.
|
||||
- Add compatibility-focused tests that prove rich/fallback projection remains
|
||||
host-owned and does not require plugin-facing vendor APIs.
|
||||
- Treat API drift as a first-class part of the change, not a postscript.
|
||||
- Capture any deprecation notices or migration breadcrumbs in the docs and API
|
||||
contract instead of relying on tribal knowledge.
|
||||
|
||||
**Patterns to follow:**
|
||||
|
||||
- `src/plugin-sdk/AGENTS.md`
|
||||
- `src/plugin-sdk/api-baseline.ts`
|
||||
- `scripts/lib/plugin-sdk-entrypoints.json`
|
||||
- `package.json`
|
||||
|
||||
**Test scenarios:**
|
||||
|
||||
- Happy path: newly exported SDK entrypoints appear in package exports, entrypoint
|
||||
metadata, and generated API baselines together.
|
||||
- Edge case: older plugins that still depend on channel-specific runtime helpers
|
||||
continue to resolve those imports after the new interface lands.
|
||||
- Edge case: new plugin-facing docs and type surfaces do not require Telegram,
|
||||
Discord, Slack, Teams, or Feishu/Lark vendor transport details to remain
|
||||
usable.
|
||||
- Error path: export drift or missing baseline updates fail the contract checks
|
||||
instead of slipping into release artifacts.
|
||||
- Integration: the public SDK docs, entrypoint metadata, and emitted package
|
||||
exports all describe the same surface.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- The new public interface is represented consistently across source, generated
|
||||
artifacts, and docs.
|
||||
- Compatibility checks make it hard to accidentally ship a partial migration.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** This work touches the plugin runtime (`api.runtime`),
|
||||
plugin registration types, channel plugin contracts, outbound delivery,
|
||||
interactive dispatch, SDK exports, rich UI renderers, fallback projection,
|
||||
and plugin docs. It sits directly on the plugin/core boundary.
|
||||
- **Error propagation:** Unsupported operations such as sender-DM on a
|
||||
non-DM-capable channel or a rich UI primitive on a text-only channel should
|
||||
return structured unsupported or fallback behavior at the normalized
|
||||
interface boundary rather than leaking low-level transport errors.
|
||||
- **State lifecycle risks:** Route identity must continue to respect
|
||||
`DeliveryContext`, `turnSourceChannel`, and session-thread metadata so the new
|
||||
abstraction does not reintroduce cross-lane reply races.
|
||||
- **API surface parity:** The lane-oriented surface should work across commands,
|
||||
tools, background plugin services, and interactive handlers. It should also
|
||||
support both rich-native and text-fallback paths. If a concept only works in
|
||||
one plugin entry point or one channel class, the abstraction is incomplete.
|
||||
- **Integration coverage:** Cross-layer coverage is essential for same-lane
|
||||
reply routing, sender-DM resolution, capability projection, interactive
|
||||
dedupe, fallback command routing, and SDK export alignment.
|
||||
- **Unchanged invariants:** `ChannelOutboundAdapter` and `DeliveryContext`
|
||||
remain valid. This plan changes the preferred plugin authoring surface and
|
||||
strengthens the host/plugin boundary; it does not move vendor transport logic
|
||||
out of OpenClaw channel implementations.
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| The new surface becomes another thin wrapper that still leaks channel-specific concepts everywhere | Keep lane ref, actor ref, rich reply intent, and capability vocabulary small and intention-focused; do not expose provider-specific route syntax or raw vendor payloads as the primary call shape |
|
||||
| DM resolution semantics differ too much across channels | Make sender-DM resolution channel-owned and capability-driven; return unsupported explicitly where a safe generic mapping does not exist |
|
||||
| Rich UI surfaces differ too much across Slack, Teams, Feishu/Lark, Telegram, and Discord | Normalize intent plus fallback, not a fake identical widget model; keep channel-specific rendering inside OpenClaw |
|
||||
| Plugins reintroduce vendor fallback logic because the new host surface is incomplete | Make fallback projection a first-class host feature and document that vendor API logic does not belong in plugin code |
|
||||
| Public SDK drift lands incompletely across exports, docs, and baselines | Treat export metadata, docs, and baseline updates as a dedicated implementation unit with contract coverage |
|
||||
| Bundled or third-party plugins break during migration | Keep the change additive, preserve legacy registrations/helpers, and migrate first-party examples before considering any future deprecation |
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- Update the plugin SDK docs so new plugin authors see the host-owned
|
||||
lane-oriented rich reply path first and legacy channel helpers second.
|
||||
- Add migration guidance that explicitly explains that vendor bot/webhook API
|
||||
fallback does not belong in plugin code.
|
||||
- Document the expected graceful degradation model for channels with partial or
|
||||
no rich-interaction support.
|
||||
- Treat this as a public SDK change: package exports, generated API baseline,
|
||||
and release-note communication must remain aligned.
|
||||
|
||||
## Sources & References
|
||||
|
||||
- Related issue: [pwrdrvr/openclaw-codex-app-server#76](https://github.com/pwrdrvr/openclaw-codex-app-server/issues/76)
|
||||
- Related code: `src/utils/delivery-context.ts`
|
||||
- Related code: `src/infra/outbound/targets.ts`
|
||||
- Related code: `src/channels/plugins/types.adapters.ts`
|
||||
- Related code: `src/interactive/payload.ts`
|
||||
- Related code: `src/plugins/runtime/types-channel.ts`
|
||||
- Related code: `src/plugins/types.ts`
|
||||
- Related code: `src/plugins/interactive.ts`
|
||||
- Related code: `src/plugin-sdk/conversation-runtime.ts`
|
||||
- Related code: `src/plugin-sdk/outbound-runtime.ts`
|
||||
- Related code: `src/plugin-sdk/interactive-runtime.ts`
|
||||
- Related code: `extensions/slack/src/shared.ts`
|
||||
- Related code: `extensions/msteams/src/channel.ts`
|
||||
- Related code: `extensions/feishu/src/channel.ts`
|
||||
- Related docs: `docs/plugins/sdk-runtime.md`
|
||||
- Related docs: `docs/plugins/sdk-channel-plugins.md`
|
||||
- Related docs: `docs/plugins/sdk-overview.md`
|
||||
- Related docs: `docs/plugins/architecture.md`
|
||||
- Related docs: `docs/plugins/sdk-migration.md`
|
||||
@@ -228,20 +228,6 @@ That means:
|
||||
- channels should consume shared core capabilities instead of re-implementing
|
||||
provider behavior ad hoc
|
||||
|
||||
For messaging UX specifically, plugins should express intent in terms of lanes,
|
||||
actors, semantic actions, and fallback affordances. Channel plugins own the
|
||||
projection to native transport UX:
|
||||
|
||||
- plugins reply on a lane instead of choosing a Telegram/Discord/Slack send API
|
||||
- plugins DM actors instead of building provider-specific direct-message routes
|
||||
- plugins describe semantic interactive actions plus text/command fallbacks
|
||||
- channels render native buttons, selects, or cards when available and degrade
|
||||
to text-only affordances when they are not
|
||||
|
||||
That keeps plugin code portable across Slack, Microsoft Teams, Feishu/Lark,
|
||||
Telegram, Discord, and future channels while still allowing host-owned raw
|
||||
channel runtimes as explicit escape hatches.
|
||||
|
||||
Examples:
|
||||
|
||||
- the bundled `openai` plugin owns OpenAI model-provider behavior and OpenAI
|
||||
@@ -1100,9 +1086,6 @@ Recommended split:
|
||||
ids inside `target` values or provider-specific params, not in generic SDK
|
||||
fields.
|
||||
|
||||
At the plugin runtime boundary, normalize those provider-native ids into
|
||||
`PluginLaneRef` and `PluginActorRef` before exposing them to plugin code.
|
||||
|
||||
## Config-backed directories
|
||||
|
||||
Plugins that derive directory entries from config should keep that logic in the
|
||||
|
||||
@@ -23,55 +23,17 @@ pairing, reply threading, and outbound messaging.
|
||||
## How channel plugins work
|
||||
|
||||
Channel plugins do not need their own send/edit/react tools. OpenClaw keeps one
|
||||
shared `message` tool in core, and plugins should not own Telegram Bot API,
|
||||
Discord API, Slack Web API, Microsoft Graph, or Feishu/Lark transport code
|
||||
just to reply or render buttons. Your plugin owns:
|
||||
shared `message` tool in core. Your plugin owns:
|
||||
|
||||
- **Config** — account resolution and setup wizard
|
||||
- **Security** — DM policy and allowlists
|
||||
- **Pairing** — DM approval flow
|
||||
- **Session grammar** — how provider-specific conversation ids map to base chats, thread ids, and parent fallbacks
|
||||
- **Outbound** — sending text, media, and polls to the platform
|
||||
- **Rich projection** — translating semantic `ReplyPayload.interactive`
|
||||
content into native buttons, selects, cards, or text/command fallbacks
|
||||
- **Threading** — how replies are threaded
|
||||
|
||||
Core owns the shared message tool, prompt wiring, the outer session-key shape,
|
||||
generic `:thread:` bookkeeping, semantic interactive payloads, and dispatch.
|
||||
|
||||
## Lane-oriented plugin contract
|
||||
|
||||
Plugin-facing interaction and outbound contracts are intentionally lane-based
|
||||
instead of channel-API based:
|
||||
|
||||
- plugins reply on a `PluginLaneRef` when they want to continue in the same
|
||||
chat/thread/topic
|
||||
- plugins DM a `PluginActorRef` when they want a private follow-up
|
||||
- plugins describe semantic actions and fallback affordances
|
||||
- channel plugins translate that intent into native UX when available
|
||||
|
||||
This lets the same plugin code work across Slack, Microsoft Teams, Feishu/Lark,
|
||||
Telegram, Discord, and future channels without importing channel transport APIs.
|
||||
|
||||
## Rich interactions and fallbacks
|
||||
|
||||
When a reply includes `interactive` content, channel plugins should treat that
|
||||
payload as semantic intent rather than as "Telegram buttons" or "Discord
|
||||
components".
|
||||
|
||||
- Render native buttons, selects, or cards when the channel supports them.
|
||||
- Preserve semantic action ids so generic interaction handlers can route by
|
||||
action rather than raw provider payloads.
|
||||
- When the channel does not support the requested richness, fall back to text
|
||||
and command affordances from the payload.
|
||||
- When you emit command affordances, prefer `/namespace action-id` shapes so
|
||||
the host can route those fallbacks back into the same
|
||||
`registerInteractionHandler(...)` namespace on text-only paths.
|
||||
|
||||
The shared `ChannelCapabilities` contract now includes `richReplies` and
|
||||
`interactionResponses` so a channel can declare whether it supports buttons,
|
||||
selects, cards, command fallbacks, follow-ups, edit-in-place, and related
|
||||
interaction behaviors.
|
||||
generic `:thread:` bookkeeping, and dispatch.
|
||||
|
||||
If your platform stores extra scope inside conversation ids, keep that parsing
|
||||
in the plugin with `messaging.resolveSessionConversation(...)`. That is the
|
||||
|
||||
@@ -131,16 +131,6 @@ bundled plugin workspace, keep provider-owned helpers in that plugin's own
|
||||
| `ensureAgentWorkspace` | `api.runtime.agent.ensureAgentWorkspace` |
|
||||
| session store helpers | `api.runtime.agent.session.*` |
|
||||
|
||||
For messaging plugins, also move away from channel-branded send and
|
||||
interaction contracts when possible:
|
||||
|
||||
| Legacy pattern | Modern equivalent |
|
||||
| --- | --- |
|
||||
| channel-specific outbound helper | `api.runtime.channel.outbound.sendToLane(...)` |
|
||||
| channel-specific DM helper | `api.runtime.channel.outbound.sendToActorDm(...)` |
|
||||
| channel-specific interactive callback handler | `api.registerInteractionHandler(...)` |
|
||||
| raw button/select payload shaping | semantic `InteractiveReply` plus `interactive-runtime` fallback helpers |
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Build and test">
|
||||
|
||||
@@ -97,9 +97,6 @@ subpaths is in `scripts/lib/plugin-sdk-entrypoints.json`.
|
||||
| Subpath | Key exports |
|
||||
| --- | --- |
|
||||
| `plugin-sdk/runtime-store` | `createPluginRuntimeStore` |
|
||||
| `plugin-sdk/conversation-runtime` | Conversation binding and session binding helpers |
|
||||
| `plugin-sdk/outbound-runtime` | `PluginLaneRef`, `PluginActorRef`, outbound identity/send-deps helpers |
|
||||
| `plugin-sdk/interactive-runtime` | `InteractiveReply`, normalization, fallback, and action helpers |
|
||||
| `plugin-sdk/config-runtime` | Config load/write helpers |
|
||||
| `plugin-sdk/approval-runtime` | Exec/plugin approval helpers, approval-capability builders, auth/profile helpers, native routing/runtime helpers |
|
||||
| `plugin-sdk/infra-runtime` | System event/heartbeat helpers |
|
||||
@@ -150,28 +147,14 @@ methods:
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| Method | What it registers |
|
||||
| ---------------------------------------------- | --------------------------------- |
|
||||
| `api.registerHook(events, handler, opts?)` | Event hook |
|
||||
| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
|
||||
| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
|
||||
| `api.registerCli(registrar, opts?)` | CLI subcommand |
|
||||
| `api.registerService(service)` | Background service |
|
||||
| `api.registerInteractionHandler(registration)` | Lane-oriented interaction handler |
|
||||
| `api.registerInteractiveHandler(registration)` | Interactive handler |
|
||||
|
||||
`api.registerInteractionHandler(...)` is the preferred interaction surface for
|
||||
new plugins. It gives handlers a normalized lane, sender, semantic action, and
|
||||
capability-aware response helpers without forcing the plugin to branch on
|
||||
Telegram, Discord, Slack, Teams, or Lark-specific payload shapes.
|
||||
|
||||
If a user invokes a text fallback command shaped like `/namespace action-id`
|
||||
and no explicit plugin command claims that command name, OpenClaw routes it
|
||||
through the same interaction handler namespace. That lets text-only channels
|
||||
and degraded rich replies reuse the same semantic action flow.
|
||||
|
||||
`api.registerInteractiveHandler(...)` remains available as a compatibility path
|
||||
for legacy channel-specific handlers.
|
||||
| Method | What it registers |
|
||||
| ---------------------------------------------- | --------------------- |
|
||||
| `api.registerHook(events, handler, opts?)` | Event hook |
|
||||
| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
|
||||
| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
|
||||
| `api.registerCli(registrar, opts?)` | CLI subcommand |
|
||||
| `api.registerService(service)` | Background service |
|
||||
| `api.registerInteractiveHandler(registration)` | Interactive handler |
|
||||
|
||||
### CLI registration metadata
|
||||
|
||||
|
||||
@@ -330,49 +330,6 @@ api.runtime.tools.registerMemoryCli(/* ... */);
|
||||
|
||||
Channel-specific runtime helpers (available when a channel plugin is loaded).
|
||||
|
||||
Prefer the generic outbound helpers when you want to reply on the same lane a
|
||||
message came from or DM the sender without owning channel transport details:
|
||||
|
||||
```typescript
|
||||
await api.runtime.channel.outbound.sendToLane({
|
||||
cfg: api.config,
|
||||
lane: ctx.lane,
|
||||
payload: {
|
||||
text: "Build finished.",
|
||||
},
|
||||
});
|
||||
|
||||
await api.runtime.channel.outbound.sendToActorDm({
|
||||
cfg: api.config,
|
||||
actor: ctx.sender,
|
||||
payload: {
|
||||
text: "I sent the result privately.",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`api.runtime.channel.outbound` also exposes:
|
||||
|
||||
- `loadAdapter(channelId)` to work with the host-owned outbound adapter for a
|
||||
specific channel when you are writing channel runtime code
|
||||
- `resolveActorDmLane(actor)` to discover a sender's DM route before deciding
|
||||
whether to DM or stay in-channel
|
||||
|
||||
For rich interactions, prefer semantic `ReplyPayload.interactive` data plus the
|
||||
helpers exported from `openclaw/plugin-sdk/interactive-runtime`. Channel
|
||||
plugins project that semantic payload into native buttons, selects, cards, or
|
||||
fallback text/commands based on channel capabilities.
|
||||
|
||||
When a fallback command is rendered as `/namespace action-id`, the normal text
|
||||
command pipeline can route that invocation back into the plugin's
|
||||
`api.registerInteractionHandler(...)` namespace as long as no explicit plugin
|
||||
command owns `/namespace`.
|
||||
|
||||
Raw namespaces such as `api.runtime.channel.telegram` or
|
||||
`api.runtime.channel.discord` still exist for host-owned runtime code, but they
|
||||
are escape hatches. New plugin-facing features should usually land on a generic
|
||||
SDK subpath or on `api.runtime.channel.outbound` first.
|
||||
|
||||
## Storing runtime references
|
||||
|
||||
Use `createPluginRuntimeStore` to store the runtime reference for use outside
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -105,8 +105,10 @@ The YAML frontmatter supports these fields:
|
||||
| Location | Precedence | Scope |
|
||||
| ------------------------------- | ---------- | --------------------- |
|
||||
| `\<workspace\>/skills/` | Highest | Per-agent |
|
||||
| `\<workspace\>/.agents/skills/` | High | Per-workspace agent |
|
||||
| `~/.agents/skills/` | Medium | Shared agent profile |
|
||||
| `~/.openclaw/skills/` | Medium | Shared (all agents) |
|
||||
| Bundled (shipped with OpenClaw) | Lowest | Global |
|
||||
| Bundled (shipped with OpenClaw) | Low | Global |
|
||||
| `skills.load.extraDirs` | Lowest | Custom shared folders |
|
||||
|
||||
## Related
|
||||
|
||||
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),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { expect, vi } from "vitest";
|
||||
import { expect, vi, type Mock } from "vitest";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import { handleBlueBubblesWebhookRequest } from "./monitor.js";
|
||||
import { registerBlueBubblesWebhookTarget } from "./monitor.js";
|
||||
@@ -16,6 +16,11 @@ export type WebhookRequestParams = {
|
||||
};
|
||||
|
||||
export const LOOPBACK_REMOTE_ADDRESSES_FOR_TEST = ["127.0.0.1", "::1", "::ffff:127.0.0.1"] as const;
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
type HangingWebhookRequestForTest = {
|
||||
req: IncomingMessage;
|
||||
destroyMock: UnknownMock;
|
||||
};
|
||||
|
||||
export function createMockAccount(
|
||||
overrides: Partial<ResolvedBlueBubblesAccount["config"]> = {},
|
||||
@@ -182,7 +187,7 @@ export function createLoopbackWebhookRequestParamsForTest(
|
||||
export function createHangingWebhookRequestForTest(
|
||||
url = "/bluebubbles-webhook?password=test-password",
|
||||
remoteAddress = "127.0.0.1",
|
||||
) {
|
||||
): HangingWebhookRequestForTest {
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
const destroyMock = vi.fn();
|
||||
req.method = "POST";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Command } from "commander";
|
||||
import { vi } from "vitest";
|
||||
import * as parentCoreApiModule from "../core-api.js";
|
||||
import * as browserCliSharedModule from "./browser-cli-shared.js";
|
||||
@@ -56,7 +57,7 @@ vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(browserCliR
|
||||
|
||||
const { registerBrowserManageCommands } = await import("./browser-cli-manage.js");
|
||||
|
||||
export function createBrowserManageProgram(params?: { withParentTimeout?: boolean }) {
|
||||
export function createBrowserManageProgram(params?: { withParentTimeout?: boolean }): Command {
|
||||
const { program, browser, parentOpts } = createBrowserProgram();
|
||||
if (params?.withParentTimeout) {
|
||||
browser.option("--timeout <ms>", "Timeout in ms", "30000");
|
||||
|
||||
@@ -6,6 +6,10 @@ export * from "./src/components.js";
|
||||
export * from "./src/directory-config.js";
|
||||
export * from "./src/exec-approvals.js";
|
||||
export * from "./src/group-policy.js";
|
||||
export type {
|
||||
DiscordInteractiveHandlerContext,
|
||||
DiscordInteractiveHandlerRegistration,
|
||||
} from "./src/interactive-dispatch.js";
|
||||
export * from "./src/normalize.js";
|
||||
export * from "./src/pluralkit.js";
|
||||
export * from "./src/probe.js";
|
||||
|
||||
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";
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { resolveDefaultDiscordAccountId } from "../accounts.js";
|
||||
import { getPresence } from "../monitor/presence-cache.js";
|
||||
import {
|
||||
type ActionGate,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
type DiscordActionConfig,
|
||||
type OpenClawConfig,
|
||||
} from "../runtime-api.js";
|
||||
import {
|
||||
addRoleDiscord,
|
||||
@@ -92,6 +94,7 @@ export async function handleDiscordGuildAction(
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
isActionEnabled: ActionGate<DiscordActionConfig>,
|
||||
cfg?: OpenClawConfig,
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const accountId = readStringParam(params, "accountId");
|
||||
switch (action) {
|
||||
@@ -105,10 +108,13 @@ export async function handleDiscordGuildAction(
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const member = accountId
|
||||
? await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId, { accountId })
|
||||
const effectiveAccountId = accountId ?? (cfg ? resolveDefaultDiscordAccountId(cfg) : undefined);
|
||||
const member = effectiveAccountId
|
||||
? await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId, {
|
||||
accountId: effectiveAccountId,
|
||||
})
|
||||
: await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId);
|
||||
const presence = getPresence(accountId, userId);
|
||||
const presence = getPresence(effectiveAccountId, userId);
|
||||
const activities = presence?.activities ?? undefined;
|
||||
const status = presence?.status ?? undefined;
|
||||
return jsonResult({ ok: true, member, ...(presence ? { status, activities } : {}) });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { resolveDefaultDiscordAccountId } from "../accounts.js";
|
||||
import { createDiscordRuntimeAccountContext } from "../client.js";
|
||||
import { readDiscordComponentSpec } from "../components.js";
|
||||
import {
|
||||
@@ -112,7 +113,7 @@ export async function handleDiscordMessagingAction(
|
||||
const reactionRuntimeOptions = cfg
|
||||
? createDiscordRuntimeAccountContext({
|
||||
cfg,
|
||||
accountId: accountId ?? "default",
|
||||
accountId: accountId ?? resolveDefaultDiscordAccountId(cfg),
|
||||
})
|
||||
: accountId
|
||||
? { accountId }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearPresences, setPresence } from "../monitor/presence-cache.js";
|
||||
import { discordGuildActionRuntime, handleDiscordGuildAction } from "./runtime.guild.js";
|
||||
import { handleDiscordAction } from "./runtime.js";
|
||||
import {
|
||||
@@ -88,6 +89,7 @@ const moderationEnabled = (key: keyof DiscordActionConfig) => key === "moderatio
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearPresences();
|
||||
Object.assign(
|
||||
discordMessagingActionRuntime,
|
||||
originalDiscordMessagingActionRuntime,
|
||||
@@ -131,6 +133,36 @@ describe("handleDiscordMessagingAction", () => {
|
||||
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {});
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when cfg is provided and accountId is omitted", async () => {
|
||||
await handleDiscordMessagingAction(
|
||||
"react",
|
||||
{
|
||||
channelId: "C1",
|
||||
messageId: "M1",
|
||||
emoji: "✅",
|
||||
},
|
||||
enableAllActions,
|
||||
undefined,
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: { token: "token-work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
);
|
||||
|
||||
expect(reactMessageDiscord).toHaveBeenCalledWith(
|
||||
"C1",
|
||||
"M1",
|
||||
"✅",
|
||||
expect.objectContaining({ accountId: "work" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes reactions on empty emoji", async () => {
|
||||
await handleDiscordMessagingAction(
|
||||
"react",
|
||||
@@ -457,6 +489,50 @@ describe("handleDiscordMessagingAction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDiscordGuildAction", () => {
|
||||
it("uses configured defaultAccount for omitted memberInfo presence lookup", async () => {
|
||||
setPresence("work", "U1", {
|
||||
user: { id: "U1" },
|
||||
guild_id: "G1",
|
||||
status: "online",
|
||||
activities: [],
|
||||
client_status: {},
|
||||
} as never);
|
||||
|
||||
discordGuildActionRuntime.fetchMemberInfoDiscord = vi.fn(async () => ({ user: { id: "U1" } })) as never;
|
||||
|
||||
const result = await handleDiscordGuildAction(
|
||||
"memberInfo",
|
||||
{
|
||||
guildId: "G1",
|
||||
userId: "U1",
|
||||
},
|
||||
enableAllActions,
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: { token: "token-work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
);
|
||||
|
||||
expect(discordGuildActionRuntime.fetchMemberInfoDiscord).toHaveBeenCalledWith("G1", "U1", {
|
||||
accountId: "work",
|
||||
});
|
||||
expect(result.details).toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
status: "online",
|
||||
activities: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels";
|
||||
const channelsDisabled = () => false;
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function handleDiscordAction(
|
||||
return await handleDiscordMessagingAction(action, params, isActionEnabled, options, cfg);
|
||||
}
|
||||
if (guildActions.has(action)) {
|
||||
return await handleDiscordGuildAction(action, params, isActionEnabled);
|
||||
return await handleDiscordGuildAction(action, params, isActionEnabled, cfg);
|
||||
}
|
||||
if (moderationActions.has(action)) {
|
||||
return await handleDiscordModerationAction(action, params, isActionEnabled);
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("fetchDiscord", () => {
|
||||
"/users/@me/guilds",
|
||||
"test",
|
||||
fetcher,
|
||||
{ retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0 } },
|
||||
{ retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 } },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -3,8 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const loadConfigMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => loadConfigMock(),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn());
|
||||
const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../../../src/channels/plugins/binding-routing.js", async (importOriginal) => {
|
||||
vi.mock("../../../../src/channels/plugins/binding-routing.js", async () => {
|
||||
const { createConfiguredBindingConversationRuntimeModuleMock } =
|
||||
await import("../test-support/configured-binding-runtime.js");
|
||||
return await createConfiguredBindingConversationRuntimeModuleMock(
|
||||
@@ -12,7 +12,10 @@ vi.mock("../../../../src/channels/plugins/binding-routing.js", async (importOrig
|
||||
ensureConfiguredBindingRouteReadyMock,
|
||||
resolveConfiguredBindingRouteMock,
|
||||
},
|
||||
importOriginal,
|
||||
() =>
|
||||
vi.importActual<typeof import("../../../../src/channels/plugins/binding-routing.js")>(
|
||||
"../../../../src/channels/plugins/binding-routing.js",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -52,6 +52,11 @@ type CreateDiscordComponentModal =
|
||||
typeof import("./agent-components.js").createDiscordComponentModal;
|
||||
type CreateDiscordComponentStringSelect =
|
||||
typeof import("./agent-components.js").createDiscordComponentStringSelect;
|
||||
type DispatchReplyWithBufferedBlockDispatcherFn =
|
||||
typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithBufferedBlockDispatcher;
|
||||
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
|
||||
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
|
||||
>;
|
||||
|
||||
let createDiscordComponentButton: CreateDiscordComponentButton;
|
||||
let createDiscordComponentStringSelect: CreateDiscordComponentStringSelect;
|
||||
@@ -81,12 +86,7 @@ describe("discord component interactions", () => {
|
||||
...overrides,
|
||||
}) as DiscordAccountConfig;
|
||||
|
||||
type DispatchParams = {
|
||||
ctx: Record<string, unknown>;
|
||||
dispatcherOptions: {
|
||||
deliver: (payload: { text?: string }) => Promise<void> | void;
|
||||
};
|
||||
};
|
||||
type DispatchParams = Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
|
||||
|
||||
type ComponentContext = Parameters<CreateDiscordComponentButton>[0];
|
||||
|
||||
@@ -285,10 +285,22 @@ describe("discord component interactions", () => {
|
||||
resetDiscordComponentRuntimeMocks();
|
||||
lastDispatchCtx = undefined;
|
||||
enqueueSystemEventMock.mockClear();
|
||||
dispatchReplyMock.mockClear().mockImplementation(async (params: DispatchParams) => {
|
||||
lastDispatchCtx = params.ctx;
|
||||
await params.dispatcherOptions.deliver({ text: "ok" });
|
||||
});
|
||||
dispatchReplyMock
|
||||
.mockClear()
|
||||
.mockImplementation(
|
||||
async (params: DispatchParams): Promise<DispatchReplyWithBufferedBlockDispatcherResult> => {
|
||||
lastDispatchCtx = params.ctx;
|
||||
await params.dispatcherOptions.deliver({ text: "ok" }, { kind: "final" });
|
||||
return {
|
||||
queuedFinal: false,
|
||||
counts: {
|
||||
block: 0,
|
||||
final: 1,
|
||||
tool: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
recordInboundSessionMock.mockClear().mockResolvedValue(undefined);
|
||||
readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined);
|
||||
resolveStorePathMock.mockClear().mockReturnValue("/tmp/openclaw-sessions-test.json");
|
||||
|
||||
@@ -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,30 +11,65 @@ 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 =
|
||||
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<() => Promise<{ ok: boolean; error?: string }>>(async () => ({ ok: true })),
|
||||
vi.fn<EnsureConfiguredBindingRouteReady>(async () => ({ ok: true })),
|
||||
);
|
||||
const resolveConfiguredBindingRouteMock = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
() => {
|
||||
bindingResolution: {
|
||||
record: {
|
||||
conversation: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
boundSessionKey: string;
|
||||
route: {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
};
|
||||
} | null
|
||||
>(() => null),
|
||||
vi.fn<ResolveConfiguredBindingRoute>(() => createUnboundConfiguredRouteResult()),
|
||||
);
|
||||
|
||||
type ConfiguredBindingRoute = ConfiguredBindingRouteResult;
|
||||
type ConfiguredBindingResolution = NonNullable<ConfiguredBindingRoute["bindingResolution"]>;
|
||||
|
||||
function createConfiguredRouteResult(
|
||||
params: Parameters<ResolveConfiguredBindingRoute>[0],
|
||||
): ConfiguredBindingRoute {
|
||||
return {
|
||||
bindingResolution: {
|
||||
record: {
|
||||
bindingId: "binding-1",
|
||||
targetSessionKey: SESSION_KEY,
|
||||
targetKind: "session",
|
||||
status: "active",
|
||||
boundAt: Date.now(),
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "C1",
|
||||
},
|
||||
},
|
||||
} as ConfiguredBindingResolution,
|
||||
boundSessionKey: SESSION_KEY,
|
||||
route: {
|
||||
...params.route,
|
||||
agentId: "main",
|
||||
sessionKey: SESSION_KEY,
|
||||
matchedBy: "binding.channel",
|
||||
lastRoutePolicy: "session",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
||||
const { createConfiguredBindingConversationRuntimeModuleMock } =
|
||||
await import("../test-support/configured-binding-runtime.js");
|
||||
@@ -69,7 +104,7 @@ describe("discord native /think autocomplete", () => {
|
||||
ensureConfiguredBindingRouteReadyMock.mockReset();
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
|
||||
resolveConfiguredBindingRouteMock.mockReset();
|
||||
resolveConfiguredBindingRouteMock.mockReturnValue(null);
|
||||
resolveConfiguredBindingRouteMock.mockReturnValue(createUnboundConfiguredRouteResult());
|
||||
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
STORE_PATH,
|
||||
@@ -154,22 +189,7 @@ describe("discord native /think autocomplete", () => {
|
||||
|
||||
it("falls back when a configured binding is unavailable", async () => {
|
||||
const cfg = createConfig();
|
||||
resolveConfiguredBindingRouteMock.mockReturnValue({
|
||||
bindingResolution: {
|
||||
record: {
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "C1",
|
||||
},
|
||||
},
|
||||
},
|
||||
boundSessionKey: SESSION_KEY,
|
||||
route: {
|
||||
agentId: "main",
|
||||
sessionKey: SESSION_KEY,
|
||||
},
|
||||
});
|
||||
resolveConfiguredBindingRouteMock.mockImplementation(createConfiguredRouteResult);
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "acpx exited",
|
||||
|
||||
@@ -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?.(
|
||||
|
||||
@@ -41,8 +41,8 @@ const hoisted = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../send.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../send.js")>();
|
||||
vi.mock("../send.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../send.js")>("../send.js");
|
||||
return {
|
||||
...actual,
|
||||
addRoleDiscord: vi.fn(),
|
||||
|
||||
@@ -6,8 +6,10 @@ const hoisted = vi.hoisted(() => {
|
||||
return { updateSessionStore, resolveStorePath };
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
updateSessionStore: hoisted.updateSessionStore,
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import { expect, vi } from "vitest";
|
||||
import { expect, vi, type Mock } from "vitest";
|
||||
|
||||
export function createDiscordOutboundHoisted() {
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
|
||||
|
||||
type DiscordOutboundHoisted = {
|
||||
sendMessageDiscordMock: AsyncUnknownMock;
|
||||
sendDiscordComponentMessageMock: AsyncUnknownMock;
|
||||
sendPollDiscordMock: AsyncUnknownMock;
|
||||
sendWebhookMessageDiscordMock: AsyncUnknownMock;
|
||||
getThreadBindingManagerMock: UnknownMock;
|
||||
};
|
||||
|
||||
type DiscordSendModule = typeof import("./send.js");
|
||||
type DiscordSendComponentsModule = typeof import("./send.components.js");
|
||||
type DiscordThreadBindingsModule = typeof import("./monitor/thread-bindings.js");
|
||||
|
||||
function invokeMock<TArgs extends unknown[], TResult>(
|
||||
mock: (...args: unknown[]) => unknown,
|
||||
...args: TArgs
|
||||
): TResult {
|
||||
return mock(...args) as TResult;
|
||||
}
|
||||
|
||||
export function createDiscordOutboundHoisted(): DiscordOutboundHoisted {
|
||||
const sendMessageDiscordMock = vi.fn();
|
||||
const sendDiscordComponentMessageMock = vi.fn();
|
||||
const sendPollDiscordMock = vi.fn();
|
||||
@@ -21,28 +43,94 @@ export const DEFAULT_DISCORD_SEND_RESULT = {
|
||||
channelId: "ch-1",
|
||||
} as const;
|
||||
|
||||
type DiscordOutboundHoisted = ReturnType<typeof createDiscordOutboundHoisted>;
|
||||
export async function createDiscordSendModuleMock(
|
||||
hoisted: DiscordOutboundHoisted,
|
||||
loadActual: () => Promise<DiscordSendModule>,
|
||||
): Promise<DiscordSendModule> {
|
||||
const actual = await loadActual();
|
||||
return {
|
||||
...actual,
|
||||
sendMessageDiscord: (...args: Parameters<DiscordSendModule["sendMessageDiscord"]>) =>
|
||||
invokeMock<
|
||||
Parameters<DiscordSendModule["sendMessageDiscord"]>,
|
||||
ReturnType<DiscordSendModule["sendMessageDiscord"]>
|
||||
>(hoisted.sendMessageDiscordMock, ...args),
|
||||
sendPollDiscord: (...args: Parameters<DiscordSendModule["sendPollDiscord"]>) =>
|
||||
invokeMock<
|
||||
Parameters<DiscordSendModule["sendPollDiscord"]>,
|
||||
ReturnType<DiscordSendModule["sendPollDiscord"]>
|
||||
>(hoisted.sendPollDiscordMock, ...args),
|
||||
sendWebhookMessageDiscord: (
|
||||
...args: Parameters<DiscordSendModule["sendWebhookMessageDiscord"]>
|
||||
) =>
|
||||
invokeMock<
|
||||
Parameters<DiscordSendModule["sendWebhookMessageDiscord"]>,
|
||||
ReturnType<DiscordSendModule["sendWebhookMessageDiscord"]>
|
||||
>(hoisted.sendWebhookMessageDiscordMock, ...args),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDiscordSendComponentsModuleMock(
|
||||
hoisted: DiscordOutboundHoisted,
|
||||
loadActual: () => Promise<DiscordSendComponentsModule>,
|
||||
): Promise<DiscordSendComponentsModule> {
|
||||
const actual = await loadActual();
|
||||
return {
|
||||
...actual,
|
||||
sendDiscordComponentMessage: (
|
||||
...args: Parameters<DiscordSendComponentsModule["sendDiscordComponentMessage"]>
|
||||
) =>
|
||||
invokeMock<
|
||||
Parameters<DiscordSendComponentsModule["sendDiscordComponentMessage"]>,
|
||||
ReturnType<DiscordSendComponentsModule["sendDiscordComponentMessage"]>
|
||||
>(hoisted.sendDiscordComponentMessageMock, ...args),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDiscordThreadBindingsModuleMock(
|
||||
hoisted: DiscordOutboundHoisted,
|
||||
loadActual: () => Promise<DiscordThreadBindingsModule>,
|
||||
): Promise<DiscordThreadBindingsModule> {
|
||||
const actual = await loadActual();
|
||||
return {
|
||||
...actual,
|
||||
getThreadBindingManager: (
|
||||
...args: Parameters<DiscordThreadBindingsModule["getThreadBindingManager"]>
|
||||
) =>
|
||||
invokeMock<
|
||||
Parameters<DiscordThreadBindingsModule["getThreadBindingManager"]>,
|
||||
ReturnType<DiscordThreadBindingsModule["getThreadBindingManager"]>
|
||||
>(hoisted.getThreadBindingManagerMock, ...args),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installDiscordOutboundModuleSpies(hoisted: DiscordOutboundHoisted) {
|
||||
const sendModule = await import("./send.js");
|
||||
vi.spyOn(sendModule, "sendMessageDiscord").mockImplementation((...args: unknown[]) =>
|
||||
hoisted.sendMessageDiscordMock(...args),
|
||||
const mockedSendModule = await createDiscordSendModuleMock(hoisted, async () => sendModule);
|
||||
vi.spyOn(sendModule, "sendMessageDiscord").mockImplementation(
|
||||
mockedSendModule.sendMessageDiscord,
|
||||
);
|
||||
vi.spyOn(sendModule, "sendPollDiscord").mockImplementation((...args: unknown[]) =>
|
||||
hoisted.sendPollDiscordMock(...args),
|
||||
);
|
||||
vi.spyOn(sendModule, "sendWebhookMessageDiscord").mockImplementation((...args: unknown[]) =>
|
||||
hoisted.sendWebhookMessageDiscordMock(...args),
|
||||
vi.spyOn(sendModule, "sendPollDiscord").mockImplementation(mockedSendModule.sendPollDiscord);
|
||||
vi.spyOn(sendModule, "sendWebhookMessageDiscord").mockImplementation(
|
||||
mockedSendModule.sendWebhookMessageDiscord,
|
||||
);
|
||||
|
||||
const sendComponentsModule = await import("./send.components.js");
|
||||
const mockedSendComponentsModule = await createDiscordSendComponentsModuleMock(
|
||||
hoisted,
|
||||
async () => sendComponentsModule,
|
||||
);
|
||||
vi.spyOn(sendComponentsModule, "sendDiscordComponentMessage").mockImplementation(
|
||||
(...args: unknown[]) => hoisted.sendDiscordComponentMessageMock(...args),
|
||||
mockedSendComponentsModule.sendDiscordComponentMessage,
|
||||
);
|
||||
|
||||
const threadBindingsModule = await import("./monitor/thread-bindings.js");
|
||||
const mockedThreadBindingsModule = await createDiscordThreadBindingsModuleMock(
|
||||
hoisted,
|
||||
async () => threadBindingsModule,
|
||||
);
|
||||
vi.spyOn(threadBindingsModule, "getThreadBindingManager").mockImplementation(
|
||||
(...args: unknown[]) => hoisted.getThreadBindingManagerMock(...args),
|
||||
mockedThreadBindingsModule.getThreadBindingManager,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,37 +101,4 @@ describe("buildDiscordInteractiveComponents", () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to text guidance when widgets are unavailable", () => {
|
||||
expect(
|
||||
buildDiscordInteractiveComponents(
|
||||
{
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{
|
||||
label: "Retry",
|
||||
value: "retry",
|
||||
fallback: { command: "/job retry", text: "Retry the job" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
fallbackText: "Use the fallback path.",
|
||||
},
|
||||
{
|
||||
richReplies: {
|
||||
buttons: false,
|
||||
selects: false,
|
||||
commandFallback: true,
|
||||
},
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
blocks: [
|
||||
{ type: "text", text: "Use the fallback path.\n\nRetry: Retry the job (/job retry)" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type { ChannelCapabilities } from "openclaw/plugin-sdk/channel-contract";
|
||||
import {
|
||||
projectInteractiveReplyForCapabilities,
|
||||
reduceInteractiveReply,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import type {
|
||||
InteractiveButtonStyle,
|
||||
InteractiveReply,
|
||||
@@ -19,20 +15,9 @@ const DISCORD_INTERACTIVE_BUTTON_ROW_SIZE = 5;
|
||||
|
||||
export function buildDiscordInteractiveComponents(
|
||||
interactive?: InteractiveReply,
|
||||
capabilities?: Pick<ChannelCapabilities, "richReplies"> | null,
|
||||
): DiscordComponentMessageSpec | undefined {
|
||||
const projected = projectInteractiveReplyForCapabilities({
|
||||
interactive,
|
||||
capabilities: capabilities ?? {
|
||||
richReplies: {
|
||||
buttons: true,
|
||||
selects: true,
|
||||
commandFallback: true,
|
||||
},
|
||||
},
|
||||
}).interactive;
|
||||
const blocks = reduceInteractiveReply(
|
||||
projected,
|
||||
interactive,
|
||||
[] as NonNullable<DiscordComponentMessageSpec["blocks"]>,
|
||||
(state, block) => {
|
||||
if (block.type === "text") {
|
||||
|
||||
@@ -67,17 +67,6 @@ export function createDiscordPluginBase(params: {
|
||||
threads: true,
|
||||
media: true,
|
||||
nativeCommands: true,
|
||||
richReplies: {
|
||||
buttons: true,
|
||||
selects: true,
|
||||
commandFallback: true,
|
||||
},
|
||||
interactionResponses: {
|
||||
acknowledge: true,
|
||||
clearInteractive: true,
|
||||
editText: true,
|
||||
followUp: true,
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
nativeCommandsAutoEnabled: true,
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -1,32 +1,54 @@
|
||||
import { vi } from "vitest";
|
||||
import { vi, type Mock } from "vitest";
|
||||
import { parsePluginBindingApprovalCustomId } from "../../../../src/plugins/conversation-binding.js";
|
||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../src/security/dm-policy-shared.js";
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
buildPluginBindingResolvedTextMock: vi.fn(),
|
||||
dispatchPluginInteractiveHandlerMock: vi.fn(),
|
||||
dispatchReplyMock: vi.fn(),
|
||||
enqueueSystemEventMock: vi.fn(),
|
||||
readAllowFromStoreMock: vi.fn(),
|
||||
readSessionUpdatedAtMock: vi.fn(),
|
||||
recordInboundSessionMock: vi.fn(),
|
||||
resolveStorePathMock: vi.fn(),
|
||||
resolvePluginConversationBindingApprovalMock: vi.fn(),
|
||||
upsertPairingRequestMock: vi.fn(),
|
||||
}));
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
|
||||
type DispatchReplyWithBufferedBlockDispatcherFn =
|
||||
typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithBufferedBlockDispatcher;
|
||||
type DispatchReplyMock = Mock<DispatchReplyWithBufferedBlockDispatcherFn>;
|
||||
|
||||
export const readAllowFromStoreMock = runtimeMocks.readAllowFromStoreMock;
|
||||
export const dispatchPluginInteractiveHandlerMock =
|
||||
type DiscordComponentRuntimeMocks = {
|
||||
buildPluginBindingResolvedTextMock: UnknownMock;
|
||||
dispatchPluginInteractiveHandlerMock: AsyncUnknownMock;
|
||||
dispatchReplyMock: DispatchReplyMock;
|
||||
enqueueSystemEventMock: UnknownMock;
|
||||
readAllowFromStoreMock: AsyncUnknownMock;
|
||||
readSessionUpdatedAtMock: UnknownMock;
|
||||
recordInboundSessionMock: AsyncUnknownMock;
|
||||
resolveStorePathMock: UnknownMock;
|
||||
resolvePluginConversationBindingApprovalMock: AsyncUnknownMock;
|
||||
upsertPairingRequestMock: AsyncUnknownMock;
|
||||
};
|
||||
|
||||
const runtimeMocks = vi.hoisted(
|
||||
(): DiscordComponentRuntimeMocks => ({
|
||||
buildPluginBindingResolvedTextMock: vi.fn(),
|
||||
dispatchPluginInteractiveHandlerMock: vi.fn(),
|
||||
dispatchReplyMock: vi.fn<DispatchReplyWithBufferedBlockDispatcherFn>(),
|
||||
enqueueSystemEventMock: vi.fn(),
|
||||
readAllowFromStoreMock: vi.fn(),
|
||||
readSessionUpdatedAtMock: vi.fn(),
|
||||
recordInboundSessionMock: vi.fn(),
|
||||
resolveStorePathMock: vi.fn(),
|
||||
resolvePluginConversationBindingApprovalMock: vi.fn(),
|
||||
upsertPairingRequestMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const readAllowFromStoreMock: AsyncUnknownMock = runtimeMocks.readAllowFromStoreMock;
|
||||
export const dispatchPluginInteractiveHandlerMock: AsyncUnknownMock =
|
||||
runtimeMocks.dispatchPluginInteractiveHandlerMock;
|
||||
export const dispatchReplyMock = runtimeMocks.dispatchReplyMock;
|
||||
export const enqueueSystemEventMock = runtimeMocks.enqueueSystemEventMock;
|
||||
export const upsertPairingRequestMock = runtimeMocks.upsertPairingRequestMock;
|
||||
export const recordInboundSessionMock = runtimeMocks.recordInboundSessionMock;
|
||||
export const readSessionUpdatedAtMock = runtimeMocks.readSessionUpdatedAtMock;
|
||||
export const resolveStorePathMock = runtimeMocks.resolveStorePathMock;
|
||||
export const resolvePluginConversationBindingApprovalMock =
|
||||
export const dispatchReplyMock: DispatchReplyMock = runtimeMocks.dispatchReplyMock;
|
||||
export const enqueueSystemEventMock: UnknownMock = runtimeMocks.enqueueSystemEventMock;
|
||||
export const upsertPairingRequestMock: AsyncUnknownMock = runtimeMocks.upsertPairingRequestMock;
|
||||
export const recordInboundSessionMock: AsyncUnknownMock = runtimeMocks.recordInboundSessionMock;
|
||||
export const readSessionUpdatedAtMock: UnknownMock = runtimeMocks.readSessionUpdatedAtMock;
|
||||
export const resolveStorePathMock: UnknownMock = runtimeMocks.resolveStorePathMock;
|
||||
export const resolvePluginConversationBindingApprovalMock: AsyncUnknownMock =
|
||||
runtimeMocks.resolvePluginConversationBindingApprovalMock;
|
||||
export const buildPluginBindingResolvedTextMock = runtimeMocks.buildPluginBindingResolvedTextMock;
|
||||
export const buildPluginBindingResolvedTextMock: UnknownMock =
|
||||
runtimeMocks.buildPluginBindingResolvedTextMock;
|
||||
|
||||
async function readStoreAllowFromForDmPolicy(params: {
|
||||
provider: string;
|
||||
@@ -85,7 +107,7 @@ vi.mock("../monitor/agent-components.runtime.js", () => {
|
||||
),
|
||||
dispatchPluginInteractiveHandler: (...args: unknown[]) =>
|
||||
dispatchPluginInteractiveHandlerMock(...args),
|
||||
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
|
||||
dispatchReplyWithBufferedBlockDispatcher: dispatchReplyMock,
|
||||
finalizeInboundContext: vi.fn((ctx) => ctx),
|
||||
parsePluginBindingApprovalCustomId,
|
||||
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
|
||||
@@ -96,6 +118,13 @@ vi.mock("../monitor/agent-components.runtime.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../interactive-dispatch.js", () => {
|
||||
return {
|
||||
dispatchDiscordPluginInteractiveHandler: (...args: unknown[]) =>
|
||||
dispatchPluginInteractiveHandlerMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../monitor/agent-components.deps.runtime.js", () => {
|
||||
return {
|
||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
type ConfiguredBindingConversationRuntimeModule = {
|
||||
ensureConfiguredBindingRouteReady: unknown;
|
||||
resolveConfiguredBindingRoute: unknown;
|
||||
ensureConfiguredBindingRouteReady: (...args: never[]) => unknown;
|
||||
resolveConfiguredBindingRoute: (...args: never[]) => unknown;
|
||||
};
|
||||
|
||||
export async function createConfiguredBindingConversationRuntimeModuleMock<
|
||||
TModule extends ConfiguredBindingConversationRuntimeModule,
|
||||
>(
|
||||
params: {
|
||||
ensureConfiguredBindingRouteReadyMock: (...args: unknown[]) => unknown;
|
||||
resolveConfiguredBindingRouteMock: (...args: unknown[]) => unknown;
|
||||
ensureConfiguredBindingRouteReadyMock: (
|
||||
...args: Parameters<TModule["ensureConfiguredBindingRouteReady"]>
|
||||
) => ReturnType<TModule["ensureConfiguredBindingRouteReady"]>;
|
||||
resolveConfiguredBindingRouteMock: (
|
||||
...args: Parameters<TModule["resolveConfiguredBindingRoute"]>
|
||||
) => ReturnType<TModule["resolveConfiguredBindingRoute"]>;
|
||||
},
|
||||
importOriginal: () => Promise<TModule>,
|
||||
): Promise<TModule> {
|
||||
) {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
|
||||
params.ensureConfiguredBindingRouteReadyMock(...args),
|
||||
resolveConfiguredBindingRoute: (...args: unknown[]) =>
|
||||
params.resolveConfiguredBindingRouteMock(...args),
|
||||
} as TModule;
|
||||
ensureConfiguredBindingRouteReady: (
|
||||
...args: Parameters<TModule["ensureConfiguredBindingRouteReady"]>
|
||||
) => params.ensureConfiguredBindingRouteReadyMock(...args),
|
||||
resolveConfiguredBindingRoute: (
|
||||
...args: Parameters<TModule["resolveConfiguredBindingRoute"]>
|
||||
) => params.resolveConfiguredBindingRouteMock(...args),
|
||||
} satisfies TModule;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { discordPlugin } from "./src/channel.js";
|
||||
export { buildFinalizedDiscordDirectInboundContext } from "./src/monitor/inbound-context.test-helpers.js";
|
||||
export { __testing as discordThreadBindingTesting } from "./src/monitor/thread-bindings.manager.js";
|
||||
export { discordOutbound } from "./src/outbound-adapter.js";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { feishuPlugin } from "./src/channel.js";
|
||||
export * from "./src/conversation-id.js";
|
||||
export * from "./src/setup-core.js";
|
||||
export * from "./src/setup-surface.js";
|
||||
|
||||
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,
|
||||
|
||||
@@ -13,13 +13,6 @@ import {
|
||||
FEISHU_APPROVAL_REQUEST_ACTION,
|
||||
} from "./card-ux-approval.js";
|
||||
|
||||
const dispatchPluginInteractionActionMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({
|
||||
dispatchPluginInteractionAction: (...args: unknown[]) =>
|
||||
dispatchPluginInteractionActionMock(...args),
|
||||
}));
|
||||
|
||||
// Mock account resolution
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveFeishuAccount: vi.fn().mockReturnValue({ accountId: "mock-account" }),
|
||||
@@ -96,7 +89,6 @@ describe("Feishu Card Action Handler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetProcessedFeishuCardActionTokensForTests();
|
||||
dispatchPluginInteractionActionMock.mockResolvedValue({ matched: false, handled: false });
|
||||
});
|
||||
|
||||
it("handles card action with text payload", async () => {
|
||||
@@ -143,36 +135,6 @@ describe("Feishu Card Action Handler", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("routes native interactive card actions into plugin interaction handlers", async () => {
|
||||
dispatchPluginInteractionActionMock.mockResolvedValue({ matched: true, handled: true });
|
||||
const event = createCardActionEvent({
|
||||
token: "tok-plugin-1",
|
||||
actionValue: {
|
||||
oc: "interactive",
|
||||
kind: "button",
|
||||
actionId: "approval.approve",
|
||||
value: "codex:approve.thread",
|
||||
},
|
||||
chatId: "chat1",
|
||||
openId: "u123",
|
||||
});
|
||||
|
||||
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
|
||||
|
||||
expect(dispatchPluginInteractionActionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: "codex:approve.thread",
|
||||
channel: "feishu",
|
||||
accountId: "mock-account",
|
||||
action: expect.objectContaining({
|
||||
kind: "button",
|
||||
actionId: "approval.approve",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(handleFeishuMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes quick command actions with operator and conversation context", async () => {
|
||||
const event = createStructuredQuickActionEvent({
|
||||
token: "tok3",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { dispatchPluginInteractionAction } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
|
||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
|
||||
@@ -144,90 +143,6 @@ async function dispatchSyntheticCommand(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchPluginCardInteraction(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
event: FeishuCardActionEvent;
|
||||
accountId: string;
|
||||
}): Promise<boolean> {
|
||||
const actionValue = params.event.action.value;
|
||||
if (actionValue?.oc !== "interactive") {
|
||||
return false;
|
||||
}
|
||||
const rawData = typeof actionValue.value === "string" ? actionValue.value.trim() : "";
|
||||
if (!rawData) {
|
||||
return false;
|
||||
}
|
||||
const result = await dispatchPluginInteractionAction({
|
||||
data: rawData,
|
||||
channel: "feishu",
|
||||
accountId: params.accountId,
|
||||
interactionId: `feishu:${params.event.token}`,
|
||||
lane: {
|
||||
channel: "feishu",
|
||||
to: resolveCallbackTarget(params.event),
|
||||
accountId: params.accountId,
|
||||
},
|
||||
sender: {
|
||||
channel: "feishu",
|
||||
id: params.event.operator.open_id,
|
||||
accountId: params.accountId,
|
||||
dmLane: {
|
||||
channel: "feishu",
|
||||
to: `user:${params.event.operator.open_id}`,
|
||||
accountId: params.accountId,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
isAuthorizedSender: true,
|
||||
},
|
||||
action: {
|
||||
raw: rawData,
|
||||
kind: actionValue.kind === "select" ? "select" : "button",
|
||||
actionId:
|
||||
typeof actionValue.actionId === "string" && actionValue.actionId.trim()
|
||||
? actionValue.actionId.trim()
|
||||
: undefined,
|
||||
},
|
||||
capabilities: {
|
||||
acknowledge: false,
|
||||
followUp: true,
|
||||
editText: false,
|
||||
clearInteractive: false,
|
||||
deleteMessage: false,
|
||||
},
|
||||
respond: {
|
||||
acknowledge: async () => {},
|
||||
replyText: async ({ text }) => {
|
||||
await sendMessageFeishu({
|
||||
cfg: params.cfg,
|
||||
to: resolveCallbackTarget(params.event),
|
||||
text,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
},
|
||||
followUpText: async ({ text }) => {
|
||||
await sendMessageFeishu({
|
||||
cfg: params.cfg,
|
||||
to: resolveCallbackTarget(params.event),
|
||||
text,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
},
|
||||
editText: async ({ text }) => {
|
||||
await sendMessageFeishu({
|
||||
cfg: params.cfg,
|
||||
to: resolveCallbackTarget(params.event),
|
||||
text,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
},
|
||||
clearInteractive: async () => {},
|
||||
deleteMessage: async () => {},
|
||||
},
|
||||
});
|
||||
return result.matched && result.handled;
|
||||
}
|
||||
|
||||
async function sendInvalidInteractionNotice(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
event: FeishuCardActionEvent;
|
||||
@@ -272,16 +187,6 @@ export async function handleFeishuCardAction(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const handledPluginInteraction = await dispatchPluginCardInteraction({
|
||||
cfg,
|
||||
event,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
if (handledPluginInteraction) {
|
||||
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (decoded.kind === "invalid") {
|
||||
log(
|
||||
`feishu[${account.accountId}]: rejected card action from ${event.operator.open_id}: ${decoded.reason}`,
|
||||
|
||||
@@ -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");
|
||||
@@ -554,15 +558,6 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
reactions: true,
|
||||
edit: true,
|
||||
reply: true,
|
||||
richReplies: {
|
||||
buttons: true,
|
||||
selects: true,
|
||||
cards: true,
|
||||
commandFallback: true,
|
||||
},
|
||||
interactionResponses: {
|
||||
editText: true,
|
||||
},
|
||||
},
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
@@ -576,6 +571,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
},
|
||||
conversationBindings: {
|
||||
defaultTopLevelPlacement: "current",
|
||||
buildModelOverrideParentCandidates: ({ parentConversationId }) =>
|
||||
buildFeishuModelOverrideParentCandidates(parentConversationId),
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
|
||||
@@ -1189,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 [];
|
||||
}
|
||||
|
||||
@@ -1,31 +1,77 @@
|
||||
import { vi } from "vitest";
|
||||
import { vi, type Mock } from "vitest";
|
||||
|
||||
type BoundConversation = {
|
||||
bindingId: string;
|
||||
targetSessionKey: string;
|
||||
};
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
|
||||
type FinalizeInboundContextMock = Mock<
|
||||
(ctx: Record<string, unknown>, opts?: unknown) => Record<string, unknown>
|
||||
>;
|
||||
type DispatchReplyCounts = {
|
||||
final: number;
|
||||
block?: number;
|
||||
tool?: number;
|
||||
};
|
||||
type DispatchReplyContext = Record<string, unknown> & {
|
||||
SessionKey?: string;
|
||||
};
|
||||
type DispatchReplyDispatcher = {
|
||||
sendFinalReply: (payload: { text: string }) => unknown | Promise<unknown>;
|
||||
};
|
||||
type DispatchReplyFromConfigMock = Mock<
|
||||
(params: {
|
||||
ctx: DispatchReplyContext;
|
||||
dispatcher: DispatchReplyDispatcher;
|
||||
}) => Promise<{ queuedFinal: boolean; counts: DispatchReplyCounts }>
|
||||
>;
|
||||
type WithReplyDispatcherMock = Mock<
|
||||
(params: { run: () => unknown | Promise<unknown> }) => Promise<unknown>
|
||||
>;
|
||||
type FeishuLifecycleTestMocks = {
|
||||
createEventDispatcherMock: UnknownMock;
|
||||
monitorWebSocketMock: AsyncUnknownMock;
|
||||
monitorWebhookMock: AsyncUnknownMock;
|
||||
createFeishuThreadBindingManagerMock: UnknownMock;
|
||||
createFeishuReplyDispatcherMock: UnknownMock;
|
||||
resolveBoundConversationMock: Mock<() => BoundConversation | null>;
|
||||
touchBindingMock: UnknownMock;
|
||||
resolveAgentRouteMock: UnknownMock;
|
||||
resolveConfiguredBindingRouteMock: UnknownMock;
|
||||
ensureConfiguredBindingRouteReadyMock: UnknownMock;
|
||||
dispatchReplyFromConfigMock: DispatchReplyFromConfigMock;
|
||||
withReplyDispatcherMock: WithReplyDispatcherMock;
|
||||
finalizeInboundContextMock: FinalizeInboundContextMock;
|
||||
getMessageFeishuMock: AsyncUnknownMock;
|
||||
listFeishuThreadMessagesMock: AsyncUnknownMock;
|
||||
sendMessageFeishuMock: AsyncUnknownMock;
|
||||
sendCardFeishuMock: AsyncUnknownMock;
|
||||
};
|
||||
|
||||
const feishuLifecycleTestMocks = vi.hoisted(() => ({
|
||||
createEventDispatcherMock: vi.fn(),
|
||||
monitorWebSocketMock: vi.fn(async () => {}),
|
||||
monitorWebhookMock: vi.fn(async () => {}),
|
||||
createFeishuThreadBindingManagerMock: vi.fn(() => ({ stop: vi.fn() })),
|
||||
createFeishuReplyDispatcherMock: vi.fn(),
|
||||
resolveBoundConversationMock: vi.fn<() => BoundConversation | null>(() => null),
|
||||
touchBindingMock: vi.fn(),
|
||||
resolveAgentRouteMock: vi.fn(),
|
||||
resolveConfiguredBindingRouteMock: vi.fn(),
|
||||
ensureConfiguredBindingRouteReadyMock: vi.fn(),
|
||||
dispatchReplyFromConfigMock: vi.fn(),
|
||||
withReplyDispatcherMock: vi.fn(),
|
||||
finalizeInboundContextMock: vi.fn((ctx) => ctx),
|
||||
getMessageFeishuMock: vi.fn(async () => null),
|
||||
listFeishuThreadMessagesMock: vi.fn(async () => []),
|
||||
sendMessageFeishuMock: vi.fn(async () => ({ messageId: "om_sent", chatId: "chat_default" })),
|
||||
sendCardFeishuMock: vi.fn(async () => ({ messageId: "om_card", chatId: "chat_default" })),
|
||||
}));
|
||||
const feishuLifecycleTestMocks = vi.hoisted(
|
||||
(): FeishuLifecycleTestMocks => ({
|
||||
createEventDispatcherMock: vi.fn(),
|
||||
monitorWebSocketMock: vi.fn(async () => {}),
|
||||
monitorWebhookMock: vi.fn(async () => {}),
|
||||
createFeishuThreadBindingManagerMock: vi.fn(() => ({ stop: vi.fn() })),
|
||||
createFeishuReplyDispatcherMock: vi.fn(),
|
||||
resolveBoundConversationMock: vi.fn<() => BoundConversation | null>(() => null),
|
||||
touchBindingMock: vi.fn(),
|
||||
resolveAgentRouteMock: vi.fn(),
|
||||
resolveConfiguredBindingRouteMock: vi.fn(),
|
||||
ensureConfiguredBindingRouteReadyMock: vi.fn(),
|
||||
dispatchReplyFromConfigMock: vi.fn(),
|
||||
withReplyDispatcherMock: vi.fn(),
|
||||
finalizeInboundContextMock: vi.fn((ctx) => ctx),
|
||||
getMessageFeishuMock: vi.fn(async () => null),
|
||||
listFeishuThreadMessagesMock: vi.fn(async () => []),
|
||||
sendMessageFeishuMock: vi.fn(async () => ({ messageId: "om_sent", chatId: "chat_default" })),
|
||||
sendCardFeishuMock: vi.fn(async () => ({ messageId: "om_card", chatId: "chat_default" })),
|
||||
}),
|
||||
);
|
||||
|
||||
export function getFeishuLifecycleTestMocks() {
|
||||
export function getFeishuLifecycleTestMocks(): FeishuLifecycleTestMocks {
|
||||
return feishuLifecycleTestMocks;
|
||||
}
|
||||
|
||||
|
||||
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>>;
|
||||
@@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig } from "../runtime-api.js";
|
||||
|
||||
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
|
||||
@@ -16,7 +15,6 @@ vi.mock("./media.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendCardFeishu: sendCardFeishuMock,
|
||||
sendMessageFeishu: sendMessageFeishuMock,
|
||||
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
|
||||
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
|
||||
@@ -54,7 +52,6 @@ const cardRenderConfig: ClawdbotConfig = {
|
||||
function resetOutboundMocks() {
|
||||
vi.clearAllMocks();
|
||||
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
|
||||
sendCardFeishuMock.mockResolvedValue({ messageId: "interactive_card_msg" });
|
||||
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
|
||||
sendStructuredCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
|
||||
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
|
||||
@@ -191,57 +188,6 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("projects interactive payloads into Feishu cards", async () => {
|
||||
const result = await feishuOutbound.sendPayload!({
|
||||
cfg: emptyConfig,
|
||||
to: "chat_1",
|
||||
text: "",
|
||||
payload: {
|
||||
interactive: {
|
||||
blocks: [
|
||||
{ type: "text", text: "Choose an action" },
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Approve", value: "approve", actionId: "approval.approve" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
accountId: "main",
|
||||
});
|
||||
|
||||
expect(sendCardFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "chat_1",
|
||||
accountId: "main",
|
||||
card: expect.objectContaining({
|
||||
schema: "2.0",
|
||||
body: expect.objectContaining({
|
||||
elements: expect.arrayContaining([
|
||||
expect.objectContaining({ tag: "markdown", content: "Choose an action" }),
|
||||
expect.objectContaining({
|
||||
tag: "action",
|
||||
actions: [
|
||||
expect.objectContaining({
|
||||
value: expect.objectContaining({
|
||||
actionId: "approval.approve",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
channel: "feishu",
|
||||
messageId: "interactive_card_msg",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to threadId when replyToId is empty on sendText", async () => {
|
||||
await sendText({
|
||||
cfg: emptyConfig,
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
attachChannelToResult,
|
||||
createAttachedChannelResultAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-send-result";
|
||||
import {
|
||||
reduceInteractiveReply,
|
||||
renderInteractiveCommandFallback,
|
||||
resolveInteractiveActionId,
|
||||
resolveInteractiveTextFallback,
|
||||
type InteractiveReply,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { chunkTextForOutbound, type ChannelOutboundAdapter } from "../runtime-api.js";
|
||||
import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { parseFeishuCommentTarget } from "./comment-target.js";
|
||||
import { replyComment } from "./drive.js";
|
||||
import { sendMediaFeishu } from "./media.js";
|
||||
import { chunkTextForOutbound, type ChannelOutboundAdapter } from "./outbound-runtime-api.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import {
|
||||
sendCardFeishu,
|
||||
sendMarkdownCardFeishu,
|
||||
sendMessageFeishu,
|
||||
sendStructuredCardFeishu,
|
||||
} from "./send.js";
|
||||
import { sendMarkdownCardFeishu, sendMessageFeishu, sendStructuredCardFeishu } from "./send.js";
|
||||
|
||||
function normalizePossibleLocalImagePath(text: string | undefined): string | null {
|
||||
const raw = text?.trim();
|
||||
@@ -77,75 +62,6 @@ function resolveReplyToMessageId(params: {
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function buildFeishuInteractiveCard(params: {
|
||||
interactive: InteractiveReply;
|
||||
text?: string;
|
||||
}): Record<string, unknown> {
|
||||
const elements: Array<Record<string, unknown>> = [];
|
||||
|
||||
reduceInteractiveReply(params.interactive, undefined, (_state, block) => {
|
||||
if (block.type === "text") {
|
||||
const text = block.text.trim();
|
||||
if (text) {
|
||||
elements.push({ tag: "markdown", content: text });
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const actions =
|
||||
block.type === "buttons"
|
||||
? block.buttons.map((button) => ({
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: button.label },
|
||||
type: button.style === "danger" ? "danger" : "primary",
|
||||
value: {
|
||||
oc: "interactive",
|
||||
kind: "button",
|
||||
actionId: resolveInteractiveActionId(button),
|
||||
value: button.value,
|
||||
fallbackCommand: button.fallback?.command,
|
||||
},
|
||||
}))
|
||||
: block.options.map((option) => ({
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: option.label },
|
||||
type: "default",
|
||||
value: {
|
||||
oc: "interactive",
|
||||
kind: "select",
|
||||
actionId: resolveInteractiveActionId(option),
|
||||
value: option.value,
|
||||
fallbackCommand: option.fallback?.command,
|
||||
},
|
||||
}));
|
||||
|
||||
if (actions.length > 0) {
|
||||
elements.push({ tag: "action", actions });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const fallbackText =
|
||||
resolveInteractiveTextFallback({
|
||||
text: params.text,
|
||||
interactive: params.interactive,
|
||||
}) ?? renderInteractiveCommandFallback(params.interactive);
|
||||
if (fallbackText && elements.length === 0) {
|
||||
elements.push({ tag: "markdown", content: fallbackText });
|
||||
}
|
||||
const commandFallback = renderInteractiveCommandFallback(params.interactive);
|
||||
if (commandFallback && commandFallback !== fallbackText) {
|
||||
elements.push({ tag: "hr" });
|
||||
elements.push({ tag: "markdown", content: `<font color='grey'>${commandFallback}</font>` });
|
||||
}
|
||||
|
||||
return {
|
||||
schema: "2.0",
|
||||
config: { wide_screen_mode: true },
|
||||
body: { elements },
|
||||
};
|
||||
}
|
||||
|
||||
async function sendCommentThreadReply(params: {
|
||||
cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
|
||||
to: string;
|
||||
@@ -204,43 +120,6 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
chunker: chunkTextForOutbound,
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendPayload: async (ctx) => {
|
||||
if (ctx.payload.interactive && !ctx.payload.mediaUrl && !(ctx.payload.mediaUrls?.length ?? 0)) {
|
||||
return attachChannelToResult(
|
||||
"feishu",
|
||||
await sendCardFeishu({
|
||||
cfg: ctx.cfg,
|
||||
to: ctx.to,
|
||||
card: buildFeishuInteractiveCard({
|
||||
interactive: ctx.payload.interactive,
|
||||
text: ctx.payload.text,
|
||||
}),
|
||||
replyToMessageId: resolveReplyToMessageId({
|
||||
replyToId: ctx.replyToId,
|
||||
threadId: ctx.threadId,
|
||||
}),
|
||||
replyInThread: ctx.threadId != null && !ctx.replyToId,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const text =
|
||||
resolveInteractiveTextFallback({
|
||||
text: ctx.payload.text,
|
||||
interactive: ctx.payload.interactive,
|
||||
}) ?? "";
|
||||
if (ctx.payload.mediaUrl) {
|
||||
return await feishuOutbound.sendMedia!({
|
||||
...ctx,
|
||||
text,
|
||||
mediaUrl: ctx.payload.mediaUrl,
|
||||
});
|
||||
}
|
||||
return await feishuOutbound.sendText!({
|
||||
...ctx,
|
||||
text,
|
||||
});
|
||||
},
|
||||
...createAttachedChannelResultAdapter({
|
||||
channel: "feishu",
|
||||
sendText: async ({
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
formatDocsLink,
|
||||
hasConfiguredSecretInput,
|
||||
mergeAllowFromEntries,
|
||||
patchChannelConfigForAccount,
|
||||
patchTopLevelChannelConfigSection,
|
||||
promptSingleChannelSecretInput,
|
||||
splitSetupEntries,
|
||||
@@ -33,15 +32,14 @@ function normalizeString(value: unknown): string | undefined {
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function getScopedFeishuConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): FeishuConfig | FeishuAccountConfig {
|
||||
const feishuCfg = ((cfg.channels?.feishu as FeishuConfig | undefined) ?? {}) as FeishuConfig;
|
||||
type ScopedFeishuConfig = Partial<FeishuConfig> & Partial<FeishuAccountConfig>;
|
||||
|
||||
function getScopedFeishuConfig(cfg: OpenClawConfig, accountId: string): ScopedFeishuConfig {
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return feishuCfg;
|
||||
return feishuCfg ?? {};
|
||||
}
|
||||
return (feishuCfg.accounts?.[accountId] as FeishuAccountConfig | undefined) ?? {};
|
||||
return (feishuCfg?.accounts?.[accountId] as FeishuAccountConfig | undefined) ?? {};
|
||||
}
|
||||
|
||||
function patchFeishuConfig(
|
||||
@@ -49,11 +47,30 @@ function patchFeishuConfig(
|
||||
accountId: string,
|
||||
patch: Record<string, unknown>,
|
||||
): OpenClawConfig {
|
||||
return patchChannelConfigForAccount({
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return patchTopLevelChannelConfigSection({
|
||||
cfg,
|
||||
channel,
|
||||
enabled: true,
|
||||
patch,
|
||||
});
|
||||
}
|
||||
const nextAccountPatch = {
|
||||
...((feishuCfg?.accounts?.[accountId] as Record<string, unknown> | undefined) ?? {}),
|
||||
enabled: true,
|
||||
...patch,
|
||||
};
|
||||
return patchTopLevelChannelConfigSection({
|
||||
cfg,
|
||||
channel,
|
||||
accountId,
|
||||
patch,
|
||||
enabled: true,
|
||||
patch: {
|
||||
accounts: {
|
||||
...(feishuCfg?.accounts ?? {}),
|
||||
[accountId]: nextAccountPatch,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,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);
|
||||
@@ -105,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: {
|
||||
@@ -241,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
|
||||
? (() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, vi } from "vitest";
|
||||
import { expect, vi, type Mock } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../../../test/helpers/plugins/plugin-runtime-mock.js";
|
||||
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../../runtime-api.js";
|
||||
import { setFeishuRuntime } from "../runtime.js";
|
||||
@@ -9,6 +9,37 @@ type InboundDebouncerParams<T> = {
|
||||
onFlush?: (items: T[]) => Promise<void>;
|
||||
onError?: (err: unknown, items: T[]) => void;
|
||||
};
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
|
||||
type FeishuDispatchReplyCounts = {
|
||||
final: number;
|
||||
block?: number;
|
||||
tool?: number;
|
||||
};
|
||||
type FeishuDispatchReplyContext = Record<string, unknown> & {
|
||||
SessionKey?: string;
|
||||
};
|
||||
type FeishuDispatchReplyDispatcher = {
|
||||
sendFinalReply: (payload: { text: string }) => unknown | Promise<unknown>;
|
||||
};
|
||||
type FeishuDispatchReplyMock = Mock<
|
||||
(args: {
|
||||
ctx: FeishuDispatchReplyContext;
|
||||
dispatcher: FeishuDispatchReplyDispatcher;
|
||||
}) => Promise<{ queuedFinal: boolean; counts: FeishuDispatchReplyCounts }>
|
||||
>;
|
||||
type FeishuLifecycleReplyDispatcher = {
|
||||
dispatcher: {
|
||||
sendToolResult: UnknownMock;
|
||||
sendBlockReply: UnknownMock;
|
||||
sendFinalReply: AsyncUnknownMock;
|
||||
waitForIdle: AsyncUnknownMock;
|
||||
getQueuedCounts: UnknownMock;
|
||||
markComplete: UnknownMock;
|
||||
};
|
||||
replyOptions: Record<string, never>;
|
||||
markDispatchIdle: UnknownMock;
|
||||
};
|
||||
|
||||
export function setFeishuLifecycleStateDir(prefix: string) {
|
||||
process.env.OPENCLAW_STATE_DIR = `/tmp/${prefix}-${randomUUID()}`;
|
||||
@@ -28,7 +59,7 @@ export const FEISHU_PREFETCHED_BOT_OPEN_ID_SOURCE = {
|
||||
botName: "Bot",
|
||||
} as const;
|
||||
|
||||
export function createFeishuLifecycleReplyDispatcher() {
|
||||
export function createFeishuLifecycleReplyDispatcher(): FeishuLifecycleReplyDispatcher {
|
||||
return {
|
||||
dispatcher: {
|
||||
sendToolResult: vi.fn(() => false),
|
||||
@@ -134,16 +165,7 @@ export function installFeishuLifecycleReplyRuntime(params: {
|
||||
}
|
||||
|
||||
export function mockFeishuReplyOnceDispatch(params: {
|
||||
dispatchReplyFromConfigMock: {
|
||||
mockImplementation: (
|
||||
fn: (args: {
|
||||
ctx?: unknown;
|
||||
dispatcher?: {
|
||||
sendFinalReply?: (payload: { text: string }) => Promise<unknown>;
|
||||
};
|
||||
}) => Promise<unknown>,
|
||||
) => void;
|
||||
};
|
||||
dispatchReplyFromConfigMock: FeishuDispatchReplyMock;
|
||||
replyText: string;
|
||||
shouldSendFinalReply?: (ctx: unknown) => boolean;
|
||||
}) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
44
extensions/feishu/src/tool-account.test.ts
Normal file
44
extensions/feishu/src/tool-account.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFeishuToolAccount } from "./tool-account.js";
|
||||
|
||||
describe("resolveFeishuToolAccount", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
defaultAccount: "ops",
|
||||
appId: "base-app-id",
|
||||
appSecret: "base-app-secret", // pragma: allowlist secret
|
||||
accounts: {
|
||||
ops: {
|
||||
enabled: true,
|
||||
appId: "ops-app-id",
|
||||
appSecret: "ops-app-secret", // pragma: allowlist secret
|
||||
},
|
||||
work: {
|
||||
enabled: true,
|
||||
appId: "work-app-id",
|
||||
appSecret: "work-app-secret", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("prefers the active contextual account over configured defaultAccount", () => {
|
||||
const resolved = resolveFeishuToolAccount({
|
||||
api: { config: cfg },
|
||||
defaultAccountId: "work",
|
||||
});
|
||||
|
||||
expect(resolved.accountId).toBe("work");
|
||||
});
|
||||
|
||||
it("falls back to configured defaultAccount when there is no contextual account", () => {
|
||||
const resolved = resolveFeishuToolAccount({
|
||||
api: { config: cfg },
|
||||
});
|
||||
|
||||
expect(resolved.accountId).toBe("ops");
|
||||
});
|
||||
});
|
||||
@@ -35,25 +35,23 @@ function resolveImplicitToolAccountId(params: {
|
||||
return explicitAccountId;
|
||||
}
|
||||
|
||||
const contextualAccountId = normalizeOptionalAccountId(params.defaultAccountId);
|
||||
if (contextualAccountId && listFeishuAccountIds(params.api.config).includes(contextualAccountId)) {
|
||||
const contextualAccount = resolveFeishuAccount({
|
||||
cfg: params.api.config,
|
||||
accountId: contextualAccountId,
|
||||
});
|
||||
if (contextualAccount.enabled) {
|
||||
return contextualAccountId;
|
||||
}
|
||||
}
|
||||
|
||||
const configuredDefaultAccountId = readConfiguredDefaultAccountId(params.api.config);
|
||||
if (configuredDefaultAccountId) {
|
||||
return configuredDefaultAccountId;
|
||||
}
|
||||
|
||||
const contextualAccountId = normalizeOptionalAccountId(params.defaultAccountId);
|
||||
if (!contextualAccountId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!listFeishuAccountIds(params.api.config).includes(contextualAccountId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const contextualAccount = resolveFeishuAccount({
|
||||
cfg: params.api.config,
|
||||
accountId: contextualAccountId,
|
||||
});
|
||||
return contextualAccount.enabled ? contextualAccountId : undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveFeishuToolAccount(params: {
|
||||
|
||||
@@ -1,76 +1,13 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream";
|
||||
import {
|
||||
applyAnthropicEphemeralCacheControlMarkers,
|
||||
buildCopilotDynamicHeaders,
|
||||
hasCopilotVisionInput,
|
||||
streamWithPayloadPatch,
|
||||
} from "openclaw/plugin-sdk/provider-stream";
|
||||
|
||||
type StreamContext = Parameters<StreamFn>[1];
|
||||
type StreamMessage = StreamContext["messages"][number];
|
||||
|
||||
function inferCopilotInitiator(messages: StreamContext["messages"]): "agent" | "user" {
|
||||
const last = messages[messages.length - 1];
|
||||
return last && last.role !== "user" ? "agent" : "user";
|
||||
}
|
||||
|
||||
function hasCopilotVisionInput(messages: StreamContext["messages"]): boolean {
|
||||
return messages.some((message: StreamMessage) => {
|
||||
if (message.role === "user" && Array.isArray(message.content)) {
|
||||
return message.content.some((item) => item.type === "image");
|
||||
}
|
||||
if (message.role === "toolResult" && Array.isArray(message.content)) {
|
||||
return message.content.some((item) => item.type === "image");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function buildCopilotDynamicHeaders(params: {
|
||||
messages: StreamContext["messages"];
|
||||
}): Record<string, string> {
|
||||
return {
|
||||
"X-Initiator": inferCopilotInitiator(params.messages),
|
||||
"Openai-Intent": "conversation-edits",
|
||||
...(hasCopilotVisionInput(params.messages) ? { "Copilot-Vision-Request": "true" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function applyAnthropicPromptCacheMarkers(payloadObj: Record<string, unknown>): void {
|
||||
const messages = payloadObj.messages;
|
||||
if (!Array.isArray(messages)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const message of messages as Array<{ role?: string; content?: unknown }>) {
|
||||
if (message.role === "system" || message.role === "developer") {
|
||||
if (typeof message.content === "string") {
|
||||
message.content = [
|
||||
{ type: "text", text: message.content, cache_control: { type: "ephemeral" } },
|
||||
];
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(message.content) && message.content.length > 0) {
|
||||
const last = message.content[message.content.length - 1];
|
||||
if (last && typeof last === "object") {
|
||||
const record = last as Record<string, unknown>;
|
||||
if (record.type !== "thinking" && record.type !== "redacted_thinking") {
|
||||
record.cache_control = { type: "ephemeral" };
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = block as Record<string, unknown>;
|
||||
if (record.type === "thinking" || record.type === "redacted_thinking") {
|
||||
delete record.cache_control;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapCopilotAnthropicStream(baseStreamFn: StreamFn | undefined): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
@@ -86,11 +23,14 @@ export function wrapCopilotAnthropicStream(baseStreamFn: StreamFn | undefined):
|
||||
{
|
||||
...options,
|
||||
headers: {
|
||||
...buildCopilotDynamicHeaders({ messages: context.messages }),
|
||||
...buildCopilotDynamicHeaders({
|
||||
messages: context.messages as StreamContext["messages"],
|
||||
hasImages: hasCopilotVisionInput(context.messages as StreamContext["messages"]),
|
||||
}),
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
},
|
||||
applyAnthropicPromptCacheMarkers,
|
||||
applyAnthropicEphemeralCacheControlMarkers,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user