Compare commits

..

156 Commits

Author SHA1 Message Date
Gustavo Madeira Santana
a1e6efb0fb test(plugins): cover capability allowlist fallback paths 2026-04-03 16:22:32 -04:00
Gustavo Madeira Santana
476ea0d097 fix(plugins): respect capability provider allowlists 2026-04-03 16:15:37 -04:00
YimingPan
4be2c52041 fix(plugins): honor explicit capability allowlists
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:15:37 -04:00
Tak Hoffman
de6997a203 fix: honor googlechat default setup status 2026-04-03 15:15:01 -05:00
Peter Steinberger
ee63fdb056 test(ci): harden context engine runtime bridge test 2026-04-03 21:12:56 +01:00
Peter Steinberger
93e716e775 test: trim model startup retry partial mocks 2026-04-03 21:10:17 +01:00
Tak Hoffman
756597e6ad fix: honor discord default directory cache account 2026-04-03 15:09:18 -05:00
Peter Steinberger
328b7bee75 test(ci): fix slack pairing notify typing 2026-04-03 21:07:06 +01:00
Tak Hoffman
78022740fc fix: honor twitch default send account 2026-04-03 15:06:56 -05:00
Peter Steinberger
4c0f51df81 test: trim gateway and infra partial mocks 2026-04-03 21:03:54 +01:00
Peter Steinberger
b57922552e fix(ci): restore command auth sdk export 2026-04-03 21:02:59 +01:00
Vincent Koc
58ee283658 test(providers): fix anthropic payload test typing 2026-04-04 05:02:22 +09:00
Tak Hoffman
299ed8cb39 fix: honor slack default pairing account 2026-04-03 15:01:55 -05:00
@zimeg
2a13508379 docs(slack): expand app manifest example and scope checklist 2026-04-03 12:58:47 -07:00
Vincent Koc
067496b129 refactor(providers): share anthropic payload policy 2026-04-04 04:57:47 +09:00
Peter Steinberger
3e0ddaf5bc test(ci): stabilize mattermost websocket retry test 2026-04-03 20:56:09 +01:00
Tak Hoffman
d44af743db fix: honor whatsapp default setup finalize account 2026-04-03 14:55:27 -05:00
Tak Hoffman
f8a0f9ffd3 fix: honor twitch default setup account 2026-04-03 14:52:31 -05:00
Peter Steinberger
d007559c38 test: trim more agent e2e partial mocks 2026-04-03 20:50:57 +01:00
Tak Hoffman
84db697cd6 fix: honor twitch default outbound account 2026-04-03 14:49:55 -05:00
Peter Steinberger
2a5fbf0fd6 fix(ci): align discord dm auth account expectation 2026-04-03 20:48:05 +01:00
Peter Steinberger
7db148706a test: trim more runtime partial mocks 2026-04-03 20:46:57 +01:00
Tak Hoffman
f56a9f3b3b fix: honor twitch default runtime account 2026-04-03 14:46:23 -05:00
Peter Steinberger
1ff586cda1 fix(ci): repair discord preflight test types 2026-04-03 20:44:03 +01:00
Tak Hoffman
314512ae14 fix: honor whatsapp default heartbeat account 2026-04-03 14:42:38 -05:00
Peter Steinberger
2247089381 test: trim command install partial mocks 2026-04-03 20:42:29 +01:00
Peter Steinberger
92409aa4d6 test: trim agent e2e partial mocks 2026-04-03 20:42:29 +01:00
Peter Steinberger
52fb51db77 fix(ci): update whatsapp setup status mock signature 2026-04-03 20:40:54 +01:00
Peter Steinberger
3f86972e46 test: trim sandbox and gateway partial mocks 2026-04-03 20:40:27 +01:00
Tak Hoffman
5942726d25 fix: honor discord default dm preflight account 2026-04-03 14:37:56 -05:00
Peter Steinberger
ae976a90a5 test: trim more command partial mocks 2026-04-03 20:37:14 +01:00
Peter Steinberger
4481c41368 fix(ci): repair slack feishu and telegram regressions 2026-04-03 20:36:40 +01:00
Tak Hoffman
aa983566c4 fix: honor whatsapp default setup status account 2026-04-03 14:34:40 -05:00
Peter Steinberger
6f8f2a012b test: trim commands and cli partial mocks 2026-04-03 20:34:23 +01:00
Tak Hoffman
f7f467b042 fix: honor telegram default debounce account 2026-04-03 14:30:34 -05:00
Peter Steinberger
a715b83e67 test: trim auto-reply and cli partial mocks 2026-04-03 20:30:10 +01:00
Tak Hoffman
6c5064b437 fix: honor slack default hook account 2026-04-03 14:26:57 -05:00
Vincent Koc
30e43550bb test(cli): make plugin install recovery requests explicit 2026-04-04 04:26:51 +09:00
Vincent Koc
4265a59892 fix(config): hide legacy internal hook handlers 2026-04-04 04:26:51 +09:00
Peter Steinberger
d9af49a7af test: trim reply label generator mock 2026-04-03 20:25:49 +01:00
Tak Hoffman
58d6c16d12 fix: honor discord default mention account 2026-04-03 14:24:07 -05:00
Peter Steinberger
6068497409 test: trim auto-reply tools mock 2026-04-03 20:23:34 +01:00
Peter Steinberger
c8c0aeda76 test: trim more auto-reply partial mocks 2026-04-03 20:22:24 +01:00
Tak Hoffman
489a62e788 fix: honor whatsapp default outbound account 2026-04-03 14:22:08 -05:00
Peter Steinberger
63443acc2b fix(ci): repair telegram test harness config 2026-04-03 20:21:50 +01:00
Peter Steinberger
0805add3a4 test: trim more reply and agent mocks 2026-04-03 20:20:51 +01:00
Tak Hoffman
a18167a2cb fix: honor feishu tool account context 2026-04-03 14:19:15 -05:00
Peter Steinberger
f5ec0e429f test: trim more agent tool partial mocks 2026-04-03 20:18:56 +01:00
Peter Steinberger
1fbf863f53 test: trim more agent tool mocks 2026-04-03 20:16:50 +01:00
Peter Steinberger
e286ba2bab test: trim more agent partial mocks 2026-04-03 20:15:55 +01:00
Peter Steinberger
ee5113b1ae test: trim sandbox and transcript partial mocks 2026-04-03 20:14:39 +01:00
Peter Steinberger
6a465611d8 test: trim openclaw tools partial mocks 2026-04-03 20:14:39 +01:00
Tak Hoffman
6286ef55da fix: honor discord default guild action account 2026-04-03 14:13:00 -05:00
Vincent Koc
9224afca3d refactor(providers): share xai and replay helpers 2026-04-04 04:11:57 +09:00
Vincent Koc
cc1881a838 refactor(providers): share payload patch helpers 2026-04-04 04:11:56 +09:00
Vincent Koc
0273062dfd refactor(signal): lazy-load send runtime 2026-04-04 04:11:45 +09:00
Vincent Koc
b361667f98 test(contracts): split config write lanes 2026-04-04 04:11:00 +09:00
Vincent Koc
24afd52fcd test(contracts): remove old group policy runner 2026-04-04 04:10:15 +09:00
Vincent Koc
1d4fcb6a01 test(contracts): split group policy lanes 2026-04-04 04:10:15 +09:00
Vincent Koc
724dd5ca3d refactor(slack): lazy-load action and send runtimes 2026-04-04 04:08:41 +09:00
Tak Hoffman
c7554d3072 fix: honor discord default action runtime account 2026-04-03 14:08:32 -05:00
Vincent Koc
0bbacca828 test(contracts): split channel catalog lanes 2026-04-04 04:08:24 +09:00
lurebat
37de88181b fix(whatsapp): ignore self-chat quoted replies in groups (#60148)
Merged via squash.

Prepared head SHA: c51b55e0ba
Co-authored-by: lurebat <154669821+lurebat@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-04-03 16:07:46 -03:00
Peter Steinberger
2d2fe2bf47 test: trim more agent partial mocks 2026-04-03 20:06:42 +01:00
Tak Hoffman
5f17362667 fix: honor slack default channel-type account 2026-04-03 14:05:35 -05:00
Peter Steinberger
4578351488 test: trim agent and discord harness partial mocks 2026-04-03 20:04:52 +01:00
Peter Steinberger
811efa2db0 fix(ci): honor bluebubbles account action gates 2026-04-03 20:03:27 +01:00
Peter Steinberger
35a9eeb857 test: trim sandbox and pi runner partial mocks 2026-04-03 20:02:57 +01:00
Peter Steinberger
4e27e22663 test: trim agents importActual mocks 2026-04-03 20:00:35 +01:00
Peter Steinberger
ffba320a2c fix(ci): align test mock typings 2026-04-03 19:59:55 +01:00
Vincent Koc
0464435777 fix(ci): align windows builtin mock types 2026-04-04 03:57:48 +09:00
Vincent Koc
fa5ea4529a fix(types): align rebased channel setup flows 2026-04-04 03:57:47 +09:00
Vincent Koc
e57b6be85f fix(types): align setup helper contracts 2026-04-04 03:57:47 +09:00
Vincent Koc
516e9054de fix(types): align portable runtime helpers 2026-04-04 03:57:47 +09:00
Vincent Koc
5e204df0bf fix(types): align rebased main helper contracts 2026-04-04 03:57:47 +09:00
Vincent Koc
88d3b73c6d fix(types): annotate portable exported helper types 2026-04-04 03:57:47 +09:00
Peter Steinberger
4b71a94450 fix(ci): repair contract and interaction drift 2026-04-03 19:57:35 +01:00
Peter Steinberger
c9dfc35dfd test: fix discord runtime mock typing and lock UX 2026-04-03 19:56:37 +01:00
Peter Steinberger
9215ff0615 test: route pure infra tests through boundary lane 2026-04-03 19:56:12 +01:00
Tak Hoffman
7c25af83e4 fix: honor line monitor default account 2026-04-03 13:55:44 -05:00
Peter Steinberger
03aea06321 test: trim gateway importActual mocks 2026-04-03 19:54:37 +01:00
Peter Steinberger
a301e2ef87 test: trim cli and infra importActual mocks 2026-04-03 19:54:37 +01:00
Tak Hoffman
961d8eb095 fix: honor line default runtime account 2026-04-03 13:54:01 -05:00
Peter Steinberger
5e9ae0bfd4 test(node): fix execFile mock typing 2026-04-03 19:53:38 +01:00
Peter Steinberger
4948760c65 test(plugins): genericize core helper contracts 2026-04-03 19:53:38 +01:00
Peter Steinberger
1c66a050c2 refactor(plugins): move outbound dep aliases into extensions 2026-04-03 19:53:38 +01:00
Tak Hoffman
f007082e06 fix: honor signal setup default account 2026-04-03 13:52:28 -05:00
Vincent Koc
eecb36eff4 fix(ci): stabilize zero-delay retry and slack interaction tests 2026-04-04 03:52:07 +09:00
Gustavo Madeira Santana
1420b3bad7 docs: tighten skills and Matrix wording 2026-04-03 14:51:37 -04:00
Vincent Koc
1c470c2736 test(contracts): split tts lanes 2026-04-04 03:51:10 +09:00
Tak Hoffman
d305a80acd fix: honor imessage setup default account 2026-04-03 13:50:52 -05:00
Peter Steinberger
bc23db501b test: trim more core importOriginal usage 2026-04-03 19:49:43 +01:00
Peter Steinberger
6115a9498c test: trim config importOriginal usage 2026-04-03 19:49:43 +01:00
Peter Steinberger
8e8f8d0745 test: trim more extension importOriginal usage 2026-04-03 19:49:43 +01:00
Vincent Koc
d8458a1481 refactor(providers): share transport stream helpers 2026-04-04 03:49:09 +09:00
Vincent Koc
fcec417d7d fix(ci): preserve conversation runtime mock signatures 2026-04-04 03:48:58 +09:00
Tak Hoffman
d2ca915a7f fix: honor telegram default action account 2026-04-03 13:48:45 -05:00
Peter Steinberger
88ab29f492 fix(ci): relax discord runtime mock module constraint 2026-04-03 19:46:59 +01:00
Tak Hoffman
534b0c663e fix: honor zalouser default runtime account 2026-04-03 13:46:36 -05:00
Tak Hoffman
f66c9b829e fix: honor slack default runtime account 2026-04-03 13:45:28 -05:00
Tak Hoffman
4f5f1fa724 fix: honor imessage default runtime account 2026-04-03 13:44:15 -05:00
Peter Steinberger
b8af2c65e5 fix(ci): bind full discord conversation runtime mock type 2026-04-03 19:44:05 +01:00
Tak Hoffman
4ca1ae8046 fix: honor signal default runtime account 2026-04-03 13:43:09 -05:00
Tak Hoffman
c7875f193b fix: honor discord default runtime account 2026-04-03 13:41:55 -05:00
Peter Steinberger
e3f410efb5 fix(ci): widen discord binding runtime mock type 2026-04-03 19:40:47 +01:00
Peter Steinberger
3fb6e3e91f test: trim more extension importOriginal usage 2026-04-03 19:40:20 +01:00
Tak Hoffman
17c0026c04 fix: honor bluebubbles default runtime account 2026-04-03 13:39:22 -05:00
Tak Hoffman
9289f967df fix: honor mattermost default runtime account 2026-04-03 13:38:03 -05:00
Peter Steinberger
e76a16dfa5 fix(ci): preserve omitted feishu finalize account context 2026-04-03 19:38:00 +01:00
Vincent Koc
e697fa5e75 feat(providers): add google transport runtime 2026-04-04 03:35:58 +09:00
Peter Steinberger
2156bf0210 test: fix setup wizard and execFile test drift 2026-04-03 19:35:38 +01:00
Peter Steinberger
0ad2da060e test: route openclaw root through boundary config 2026-04-03 19:35:27 +01:00
Peter Steinberger
cc62fd38f6 test: trim more extension mock imports 2026-04-03 19:34:55 +01:00
Tak Hoffman
a8302e8eab fix: honor mattermost default reply account 2026-04-03 13:34:53 -05:00
Peter Steinberger
323ad51eb8 fix(ci): align execFile mock typings 2026-04-03 19:31:41 +01:00
Peter Steinberger
8be2dea382 test: trim more extension partial mocks 2026-04-03 19:31:32 +01:00
Peter Steinberger
b27fd7cc49 fix(setup): narrow default account id typing 2026-04-03 19:30:35 +01:00
Peter Steinberger
0c95e3f073 refactor(plugins): move command ui policy into extensions 2026-04-03 19:30:35 +01:00
Peter Steinberger
e5d2181403 fix(ci): repair discord interactive test seams 2026-04-03 19:29:14 +01:00
Peter Steinberger
45a6f769bb test: trim core partial mocks 2026-04-03 19:28:19 +01:00
Peter Steinberger
6eca4e0136 test: trim extension partial mocks 2026-04-03 19:28:19 +01:00
Tak Hoffman
24a4ed1013 fix: honor matrix default runtime account 2026-04-03 13:26:34 -05:00
Peter Steinberger
eea069bdc3 fix(ci): repair bundled and extension test drift 2026-04-03 19:25:23 +01:00
Tak Hoffman
e063f67ac0 fix: honor nextcloud default runtime account 2026-04-03 13:24:58 -05:00
Vincent Koc
26b7260bf4 refactor(signal): narrow channel runtime imports 2026-04-04 03:24:21 +09:00
Vincent Koc
e9cbdc7439 fix(plugins): narrow top-level allowFrom account resolver 2026-04-04 03:24:21 +09:00
Tak Hoffman
d5c6e7af0f fix: honor whatsapp default heartbeat account 2026-04-03 13:23:29 -05:00
Gustavo Madeira Santana
1f660bf930 Docs: document agent skill allowlists 2026-04-03 14:23:05 -04:00
Tak Hoffman
5c3dc40794 fix: honor googlechat default runtime account 2026-04-03 13:22:11 -05:00
Peter Steinberger
28b8e019f7 test(setup): fix latest type regressions 2026-04-03 19:21:46 +01:00
Peter Steinberger
df18f4c517 refactor(matrix): move legacy migrations behind doctor 2026-04-03 19:21:24 +01:00
Tak Hoffman
5eb3341db1 fix: honor zalo default runtime account 2026-04-03 13:19:50 -05:00
Gustavo Madeira Santana
5e365a8ec4 agents: preserve remote skill sync eligibility 2026-04-03 14:19:43 -04:00
Tak Hoffman
045010a2a5 fix: honor zalouser default runtime account 2026-04-03 13:18:11 -05:00
Peter Steinberger
72b8025107 fix: align feishu and matrix type guards 2026-04-03 19:17:14 +01:00
Peter Steinberger
4c5c361db7 test: stub gateway speech providers 2026-04-03 19:16:56 +01:00
Vincent Koc
956e746da1 fix(plugins): narrow nested allowFrom account resolver 2026-04-04 03:16:14 +09:00
Vincent Koc
7d691a3ce3 refactor(whatsapp): narrow channel runtime imports 2026-04-04 03:16:14 +09:00
Peter Steinberger
5c6dca78d9 fix(discord): avoid bundled sibling requires 2026-04-03 19:15:21 +01:00
Peter Steinberger
53f8c2047a fix(ci): restore channel approval and lifecycle harnesses 2026-04-03 19:14:42 +01:00
Tak Hoffman
d20e3d5691 fix: honor feishu setup adapter default 2026-04-03 13:14:10 -05:00
Tak Hoffman
a89cb679a2 fix: honor nostr setup default account 2026-04-03 13:12:49 -05:00
Peter Steinberger
13bc70397a test: trim test partial mocks 2026-04-03 19:10:56 +01:00
Tak Hoffman
5c4551458f fix: honor qqbot setup default account 2026-04-03 13:10:49 -05:00
Peter Steinberger
181bd6327f test(plugins): fix rebase fallout 2026-04-03 19:10:00 +01:00
Peter Steinberger
42ffe86fc7 test(cron): stabilize regression harness 2026-04-03 19:09:21 +01:00
Peter Steinberger
03a43fe231 refactor(plugins): genericize core channel seams 2026-04-03 19:09:21 +01:00
Peter Steinberger
856592cf00 fix(outbound): restore generic delivery and security seams 2026-04-03 19:09:20 +01:00
Peter Steinberger
ab96520bba refactor(plugins): move channel behavior into plugins 2026-04-03 19:09:20 +01:00
Josh Lehman
c52df32878 refactor: move bundled replay policy ownership into plugins (#60452)
* refactor: move bundled replay policy ownership into plugins

* test: preserve replay fallback until providers adopt hooks

* test: cover response replay branches for ollama and zai

---------

Co-authored-by: Shakker <shakkerdroid@gmail.com>
2026-04-03 19:08:10 +01:00
Tak Hoffman
7fb58afb41 fix: honor googlechat default allowFrom account 2026-04-03 13:07:07 -05:00
Tak Hoffman
7be2d361de fix: honor feishu finalize default account 2026-04-03 13:04:12 -05:00
Vincent Koc
abc3f27ba9 refactor(zalo): narrow action runtime imports 2026-04-04 03:03:15 +09:00
Vincent Koc
0ba93afda9 fix(feishu): guard scoped setup config access 2026-04-04 03:03:15 +09:00
Tak Hoffman
b7b53b29e8 fix: honor discord setup default account 2026-04-03 13:01:28 -05:00
Peter Steinberger
d9e59f7329 fix(ci): align loader and channel test expectations 2026-04-03 19:00:23 +01:00
740 changed files with 18261 additions and 11013 deletions

View File

@@ -85,6 +85,7 @@ 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.
- 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.
## 2026.4.2
@@ -194,6 +195,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

View File

@@ -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",

View File

@@ -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 one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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:

View File

@@ -392,7 +392,7 @@ Notes:
## Manifest and scope checklist
<AccordionGroup>
<Accordion title="Slack app manifest example">
<Accordion title="Slack app manifest example" defaultOpen>
```json
{

View File

@@ -13,6 +13,7 @@ Related:
- Multi-agent routing: [Multi-Agent Routing](/concepts/multi-agent)
- Agent workspace: [Agent workspace](/concepts/agent-workspace)
- Skill visibility config: [Skills config](/tools/skills-config)
## Examples
@@ -31,6 +32,11 @@ openclaw agents delete work
Use routing bindings to pin inbound channel traffic to a specific agent.
If you also want different visible skills per agent, configure
`agents.defaults.skills` and `agents.list[].skills` in `openclaw.json`. See
[Skills config](/tools/skills-config) and
[Configuration Reference](/gateway/configuration-reference#agentsdefaultsskills).
List bindings:
```bash

View File

@@ -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`).

View File

@@ -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)).

View File

@@ -27,8 +27,12 @@ Main agent credentials are **not** shared automatically. Never reuse `agentDir`
across agents (it causes auth/session collisions). If you want to share creds,
copy `auth-profiles.json` into the other agent's `agentDir`.
Skills are per-agent via each workspaces `skills/` folder, with shared skills
available from `~/.openclaw/skills`. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills).
Skills are loaded from each agent workspace plus shared roots such as
`~/.openclaw/skills`, then filtered by the effective agent skill allowlist when
configured. Use `agents.defaults.skills` for a shared baseline and
`agents.list[].skills` for per-agent replacement. See
[Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and
[Skills: agent skill allowlists](/tools/skills#agent-skill-allowlists).
The Gateway can host **one agent** (default) or **many agents** side-by-side.

View File

@@ -110,6 +110,10 @@ prompt instructs the model to use `read` to load the SKILL.md at the listed
location (workspace, managed, or bundled). If no skills are eligible, the
Skills section is omitted.
Eligibility includes skill metadata gates, runtime environment/config checks,
and the effective agent skill allowlist when `agents.defaults.skills` or
`agents.list[].skills` is configured.
```
<available_skills>
<skill>

View File

@@ -250,6 +250,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
"openai/gpt-5.2": { alias: "gpt" },
},
skills: ["github", "weather"], // inherited by agents that omit list[].skills
thinkingDefault: "low",
verboseDefault: "off",
elevatedDefault: "on",
@@ -308,12 +309,14 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
{
id: "main",
default: true,
// inherits defaults.skills -> github, weather
thinkingDefault: "high", // per-agent thinking override
reasoningDefault: "on", // per-agent reasoning visibility
fastModeDefault: false, // per-agent fast mode
},
{
id: "quick",
skills: [], // no skills for this agent
fastModeDefault: true, // this agent always runs fast
thinkingDefault: "off",
},
@@ -462,6 +465,27 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
## Common patterns
### Shared skill baseline with one override
```json5
{
agents: {
defaults: {
workspace: "~/.openclaw/workspace",
skills: ["github", "weather"],
},
list: [
{ id: "main", default: true },
{ id: "docs", workspace: "~/.openclaw/workspace-docs", skills: ["docs-search"] },
],
},
}
```
- `agents.defaults.skills` is the shared baseline.
- `agents.list[].skills` replaces that baseline for one agent.
- Use `skills: []` when an agent should see no skills.
### Multi-platform setup
```json5

View File

@@ -818,6 +818,30 @@ Optional repository root shown in the system prompt's Runtime line. If unset, Op
}
```
### `agents.defaults.skills`
Optional default skill allowlist for agents that do not set
`agents.list[].skills`.
```json5
{
agents: {
defaults: { skills: ["github", "weather"] },
list: [
{ id: "writer" }, // inherits github, weather
{ id: "docs", skills: ["docs-search"] }, // replaces defaults
{ id: "locked-down", skills: [] }, // no skills
],
},
}
```
- Omit `agents.defaults.skills` for unrestricted skills by default.
- Omit `agents.list[].skills` to inherit the defaults.
- Set `agents.list[].skills: []` for no skills.
- A non-empty `agents.list[].skills` list is the final set for that agent; it
does not merge with defaults.
### `agents.defaults.skipBootstrap`
Disables automatic creation of workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md`).
@@ -1425,6 +1449,7 @@ scripts/sandbox-browser-setup.sh # optional browser image
reasoningDefault: "on", // per-agent reasoning visibility override
fastModeDefault: false, // per-agent fast mode override
params: { cacheRetention: "none" }, // overrides matching defaults.models params by key
skills: ["docs-search"], // replaces agents.defaults.skills when set
identity: {
name: "Samantha",
theme: "helpful sloth",
@@ -1459,6 +1484,7 @@ scripts/sandbox-browser-setup.sh # optional browser image
- `default`: when multiple are set, first wins (warning logged). If none set, first list entry is default.
- `model`: string form overrides `primary` only; object form `{ primary, fallbacks }` overrides both (`[]` disables global fallbacks). Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`.
- `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog.
- `skills`: optional per-agent skill allowlist. If omitted, the agent inherits `agents.defaults.skills` when set; an explicit list replaces defaults instead of merging, and `[]` means no skills.
- `thinkingDefault`: optional per-agent default thinking level (`off | minimal | low | medium | high | xhigh | adaptive`). Overrides `agents.defaults.thinkingDefault` for this agent when no per-message or session override is set.
- `reasoningDefault`: optional per-agent default reasoning visibility (`on | off | stream`). Applies when no per-message or session reasoning override is set.
- `fastModeDefault`: optional per-agent default for fast mode (`true | false`). Applies when no per-message or session fast-mode override is set.

View File

@@ -175,6 +175,33 @@ When validation fails:
</Accordion>
<Accordion title="Restrict skills per agent">
Use `agents.defaults.skills` for a shared baseline, then override specific
agents with `agents.list[].skills`:
```json5
{
agents: {
defaults: {
skills: ["github", "weather"],
},
list: [
{ id: "writer" }, // inherits github, weather
{ id: "docs", skills: ["docs-search"] }, // replaces defaults
{ id: "locked-down", skills: [] }, // no skills
],
},
}
```
- Omit `agents.defaults.skills` for unrestricted skills by default.
- Omit `agents.list[].skills` to inherit the defaults.
- Set `agents.list[].skills: []` for no skills.
- See [Skills](/tools/skills), [Skills config](/tools/skills-config), and
the [Configuration Reference](/gateway/configuration-reference#agentsdefaultsskills).
</Accordion>
<Accordion title="Tune gateway channel health monitoring">
Control how aggressively the gateway restarts channels that look stale:

View File

@@ -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. 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.
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?">
@@ -1030,7 +1030,7 @@ for usage/billing and raise limits as needed.
openclaw skills update --all
```
Install the separate `clawhub` CLI only if you want to publish or sync your own skills.
Install the separate `clawhub` CLI only if you want to publish or sync your own skills. For shared installs across agents, put the skill under `~/.openclaw/skills` and use `agents.defaults.skills` or `agents.list[].skills` if you want to narrow which agents can see it.
</Accordion>
@@ -1106,7 +1106,7 @@ for usage/billing and raise limits as needed.
openclaw skills update --all
```
Native installs land in the active workspace `skills/` directory. For shared skills across agents, place them in `~/.openclaw/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawHub](/tools/clawhub).
Native installs land in the active workspace `skills/` directory. For shared skills across agents, place them in `~/.openclaw/skills/<name>/SKILL.md`. If only some agents should see a shared install, configure `agents.defaults.skills` or `agents.list[].skills`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills), [Skills config](/tools/skills-config), and [ClawHub](/tools/clawhub).
</Accordion>

