Compare commits

..

183 Commits

Author SHA1 Message Date
George Zhang
1358cba962 fix(cache): compact newest tool results first to preserve prompt cache prefix (#58036) Thanks @bcherny 2026-04-03 17:18:44 -07:00
Boris Cherny
6aa591ba56 fix(cache): compact newest tool results first to preserve prompt cache prefix
compactExistingToolResultsInPlace iterated front-to-back, replacing the
oldest tool results with placeholders when context exceeded 75%. This
rewrote messages[k] for small k, invalidating the provider prompt cache
from that point onward on every subsequent turn.

Reverse the loop to compact newest-first. The cached prefix stays intact;
the tradeoff is the model loses recent tool output instead of old, which
is acceptable since this guard only fires as an emergency measure past
the 75% threshold.
2026-04-03 17:15:52 -07:00
Peter Steinberger
d01cb5ecc6 docs: expand model fallback guide 2026-04-04 09:13:43 +09:00
Peter Steinberger
5bea93fd63 fix: restore gateway watch boot path 2026-04-04 01:10:49 +01:00
Peter Steinberger
fe72474153 fix: persist fallback overrides safely 2026-04-04 09:00:16 +09:00
Peter Steinberger
411282c36d docs(changelog): note Telegram picker fix (#60384) (thanks @sfuminya) 2026-04-04 08:58:50 +09:00
fumin
43272d27f8 fix(telegram): compare full provider/model in models picker 2026-04-04 08:58:50 +09:00
Vincent Koc
bb4e54ccf7 fix(ci): restore plugin sdk boundary seams 2026-04-04 08:51:57 +09:00
Vincent Koc
df83374a54 docs(changelog): summarize provider transport rollout 2026-04-04 08:34:23 +09:00
Peter Steinberger
236a9003b6 test(ci): fix logs cli gateway mock typing 2026-04-04 00:28:25 +01:00
Peter Steinberger
3a3fdf1920 fix(ci): restore plugin contract surfaces 2026-04-04 00:24:57 +01:00
Ted Li
ff62705206 fix(whatsapp): reset watchdog timeout after reconnect (#60007)
Merged via squash.

Prepared head SHA: 64223e5dd4
Co-authored-by: MonkeyLeeT <6754057+MonkeyLeeT@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-04-03 20:23:36 -03:00
Peter Steinberger
306fe841f5 fix(cli): add local logs fallback 2026-04-04 08:17:11 +09:00
Peter Steinberger
de2eaccfce fix: restore discord startup logging and boundary bootstrap 2026-04-04 00:16:10 +01:00
Peter Steinberger
0f18e44538 test: trim onboarding helper partial mock 2026-04-04 00:13:45 +01:00
Peter Steinberger
d02fc365b4 test(plugins): drop stale core test files 2026-04-04 00:11:54 +01:00
Peter Steinberger
ab318de8b7 test(plugins): finish moving contract coverage 2026-04-04 00:11:39 +01:00
Peter Steinberger
e4b5027c5e refactor(plugins): move extension seams into extensions 2026-04-04 00:10:16 +01:00
Tak Hoffman
c19321ed9e docs: trim PR template root-cause boilerplate 2026-04-03 18:08:55 -05:00
Agustin Rivera
ff607adc69 fix(sandbox): block home credential binds (#59157)
* fix(sandbox): block home credential binds
* fix(sandbox): harden blocked credential bind checks
2026-04-03 16:06:22 -07:00
Peter Steinberger
4540effd6c test(ci): make mattermost client retry deterministic 2026-04-03 23:55:39 +01:00
Efe Büken
5fc9918a20 fix(matrix): surface user-visible 'too large' marker when Matrix media exceeds size limit (#60289)
Merged via squash.

Prepared head SHA: f33dd49946
Co-authored-by: efe-arv <259833796+efe-arv@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-03 18:53:51 -04:00
Peter Steinberger
94fee8486f test: make mattermost reconnect tests deterministic 2026-04-03 23:46:19 +01:00
pgondhi987
e19dce0aed fix(hooks): harden before_tool_call hook runner to fail-closed on error [AI] (#59822)
* fix: address issue

* fix: address PR review feedback

* docs: add changelog entry for PR merge

* docs: normalize changelog entry placement

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-03 16:44:35 -06:00
Peter Steinberger
1322aa2ba2 test(ci): make mattermost reconnect deterministic 2026-04-03 23:30:33 +01:00
Peter Steinberger
2af05ac558 test: restore leaked timer spies in mattermost reconnect tests 2026-04-03 23:27:29 +01:00
Peter Steinberger
3d2734185b test: stabilize rebased auto-reply command checks 2026-04-03 23:11:34 +01:00
Gustavo Madeira Santana
9004ef65df Plugins: add install --force overwrite flag (#60544)
Merged via squash.

Prepared head SHA: 28ae50b615
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-03 18:09:14 -04:00
Tak Hoffman
3fd29e549d fix: honor tools default account 2026-04-03 17:06:50 -05:00
Peter Steinberger
ccf16a25d2 test: tolerate plain baileys exports in whatsapp hooks 2026-04-03 23:06:43 +01:00
Tak Hoffman
cc0987f7b1 fix: honor allowlist default account 2026-04-03 17:04:11 -05:00
Agustin Rivera
42ffdf882f fix(fetch): normalize guarded redirect handling (#59121)
* fix(fetch): align guarded redirect rewrites

* fix(fetch): tighten redirect coverage

* fix(fetch): add changelog entry
2026-04-03 15:03:18 -07:00
Tak Hoffman
393d8c7606 fix: honor approve default account 2026-04-03 17:01:58 -05:00
Tak Hoffman
5f1f43af4d fix: honor config write default account 2026-04-03 16:59:58 -05:00
Tak Hoffman
4d60d61dec fix: honor reset notice default account 2026-04-03 16:57:15 -05:00
Tak Hoffman
da68fa4079 fix: honor followup default account 2026-04-03 16:53:38 -05:00
Tak Hoffman
6ddc86a3d1 fix: honor session default delivery account 2026-04-03 16:51:34 -05:00
Peter Steinberger
30fd4c6cdb test: add default channel account stubs in subagent focus tests 2026-04-03 22:51:17 +01:00
Tak Hoffman
01534d9bd5 fix: honor acp projector default account 2026-04-03 16:49:16 -05:00
Peter Steinberger
381a865822 test: force real timers in mattermost reconnect tests 2026-04-03 22:47:19 +01:00
Tak Hoffman
2b54ce30ae fix: honor acp delivery default account 2026-04-03 16:46:25 -05:00
Tak Hoffman
bb649de1ad fix: honor subagent default account 2026-04-03 16:43:26 -05:00
Tak Hoffman
4518b9ea7a fix: honor acp reset default account 2026-04-03 16:39:50 -05:00
Peter Steinberger
32f9a7c7bc test(ci): stabilize discord model picker timers 2026-04-03 22:35:42 +01:00
Tak Hoffman
10062e8111 fix: honor plugin binding default account 2026-04-03 16:31:24 -05:00
Peter Steinberger
4fb0837220 test: relax qr dashboard cli exit assertion 2026-04-03 22:30:46 +01:00
Agustin Rivera
e8e7d1fab3 Keep non-interactive auth choices on trusted plugins (#59120)
* fix(onboard): ignore untrusted workspace auth choices

* fix(onboard): scope auth-choice inference to trusted plugins (#59120) (thanks @eleqtrizit)
2026-04-03 14:28:01 -07:00
Tak Hoffman
037da3ce34 fix: honor acp dispatch default account 2026-04-03 16:26:41 -05:00
Peter Steinberger
ee45a59b4e test: normalize owning npm path assertions 2026-04-03 22:25:34 +01:00
Tak Hoffman
932379b19f fix: honor acp spawn default account 2026-04-03 16:23:29 -05:00
Peter Steinberger
be1d31fa8a test(ci): fix windows update and task cleanup cases 2026-04-03 22:22:51 +01:00
Peter Steinberger
9f132fc1b0 test: stabilize qr dashboard ci assertion 2026-04-03 22:17:01 +01:00
Nyx
dc21e3bb1e fix(plugins): reuse active registry during tool resolution (#52262)
Merged via squash.

Prepared head SHA: 55982a6be6
Co-authored-by: PerfectPan <24316656+PerfectPan@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-03 17:16:33 -04:00
Peter Steinberger
5b29483ab1 test(ci): type-safe exec timeout stub 2026-04-03 22:14:59 +01:00
Tak Hoffman
001e0c1f65 fix: honor default account in plugin commands 2026-04-03 16:13:31 -05:00
Peter Steinberger
5a94909654 test(ci): stabilize exec timeout tests 2026-04-03 22:12:08 +01:00
solodmd
8ae8a5c174 config: skip empty string in raw redaction to avoid corrupting snapshot (#28214)
Merged via squash.

Prepared head SHA: 07ec5b77b1
Co-authored-by: solodmd <51304754+solodmd@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-03 17:11:06 -04:00
Tak Hoffman
759598f737 fix: honor default account in conversation bindings 2026-04-03 16:09:48 -05:00
Peter Steinberger
267b6f595c test: harden windows ci coverage 2026-04-03 22:09:34 +01:00
Peter Steinberger
361efd28c9 test(ci): preserve runtime module shape in qr tests 2026-04-03 22:05:25 +01:00
Peter Steinberger
66dfe18c36 fix(ci): avoid duplicate accountId spread 2026-04-03 21:59:44 +01:00
Nimrod Gutman
9b667fc534 fix(agents): cover default workspace fallback (#59858) (thanks @joelnishanth) 2026-04-03 23:57:58 +03:00
joelnishanth
94e170763e fix: respect agents.defaults.workspace for non-default agents (#59789) 2026-04-03 23:57:58 +03:00
Peter Steinberger
8343a11a6b fix(ci): type qr dashboard runtime mocks 2026-04-03 21:57:39 +01:00
Tak Hoffman
1c5a4d01c9 fix: preserve channel status account ids 2026-04-03 15:56:54 -05:00
Peter Steinberger
eb6698002c fix(ci): repair qr test typing and mattermost setup status 2026-04-03 21:55:33 +01:00
Peter Steinberger
51eb877a15 test(ci): stabilize qr cli runtime mocks 2026-04-03 21:53:02 +01:00
Tak Hoffman
db4d0c0abc fix: honor mattermost default setup status 2026-04-03 15:51:49 -05:00
Tak Hoffman
8e023ffd06 fix: honor imessage default setup status 2026-04-03 15:49:56 -05:00
Tak Hoffman
2e9cad224d fix: honor irc default setup status 2026-04-03 15:47:49 -05:00
Tak Hoffman
cfef9bf856 fix: honor feishu default setup status 2026-04-03 15:44:49 -05:00
Peter Steinberger
0204b8dd28 fix: stabilize live and docker test lanes 2026-04-03 21:43:36 +01:00
Tak Hoffman
5d3edb1d40 fix: honor nextcloud default setup status 2026-04-03 15:41:44 -05:00
Bruce MacDonald
5ec53fff0c feat(ollama): add bundled web search provider (#59318)
Merged via squash.

Prepared head SHA: 1ec105f356
Co-authored-by: BruceMacD <5853428+BruceMacD@users.noreply.github.com>
Co-authored-by: BruceMacD <5853428+BruceMacD@users.noreply.github.com>
Reviewed-by: @BruceMacD
2026-04-03 13:41:24 -07:00
Tak Hoffman
dbb0164934 fix: honor signal default setup status 2026-04-03 15:39:50 -05:00
Peter Steinberger
40ae49effa fix(ci): normalize line default-account setup checks 2026-04-03 21:39:01 +01:00
Peter Steinberger
f336f0b83c test: trim more helper partial mocks 2026-04-03 21:38:01 +01:00
mappel-nv
21e53aea9e Gateway: refresh websocket auth after secrets reload (#60323)
* Gateway: refresh websocket auth after secrets reload

* Gateway: always restore auth reload test globals

* chore: add changelog for websocket auth reload

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
2026-04-03 14:35:31 -06:00
Peter Steinberger
f3a6d13965 test: trim helper partial mocks 2026-04-03 21:34:42 +01:00
Tak Hoffman
183601c347 fix: honor line default setup status 2026-04-03 15:32:44 -05:00
Peter Steinberger
fa6e6603fa test(ci): harden cli and exec tests for shared workers 2026-04-03 21:30:47 +01:00
Tak Hoffman
5361b5cf04 fix: honor zalo default setup status 2026-04-03 15:27:59 -05:00
Tak Hoffman
8cb3316afb fix: honor bluebubbles default setup status 2026-04-03 15:24:29 -05:00
Peter Steinberger
63603876bf test: trim fs bridge partial mocks 2026-04-03 21:24:00 +01:00
Tak Hoffman
6317fce9cb fix: honor discord default setup status 2026-04-03 15:22:44 -05:00
Peter Steinberger
8158597f84 test(ci): make mattermost retry test deterministic 2026-04-03 21:22:16 +01:00
@zimeg
8d557c19d5 docs(slack): set always online to true in example app manifest 2026-04-03 13:21:15 -07:00
Tak Hoffman
f59c52bc16 fix: honor slack default setup status 2026-04-03 15:21:02 -05:00
Peter Steinberger
faff198777 test: trim control ui assets partial mocks 2026-04-03 21:19:14 +01:00
Peter Steinberger
fde573bab2 test(ci): reset mattermost websocket timers per test 2026-04-03 21:17:55 +01: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
853 changed files with 20188 additions and 18295 deletions

View File

@@ -35,19 +35,17 @@ If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta
- Related #
- [ ] This PR fixes a bug or regression
## Root Cause / Regression History (if applicable)
## Root Cause (if applicable)
For bug fixes or regressions, explain why this happened, not just what changed. Otherwise write `N/A`. If the cause is unclear, write `Unknown`.
- Root cause:
- Missing detection / guardrail:
- Prior context (`git blame`, prior PR, issue, or refactor if known):
- Why this regressed now:
- If unknown, what was ruled out:
- Contributing context (if known):
## Regression Test Plan (if applicable)
For bug fixes or regressions, name the smallest reliable test coverage that should have caught this. Otherwise write `N/A`.
For bug fixes or regressions, name the smallest reliable test coverage that should catch this. Otherwise write `N/A`.
- Coverage level that should have caught this:
- [ ] Unit test

View File

@@ -15,6 +15,9 @@ Docs: https://docs.openclaw.ai
- Plugins/browser seams: split browser and WhatsApp plugin-sdk seams into narrower browser, approval-auth, and target-helper facades so hot paths and owner tests avoid broader runtime fan-out. (#60376) Thanks @shakkernerd.
- Tests/runtime: trim local unit-test import/runtime fan-out across browser, WhatsApp, cron, task, and reply flows so owner suites start faster with lower shared-worker overhead while preserving the same focused behavior coverage. (#60249) Thanks @shakkernerd.
- Tests/secrets runtime: restore split secrets suite cache and env isolation cleanup so broader runs do not leak stale plugin or provider snapshot state. (#60395) Thanks @shakkernerd.
- Providers/Ollama: add bundled Ollama Web Search provider for key-free web_search via your configured Ollama host and `ollama signin`. (#59318) Thanks @BruceMacD.
- Plugins/install: add `openclaw plugins install --force` to overwrite existing plugin and hook-pack install targets without using the dangerous-code override flag. (#60544) Thanks @gumadeiras.
- Providers/transport: add shared proxy/TLS/auth-aware request transport support across model-provider paths, including Anthropic and Google native transport runtimes, so provider request overrides work beyond OpenAI-family traffic.
### Fixes
@@ -28,6 +31,7 @@ Docs: https://docs.openclaw.ai
- Telegram/local Bot API: thread `channels.telegram.apiRoot` through buffered reply-media and album downloads so self-hosted Bot API file paths stop falling back to `api.telegram.org` and 404ing. (#59544) Thanks @SARAMALI15792.
- Telegram/replies: preserve explicit topic targets when `replyTo` is present while still inheriting the current topic for same-chat replies without an explicit topic. (#59634) Thanks @dashhuang.
- Telegram/native commands: clean up metadata-driven progress placeholders when replies fall back, edits fail, or local exec approval prompts are suppressed. (#59300) Thanks @jalehman.
- Telegram/models: compare full provider/model refs in the Telegram picker so same-id models from other providers no longer show the wrong current-model checkmark. (#60384) Thanks @sfuminya.
- Media/request overrides: resolve shared and capability-filtered media request SecretRefs correctly and expose media transport override fields to schema-driven config consumers. (#59848) Thanks @vincentkoc.
- Providers/request overrides: stop advertising unsupported proxy and TLS transport settings on `models.providers.*.request`, and fail closed if unvalidated config tries to route LLM model-provider traffic through dead transport fields. (#59682) Thanks @vincentkoc.
- Discord/mentions: treat `@everyone` and `@here` as valid mention-gate triggers in guild preflight so mention-required bots still respond to those broadcasts. (#60343) Thanks @geekhuashan.
@@ -36,6 +40,7 @@ Docs: https://docs.openclaw.ai
- Ollama/auth: prefer real cloud auth over local marker during model auth resolution so cloud-backed Ollama auth does not get shadowed by stale local-only markers.
- Plugins/Kimi Coding: parse tagged Kimi tool-call text into structured tool calls on the provider stream path so tools execute instead of echoing raw markup. (#60051) Thanks @obviyus.
- Channels/passive hooks: emit passive message hooks for mention-skipped Telegram and Signal group messages when `ingest` is enabled, including wildcard/default fallback and per-group override handling. (#60018) Thanks @obviyus.
- Providers/compat: stop forcing OpenAI-only payload defaults on proxy and custom OpenAI-compatible routes, and preserve native vendor-specific reasoning, tool, and streaming behavior for Anthropic-compatible, Moonshot, Mistral, ModelStudio, OpenRouter, xAI, Z.ai, and other routed provider paths.
- Plugins/manifest registry: stop warning when an explicit manifest `id` intentionally differs from the discovery hint. (#59185) Thanks @samzong.
- WhatsApp/streaming: honor `channels.whatsapp.blockStreaming` again for inbound auto-replies so progressive block replies can be enabled explicitly instead of being forced to final-only delivery. Thanks @mcaxtr.
- Auth/failover: shorten `auth_permanent` lockouts, add dedicated config knobs for permanent-auth backoff, and downgrade ambiguous auth-ish upstream incidents to retryable auth failures so providers recover automatically after transient outages. (#60404) Thanks @extrasmall0.
@@ -43,6 +48,7 @@ Docs: https://docs.openclaw.ai
- Plugins/runtime: reuse compatible active registries for `web_search` and `web_fetch` provider snapshot resolution so repeated runtime reads do not re-import the same bundled plugin set on each agent message. Related #48380.
- Infra/tailscale: ignore `OPENCLAW_TEST_TAILSCALE_BINARY` outside explicit test environments and block it from workspace `.env`, so test-only binary overrides cannot be injected through trusted repository state. (#58468) Thanks @eleqtrizit.
- Plugins/OpenAI: enable reference-image edits for `gpt-image-1` by routing edit calls to `/images/edits` with multipart image uploads, and update image-generation capability/docs metadata accordingly. Thanks @steipete.
- Cache/context guard: compact newest tool results first so the cached prompt prefix stays byte-identical and avoids full re-tokenization every turn past the 75% context threshold. (#58036) Thanks @bcherny.
- Agents/tools: include value-shape hints in missing-parameter tool errors so dropped, empty-string, and wrong-type write payloads are easier to diagnose from logs. (#55317) Thanks @priyansh19.
- Android/assistant: keep queued App Actions prompts pending when auto-send enqueue is rejected, so transient chat-health drops do not silently lose the assistant request. Thanks @obviyus.
- Plugins/startup: migrate legacy `tools.web.search.<provider>` config before strict startup validation, and record plugin failure phase/timestamp so degraded plugin startup is easier to diagnose from logs and `plugins list`.
@@ -77,6 +83,7 @@ Docs: https://docs.openclaw.ai
- Plugins/allowlists: let explicit bundled chat channel enablement bypass `plugins.allow`, while keeping auto-enabled channel activation and startup sidecars behind restrictive allowlists. (#60233) Thanks @dorukardahan.
- Allowlist/commands: require owner access for `/allowlist add` and `/allowlist remove` so command-authorized non-owners cannot mutate persisted allowlists. (#59836) Thanks @eleqtrizit.
- Control UI/skills: clear stale ClawHub results immediately when the search query changes, so debounced searches cannot keep outdated install targets visible. Related #60134.
- Fetch/redirects: normalize guarded redirect method rewriting and loop detection so SSRF-guarded requests match platform redirect behavior without missing loops back to the original URL. (#59121) Thanks @eleqtrizit.
- Discord/ack reactions: keep automatic ACK reaction auth on the active hydrated Discord account so SecretRef-backed and non-default-account reactions stop falling back to stale default config resolution. (#60081) Thanks @FunJim.
- Telegram/model switching: render non-default `/model` callback confirmations with HTML formatting so Telegram shows the selected model in bold instead of raw `**...**` markers. (#60042) Thanks @GitZhangChi.
- Plugins/update: allow `openclaw plugins update` to use `--dangerously-force-unsafe-install` for built-in dangerous-code false positives during plugin updates. (#60066) Thanks @huntharo.
@@ -85,6 +92,16 @@ Docs: https://docs.openclaw.ai
- Discord/voice: make READY auto-join fire-and-forget while keeping the shorter initial voice-connect timeout separate from the longer playback-start wait. (#60345) Thanks @geekhuashan.
- Agents/skills: add inherited `agents.defaults.skills` allowlists, make per-agent `agents.list[].skills` replace defaults instead of merging, and scope embedded, session, sandbox, and cron skill snapshots through the effective runtime agent. (#59992) Thanks @gumadeiras.
- Matrix/Telegram exec approvals: recover stored same-channel account bindings even when session reply state drifted to another channel, so foreign-channel approvals route to the bound account instead of fanning out or being rejected as ambiguous. (#60417) thanks @gumadeiras.
- Slack/app manifest: set `bot_user.always_online` to `true` in the onboarding and example Slack app manifest so the Slack app appears ready to respond.
- Gateway/websocket auth: refresh auth on new websocket connects after secrets reload so rotated gateway tokens take effect immediately without requiring a restart. (#60323) Thanks @mappel-nv.
- Onboarding/plugins: keep non-interactive auth-choice inference scoped to bundled and already-trusted plugins so untrusted workspace manifests cannot hijack built-in provider API-key flows. (#59120) Thanks @eleqtrizit.
- Agents/workspace: respect `agents.defaults.workspace` for non-default agents by resolving them under the configured base path instead of falling back to `workspace-<id>`. (#59858) Thanks @joelnishanth.
- Config/All Settings: keep the raw config view intact when sensitive fields are blank instead of corrupting or dropping the snapshot during redaction. (#28214) thanks @solodmd.
- Plugins/runtime: honor explicit capability allowlists during fallback speech, media-understanding, and image-generation provider loading so bundled capability plugins do not bypass restrictive `plugins.allow` config. (#52262) Thanks @PerfectPan.
- Hooks/tool policy: block tool calls when a `before_tool_call` hook crashes so hook failures fail closed instead of silently allowing execution. (#59822) Thanks @pgondhi987.
- Matrix/media: surface a dedicated `[matrix <kind> attachment too large]` marker for oversized inbound media instead of the generic unavailable marker, and classify size-limit failures with a typed Matrix error. (#60289) Thanks @efe-arv.
- WhatsApp/watchdog: reset watchdog timeout after reconnect so quiet channels no longer enter a tight reconnect loop from stale message timestamps carried across connection runs. (#60007) Thanks @MonkeyLeeT.
- Agents/fallback: persist selected fallback overrides before retry attempts start, prefer persisted overrides during live-session reconciliation, and keep provider-scoped auth-profile failover from snapping retries back to stale primary selections.
## 2026.4.2
@@ -114,6 +131,10 @@ Docs: https://docs.openclaw.ai
### Fixes
- Sandbox/security: block credential-path binds even when sandbox home paths resolve through canonical aliases, so agent containers cannot mount user secret stores through alternate home-directory paths. (#59157) Thanks @eleqtrizit.
## 2026.4.1-beta.1
- Providers/transport policy: centralize request auth, proxy, TLS, and header shaping across shared HTTP, stream, and websocket paths, block insecure TLS/runtime transport overrides, and keep proxy-hop TLS separate from target mTLS settings. (#59682) Thanks @vincentkoc.
- Providers/OpenRouter: gate documented OpenRouter attribution to native OpenRouter endpoints or the default route so custom proxy base URLs do not inherit OpenRouter request headers.
- Providers/Copilot: classify native GitHub Copilot API hosts in the shared provider endpoint resolver and harden token-derived proxy endpoint parsing so Copilot base URL routing stays centralized and fails closed on malformed hints. (#59644) Thanks @vincentkoc.
@@ -194,6 +215,7 @@ Docs: https://docs.openclaw.ai
- Telegram/local Bot API: preserve media MIME types for absolute-path downloads so local audio files still trigger transcription and other MIME-based handling. (#54603) Thanks @jzakirov
- Channels/WhatsApp: pass inbound message timestamp to model context so the AI can see when WhatsApp messages were sent. (#58590) Thanks @Maninae
- QQBot/voice: lazy-load `silk-wasm` in `audio-convert.ts` so qqbot still starts when the optional voice dependency is missing, while voice encode/decode degrades gracefully instead of crashing at module load time. (#58829) Thanks @WideLee.
- WhatsApp/groups: fix bot waking up on self-number quoted replies in groups with `selfChatMode` enabled. (#60148) Thanks @lurebat
## 2026.3.31

View File

@@ -1,5 +1,5 @@
{
"originHash" : "1c9c9d251b760ed3234ecff741a88eb4bf42315ad6f50ac7392b187cf226c16c",
"originHash" : "fb90e7b1977f43661ac91681d16da11f9ddd85630407ef170eaada0a6ee39972",
"pins" : [
{
"identity" : "axorcist",
@@ -24,7 +24,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.1.0"
}
},

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 it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -215,6 +215,10 @@
"source": "FAQ",
"target": "常见问题"
},
{
"source": "Ollama Web Search",
"target": "Ollama Web 搜索"
},
{
"source": "onboarding",
"target": "新手引导"

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
{
@@ -403,7 +403,7 @@ Notes:
"features": {
"bot_user": {
"display_name": "OpenClaw",
"always_online": false
"always_online": true
},
"app_home": {
"messages_tab_enabled": true,

View File

@@ -308,7 +308,7 @@ Manage extensions and their config:
- `openclaw plugins list` — discover plugins (use `--json` for machine output).
- `openclaw plugins inspect <id>` — show details for a plugin (`info` is an alias).
- `openclaw plugins install <path|.tgz|npm-spec|plugin@marketplace>` — install a plugin (or add a plugin path to `plugins.load.paths`).
- `openclaw plugins install <path|.tgz|npm-spec|plugin@marketplace>` — install a plugin (or add a plugin path to `plugins.load.paths`; use `--force` to overwrite an existing install target).
- `openclaw plugins marketplace list <marketplace>` — list marketplace entries before install.
- `openclaw plugins enable <id>` / `disable <id>` — toggle `plugins.entries.<id>.enabled`.
- `openclaw plugins doctor` — report plugin load errors.

View File

@@ -48,6 +48,7 @@ capabilities.
```bash
openclaw plugins install <package> # ClawHub first, then npm
openclaw plugins install clawhub:<package> # ClawHub only
openclaw plugins install <package> --force # overwrite existing install
openclaw plugins install <package> --pin # pin version
openclaw plugins install <package> --dangerously-force-unsafe-install
openclaw plugins install <path> # local path
@@ -58,6 +59,10 @@ openclaw plugins install <plugin> --marketplace <name> # marketplace (explicit)
Bare package names are checked against ClawHub first, then npm. Security note:
treat plugin installs like running code. Prefer pinned versions.
`--force` reuses the existing install target and overwrites an already-installed
plugin or hook pack in place. Use it when you are intentionally reinstalling
the same id from a new local path, archive, ClawHub package, or npm artifact.
`--dangerously-force-unsafe-install` is a break-glass option for false positives
in the built-in dangerous-code scanner. It allows the install to continue even
when the built-in scanner reports `critical` findings, but it does **not**
@@ -157,6 +162,9 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
openclaw plugins install -l ./my-plugin
```
`--force` is not supported with `--link` because linked installs reuse the
source path instead of copying over a managed install target.
Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in
`plugins.installs` while keeping the default behavior unpinned.

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

@@ -3,6 +3,7 @@ summary: "How OpenClaw rotates auth profiles and falls back across models"
read_when:
- Diagnosing auth profile rotation, cooldowns, or model fallback behavior
- Updating failover rules for auth profiles or models
- Understanding how session model overrides interact with fallback retries
title: "Model Failover"
---
@@ -15,6 +16,44 @@ OpenClaw handles failures in two stages:
This doc explains the runtime rules and the data that backs them.
## Runtime flow
For a normal text run, OpenClaw evaluates candidates in this order:
1. The currently selected session model.
2. Configured `agents.defaults.model.fallbacks` in order.
3. The configured primary model at the end when the run started from an override.
Inside each candidate, OpenClaw tries auth-profile failover before advancing to
the next model candidate.
High-level sequence:
1. Resolve the active session model and auth-profile preference.
2. Build the model candidate chain.
3. Try the current provider with auth-profile rotation/cooldown rules.
4. If that provider is exhausted with a failover-worthy error, move to the next
model candidate.
5. Persist the selected fallback override before the retry starts so other
session readers see the same provider/model the runner is about to use.
6. If the fallback candidate fails, roll back only the fallback-owned session
override fields when they still match that failed candidate.
7. If every candidate fails, throw a `FallbackSummaryError` with per-attempt
detail and the soonest cooldown expiry when one is known.
This is intentionally narrower than "save and restore the whole session". The
reply runner only persists the model-selection fields it owns for fallback:
- `providerOverride`
- `modelOverride`
- `authProfileOverride`
- `authProfileOverrideSource`
- `authProfileOverrideCompactionCount`
That prevents a failed fallback retry from overwriting newer unrelated session
mutations such as manual `/model` changes or session rotation updates that
happened while the attempt was running.
## Auth storage (keys + OAuth)
OpenClaw uses **auth profiles** for both API keys and OAuth tokens.
@@ -148,6 +187,102 @@ with `auth.cooldowns.overloadedProfileRotations`,
When a run starts with a model override (hooks or CLI), fallbacks still end at
`agents.defaults.model.primary` after trying any configured fallbacks.
### Candidate chain rules
OpenClaw builds the candidate list from the currently requested `provider/model`
plus configured fallbacks.
Rules:
- The requested model is always first.
- Explicit configured fallbacks are deduplicated but not filtered by the model
allowlist. They are treated as explicit operator intent.
- If the current run is already on a configured fallback in the same provider
family, OpenClaw keeps using the full configured chain.
- If the current run is on a different provider than config and that current
model is not already part of the configured fallback chain, OpenClaw does not
append unrelated configured fallbacks from another provider.
- When the run started from an override, the configured primary is appended at
the end so the chain can settle back onto the normal default once earlier
candidates are exhausted.
### Which errors advance fallback
Model fallback continues on:
- auth failures
- rate limits and cooldown exhaustion
- overloaded/provider-busy errors
- timeout-shaped failover errors
- billing disables
- `LiveSessionModelSwitchError`, which is normalized into a failover path so a
stale persisted model does not create an outer retry loop
- other unrecognized errors when there are still remaining candidates
Model fallback does not continue on:
- explicit aborts that are not timeout/failover-shaped
- context overflow errors that should stay inside compaction/retry logic
- a final unknown error when there are no candidates left
### Cooldown skip vs probe behavior
When every auth profile for a provider is already in cooldown, OpenClaw does
not automatically skip that provider forever. It makes a per-candidate decision:
- Persistent auth failures skip the whole provider immediately.
- Billing disables usually skip, but the primary candidate can still be probed
on a throttle so recovery is possible without restarting.
- The primary candidate may be probed near cooldown expiry, with a per-provider
throttle.
- Same-provider fallback siblings can be attempted despite cooldown when the
failure looks transient (`rate_limit`, `overloaded`, or unknown).
- Transient cooldown probes are limited to one per provider per fallback run so
a single provider does not stall cross-provider fallback.
## Session overrides and live model switching
Session model changes are shared state. The active runner, `/model` command,
compaction/session updates, and live-session reconciliation all read or write
parts of the same session entry.
That means fallback retries have to coordinate with live model switching:
- Before a fallback retry starts, the reply runner persists the selected
fallback override fields to the session entry.
- Live-session reconciliation prefers persisted session overrides over stale
runtime model fields.
- If the fallback attempt fails, the runner rolls back only the override fields
it wrote, and only if they still match that failed candidate.
This prevents the classic race:
1. Primary fails.
2. Fallback candidate is chosen in memory.
3. Session store still says the old primary.
4. Live-session reconciliation reads the stale session state.
5. The retry gets snapped back to the old model before the fallback attempt
starts.
The persisted fallback override closes that window, and the narrow rollback
keeps newer manual or runtime session changes intact.
## Observability and failure summaries
`runWithModelFallback(...)` records per-attempt details that feed logs and
user-facing cooldown messaging:
- provider/model attempted
- reason (`rate_limit`, `overloaded`, `billing`, `auth`, `model_not_found`, and
similar failover reasons)
- optional status/code
- human-readable error summary
When every candidate fails, OpenClaw throws `FallbackSummaryError`. The outer
reply runner can use that to build a more specific message such as "all models
are temporarily rate-limited" and include the soonest cooldown expiry when one
is known.
## Related config
See [Gateway configuration](/gateway/configuration) for:

View File

@@ -16,6 +16,8 @@ For model selection rules, see [/concepts/models](/concepts/models).
- Model refs use `provider/model` (example: `opencode/claude-opus-4-6`).
- If you set `agents.defaults.models`, it becomes the allowlist.
- CLI helpers: `openclaw onboard`, `openclaw models list`, `openclaw models set <provider/model>`.
- Fallback runtime rules, cooldown probes, and session-override persistence are
documented in [/concepts/model-failover](/concepts/model-failover).
- Provider plugins can inject model catalogs via `registerProvider({ catalog })`;
OpenClaw merges that output into `models.providers` before writing
`models.json`.

View File

@@ -1177,6 +1177,7 @@
"tools/gemini-search",
"tools/grok-search",
"tools/kimi-search",
"tools/ollama-search",
"tools/perplexity-search",
"tools/searxng-search",
"tools/tavily"

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. If you need the skill installed globally but only visible to some agents, keep the shared copy in `~/.openclaw/skills` and control visibility with `agents.defaults.skills` and `agents.list[].skills`. Only upstream-worthy edits should live in the repo and go out as PRs.
Use managed overrides instead of editing the repo copy. Put your changes in `~/.openclaw/skills/<name>/SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.openclaw/openclaw.json`). Precedence is `<workspace>/skills` → `<workspace>/.agents/skills` → `~/.agents/skills` → `~/.openclaw/skills` bundled → `skills.load.extraDirs`, so managed overrides still win over bundled skills without touching git. If you need the skill installed globally but only visible to some agents, keep the shared copy in `~/.openclaw/skills` and control visibility with `agents.defaults.skills` and `agents.list[].skills`. Only upstream-worthy edits should live in the repo and go out as PRs.
</Accordion>
<Accordion title="Can I load skills from a custom folder?">
Yes. Add extra directories via `skills.load.extraDirs` in `~/.openclaw/openclaw.json` (lowest precedence). Default precedence remains: `<workspace>/skills` → `~/.openclaw/skills` → bundled → `skills.load.extraDirs`. `clawhub` installs into `./skills` by default, which OpenClaw treats as `<workspace>/skills` on the next session. If the skill should only be visible to certain agents, pair that with `agents.defaults.skills` or `agents.list[].skills`.
Yes. Add extra directories via `skills.load.extraDirs` in `~/.openclaw/openclaw.json` (lowest precedence). Default precedence is `<workspace>/skills` → `<workspace>/.agents/skills` → `~/.agents/skills` → `~/.openclaw/skills` → bundled → `skills.load.extraDirs`. `clawhub` installs into `./skills` by default, which OpenClaw treats as `<workspace>/skills` on the next session. If the skill should only be visible to certain agents, pair that with `agents.defaults.skills` or `agents.list[].skills`.
</Accordion>
<Accordion title="How can I use different models for different tasks?">
@@ -1410,16 +1410,24 @@ for usage/billing and raise limits as needed.
</Accordion>
<Accordion title="How do I enable web search (and web fetch)?">
`web_fetch` works without an API key. `web_search` requires a key for your
selected provider (Brave, Gemini, Grok, Kimi, or Perplexity).
`web_fetch` works without an API key. `web_search` depends on your selected
provider:
- API-backed providers such as Brave, Exa, Firecrawl, Gemini, Grok, Kimi, Perplexity, and Tavily require their normal API key setup.
- Ollama Web Search is key-free, but it uses your configured Ollama host and requires `ollama signin`.
- DuckDuckGo is key-free, but it is an unofficial HTML-based integration.
**Recommended:** run `openclaw configure --section web` and choose a provider.
Environment alternatives:
- Brave: `BRAVE_API_KEY`
- Exa: `EXA_API_KEY`
- Firecrawl: `FIRECRAWL_API_KEY`
- Gemini: `GEMINI_API_KEY`
- Grok: `XAI_API_KEY`
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
- Tavily: `TAVILY_API_KEY`
```json5
{
@@ -2080,13 +2088,13 @@ for usage/billing and raise limits as needed.
1. Install Ollama from `https://ollama.com/download`
2. Pull a local model such as `ollama pull glm-4.7-flash`
3. If you want Ollama Cloud too, run `ollama signin`
3. If you want cloud models too, run `ollama signin`
4. Run `openclaw onboard` and choose `Ollama`
5. Pick `Local` or `Cloud + Local`
Notes:
- `Cloud + Local` gives you Ollama Cloud models plus your local Ollama models
- `Cloud + Local` gives you cloud models plus your local Ollama models
- cloud models such as `kimi-k2.5:cloud` do not need a local pull
- for manual switching, use `openclaw models list` and `openclaw models set ollama/<model>`

View File

@@ -1,814 +0,0 @@
---
title: feat: Add host-owned lane-oriented rich channel interface
type: feat
status: completed
date: 2026-04-02
---
# feat: Add host-owned lane-oriented rich channel interface
## Overview
Add an additive, host-owned channel interface so plugin authors can express
conversation intent once and let OpenClaw own the vendor-specific transport and
UI projection:
- reply in the same lane that produced the inbound event
- send a DM to the sender on that same channel
- declare rich experiences once and let OpenClaw render buttons, blocks, cards,
or selects when a channel supports them
- fall back to text-only guidance or command invocation when a rich surface is
unavailable or disabled, without asking the plugin to author per-channel
rescue paths
- keep Telegram Bot API, Discord Bot API, Slack Web API, Teams Graph details,
and Feishu/Lark request shapes out of third-party plugin code entirely
The design should sit on top of the existing `DeliveryContext`,
`ChannelOutboundAdapter`, and channel plugin contracts rather than replacing the
transport layer. Rich rendering and vendor-specific fallbacks should be owned by
OpenClaw channel adapters, not by plugins such as
`openclaw-codex-app-server`.
Implementation note: completed on `codex/host-owned-channel-interface`. The
suggested file lists in this plan were directional; the shipped work used a few
adjacent seams where that produced a smaller, safer change while preserving the
host-owned lane, DM, and semantic interaction goals.
## Problem Frame
OpenClaw already has the beginnings of a generic conversation model:
- `src/utils/delivery-context.ts` records lane identity as
`{ channel, to, accountId, threadId }`
- `src/infra/outbound/targets.ts` routes replies back to the current lane
- `src/channels/plugins/types.adapters.ts` defines a generic outbound adapter
with `sendPayload`, `sendText`, `sendMedia`, and `sendPoll`
- `src/interactive/payload.ts` already defines normalized interactive reply
blocks
- channels such as Slack, MS Teams, and Feishu/Lark already advertise richer
UI capabilities in their OpenClaw channel plugins
The notes in
`https://github.com/pwrdrvr/openclaw-codex-app-server/issues/76`
clarify that the current direction is still not strong enough. A plugin boundary
that says "use OpenClaw when possible, but call Telegram directly for the rest"
is still a broken boundary.
Today, the public plugin surface still nudges authors toward channel-specific
thinking, and some plugin integrations still end up owning vendor transport
knowledge:
- `src/plugins/runtime/types-channel.ts` exposes channel-namespaced runtime
helpers such as `discord`, `slack`, `signal`, and `line`
- `src/plugins/types.ts` models interactive handlers as channel-specific unions
with different response APIs for Telegram, Discord, and Slack
- plugins that want to say "reply here", "DM the sender", or "offer these
choices" still need to reason about channel ids, sender ids, per-channel
helper shapes, or which channels have buttons vs cards vs selects
- fallback behavior can drift into plugin-owned controller code, which leaks
token lookup, endpoint names, and raw request payloads across the host/plugin
boundary
This creates two mismatches:
- the intended plugin boundary is "talk to OpenClaw", but the actual experience
still leaks channel-specific transport concerns
- the intended UX is "rich when possible, graceful fallback otherwise", but the
current surfaces are optimized around channel-specific buttons rather than a
cross-channel intent model
The goal of this plan is therefore stronger than the original version:
- make the generic lane model the default plugin contract
- make the rich interaction contract host-owned, semantic-action-based, and
capability-driven
- ensure third-party plugins never need direct vendor bot or webhook API logic
for common messaging and interaction flows
## Requirements Trace
- R1. Plugins can reply to the exact inbound lane without using
channel-specific runtime methods.
- R2. Plugins can request a DM to the current sender through a generic contract
when the channel supports it.
- R3. Plugins can describe rich interaction intent through a normalized payload
and capability model instead of channel-specific button, block, or card APIs.
- R4. OpenClaw can project that rich intent to native channel UI when available
and degrade gracefully to text-only or command-driven flows when it is not.
- R4a. Plugins declare semantic actions and fallback affordances once, and
OpenClaw decides whether those become native buttons/cards/selects or
text-and-command guidance on each channel.
- R5. Third-party plugins do not need to know vendor endpoint names, token
lookup rules, raw request payloads, or fallback precedence for Telegram,
Discord, Slack, MS Teams, Feishu/Lark, or similar channels.
- R6. The new surface is additive and backwards compatible for bundled and
third-party plugins.
- R7. The interface is designed around cross-channel viability, with Slack, MS
Teams, Feishu/Lark, Telegram, and Discord as representative target channels.
- R8. Public SDK exports, docs, API baselines, and contract tests stay aligned
with the new surface.
## Scope Boundaries
- Do not require identical native UX on every channel; capability-aware
degradation is part of the design.
- Do not redesign the underlying `ChannelPlugin` transport model or replace
`ChannelOutboundAdapter` with a completely new transport abstraction.
- Do not move vendor-specific HTTP or SDK logic into third-party plugin code or
repo-local plugin controllers as a fallback strategy.
- Do not require plugins to choose a renderer family such as "Telegram buttons"
or "Teams cards" as part of normal authoring; renderer choice is host-owned.
- Do not force every advanced channel-specific admin or moderation action into
the new generic surface; those can remain channel-owned operations inside
OpenClaw.
- Do not change end-user message semantics beyond making the plugin authoring
surface more consistent and the fallback behavior more predictable.
## Context & Research
### Relevant Code and Patterns
- `src/utils/delivery-context.ts` is the current normalized route identity and
should remain the source of truth for lane resolution.
- `src/infra/outbound/targets.ts` already protects same-lane reply routing
across shared-session cases, especially for `dmScope=main`.
- `src/channels/plugins/types.adapters.ts` already has a generic outbound
contract worth preserving and lifting into a better plugin-facing API.
- `src/interactive/payload.ts` already provides normalized `text`, `buttons`,
and `select` blocks, which is a strong starting point for a richer
channel-neutral interaction contract.
- `src/channels/plugins/types.core.ts` already carries static channel
capabilities and message-tool discovery capabilities, which can become the
basis for capability negotiation rather than channel-name branching.
- `src/plugins/types.ts` shows that plugin commands are already lane-oriented in
spirit because they return `ReplyPayload` and core routes the reply on the
active conversation.
- `src/plugins/interactive.ts` and `src/plugins/interactive.test.ts` show the
existing channel-specific interaction dispatch layer that will need a
compatibility wrapper rather than a flag-day rewrite.
- `src/plugin-sdk/conversation-runtime.ts`,
`src/plugin-sdk/outbound-runtime.ts`, and
`src/plugin-sdk/interactive-runtime.ts` are existing focused public subpaths
and are a better evolution point than expanding the legacy
`src/plugin-sdk/channel-runtime.ts` shim.
- `extensions/slack/src/shared.ts` already documents Slack-native interactive
replies and text fallback guidance.
- `extensions/msteams/src/channel.ts` already advertises card capabilities and
same-lane reply targeting hints.
- `extensions/feishu/src/channel.ts` already exposes rich delivery hints and
channel-specific capabilities for cards, editing, threading, and reactions.
- `src/plugin-sdk/AGENTS.md` and `src/channels/AGENTS.md` both reinforce that
new seams should flow through `openclaw/plugin-sdk/*` instead of direct
`src/channels/**` exposure.
### Institutional Learnings
- No `docs/solutions/` knowledge base was present in this repo, so there were
no institutional learnings to carry forward for this topic.
### External References
- External research was intentionally skipped. The repo already has strong
local patterns for plugin runtime surfaces, channel contracts, and SDK export
discipline, and the main open question is architectural fit within OpenClaw's
existing boundaries rather than third-party framework behavior.
- The notes in `pwrdrvr/openclaw-codex-app-server#76` materially change the
design constraint: the plugin/controller boundary must not leak Telegram Bot
API, Discord Bot API, or similar vendor-specific transport details.
## Key Technical Decisions
- Add a host-owned rich interaction contract on top of `DeliveryContext`,
`ReplyPayload`, and `ChannelOutboundAdapter` rather than replacing channel
transports.
Rationale: the transport layer is already generic enough; the real gap is a
plugin-facing contract for lane routing, actor targeting, rich interaction
intent, and fallback semantics.
- Keep vendor-specific transport logic entirely inside OpenClaw channel
adapters.
Rationale: issue `#76` makes this explicit. A third-party plugin should not
need Telegram endpoint names, Discord route shapes, Slack block transport
details, or token resolution rules. If raw vendor fallback exists, it belongs
in OpenClaw's own channel implementations, not in plugin controller code.
- Evolve focused SDK subpaths such as `conversation-runtime`,
`outbound-runtime`, and `interactive-runtime` instead of widening the legacy
`channel-runtime` shim.
Rationale: `src/plugin-sdk/AGENTS.md` prefers narrow, purpose-built subpaths
and explicitly warns against broad convenience surfaces.
- Introduce a normalized lane/actor vocabulary plus a rich reply/fallback
vocabulary in the public contract.
Rationale: "reply here", "DM this sender", "offer these choices", and "fall
back to this text or command" are intent-level operations. Plugins should not
need to manually juggle `to`, `threadId`, callback ids, cards vs blocks vs
inline buttons, or provider-specific route strings to express those intents.
- Represent rich interactions around semantic actions with host-owned fallback
affordances.
Rationale: the stable cross-channel unit is not "Telegram inline button" or
"Teams Adaptive Card" but "user can choose action X". Labels, descriptions,
confirmation text, and fallback commands can project into many renderer
families while preserving one plugin-owned intent model.
- Model channel differences as capabilities and renderers, not as plugin-facing
vendor APIs.
Rationale: Slack, MS Teams, Feishu/Lark, Telegram, and Discord differ in what
they can render, but plugin authors should only choose among normalized
capabilities and fallback behavior, not channel-specific HTTP or SDK methods.
- Keep existing raw channel-specific runtime namespaces only as legacy
compatibility surfaces, not as the recommended design center.
Rationale: additive migration still matters, but docs and first-party example
code should stop steering plugin authors toward channel namespaces for common
flows.
- Migrate bundled plugins and docs to the new surface before considering any
future tightening of legacy plugin-facing helpers.
Rationale: OpenClaw should prove the new interface on its own rich channels
before asking external plugins to adopt it.
## Open Questions
### Resolved During Planning
- Should this be additive or breaking?
Resolution: additive only. Existing plugin shims stay working; the new
interface becomes the preferred path.
- Should the generic model replace `DeliveryContext`?
Resolution: no. `DeliveryContext` remains the canonical route identity; the
new interface should wrap it with a better authoring model.
- Should third-party plugins ever own raw vendor API fallbacks?
Resolution: no. If vendor-specific transport fallback is needed, it belongs
inside OpenClaw channel adapters, not in plugin controller code.
- Should the new interface live in a broad catch-all SDK file?
Resolution: no. Use focused `openclaw/plugin-sdk/*` subpaths and
`api.runtime.channel` helpers instead of widening `channel-runtime`.
### Deferred to Implementation
- Exact type and export names for the normalized lane, actor, and rich reply
objects.
This is a naming decision that should follow the final call sites once the
implementation touches the existing facades.
- Whether the rich reply contract should extend the existing
`InteractiveReply` shape directly or introduce a new richer wrapper that
embeds `InteractiveReply` plus fallback metadata.
This depends on how much of the current `ReplyPayload` structure can be reused
cleanly without overloading it.
- Whether the generic interaction API lands as a new registration method,
a new helper that wraps `registerInteractiveHandler`, or both.
The answer depends on which option yields the cleanest migration with the
least duplication in dispatch wiring.
- Which rich primitives should be part of the cross-channel baseline for v1:
text, buttons, select menus, cards, command invocations, or some subset.
This should be finalized against the actual Slack, MS Teams, Feishu/Lark, and
Telegram/Discord renderer constraints during implementation.
## High-Level Technical Design
> _This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce._
```mermaid
flowchart TB
A["Inbound event<br/>message / slash command / button"] --> B["Normalize context<br/>lane ref + sender ref + capability snapshot"]
B --> C["Plugin-facing API<br/>replyHere(richReply)<br/>dmSender(richReply)<br/>handleInteraction(action)"]
C --> D["Core projection layer<br/>capability negotiation + fallback planning"]
D --> E["Channel renderers<br/>blocks / cards / inline buttons / text+commands"]
E --> F["Slack / MS Teams / Feishu-Lark / Telegram / Discord / ..."]
D --> G["Fallback path<br/>text-only guidance or command invocation"]
```
The key shape change is that plugin code should act on three normalized
concepts:
- **Lane ref**: the current conversation lane, derived from the ambient inbound
context or an explicit conversation binding
- **Actor ref**: the sender or target actor within a channel, with an optional
DM route when the channel can resolve one
- **Rich reply intent**: structured content plus explicit fallback behavior that
OpenClaw can project to channel-native UI or plain text
- **Semantic action**: a stable plugin-defined action id plus presentation
metadata that can arrive from a native interaction callback or a fallback
command invocation
Those normalized concepts then flow through generic runtime helpers and
capability checks instead of channel-specific senders or plugin-owned vendor API
fallbacks.
## Implementation Units
```mermaid
flowchart TB
U1["Unit 1<br/>Define lane, actor, and rich reply contracts"]
U2["Unit 2<br/>Add capability projection and fallback planning"]
U3["Unit 3<br/>Wire host-owned lane reply and DM helpers"]
U4["Unit 4<br/>Normalize interaction return path"]
U5["Unit 5<br/>Migrate docs and first-party examples"]
U6["Unit 6<br/>Lock SDK drift and compatibility"]
U1 --> U2
U1 --> U3
U1 --> U4
U2 --> U5
U3 --> U5
U4 --> U5
U5 --> U6
```
- [x] **Unit 1: Define normalized lane, actor, and rich reply contracts**
**Goal:** Introduce the public vocabulary for "reply here", "DM this sender",
and "offer this rich experience with fallback" without exposing raw channel
implementation details.
**Requirements:** R1, R2, R3, R4, R5, R7
**Dependencies:** None
**Files:**
- Modify: `src/interactive/payload.ts`
- Modify: `src/plugins/types.ts`
- Modify: `src/plugins/runtime/types-channel.ts`
- Modify: `src/channels/plugins/types.adapters.ts`
- Modify: `src/channels/plugins/types.core.ts`
- Modify: `src/channels/plugins/types.plugin.ts`
- Modify: `src/plugin-sdk/channel-contract.ts`
- Modify: `src/plugin-sdk/conversation-runtime.ts`
- Modify: `src/plugin-sdk/interactive-runtime.ts`
- Test: `src/plugins/interactive.test.ts`
- Test: `src/channels/plugins/contracts/plugins-core.contract.test.ts`
**Approach:**
- Add normalized public types for lane refs, actor refs, rich reply intent, and
channel capability snapshots to the plugin-facing contract.
- Add a semantic-action type that describes the action id, user-facing label,
optional description, and fallback command/text affordance without committing
to a specific renderer family.
- Define a channel-owned resolver seam for sender-DM routing so generic plugin
code does not guess how to derive a DM target from provider-specific sender
ids.
- Extend the current interactive payload model so it can carry fallback intent
such as "render buttons if available, otherwise render this text and/or this
command affordance".
- Keep the transport-facing adapter model intact; the new types should wrap
existing route and reply primitives rather than replacing them.
- Extend `channel-contract`, `conversation-runtime`, and
`interactive-runtime` with the narrowest set of public types/helpers needed
for plugin authors and tests.
**Patterns to follow:**
- `src/utils/delivery-context.ts`
- `src/interactive/payload.ts`
- `src/channels/plugins/types.adapters.ts`
- `src/plugin-sdk/channel-contract.ts`
- `src/plugin-sdk/conversation-runtime.ts`
**Test scenarios:**
- Happy path: a normalized lane ref can represent a direct chat and a threaded
group chat without losing `accountId` or `threadId`.
- Happy path: an actor ref can carry sender identity plus a channel-resolved DM
target when the channel supports direct messaging.
- Happy path: a rich reply intent can describe structured choices plus a text or
command fallback without encoding any Telegram-, Discord-, Slack-, or Teams-
specific payload shape.
- Happy path: a semantic action can be represented once and later projected as
a button, card action, select option, or fallback command without changing
the plugin-authored action id.
- Edge case: channels that cannot derive a DM target report unsupported state
explicitly instead of returning an invalid route.
- Edge case: a channel that lacks rich interaction support can still consume the
fallback fields without losing the core message intent.
- Error path: malformed or incomplete route inputs are rejected at the
normalization layer rather than propagating broken targets to delivery.
- Integration: existing channel plugin contracts still typecheck when they do
not adopt the new normalized surfaces yet.
**Verification:**
- The new public types can be consumed from `openclaw/plugin-sdk/*` without
reaching into `src/channels/**`.
- The contract can describe cross-channel rich/fallback intent without exposing
vendor endpoint names, token concepts, or raw request payloads.
- [x] **Unit 2: Add capability projection and fallback planning**
**Goal:** Let OpenClaw project rich reply intent into channel-native UI when
available and into text or command-driven fallback when it is not.
**Requirements:** R3, R4, R5, R7
**Dependencies:** Unit 1
**Files:**
- Modify: `src/interactive/payload.ts`
- Modify: `src/channels/plugins/outbound/interactive.ts`
- Modify: `src/channels/plugins/types.core.ts`
- Modify: `src/channels/plugins/types.adapters.ts`
- Modify: `extensions/slack/src/blocks-render.ts`
- Modify: `extensions/msteams/src/channel.ts`
- Modify: `extensions/feishu/src/channel.ts`
- Modify: `extensions/telegram/src/button-types.ts`
- Modify: `extensions/discord/src/shared-interactive.ts`
- Test: `src/channels/plugins/outbound/interactive.test.ts`
- Test: `extensions/slack/src/blocks-render.test.ts`
- Test: `extensions/telegram/src/button-types.test.ts`
**Approach:**
- Add a host-owned projection layer that inspects channel capabilities and
chooses the best renderer for a rich reply intent.
- Keep the renderer choice inside OpenClaw so plugins provide intent and
fallback metadata, not vendor-specific block/card/button payloads.
- Expand the channel capability vocabulary so projection can distinguish between
coarse transport support and richer affordances such as buttons, single
select, cards, editable interactive messages, interaction acknowledgements,
and fallback command support.
- Support a minimum fallback contract that can degrade to plain text guidance or
a command-like invocation path when a channel lacks native rich UI.
- Treat Slack, MS Teams, and Feishu/Lark as proof points for richer renderers,
while Telegram/Discord remain important compatibility and interaction-return
channels.
**Patterns to follow:**
- `src/interactive/payload.ts`
- `src/channels/plugins/outbound/interactive.ts`
- `extensions/slack/src/blocks-render.ts`
- `extensions/msteams/src/channel.ts`
- `extensions/feishu/src/channel.ts`
**Test scenarios:**
- Happy path: the same rich reply intent renders as Slack blocks when Slack
interactive replies are enabled.
- Happy path: the same rich reply intent renders as an MS Teams card when Teams
advertises cards support.
- Happy path: the same rich reply intent renders as a Feishu/Lark card or other
supported rich surface when that capability is available.
- Edge case: when a channel lacks rich rendering support, the projection layer
chooses the declared text fallback rather than silently dropping interaction
intent.
- Edge case: when only a subset of rich primitives is supported, unsupported
blocks degrade cleanly without losing the core text content.
- Edge case: when a channel supports replies but not native interaction widgets,
the projection layer emits the declared command/text fallback rather than
pretending the action is unavailable.
- Error path: invalid or incomplete fallback metadata is rejected before it
reaches a channel renderer.
- Integration: renderer selection depends on advertised channel capabilities,
not channel-name branching in plugin code.
**Verification:**
- A plugin can describe one rich interaction and rely on OpenClaw to render it
appropriately across Slack, MS Teams, Feishu/Lark, Telegram, and Discord
paths.
- Rich replies can degrade to usable text or command flows without plugin-owned
vendor logic.
- [x] **Unit 3: Wire host-owned lane reply and sender-DM helpers**
**Goal:** Make `api.runtime.channel` capable of delivering generic lane replies
and sender-targeted DMs through the existing outbound stack, with all vendor
transport logic remaining inside OpenClaw.
**Requirements:** R1, R2, R5, R6
**Dependencies:** Unit 1
**Files:**
- Modify: `src/plugins/runtime/runtime-channel.ts`
- Modify: `src/plugins/runtime/types-channel.ts`
- Modify: `src/utils/delivery-context.ts`
- Modify: `src/infra/outbound/targets.ts`
- Modify: `src/infra/outbound/deliver.ts`
- Modify: `src/plugin-sdk/outbound-runtime.ts`
- Modify: `src/plugin-sdk/conversation-runtime.ts`
- Test: `src/utils/delivery-context.test.ts`
- Test: `src/infra/outbound/message.channels.test.ts`
- Test: `src/infra/outbound/targets.test.ts`
- Test: `src/plugins/runtime.channel-pin.test.ts`
**Approach:**
- Add runtime helpers that accept a normalized lane ref or actor ref and route
through the existing outbound adapter loader and target resolution path.
- Treat same-lane reply as the default operation, so plugin code does not need
to rebuild `to`, `accountId`, or `threadId` from scratch.
- Resolve sender DMs through channel-owned target resolution logic instead of
generic string manipulation or plugin-owned vendor API rules.
- Keep `DeliveryContext` as the canonical persisted route, with the new runtime
helpers acting as thin, intention-revealing wrappers around it.
**Execution note:** Start with characterization coverage around current
same-lane routing and thread preservation before introducing the new helper
entry points.
**Patterns to follow:**
- `src/infra/outbound/targets.ts`
- `src/infra/outbound/deliver.ts`
- `src/plugins/runtime/runtime-channel.ts`
- `src/plugin-sdk/outbound-runtime.ts`
**Test scenarios:**
- Happy path: a plugin runtime helper replies to the exact inbound lane for a
Telegram topic, a Slack thread, and a Discord channel without provider-
specific branching.
- Happy path: a sender-DM helper resolves a DM target for a channel that
supports direct messaging and delivers through the existing outbound adapter.
- Edge case: a same-lane reply in a shared `main` session still honors the
original turn source and does not leak into another channel's `lastChannel`.
- Error path: a sender-DM request on a channel without DM support returns a
structured unsupported result instead of a transport error.
- Integration: the runtime helper path still uses `loadChannelOutboundAdapter`
and preserves existing outbound chunking, payload normalization, and delivery
metadata.
**Verification:**
- Generic runtime helpers can deliver replies and DMs through the existing
outbound stack without channel-specific runtime shims.
- A third-party plugin no longer needs any Telegram Bot API, Discord Bot API,
or similar vendor transport fallback to cover common reply and DM flows.
- Existing route-safety guarantees around `turnSourceChannel` and persisted
`deliveryContext` remain intact.
- [x] **Unit 4: Normalize the interaction return path**
**Goal:** Provide a channel-neutral inbound interaction context so native rich
surfaces and text/command fallbacks can converge on the same plugin-facing
semantic action model.
**Requirements:** R3, R4, R5, R7
**Dependencies:** Unit 1
**Files:**
- Modify: `src/plugins/types.ts`
- Modify: `src/plugins/interactive.ts`
- Modify: `src/plugins/commands.ts`
- Modify: `src/plugin-sdk/interactive-runtime.ts`
- Modify: `src/channels/plugins/types.core.ts`
- Modify: `src/channels/plugins/types.adapters.ts`
- Test: `src/plugins/interactive.test.ts`
- Test: `src/plugins/captured-registration.test.ts`
**Approach:**
- Add a normalized interaction context that exposes lane ref, actor ref,
interaction payload, capability snapshot, and generic response helpers such as
`reply`, `edit`, `clearInteractive`, and `acknowledge` where supported.
- Keep legacy per-channel handler registrations as compatibility wrappers that
adapt into the new normalized context.
- Define a shared semantic action return path so native rich UI clicks and
text/command fallback invocations can both deliver the same action payload to
plugin code.
- Model channel-specific response differences as capabilities rather than as
distinct context unions wherever possible.
- Keep any truly channel-only interaction features behind host-owned adapter
seams rather than expanding the plugin-facing contract with new vendor APIs.
**Patterns to follow:**
- `src/plugins/interactive.ts`
- `src/plugins/types.ts`
- `src/plugin-sdk/interactive-runtime.ts`
- `src/interactive/payload.ts`
**Test scenarios:**
- Happy path: a normalized interaction handler can reply and edit buttons for a
Telegram callback, a Discord button interaction, and a Slack button action
through one shared response contract.
- Happy path: a text-only fallback command or invocation path can resolve to the
same semantic action payload as the native rich button/select path.
- Happy path: the same semantic action id is observed by plugin code whether it
originated from Slack, MS Teams, Feishu/Lark, Telegram, Discord, or a
text-command fallback path.
- Happy path: capability metadata correctly advertises whether `acknowledge`,
`edit`, `clearInteractive`, or follow-up responses are available.
- Edge case: a channel whose interaction model lacks one response action marks
it unsupported without breaking other response methods.
- Error path: duplicate callback/interactions still dedupe correctly after the
compatibility wrapper adapts into the normalized surface.
- Integration: legacy `registerInteractiveHandler` registrations continue to
dispatch correctly through the adapter layer, and fallback command invocations
can enter the same semantic action pipeline.
**Verification:**
- Bundled plugins can adopt a normalized interactive surface incrementally.
- The core dispatch layer still honors existing dedupe and conversation-binding
behavior across both native rich interactions and fallback command paths.
- [x] **Unit 5: Migrate docs and first-party examples to the new default**
**Goal:** Make the host-owned lane and rich reply interface the documented and
demonstrated primary path for plugin development.
**Requirements:** R1, R2, R3, R4, R5, R7, R8
**Dependencies:** Units 2, 3, and 4
**Files:**
- Modify: `docs/plugins/sdk-runtime.md`
- Modify: `docs/plugins/sdk-overview.md`
- Modify: `docs/plugins/sdk-channel-plugins.md`
- Modify: `docs/plugins/architecture.md`
- Modify: `docs/plugins/sdk-migration.md`
- Modify: `src/plugin-sdk/channel-runtime.ts`
- Modify: `src/plugin-sdk/conversation-runtime.ts`
- Modify: `src/plugin-sdk/outbound-runtime.ts`
- Modify: `src/plugin-sdk/interactive-runtime.ts`
- Modify: `extensions/slack/src/channel.ts`
- Modify: `extensions/msteams/src/channel.ts`
- Modify: `extensions/feishu/src/channel.ts`
- Test: `test/channel-outbounds.ts`
**Approach:**
- Update docs to show `reply here`, `DM sender`, and normalized interaction
handling as the preferred plugin path.
- Update docs and examples to show rich intent plus graceful fallback rather
than channel-specific button helper usage.
- Update docs and examples to show semantic actions plus fallback guidance
rather than channel-specific callback payload handling.
- Reframe `channel-runtime` as legacy compatibility only; do not add new
examples there.
- Use Slack, MS Teams, and Feishu/Lark as first-class example channels for rich
UI, and Telegram/Discord as important interaction-return and compatibility
channels.
- Add migration guidance for external plugins that explicitly says vendor bot or
webhook API fallback should not live in plugin code.
**Patterns to follow:**
- `docs/plugins/sdk-migration.md`
- `docs/plugins/sdk-overview.md`
- `src/plugin-sdk/AGENTS.md`
**Test scenarios:**
- Happy path: the documentation examples compile conceptually against the new
public surface and no longer require channel-specific send helpers for common
reply paths.
- Integration: representative rich channels can switch to the new helpers
without changing the user-visible delivery behavior for supported rich flows.
- Edge case: channels without a given rich surface are documented as graceful
fallback targets rather than as unsupported or plugin-owned transport escape
hatches.
- Test expectation: none -- this unit is primarily documentation and targeted
adoption, with behavior proven by the runtime and contract suites above.
**Verification:**
- The preferred docs path for new plugins uses the host-owned lane and rich
reply interface.
- At least one real rich channel example demonstrates channel-native rendering
and one fallback example demonstrates text/command degradation.
- [x] **Unit 6: Lock SDK drift, compatibility, and rollout safety**
**Goal:** Make the new interface durable by aligning exports, generated
baselines, and compatibility coverage with the new public contract.
**Requirements:** R6, R7, R8
**Dependencies:** Units 1 through 5
**Files:**
- Modify: `package.json`
- Modify: `scripts/lib/plugin-sdk-entrypoints.json`
- Modify: `src/plugin-sdk/entrypoints.ts`
- Modify: `src/plugin-sdk/api-baseline.ts`
- Modify: `docs/.generated/plugin-sdk-api-baseline.json`
- Modify: `docs/.generated/plugin-sdk-api-baseline.jsonl`
- Test: `src/channels/plugins/contracts/plugins-core.contract.test.ts`
- Test: `src/plugins/interactive.test.ts`
- Test: `src/utils/delivery-context.test.ts`
**Approach:**
- Publish any new or widened public SDK subpaths through the normal plugin-sdk
export pipeline and baseline checks.
- Add compatibility-focused tests that prove existing channel-specific helpers
still load while the new lane-oriented contract remains available.
- Add compatibility-focused tests that prove rich/fallback projection remains
host-owned and does not require plugin-facing vendor APIs.
- Treat API drift as a first-class part of the change, not a postscript.
- Capture any deprecation notices or migration breadcrumbs in the docs and API
contract instead of relying on tribal knowledge.
**Patterns to follow:**
- `src/plugin-sdk/AGENTS.md`
- `src/plugin-sdk/api-baseline.ts`
- `scripts/lib/plugin-sdk-entrypoints.json`
- `package.json`
**Test scenarios:**
- Happy path: newly exported SDK entrypoints appear in package exports, entrypoint
metadata, and generated API baselines together.
- Edge case: older plugins that still depend on channel-specific runtime helpers
continue to resolve those imports after the new interface lands.
- Edge case: new plugin-facing docs and type surfaces do not require Telegram,
Discord, Slack, Teams, or Feishu/Lark vendor transport details to remain
usable.
- Error path: export drift or missing baseline updates fail the contract checks
instead of slipping into release artifacts.
- Integration: the public SDK docs, entrypoint metadata, and emitted package
exports all describe the same surface.
**Verification:**
- The new public interface is represented consistently across source, generated
artifacts, and docs.
- Compatibility checks make it hard to accidentally ship a partial migration.
## System-Wide Impact
- **Interaction graph:** This work touches the plugin runtime (`api.runtime`),
plugin registration types, channel plugin contracts, outbound delivery,
interactive dispatch, SDK exports, rich UI renderers, fallback projection,
and plugin docs. It sits directly on the plugin/core boundary.
- **Error propagation:** Unsupported operations such as sender-DM on a
non-DM-capable channel or a rich UI primitive on a text-only channel should
return structured unsupported or fallback behavior at the normalized
interface boundary rather than leaking low-level transport errors.
- **State lifecycle risks:** Route identity must continue to respect
`DeliveryContext`, `turnSourceChannel`, and session-thread metadata so the new
abstraction does not reintroduce cross-lane reply races.
- **API surface parity:** The lane-oriented surface should work across commands,
tools, background plugin services, and interactive handlers. It should also
support both rich-native and text-fallback paths. If a concept only works in
one plugin entry point or one channel class, the abstraction is incomplete.
- **Integration coverage:** Cross-layer coverage is essential for same-lane
reply routing, sender-DM resolution, capability projection, interactive
dedupe, fallback command routing, and SDK export alignment.
- **Unchanged invariants:** `ChannelOutboundAdapter` and `DeliveryContext`
remain valid. This plan changes the preferred plugin authoring surface and
strengthens the host/plugin boundary; it does not move vendor transport logic
out of OpenClaw channel implementations.
## Risks & Dependencies
| Risk | Mitigation |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The new surface becomes another thin wrapper that still leaks channel-specific concepts everywhere | Keep lane ref, actor ref, rich reply intent, and capability vocabulary small and intention-focused; do not expose provider-specific route syntax or raw vendor payloads as the primary call shape |
| DM resolution semantics differ too much across channels | Make sender-DM resolution channel-owned and capability-driven; return unsupported explicitly where a safe generic mapping does not exist |
| Rich UI surfaces differ too much across Slack, Teams, Feishu/Lark, Telegram, and Discord | Normalize intent plus fallback, not a fake identical widget model; keep channel-specific rendering inside OpenClaw |
| Plugins reintroduce vendor fallback logic because the new host surface is incomplete | Make fallback projection a first-class host feature and document that vendor API logic does not belong in plugin code |
| Public SDK drift lands incompletely across exports, docs, and baselines | Treat export metadata, docs, and baseline updates as a dedicated implementation unit with contract coverage |
| Bundled or third-party plugins break during migration | Keep the change additive, preserve legacy registrations/helpers, and migrate first-party examples before considering any future deprecation |
## Documentation / Operational Notes
- Update the plugin SDK docs so new plugin authors see the host-owned
lane-oriented rich reply path first and legacy channel helpers second.
- Add migration guidance that explicitly explains that vendor bot/webhook API
fallback does not belong in plugin code.
- Document the expected graceful degradation model for channels with partial or
no rich-interaction support.
- Treat this as a public SDK change: package exports, generated API baseline,
and release-note communication must remain aligned.
## Sources & References
- Related issue: [pwrdrvr/openclaw-codex-app-server#76](https://github.com/pwrdrvr/openclaw-codex-app-server/issues/76)
- Related code: `src/utils/delivery-context.ts`
- Related code: `src/infra/outbound/targets.ts`
- Related code: `src/channels/plugins/types.adapters.ts`
- Related code: `src/interactive/payload.ts`
- Related code: `src/plugins/runtime/types-channel.ts`
- Related code: `src/plugins/types.ts`
- Related code: `src/plugins/interactive.ts`
- Related code: `src/plugin-sdk/conversation-runtime.ts`
- Related code: `src/plugin-sdk/outbound-runtime.ts`
- Related code: `src/plugin-sdk/interactive-runtime.ts`
- Related code: `extensions/slack/src/shared.ts`
- Related code: `extensions/msteams/src/channel.ts`
- Related code: `extensions/feishu/src/channel.ts`
- Related docs: `docs/plugins/sdk-runtime.md`
- Related docs: `docs/plugins/sdk-channel-plugins.md`
- Related docs: `docs/plugins/sdk-overview.md`
- Related docs: `docs/plugins/architecture.md`
- Related docs: `docs/plugins/sdk-migration.md`

View File

@@ -228,20 +228,6 @@ That means:
- channels should consume shared core capabilities instead of re-implementing
provider behavior ad hoc
For messaging UX specifically, plugins should express intent in terms of lanes,
actors, semantic actions, and fallback affordances. Channel plugins own the
projection to native transport UX:
- plugins reply on a lane instead of choosing a Telegram/Discord/Slack send API
- plugins DM actors instead of building provider-specific direct-message routes
- plugins describe semantic interactive actions plus text/command fallbacks
- channels render native buttons, selects, or cards when available and degrade
to text-only affordances when they are not
That keeps plugin code portable across Slack, Microsoft Teams, Feishu/Lark,
Telegram, Discord, and future channels while still allowing host-owned raw
channel runtimes as explicit escape hatches.
Examples:
- the bundled `openai` plugin owns OpenAI model-provider behavior and OpenAI
@@ -1100,9 +1086,6 @@ Recommended split:
ids inside `target` values or provider-specific params, not in generic SDK
fields.
At the plugin runtime boundary, normalize those provider-native ids into
`PluginLaneRef` and `PluginActorRef` before exposing them to plugin code.
## Config-backed directories
Plugins that derive directory entries from config should keep that logic in the

View File

@@ -23,55 +23,17 @@ pairing, reply threading, and outbound messaging.
## How channel plugins work
Channel plugins do not need their own send/edit/react tools. OpenClaw keeps one
shared `message` tool in core, and plugins should not own Telegram Bot API,
Discord API, Slack Web API, Microsoft Graph, or Feishu/Lark transport code
just to reply or render buttons. Your plugin owns:
shared `message` tool in core. Your plugin owns:
- **Config** — account resolution and setup wizard
- **Security** — DM policy and allowlists
- **Pairing** — DM approval flow
- **Session grammar** — how provider-specific conversation ids map to base chats, thread ids, and parent fallbacks
- **Outbound** — sending text, media, and polls to the platform
- **Rich projection** — translating semantic `ReplyPayload.interactive`
content into native buttons, selects, cards, or text/command fallbacks
- **Threading** — how replies are threaded
Core owns the shared message tool, prompt wiring, the outer session-key shape,
generic `:thread:` bookkeeping, semantic interactive payloads, and dispatch.
## Lane-oriented plugin contract
Plugin-facing interaction and outbound contracts are intentionally lane-based
instead of channel-API based:
- plugins reply on a `PluginLaneRef` when they want to continue in the same
chat/thread/topic
- plugins DM a `PluginActorRef` when they want a private follow-up
- plugins describe semantic actions and fallback affordances
- channel plugins translate that intent into native UX when available
This lets the same plugin code work across Slack, Microsoft Teams, Feishu/Lark,
Telegram, Discord, and future channels without importing channel transport APIs.
## Rich interactions and fallbacks
When a reply includes `interactive` content, channel plugins should treat that
payload as semantic intent rather than as "Telegram buttons" or "Discord
components".
- Render native buttons, selects, or cards when the channel supports them.
- Preserve semantic action ids so generic interaction handlers can route by
action rather than raw provider payloads.
- When the channel does not support the requested richness, fall back to text
and command affordances from the payload.
- When you emit command affordances, prefer `/namespace action-id` shapes so
the host can route those fallbacks back into the same
`registerInteractionHandler(...)` namespace on text-only paths.
The shared `ChannelCapabilities` contract now includes `richReplies` and
`interactionResponses` so a channel can declare whether it supports buttons,
selects, cards, command fallbacks, follow-ups, edit-in-place, and related
interaction behaviors.
generic `:thread:` bookkeeping, and dispatch.
If your platform stores extra scope inside conversation ids, keep that parsing
in the plugin with `messaging.resolveSessionConversation(...)`. That is the

View File

@@ -131,16 +131,6 @@ bundled plugin workspace, keep provider-owned helpers in that plugin's own
| `ensureAgentWorkspace` | `api.runtime.agent.ensureAgentWorkspace` |
| session store helpers | `api.runtime.agent.session.*` |
For messaging plugins, also move away from channel-branded send and
interaction contracts when possible:
| Legacy pattern | Modern equivalent |
| --- | --- |
| channel-specific outbound helper | `api.runtime.channel.outbound.sendToLane(...)` |
| channel-specific DM helper | `api.runtime.channel.outbound.sendToActorDm(...)` |
| channel-specific interactive callback handler | `api.registerInteractionHandler(...)` |
| raw button/select payload shaping | semantic `InteractiveReply` plus `interactive-runtime` fallback helpers |
</Step>
<Step title="Build and test">

View File

@@ -97,9 +97,6 @@ subpaths is in `scripts/lib/plugin-sdk-entrypoints.json`.
| Subpath | Key exports |
| --- | --- |
| `plugin-sdk/runtime-store` | `createPluginRuntimeStore` |
| `plugin-sdk/conversation-runtime` | Conversation binding and session binding helpers |
| `plugin-sdk/outbound-runtime` | `PluginLaneRef`, `PluginActorRef`, outbound identity/send-deps helpers |
| `plugin-sdk/interactive-runtime` | `InteractiveReply`, normalization, fallback, and action helpers |
| `plugin-sdk/config-runtime` | Config load/write helpers |
| `plugin-sdk/approval-runtime` | Exec/plugin approval helpers, approval-capability builders, auth/profile helpers, native routing/runtime helpers |
| `plugin-sdk/infra-runtime` | System event/heartbeat helpers |
@@ -150,28 +147,14 @@ methods:
### Infrastructure
| Method | What it registers |
| ---------------------------------------------- | --------------------------------- |
| `api.registerHook(events, handler, opts?)` | Event hook |
| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
| `api.registerCli(registrar, opts?)` | CLI subcommand |
| `api.registerService(service)` | Background service |
| `api.registerInteractionHandler(registration)` | Lane-oriented interaction handler |
| `api.registerInteractiveHandler(registration)` | Interactive handler |
`api.registerInteractionHandler(...)` is the preferred interaction surface for
new plugins. It gives handlers a normalized lane, sender, semantic action, and
capability-aware response helpers without forcing the plugin to branch on
Telegram, Discord, Slack, Teams, or Lark-specific payload shapes.
If a user invokes a text fallback command shaped like `/namespace action-id`
and no explicit plugin command claims that command name, OpenClaw routes it
through the same interaction handler namespace. That lets text-only channels
and degraded rich replies reuse the same semantic action flow.
`api.registerInteractiveHandler(...)` remains available as a compatibility path
for legacy channel-specific handlers.
| Method | What it registers |
| ---------------------------------------------- | --------------------- |
| `api.registerHook(events, handler, opts?)` | Event hook |
| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
| `api.registerCli(registrar, opts?)` | CLI subcommand |
| `api.registerService(service)` | Background service |
| `api.registerInteractiveHandler(registration)` | Interactive handler |
### CLI registration metadata

View File

@@ -330,49 +330,6 @@ api.runtime.tools.registerMemoryCli(/* ... */);
Channel-specific runtime helpers (available when a channel plugin is loaded).
Prefer the generic outbound helpers when you want to reply on the same lane a
message came from or DM the sender without owning channel transport details:
```typescript
await api.runtime.channel.outbound.sendToLane({
cfg: api.config,
lane: ctx.lane,
payload: {
text: "Build finished.",
},
});
await api.runtime.channel.outbound.sendToActorDm({
cfg: api.config,
actor: ctx.sender,
payload: {
text: "I sent the result privately.",
},
});
```
`api.runtime.channel.outbound` also exposes:
- `loadAdapter(channelId)` to work with the host-owned outbound adapter for a
specific channel when you are writing channel runtime code
- `resolveActorDmLane(actor)` to discover a sender's DM route before deciding
whether to DM or stay in-channel
For rich interactions, prefer semantic `ReplyPayload.interactive` data plus the
helpers exported from `openclaw/plugin-sdk/interactive-runtime`. Channel
plugins project that semantic payload into native buttons, selects, cards, or
fallback text/commands based on channel capabilities.
When a fallback command is rendered as `/namespace action-id`, the normal text
command pipeline can route that invocation back into the plugin's
`api.registerInteractionHandler(...)` namespace as long as no explicit plugin
command owns `/namespace`.
Raw namespaces such as `api.runtime.channel.telegram` or
`api.runtime.channel.discord` still exist for host-owned runtime code, but they
are escape hatches. New plugin-facing features should usually land on a generic
SDK subpath or on `api.runtime.channel.outbound` first.
## Storing runtime references
Use `createPluginRuntimeStore` to store the runtime reference for use outside

View File

@@ -235,6 +235,33 @@ To use cloud models, select **Cloud + Local** mode during setup. The wizard chec
You can also sign in directly at [ollama.com/signin](https://ollama.com/signin).
## Ollama Web Search
OpenClaw also supports **Ollama Web Search** as a bundled `web_search`
provider.
- It uses your configured Ollama host (`models.providers.ollama.baseUrl` when
set, otherwise `http://127.0.0.1:11434`).
- It is key-free.
- It requires Ollama to be running and signed in with `ollama signin`.
Choose **Ollama Web Search** during `openclaw onboard` or
`openclaw configure --section web`, or set:
```json5
{
tools: {
web: {
search: {
provider: "ollama",
},
},
},
}
```
For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-search).
## Advanced
### Reasoning models

View File

@@ -77,13 +77,18 @@ See [Memory](/concepts/memory).
### 4) Web search tool
`web_search` uses API keys and may incur usage charges depending on your provider:
`web_search` may incur usage charges depending on your provider:
- **Brave Search API**: `BRAVE_API_KEY` or `plugins.entries.brave.config.webSearch.apiKey`
- **Exa**: `EXA_API_KEY` or `plugins.entries.exa.config.webSearch.apiKey`
- **Firecrawl**: `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webSearch.apiKey`
- **Gemini (Google Search)**: `GEMINI_API_KEY` or `plugins.entries.google.config.webSearch.apiKey`
- **Grok (xAI)**: `XAI_API_KEY` or `plugins.entries.xai.config.webSearch.apiKey`
- **Kimi (Moonshot)**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `plugins.entries.moonshot.config.webSearch.apiKey`
- **Ollama Web Search**: key-free, but requires a reachable Ollama host plus `ollama signin`
- **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `plugins.entries.perplexity.config.webSearch.apiKey`
- **Tavily**: `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey`
- **DuckDuckGo**: key-free fallback (no API billing, but unofficial and HTML-based)
Legacy `tools.web.search.*` provider paths still load through the temporary compatibility shim, but they are no longer the recommended config surface.

View File

@@ -100,8 +100,8 @@ For a high-level overview, see [Onboarding (CLI)](/start/wizard).
- DM security: default is pairing. First DM sends a code; approve via `openclaw pairing approve <channel> <code>` or use allowlists.
</Step>
<Step title="Web search">
- Pick a provider: Perplexity, Brave, Gemini, Grok, or Kimi (or skip).
- Paste your API key (QuickStart auto-detects keys from env vars or existing config).
- Pick a supported provider such as Brave, Firecrawl, Gemini, Grok, Kimi, Ollama Web Search, Perplexity, or Tavily (or skip).
- API-backed providers can use env vars or existing config for quick setup; key-free providers use their provider-specific prerequisites instead.
- Skip with `--skip-search`.
- Configure later: `openclaw configure --section web`.
</Step>

View File

@@ -36,8 +36,9 @@ openclaw agents add <name>
<Tip>
CLI onboarding includes a web search step where you can pick a provider
(Perplexity, Brave, Gemini, Grok, or Kimi) and paste your API key so the agent
can use `web_search`. You can also configure this later with
such as Brave, Firecrawl, Gemini, Grok, Kimi, Ollama Web Search, Perplexity,
or Tavily. Some providers require an API key, while others are key-free. You
can also configure this later with
`openclaw configure --section web`. Docs: [Web tools](/tools/web).
</Tip>

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

@@ -0,0 +1,94 @@
---
summary: "Ollama Web Search via your configured Ollama host"
read_when:
- You want to use Ollama for web_search
- You want a key-free web_search provider
- You need Ollama Web Search setup guidance
title: "Ollama Web Search"
---
# Ollama Web Search
OpenClaw supports **Ollama Web Search** as a bundled `web_search` provider.
It uses Ollama's experimental web-search API and returns structured results
with titles, URLs, and snippets.
Unlike the Ollama model provider, this setup does not need an API key. It
does require:
- an Ollama host that is reachable from OpenClaw
- `ollama signin`
## Setup
<Steps>
<Step title="Start Ollama">
Make sure Ollama is installed and running.
</Step>
<Step title="Sign in">
Run:
```bash
ollama signin
```
</Step>
<Step title="Choose Ollama Web Search">
Run:
```bash
openclaw configure --section web
```
Then select **Ollama Web Search** as the provider.
</Step>
</Steps>
If you already use Ollama for models, Ollama Web Search reuses the same
configured host.
## Config
```json5
{
tools: {
web: {
search: {
provider: "ollama",
},
},
},
}
```
Optional Ollama host override:
```json5
{
models: {
providers: {
ollama: {
baseUrl: "http://ollama-host:11434",
},
},
},
}
```
If no explicit Ollama base URL is set, OpenClaw uses `http://127.0.0.1:11434`.
## Notes
- No API key field is required for this provider.
- OpenClaw warns during setup if Ollama is unreachable or not signed in, but
it does not block selection.
- Runtime auto-detect can fall back to Ollama Web Search when no higher-priority
credentialed provider is configured.
- The provider uses Ollama's experimental `/api/experimental/web_search`
endpoint.
## Related
- [Web Search overview](/tools/web) -- all providers and auto-detection
- [Ollama](/providers/ollama) -- Ollama model setup and cloud/local modes

View File

@@ -209,6 +209,7 @@ openclaw plugins doctor # diagnostics
openclaw plugins install <package> # install (ClawHub first, then npm)
openclaw plugins install clawhub:<pkg> # install from ClawHub only
openclaw plugins install <spec> --force # overwrite existing install
openclaw plugins install <path> # install from local path
openclaw plugins install -l <path> # link (no copy) for dev
openclaw plugins install <spec> --dangerously-force-unsafe-install
@@ -220,6 +221,10 @@ openclaw plugins enable <id>
openclaw plugins disable <id>
```
`--force` overwrites an existing installed plugin or hook pack in place.
It is not supported with `--link`, which reuses the source path instead of
copying over a managed install target.
`--dangerously-force-unsafe-install` is a break-glass override for false
positives from the built-in dangerous-code scanner. It allows plugin installs
and plugin updates to continue past built-in `critical` findings, but it still

View File

@@ -27,16 +27,18 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
## Quick start
<Steps>
<Step title="Get an API key">
Pick a provider and get an API key. See the provider pages below for
sign-up links.
<Step title="Choose a provider">
Pick a provider and complete any required setup. Some providers are
key-free, while others use API keys. See the provider pages below for
details.
</Step>
<Step title="Configure">
```bash
openclaw configure --section web
```
This stores the key and sets the provider. You can also set an env var
(e.g. `BRAVE_API_KEY`) and skip this step.
This stores the provider and any needed credential. You can also set an env
var (for example `BRAVE_API_KEY`) and skip this step for API-backed
providers.
</Step>
<Step title="Use it">
The agent can now call `web_search`:
@@ -78,6 +80,9 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
<Card title="Kimi" icon="moon" href="/tools/kimi-search">
AI-synthesized answers with citations via Moonshot web search.
</Card>
<Card title="Ollama Web Search" icon="globe" href="/tools/ollama-search">
Key-free search via your configured Ollama host. Requires `ollama signin`.
</Card>
<Card title="Perplexity" icon="search" href="/tools/perplexity-search">
Structured results with content extraction controls and domain filtering.
</Card>
@@ -91,18 +96,19 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
### Provider comparison
| Provider | Result style | Filters | API key |
| -------------------------------------- | -------------------------- | ------------------------------------------------ | ------------------------------------------- |
| [Brave](/tools/brave-search) | Structured snippets | Country, language, time, `llm-context` mode | `BRAVE_API_KEY` |
| [DuckDuckGo](/tools/duckduckgo-search) | Structured snippets | -- | None (key-free) |
| [Exa](/tools/exa-search) | Structured + extracted | Neural/keyword mode, date, content extraction | `EXA_API_KEY` |
| [Firecrawl](/tools/firecrawl) | Structured snippets | Via `firecrawl_search` tool | `FIRECRAWL_API_KEY` |
| [Gemini](/tools/gemini-search) | AI-synthesized + citations | -- | `GEMINI_API_KEY` |
| [Grok](/tools/grok-search) | AI-synthesized + citations | -- | `XAI_API_KEY` |
| [Kimi](/tools/kimi-search) | AI-synthesized + citations | -- | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
| [Perplexity](/tools/perplexity-search) | Structured snippets | Country, language, time, domains, content limits | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` |
| [SearXNG](/tools/searxng-search) | Structured snippets | Categories, language | None (self-hosted) |
| [Tavily](/tools/tavily) | Structured snippets | Via `tavily_search` tool | `TAVILY_API_KEY` |
| Provider | Result style | Filters | API key |
| ----------------------------------------- | -------------------------- | ------------------------------------------------ | ------------------------------------------- |
| [Brave](/tools/brave-search) | Structured snippets | Country, language, time, `llm-context` mode | `BRAVE_API_KEY` |
| [DuckDuckGo](/tools/duckduckgo-search) | Structured snippets | -- | None (key-free) |
| [Exa](/tools/exa-search) | Structured + extracted | Neural/keyword mode, date, content extraction | `EXA_API_KEY` |
| [Firecrawl](/tools/firecrawl) | Structured snippets | Via `firecrawl_search` tool | `FIRECRAWL_API_KEY` |
| [Gemini](/tools/gemini-search) | AI-synthesized + citations | -- | `GEMINI_API_KEY` |
| [Grok](/tools/grok-search) | AI-synthesized + citations | -- | `XAI_API_KEY` |
| [Kimi](/tools/kimi-search) | AI-synthesized + citations | -- | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
| [Ollama Web Search](/tools/ollama-search) | Structured snippets | -- | None (`ollama signin` required) |
| [Perplexity](/tools/perplexity-search) | Structured snippets | Country, language, time, domains, content limits | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` |
| [SearXNG](/tools/searxng-search) | Structured snippets | Categories, language | None (self-hosted) |
| [Tavily](/tools/tavily) | Structured snippets | Via `tavily_search` tool | `TAVILY_API_KEY` |
## Auto-detection
@@ -146,8 +152,8 @@ If native Codex search is enabled but the current model is not Codex-capable, Op
Provider lists in docs and setup flows are alphabetical. Auto-detection keeps a
separate precedence order:
If no `provider` is set, OpenClaw checks for API keys in this order and uses
the first one found:
If no `provider` is set, OpenClaw checks providers in this order and uses the
first one that is ready:
1. **Brave** -- `BRAVE_API_KEY` or `plugins.entries.brave.config.webSearch.apiKey`
2. **Gemini** -- `GEMINI_API_KEY` or `plugins.entries.google.config.webSearch.apiKey`
@@ -155,12 +161,14 @@ the first one found:
4. **Kimi** -- `KIMI_API_KEY` / `MOONSHOT_API_KEY` or `plugins.entries.moonshot.config.webSearch.apiKey`
5. **Perplexity** -- `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` or `plugins.entries.perplexity.config.webSearch.apiKey`
6. **Firecrawl** -- `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webSearch.apiKey`
7. **Tavily** -- `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey`
7. **Exa** -- `EXA_API_KEY` or `plugins.entries.exa.config.webSearch.apiKey`
8. **Tavily** -- `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey`
9. **DuckDuckGo** -- key-free HTML fallback with no account or API key
10. **Ollama Web Search** -- key-free fallback via your configured Ollama host; requires Ollama to be reachable and signed in with `ollama signin`
Key-free providers are checked after API-backed providers:
8. **DuckDuckGo** -- no key needed (auto-detect order 100)
9. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (auto-detect order 200)
11. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (auto-detect order 200)
If no provider is detected, it falls back to Brave (you will get a missing-key
error prompting you to configure one).
@@ -376,3 +384,4 @@ If you use tool profiles or allowlists, add `web_search`, `x_search`, or `group:
- [Web Fetch](/tools/web-fetch) -- fetch a URL and extract readable content
- [Web Browser](/tools/browser) -- full browser automation for JS-heavy sites
- [Grok Search](/tools/grok-search) -- Grok as the `web_search` provider
- [Ollama Web Search](/tools/ollama-search) -- key-free web search through your Ollama host

View File

@@ -0,0 +1,8 @@
export {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,
createAnthropicServiceTierWrapper,
resolveAnthropicBetas,
resolveAnthropicFastMode,
resolveAnthropicServiceTier,
} from "./stream-wrappers.js";

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

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

@@ -245,6 +245,34 @@ describe("bluebubbles setup surface", () => {
expect(resolved.configured).toBe(true);
});
it("uses configured defaultAccount for omitted setup configured state", async () => {
const { blueBubblesSetupWizard } = await import("./setup-surface.js");
const configured = await blueBubblesSetupWizard.status.resolveConfigured({
cfg: {
channels: {
bluebubbles: {
defaultAccount: "work",
serverUrl: "http://localhost:3000",
password: "top-secret",
accounts: {
alerts: {
serverUrl: "http://localhost:4000",
password: "alerts-secret",
},
work: {
serverUrl: "",
password: "",
},
},
},
},
} as OpenClawConfig,
});
expect(configured).toBe(false);
});
it('writes open policy state to the named account and preserves inherited allowFrom with "*"', async () => {
const { blueBubblesSetupWizard } = await import("./setup-surface.js");

View File

@@ -174,10 +174,7 @@ export const blueBubblesSetupWizard: ChannelSetupWizard = {
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg, accountId }) =>
(accountId ? [accountId] : listBlueBubblesAccountIds(cfg)).some((resolvedAccountId) => {
const account = resolveBlueBubblesAccount({ cfg, accountId: resolvedAccountId });
return account.configured;
}),
resolveBlueBubblesAccount({ cfg, accountId }).configured,
}),
resolveSelectionHint: ({ configured }) =>
configured ? "configured" : "iMessage via BlueBubbles app",

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

@@ -6,6 +6,10 @@ export * from "./src/components.js";
export * from "./src/directory-config.js";
export * from "./src/exec-approvals.js";
export * from "./src/group-policy.js";
export type {
DiscordInteractiveHandlerContext,
DiscordInteractiveHandlerRegistration,
} from "./src/interactive-dispatch.js";
export * from "./src/normalize.js";
export * from "./src/pluralkit.js";
export * from "./src/probe.js";

View File

@@ -0,0 +1,16 @@
export { createThreadBindingManager } from "./src/monitor/thread-bindings.manager.js";
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-config-contract.js";
export {
unsupportedSecretRefSurfacePatterns,
collectUnsupportedSecretRefConfigCandidates,
} from "./src/security-contract.js";
export { deriveLegacySessionChatType } from "./src/session-contract.js";
export type {
DiscordInteractiveHandlerContext,
DiscordInteractiveHandlerRegistration,
} from "./src/interactive-dispatch.js";
export { collectDiscordSecurityAuditFindings } from "./src/security-audit.js";

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

@@ -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,292 @@
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function normalizeDiscordStreamingAliases(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let updated = params.entry;
const hadLegacyStreamMode = updated.streamMode !== undefined;
const beforeStreaming = updated.streaming;
const resolved = resolveDiscordPreviewStreamMode(updated);
const shouldNormalize =
hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
if (!shouldNormalize) {
return { entry: updated, changed: false };
}
let changed = false;
if (beforeStreaming !== resolved) {
updated = { ...updated, streaming: resolved };
changed = true;
}
if (hadLegacyStreamMode) {
const { streamMode: _ignored, ...rest } = updated;
updated = rest;
changed = true;
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
);
}
if (typeof beforeStreaming === "boolean") {
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
params.changes.push(
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
);
}
if (
params.pathPrefix.startsWith("channels.discord") &&
resolved === "off" &&
hadLegacyStreamMode
) {
params.changes.push(
`${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming="partial" to opt in explicitly.`,
);
}
return { entry: updated, changed };
}
function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
const entry = asObjectRecord(value);
if (!entry) {
return false;
}
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" &&
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
);
}
function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacyDiscordStreamingAliases(account));
}
const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const;
function hasLegacyTtsProviderKeys(value: unknown): boolean {
const tts = asObjectRecord(value);
if (!tts) {
return false;
}
return LEGACY_TTS_PROVIDER_KEYS.some((key) => Object.prototype.hasOwnProperty.call(tts, key));
}
function hasLegacyDiscordAccountTtsProviderKeys(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((accountValue) => {
const account = asObjectRecord(accountValue);
const voice = asObjectRecord(account?.voice);
return hasLegacyTtsProviderKeys(voice?.tts);
});
}
function mergeMissing(target: Record<string, unknown>, source: Record<string, unknown>) {
for (const [key, value] of Object.entries(source)) {
if (value === undefined) {
continue;
}
const existing = target[key];
if (existing === undefined) {
target[key] = value;
continue;
}
if (
existing &&
typeof existing === "object" &&
!Array.isArray(existing) &&
value &&
typeof value === "object" &&
!Array.isArray(value)
) {
mergeMissing(existing as Record<string, unknown>, value as Record<string, unknown>);
}
}
}
function getOrCreateTtsProviders(tts: Record<string, unknown>): Record<string, unknown> {
const providers = asObjectRecord(tts.providers) ?? {};
tts.providers = providers;
return providers;
}
function mergeLegacyTtsProviderConfig(
tts: Record<string, unknown>,
legacyKey: string,
providerId: string,
): boolean {
const legacyValue = asObjectRecord(tts[legacyKey]);
if (!legacyValue) {
return false;
}
const providers = getOrCreateTtsProviders(tts);
const existing = asObjectRecord(providers[providerId]) ?? {};
const merged = structuredClone(existing);
mergeMissing(merged, legacyValue);
providers[providerId] = merged;
delete tts[legacyKey];
return true;
}
function migrateLegacyTtsConfig(
tts: Record<string, unknown> | null,
pathLabel: string,
changes: string[],
): boolean {
if (!tts) {
return false;
}
let changed = false;
if (mergeLegacyTtsProviderConfig(tts, "openai", "openai")) {
changes.push(`Moved ${pathLabel}.openai → ${pathLabel}.providers.openai.`);
changed = true;
}
if (mergeLegacyTtsProviderConfig(tts, "elevenlabs", "elevenlabs")) {
changes.push(`Moved ${pathLabel}.elevenlabs → ${pathLabel}.providers.elevenlabs.`);
changed = true;
}
if (mergeLegacyTtsProviderConfig(tts, "microsoft", "microsoft")) {
changes.push(`Moved ${pathLabel}.microsoft → ${pathLabel}.providers.microsoft.`);
changed = true;
}
if (mergeLegacyTtsProviderConfig(tts, "edge", "microsoft")) {
changes.push(`Moved ${pathLabel}.edge → ${pathLabel}.providers.microsoft.`);
changed = true;
}
return changed;
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "discord"],
message:
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
match: hasLegacyDiscordStreamingAliases,
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming.",
match: hasLegacyDiscordAccountStreamingAliases,
},
{
path: ["channels", "discord", "voice", "tts"],
message:
"channels.discord.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.voice.tts.providers.<provider> (auto-migrated on load).",
match: hasLegacyTtsProviderKeys,
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.accounts.<id>.voice.tts.providers.<provider> (auto-migrated on load).",
match: hasLegacyDiscordAccountTtsProviderKeys,
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.discord);
if (!rawEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updated = rawEntry;
let changed = false;
const streaming = normalizeDiscordStreamingAliases({
entry: updated,
pathPrefix: "channels.discord",
changes,
});
updated = streaming.entry;
changed = changed || streaming.changed;
const rawAccounts = asObjectRecord(updated.accounts);
if (rawAccounts) {
let accountsChanged = false;
const accounts = { ...rawAccounts };
for (const [accountId, rawAccount] of Object.entries(rawAccounts)) {
const account = asObjectRecord(rawAccount);
if (!account) {
continue;
}
const accountStreaming = normalizeDiscordStreamingAliases({
entry: account,
pathPrefix: `channels.discord.accounts.${accountId}`,
changes,
});
if (accountStreaming.changed) {
accounts[accountId] = accountStreaming.entry;
accountsChanged = true;
}
const accountVoice = asObjectRecord(accountStreaming.entry.voice);
if (
accountVoice &&
migrateLegacyTtsConfig(
asObjectRecord(accountVoice.tts),
`channels.discord.accounts.${accountId}.voice.tts`,
changes,
)
) {
accounts[accountId] = {
...accountStreaming.entry,
voice: accountVoice,
};
accountsChanged = true;
}
}
if (accountsChanged) {
updated = { ...updated, accounts };
changed = true;
}
}
const voice = asObjectRecord(updated.voice);
if (
voice &&
migrateLegacyTtsConfig(asObjectRecord(voice.tts), "channels.discord.voice.tts", changes)
) {
updated = { ...updated, voice };
changed = true;
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
discord: updated,
} as OpenClawConfig["channels"],
},
changes,
};
}

View File

@@ -1,15 +1,12 @@
import {
type ChannelDoctorAdapter,
type ChannelDoctorConfigMutation,
type ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import {
resolveDiscordPreviewStreamMode,
type OpenClawConfig,
} from "openclaw/plugin-sdk/config-runtime";
import {
collectProviderDangerousNameMatchingScopes,
isDiscordMutableAllowEntry,
} from "openclaw/plugin-sdk/runtime";
import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime";
import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js";
import { isDiscordMutableAllowEntry } from "./security-audit.js";
type DiscordNumericIdHit = { path: string; entry: number; safe: boolean };
@@ -517,11 +514,48 @@ function collectDiscordMutableAllowlistWarnings(cfg: OpenClawConfig): string[] {
];
}
function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
const entry = asObjectRecord(value);
if (!entry) {
return false;
}
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" &&
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
);
}
function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacyDiscordStreamingAliases(account));
}
const DISCORD_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "discord"],
message:
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
match: hasLegacyDiscordStreamingAliases,
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming.",
match: hasLegacyDiscordAccountStreamingAliases,
},
];
export const discordDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "topOrNested",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
legacyConfigRules: DISCORD_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: ({ cfg }) => normalizeDiscordCompatibilityConfig(cfg),
collectPreviewWarnings: ({ cfg, doctorFixCommand }) =>
collectDiscordNumericIdWarnings({

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

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

@@ -16,7 +16,6 @@ import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pi
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime";
import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
@@ -40,6 +39,7 @@ import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js";
import { createDiscordDraftStream } from "../draft-stream.js";
import { resolveDiscordPreviewStreamMode } from "../preview-streaming.js";
import { removeReactionDiscord } from "../send.js";
import { editMessageDiscord } from "../send.messages.js";
import {

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

@@ -6,7 +6,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import * as dispatcherModule from "openclaw/plugin-sdk/reply-dispatch-runtime";
import * as globalsModule from "openclaw/plugin-sdk/runtime-env";
import * as commandTextModule from "openclaw/plugin-sdk/text-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
import * as modelPickerModule from "./model-picker.js";
import { createModelsProviderData as createBaseModelsProviderData } from "./model-picker.test-utils.js";
@@ -248,12 +248,17 @@ function createDispatchSpy() {
describe("Discord model picker interactions", () => {
beforeEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
nativeCommandTesting.setDispatchReplyWithDispatcher(
dispatcherModule.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher,
);
});
afterEach(() => {
vi.useRealTimers();
});
it("registers distinct fallback ids for button and select handlers", () => {
const context = createModelPickerContext();
const button = createDiscordModelPickerFallbackButton(context);

View File

@@ -11,30 +11,65 @@ import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/config-runtim
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
type ConversationRuntimeModule = typeof import("openclaw/plugin-sdk/conversation-runtime");
type ResolveConfiguredBindingRoute = ConversationRuntimeModule["resolveConfiguredBindingRoute"];
type ConfiguredBindingRouteResult = ReturnType<ResolveConfiguredBindingRoute>;
type EnsureConfiguredBindingRouteReady =
ConversationRuntimeModule["ensureConfiguredBindingRouteReady"];
function createUnboundConfiguredRouteResult(): ConfiguredBindingRouteResult {
return {
bindingResolution: null,
route: {
agentId: "main",
channel: "discord",
accountId: "default",
sessionKey: SESSION_KEY,
mainSessionKey: SESSION_KEY,
lastRoutePolicy: "main",
matchedBy: "default",
},
};
}
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
vi.fn<() => Promise<{ ok: boolean; error?: string }>>(async () => ({ ok: true })),
vi.fn<EnsureConfiguredBindingRouteReady>(async () => ({ ok: true })),
);
const resolveConfiguredBindingRouteMock = vi.hoisted(() =>
vi.fn<
() => {
bindingResolution: {
record: {
conversation: {
channel: string;
accountId: string;
conversationId: string;
};
};
};
boundSessionKey: string;
route: {
agentId: string;
sessionKey: string;
};
} | null
>(() => null),
vi.fn<ResolveConfiguredBindingRoute>(() => createUnboundConfiguredRouteResult()),
);
type ConfiguredBindingRoute = ConfiguredBindingRouteResult;
type ConfiguredBindingResolution = NonNullable<ConfiguredBindingRoute["bindingResolution"]>;
function createConfiguredRouteResult(
params: Parameters<ResolveConfiguredBindingRoute>[0],
): ConfiguredBindingRoute {
return {
bindingResolution: {
record: {
bindingId: "binding-1",
targetSessionKey: SESSION_KEY,
targetKind: "session",
status: "active",
boundAt: Date.now(),
conversation: {
channel: "discord",
accountId: "default",
conversationId: "C1",
},
},
} as ConfiguredBindingResolution,
boundSessionKey: SESSION_KEY,
route: {
...params.route,
agentId: "main",
sessionKey: SESSION_KEY,
matchedBy: "binding.channel",
lastRoutePolicy: "session",
},
};
}
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
const { createConfiguredBindingConversationRuntimeModuleMock } =
await import("../test-support/configured-binding-runtime.js");
@@ -69,7 +104,7 @@ describe("discord native /think autocomplete", () => {
ensureConfiguredBindingRouteReadyMock.mockReset();
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
resolveConfiguredBindingRouteMock.mockReset();
resolveConfiguredBindingRouteMock.mockReturnValue(null);
resolveConfiguredBindingRouteMock.mockReturnValue(createUnboundConfiguredRouteResult());
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
fs.writeFileSync(
STORE_PATH,
@@ -154,22 +189,7 @@ describe("discord native /think autocomplete", () => {
it("falls back when a configured binding is unavailable", async () => {
const cfg = createConfig();
resolveConfiguredBindingRouteMock.mockReturnValue({
bindingResolution: {
record: {
conversation: {
channel: "discord",
accountId: "default",
conversationId: "C1",
},
},
},
boundSessionKey: SESSION_KEY,
route: {
agentId: "main",
sessionKey: SESSION_KEY,
},
});
resolveConfiguredBindingRouteMock.mockImplementation(createConfiguredRouteResult);
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
ok: false,
error: "acpx exited",

View File

@@ -1063,6 +1063,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
eventQueueListenerTimeoutMs: eventQueueOpts.listenerTimeout,
});
logDiscordStartupPhase({
runtime,
accountId: account.accountId,
phase: "client-start",
startAt: startupStartedAt,
gateway: lifecycleGateway,
});
const botIdentity =
botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? "");
runtime.log?.(

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,51 @@
export type DiscordPreviewStreamMode = "off" | "partial" | "block";
function normalizeStreamingMode(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
return normalized || null;
}
function parseStreamingMode(value: unknown): "off" | "partial" | "block" | "progress" | null {
const normalized = normalizeStreamingMode(value);
if (
normalized === "off" ||
normalized === "partial" ||
normalized === "block" ||
normalized === "progress"
) {
return normalized;
}
return null;
}
function parseDiscordPreviewStreamMode(value: unknown): DiscordPreviewStreamMode | null {
const parsed = parseStreamingMode(value);
if (!parsed) {
return null;
}
return parsed === "progress" ? "partial" : parsed;
}
export function resolveDiscordPreviewStreamMode(
params: {
streamMode?: unknown;
streaming?: unknown;
} = {},
): DiscordPreviewStreamMode {
const parsedStreaming = parseDiscordPreviewStreamMode(params.streaming);
if (parsedStreaming) {
return parsedStreaming;
}
const legacy = parseDiscordPreviewStreamMode(params.streamMode);
if (legacy) {
return legacy;
}
if (typeof params.streaming === "boolean") {
return params.streaming ? "partial" : "off";
}
return "off";
}

View File

@@ -0,0 +1,140 @@
import {
collectNestedChannelFieldAssignments,
collectNestedChannelTtsAssignments,
collectSimpleChannelFieldAssignments,
getChannelSurface,
isBaseFieldActiveForChannelSurface,
isEnabledFlag,
isRecord,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/security-runtime";
export const secretTargetRegistryEntries = [
{
id: "channels.discord.accounts.*.pluralkit.token",
targetType: "channels.discord.accounts.*.pluralkit.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.accounts.*.pluralkit.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.accounts.*.token",
targetType: "channels.discord.accounts.*.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.accounts.*.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.accounts.*.voice.tts.providers.*.apiKey",
targetType: "channels.discord.accounts.*.voice.tts.providers.*.apiKey",
configFile: "openclaw.json",
pathPattern: "channels.discord.accounts.*.voice.tts.providers.*.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
providerIdPathSegmentIndex: 6,
},
{
id: "channels.discord.pluralkit.token",
targetType: "channels.discord.pluralkit.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.pluralkit.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.token",
targetType: "channels.discord.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.voice.tts.providers.*.apiKey",
targetType: "channels.discord.voice.tts.providers.*.apiKey",
configFile: "openclaw.json",
pathPattern: "channels.discord.voice.tts.providers.*.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
providerIdPathSegmentIndex: 4,
},
] satisfies SecretTargetRegistryEntry[];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "discord");
if (!resolved) {
return;
}
const { channel: discord, surface } = resolved;
collectSimpleChannelFieldAssignments({
channelKey: "discord",
field: "token",
channel: discord,
surface,
defaults: params.defaults,
context: params.context,
topInactiveReason: "no enabled account inherits this top-level Discord token.",
accountInactiveReason: "Discord account is disabled.",
});
collectNestedChannelFieldAssignments({
channelKey: "discord",
nestedKey: "pluralkit",
field: "token",
channel: discord,
surface,
defaults: params.defaults,
context: params.context,
topLevelActive:
isBaseFieldActiveForChannelSurface(surface, "pluralkit") &&
isRecord(discord.pluralkit) &&
isEnabledFlag(discord.pluralkit),
topInactiveReason:
"no enabled Discord surface inherits this top-level PluralKit config or PluralKit is disabled.",
accountActive: ({ account, enabled }) =>
enabled && isRecord(account.pluralkit) && isEnabledFlag(account.pluralkit),
accountInactiveReason: "Discord account is disabled or PluralKit is disabled for this account.",
});
collectNestedChannelTtsAssignments({
channelKey: "discord",
nestedKey: "voice",
channel: discord,
surface,
defaults: params.defaults,
context: params.context,
topLevelActive:
isBaseFieldActiveForChannelSurface(surface, "voice") &&
isRecord(discord.voice) &&
isEnabledFlag(discord.voice),
topInactiveReason:
"no enabled Discord surface inherits this top-level voice config or voice is disabled.",
accountActive: ({ account, enabled }) =>
enabled && isRecord(account.voice) && isEnabledFlag(account.voice),
accountInactiveReason: "Discord account is disabled or voice is disabled for this account.",
});
}

View File

@@ -21,7 +21,7 @@ function coerceNativeSetting(value: unknown): boolean | "auto" | undefined {
return undefined;
}
function isDiscordMutableAllowEntry(raw: string): boolean {
export function isDiscordMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;

View File

@@ -0,0 +1,49 @@
type UnsupportedSecretRefConfigCandidate = {
path: string;
value: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export const unsupportedSecretRefSurfacePatterns = [
"channels.discord.threadBindings.webhookToken",
"channels.discord.accounts.*.threadBindings.webhookToken",
] as const;
export function collectUnsupportedSecretRefConfigCandidates(
raw: unknown,
): UnsupportedSecretRefConfigCandidate[] {
if (!isRecord(raw)) {
return [];
}
if (!isRecord(raw.channels) || !isRecord(raw.channels.discord)) {
return [];
}
const candidates: UnsupportedSecretRefConfigCandidate[] = [];
const discord = raw.channels.discord;
const threadBindings = isRecord(discord.threadBindings) ? discord.threadBindings : null;
if (threadBindings) {
candidates.push({
path: "channels.discord.threadBindings.webhookToken",
value: threadBindings.webhookToken,
});
}
const accounts = isRecord(discord.accounts) ? discord.accounts : null;
if (!accounts) {
return candidates;
}
for (const [accountId, account] of Object.entries(accounts)) {
if (!isRecord(account) || !isRecord(account.threadBindings)) {
continue;
}
candidates.push({
path: `channels.discord.accounts.${accountId}.threadBindings.webhookToken`,
value: account.threadBindings.webhookToken,
});
}
return candidates;
}

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

@@ -0,0 +1,3 @@
export function deriveLegacySessionChatType(sessionKey: string): "channel" | undefined {
return /^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/.test(sessionKey) ? "channel" : undefined;
}

View File

@@ -109,10 +109,7 @@ export function createDiscordSetupWizardBase(handlers: {
configuredScore: 2,
unconfiguredScore: 1,
resolveConfigured: ({ cfg, accountId }) =>
(accountId ? [accountId] : listDiscordSetupAccountIds(cfg)).some((resolvedAccountId) => {
const account = inspectDiscordSetupAccount({ cfg, accountId: resolvedAccountId });
return account.configured;
}),
inspectDiscordSetupAccount({ cfg, accountId }).configured,
}),
credentials: [
{

View File

@@ -54,3 +54,28 @@ describe("discordSetupWizard.dmPolicy", () => {
expect(next?.channels?.discord?.accounts?.alerts?.allowFrom).toEqual(["123", "*"]);
});
});
describe("discordSetupWizard.status", () => {
it("uses configured defaultAccount for omitted setup configured state", async () => {
const configured = await discordSetupWizard.status.resolveConfigured({
cfg: {
channels: {
discord: {
defaultAccount: "work",
token: "discord-root-token",
accounts: {
alerts: {
token: "discord-alerts-token",
},
work: {
token: "",
},
},
},
},
} as OpenClawConfig,
});
expect(configured).toBe(false);
});
});

View File

@@ -101,37 +101,4 @@ describe("buildDiscordInteractiveComponents", () => {
],
});
});
it("falls back to text guidance when widgets are unavailable", () => {
expect(
buildDiscordInteractiveComponents(
{
blocks: [
{
type: "buttons",
buttons: [
{
label: "Retry",
value: "retry",
fallback: { command: "/job retry", text: "Retry the job" },
},
],
},
],
fallbackText: "Use the fallback path.",
},
{
richReplies: {
buttons: false,
selects: false,
commandFallback: true,
},
},
),
).toEqual({
blocks: [
{ type: "text", text: "Use the fallback path.\n\nRetry: Retry the job (/job retry)" },
],
});
});
});

View File

@@ -1,8 +1,4 @@
import type { ChannelCapabilities } from "openclaw/plugin-sdk/channel-contract";
import {
projectInteractiveReplyForCapabilities,
reduceInteractiveReply,
} from "openclaw/plugin-sdk/interactive-runtime";
import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import type {
InteractiveButtonStyle,
InteractiveReply,
@@ -19,20 +15,9 @@ const DISCORD_INTERACTIVE_BUTTON_ROW_SIZE = 5;
export function buildDiscordInteractiveComponents(
interactive?: InteractiveReply,
capabilities?: Pick<ChannelCapabilities, "richReplies"> | null,
): DiscordComponentMessageSpec | undefined {
const projected = projectInteractiveReplyForCapabilities({
interactive,
capabilities: capabilities ?? {
richReplies: {
buttons: true,
selects: true,
commandFallback: true,
},
},
}).interactive;
const blocks = reduceInteractiveReply(
projected,
interactive,
[] as NonNullable<DiscordComponentMessageSpec["blocks"]>,
(state, block) => {
if (block.type === "text") {

View File

@@ -67,17 +67,6 @@ export function createDiscordPluginBase(params: {
threads: true,
media: true,
nativeCommands: true,
richReplies: {
buttons: true,
selects: true,
commandFallback: true,
},
interactionResponses: {
acknowledge: true,
clearInteractive: true,
editText: true,
followUp: true,
},
},
commands: {
nativeCommandsAutoEnabled: true,

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

View File

@@ -1,23 +1,29 @@
type ConfiguredBindingConversationRuntimeModule = {
ensureConfiguredBindingRouteReady: unknown;
resolveConfiguredBindingRoute: unknown;
ensureConfiguredBindingRouteReady: (...args: never[]) => unknown;
resolveConfiguredBindingRoute: (...args: never[]) => unknown;
};
export async function createConfiguredBindingConversationRuntimeModuleMock<
TModule extends ConfiguredBindingConversationRuntimeModule,
>(
params: {
ensureConfiguredBindingRouteReadyMock: (...args: unknown[]) => unknown;
resolveConfiguredBindingRouteMock: (...args: unknown[]) => unknown;
ensureConfiguredBindingRouteReadyMock: (
...args: Parameters<TModule["ensureConfiguredBindingRouteReady"]>
) => ReturnType<TModule["ensureConfiguredBindingRouteReady"]>;
resolveConfiguredBindingRouteMock: (
...args: Parameters<TModule["resolveConfiguredBindingRoute"]>
) => ReturnType<TModule["resolveConfiguredBindingRoute"]>;
},
importOriginal: () => Promise<TModule>,
): Promise<TModule> {
) {
const actual = await importOriginal();
return {
...actual,
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
params.ensureConfiguredBindingRouteReadyMock(...args),
resolveConfiguredBindingRoute: (...args: unknown[]) =>
params.resolveConfiguredBindingRouteMock(...args),
} as TModule;
ensureConfiguredBindingRouteReady: (
...args: Parameters<TModule["ensureConfiguredBindingRouteReady"]>
) => params.ensureConfiguredBindingRouteReadyMock(...args),
resolveConfiguredBindingRoute: (
...args: Parameters<TModule["resolveConfiguredBindingRoute"]>
) => params.resolveConfiguredBindingRouteMock(...args),
} satisfies TModule;
}

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

@@ -0,0 +1,6 @@
export { createFeishuThreadBindingManager } from "./src/thread-bindings.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export { messageActionTargetAliases } from "./src/message-action-contract.js";

View File

@@ -1,25 +1,48 @@
// Private runtime barrel for the bundled Feishu extension.
// Keep this barrel thin and aligned with the local extension surface.
// Keep this barrel thin and generic-only.
export type {
AllowlistMatch,
AnyAgentTool,
BaseProbeResult,
ChannelGroupContext,
ChannelMessageActionName,
ChannelMeta,
ChannelOutboundAdapter,
OpenClawConfig as ClawdbotConfig,
ChannelPlugin,
HistoryEntry,
OpenClawConfig,
OpenClawPluginApi,
OutboundIdentity,
PluginRuntime,
RuntimeEnv,
} from "openclaw/plugin-sdk/feishu";
ReplyPayload,
} from "openclaw/plugin-sdk/core";
export type { OpenClawConfig as ClawdbotConfig } from "openclaw/plugin-sdk/core";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/config-runtime";
export {
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
createActionGate,
createDedupeCache,
} from "openclaw/plugin-sdk/core";
export {
PAIRING_APPROVED_MESSAGE,
buildProbeChannelStatusSummary,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/feishu";
export * from "openclaw/plugin-sdk/feishu";
} from "openclaw/plugin-sdk/channel-status";
export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createReplyPrefixContext } from "openclaw/plugin-sdk/channel-reply-pipeline";
export {
evaluateSupplementalContextVisibility,
filterSupplementalContextItems,
resolveChannelContextVisibilityMode,
} from "openclaw/plugin-sdk/config-runtime";
export { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
export { createPersistentDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
export { normalizeAgentId } from "openclaw/plugin-sdk/routing";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export {
isRequestBodyLimitError,
readRequestBodyWithLimit,

View File

@@ -13,13 +13,6 @@ import {
FEISHU_APPROVAL_REQUEST_ACTION,
} from "./card-ux-approval.js";
const dispatchPluginInteractionActionMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({
dispatchPluginInteractionAction: (...args: unknown[]) =>
dispatchPluginInteractionActionMock(...args),
}));
// Mock account resolution
vi.mock("./accounts.js", () => ({
resolveFeishuAccount: vi.fn().mockReturnValue({ accountId: "mock-account" }),
@@ -96,7 +89,6 @@ describe("Feishu Card Action Handler", () => {
beforeEach(() => {
vi.clearAllMocks();
resetProcessedFeishuCardActionTokensForTests();
dispatchPluginInteractionActionMock.mockResolvedValue({ matched: false, handled: false });
});
it("handles card action with text payload", async () => {
@@ -143,36 +135,6 @@ describe("Feishu Card Action Handler", () => {
);
});
it("routes native interactive card actions into plugin interaction handlers", async () => {
dispatchPluginInteractionActionMock.mockResolvedValue({ matched: true, handled: true });
const event = createCardActionEvent({
token: "tok-plugin-1",
actionValue: {
oc: "interactive",
kind: "button",
actionId: "approval.approve",
value: "codex:approve.thread",
},
chatId: "chat1",
openId: "u123",
});
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
expect(dispatchPluginInteractionActionMock).toHaveBeenCalledWith(
expect.objectContaining({
data: "codex:approve.thread",
channel: "feishu",
accountId: "mock-account",
action: expect.objectContaining({
kind: "button",
actionId: "approval.approve",
}),
}),
);
expect(handleFeishuMessage).not.toHaveBeenCalled();
});
it("routes quick command actions with operator and conversation context", async () => {
const event = createStructuredQuickActionEvent({
token: "tok3",

View File

@@ -1,4 +1,3 @@
import { dispatchPluginInteractionAction } from "openclaw/plugin-sdk/plugin-runtime";
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
@@ -144,90 +143,6 @@ async function dispatchSyntheticCommand(params: {
});
}
async function dispatchPluginCardInteraction(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
accountId: string;
}): Promise<boolean> {
const actionValue = params.event.action.value;
if (actionValue?.oc !== "interactive") {
return false;
}
const rawData = typeof actionValue.value === "string" ? actionValue.value.trim() : "";
if (!rawData) {
return false;
}
const result = await dispatchPluginInteractionAction({
data: rawData,
channel: "feishu",
accountId: params.accountId,
interactionId: `feishu:${params.event.token}`,
lane: {
channel: "feishu",
to: resolveCallbackTarget(params.event),
accountId: params.accountId,
},
sender: {
channel: "feishu",
id: params.event.operator.open_id,
accountId: params.accountId,
dmLane: {
channel: "feishu",
to: `user:${params.event.operator.open_id}`,
accountId: params.accountId,
},
},
auth: {
isAuthorizedSender: true,
},
action: {
raw: rawData,
kind: actionValue.kind === "select" ? "select" : "button",
actionId:
typeof actionValue.actionId === "string" && actionValue.actionId.trim()
? actionValue.actionId.trim()
: undefined,
},
capabilities: {
acknowledge: false,
followUp: true,
editText: false,
clearInteractive: false,
deleteMessage: false,
},
respond: {
acknowledge: async () => {},
replyText: async ({ text }) => {
await sendMessageFeishu({
cfg: params.cfg,
to: resolveCallbackTarget(params.event),
text,
accountId: params.accountId,
});
},
followUpText: async ({ text }) => {
await sendMessageFeishu({
cfg: params.cfg,
to: resolveCallbackTarget(params.event),
text,
accountId: params.accountId,
});
},
editText: async ({ text }) => {
await sendMessageFeishu({
cfg: params.cfg,
to: resolveCallbackTarget(params.event),
text,
accountId: params.accountId,
});
},
clearInteractive: async () => {},
deleteMessage: async () => {},
},
});
return result.matched && result.handled;
}
async function sendInvalidInteractionNotice(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
@@ -272,16 +187,6 @@ export async function handleFeishuCardAction(params: {
}
try {
const handledPluginInteraction = await dispatchPluginCardInteraction({
cfg,
event,
accountId: account.accountId,
});
if (handledPluginInteraction) {
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
if (decoded.kind === "invalid") {
log(
`feishu[${account.accountId}]: rejected card action from ${event.operator.open_id}: ${decoded.reason}`,

View File

@@ -51,6 +51,7 @@ import type {
import { createFeishuClient } from "./client.js";
import { FeishuConfigSchema } from "./config-schema.js";
import {
buildFeishuModelOverrideParentCandidates,
buildFeishuConversationId,
parseFeishuConversationId,
parseFeishuDirectConversationId,
@@ -59,6 +60,7 @@ import {
import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { getFeishuRuntime } from "./runtime.js";
import { collectFeishuSecurityAuditFindings } from "./security-audit.js";
import {
resolveFeishuParentConversationCandidates,
resolveFeishuSessionConversation,
@@ -146,7 +148,9 @@ function describeFeishuMessageTool({
NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>
>[0]): ChannelMessageToolDiscovery {
const enabledAccounts = accountId
? [resolveFeishuAccount({ cfg, accountId })].filter((account) => account.enabled && account.configured)
? [resolveFeishuAccount({ cfg, accountId })].filter(
(account) => account.enabled && account.configured,
)
: listEnabledFeishuAccounts(cfg);
const enabled =
enabledAccounts.length > 0 ||
@@ -179,9 +183,9 @@ function describeFeishuMessageTool({
"channel-list",
]);
if (
(accountId
accountId
? enabledAccounts.some((account) => isFeishuReactionsActionEnabled({ cfg, account }))
: areAnyFeishuReactionActionsEnabled(cfg))
: areAnyFeishuReactionActionsEnabled(cfg)
) {
actions.add("react");
actions.add("reactions");
@@ -554,15 +558,6 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
reactions: true,
edit: true,
reply: true,
richReplies: {
buttons: true,
selects: true,
cards: true,
commandFallback: true,
},
interactionResponses: {
editText: true,
},
},
agentPrompt: {
messageToolHints: () => [
@@ -576,6 +571,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
},
conversationBindings: {
defaultTopLevelPlacement: "current",
buildModelOverrideParentCandidates: ({ parentConversationId }) =>
buildFeishuModelOverrideParentCandidates(parentConversationId),
},
mentions: {
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
@@ -1189,6 +1186,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
cfg: ClawdbotConfig;
accountId?: string | null;
}>(collectFeishuSecurityWarnings),
collectAuditFindings: ({ cfg }) => collectFeishuSecurityAuditFindings({ cfg }),
},
pairing: {
text: {

View File

@@ -166,3 +166,32 @@ export function parseFeishuConversationId(params: {
scope: "group",
};
}
export function buildFeishuModelOverrideParentCandidates(
parentConversationId?: string | null,
): string[] {
const rawId = normalizeText(parentConversationId);
if (!rawId) {
return [];
}
const topicSenderMatch = rawId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/i);
if (topicSenderMatch) {
const chatId = topicSenderMatch[1]?.trim().toLowerCase();
const topicId = topicSenderMatch[2]?.trim().toLowerCase();
if (chatId && topicId) {
return [`${chatId}:topic:${topicId}`, chatId];
}
return [];
}
const topicMatch = rawId.match(/^(.+):topic:([^:]+)$/i);
if (topicMatch) {
const chatId = topicMatch[1]?.trim().toLowerCase();
return chatId ? [chatId] : [];
}
const senderMatch = rawId.match(/^(.+):sender:([^:]+)$/i);
if (senderMatch) {
const chatId = senderMatch[1]?.trim().toLowerCase();
return chatId ? [chatId] : [];
}
return [];
}

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

@@ -0,0 +1,13 @@
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract";
type MessageActionTargetAliasSpec = {
aliases: string[];
};
export const messageActionTargetAliases = {
read: { aliases: ["messageId"] },
pin: { aliases: ["messageId"] },
unpin: { aliases: ["messageId"] },
"list-pins": { aliases: ["chatId"] },
"channel-info": { aliases: ["chatId"] },
} satisfies Partial<Record<ChannelMessageActionName, MessageActionTargetAliasSpec>>;

View File

@@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
@@ -16,7 +15,6 @@ vi.mock("./media.js", () => ({
}));
vi.mock("./send.js", () => ({
sendCardFeishu: sendCardFeishuMock,
sendMessageFeishu: sendMessageFeishuMock,
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
@@ -54,7 +52,6 @@ const cardRenderConfig: ClawdbotConfig = {
function resetOutboundMocks() {
vi.clearAllMocks();
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
sendCardFeishuMock.mockResolvedValue({ messageId: "interactive_card_msg" });
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendStructuredCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
@@ -191,57 +188,6 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
);
});
it("projects interactive payloads into Feishu cards", async () => {
const result = await feishuOutbound.sendPayload!({
cfg: emptyConfig,
to: "chat_1",
text: "",
payload: {
interactive: {
blocks: [
{ type: "text", text: "Choose an action" },
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve", actionId: "approval.approve" }],
},
],
},
},
accountId: "main",
});
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1",
accountId: "main",
card: expect.objectContaining({
schema: "2.0",
body: expect.objectContaining({
elements: expect.arrayContaining([
expect.objectContaining({ tag: "markdown", content: "Choose an action" }),
expect.objectContaining({
tag: "action",
actions: [
expect.objectContaining({
value: expect.objectContaining({
actionId: "approval.approve",
}),
}),
],
}),
]),
}),
}),
}),
);
expect(result).toEqual(
expect.objectContaining({
channel: "feishu",
messageId: "interactive_card_msg",
}),
);
});
it("falls back to threadId when replyToId is empty on sendText", async () => {
await sendText({
cfg: emptyConfig,

View File

@@ -1,29 +1,14 @@
import fs from "fs";
import path from "path";
import {
attachChannelToResult,
createAttachedChannelResultAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import {
reduceInteractiveReply,
renderInteractiveCommandFallback,
resolveInteractiveActionId,
resolveInteractiveTextFallback,
type InteractiveReply,
} from "openclaw/plugin-sdk/interactive-runtime";
import { chunkTextForOutbound, type ChannelOutboundAdapter } from "../runtime-api.js";
import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { parseFeishuCommentTarget } from "./comment-target.js";
import { replyComment } from "./drive.js";
import { sendMediaFeishu } from "./media.js";
import { chunkTextForOutbound, type ChannelOutboundAdapter } from "./outbound-runtime-api.js";
import { getFeishuRuntime } from "./runtime.js";
import {
sendCardFeishu,
sendMarkdownCardFeishu,
sendMessageFeishu,
sendStructuredCardFeishu,
} from "./send.js";
import { sendMarkdownCardFeishu, sendMessageFeishu, sendStructuredCardFeishu } from "./send.js";
function normalizePossibleLocalImagePath(text: string | undefined): string | null {
const raw = text?.trim();
@@ -77,75 +62,6 @@ function resolveReplyToMessageId(params: {
return trimmed || undefined;
}
function buildFeishuInteractiveCard(params: {
interactive: InteractiveReply;
text?: string;
}): Record<string, unknown> {
const elements: Array<Record<string, unknown>> = [];
reduceInteractiveReply(params.interactive, undefined, (_state, block) => {
if (block.type === "text") {
const text = block.text.trim();
if (text) {
elements.push({ tag: "markdown", content: text });
}
return undefined;
}
const actions =
block.type === "buttons"
? block.buttons.map((button) => ({
tag: "button",
text: { tag: "plain_text", content: button.label },
type: button.style === "danger" ? "danger" : "primary",
value: {
oc: "interactive",
kind: "button",
actionId: resolveInteractiveActionId(button),
value: button.value,
fallbackCommand: button.fallback?.command,
},
}))
: block.options.map((option) => ({
tag: "button",
text: { tag: "plain_text", content: option.label },
type: "default",
value: {
oc: "interactive",
kind: "select",
actionId: resolveInteractiveActionId(option),
value: option.value,
fallbackCommand: option.fallback?.command,
},
}));
if (actions.length > 0) {
elements.push({ tag: "action", actions });
}
return undefined;
});
const fallbackText =
resolveInteractiveTextFallback({
text: params.text,
interactive: params.interactive,
}) ?? renderInteractiveCommandFallback(params.interactive);
if (fallbackText && elements.length === 0) {
elements.push({ tag: "markdown", content: fallbackText });
}
const commandFallback = renderInteractiveCommandFallback(params.interactive);
if (commandFallback && commandFallback !== fallbackText) {
elements.push({ tag: "hr" });
elements.push({ tag: "markdown", content: `<font color='grey'>${commandFallback}</font>` });
}
return {
schema: "2.0",
config: { wide_screen_mode: true },
body: { elements },
};
}
async function sendCommentThreadReply(params: {
cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
to: string;
@@ -204,43 +120,6 @@ export const feishuOutbound: ChannelOutboundAdapter = {
chunker: chunkTextForOutbound,
chunkerMode: "markdown",
textChunkLimit: 4000,
sendPayload: async (ctx) => {
if (ctx.payload.interactive && !ctx.payload.mediaUrl && !(ctx.payload.mediaUrls?.length ?? 0)) {
return attachChannelToResult(
"feishu",
await sendCardFeishu({
cfg: ctx.cfg,
to: ctx.to,
card: buildFeishuInteractiveCard({
interactive: ctx.payload.interactive,
text: ctx.payload.text,
}),
replyToMessageId: resolveReplyToMessageId({
replyToId: ctx.replyToId,
threadId: ctx.threadId,
}),
replyInThread: ctx.threadId != null && !ctx.replyToId,
accountId: ctx.accountId ?? undefined,
}),
);
}
const text =
resolveInteractiveTextFallback({
text: ctx.payload.text,
interactive: ctx.payload.interactive,
}) ?? "";
if (ctx.payload.mediaUrl) {
return await feishuOutbound.sendMedia!({
...ctx,
text,
mediaUrl: ctx.payload.mediaUrl,
});
}
return await feishuOutbound.sendText!({
...ctx,
text,
});
},
...createAttachedChannelResultAdapter({
channel: "feishu",
sendText: async ({

View File

@@ -0,0 +1,140 @@
import {
collectConditionalChannelFieldAssignments,
collectSimpleChannelFieldAssignments,
getChannelSurface,
hasOwnProperty,
normalizeSecretStringValue,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/security-runtime";
export const secretTargetRegistryEntries = [
{
id: "channels.feishu.accounts.*.appSecret",
targetType: "channels.feishu.accounts.*.appSecret",
configFile: "openclaw.json",
pathPattern: "channels.feishu.accounts.*.appSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.accounts.*.encryptKey",
targetType: "channels.feishu.accounts.*.encryptKey",
configFile: "openclaw.json",
pathPattern: "channels.feishu.accounts.*.encryptKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.accounts.*.verificationToken",
targetType: "channels.feishu.accounts.*.verificationToken",
configFile: "openclaw.json",
pathPattern: "channels.feishu.accounts.*.verificationToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.appSecret",
targetType: "channels.feishu.appSecret",
configFile: "openclaw.json",
pathPattern: "channels.feishu.appSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.encryptKey",
targetType: "channels.feishu.encryptKey",
configFile: "openclaw.json",
pathPattern: "channels.feishu.encryptKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.verificationToken",
targetType: "channels.feishu.verificationToken",
configFile: "openclaw.json",
pathPattern: "channels.feishu.verificationToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
] satisfies SecretTargetRegistryEntry[];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "feishu");
if (!resolved) {
return;
}
const { channel: feishu, surface } = resolved;
collectSimpleChannelFieldAssignments({
channelKey: "feishu",
field: "appSecret",
channel: feishu,
surface,
defaults: params.defaults,
context: params.context,
topInactiveReason: "no enabled account inherits this top-level Feishu appSecret.",
accountInactiveReason: "Feishu account is disabled.",
});
const baseConnectionMode =
normalizeSecretStringValue(feishu.connectionMode) === "webhook" ? "webhook" : "websocket";
const resolveAccountMode = (account: Record<string, unknown>) =>
hasOwnProperty(account, "connectionMode")
? normalizeSecretStringValue(account.connectionMode)
: baseConnectionMode;
collectConditionalChannelFieldAssignments({
channelKey: "feishu",
field: "encryptKey",
channel: feishu,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: baseConnectionMode === "webhook",
topLevelInheritedAccountActive: ({ account, enabled }) =>
enabled &&
!hasOwnProperty(account, "encryptKey") &&
resolveAccountMode(account) === "webhook",
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "webhook",
topInactiveReason: "no enabled Feishu webhook-mode surface inherits this top-level encryptKey.",
accountInactiveReason: "Feishu account is disabled or not running in webhook mode.",
});
collectConditionalChannelFieldAssignments({
channelKey: "feishu",
field: "verificationToken",
channel: feishu,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: baseConnectionMode === "webhook",
topLevelInheritedAccountActive: ({ account, enabled }) =>
enabled &&
!hasOwnProperty(account, "verificationToken") &&
resolveAccountMode(account) === "webhook",
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "webhook",
topInactiveReason:
"no enabled Feishu webhook-mode surface inherits this top-level verificationToken.",
accountInactiveReason: "Feishu account is disabled or not running in webhook mode.",
});
}

View File

@@ -0,0 +1,70 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/setup";
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function isFeishuDocToolEnabled(cfg: OpenClawConfig): boolean {
const channels = asRecord(cfg.channels);
const feishu = asRecord(channels?.feishu);
if (!feishu || feishu.enabled === false) {
return false;
}
const baseTools = asRecord(feishu.tools);
const baseDocEnabled = baseTools?.doc !== false;
const baseAppId = hasNonEmptyString(feishu.appId);
const baseAppSecret = hasConfiguredSecretInput(feishu.appSecret, cfg.secrets?.defaults);
const baseConfigured = baseAppId && baseAppSecret;
const accounts = asRecord(feishu.accounts);
if (!accounts || Object.keys(accounts).length === 0) {
return baseDocEnabled && baseConfigured;
}
for (const accountValue of Object.values(accounts)) {
const account = asRecord(accountValue) ?? {};
if (account.enabled === false) {
continue;
}
const accountTools = asRecord(account.tools);
const effectiveTools = accountTools ?? baseTools;
const docEnabled = effectiveTools?.doc !== false;
if (!docEnabled) {
continue;
}
const accountConfigured =
(hasNonEmptyString(account.appId) || baseAppId) &&
(hasConfiguredSecretInput(account.appSecret, cfg.secrets?.defaults) || baseAppSecret);
if (accountConfigured) {
return true;
}
}
return false;
}
export function collectFeishuSecurityAuditFindings(params: { cfg: OpenClawConfig }) {
if (!isFeishuDocToolEnabled(params.cfg)) {
return [];
}
return [
{
checkId: "channels.feishu.doc_owner_open_id",
severity: "warn" as const,
title: "Feishu doc create can grant requester permissions",
detail:
'channels.feishu tools include "doc"; feishu_doc action "create" can grant document access to the trusted requesting Feishu user.',
remediation:
"Disable channels.feishu.tools.doc when not needed, and restrict tool access for untrusted prompts.",
},
];
}

View File

@@ -318,6 +318,34 @@ describe("feishu setup wizard status", () => {
expect(status.statusLines).toEqual(["Feishu: needs app credentials"]);
});
it("uses configured defaultAccount for omitted setup configured state", async () => {
const status = await feishuGetStatus({
cfg: {
channels: {
feishu: {
defaultAccount: "work",
appId: "top_level_app",
appSecret: "top-level-secret", // pragma: allowlist secret
accounts: {
alerts: {
appId: "alerts-app",
appSecret: "alerts-secret", // pragma: allowlist secret
},
work: {
appId: "",
appSecret: "work-secret", // pragma: allowlist secret
},
},
},
},
} as never,
accountOverrides: {},
});
expect(status.configured).toBe(false);
expect(status.statusLines).toEqual(["Feishu: needs app credentials"]);
});
it("uses configured defaultAccount for omitted DM policy account context", async () => {
const { feishuSetupWizard } = await import("./setup-surface.js");
const cfg = {

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 {
const feishuCfg = ((cfg.channels?.feishu as FeishuConfig | undefined) ?? {}) as FeishuConfig;
type ScopedFeishuConfig = Partial<FeishuConfig> & Partial<FeishuAccountConfig>;
function getScopedFeishuConfig(cfg: OpenClawConfig, accountId: string): ScopedFeishuConfig {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (accountId === DEFAULT_ACCOUNT_ID) {
return feishuCfg;
return feishuCfg ?? {};
}
return (feishuCfg.accounts?.[accountId] as FeishuAccountConfig | undefined) ?? {};
return (feishuCfg?.accounts?.[accountId] as FeishuAccountConfig | undefined) ?? {};
}
function patchFeishuConfig(
@@ -49,11 +47,30 @@ function patchFeishuConfig(
accountId: string,
patch: Record<string, unknown>,
): OpenClawConfig {
return patchChannelConfigForAccount({
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
if (accountId === DEFAULT_ACCOUNT_ID) {
return patchTopLevelChannelConfigSection({
cfg,
channel,
enabled: true,
patch,
});
}
const nextAccountPatch = {
...((feishuCfg?.accounts?.[accountId] as Record<string, unknown> | undefined) ?? {}),
enabled: true,
...patch,
};
return patchTopLevelChannelConfigSection({
cfg,
channel,
accountId,
patch,
enabled: true,
patch: {
accounts: {
...(feishuCfg?.accounts ?? {}),
[accountId]: nextAccountPatch,
},
},
});
}
@@ -81,8 +98,9 @@ function setFeishuGroupAllowFrom(
return patchFeishuConfig(cfg, accountId, { groupAllowFrom });
}
function isFeishuConfigured(cfg: OpenClawConfig): boolean {
function isFeishuConfigured(cfg: OpenClawConfig, accountId?: string | null): boolean {
const feishuCfg = ((cfg.channels?.feishu as FeishuConfig | undefined) ?? {}) as FeishuConfig;
const resolvedAccountId = normalizeString(accountId) ?? resolveDefaultFeishuAccountId(cfg);
const isAppIdConfigured = (value: unknown): boolean => {
const asString = normalizeString(value);
@@ -105,22 +123,25 @@ function isFeishuConfigured(cfg: OpenClawConfig): boolean {
isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret),
);
const accountConfigured = Object.values(feishuCfg.accounts ?? {}).some((account) => {
if (!account || typeof account !== "object") {
return false;
}
const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId");
const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret");
const accountAppIdConfigured = hasOwnAppId
? isAppIdConfigured((account as Record<string, unknown>).appId)
: isAppIdConfigured(feishuCfg?.appId);
const accountSecretConfigured = hasOwnAppSecret
? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
: hasConfiguredSecretInput(feishuCfg?.appSecret);
return Boolean(accountAppIdConfigured && accountSecretConfigured);
});
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
return topLevelConfigured;
}
return topLevelConfigured || accountConfigured;
const account = feishuCfg.accounts?.[resolvedAccountId];
if (!account || typeof account !== "object") {
return topLevelConfigured;
}
const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId");
const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret");
const accountAppIdConfigured = hasOwnAppId
? isAppIdConfigured((account as Record<string, unknown>).appId)
: isAppIdConfigured(feishuCfg?.appId);
const accountSecretConfigured = hasOwnAppSecret
? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
: hasConfiguredSecretInput(feishuCfg?.appSecret);
return Boolean(accountAppIdConfigured && accountSecretConfigured);
}
async function promptFeishuAllowFrom(params: {
@@ -241,8 +262,7 @@ export const feishuSetupWizard: ChannelSetupWizard = {
unconfiguredHint: "needs app creds",
configuredScore: 2,
unconfiguredScore: 0,
resolveConfigured: ({ cfg, accountId }) =>
accountId ? resolveFeishuAccount({ cfg, accountId }).configured : isFeishuConfigured(cfg),
resolveConfigured: ({ cfg, accountId }) => isFeishuConfigured(cfg, accountId),
resolveStatusLines: async ({ cfg, accountId, configured }) => {
const resolvedCredentials = accountId
? (() => {

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

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

View File

@@ -50,6 +50,7 @@ import {
type OpenClawConfig,
type ResolvedGoogleChatAccount,
} from "./channel.deps.runtime.js";
import { collectGoogleChatMutableAllowlistWarnings } from "./doctor.js";
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
import { getGoogleChatRuntime } from "./runtime.js";
import { googlechatSetupAdapter } from "./setup-core.js";
@@ -218,6 +219,7 @@ export const googlechatPlugin = createChatChannelPlugin({
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
collectMutableAllowlistWarnings: collectGoogleChatMutableAllowlistWarnings,
},
status: createComputedAccountStatusAdapter<ResolvedGoogleChatAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),

View File

@@ -0,0 +1,57 @@
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function isGoogleChatMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const withoutPrefix = text.replace(/^(googlechat|google-chat|gchat):/i, "").trim();
if (!withoutPrefix) {
return false;
}
const withoutUsers = withoutPrefix.replace(/^users\//i, "");
return withoutUsers.includes("@");
}
export const collectGoogleChatMutableAllowlistWarnings =
createDangerousNameMatchingMutableAllowlistWarningCollector({
channel: "googlechat",
detector: isGoogleChatMutableAllowEntry,
collectLists: (scope) => {
const lists = [
{
pathLabel: `${scope.prefix}.groupAllowFrom`,
list: scope.account.groupAllowFrom,
},
];
const dm = asObjectRecord(scope.account.dm);
if (dm) {
lists.push({
pathLabel: `${scope.prefix}.dm.allowFrom`,
list: dm.allowFrom,
});
}
const groups = asObjectRecord(scope.account.groups);
if (groups) {
for (const [groupKey, groupRaw] of Object.entries(groups)) {
const group = asObjectRecord(groupRaw);
if (!group) {
continue;
}
lists.push({
pathLabel: `${scope.prefix}.groups.${groupKey}.users`,
list: group.users,
});
}
}
return lists;
},
});

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