View File

@@ -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

View File

@@ -8,7 +8,9 @@ title: "Skills Config"
# Skills Config
All skills-related configuration lives under `skills` in `~/.openclaw/openclaw.json`.
Most skills loader/install configuration lives under `skills` in
`~/.openclaw/openclaw.json`. Agent-specific skill visibility lives under
`agents.defaults.skills` and `agents.list[].skills`.
```json5
{
@@ -51,6 +53,35 @@ Examples:
- Native Nano Banana-style setup: `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"`
- Native fal setup: `agents.defaults.imageGenerationModel.primary: "fal/fal-ai/flux/dev"`
## Agent skill allowlists
Use agent config when you want the same machine/workspace skill roots, but a
different visible skill set per agent.
```json5
{
agents: {
defaults: {
skills: ["github", "weather"],
},
list: [
{ id: "writer" }, // inherits defaults -> github, weather
{ id: "docs", skills: ["docs-search"] }, // replaces defaults
{ id: "locked-down", skills: [] }, // no skills
],
},
}
```
Rules:
- `agents.defaults.skills`: shared baseline allowlist for agents that omit
`agents.list[].skills`.
- Omit `agents.defaults.skills` to leave skills unrestricted by default.
- `agents.list[].skills`: explicit final skill set for that agent; it does not
merge with defaults.
- `agents.list[].skills: []`: expose no skills for that agent.
## Fields
- Built-in skill roots always include `~/.openclaw/skills`, `~/.agents/skills`,
@@ -65,6 +96,10 @@ Examples:
This only affects **skill installs**; the Gateway runtime should still be Node
(Bun not recommended for WhatsApp/Telegram).
- `entries.<skillKey>`: per-skill overrides.
- `agents.defaults.skills`: optional default skill allowlist inherited by agents
that omit `agents.list[].skills`.
- `agents.list[].skills`: optional per-agent final skill allowlist; explicit
lists replace inherited defaults instead of merging.
Per-skill fields:

View File

@@ -43,6 +43,42 @@ If the same skill name exists in more than one place, the usual precedence
applies: workspace wins, then project agent skills, then personal agent skills,
then managed/local, then bundled, then extra dirs.
## Agent skill allowlists
Skill **location** and skill **visibility** are separate controls.
- Location/precedence decides which copy of a same-named skill wins.
- Agent allowlists decide which visible skills an agent can actually use.
Use `agents.defaults.skills` for a shared baseline, then override per agent with
`agents.list[].skills`:
```json5
{
agents: {
defaults: {
skills: ["github", "weather"],
},
list: [
{ id: "writer" }, // inherits github, weather
{ id: "docs", skills: ["docs-search"] }, // replaces defaults
{ id: "locked-down", skills: [] }, // no skills
],
},
}
```
Rules:
- Omit `agents.defaults.skills` for unrestricted skills by default.
- Omit `agents.list[].skills` to inherit `agents.defaults.skills`.
- Set `agents.list[].skills: []` for no skills.
- A non-empty `agents.list[].skills` list is the final set for that agent; it
does not merge with defaults.
OpenClaw applies the effective agent skill set across prompt building, skill
slash-command discovery, sandbox sync, and skill snapshots.
## Plugins + skills
Plugins can ship their own skills by listing `skills` directories in
@@ -267,6 +303,10 @@ OpenClaw snapshots the eligible skills **when a session starts** and reuses that
Skills can also refresh mid-session when the skills watcher is enabled or when a new eligible remote node appears (see below). Think of this as a **hot reload**: the refreshed list is picked up on the next agent turn.
If the effective agent skill allowlist changes for that session, OpenClaw
refreshes the snapshot so the visible skills stay aligned with the current
agent.
## Remote macOS nodes (Linux gateway)
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Exec approvals security not set to `deny`), OpenClaw can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `exec` tool with `host=node`.

View File

@@ -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),
);
};
}

View File

@@ -41,7 +41,9 @@ export function resolveBlueBubblesAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedBlueBubblesAccount {
const accountId = normalizeAccountId(params.accountId);
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultBlueBubblesAccountId(params.cfg),
);
const baseEnabled = params.cfg.channels?.bluebubbles?.enabled;
const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;

View File

@@ -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";

View File

@@ -217,6 +217,34 @@ describe("bluebubbles setup surface", () => {
expect(next?.channels?.bluebubbles?.accounts?.work?.dmPolicy).toBe("open");
});
it("uses configured defaultAccount when accountId is omitted in account resolution", async () => {
const { resolveBlueBubblesAccount } = await import("./accounts.js");
const resolved = resolveBlueBubblesAccount({
cfg: {
channels: {
bluebubbles: {
defaultAccount: "work",
serverUrl: "http://localhost:3000",
password: "top-secret",
accounts: {
work: {
serverUrl: "http://localhost:1234",
password: "secret",
name: "Work",
},
},
},
},
} as OpenClawConfig,
});
expect(resolved.accountId).toBe("work");
expect(resolved.name).toBe("Work");
expect(resolved.baseUrl).toBe("http://localhost:1234");
expect(resolved.configured).toBe(true);
});
it('writes open policy state to the named account and preserves inherited allowFrom with "*"', async () => {
const { blueBubblesSetupWizard } = await import("./setup-surface.js");

View File

@@ -14,8 +14,8 @@ const runtimeApiMocks = vi.hoisted(() => ({
registerBrowserCli: vi.fn(),
}));
vi.mock("./runtime-api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./runtime-api.js")>();
vi.mock("./runtime-api.js", async () => {
const actual = await vi.importActual<typeof import("./runtime-api.js")>("./runtime-api.js");
return {
...actual,
createBrowserPluginService: runtimeApiMocks.createBrowserPluginService,

View File

@@ -115,8 +115,10 @@ vi.mock("../../../src/agents/tools/gateway.js", () => gatewayMocks);
const configMocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({ browser: {} })),
}));
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: configMocks.loadConfig,

View File

@@ -23,27 +23,17 @@ export function isWebSocketUrl(url: string): boolean {
}
}
function shouldSkipCreationTimePolicyResolution(ssrfPolicy?: SsrFPolicy): boolean {
if (!ssrfPolicy) {
return true;
}
return (
ssrfPolicy.dangerouslyAllowPrivateNetwork === true &&
(!ssrfPolicy.hostnameAllowlist || ssrfPolicy.hostnameAllowlist.length === 0)
);
}
export async function assertCdpEndpointAllowed(
cdpUrl: string,
ssrfPolicy?: SsrFPolicy,
): Promise<void> {
if (!ssrfPolicy) {
return;
}
const parsed = new URL(cdpUrl);
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
}
if (shouldSkipCreationTimePolicyResolution(ssrfPolicy)) {
return;
}
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
policy: ssrfPolicy,
});

View File

@@ -1,25 +1,32 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", async (importOriginal) => {
vi.mock("node:child_process", async () => {
const { mockNodeBuiltinModule } = await import("../../../../test/helpers/node-builtin-mocks.js");
return mockNodeBuiltinModule(importOriginal, {
execFileSync: vi.fn(),
});
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
{
execFileSync: vi.fn(),
},
);
});
vi.mock("node:fs", async (importOriginal) => {
vi.mock("node:fs", async () => {
const { mockNodeBuiltinModule } = await import("../../../../test/helpers/node-builtin-mocks.js");
const existsSync = vi.fn();
const readFileSync = vi.fn();
return mockNodeBuiltinModule(
importOriginal,
() => vi.importActual<typeof import("node:fs")>("node:fs"),
{ existsSync, readFileSync },
{ mirrorToDefault: true },
);
});
vi.mock("node:os", async (importOriginal) => {
vi.mock("node:os", async () => {
const { mockNodeBuiltinModule } = await import("../../../../test/helpers/node-builtin-mocks.js");
const homedir = vi.fn();
return mockNodeBuiltinModule(importOriginal, { homedir }, { mirrorToDefault: true });
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:os")>("node:os"),
{ homedir },
{ mirrorToDefault: true },
);
});
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";

View File

@@ -22,8 +22,8 @@ const mocks = vi.hoisted(() => ({
dispatch: vi.fn(async (): Promise<BrowserDispatchResponse> => okDispatchResponse()),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
loadConfig: mocks.loadConfig,

View File

@@ -17,8 +17,8 @@ const mocks = vi.hoisted(() => ({
})),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
loadConfig: mocks.loadConfig,

View File

@@ -6,8 +6,8 @@ import { resolveOpenClawUserDataDir } from "./chrome.js";
import type { BrowserRouteContext, BrowserServerState } from "./server-context.js";
import { movePathToTrash } from "./trash.js";
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
loadConfig: vi.fn(),
@@ -141,56 +141,6 @@ describe("BrowserProfilesService", () => {
);
});
it("accepts remote hostnames without DNS resolution in trusted SSRF mode", async () => {
const resolved = resolveBrowserConfig({});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({
name: "remote-hostname",
cdpUrl: "https://vpn-only.invalid:9222",
});
expect(result.cdpUrl).toBe("https://vpn-only.invalid:9222");
expect(result.cdpPort).toBe(9222);
expect(result.isRemote).toBe(true);
expect(writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
browser: expect.objectContaining({
profiles: expect.objectContaining({
"remote-hostname": expect.objectContaining({
cdpUrl: "https://vpn-only.invalid:9222",
}),
}),
}),
}),
);
});
it("rejects private-network cdpUrl when strict SSRF mode is enabled", async () => {
const resolved = resolveBrowserConfig({
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: false,
},
});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
await expect(
service.createProfile({
name: "remote",
cdpUrl: "http://10.0.0.42:9222",
}),
).rejects.toThrow(/blocked hostname|private\/internal\/special-use ip address/i);
expect(writeConfigFile).not.toHaveBeenCalled();
});
it("creates existing-session profiles as attach-only local entries", async () => {
const resolved = resolveBrowserConfig({});
const { ctx, state } = createCtx(resolved);

View File

@@ -4,7 +4,6 @@ import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
import { resolveUserPath } from "../utils.js";
import { assertCdpEndpointAllowed } from "./cdp.helpers.js";
import { resolveOpenClawUserDataDir } from "./chrome.js";
import { parseHttpUrl, resolveProfile } from "./config.js";
import {
@@ -125,7 +124,6 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
let parsed: ReturnType<typeof parseHttpUrl>;
try {
parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
await assertCdpEndpointAllowed(parsed.normalized, state.resolved.ssrfPolicy);
} catch (err) {
throw new BrowserValidationError(String(err));
}

View File

@@ -35,8 +35,8 @@ function buildConfig(): TestConfig {
};
}
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
createConfigIO: () => ({

View File

@@ -11,8 +11,8 @@ const mocks = vi.hoisted(() => ({
ensureExtensionRelayForProfiles: vi.fn(async () => {}),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
const browserConfig = {
enabled: true,
};
@@ -24,8 +24,8 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
vi.mock("./config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./config.js")>();
vi.mock("./config.js", async () => {
const actual = await vi.importActual<typeof import("./config.js")>("./config.js");
return {
...actual,
resolveBrowserConfig: vi.fn(() => ({

View File

@@ -225,8 +225,8 @@ function defaultProfilesForState(testPort: number): HarnessState["cfgProfiles"]
};
}
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
const loadConfig = () => {
return {
browser: {

View File

@@ -35,8 +35,8 @@ const routeCtxMocks = vi.hoisted(() => {
};
});
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
loadConfig: () => ({
@@ -57,8 +57,8 @@ vi.mock("./pw-ai-module.js", () => ({
getPwAiModule: vi.fn(async () => pwMocks),
}));
vi.mock("./server-context.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./server-context.js")>();
vi.mock("./server-context.js", async () => {
const actual = await vi.importActual<typeof import("./server-context.js")>("./server-context.js");
return {
...actual,
createBrowserRouteContext: routeCtxMocks.createBrowserRouteContext,

View File

@@ -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");

View File

@@ -8,8 +8,10 @@ const { loadConfigMock, isNodeCommandAllowedMock, resolveNodeCommandAllowlistMoc
}),
);
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,

View File

@@ -6,12 +6,17 @@ 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";
export * from "./src/session-key-normalization.js";
export * from "./src/status-issues.js";
export * from "./src/targets.js";
export * from "./src/security-audit.js";
export { resolveDiscordRuntimeGroupPolicy } from "./src/runtime-group-policy.js";
export {
DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS,

View File

@@ -1,7 +1,30 @@
import { describe, expect, it } from "vitest";
import { resolveDiscordAccount, resolveDiscordMaxLinesPerMessage } from "./accounts.js";
import {
createDiscordActionGate,
resolveDiscordAccount,
resolveDiscordMaxLinesPerMessage,
} from "./accounts.js";
describe("resolveDiscordAccount allowFrom precedence", () => {
it("uses configured defaultAccount when accountId is omitted", () => {
const resolved = resolveDiscordAccount({
cfg: {
channels: {
discord: {
defaultAccount: "work",
accounts: {
work: { token: "token-work", name: "Work" },
},
},
},
},
});
expect(resolved.accountId).toBe("work");
expect(resolved.name).toBe("Work");
expect(resolved.token).toBe("token-work");
});
it("prefers accounts.default.allowFrom over top-level for default account", () => {
const resolved = resolveDiscordAccount({
cfg: {
@@ -57,6 +80,29 @@ describe("resolveDiscordAccount allowFrom precedence", () => {
});
});
describe("createDiscordActionGate", () => {
it("uses configured defaultAccount when accountId is omitted", () => {
const gate = createDiscordActionGate({
cfg: {
channels: {
discord: {
actions: { reactions: false },
defaultAccount: "work",
accounts: {
work: {
token: "token-work",
actions: { reactions: true },
},
},
},
},
},
});
expect(gate("reactions")).toBe(true);
});
});
describe("resolveDiscordMaxLinesPerMessage", () => {
it("falls back to merged root discord maxLinesPerMessage when runtime config omits it", () => {
const resolved = resolveDiscordMaxLinesPerMessage({

View File

@@ -45,7 +45,9 @@ export function createDiscordActionGate(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): (key: keyof DiscordActionConfig, defaultValue?: boolean) => boolean {
const accountId = normalizeAccountId(params.accountId);
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultDiscordAccountId(params.cfg),
);
return createAccountActionGate({
baseActions: params.cfg.channels?.discord?.actions,
accountActions: resolveDiscordAccountConfig(params.cfg, accountId)?.actions,
@@ -56,7 +58,9 @@ export function resolveDiscordAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedDiscordAccount {
const accountId = normalizeAccountId(params.accountId);
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultDiscordAccountId(params.cfg),
);
const baseEnabled = params.cfg.channels?.discord?.enabled !== false;
const merged = mergeDiscordAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;

View File

@@ -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 } : {}) });

View File

@@ -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 }

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -1,3 +1,5 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
import { createStartAccountContext } from "../../../test/helpers/plugins/start-account-context.js";
@@ -98,6 +100,15 @@ beforeAll(async () => {
});
describe("discordPlugin outbound", () => {
it("avoids local require calls for bundled-only sibling modules", async () => {
const source = await readFile(
resolve(process.cwd(), "extensions/discord/src/channel.ts"),
"utf8",
);
expect(source).not.toContain('require("./ui.js")');
expect(source).not.toContain('require("./channel-actions.js")');
});
it("honors per-account replyToMode overrides", () => {
const resolveReplyToMode = discordPlugin.threading?.resolveReplyToMode;
if (!resolveReplyToMode) {

View File

@@ -33,6 +33,7 @@ import {
type ResolvedDiscordAccount,
} from "./accounts.js";
import { getDiscordApprovalCapability } from "./approval-native.js";
import { discordMessageActions as discordMessageActionsImpl } from "./channel-actions.js";
import {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
@@ -43,6 +44,11 @@ import {
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
} from "./group-policy.js";
import {
createThreadBindingManager,
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
} from "./monitor/thread-bindings.js";
import {
looksLikeDiscordTargetId,
normalizeDiscordMessagingTarget,
@@ -63,17 +69,17 @@ import {
type OpenClawConfig,
} from "./runtime-api.js";
import { getDiscordRuntime } from "./runtime.js";
import { collectDiscordSecurityAuditFindings } from "./security-audit.js";
import { fetchChannelPermissionsDiscord, sendMessageDiscord, sendPollDiscord } from "./send.js";
import { normalizeExplicitDiscordSessionKey } from "./session-key-normalization.js";
import { discordSetupAdapter } from "./setup-core.js";
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
import { collectDiscordStatusIssues } from "./status-issues.js";
import { parseDiscordTarget } from "./targets.js";
import { DiscordUiContainer } from "./ui.js";
type DiscordSendFn = typeof sendMessageDiscord;
type DiscordUiModule = typeof import("./ui.js");
type DiscordCarbonModule = typeof import("@buape/carbon");
type DiscordChannelActionsModule = typeof import("./channel-actions.js");
type DiscordTextDisplay = InstanceType<DiscordCarbonModule["TextDisplay"]>;
type DiscordSeparator = InstanceType<DiscordCarbonModule["Separator"]>;
@@ -82,9 +88,7 @@ let discordProviderRuntimePromise:
| undefined;
let discordProbeRuntimePromise: Promise<typeof import("./probe.runtime.js")> | undefined;
let discordAuditModulePromise: Promise<typeof import("./audit.js")> | undefined;
let discordUiModuleCache: DiscordUiModule | null = null;
let discordCarbonModuleCache: DiscordCarbonModule | null = null;
let discordChannelActionsModuleCache: DiscordChannelActionsModule | null = null;
const require = createRequire(import.meta.url);
@@ -108,17 +112,6 @@ function loadDiscordCarbonModule() {
return discordCarbonModuleCache;
}
function loadDiscordUiModule() {
discordUiModuleCache ??= require("./ui.js") as DiscordUiModule;
return discordUiModuleCache;
}
function loadDiscordChannelActionsModule() {
discordChannelActionsModuleCache ??=
require("./channel-actions.js") as DiscordChannelActionsModule;
return discordChannelActionsModuleCache;
}
const meta = getChatChannelMeta("discord");
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000;
@@ -152,13 +145,13 @@ const discordMessageActions = {
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>>[0],
): ChannelMessageToolDiscovery | null =>
resolveRuntimeDiscordMessageActions()?.describeMessageTool?.(ctx) ??
loadDiscordChannelActionsModule().discordMessageActions.describeMessageTool?.(ctx) ??
discordMessageActionsImpl.describeMessageTool?.(ctx) ??
null,
extractToolSend: (
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["extractToolSend"]>>[0],
) =>
resolveRuntimeDiscordMessageActions()?.extractToolSend?.(ctx) ??
loadDiscordChannelActionsModule().discordMessageActions.extractToolSend?.(ctx) ??
discordMessageActionsImpl.extractToolSend?.(ctx) ??
null,
handleAction: async (
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["handleAction"]>>[0],
@@ -167,11 +160,10 @@ const discordMessageActions = {
if (runtimeHandleAction) {
return await runtimeHandleAction(ctx);
}
const { discordMessageActions } = loadDiscordChannelActionsModule();
if (!discordMessageActions.handleAction) {
if (!discordMessageActionsImpl.handleAction) {
throw new Error("Discord message actions not available");
}
return await discordMessageActions.handleAction(ctx);
return await discordMessageActionsImpl.handleAction(ctx);
},
};
@@ -221,7 +213,6 @@ function buildDiscordCrossContextComponents(params: {
accountId?: string | null;
}) {
const { Separator, TextDisplay } = loadDiscordCarbonModule();
const { DiscordUiContainer } = loadDiscordUiModule();
const trimmed = params.message.trim();
const components: Array<DiscordTextDisplay | DiscordSeparator> = [];
if (trimmed) {
@@ -353,6 +344,42 @@ function resolveDiscordCommandConversation(params: {
return conversationId ? { conversationId } : null;
}
function resolveDiscordInboundConversation(params: {
from?: string;
to?: string;
conversationId?: string;
isGroup: boolean;
}) {
const rawSender = params.from?.trim() || "";
if (!params.isGroup && rawSender) {
const senderTarget = parseDiscordTarget(rawSender, { defaultKind: "user" });
if (senderTarget?.kind === "user") {
return { conversationId: `user:${senderTarget.id}` };
}
}
const rawTarget = params.to?.trim() || params.conversationId?.trim() || "";
if (!rawTarget) {
return null;
}
const target = parseDiscordTarget(rawTarget, { defaultKind: "channel" });
return target ? { conversationId: `${target.kind}:${target.id}` } : null;
}
function toConversationLifecycleBinding(binding: {
boundAt: number;
lastActivityAt?: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
}) {
return {
boundAt: binding.boundAt,
lastActivityAt:
typeof binding.lastActivityAt === "number" ? binding.lastActivityAt : binding.boundAt,
idleTimeoutMs: typeof binding.idleTimeoutMs === "number" ? binding.idleTimeoutMs : undefined,
maxAgeMs: typeof binding.maxAgeMs === "number" ? binding.maxAgeMs : undefined,
};
}
function parseDiscordExplicitTarget(raw: string) {
try {
const target = parseDiscordTarget(raw, { defaultKind: "channel" });
@@ -401,6 +428,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
},
messaging: {
normalizeTarget: normalizeDiscordMessagingTarget,
resolveInboundConversation: ({ from, to, conversationId, isGroup }) =>
resolveDiscordInboundConversation({ from, to, conversationId, isGroup }),
normalizeExplicitSessionKey: ({ sessionKey, ctx }) =>
normalizeExplicitDiscordSessionKey(sessionKey, ctx),
resolveSessionTarget: ({ id }) => normalizeDiscordMessagingTarget(`channel:${id}`),
@@ -491,6 +520,29 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
fallbackTo,
}),
},
conversationBindings: {
supportsCurrentConversationBinding: true,
defaultTopLevelPlacement: "child",
createManager: ({ cfg, accountId }) =>
createThreadBindingManager({
cfg,
accountId: accountId ?? undefined,
persist: false,
enableSweeper: false,
}),
setIdleTimeoutBySessionKey: ({ targetSessionKey, accountId, idleTimeoutMs }) =>
setThreadBindingIdleTimeoutBySessionKey({
targetSessionKey,
accountId: accountId ?? undefined,
idleTimeoutMs,
}).map(toConversationLifecycleBinding),
setMaxAgeBySessionKey: ({ targetSessionKey, accountId, maxAgeMs }) =>
setThreadBindingMaxAgeBySessionKey({
targetSessionKey,
accountId: accountId ?? undefined,
maxAgeMs,
}).map(toConversationLifecycleBinding),
},
status: createComputedAccountStatusAdapter<ResolvedDiscordAccount, DiscordProbe, unknown>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, {
connected: false,
@@ -718,6 +770,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
security: {
resolveDmPolicy: resolveDiscordDmPolicy,
collectWarnings: collectDiscordSecurityWarnings,
collectAuditFindings: collectDiscordSecurityAuditFindings,
},
threading: {
scopedAccountReplyToMode: {

View File

@@ -4,8 +4,10 @@ import { createDiscordRestClient } from "./client.js";
const makeProxyFetchMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
vi.mock("openclaw/plugin-sdk/infra-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/infra-runtime")>(
"openclaw/plugin-sdk/infra-runtime",
);
makeProxyFetchMock.mockImplementation((proxyUrl: string) => {
if (proxyUrl === "bad-proxy") {
throw new Error("bad proxy");

View File

@@ -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,

View File

@@ -0,0 +1,69 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import {
collectDiscordNumericIdWarnings,
maybeRepairDiscordNumericIds,
scanDiscordNumericIdEntries,
} from "./doctor.js";
describe("discord doctor", () => {
it("finds numeric id entries across discord scopes", () => {
const cfg = {
channels: {
discord: {
allowFrom: [123],
dm: { allowFrom: ["ok"], groupChannels: [456] },
execApprovals: { approvers: [789] },
guilds: {
main: {
users: [111],
roles: [222],
channels: { general: { users: [333], roles: [444] } },
},
},
},
},
} as unknown as OpenClawConfig;
const hits = scanDiscordNumericIdEntries(cfg);
expect(hits.map((hit) => hit.path)).toEqual([
"channels.discord.allowFrom[0]",
"channels.discord.dm.groupChannels[0]",
"channels.discord.execApprovals.approvers[0]",
"channels.discord.guilds.main.users[0]",
"channels.discord.guilds.main.roles[0]",
"channels.discord.guilds.main.channels.general.users[0]",
"channels.discord.guilds.main.channels.general.roles[0]",
]);
});
it("repairs safe numeric ids into strings and warns for unsafe lists", () => {
const cfg = {
channels: {
discord: {
allowFrom: [123],
dm: { allowFrom: [99] },
guilds: { main: { users: [111], roles: [222] } },
},
},
} as unknown as OpenClawConfig;
const result = maybeRepairDiscordNumericIds(cfg, "openclaw doctor --fix");
expect(result.config.channels?.discord?.allowFrom).toEqual(["123"]);
expect(result.config.channels?.discord?.dm?.allowFrom).toEqual(["99"]);
expect(result.config.channels?.discord?.guilds?.main?.users).toEqual(["111"]);
expect(result.config.channels?.discord?.guilds?.main?.roles).toEqual(["222"]);
expect(result.changes).not.toHaveLength(0);
expect(result.warnings).toEqual([]);
});
it("formats repair guidance for unsafe numeric ids", () => {
const warnings = collectDiscordNumericIdWarnings({
hits: [{ path: "channels.discord.allowFrom[0]", entry: 106232522769186816, safe: false }],
doctorFixCommand: "openclaw doctor --fix",
});
expect(warnings[0]).toContain("cannot be auto-repaired");
expect(warnings[1]).toContain("openclaw doctor --fix");
});
});

View File

@@ -0,0 +1,533 @@
import {
type ChannelDoctorAdapter,
type ChannelDoctorConfigMutation,
} from "openclaw/plugin-sdk/channel-contract";
import {
resolveDiscordPreviewStreamMode,
type OpenClawConfig,
} from "openclaw/plugin-sdk/config-runtime";
import {
collectProviderDangerousNameMatchingScopes,
isDiscordMutableAllowEntry,
} from "openclaw/plugin-sdk/runtime";
type DiscordNumericIdHit = { path: string; entry: number; safe: boolean };
type DiscordIdListRef = {
pathLabel: string;
holder: Record<string, unknown>;
key: string;
};
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function sanitizeForLog(value: string): string {
return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
}
function normalizeDiscordDmAliases(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let changed = false;
let updated: Record<string, unknown> = params.entry;
const rawDm = updated.dm;
const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record<string, unknown>) : null;
let dmChanged = false;
const allowFromEqual = (a: unknown, b: unknown): boolean => {
if (!Array.isArray(a) || !Array.isArray(b)) {
return false;
}
const na = a.map((v) => String(v).trim()).filter(Boolean);
const nb = b.map((v) => String(v).trim()).filter(Boolean);
if (na.length !== nb.length) {
return false;
}
return na.every((v, i) => v === nb[i]);
};
const topDmPolicy = updated.dmPolicy;
const legacyDmPolicy = dm?.policy;
if (topDmPolicy === undefined && legacyDmPolicy !== undefined) {
updated = { ...updated, dmPolicy: legacyDmPolicy };
changed = true;
if (dm) {
delete dm.policy;
dmChanged = true;
}
params.changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`);
} else if (
topDmPolicy !== undefined &&
legacyDmPolicy !== undefined &&
topDmPolicy === legacyDmPolicy
) {
if (dm) {
delete dm.policy;
dmChanged = true;
params.changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`);
}
}
const topAllowFrom = updated.allowFrom;
const legacyAllowFrom = dm?.allowFrom;
if (topAllowFrom === undefined && legacyAllowFrom !== undefined) {
updated = { ...updated, allowFrom: legacyAllowFrom };
changed = true;
if (dm) {
delete dm.allowFrom;
dmChanged = true;
}
params.changes.push(
`Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`,
);
} else if (
topAllowFrom !== undefined &&
legacyAllowFrom !== undefined &&
allowFromEqual(topAllowFrom, legacyAllowFrom)
) {
if (dm) {
delete dm.allowFrom;
dmChanged = true;
params.changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`);
}
}
if (dm && asObjectRecord(rawDm) && dmChanged) {
const keys = Object.keys(dm);
if (keys.length === 0) {
if (updated.dm !== undefined) {
const { dm: _ignored, ...rest } = updated;
updated = rest;
changed = true;
params.changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`);
}
} else {
updated = { ...updated, dm };
changed = true;
}
}
return { entry: updated, changed };
}
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 normalizeDiscordCompatibilityConfig(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 base = normalizeDiscordDmAliases({
entry: rawEntry,
pathPrefix: "channels.discord",
changes,
});
updated = base.entry;
changed = base.changed;
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;
}
let accountEntry = account;
let accountChanged = false;
const dm = normalizeDiscordDmAliases({
entry: account,
pathPrefix: `channels.discord.accounts.${accountId}`,
changes,
});
accountEntry = dm.entry;
accountChanged = dm.changed;
const accountStreaming = normalizeDiscordStreamingAliases({
entry: accountEntry,
pathPrefix: `channels.discord.accounts.${accountId}`,
changes,
});
accountEntry = accountStreaming.entry;
accountChanged = accountChanged || accountStreaming.changed;
if (accountChanged) {
accounts[accountId] = accountEntry;
accountsChanged = true;
}
}
if (accountsChanged) {
updated = { ...updated, accounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
discord: updated,
} as OpenClawConfig["channels"],
},
changes,
};
}
function collectDiscordAccountScopes(
cfg: OpenClawConfig,
): Array<{ prefix: string; account: Record<string, unknown> }> {
const scopes: Array<{ prefix: string; account: Record<string, unknown> }> = [];
const discord = asObjectRecord(cfg.channels?.discord);
if (!discord) {
return scopes;
}
scopes.push({ prefix: "channels.discord", account: discord });
const accounts = asObjectRecord(discord.accounts);
if (!accounts) {
return scopes;
}
for (const key of Object.keys(accounts)) {
const account = asObjectRecord(accounts[key]);
if (account) {
scopes.push({ prefix: `channels.discord.accounts.${key}`, account });
}
}
return scopes;
}
function collectDiscordIdLists(
prefix: string,
account: Record<string, unknown>,
): DiscordIdListRef[] {
const refs: DiscordIdListRef[] = [
{ pathLabel: `${prefix}.allowFrom`, holder: account, key: "allowFrom" },
];
const dm = asObjectRecord(account.dm);
if (dm) {
refs.push({ pathLabel: `${prefix}.dm.allowFrom`, holder: dm, key: "allowFrom" });
refs.push({ pathLabel: `${prefix}.dm.groupChannels`, holder: dm, key: "groupChannels" });
}
const execApprovals = asObjectRecord(account.execApprovals);
if (execApprovals) {
refs.push({
pathLabel: `${prefix}.execApprovals.approvers`,
holder: execApprovals,
key: "approvers",
});
}
const guilds = asObjectRecord(account.guilds);
if (!guilds) {
return refs;
}
for (const guildId of Object.keys(guilds)) {
const guild = asObjectRecord(guilds[guildId]);
if (!guild) {
continue;
}
refs.push({ pathLabel: `${prefix}.guilds.${guildId}.users`, holder: guild, key: "users" });
refs.push({ pathLabel: `${prefix}.guilds.${guildId}.roles`, holder: guild, key: "roles" });
const channels = asObjectRecord(guild.channels);
if (!channels) {
continue;
}
for (const channelId of Object.keys(channels)) {
const channel = asObjectRecord(channels[channelId]);
if (!channel) {
continue;
}
refs.push({
pathLabel: `${prefix}.guilds.${guildId}.channels.${channelId}.users`,
holder: channel,
key: "users",
});
refs.push({
pathLabel: `${prefix}.guilds.${guildId}.channels.${channelId}.roles`,
holder: channel,
key: "roles",
});
}
}
return refs;
}
export function scanDiscordNumericIdEntries(cfg: OpenClawConfig): DiscordNumericIdHit[] {
const hits: DiscordNumericIdHit[] = [];
const scanList = (pathLabel: string, list: unknown) => {
if (!Array.isArray(list)) {
return;
}
for (const [index, entry] of list.entries()) {
if (typeof entry !== "number") {
continue;
}
hits.push({
path: `${pathLabel}[${index}]`,
entry,
safe: Number.isSafeInteger(entry) && entry >= 0,
});
}
};
for (const scope of collectDiscordAccountScopes(cfg)) {
for (const ref of collectDiscordIdLists(scope.prefix, scope.account)) {
scanList(ref.pathLabel, ref.holder[ref.key]);
}
}
return hits;
}
export function collectDiscordNumericIdWarnings(params: {
hits: DiscordNumericIdHit[];
doctorFixCommand: string;
}): string[] {
if (params.hits.length === 0) {
return [];
}
const hitsByListPath = new Map<string, DiscordNumericIdHit[]>();
for (const hit of params.hits) {
const listPath = hit.path.replace(/\[\d+\]$/, "");
const existing = hitsByListPath.get(listPath);
if (existing) {
existing.push(hit);
} else {
hitsByListPath.set(listPath, [hit]);
}
}
const repairableHits: DiscordNumericIdHit[] = [];
const blockedHits: DiscordNumericIdHit[] = [];
for (const hits of hitsByListPath.values()) {
if (hits.some((hit) => !hit.safe)) {
blockedHits.push(...hits);
} else {
repairableHits.push(...hits);
}
}
const lines: string[] = [];
if (repairableHits.length > 0) {
const sample = repairableHits[0]!;
lines.push(
`- Discord allowlists contain ${repairableHits.length} numeric ${repairableHits.length === 1 ? "entry" : "entries"} (e.g. ${sanitizeForLog(sample.path)}=${sanitizeForLog(String(sample.entry))}).`,
`- Discord IDs must be strings; run "${params.doctorFixCommand}" to convert numeric IDs to quoted strings.`,
);
}
if (blockedHits.length > 0) {
const sample = blockedHits[0]!;
lines.push(
`- Discord allowlists contain ${blockedHits.length} numeric ${blockedHits.length === 1 ? "entry" : "entries"} in lists that cannot be auto-repaired (e.g. ${sanitizeForLog(sample.path)}).`,
`- These lists include invalid or precision-losing numeric IDs; manually quote the original values in your config file, then rerun "${params.doctorFixCommand}".`,
);
}
return lines;
}
export function maybeRepairDiscordNumericIds(
cfg: OpenClawConfig,
doctorFixCommand: string,
): { config: OpenClawConfig; changes: string[]; warnings?: string[] } {
const hits = scanDiscordNumericIdEntries(cfg);
if (hits.length === 0) {
return { config: cfg, changes: [] };
}
const next = structuredClone(cfg);
const changes: string[] = [];
const repairList = (pathLabel: string, holder: Record<string, unknown>, key: string) => {
const raw = holder[key];
if (!Array.isArray(raw)) {
return;
}
const hasUnsafe = raw.some(
(entry) => typeof entry === "number" && (!Number.isSafeInteger(entry) || entry < 0),
);
if (hasUnsafe) {
return;
}
let converted = 0;
holder[key] = raw.map((entry) => {
if (typeof entry === "number") {
converted += 1;
return String(entry);
}
return entry;
});
if (converted > 0) {
changes.push(
`- ${sanitizeForLog(pathLabel)}: converted ${converted} numeric ${converted === 1 ? "ID" : "IDs"} to strings`,
);
}
};
for (const scope of collectDiscordAccountScopes(next)) {
for (const ref of collectDiscordIdLists(scope.prefix, scope.account)) {
repairList(ref.pathLabel, ref.holder, ref.key);
}
}
if (changes.length === 0) {
return {
config: cfg,
changes: [],
warnings: collectDiscordNumericIdWarnings({ hits, doctorFixCommand }),
};
}
return {
config: next,
changes,
warnings: collectDiscordNumericIdWarnings({
hits: hits.filter((hit) => !hit.safe),
doctorFixCommand,
}),
};
}
function collectDiscordMutableAllowlistWarnings(cfg: OpenClawConfig): string[] {
const hits: Array<{ path: string; entry: string }> = [];
const addHits = (pathLabel: string, list: unknown) => {
if (!Array.isArray(list)) {
return;
}
for (const entry of list) {
const text = String(entry).trim();
if (!text || text === "*" || !isDiscordMutableAllowEntry(text)) {
continue;
}
hits.push({ path: pathLabel, entry: text });
}
};
for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "discord")) {
if (scope.dangerousNameMatchingEnabled) {
continue;
}
addHits(`${scope.prefix}.allowFrom`, scope.account.allowFrom);
const dm = asObjectRecord(scope.account.dm);
if (dm) {
addHits(`${scope.prefix}.dm.allowFrom`, dm.allowFrom);
}
const guilds = asObjectRecord(scope.account.guilds);
if (!guilds) {
continue;
}
for (const [guildId, guildRaw] of Object.entries(guilds)) {
const guild = asObjectRecord(guildRaw);
if (!guild) {
continue;
}
addHits(`${scope.prefix}.guilds.${guildId}.users`, guild.users);
const channels = asObjectRecord(guild.channels);
if (!channels) {
continue;
}
for (const [channelId, channelRaw] of Object.entries(channels)) {
const channel = asObjectRecord(channelRaw);
if (channel) {
addHits(`${scope.prefix}.guilds.${guildId}.channels.${channelId}.users`, channel.users);
}
}
}
}
if (hits.length === 0) {
return [];
}
const exampleLines = hits
.slice(0, 8)
.map((hit) => `- ${sanitizeForLog(hit.path)}: ${sanitizeForLog(hit.entry)}`);
const remaining =
hits.length > 8 ? `- +${hits.length - 8} more mutable allowlist entries.` : null;
return [
`- Found ${hits.length} mutable allowlist ${hits.length === 1 ? "entry" : "entries"} across discord while name matching is disabled by default.`,
...exampleLines,
...(remaining ? [remaining] : []),
`- Option A (break-glass): enable channels.discord.dangerousNameMatching=true for the affected scope.`,
`- Option B (recommended): resolve names to stable Discord IDs and rewrite the allowlist entries.`,
];
}
export const discordDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "topOrNested",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
normalizeCompatibilityConfig: ({ cfg }) => normalizeDiscordCompatibilityConfig(cfg),
collectPreviewWarnings: ({ cfg, doctorFixCommand }) =>
collectDiscordNumericIdWarnings({
hits: scanDiscordNumericIdEntries(cfg),
doctorFixCommand,
}),
collectMutableAllowlistWarnings: ({ cfg }) => collectDiscordMutableAllowlistWarnings(cfg),
repairConfig: ({ cfg, doctorFixCommand }) => maybeRepairDiscordNumericIds(cfg, doctorFixCommand),
};

View File

@@ -0,0 +1,104 @@
import type { ChannelStructuredComponents } from "openclaw/plugin-sdk/channel-contract";
import {
createInteractiveConversationBindingHelpers,
dispatchPluginInteractiveHandler,
type PluginConversationBinding,
type PluginConversationBindingRequestParams,
type PluginConversationBindingRequestResult,
type PluginInteractiveRegistration,
} from "openclaw/plugin-sdk/plugin-runtime";
export type DiscordInteractiveHandlerContext = {
channel: "discord";
accountId: string;
interactionId: string;
conversationId: string;
parentConversationId?: string;
guildId?: string;
senderId?: string;
senderUsername?: string;
auth: {
isAuthorizedSender: boolean;
};
interaction: {
kind: "button" | "select" | "modal";
data: string;
namespace: string;
payload: string;
messageId?: string;
values?: string[];
fields?: Array<{ id: string; name: string; values: string[] }>;
};
respond: {
acknowledge: () => Promise<void>;
reply: (params: { text: string; ephemeral?: boolean }) => Promise<void>;
followUp: (params: { text: string; ephemeral?: boolean }) => Promise<void>;
editMessage: (params: {
text?: string;
components?: ChannelStructuredComponents;
}) => Promise<void>;
clearComponents: (params?: { text?: string }) => Promise<void>;
};
requestConversationBinding: (
params?: PluginConversationBindingRequestParams,
) => Promise<PluginConversationBindingRequestResult>;
detachConversationBinding: () => Promise<{ removed: boolean }>;
getCurrentConversationBinding: () => Promise<PluginConversationBinding | null>;
};
export type DiscordInteractiveHandlerRegistration = PluginInteractiveRegistration<
DiscordInteractiveHandlerContext,
"discord"
>;
export type DiscordInteractiveDispatchContext = Omit<
DiscordInteractiveHandlerContext,
| "interaction"
| "respond"
| "channel"
| "requestConversationBinding"
| "detachConversationBinding"
| "getCurrentConversationBinding"
> & {
interaction: Omit<
DiscordInteractiveHandlerContext["interaction"],
"data" | "namespace" | "payload"
>;
};
export async function dispatchDiscordPluginInteractiveHandler(params: {
data: string;
interactionId: string;
ctx: DiscordInteractiveDispatchContext;
respond: DiscordInteractiveHandlerContext["respond"];
onMatched?: () => Promise<void> | void;
}) {
return await dispatchPluginInteractiveHandler<DiscordInteractiveHandlerRegistration>({
channel: "discord",
data: params.data,
dedupeId: params.interactionId,
onMatched: params.onMatched,
invoke: ({ registration, namespace, payload }) =>
registration.handler({
...params.ctx,
channel: "discord",
interaction: {
...params.ctx.interaction,
data: params.data,
namespace,
payload,
},
respond: params.respond,
...createInteractiveConversationBindingHelpers({
registration,
senderId: params.ctx.senderId,
conversation: {
channel: "discord",
accountId: params.ctx.accountId,
conversationId: params.ctx.conversationId,
parentConversationId: params.ctx.parentConversationId,
},
}),
}),
});
}

View File

@@ -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(),

View File

@@ -28,7 +28,6 @@ import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import type { PluginInteractiveDiscordHandlerContext } from "openclaw/plugin-sdk/plugin-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
@@ -40,6 +39,8 @@ import {
} from "../component-custom-id.js";
import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js";
import type { DiscordComponentEntry, DiscordModalEntry } from "../components.js";
import { type DiscordInteractiveHandlerContext } from "../interactive-dispatch.js";
import { dispatchDiscordPluginInteractiveHandler } from "../interactive-dispatch.js";
import { editDiscordComponentMessage } from "../send.components.js";
import {
AGENT_BUTTON_KEY,
@@ -91,6 +92,7 @@ import { deliverDiscordReply } from "./reply-delivery.js";
let conversationRuntimePromise: Promise<typeof import("./agent-components.runtime.js")> | undefined;
let componentsRuntimePromise: Promise<typeof import("../components.js")> | undefined;
let replyRuntimePromise: Promise<typeof import("openclaw/plugin-sdk/reply-runtime")> | undefined;
let replyPipelineRuntimePromise:
| Promise<typeof import("openclaw/plugin-sdk/channel-reply-pipeline")>
| undefined;
@@ -106,6 +108,10 @@ async function loadComponentsRuntime() {
return await componentsRuntimePromise;
}
async function loadReplyRuntime() {
replyRuntimePromise ??= import("openclaw/plugin-sdk/reply-runtime");
return await replyRuntimePromise;
}
async function loadReplyPipelineRuntime() {
replyPipelineRuntimePromise ??= import("openclaw/plugin-sdk/channel-reply-pipeline");
return await replyPipelineRuntimePromise;
@@ -191,7 +197,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
}
await params.interaction.update(payload);
};
const respond: PluginInteractiveDiscordHandlerContext["respond"] = {
const respond: DiscordInteractiveHandlerContext["respond"] = {
acknowledge: async () => {
if (responded) {
return;
@@ -215,7 +221,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
});
},
editMessage: async (
input: Parameters<PluginInteractiveDiscordHandlerContext["respond"]["editMessage"]>[0],
input: Parameters<DiscordInteractiveHandlerContext["respond"]["editMessage"]>[0],
) => {
const { text, components } = input;
responded = true;
@@ -279,9 +285,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
}
return "handled";
}
const { dispatchPluginInteractiveHandler } = await loadConversationRuntime();
const dispatched = await dispatchPluginInteractiveHandler({
channel: "discord",
const dispatched = await dispatchDiscordPluginInteractiveHandler({
data: params.data,
interactionId: resolveDiscordInteractionId(params.interaction),
ctx: {

View File

@@ -57,8 +57,8 @@ const mockGatewayClientCtor = vi.hoisted(() => vi.fn());
const mockResolveGatewayConnectionAuth = vi.hoisted(() => vi.fn());
const mockCreateOperatorApprovalsGatewayClient = vi.hoisted(() => vi.fn());
vi.mock("../send.shared.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../send.shared.js")>();
vi.mock("../send.shared.js", async () => {
const actual = await vi.importActual<typeof import("../send.shared.js")>("../send.shared.js");
return {
...actual,
createDiscordClient: () => ({
@@ -72,8 +72,10 @@ vi.mock("../send.shared.js", async (importOriginal) => {
};
});
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,
loadSessionStore: () => mockSessionStoreEntries.value,
@@ -148,8 +150,10 @@ vi.mock("../../../../src/gateway/client.js", () => ({
},
}));
vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-runtime")>();
vi.mock("openclaw/plugin-sdk/text-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/text-runtime")>(
"openclaw/plugin-sdk/text-runtime",
);
return {
...actual,
logDebug: vi.fn(),

View File

@@ -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",
),
);
});

View File

@@ -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",

View File

@@ -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) {

View File

@@ -5,8 +5,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const fetchRemoteMedia = vi.fn();
const saveMediaBuffer = vi.fn();
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/media-runtime")>(
"openclaw/plugin-sdk/media-runtime",
);
return {
...actual,
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),

View File

@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { peekSystemEvents, resetSystemEventsForTest } from "../../../../src/infra/system-events.js";
import { expectPairingReplyText } from "../../../../test/helpers/pairing-reply.js";
import {
enqueueSystemEventMock,
readAllowFromStoreMock,
resetDiscordComponentRuntimeMocks,
upsertPairingRequestMock,
@@ -29,7 +30,6 @@ describe("agent components", () => {
});
const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
const createBaseDmInteraction = (overrides: Record<string, unknown> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined);
const defer = vi.fn().mockResolvedValue(undefined);
@@ -204,9 +204,12 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(peekSystemEvents(defaultGroupDmSessionKey)).toEqual([
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
"[Discord component: hello clicked by Alice#1234 (123456789)]",
]);
expect.objectContaining({
sessionKey: defaultGroupDmSessionKey,
}),
);
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([]);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -224,9 +227,12 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
"[Discord component: hello clicked by Alice#1234 (123456789)]",
]);
expect.objectContaining({
sessionKey: defaultDmSessionKey,
}),
);
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
});
@@ -244,9 +250,12 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
"[Discord component: hello clicked by Alice#1234 (123456789)]",
]);
expect.objectContaining({
sessionKey: defaultDmSessionKey,
}),
);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -284,9 +293,12 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
"[Discord select menu: hello interacted by Alice#1234 (123456789) (selected: alpha)]",
]);
expect.objectContaining({
sessionKey: defaultDmSessionKey,
}),
);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -303,9 +315,12 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
"[Discord component: hello_cid clicked by Alice#1234 (123456789)]",
]);
expect.objectContaining({
sessionKey: defaultDmSessionKey,
}),
);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -322,9 +337,12 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
"[Discord component: hello%2G clicked by Alice#1234 (123456789)]",
]);
expect.objectContaining({
sessionKey: defaultDmSessionKey,
}),
);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
});

View File

@@ -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");

View File

@@ -153,7 +153,7 @@ describe("createDiscordNativeCommand option wiring", () => {
});
it("keeps static choices for non-acp string action arguments", () => {
const command = createNativeCommand("voice");
const command = createNativeCommand("config");
const action = requireOption(command, "action");
const choices = readChoices(action);

View File

@@ -21,8 +21,10 @@ const runtimeModuleMocks = vi.hoisted(() => ({
dispatchReplyWithDispatcher: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-runtime")>(
"openclaw/plugin-sdk/plugin-runtime",
);
return {
...actual,
matchPluginCommand: (...args: unknown[]) => runtimeModuleMocks.matchPluginCommand(...args),
@@ -30,8 +32,10 @@ vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
};
});
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-runtime")>(
"openclaw/plugin-sdk/reply-runtime",
);
return {
...actual,
dispatchReplyWithDispatcher: (...args: unknown[]) =>

View File

@@ -11,39 +11,62 @@ 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 EnsureConfiguredBindingRouteReady =
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
type ResolveConfiguredBindingRoute =
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
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>(({ route }) => ({
bindingResolution: null,
route,
})),
);
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
type ConfiguredBindingRoute = ReturnType<ResolveConfiguredBindingRoute>;
type ConfiguredBindingResolution = NonNullable<ConfiguredBindingRoute["bindingResolution"]>;
function createConfiguredRouteResult(
params: Parameters<ResolveConfiguredBindingRoute>[0],
): ConfiguredBindingRoute {
return {
bindingResolution: {
record: {
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");
return await createConfiguredBindingConversationRuntimeModuleMock(
return await createConfiguredBindingConversationRuntimeModuleMock<
typeof import("openclaw/plugin-sdk/conversation-runtime")
>(
{
ensureConfiguredBindingRouteReadyMock,
resolveConfiguredBindingRouteMock,
},
importOriginal,
() =>
vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
"openclaw/plugin-sdk/conversation-runtime",
),
);
});
@@ -64,7 +87,10 @@ describe("discord native /think autocomplete", () => {
ensureConfiguredBindingRouteReadyMock.mockReset();
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
resolveConfiguredBindingRouteMock.mockReset();
resolveConfiguredBindingRouteMock.mockReturnValue(null);
resolveConfiguredBindingRouteMock.mockImplementation(({ route }) => ({
bindingResolution: null,
route,
}));
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
fs.writeFileSync(
STORE_PATH,
@@ -149,22 +175,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",

View File

@@ -36,8 +36,8 @@ const retryAsyncMock = 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,
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscordMock(...args),
@@ -50,8 +50,10 @@ vi.mock("../send.shared.js", () => ({
sendDiscordText: (...args: unknown[]) => sendDiscordTextMock(...args),
}));
vi.mock("openclaw/plugin-sdk/retry-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/retry-runtime")>();
vi.mock("openclaw/plugin-sdk/retry-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/retry-runtime")>(
"openclaw/plugin-sdk/retry-runtime",
);
return {
...actual,
retryAsync: retryAsyncMock,

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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,
);
}

View File

@@ -0,0 +1,235 @@
import {
isDangerousNameMatchingEnabled,
resolveNativeCommandsEnabled,
resolveNativeSkillsEnabled,
} from "openclaw/plugin-sdk/config-runtime";
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
import type { ResolvedDiscordAccount } from "./accounts.js";
import type { OpenClawConfig } from "./runtime-api.js";
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
if (!Array.isArray(list)) {
return [];
}
return list.map((value) => String(value).trim()).filter(Boolean);
}
function coerceNativeSetting(value: unknown): boolean | "auto" | undefined {
if (value === true || value === false || value === "auto") {
return value;
}
return undefined;
}
function isDiscordMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const maybeMentionId = text.replace(/^<@!?/, "").replace(/>$/, "");
if (/^\d+$/.test(maybeMentionId)) {
return false;
}
for (const prefix of ["discord:", "user:", "pk:"]) {
if (!text.startsWith(prefix)) {
continue;
}
return text.slice(prefix.length).trim().length === 0;
}
return true;
}
function addDiscordNameBasedEntries(params: {
target: Set<string>;
values: unknown;
source: string;
}) {
if (!Array.isArray(params.values)) {
return;
}
for (const value of params.values) {
if (!isDiscordMutableAllowEntry(String(value))) {
continue;
}
const text = String(value).trim();
if (!text) {
continue;
}
params.target.add(`${params.source}:${text}`);
}
}
export async function collectDiscordSecurityAuditFindings(params: {
cfg: OpenClawConfig;
accountId?: string | null;
account: ResolvedDiscordAccount;
orderedAccountIds: string[];
hasExplicitAccountPath: boolean;
}) {
const findings: Array<{
checkId: string;
severity: "info" | "warn" | "critical";
title: string;
detail: string;
remediation?: string;
}> = [];
const discordCfg = params.account.config ?? {};
const accountId = params.accountId?.trim() || params.account.accountId || "default";
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(discordCfg);
const storeAllowFrom = await readChannelAllowFromStore("discord", process.env, accountId).catch(
() => [],
);
const discordNameBasedAllowEntries = new Set<string>();
const discordPathPrefix =
params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
? `channels.discord.accounts.${accountId}`
: "channels.discord";
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: discordCfg.allowFrom,
source: `${discordPathPrefix}.allowFrom`,
});
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom,
source: `${discordPathPrefix}.dm.allowFrom`,
});
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: storeAllowFrom,
source: "~/.openclaw/credentials/discord-allowFrom.json",
});
const guildEntries = (discordCfg.guilds as Record<string, unknown> | undefined) ?? {};
for (const [guildKey, guildValue] of Object.entries(guildEntries)) {
if (!guildValue || typeof guildValue !== "object") {
continue;
}
const guild = guildValue as Record<string, unknown>;
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: guild.users,
source: `${discordPathPrefix}.guilds.${guildKey}.users`,
});
const channels = guild.channels;
if (!channels || typeof channels !== "object") {
continue;
}
for (const [channelKey, channelValue] of Object.entries(channels as Record<string, unknown>)) {
if (!channelValue || typeof channelValue !== "object") {
continue;
}
const channel = channelValue as Record<string, unknown>;
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: channel.users,
source: `${discordPathPrefix}.guilds.${guildKey}.channels.${channelKey}.users`,
});
}
}
if (discordNameBasedAllowEntries.size > 0) {
const examples = Array.from(discordNameBasedAllowEntries).slice(0, 5);
const more =
discordNameBasedAllowEntries.size > examples.length
? ` (+${discordNameBasedAllowEntries.size - examples.length} more)`
: "";
findings.push({
checkId: "channels.discord.allowFrom.name_based_entries",
severity: dangerousNameMatchingEnabled ? "info" : "warn",
title: dangerousNameMatchingEnabled
? "Discord allowlist uses break-glass name/tag matching"
: "Discord allowlist contains name or tag entries",
detail: dangerousNameMatchingEnabled
? "Discord name/tag allowlist matching is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
`Found: ${examples.join(", ")}${more}.`
: "Discord name/tag allowlist matching uses normalized slugs and can collide across users. " +
`Found: ${examples.join(", ")}${more}.`,
remediation: dangerousNameMatchingEnabled
? "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>), then disable dangerouslyAllowNameMatching."
: "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>) in channels.discord.allowFrom and channels.discord.guilds.*.users, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept the risk.",
});
}
const nativeEnabled = resolveNativeCommandsEnabled({
providerId: "discord",
providerSetting: coerceNativeSetting(
(discordCfg.commands as { native?: unknown } | undefined)?.native,
),
globalSetting: params.cfg.commands?.native,
});
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
providerId: "discord",
providerSetting: coerceNativeSetting(
(discordCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
),
globalSetting: params.cfg.commands?.nativeSkills,
});
if (!nativeEnabled && !nativeSkillsEnabled) {
return findings;
}
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
const groupPolicy =
(discordCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
const guildsConfigured = Object.keys(guildEntries).length > 0;
const hasAnyUserAllowlist = Object.values(guildEntries).some((guild) => {
if (!guild || typeof guild !== "object") {
return false;
}
const record = guild as Record<string, unknown>;
if (Array.isArray(record.users) && record.users.length > 0) {
return true;
}
const channels = record.channels;
if (!channels || typeof channels !== "object") {
return false;
}
return Object.values(channels as Record<string, unknown>).some((channel) => {
if (!channel || typeof channel !== "object") {
return false;
}
const channelRecord = channel as Record<string, unknown>;
return Array.isArray(channelRecord.users) && channelRecord.users.length > 0;
});
});
const dmAllowFromRaw = (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom;
const dmAllowFrom = Array.isArray(dmAllowFromRaw) ? dmAllowFromRaw : [];
const ownerAllowFromConfigured =
normalizeAllowFromList([...dmAllowFrom, ...storeAllowFrom]).length > 0;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (!useAccessGroups && groupPolicy !== "disabled" && guildsConfigured && !hasAnyUserAllowlist) {
findings.push({
checkId: "channels.discord.commands.native.unrestricted",
severity: "critical",
title: "Discord slash commands are unrestricted",
detail:
"commands.useAccessGroups=false disables sender allowlists for Discord slash commands unless a per-guild/channel users allowlist is configured; with no users allowlist, any user in allowed guild channels can invoke /… commands.",
remediation:
"Set commands.useAccessGroups=true (recommended), or configure channels.discord.guilds.<id>.users (or channels.discord.guilds.<id>.channels.<channel>.users).",
});
} else if (
useAccessGroups &&
groupPolicy !== "disabled" &&
guildsConfigured &&
!ownerAllowFromConfigured &&
!hasAnyUserAllowlist
) {
findings.push({
checkId: "channels.discord.commands.native.no_allowlists",
severity: "warn",
title: "Discord slash commands have no allowlists",
detail:
"Discord slash commands are enabled, but neither an owner allowFrom list nor any per-guild/channel users allowlist is configured; /… commands will be rejected for everyone.",
remediation:
"Add your user id to channels.discord.allowFrom (or approve yourself via pairing), or configure channels.discord.guilds.<id>.users.",
});
}
return findings;
}

View File

@@ -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) {

View File

@@ -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.

View File

@@ -3,16 +3,20 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
const recordChannelActivityMock = vi.hoisted(() => vi.fn());
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ channels: { discord: {} } })));
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(),
};
});
vi.mock("../../../src/infra/channel-activity.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/infra/channel-activity.js")>();
vi.mock("../../../src/infra/channel-activity.js", async () => {
const actual = await vi.importActual<typeof import("../../../src/infra/channel-activity.js")>(
"../../../src/infra/channel-activity.js",
);
return {
...actual,
recordChannelActivity: (...args: unknown[]) => recordChannelActivityMock(...args),

View File

@@ -4,8 +4,10 @@ import { sendWebhookMessageDiscord } from "./send.outbound.js";
const makeProxyFetchMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
vi.mock("openclaw/plugin-sdk/infra-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/infra-runtime")>(
"openclaw/plugin-sdk/infra-runtime",
);
return {
...actual,
makeProxyFetch: makeProxyFetchMock,

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
inspectDiscordSetupAccount,
listDiscordSetupAccountIds,
resolveDefaultDiscordSetupAccountId,
resolveDiscordSetupAccountConfig,
} from "./setup-account-state.js";
@@ -41,6 +42,31 @@ describe("discord setup account state", () => {
expect(resolved.config.allowFrom).toEqual(["acct"]);
});
it("uses configured defaultAccount for omitted setup account resolution", () => {
const cfg = {
channels: {
discord: {
defaultAccount: "work",
allowFrom: ["top"],
accounts: {
alerts: { allowFrom: ["alerts-only"] },
work: { name: "Work", allowFrom: ["work-only"] },
},
},
},
};
expect(resolveDefaultDiscordSetupAccountId(cfg)).toBe("work");
const resolved = resolveDiscordSetupAccountConfig({
cfg,
});
expect(resolved.accountId).toBe("work");
expect(resolved.config.name).toBe("Work");
expect(resolved.config.allowFrom).toEqual(["work-only"]);
});
it("treats explicit blank account tokens as missing without falling back", () => {
const inspected = inspectDiscordSetupAccount({
cfg: {

View File

@@ -5,6 +5,7 @@ import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
import { resolveDefaultDiscordAccountId } from "./accounts.js";
import { mergeDiscordAccountConfig, resolveDiscordAccountConfig } from "./accounts.js";
import type { DiscordAccountConfig } from "./runtime-api.js";
import { resolveDiscordToken } from "./token.js";
@@ -54,14 +55,16 @@ export function listDiscordSetupAccountIds(cfg: OpenClawConfig): string[] {
}
export function resolveDefaultDiscordSetupAccountId(cfg: OpenClawConfig): string {
return listDiscordSetupAccountIds(cfg)[0] ?? DEFAULT_ACCOUNT_ID;
return resolveDefaultDiscordAccountId(cfg);
}
export function resolveDiscordSetupAccountConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): { accountId: string; config: DiscordAccountConfig } {
const accountId = normalizeAccountId(params.accountId ?? DEFAULT_ACCOUNT_ID);
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultDiscordSetupAccountId(params.cfg),
);
return {
accountId,
config: mergeDiscordAccountConfig(params.cfg, accountId),

View File

@@ -10,6 +10,7 @@ import {
type ResolvedDiscordAccount,
} from "./accounts.js";
import { DiscordChannelConfigSchema } from "./config-schema.js";
import { discordDoctor } from "./doctor.js";
import {
createScopedChannelConfigAdapter,
getChatChannelMeta,
@@ -47,6 +48,8 @@ export function createDiscordPluginBase(params: {
| "meta"
| "setupWizard"
| "capabilities"
| "commands"
| "doctor"
| "streaming"
| "reload"
| "configSchema"
@@ -65,6 +68,13 @@ export function createDiscordPluginBase(params: {
media: true,
nativeCommands: true,
},
commands: {
nativeCommandsAutoEnabled: true,
nativeSkillsAutoEnabled: true,
resolveNativeCommandName: ({ commandKey, defaultName }) =>
commandKey === "tts" ? "voice" : defaultName,
},
doctor: discordDoctor,
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
@@ -89,6 +99,8 @@ export function createDiscordPluginBase(params: {
| "meta"
| "setupWizard"
| "capabilities"
| "commands"
| "doctor"
| "streaming"
| "reload"
| "configSchema"

View File

@@ -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", () => {

View File

@@ -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],
});

View File

@@ -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),
@@ -104,6 +133,17 @@ vi.mock("../monitor/agent-components.deps.runtime.js", () => {
};
});
vi.mock("../interactive-dispatch.js", async () => {
const actual = await vi.importActual<typeof import("../interactive-dispatch.js")>(
"../interactive-dispatch.js",
);
return {
...actual,
dispatchDiscordPluginInteractiveHandler: (...args: unknown[]) =>
dispatchPluginInteractiveHandlerMock(...args),
};
});
export function resetDiscordComponentRuntimeMocks() {
dispatchPluginInteractiveHandlerMock.mockReset().mockResolvedValue({
matched: false,

View File

@@ -1,19 +1,29 @@
export async function createConfiguredBindingConversationRuntimeModuleMock(
type ConfiguredBindingConversationRuntimeModule = {
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<{
ensureConfiguredBindingRouteReady: (...args: unknown[]) => unknown;
resolveConfiguredBindingRoute: (...args: unknown[]) => unknown;
}>,
importOriginal: () => Promise<TModule>,
) {
const actual = await importOriginal();
return {
...actual,
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
params.ensureConfiguredBindingRouteReadyMock(...args),
resolveConfiguredBindingRoute: (...args: unknown[]) =>
params.resolveConfiguredBindingRouteMock(...args),
};
ensureConfiguredBindingRouteReady: (
...args: Parameters<TModule["ensureConfiguredBindingRouteReady"]>
) => params.ensureConfiguredBindingRouteReadyMock(...args),
resolveConfiguredBindingRoute: (
...args: Parameters<TModule["resolveConfiguredBindingRoute"]>
) => params.resolveConfiguredBindingRouteMock(...args),
} satisfies TModule;
}

View File

@@ -3,13 +3,13 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const runFfprobeMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<string>>());
const runFfmpegMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<void>>());
vi.mock("openclaw/plugin-sdk/temp-path", async (importOriginal) => {
vi.mock("openclaw/plugin-sdk/temp-path", async () => {
return {
resolvePreferredOpenClawTmpDir: () => "/tmp",
};
});
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
return {
runFfprobe: runFfprobeMock,
runFfmpeg: runFfmpegMock,

View File

@@ -87,16 +87,20 @@ vi.mock("./sdk-runtime.js", () => ({
}),
}));
vi.mock("openclaw/plugin-sdk/routing", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/routing")>();
vi.mock("openclaw/plugin-sdk/routing", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/routing")>(
"openclaw/plugin-sdk/routing",
);
return {
...actual,
resolveAgentRoute: resolveAgentRouteMock,
};
});
vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-runtime")>();
vi.mock("openclaw/plugin-sdk/agent-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/agent-runtime")>(
"openclaw/plugin-sdk/agent-runtime",
);
return {
...actual,
agentCommandFromIngress: agentCommandMock,

View File

@@ -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";

View File

@@ -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";

View File

@@ -289,8 +289,10 @@ vi.mock("./client.js", () => ({
createFeishuClient: mockCreateFeishuClient,
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
"openclaw/plugin-sdk/conversation-runtime",
);
return {
...actual,
resolveConfiguredBindingRoute: (params: unknown) =>

View File

@@ -565,6 +565,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
groups: {
resolveToolPolicy: resolveFeishuGroupToolPolicy,
},
conversationBindings: {
defaultTopLevelPlacement: "current",
},
mentions: {
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
},

View File

@@ -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;
}

View File

@@ -3,6 +3,7 @@ import {
type ChannelSetupAdapter,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import { resolveDefaultFeishuAccountId } from "./accounts.js";
import type { FeishuConfig } from "./types.js";
export function setFeishuNamedAccountEnabled(
@@ -30,7 +31,7 @@ export function setFeishuNamedAccountEnabled(
}
export const feishuSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => accountId?.trim() || DEFAULT_ACCOUNT_ID,
resolveAccountId: ({ cfg, accountId }) => accountId?.trim() || resolveDefaultFeishuAccountId(cfg),
applyAccountConfig: ({ cfg, accountId }) => {
const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {

View File

@@ -5,6 +5,7 @@ import {
createPluginSetupWizardStatus,
createTestWizardPrompter,
runSetupWizardConfigure,
runSetupWizardFinalize,
type WizardPrompter,
} from "../../../test/helpers/plugins/setup-wizard.js";
@@ -71,6 +72,28 @@ describe("feishu setup wizard", () => {
).toBe("work");
});
it("setup adapter uses configured defaultAccount when accountId is omitted", () => {
expect(
feishuPlugin.setup?.resolveAccountId?.({
cfg: {
channels: {
feishu: {
defaultAccount: "work",
accounts: {
work: {
appId: "work-app",
appSecret: "work-secret", // pragma: allowlist secret
},
},
},
},
} as never,
accountId: undefined,
input: {},
} as never),
).toBe("work");
});
it("does not throw when config appId/appSecret are SecretRef objects", async () => {
const text = vi
.fn()
@@ -151,6 +174,81 @@ describe("feishu setup wizard", () => {
appSecret: "work-secret",
});
});
it("uses configured defaultAccount for omitted finalize writes", async () => {
const prompter = createTestWizardPrompter({
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Enter Feishu App Secret") {
return "work-secret"; // pragma: allowlist secret
}
if (message === "Enter Feishu App ID") {
return "work-app";
}
if (message === "Feishu webhook path") {
return "/feishu/events";
}
if (message === "Group chat allowlist (chat_ids)") {
return "";
}
throw new Error(`Unexpected prompt: ${message}`);
}) as WizardPrompter["text"],
select: vi.fn(
async ({ message, initialValue }: { message: string; initialValue?: string }) => {
if (message === "Feishu connection mode") {
return initialValue ?? "websocket";
}
if (message === "Which Feishu domain?") {
return initialValue ?? "feishu";
}
if (message === "Group chat policy") {
return "disabled";
}
return initialValue ?? "websocket";
},
) as never,
note: vi.fn(async () => {}),
});
const setupWizard = feishuPlugin.setupWizard;
if (!setupWizard || !("finalize" in setupWizard) || !setupWizard.finalize) {
throw new Error("feishu setupWizard.finalize unavailable");
}
const result = await setupWizard.finalize({
cfg: {
channels: {
feishu: {
appId: "top-level-app",
appSecret: "top-level-secret", // pragma: allowlist secret
defaultAccount: "work",
accounts: {
work: {
appId: "",
},
},
},
},
} as never,
accountId: "work",
credentialValues: {},
forceAllowFrom: false,
prompter,
runtime: createNonExitingTypedRuntimeEnv<FeishuConfigureRuntime>(),
options: {},
});
expect(result && typeof result === "object" && "cfg" in result).toBe(true);
const nextCfg =
result && typeof result === "object" && "cfg" in result ? result.cfg : undefined;
expect(nextCfg?.channels?.feishu).toBeDefined();
expect(nextCfg?.channels?.feishu?.appId).toBe("top-level-app");
expect(nextCfg?.channels?.feishu?.appSecret).toBe("top-level-secret");
expect(nextCfg?.channels?.feishu?.accounts?.work).toMatchObject({
enabled: true,
appId: "work-app",
appSecret: "work-secret",
});
});
});
describe("feishu setup wizard status", () => {

View File

@@ -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 {
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.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,
},
},
});
}
@@ -82,7 +99,7 @@ function setFeishuGroupAllowFrom(
}
function isFeishuConfigured(cfg: OpenClawConfig): boolean {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const feishuCfg = ((cfg.channels?.feishu as FeishuConfig | undefined) ?? {}) as FeishuConfig;
const isAppIdConfigured = (value: unknown): boolean => {
const asString = normalizeString(value);
@@ -105,7 +122,7 @@ function isFeishuConfigured(cfg: OpenClawConfig): boolean {
isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret),
);
const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => {
const accountConfigured = Object.values(feishuCfg.accounts ?? {}).some((account) => {
if (!account || typeof account !== "object") {
return false;
}
@@ -275,7 +292,7 @@ export const feishuSetupWizard: ChannelSetupWizard = {
},
credentials: [],
finalize: async ({ cfg, accountId, prompter, options }) => {
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const resolvedAccountId = accountId ?? resolveDefaultFeishuAccountId(cfg);
const resolvedAccount = resolveFeishuAccount({ cfg, accountId: resolvedAccountId });
const scopedConfig = getScopedFeishuConfig(cfg, resolvedAccountId);
const resolved =

View File

@@ -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;
}) {

View File

@@ -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 () => {

View 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");
});
});

View File

@@ -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: {

View File

@@ -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,
);
};
}

View File

@@ -130,7 +130,9 @@ export function resolveGoogleChatAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedGoogleChatAccount {
const accountId = normalizeAccountId(params.accountId);
const accountId = normalizeAccountId(
params.accountId ?? params.cfg.channels?.["googlechat"]?.defaultAccount,
);
const baseEnabled = params.cfg.channels?.["googlechat"]?.enabled !== false;
const merged = mergeGoogleChatAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;

View File

@@ -32,8 +32,8 @@ vi.mock("./targets.js", () => ({
resolveGoogleChatOutboundSpace,
}));
vi.mock("../runtime-api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../runtime-api.js")>();
vi.mock("../runtime-api.js", async () => {
const actual = await vi.importActual<typeof import("../runtime-api.js")>("../runtime-api.js");
return {
...actual,
loadOutboundMediaFromUrl: (...args: Parameters<typeof actual.loadOutboundMediaFromUrl>) =>

View File

@@ -18,6 +18,7 @@ import {
} from "openclaw/plugin-sdk/directory-runtime";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
@@ -212,6 +213,12 @@ export const googlechatPlugin = createChatChannelPlugin({
},
},
actions: googlechatActions,
doctor: {
dmAllowFromMode: "nestedOnly",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
},
status: createComputedAccountStatusAdapter<ResolvedGoogleChatAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
collectStatusIssues: (accounts): ChannelStatusIssue[] =>
@@ -355,6 +362,7 @@ export const googlechatPlugin = createChatChannelPlugin({
chunker: chunkTextForOutbound,
chunkerMode: "markdown",
textChunkLimit: 4000,
sanitizeText: ({ text }) => sanitizeForPlainText(text),
resolveTarget: ({ to }) => {
const trimmed = to?.trim() ?? "";

View File

@@ -1,7 +1,7 @@
import {
addWildcardAllowFrom,
applySetupAccountConfigPatch,
createNestedChannelParsedAllowFromPrompt,
createPromptParsedAllowFromForAccount,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
@@ -25,16 +25,27 @@ const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
const USE_ENV_FLAG = "__googlechatUseEnv";
const AUTH_METHOD_FLAG = "__googlechatAuthMethod";
const promptAllowFrom = createNestedChannelParsedAllowFromPrompt({
channel,
section: "dm",
defaultAccountId: DEFAULT_ACCOUNT_ID,
enabled: true,
const promptAllowFrom = createPromptParsedAllowFromForAccount({
defaultAccountId: resolveDefaultGoogleChatAccountId,
message: "Google Chat allowFrom (users/<id> or raw email; avoid users/<email>)",
placeholder: "users/123456789, name@example.com",
parseEntries: (raw) => ({
entries: mergeAllowFromEntries(undefined, splitSetupEntries(raw)),
}),
getExistingAllowFrom: ({ cfg, accountId }) =>
resolveGoogleChatAccount({ cfg, accountId }).config.dm?.allowFrom ?? [],
applyAllowFrom: ({ cfg, accountId, allowFrom }) =>
applySetupAccountConfigPatch({
cfg,
channelKey: channel,
accountId,
patch: {
dm: {
...(resolveGoogleChatAccount({ cfg, accountId }).config.dm ?? {}),
allowFrom,
},
},
}),
});
const googlechatDmPolicy: ChannelSetupDmPolicy = {
@@ -93,13 +104,7 @@ export const googlechatSetupWizard: ChannelSetupWizard = {
unconfiguredHint: "needs auth",
includeStatusLine: true,
resolveConfigured: ({ cfg, accountId }) =>
accountId
? resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none"
: listGoogleChatAccountIds(cfg).some(
(resolvedAccountId) =>
resolveGoogleChatAccount({ cfg, accountId: resolvedAccountId }).credentialSource !==
"none",
),
resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none",
}),
introNote: {
title: "Google Chat setup",

View File

@@ -211,6 +211,28 @@ describe("googlechat setup", () => {
expect(status.configured).toBe(false);
});
it("reports configured state for the configured defaultAccount instead of any account", async () => {
const status = await googlechatStatus({
cfg: {
channels: {
googlechat: {
defaultAccount: "alerts",
accounts: {
default: {
serviceAccount: { client_email: "default@example.com" },
},
alerts: {},
},
},
},
} as OpenClawConfig,
accountOverrides: {},
options: {},
});
expect(status.configured).toBe(false);
});
it("reports account-scoped config keys for named accounts", () => {
expect(googlechatPlugin.setupWizard?.dmPolicy?.resolveConfigKeys?.({}, "alerts")).toEqual({
policyKey: "channels.googlechat.accounts.alerts.dm.policy",
@@ -249,6 +271,41 @@ describe("googlechat setup", () => {
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.policy).toBe("open");
});
it("uses configured defaultAccount for omitted allowFrom prompt context", async () => {
const prompter = {
note: vi.fn(async () => {}),
text: vi.fn(async () => "users/123456789"),
};
const next = await googlechatPlugin.setupWizard?.dmPolicy?.promptAllowFrom?.({
cfg: {
channels: {
googlechat: {
defaultAccount: "alerts",
dm: {
allowFrom: ["users/root"],
},
accounts: {
alerts: {
serviceAccount: { client_email: "bot@example.com" },
dm: {
allowFrom: ["users/alerts"],
},
},
},
},
},
} as OpenClawConfig,
// oxlint-disable-next-line typescript/no-explicit-any
prompter: prompter as any,
});
expect(next?.channels?.googlechat?.dm?.allowFrom).toEqual(["users/root"]);
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.allowFrom).toEqual([
"users/123456789",
]);
});
it('writes open DM policy to the named account and preserves inherited allowFrom with "*"', () => {
const next = googlechatPlugin.setupWizard?.dmPolicy?.setPolicy(
{
@@ -454,4 +511,24 @@ describe("resolveGoogleChatAccount", () => {
expect(resolved.config.dangerouslyAllowNameMatching).toBeUndefined();
expect(resolved.config.audienceType).toBe("app-url");
});
it("uses configured defaultAccount when accountId is omitted", () => {
const cfg: OpenClawConfig = {
channels: {
googlechat: {
defaultAccount: "alerts",
accounts: {
alerts: {
serviceAccountFile: "/tmp/alerts-sa.json",
},
},
},
},
};
const resolved = resolveGoogleChatAccount({ cfg });
expect(resolved.accountId).toBe("alerts");
expect(resolved.credentialSource).toBe("file");
expect(resolved.credentialsFile).toBe("/tmp/alerts-sa.json");
});
});

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