Compare commits

..
Author SHA1 Message Date
Peter Steinberger 432d1481dc fix: add nextcloud talk manifest (#1297) (thanks @ysqander) 2026-01-20 11:37:46 +00:00
Peter Steinberger c9e3c14f9c fix: finalize exec fish fallback (#1297) (thanks @ysqander) 2026-01-20 11:25:49 +00:00
Peter Steinberger 636a8e3181 fix: merge plugin manifest types 2026-01-20 11:22:28 +00:00
Peter Steinberger 660f87278c refactor: plugin catalog + nextcloud policy 2026-01-20 11:22:27 +00:00
Peter Steinberger 9ec1fb4a80 Merge pull request #1297 from ysqander/fix/fish-shell-bash
exec: prefer bash when fish is default shell
2026-01-20 11:13:35 +00:00
Peter Steinberger 74757cd5af fix: stabilize gateway defaults 2026-01-20 11:11:26 +00:00
Peter Steinberger 9f75550702 Merge pull request #1294 from bradleypriest/pr/sessions-labels
ui(sessions): support editing session labels
2026-01-20 11:05:46 +00:00
Peter Steinberger 8214ab507c Merge pull request #1272 from clawdbot/shadow/config-plugin-validation
Config: validate plugin config
2026-01-20 11:03:38 +00:00
Shadowandthewilloftheshadow 2f6d5805de fix: enforce plugin config schemas (#1272) (thanks @thewilloftheshadow)
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-20 11:03:17 +00:00
ysqander 8ddedc3fc5 exec: prefer bash when fish is default shell 2026-01-20 17:42:02 +08:00
Bradley Priest 51f1f23235 ui(sessions): support editing session labels
Expose session "label" as an editable field in the Sessions view and persist changes via sessions.patch.
2026-01-20 21:07:41 +13:00
90 changed files with 4857 additions and 642 deletions

No files matched your search

+4 -9
View File
@@ -6,19 +6,14 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- Repo: remove the Peekaboo git submodule now that the SPM release is used. - Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Gateway: raise default lane concurrency for main and sub-agent runs. - Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Config: centralize default agent concurrency limits. - Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
### Fixes ### Fixes
- Cron: serialize scheduler operations per store path to prevent duplicate runs across hot reloads. (#1216) — thanks @carlulsoe.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). - Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- Agents: treat OAuth refresh failures as auth errors to trigger model fallback. (#1261) — thanks @zknicker.
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs. - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- CLI: avoid duplicating --profile/--dev flags when formatting commands. - CLI: avoid duplicating --profile/--dev flags when formatting commands.
- Auth: dedupe codex-cli profiles when tokens match custom openai-codex entries. (#1264) — thanks @odrobnik. - Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- Agents: avoid misclassifying context-window-too-small errors as context overflow. (#1266) — thanks @humanwritten. - Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
- Slack: resolve Bolt default-export shapes for monitor startup. (#1208) — thanks @24601.
- UI: allow editing session labels in the Sessions table. (#1294) — thanks @bradleypriest.
## 2026.1.19-3 ## 2026.1.19-3
+16 -15
View File
@@ -475,21 +475,22 @@ Thanks to all clawtributors:
<a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a>
<a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a>
<a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a>
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a>
<a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a>
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a>
<a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a>
<a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a>
<a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a>
<a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a>
<a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a>
<a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a>
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a>
<a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a>
<a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a>
<a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a>
<a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>
+1
View File
@@ -19,6 +19,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [iMessage](/channels/imessage) — macOS only; native integration. - [iMessage](/channels/imessage) — macOS only; native integration.
- [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default). - [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default).
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately). - [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
+119
View File
@@ -0,0 +1,119 @@
---
summary: "Nextcloud Talk support status, capabilities, and configuration"
read_when:
- Working on Nextcloud Talk channel features
---
# Nextcloud Talk (plugin)
Status: supported via plugin (webhook bot). Direct messages, rooms, reactions, and markdown messages are supported.
## Plugin required
Nextcloud Talk ships as a plugin and is not bundled with the core install.
Install via CLI (npm registry):
```bash
clawdbot plugins install @clawdbot/nextcloud-talk
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/nextcloud-talk
```
If you choose Nextcloud Talk during configure/onboarding and a git checkout is detected,
Clawdbot will offer the local install path automatically.
Details: [Plugins](/plugin)
## Quick setup (beginner)
1) Install the Nextcloud Talk plugin.
2) On your Nextcloud server, create a bot:
```bash
./occ talk:bot:install "Clawdbot" "<shared-secret>" "<webhook-url>" --feature reaction
```
3) Enable the bot in the target room settings.
4) Configure Clawdbot:
- Config: `channels.nextcloud-talk.baseUrl` + `channels.nextcloud-talk.botSecret`
- Or env: `NEXTCLOUD_TALK_BOT_SECRET` (default account only)
5) Restart the gateway (or finish onboarding).
Minimal config:
```json5
{
channels: {
"nextcloud-talk": {
enabled: true,
baseUrl: "https://cloud.example.com",
botSecret: "shared-secret",
dmPolicy: "pairing"
}
}
}
```
## Notes
- Bots cannot initiate DMs. The user must message the bot first.
- Webhook URL must be reachable by the Gateway; set `webhookPublicUrl` if behind a proxy.
- Media uploads are not supported by the bot API; media is sent as URLs.
- The webhook payload does not distinguish DMs vs rooms; set `apiUser` + `apiPassword` to enable room-type lookups (otherwise DMs are treated as rooms).
## Access control (DMs)
- Default: `channels.nextcloud-talk.dmPolicy = "pairing"`. Unknown senders get a pairing code.
- Approve via:
- `clawdbot pairing list nextcloud-talk`
- `clawdbot pairing approve nextcloud-talk <CODE>`
- Public DMs: `channels.nextcloud-talk.dmPolicy="open"` plus `channels.nextcloud-talk.allowFrom=["*"]`.
## Rooms (groups)
- Default: `channels.nextcloud-talk.groupPolicy = "allowlist"` (mention-gated).
- Allowlist rooms with `channels.nextcloud-talk.rooms`:
```json5
{
channels: {
"nextcloud-talk": {
rooms: {
"room-token": { requireMention: true }
}
}
}
}
```
- To allow no rooms, keep the allowlist empty or set `channels.nextcloud-talk.groupPolicy="disabled"`.
## Capabilities
| Feature | Status |
|---------|--------|
| Direct messages | Supported |
| Rooms | Supported |
| Threads | Not supported |
| Media | URL-only |
| Reactions | Supported |
| Native commands | Not supported |
## Configuration reference (Nextcloud Talk)
Full configuration: [Configuration](/gateway/configuration)
Provider options:
- `channels.nextcloud-talk.enabled`: enable/disable channel startup.
- `channels.nextcloud-talk.baseUrl`: Nextcloud instance URL.
- `channels.nextcloud-talk.botSecret`: bot shared secret.
- `channels.nextcloud-talk.botSecretFile`: secret file path.
- `channels.nextcloud-talk.apiUser`: API user for room lookups (DM detection).
- `channels.nextcloud-talk.apiPassword`: API/app password for room lookups.
- `channels.nextcloud-talk.apiPasswordFile`: API password file path.
- `channels.nextcloud-talk.webhookPort`: webhook listener port (default: 8788).
- `channels.nextcloud-talk.webhookHost`: webhook host (default: 0.0.0.0).
- `channels.nextcloud-talk.webhookPath`: webhook path (default: /nextcloud-talk-webhook).
- `channels.nextcloud-talk.webhookPublicUrl`: externally reachable webhook URL.
- `channels.nextcloud-talk.dmPolicy`: `pairing | allowlist | open | disabled`.
- `channels.nextcloud-talk.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`.
- `channels.nextcloud-talk.groupPolicy`: `allowlist | open | disabled`.
- `channels.nextcloud-talk.groupAllowFrom`: group allowlist (user IDs).
- `channels.nextcloud-talk.rooms`: per-room settings and allowlist.
- `channels.nextcloud-talk.historyLimit`: group history limit (0 disables).
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
+5
View File
@@ -11,6 +11,7 @@ Manage Gateway plugins/extensions (loaded in-process).
Related: Related:
- Plugin system: [Plugins](/plugin) - Plugin system: [Plugins](/plugin)
- Plugin manifest + schema: [Plugin manifest](/plugins/manifest)
- Security hardening: [Security](/gateway/security) - Security hardening: [Security](/gateway/security)
## Commands ## Commands
@@ -28,6 +29,10 @@ clawdbot plugins update --all
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
activate them. activate them.
All plugins must ship a `clawdbot.plugin.json` file with an inline JSON Schema
(`configSchema`, even if empty). Missing/invalid manifests or schemas prevent
the plugin from loading and fail config validation.
### Install ### Install
```bash ```bash
+64 -14
View File
@@ -48,8 +48,11 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default) - Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default) - Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. **Config
register: validation does not execute plugin code**; it uses the plugin manifest and JSON
Schema instead. See [Plugin manifest](/plugins/manifest).
Plugins can register:
- Gateway RPC methods - Gateway RPC methods
- Gateway HTTP handlers - Gateway HTTP handlers
@@ -83,6 +86,10 @@ Bundled plugins must be enabled explicitly via `plugins.entries.<id>.enabled`
or `clawdbot plugins enable <id>`. Installed plugins are enabled by default, or `clawdbot plugins enable <id>`. Installed plugins are enabled by default,
but can be disabled the same way. but can be disabled the same way.
Each plugin must include a `clawdbot.plugin.json` file in its root. If a path
points at a file, the plugin root is the file's directory and must contain the
manifest.
If multiple plugins resolve to the same id, the first match in the order above If multiple plugins resolve to the same id, the first match in the order above
wins and lower-precedence copies are ignored. wins and lower-precedence copies are ignored.
@@ -105,6 +112,37 @@ becomes `name/<fileBase>`.
If your plugin imports npm deps, install them in that directory so If your plugin imports npm deps, install them in that directory so
`node_modules` is available (`npm install` / `pnpm install`). `node_modules` is available (`npm install` / `pnpm install`).
### Channel catalog metadata
Channel plugins can advertise onboarding metadata via `clawdbot.channel` and
install hints via `clawdbot.install`. This keeps the core catalog data-free.
Example:
```json
{
"name": "@clawdbot/nextcloud-talk",
"clawdbot": {
"extensions": ["./index.ts"],
"channel": {
"id": "nextcloud-talk",
"label": "Nextcloud Talk",
"selectionLabel": "Nextcloud Talk (self-hosted)",
"docsPath": "/channels/nextcloud-talk",
"docsLabel": "nextcloud-talk",
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
"order": 65,
"aliases": ["nc-talk", "nc"]
},
"install": {
"npmSpec": "@clawdbot/nextcloud-talk",
"localPath": "extensions/nextcloud-talk",
"defaultChoice": "npm"
}
}
}
```
## Plugin IDs ## Plugin IDs
Default plugin ids: Default plugin ids:
@@ -140,6 +178,14 @@ Fields:
Config changes **require a gateway restart**. Config changes **require a gateway restart**.
Validation rules (strict):
- Unknown plugin ids in `entries`, `allow`, `deny`, or `slots` are **errors**.
- Unknown `channels.<id>` keys are **errors** unless a plugin manifest declares
the channel id.
- Plugin config is validated using the JSON Schema embedded in
`clawdbot.plugin.json` (`configSchema`).
- If a plugin is disabled, its config is preserved and a **warning** is emitted.
## Plugin slots (exclusive categories) ## Plugin slots (exclusive categories)
Some plugin categories are **exclusive** (only one active at a time). Use Some plugin categories are **exclusive** (only one active at a time). Use
@@ -169,22 +215,26 @@ Clawdbot augments `uiHints` at runtime based on discovered plugins:
`plugins.entries.<id>.config.<field>` `plugins.entries.<id>.config.<field>`
If you want your plugin config fields to show good labels/placeholders (and mark secrets as sensitive), If you want your plugin config fields to show good labels/placeholders (and mark secrets as sensitive),
provide `configSchema.uiHints`. provide `uiHints` alongside your JSON Schema in the plugin manifest.
Example: Example:
```ts ```json
export default { {
id: "my-plugin", "id": "my-plugin",
configSchema: { "configSchema": {
parse: (v) => v, "type": "object",
uiHints: { "additionalProperties": false,
"apiKey": { label: "API Key", sensitive: true }, "properties": {
"region": { label: "Region", placeholder: "us-east-1" }, "apiKey": { "type": "string" },
}, "region": { "type": "string" }
}
}, },
register(api) {}, "uiHints": {
}; "apiKey": { "label": "API Key", "sensitive": true },
"region": { "label": "Region", "placeholder": "us-east-1" }
}
}
``` ```
## CLI ## CLI
+63
View File
@@ -0,0 +1,63 @@
---
summary: "Plugin manifest + JSON schema requirements (strict config validation)"
read_when:
- You are building a Clawdbot plugin
- You need to ship a plugin config schema or debug plugin validation errors
---
# Plugin manifest (clawdbot.plugin.json)
Every plugin **must** ship a `clawdbot.plugin.json` file in the **plugin root**.
Clawdbot uses this manifest to validate configuration **without executing plugin
code**. Missing or invalid manifests are treated as plugin errors and block
config validation.
See the full plugin system guide: [Plugins](/plugin).
## Required fields
```json
{
"id": "voice-call",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
```
Required keys:
- `id` (string): canonical plugin id.
- `configSchema` (object): JSON Schema for plugin config (inline).
Optional keys:
- `kind` (string): plugin kind (example: `"memory"`).
- `channels` (array): channel ids registered by this plugin (example: `["matrix"]`).
- `providers` (array): provider ids registered by this plugin.
- `name` (string): display name for the plugin.
- `description` (string): short plugin summary.
- `uiHints` (object): config field labels/placeholders/sensitive flags for UI rendering.
- `version` (string): plugin version (informational).
## JSON Schema requirements
- **Every plugin must ship a JSON Schema**, even if it accepts no config.
- An empty schema is acceptable (for example, `{ "type": "object", "additionalProperties": false }`).
- Schemas are validated at config read/write time, not at runtime.
## Validation behavior
- Unknown `channels.*` keys are **errors**, unless the channel id is declared by
a plugin manifest.
- `plugins.entries.<id>`, `plugins.allow`, `plugins.deny`, and `plugins.slots.*`
must reference **discoverable** plugin ids. Unknown ids are **errors**.
- If a plugin is installed but has a broken or missing manifest or schema,
validation fails and Doctor reports the plugin error.
- If plugin config exists but the plugin is **disabled**, the config is kept and
a **warning** is surfaced in Doctor + logs.
## Notes
- The manifest is **required for all plugins**, including local filesystem loads.
- Runtime still loads the plugin module separately; the manifest is only for
discovery + validation.
+5 -2
View File
@@ -22,17 +22,20 @@ read_when:
- Unknown keys are validation errors (no passthrough at root or nested). - Unknown keys are validation errors (no passthrough at root or nested).
- `plugins.entries.<id>.config` must be validated by the plugin’s schema. - `plugins.entries.<id>.config` must be validated by the plugin’s schema.
- If a plugin lacks a schema, **reject plugin load** and surface a clear error. - If a plugin lacks a schema, **reject plugin load** and surface a clear error.
- Unknown `channels.<id>` keys are errors unless a plugin manifest declares the channel id.
- Plugin manifests (`clawdbot.plugin.json`) are required for all plugins.
## Plugin schema enforcement ## Plugin schema enforcement
- Each plugin provides a strict schema for its config (no passthrough). - Each plugin provides a strict JSON Schema for its config (inline in the manifest).
- Plugin load flow: - Plugin load flow:
1) Resolve plugin schema by plugin id. 1) Resolve plugin manifest + schema (`clawdbot.plugin.json`).
2) Validate config against the schema. 2) Validate config against the schema.
3) If missing schema or invalid config: block plugin load, record error. 3) If missing schema or invalid config: block plugin load, record error.
- Error message includes: - Error message includes:
- Plugin id - Plugin id
- Reason (missing schema / invalid config) - Reason (missing schema / invalid config)
- Path(s) that failed validation - Path(s) that failed validation
- Disabled plugins keep their config, but Doctor + logs surface a warning.
## Doctor flow ## Doctor flow
- Doctor runs **every time** config is loaded (dry-run by default). - Doctor runs **every time** config is loaded (dry-run by default).
+2
View File
@@ -32,6 +32,8 @@ Notes:
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`. - `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
- `node` requires a paired node (companion app or headless node host). - `node` requires a paired node (companion app or headless node host).
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one. - If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
- On non-Windows hosts, exec uses `SHELL` when set; if `SHELL` is `fish`, it prefers `bash` (or `sh`)
from `PATH` to avoid fish-incompatible scripts, then falls back to `SHELL` if neither exists.
## Config ## Config
@@ -0,0 +1,11 @@
{
"id": "bluebubbles",
"channels": [
"bluebubbles"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+15 -1
View File
@@ -4,6 +4,20 @@
"type": "module", "type": "module",
"description": "Clawdbot BlueBubbles channel plugin", "description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": { "clawdbot": {
"extensions": ["./index.ts"] "extensions": ["./index.ts"],
"channel": {
"id": "bluebubbles",
"label": "BlueBubbles",
"selectionLabel": "BlueBubbles (macOS app)",
"docsPath": "/channels/bluebubbles",
"docsLabel": "bluebubbles",
"blurb": "iMessage via the BlueBubbles mac app + REST API.",
"order": 75
},
"install": {
"npmSpec": "@clawdbot/bluebubbles",
"localPath": "extensions/bluebubbles",
"defaultChoice": "npm"
}
} }
} }
@@ -0,0 +1,11 @@
{
"id": "copilot-proxy",
"providers": [
"copilot-proxy"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "discord",
"channels": [
"discord"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
@@ -0,0 +1,11 @@
{
"id": "google-antigravity-auth",
"providers": [
"google-antigravity"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
@@ -0,0 +1,11 @@
{
"id": "google-gemini-cli-auth",
"providers": [
"google-gemini-cli"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "imessage",
"channels": [
"imessage"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "matrix",
"channels": [
"matrix"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+16 -1
View File
@@ -6,7 +6,22 @@
"clawdbot": { "clawdbot": {
"extensions": [ "extensions": [
"./index.ts" "./index.ts"
] ],
"channel": {
"id": "matrix",
"label": "Matrix",
"selectionLabel": "Matrix (plugin)",
"docsPath": "/channels/matrix",
"docsLabel": "matrix",
"blurb": "open protocol; install the plugin to enable.",
"order": 70,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/matrix",
"localPath": "extensions/matrix",
"defaultChoice": "npm"
}
}, },
"dependencies": { "dependencies": {
"clawdbot": "workspace:*", "clawdbot": "workspace:*",
@@ -0,0 +1,9 @@
{
"id": "memory-core",
"kind": "memory",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
@@ -0,0 +1,67 @@
{
"id": "memory-lancedb",
"kind": "memory",
"uiHints": {
"embedding.apiKey": {
"label": "OpenAI API Key",
"sensitive": true,
"placeholder": "sk-proj-...",
"help": "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})"
},
"embedding.model": {
"label": "Embedding Model",
"placeholder": "text-embedding-3-small",
"help": "OpenAI embedding model to use"
},
"dbPath": {
"label": "Database Path",
"placeholder": "~/.clawdbot/memory/lancedb",
"advanced": true
},
"autoCapture": {
"label": "Auto-Capture",
"help": "Automatically capture important information from conversations"
},
"autoRecall": {
"label": "Auto-Recall",
"help": "Automatically inject relevant memories into context"
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"embedding": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"model": {
"type": "string",
"enum": [
"text-embedding-3-small",
"text-embedding-3-large"
]
}
},
"required": [
"apiKey"
]
},
"dbPath": {
"type": "string"
},
"autoCapture": {
"type": "boolean"
},
"autoRecall": {
"type": "boolean"
}
},
"required": [
"embedding"
]
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "msteams",
"channels": [
"msteams"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+16 -1
View File
@@ -6,7 +6,22 @@
"clawdbot": { "clawdbot": {
"extensions": [ "extensions": [
"./index.ts" "./index.ts"
] ],
"channel": {
"id": "msteams",
"label": "Microsoft Teams",
"selectionLabel": "Microsoft Teams (Bot Framework)",
"docsPath": "/channels/msteams",
"docsLabel": "msteams",
"blurb": "Bot Framework; enterprise support.",
"aliases": ["teams"],
"order": 60
},
"install": {
"npmSpec": "@clawdbot/msteams",
"localPath": "extensions/msteams",
"defaultChoice": "npm"
}
}, },
"dependencies": { "dependencies": {
"@microsoft/agents-hosting": "^1.2.2", "@microsoft/agents-hosting": "^1.2.2",
@@ -0,0 +1,11 @@
{
"id": "nextcloud-talk",
"channels": [
"nextcloud-talk"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { nextcloudTalkPlugin } from "./src/channel.js";
import { setNextcloudTalkRuntime } from "./src/runtime.js";
const plugin = {
id: "nextcloud-talk",
name: "Nextcloud Talk",
description: "Nextcloud Talk channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setNextcloudTalkRuntime(api.runtime);
api.registerChannel({ plugin: nextcloudTalkPlugin });
},
};
export default plugin;
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@clawdbot/nextcloud-talk",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": {
"extensions": ["./index.ts"],
"channel": {
"id": "nextcloud-talk",
"label": "Nextcloud Talk",
"selectionLabel": "Nextcloud Talk (self-hosted)",
"docsPath": "/channels/nextcloud-talk",
"docsLabel": "nextcloud-talk",
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
"aliases": ["nc-talk", "nc"],
"order": 65,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/nextcloud-talk",
"localPath": "extensions/nextcloud-talk",
"defaultChoice": "npm"
}
}
}
+154
View File
@@ -0,0 +1,154 @@
import { readFileSync } from "node:fs";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]);
function isTruthyEnvValue(value?: string): boolean {
if (!value) return false;
return TRUTHY_ENV.has(value.trim().toLowerCase());
}
const debugAccounts = (...args: unknown[]) => {
if (isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_NEXTCLOUD_TALK_ACCOUNTS)) {
console.warn("[nextcloud-talk:accounts]", ...args);
}
};
export type ResolvedNextcloudTalkAccount = {
accountId: string;
enabled: boolean;
name?: string;
baseUrl: string;
secret: string;
secretSource: "env" | "secretFile" | "config" | "none";
config: NextcloudTalkAccountConfig;
};
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
const accounts = cfg.channels?.["nextcloud-talk"]?.accounts;
if (!accounts || typeof accounts !== "object") return [];
const ids = new Set<string>();
for (const key of Object.keys(accounts)) {
if (!key) continue;
ids.add(normalizeAccountId(key));
}
return [...ids];
}
export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
debugAccounts("listNextcloudTalkAccountIds", ids);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultNextcloudTalkAccountId(cfg: CoreConfig): string {
const ids = listNextcloudTalkAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: CoreConfig,
accountId: string,
): NextcloudTalkAccountConfig | undefined {
const accounts = cfg.channels?.["nextcloud-talk"]?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
const direct = accounts[accountId] as NextcloudTalkAccountConfig | undefined;
if (direct) return direct;
const normalized = normalizeAccountId(accountId);
const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
return matchKey ? (accounts[matchKey] as NextcloudTalkAccountConfig | undefined) : undefined;
}
function mergeNextcloudTalkAccountConfig(
cfg: CoreConfig,
accountId: string,
): NextcloudTalkAccountConfig {
const { accounts: _ignored, ...base } = (cfg.channels?.["nextcloud-talk"] ??
{}) as NextcloudTalkAccountConfig & { accounts?: unknown };
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}
function resolveNextcloudTalkSecret(
cfg: CoreConfig,
opts: { accountId?: string },
): { secret: string; source: ResolvedNextcloudTalkAccount["secretSource"] } {
const merged = mergeNextcloudTalkAccountConfig(cfg, opts.accountId ?? DEFAULT_ACCOUNT_ID);
const envSecret = process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim();
if (envSecret && (!opts.accountId || opts.accountId === DEFAULT_ACCOUNT_ID)) {
return { secret: envSecret, source: "env" };
}
if (merged.botSecretFile) {
try {
const fileSecret = readFileSync(merged.botSecretFile, "utf-8").trim();
if (fileSecret) return { secret: fileSecret, source: "secretFile" };
} catch {
// File not found or unreadable, fall through.
}
}
if (merged.botSecret?.trim()) {
return { secret: merged.botSecret.trim(), source: "config" };
}
return { secret: "", source: "none" };
}
export function resolveNextcloudTalkAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedNextcloudTalkAccount {
const hasExplicitAccountId = Boolean(params.accountId?.trim());
const baseEnabled = params.cfg.channels?.["nextcloud-talk"]?.enabled !== false;
const resolve = (accountId: string) => {
const merged = mergeNextcloudTalkAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const secretResolution = resolveNextcloudTalkSecret(params.cfg, { accountId });
const baseUrl = merged.baseUrl?.trim()?.replace(/\/$/, "") ?? "";
debugAccounts("resolve", {
accountId,
enabled,
secretSource: secretResolution.source,
baseUrl: baseUrl ? "[set]" : "[missing]",
});
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
baseUrl,
secret: secretResolution.secret,
secretSource: secretResolution.source,
config: merged,
} satisfies ResolvedNextcloudTalkAccount;
};
const normalized = normalizeAccountId(params.accountId);
const primary = resolve(normalized);
if (hasExplicitAccountId) return primary;
if (primary.secretSource !== "none") return primary;
const fallbackId = resolveDefaultNextcloudTalkAccountId(params.cfg);
if (fallbackId === primary.accountId) return primary;
const fallback = resolve(fallbackId);
if (fallback.secretSource === "none") return primary;
return fallback;
}
export function listEnabledNextcloudTalkAccounts(
cfg: CoreConfig,
): ResolvedNextcloudTalkAccount[] {
return listNextcloudTalkAccountIds(cfg)
.map((accountId) => resolveNextcloudTalkAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
+401
View File
@@ -0,0 +1,401 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
normalizeAccountId,
setAccountEnabledInConfigSection,
type ChannelPlugin,
type ClawdbotConfig,
type ChannelSetupInput,
} from "clawdbot/plugin-sdk";
import {
listNextcloudTalkAccountIds,
resolveDefaultNextcloudTalkAccountId,
resolveNextcloudTalkAccount,
type ResolvedNextcloudTalkAccount,
} from "./accounts.js";
import { NextcloudTalkConfigSchema } from "./config-schema.js";
import { monitorNextcloudTalkProvider } from "./monitor.js";
import { looksLikeNextcloudTalkTargetId, normalizeNextcloudTalkMessagingTarget } from "./normalize.js";
import { nextcloudTalkOnboardingAdapter } from "./onboarding.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import { sendMessageNextcloudTalk } from "./send.js";
import type { CoreConfig } from "./types.js";
const meta = {
id: "nextcloud-talk",
label: "Nextcloud Talk",
selectionLabel: "Nextcloud Talk (self-hosted)",
docsPath: "/channels/nextcloud-talk",
docsLabel: "nextcloud-talk",
blurb: "Self-hosted chat via Nextcloud Talk webhook bots.",
aliases: ["nc-talk", "nc"],
order: 65,
quickstartAllowFrom: true,
};
type NextcloudSetupInput = ChannelSetupInput & {
baseUrl?: string;
secret?: string;
secretFile?: string;
useEnv?: boolean;
};
export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> = {
id: "nextcloud-talk",
meta,
onboarding: nextcloudTalkOnboardingAdapter,
pairing: {
idLabel: "nextcloudUserId",
normalizeAllowEntry: (entry) =>
entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(),
notifyApproval: async ({ id }) => {
console.log(`[nextcloud-talk] User ${id} approved for pairing`);
},
},
capabilities: {
chatTypes: ["direct", "group"],
reactions: true,
threads: false,
media: true,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.nextcloud-talk"] },
configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema),
config: {
listAccountIds: (cfg) => listNextcloudTalkAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) =>
resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "nextcloud-talk",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "nextcloud-talk",
accountId,
clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"],
}),
isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.secret?.trim() && account.baseUrl?.trim()),
secretSource: account.secretSource,
baseUrl: account.baseUrl ? "[set]" : "[missing]",
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry).toLowerCase(),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, ""))
.map((entry) => entry.toLowerCase()),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
cfg.channels?.["nextcloud-talk"]?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.nextcloud-talk.accounts.${resolvedAccountId}.`
: "channels.nextcloud-talk.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("nextcloud-talk"),
normalizeEntry: (raw) =>
raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(),
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
const roomAllowlistConfigured =
account.config.rooms && Object.keys(account.config.rooms).length > 0;
if (roomAllowlistConfigured) {
return [
`- Nextcloud Talk rooms: groupPolicy="open" allows any member in allowed rooms to trigger (mention-gated). Set channels.nextcloud-talk.groupPolicy="allowlist" + channels.nextcloud-talk.groupAllowFrom to restrict senders.`,
];
}
return [
`- Nextcloud Talk rooms: groupPolicy="open" with no channels.nextcloud-talk.rooms allowlist; any room can add + ping (mention-gated). Set channels.nextcloud-talk.groupPolicy="allowlist" + channels.nextcloud-talk.groupAllowFrom or configure channels.nextcloud-talk.rooms.`,
];
},
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
const rooms = account.config.rooms;
if (!rooms || !groupId) return true;
const roomConfig = rooms[groupId];
if (roomConfig?.requireMention !== undefined) {
return roomConfig.requireMention;
}
const wildcardConfig = rooms["*"];
if (wildcardConfig?.requireMention !== undefined) {
return wildcardConfig.requireMention;
}
return true;
},
},
messaging: {
normalizeTarget: normalizeNextcloudTalkMessagingTarget,
targetResolver: {
looksLikeId: looksLikeNextcloudTalkTargetId,
hint: "<roomToken>",
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "nextcloud-talk",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
const setupInput = input as NextcloudSetupInput;
if (setupInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account.";
}
if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) {
return "Nextcloud Talk requires bot secret or --secret-file (or --use-env).";
}
if (!setupInput.baseUrl) {
return "Nextcloud Talk requires --base-url.";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const setupInput = input as NextcloudSetupInput;
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "nextcloud-talk",
accountId,
name: setupInput.name,
});
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...namedConfig,
channels: {
...namedConfig.channels,
"nextcloud-talk": {
...namedConfig.channels?.["nextcloud-talk"],
enabled: true,
baseUrl: setupInput.baseUrl,
...(setupInput.useEnv
? {}
: setupInput.secretFile
? { botSecretFile: setupInput.secretFile }
: setupInput.secret
? { botSecret: setupInput.secret }
: {}),
},
},
} as ClawdbotConfig;
}
return {
...namedConfig,
channels: {
...namedConfig.channels,
"nextcloud-talk": {
...namedConfig.channels?.["nextcloud-talk"],
enabled: true,
accounts: {
...namedConfig.channels?.["nextcloud-talk"]?.accounts,
[accountId]: {
...namedConfig.channels?.["nextcloud-talk"]?.accounts?.[accountId],
enabled: true,
baseUrl: setupInput.baseUrl,
...(setupInput.secretFile
? { botSecretFile: setupInput.secretFile }
: setupInput.secret
? { botSecret: setupInput.secret }
: {}),
},
},
},
},
} as ClawdbotConfig;
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, replyToId }) => {
const result = await sendMessageNextcloudTalk(to, text, {
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
});
return { channel: "nextcloud-talk", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
const messageWithMedia = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
const result = await sendMessageNextcloudTalk(to, messageWithMedia, {
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
});
return { channel: "nextcloud-talk", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
secretSource: snapshot.secretSource ?? "none",
running: snapshot.running ?? false,
mode: "webhook",
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
}),
buildAccountSnapshot: ({ account, runtime }) => {
const configured = Boolean(account.secret?.trim() && account.baseUrl?.trim());
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
secretSource: account.secretSource,
baseUrl: account.baseUrl ? "[set]" : "[missing]",
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
mode: "webhook",
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
if (!account.secret || !account.baseUrl) {
throw new Error(
`Nextcloud Talk not configured for account "${account.accountId}" (missing secret or baseUrl)`,
);
}
ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk webhook server`);
const { stop } = await monitorNextcloudTalkProvider({
accountId: account.accountId,
config: ctx.cfg as CoreConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
});
return { stop };
},
logoutAccount: async ({ accountId, cfg }) => {
const nextCfg = { ...cfg } as ClawdbotConfig;
const nextSection = cfg.channels?.["nextcloud-talk"]
? { ...cfg.channels["nextcloud-talk"] }
: undefined;
let cleared = false;
let changed = false;
if (nextSection) {
if (accountId === DEFAULT_ACCOUNT_ID && nextSection.botSecret) {
delete nextSection.botSecret;
cleared = true;
changed = true;
}
const accounts =
nextSection.accounts && typeof nextSection.accounts === "object"
? { ...nextSection.accounts }
: undefined;
if (accounts && accountId in accounts) {
const entry = accounts[accountId];
if (entry && typeof entry === "object") {
const nextEntry = { ...entry } as Record<string, unknown>;
if ("botSecret" in nextEntry) {
const secret = nextEntry.botSecret;
if (typeof secret === "string" ? secret.trim() : secret) {
cleared = true;
}
delete nextEntry.botSecret;
changed = true;
}
if (Object.keys(nextEntry).length === 0) {
delete accounts[accountId];
changed = true;
} else {
accounts[accountId] = nextEntry as typeof entry;
}
}
}
if (accounts) {
if (Object.keys(accounts).length === 0) {
delete nextSection.accounts;
changed = true;
} else {
nextSection.accounts = accounts;
}
}
}
if (changed) {
if (nextSection && Object.keys(nextSection).length > 0) {
nextCfg.channels = { ...nextCfg.channels, "nextcloud-talk": nextSection };
} else {
const nextChannels = { ...nextCfg.channels } as Record<string, unknown>;
delete nextChannels["nextcloud-talk"];
if (Object.keys(nextChannels).length > 0) {
nextCfg.channels = nextChannels as ClawdbotConfig["channels"];
} else {
delete nextCfg.channels;
}
}
}
const resolved = resolveNextcloudTalkAccount({
cfg: (changed ? (nextCfg as CoreConfig) : (cfg as CoreConfig)),
accountId,
});
const loggedOut = resolved.secretSource === "none";
if (changed) {
await getNextcloudTalkRuntime().config.writeConfigFile(nextCfg);
}
return {
cleared,
envSecret: Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()),
loggedOut,
};
},
},
};
@@ -0,0 +1,73 @@
import {
BlockStreamingCoalesceSchema,
DmConfigSchema,
DmPolicySchema,
GroupPolicySchema,
requireOpenAllowFrom,
} from "clawdbot/plugin-sdk";
import { z } from "zod";
export const NextcloudTalkRoomSchema = z
.object({
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
})
.strict();
export const NextcloudTalkAccountSchemaBase = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
baseUrl: z.string().optional(),
botSecret: z.string().optional(),
botSecretFile: z.string().optional(),
apiUser: z.string().optional(),
apiPassword: z.string().optional(),
apiPasswordFile: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
webhookPort: z.number().int().positive().optional(),
webhookHost: z.string().optional(),
webhookPath: z.string().optional(),
webhookPublicUrl: z.string().optional(),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaMaxMb: z.number().positive().optional(),
})
.strict();
export const NextcloudTalkAccountSchema = NextcloudTalkAccountSchemaBase.superRefine(
(value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.nextcloud-talk.dmPolicy="open" requires channels.nextcloud-talk.allowFrom to include "*"',
});
},
);
export const NextcloudTalkConfigSchema = NextcloudTalkAccountSchemaBase.extend({
accounts: z.record(z.string(), NextcloudTalkAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.nextcloud-talk.dmPolicy="open" requires channels.nextcloud-talk.allowFrom to include "*"',
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* Format utilities for Nextcloud Talk messages.
*
* Nextcloud Talk supports markdown natively, so most formatting passes through.
* This module handles any edge cases or transformations needed.
*/
/**
* Convert markdown to Nextcloud Talk compatible format.
* Nextcloud Talk supports standard markdown, so minimal transformation needed.
*/
export function markdownToNextcloudTalk(text: string): string {
return text.trim();
}
/**
* Escape special characters in text to prevent markdown interpretation.
*/
export function escapeNextcloudTalkMarkdown(text: string): string {
return text.replace(/([*_`~[\]()#>+\-=|{}!\\])/g, "\\$1");
}
/**
* Format a mention for a Nextcloud user.
* Nextcloud Talk uses @user format for mentions.
*/
export function formatNextcloudTalkMention(userId: string): string {
return `@${userId.replace(/^@/, "")}`;
}
/**
* Format a code block for Nextcloud Talk.
*/
export function formatNextcloudTalkCodeBlock(code: string, language?: string): string {
const lang = language ?? "";
return `\`\`\`${lang}\n${code}\n\`\`\``;
}
/**
* Format inline code for Nextcloud Talk.
*/
export function formatNextcloudTalkInlineCode(code: string): string {
if (code.includes("`")) {
return `\`\` ${code} \`\``;
}
return `\`${code}\``;
}
/**
* Strip Nextcloud Talk specific formatting from text.
* Useful for extracting plain text content.
*/
export function stripNextcloudTalkFormatting(text: string): string {
return (
text
.replace(/```[\s\S]*?```/g, "")
.replace(/`[^`]+`/g, "")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/_([^_]+)_/g, "$1")
.replace(/~~([^~]+)~~/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/\s+/g, " ")
.trim()
);
}
/**
* Truncate text to a maximum length, preserving word boundaries.
*/
export function truncateNextcloudTalkText(text: string, maxLength: number, suffix = "..."): string {
if (text.length <= maxLength) return text;
const truncated = text.slice(0, maxLength - suffix.length);
const lastSpace = truncated.lastIndexOf(" ");
if (lastSpace > maxLength * 0.7) {
return truncated.slice(0, lastSpace) + suffix;
}
return truncated + suffix;
}
+331
View File
@@ -0,0 +1,331 @@
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
import {
normalizeNextcloudTalkAllowlist,
resolveNextcloudTalkAllowlistMatch,
resolveNextcloudTalkGroupAllow,
resolveNextcloudTalkMentionGate,
resolveNextcloudTalkRequireMention,
resolveNextcloudTalkRoomMatch,
} from "./policy.js";
import { resolveNextcloudTalkRoomKind } from "./room-info.js";
import { sendMessageNextcloudTalk } from "./send.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
const CHANNEL_ID = "nextcloud-talk" as const;
async function deliverNextcloudTalkReply(params: {
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
roomToken: string;
accountId: string;
statusSink?: (patch: { lastOutboundAt?: number }) => void;
}): Promise<void> {
const { payload, roomToken, accountId, statusSink } = params;
const text = payload.text ?? "";
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
if (!text.trim() && mediaList.length === 0) return;
const mediaBlock = mediaList.length
? mediaList.map((url) => `Attachment: ${url}`).join("\n")
: "";
const combined = text.trim()
? mediaBlock
? `${text.trim()}\n\n${mediaBlock}`
: text.trim()
: mediaBlock;
await sendMessageNextcloudTalk(roomToken, combined, {
accountId,
replyTo: payload.replyToId,
});
statusSink?.({ lastOutboundAt: Date.now() });
}
export async function handleNextcloudTalkInbound(params: {
message: NextcloudTalkInboundMessage;
account: ResolvedNextcloudTalkAccount;
config: CoreConfig;
runtime: RuntimeEnv;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}): Promise<void> {
const { message, account, config, runtime, statusSink } = params;
const core = getNextcloudTalkRuntime();
const rawBody = message.text?.trim() ?? "";
if (!rawBody) return;
const roomKind = await resolveNextcloudTalkRoomKind({
account,
roomToken: message.roomToken,
runtime,
});
const isGroup =
roomKind === "direct" ? false : roomKind === "group" ? true : message.isGroupChat;
const senderId = message.senderId;
const senderName = message.senderName;
const roomToken = message.roomToken;
const roomName = message.roomName;
statusSink?.({ lastInboundAt: message.timestamp });
const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom);
const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom);
const storeAllowFrom = await core.channel.pairing
.readAllowFromStore(CHANNEL_ID)
.catch(() => []);
const storeAllowList = normalizeNextcloudTalkAllowlist(storeAllowFrom);
const roomMatch = resolveNextcloudTalkRoomMatch({
rooms: account.config.rooms,
roomToken,
roomName,
});
const roomConfig = roomMatch.roomConfig;
if (isGroup && !roomMatch.allowed) {
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (not allowlisted)`);
return;
}
if (roomConfig?.enabled === false) {
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (disabled)`);
return;
}
const roomAllowFrom = normalizeNextcloudTalkAllowlist(roomConfig?.allowFrom);
const baseGroupAllowFrom =
configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom;
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean);
const effectiveGroupAllowFrom = [...baseGroupAllowFrom, ...storeAllowList].filter(Boolean);
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg: config as ClawdbotConfig,
surface: CHANNEL_ID,
});
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = resolveNextcloudTalkAllowlistMatch({
allowFrom: isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
senderId,
senderName,
}).allowed;
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{
configured:
(isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0,
allowed: senderAllowedForCommands,
},
],
});
if (isGroup) {
const groupAllow = resolveNextcloudTalkGroupAllow({
groupPolicy,
outerAllowFrom: effectiveGroupAllowFrom,
innerAllowFrom: roomAllowFrom,
senderId,
senderName,
});
if (!groupAllow.allowed) {
runtime.log?.(
`nextcloud-talk: drop group sender ${senderId} (policy=${groupPolicy})`,
);
return;
}
} else {
if (dmPolicy === "disabled") {
runtime.log?.(`nextcloud-talk: drop DM sender=${senderId} (dmPolicy=disabled)`);
return;
}
if (dmPolicy !== "open") {
const dmAllowed = resolveNextcloudTalkAllowlistMatch({
allowFrom: effectiveAllowFrom,
senderId,
senderName,
}).allowed;
if (!dmAllowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: CHANNEL_ID,
id: senderId,
meta: { name: senderName || undefined },
});
if (created) {
try {
await sendMessageNextcloudTalk(
roomToken,
core.channel.pairing.buildPairingReply({
channel: CHANNEL_ID,
idLine: `Your Nextcloud user id: ${senderId}`,
code,
}),
{ accountId: account.accountId },
);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(
`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`,
);
}
}
}
runtime.log?.(
`nextcloud-talk: drop DM sender ${senderId} (dmPolicy=${dmPolicy})`,
);
return;
}
}
}
if (
isGroup &&
allowTextCommands &&
core.channel.text.hasControlCommand(rawBody, config as ClawdbotConfig) &&
commandAuthorized !== true
) {
runtime.log?.(
`nextcloud-talk: drop control command from unauthorized sender ${senderId}`,
);
return;
}
const mentionRegexes = core.channel.mentions.buildMentionRegexes(
config as ClawdbotConfig,
);
const wasMentioned = mentionRegexes.length
? core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes)
: false;
const shouldRequireMention = isGroup
? resolveNextcloudTalkRequireMention({
roomConfig,
wildcardConfig: roomMatch.wildcardConfig,
})
: false;
const hasControlCommand = core.channel.text.hasControlCommand(
rawBody,
config as ClawdbotConfig,
);
const mentionGate = resolveNextcloudTalkMentionGate({
isGroup,
requireMention: shouldRequireMention,
wasMentioned,
allowTextCommands,
hasControlCommand,
commandAuthorized,
});
if (isGroup && mentionGate.shouldSkip) {
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`);
return;
}
const route = core.channel.routing.resolveAgentRoute({
cfg: config as ClawdbotConfig,
channel: CHANNEL_ID,
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: isGroup ? roomToken : senderId,
},
});
const fromLabel = isGroup
? `room:${roomName || roomToken}`
: senderName || `user:${senderId}`;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(
config as ClawdbotConfig,
);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Nextcloud Talk",
from: fromLabel,
timestamp: message.timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});
const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`,
To: `nextcloud-talk:${roomToken}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
SenderName: senderName || undefined,
SenderId: senderId,
GroupSubject: isGroup ? roomName || roomToken : undefined,
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
Provider: CHANNEL_ID,
Surface: CHANNEL_ID,
WasMentioned: isGroup ? wasMentioned : undefined,
MessageSid: message.messageId,
Timestamp: message.timestamp,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: `nextcloud-talk:${roomToken}`,
CommandAuthorized: commandAuthorized,
});
void core.channel.session
.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
})
.catch((err) => {
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config as ClawdbotConfig,
dispatcherOptions: {
deliver: async (payload) => {
await deliverNextcloudTalkReply({
payload: payload as {
text?: string;
mediaUrls?: string[];
mediaUrl?: string;
replyToId?: string;
},
roomToken,
accountId: account.accountId,
statusSink,
});
},
onError: (err, info) => {
runtime.error?.(
`nextcloud-talk ${info.kind} reply failed: ${String(err)}`,
);
},
},
replyOptions: {
skillFilter: roomConfig?.skills,
disableBlockStreaming:
typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming
: undefined,
},
});
}
+246
View File
@@ -0,0 +1,246 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import { resolveNextcloudTalkAccount } from "./accounts.js";
import { handleNextcloudTalkInbound } from "./inbound.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js";
import type {
CoreConfig,
NextcloudTalkInboundMessage,
NextcloudTalkWebhookPayload,
NextcloudTalkWebhookServerOptions,
} from "./types.js";
const DEFAULT_WEBHOOK_PORT = 8788;
const DEFAULT_WEBHOOK_HOST = "0.0.0.0";
const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";
const HEALTH_PATH = "/healthz";
function formatError(err: unknown): string {
if (err instanceof Error) return err.message;
return typeof err === "string" ? err : JSON.stringify(err);
}
function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null {
try {
const data = JSON.parse(body);
if (
!data.type ||
!data.actor?.type ||
!data.actor?.id ||
!data.object?.type ||
!data.object?.id ||
!data.target?.type ||
!data.target?.id
) {
return null;
}
return data as NextcloudTalkWebhookPayload;
} catch {
return null;
}
}
function payloadToInboundMessage(
payload: NextcloudTalkWebhookPayload,
): NextcloudTalkInboundMessage {
// Payload doesn't indicate DM vs room; mark as group and let inbound handler refine.
const isGroupChat = true;
return {
messageId: String(payload.object.id),
roomToken: payload.target.id,
roomName: payload.target.name,
senderId: payload.actor.id,
senderName: payload.actor.name,
text: payload.object.content || payload.object.name || "",
mediaType: payload.object.mediaType || "text/plain",
timestamp: Date.now(),
isGroupChat,
};
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
req.on("error", reject);
});
}
export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServerOptions): {
server: Server;
start: () => Promise<void>;
stop: () => void;
} {
const { port, host, path, secret, onMessage, onError, abortSignal } = opts;
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
if (req.url === HEALTH_PATH) {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("ok");
return;
}
if (req.url !== path || req.method !== "POST") {
res.writeHead(404);
res.end();
return;
}
try {
const body = await readBody(req);
const headers = extractNextcloudTalkHeaders(
req.headers as Record<string, string | string[] | undefined>,
);
if (!headers) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Missing signature headers" }));
return;
}
const isValid = verifyNextcloudTalkSignature({
signature: headers.signature,
random: headers.random,
body,
secret,
});
if (!isValid) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid signature" }));
return;
}
const payload = parseWebhookPayload(body);
if (!payload) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid payload format" }));
return;
}
if (payload.type !== "Create") {
res.writeHead(200);
res.end();
return;
}
const message = payloadToInboundMessage(payload);
res.writeHead(200);
res.end();
try {
await onMessage(message);
} catch (err) {
onError?.(err instanceof Error ? err : new Error(formatError(err)));
}
} catch (err) {
const error = err instanceof Error ? err : new Error(formatError(err));
onError?.(error);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Internal server error" }));
}
}
});
const start = (): Promise<void> => {
return new Promise((resolve) => {
server.listen(port, host, () => resolve());
});
};
const stop = () => {
server.close();
};
if (abortSignal) {
abortSignal.addEventListener("abort", stop, { once: true });
}
return { server, start, stop };
}
export type NextcloudTalkMonitorOptions = {
accountId?: string;
config?: CoreConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
onMessage?: (message: NextcloudTalkInboundMessage) => void | Promise<void>;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
export async function monitorNextcloudTalkProvider(
opts: NextcloudTalkMonitorOptions,
): Promise<{ stop: () => void }> {
const core = getNextcloudTalkRuntime();
const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig);
const account = resolveNextcloudTalkAccount({
cfg,
accountId: opts.accountId,
});
const runtime: RuntimeEnv = opts.runtime ?? {
log: (message: string) => core.logging.getChildLogger().info(message),
error: (message: string) => core.logging.getChildLogger().error(message),
exit: () => {
throw new Error("Runtime exit not available");
},
};
if (!account.secret) {
throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`);
}
const port = account.config.webhookPort ?? DEFAULT_WEBHOOK_PORT;
const host = account.config.webhookHost ?? DEFAULT_WEBHOOK_HOST;
const path = account.config.webhookPath ?? DEFAULT_WEBHOOK_PATH;
const logger = core.logging.getChildLogger({
channel: "nextcloud-talk",
accountId: account.accountId,
});
const { start, stop } = createNextcloudTalkWebhookServer({
port,
host,
path,
secret: account.secret,
onMessage: async (message) => {
core.channel.activity.record({
channel: "nextcloud-talk",
accountId: account.accountId,
direction: "inbound",
at: message.timestamp,
});
if (opts.onMessage) {
await opts.onMessage(message);
return;
}
await handleNextcloudTalkInbound({
message,
account,
config: cfg,
runtime,
statusSink: opts.statusSink,
});
},
onError: (error) => {
logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`);
},
abortSignal: opts.abortSignal,
});
await start();
const publicUrl =
account.config.webhookPublicUrl ??
`http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`;
logger.info(`[nextcloud-talk:${account.accountId}] webhook listening on ${publicUrl}`);
return { stop };
}
@@ -0,0 +1,31 @@
export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
let normalized = trimmed;
if (normalized.startsWith("nextcloud-talk:")) {
normalized = normalized.slice("nextcloud-talk:".length).trim();
} else if (normalized.startsWith("nc-talk:")) {
normalized = normalized.slice("nc-talk:".length).trim();
} else if (normalized.startsWith("nc:")) {
normalized = normalized.slice("nc:".length).trim();
}
if (normalized.startsWith("room:")) {
normalized = normalized.slice("room:".length).trim();
}
if (!normalized) return undefined;
return `nextcloud-talk:${normalized}`.toLowerCase();
}
export function looksLikeNextcloudTalkTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(nextcloud-talk|nc-talk|nc):/i.test(trimmed)) return true;
return /^[a-z0-9]{8,}$/i.test(trimmed);
}
+341
View File
@@ -0,0 +1,341 @@
import {
addWildcardAllowFrom,
formatDocsLink,
promptAccountId,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type WizardPrompter,
} from "clawdbot/plugin-sdk";
import {
listNextcloudTalkAccountIds,
resolveDefaultNextcloudTalkAccountId,
resolveNextcloudTalkAccount,
} from "./accounts.js";
import type { CoreConfig, DmPolicy } from "./types.js";
const channel = "nextcloud-talk" as const;
function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
const existingConfig = cfg.channels?.["nextcloud-talk"];
const existingAllowFrom: string[] = (existingConfig?.allowFrom ?? []).map((x) => String(x));
const allowFrom: string[] =
dmPolicy === "open" ? (addWildcardAllowFrom(existingAllowFrom) as string[]) : existingAllowFrom;
const newNextcloudTalkConfig = {
...existingConfig,
dmPolicy,
allowFrom,
};
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": newNextcloudTalkConfig,
},
} as CoreConfig;
}
async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) SSH into your Nextcloud server",
'2) Run: ./occ talk:bot:install "Clawdbot" "<shared-secret>" "<webhook-url>" --feature reaction',
"3) Copy the shared secret you used in the command",
"4) Enable the bot in your Nextcloud Talk room settings",
"Tip: you can also set NEXTCLOUD_TALK_BOT_SECRET in your env.",
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
].join("\n"),
"Nextcloud Talk bot setup",
);
}
async function noteNextcloudTalkUserIdHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) Check the Nextcloud admin panel for user IDs",
"2) Or look at the webhook payload logs when someone messages",
"3) User IDs are typically lowercase usernames in Nextcloud",
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
].join("\n"),
"Nextcloud Talk user id",
);
}
async function promptNextcloudTalkAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<CoreConfig> {
const { cfg, prompter, accountId } = params;
const resolved = resolveNextcloudTalkAccount({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
await noteNextcloudTalkUserIdHelp(prompter);
const parseInput = (value: string) =>
value
.split(/[\n,;]+/g)
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
let resolvedIds: string[] = [];
while (resolvedIds.length === 0) {
const entry = await prompter.text({
message: "Nextcloud Talk allowFrom (user id)",
placeholder: "username",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
resolvedIds = parseInput(String(entry));
if (resolvedIds.length === 0) {
await prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk allowlist");
}
}
const merged = [
...existingAllowFrom.map((item) => String(item).trim().toLowerCase()).filter(Boolean),
...resolvedIds,
];
const unique = [...new Set(merged)];
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": {
...cfg.channels?.["nextcloud-talk"],
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": {
...cfg.channels?.["nextcloud-talk"],
enabled: true,
accounts: {
...cfg.channels?.["nextcloud-talk"]?.accounts,
[accountId]: {
...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId],
enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
},
};
}
async function promptNextcloudTalkAllowFromForAccount(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<CoreConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultNextcloudTalkAccountId(params.cfg);
return promptNextcloudTalkAllowFrom({
cfg: params.cfg,
prompter: params.prompter,
accountId,
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Nextcloud Talk",
channel,
policyKey: "channels.nextcloud-talk.dmPolicy",
allowFromKey: "channels.nextcloud-talk.allowFrom",
getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy),
promptAllowFrom: promptNextcloudTalkAllowFromForAccount,
};
export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listNextcloudTalkAccountIds(cfg as CoreConfig).some((accountId) => {
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
return Boolean(account.secret && account.baseUrl);
});
return {
channel,
configured,
statusLines: [`Nextcloud Talk: ${configured ? "configured" : "needs setup"}`],
selectionHint: configured ? "configured" : "self-hosted chat",
quickstartScore: configured ? 1 : 5,
};
},
configure: async ({
cfg,
prompter,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom,
}) => {
const nextcloudTalkOverride = accountOverrides["nextcloud-talk"]?.trim();
const defaultAccountId = resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig);
let accountId = nextcloudTalkOverride
? normalizeAccountId(nextcloudTalkOverride)
: defaultAccountId;
if (shouldPromptAccountIds && !nextcloudTalkOverride) {
accountId = await promptAccountId({
cfg: cfg as CoreConfig,
prompter,
label: "Nextcloud Talk",
currentId: accountId,
listAccountIds: listNextcloudTalkAccountIds,
defaultAccountId,
});
}
let next = cfg as CoreConfig;
const resolvedAccount = resolveNextcloudTalkAccount({
cfg: next,
accountId,
});
const accountConfigured = Boolean(resolvedAccount.secret && resolvedAccount.baseUrl);
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const canUseEnv = allowEnv && Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim());
const hasConfigSecret = Boolean(
resolvedAccount.config.botSecret || resolvedAccount.config.botSecretFile,
);
let baseUrl = resolvedAccount.baseUrl;
if (!baseUrl) {
baseUrl = String(
await prompter.text({
message: "Enter Nextcloud instance URL (e.g., https://cloud.example.com)",
validate: (value) => {
const v = String(value ?? "").trim();
if (!v) return "Required";
if (!v.startsWith("http://") && !v.startsWith("https://")) {
return "URL must start with http:// or https://";
}
return undefined;
},
}),
).trim();
}
let secret: string | null = null;
if (!accountConfigured) {
await noteNextcloudTalkSecretHelp(prompter);
}
if (canUseEnv && !resolvedAccount.config.botSecret) {
const keepEnv = await prompter.confirm({
message: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?",
initialValue: true,
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
"nextcloud-talk": {
...next.channels?.["nextcloud-talk"],
enabled: true,
baseUrl,
},
},
};
} else {
secret = String(
await prompter.text({
message: "Enter Nextcloud Talk bot secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else if (hasConfigSecret) {
const keep = await prompter.confirm({
message: "Nextcloud Talk secret already configured. Keep it?",
initialValue: true,
});
if (!keep) {
secret = String(
await prompter.text({
message: "Enter Nextcloud Talk bot secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else {
secret = String(
await prompter.text({
message: "Enter Nextcloud Talk bot secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (secret || baseUrl !== resolvedAccount.baseUrl) {
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
"nextcloud-talk": {
...next.channels?.["nextcloud-talk"],
enabled: true,
baseUrl,
...(secret ? { botSecret: secret } : {}),
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
"nextcloud-talk": {
...next.channels?.["nextcloud-talk"],
enabled: true,
accounts: {
...next.channels?.["nextcloud-talk"]?.accounts,
[accountId]: {
...next.channels?.["nextcloud-talk"]?.accounts?.[accountId],
enabled: next.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
baseUrl,
...(secret ? { botSecret: secret } : {}),
},
},
},
},
};
}
}
if (forceAllowFrom) {
next = await promptNextcloudTalkAllowFrom({
cfg: next,
prompter,
accountId,
});
}
return { cfg: next, accountId };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": { ...cfg.channels?.["nextcloud-talk"], enabled: false },
},
}),
};
+160
View File
@@ -0,0 +1,160 @@
import type { AllowlistMatch, GroupPolicy } from "clawdbot/plugin-sdk";
import {
buildChannelKeyCandidates,
normalizeChannelSlug,
resolveChannelEntryMatchWithFallback,
resolveMentionGatingWithBypass,
resolveNestedAllowlistDecision,
} from "clawdbot/plugin-sdk";
import type { NextcloudTalkRoomConfig } from "./types.js";
function normalizeAllowEntry(raw: string): string {
return raw.trim().toLowerCase().replace(/^(nextcloud-talk|nc-talk|nc):/i, "");
}
export function normalizeNextcloudTalkAllowlist(
values: Array<string | number> | undefined,
): string[] {
return (values ?? []).map((value) => normalizeAllowEntry(String(value))).filter(Boolean);
}
export function resolveNextcloudTalkAllowlistMatch(params: {
allowFrom: Array<string | number> | undefined;
senderId: string;
senderName?: string | null;
}): AllowlistMatch<"wildcard" | "id" | "name"> {
const allowFrom = normalizeNextcloudTalkAllowlist(params.allowFrom);
if (allowFrom.length === 0) return { allowed: false };
if (allowFrom.includes("*")) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
const senderId = normalizeAllowEntry(params.senderId);
if (allowFrom.includes(senderId)) {
return { allowed: true, matchKey: senderId, matchSource: "id" };
}
const senderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
if (senderName && allowFrom.includes(senderName)) {
return { allowed: true, matchKey: senderName, matchSource: "name" };
}
return { allowed: false };
}
export type NextcloudTalkRoomMatch = {
roomConfig?: NextcloudTalkRoomConfig;
wildcardConfig?: NextcloudTalkRoomConfig;
roomKey?: string;
matchSource?: "direct" | "parent" | "wildcard";
allowed: boolean;
allowlistConfigured: boolean;
};
export function resolveNextcloudTalkRoomMatch(params: {
rooms?: Record<string, NextcloudTalkRoomConfig>;
roomToken: string;
roomName?: string | null;
}): NextcloudTalkRoomMatch {
const rooms = params.rooms ?? {};
const allowlistConfigured = Object.keys(rooms).length > 0;
const roomName = params.roomName?.trim() || undefined;
const roomCandidates = buildChannelKeyCandidates(
params.roomToken,
roomName,
roomName ? normalizeChannelSlug(roomName) : undefined,
);
const match = resolveChannelEntryMatchWithFallback({
entries: rooms,
keys: roomCandidates,
wildcardKey: "*",
normalizeKey: normalizeChannelSlug,
});
const roomConfig = match.entry;
const allowed = resolveNestedAllowlistDecision({
outerConfigured: allowlistConfigured,
outerMatched: Boolean(roomConfig),
innerConfigured: false,
innerMatched: false,
});
return {
roomConfig,
wildcardConfig: match.wildcardEntry,
roomKey: match.matchKey ?? match.key,
matchSource: match.matchSource,
allowed,
allowlistConfigured,
};
}
export function resolveNextcloudTalkRequireMention(params: {
roomConfig?: NextcloudTalkRoomConfig;
wildcardConfig?: NextcloudTalkRoomConfig;
}): boolean {
if (typeof params.roomConfig?.requireMention === "boolean") {
return params.roomConfig.requireMention;
}
if (typeof params.wildcardConfig?.requireMention === "boolean") {
return params.wildcardConfig.requireMention;
}
return true;
}
export function resolveNextcloudTalkGroupAllow(params: {
groupPolicy: GroupPolicy;
outerAllowFrom: Array<string | number> | undefined;
innerAllowFrom: Array<string | number> | undefined;
senderId: string;
senderName?: string | null;
}): { allowed: boolean; outerMatch: AllowlistMatch; innerMatch: AllowlistMatch } {
if (params.groupPolicy === "disabled") {
return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
}
if (params.groupPolicy === "open") {
return { allowed: true, outerMatch: { allowed: true }, innerMatch: { allowed: true } };
}
const outerAllow = normalizeNextcloudTalkAllowlist(params.outerAllowFrom);
const innerAllow = normalizeNextcloudTalkAllowlist(params.innerAllowFrom);
if (outerAllow.length === 0 && innerAllow.length === 0) {
return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
}
const outerMatch = resolveNextcloudTalkAllowlistMatch({
allowFrom: params.outerAllowFrom,
senderId: params.senderId,
senderName: params.senderName,
});
const innerMatch = resolveNextcloudTalkAllowlistMatch({
allowFrom: params.innerAllowFrom,
senderId: params.senderId,
senderName: params.senderName,
});
const allowed = resolveNestedAllowlistDecision({
outerConfigured: outerAllow.length > 0 || innerAllow.length > 0,
outerMatched: outerAllow.length > 0 ? outerMatch.allowed : true,
innerConfigured: innerAllow.length > 0,
innerMatched: innerMatch.allowed,
});
return { allowed, outerMatch, innerMatch };
}
export function resolveNextcloudTalkMentionGate(params: {
isGroup: boolean;
requireMention: boolean;
wasMentioned: boolean;
allowTextCommands: boolean;
hasControlCommand: boolean;
commandAuthorized: boolean;
}): { shouldSkip: boolean; shouldBypassMention: boolean } {
const result = resolveMentionGatingWithBypass({
isGroup: params.isGroup,
requireMention: params.requireMention,
canDetectMention: true,
wasMentioned: params.wasMentioned,
allowTextCommands: params.allowTextCommands,
hasControlCommand: params.hasControlCommand,
commandAuthorized: params.commandAuthorized,
});
return { shouldSkip: result.shouldSkip, shouldBypassMention: result.shouldBypassMention };
}
+111
View File
@@ -0,0 +1,111 @@
import { readFileSync } from "node:fs";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
const ROOM_CACHE_TTL_MS = 5 * 60 * 1000;
const ROOM_CACHE_ERROR_TTL_MS = 30 * 1000;
const roomCache = new Map<
string,
{ kind?: "direct" | "group"; fetchedAt: number; error?: string }
>();
function resolveRoomCacheKey(params: { accountId: string; roomToken: string }) {
return `${params.accountId}:${params.roomToken}`;
}
function readApiPassword(params: {
apiPassword?: string;
apiPasswordFile?: string;
}): string | undefined {
if (params.apiPassword?.trim()) return params.apiPassword.trim();
if (!params.apiPasswordFile) return undefined;
try {
const value = readFileSync(params.apiPasswordFile, "utf-8").trim();
return value || undefined;
} catch {
return undefined;
}
}
function coerceRoomType(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function resolveRoomKindFromType(type: number | undefined): "direct" | "group" | undefined {
if (!type) return undefined;
if (type === 1 || type === 5 || type === 6) return "direct";
return "group";
}
export async function resolveNextcloudTalkRoomKind(params: {
account: ResolvedNextcloudTalkAccount;
roomToken: string;
runtime?: RuntimeEnv;
}): Promise<"direct" | "group" | undefined> {
const { account, roomToken, runtime } = params;
const key = resolveRoomCacheKey({ accountId: account.accountId, roomToken });
const cached = roomCache.get(key);
if (cached) {
const age = Date.now() - cached.fetchedAt;
if (cached.kind && age < ROOM_CACHE_TTL_MS) return cached.kind;
if (cached.error && age < ROOM_CACHE_ERROR_TTL_MS) return undefined;
}
const apiUser = account.config.apiUser?.trim();
const apiPassword = readApiPassword({
apiPassword: account.config.apiPassword,
apiPasswordFile: account.config.apiPasswordFile,
});
if (!apiUser || !apiPassword) return undefined;
const baseUrl = account.baseUrl?.trim();
if (!baseUrl) return undefined;
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room/${roomToken}`;
const auth = Buffer.from(`${apiUser}:${apiPassword}`, "utf-8").toString("base64");
try {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Basic ${auth}`,
"OCS-APIRequest": "true",
Accept: "application/json",
},
});
if (!response.ok) {
roomCache.set(key, {
fetchedAt: Date.now(),
error: `status:${response.status}`,
});
runtime?.log?.(
`nextcloud-talk: room lookup failed (${response.status}) token=${roomToken}`,
);
return undefined;
}
const payload = (await response.json()) as {
ocs?: { data?: { type?: number | string } };
};
const type = coerceRoomType(payload.ocs?.data?.type);
const kind = resolveRoomKindFromType(type);
roomCache.set(key, { fetchedAt: Date.now(), kind });
return kind;
} catch (err) {
roomCache.set(key, {
fetchedAt: Date.now(),
error: err instanceof Error ? err.message : String(err),
});
runtime?.error?.(`nextcloud-talk: room lookup error: ${String(err)}`);
return undefined;
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setNextcloudTalkRuntime(next: PluginRuntime) {
runtime = next;
}
export function getNextcloudTalkRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Nextcloud Talk runtime not initialized");
}
return runtime;
}
+196
View File
@@ -0,0 +1,196 @@
import { resolveNextcloudTalkAccount } from "./accounts.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import { generateNextcloudTalkSignature } from "./signature.js";
import type { CoreConfig, NextcloudTalkSendResult } from "./types.js";
type NextcloudTalkSendOpts = {
baseUrl?: string;
secret?: string;
accountId?: string;
replyTo?: string;
verbose?: boolean;
};
function resolveCredentials(
explicit: { baseUrl?: string; secret?: string },
account: { baseUrl: string; secret: string; accountId: string },
): { baseUrl: string; secret: string } {
const baseUrl = explicit.baseUrl?.trim() ?? account.baseUrl;
const secret = explicit.secret?.trim() ?? account.secret;
if (!baseUrl) {
throw new Error(
`Nextcloud Talk baseUrl missing for account "${account.accountId}" (set channels.nextcloud-talk.baseUrl).`,
);
}
if (!secret) {
throw new Error(
`Nextcloud Talk bot secret missing for account "${account.accountId}" (set channels.nextcloud-talk.botSecret/botSecretFile or NEXTCLOUD_TALK_BOT_SECRET for default).`,
);
}
return { baseUrl, secret };
}
function normalizeRoomToken(to: string): string {
const trimmed = to.trim();
if (!trimmed) throw new Error("Room token is required for Nextcloud Talk sends");
let normalized = trimmed;
if (normalized.startsWith("nextcloud-talk:")) {
normalized = normalized.slice("nextcloud-talk:".length).trim();
} else if (normalized.startsWith("nc:")) {
normalized = normalized.slice("nc:".length).trim();
}
if (normalized.startsWith("room:")) {
normalized = normalized.slice("room:".length).trim();
}
if (!normalized) throw new Error("Room token is required for Nextcloud Talk sends");
return normalized;
}
export async function sendMessageNextcloudTalk(
to: string,
text: string,
opts: NextcloudTalkSendOpts = {},
): Promise<NextcloudTalkSendResult> {
const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig;
const account = resolveNextcloudTalkAccount({
cfg,
accountId: opts.accountId,
});
const { baseUrl, secret } = resolveCredentials(
{ baseUrl: opts.baseUrl, secret: opts.secret },
account,
);
const roomToken = normalizeRoomToken(to);
if (!text?.trim()) {
throw new Error("Message must be non-empty for Nextcloud Talk sends");
}
const body: Record<string, unknown> = {
message: text.trim(),
};
if (opts.replyTo) {
body.replyTo = opts.replyTo;
}
const bodyStr = JSON.stringify(body);
const { random, signature } = generateNextcloudTalkSignature({
body: bodyStr,
secret,
});
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${roomToken}/message`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"OCS-APIRequest": "true",
"X-Nextcloud-Talk-Bot-Random": random,
"X-Nextcloud-Talk-Bot-Signature": signature,
},
body: bodyStr,
});
if (!response.ok) {
const errorBody = await response.text().catch(() => "");
const status = response.status;
let errorMsg = `Nextcloud Talk send failed (${status})`;
if (status === 400) {
errorMsg = `Nextcloud Talk: bad request - ${errorBody || "invalid message format"}`;
} else if (status === 401) {
errorMsg = "Nextcloud Talk: authentication failed - check bot secret";
} else if (status === 403) {
errorMsg = "Nextcloud Talk: forbidden - bot may not have permission in this room";
} else if (status === 404) {
errorMsg = `Nextcloud Talk: room not found (token=${roomToken})`;
} else if (errorBody) {
errorMsg = `Nextcloud Talk send failed: ${errorBody}`;
}
throw new Error(errorMsg);
}
let messageId = "unknown";
let timestamp: number | undefined;
try {
const data = (await response.json()) as {
ocs?: {
data?: {
id?: number | string;
timestamp?: number;
};
};
};
if (data.ocs?.data?.id != null) {
messageId = String(data.ocs.data.id);
}
if (typeof data.ocs?.data?.timestamp === "number") {
timestamp = data.ocs.data.timestamp;
}
} catch {
// Response parsing failed, but message was sent.
}
if (opts.verbose) {
console.log(`[nextcloud-talk] Sent message ${messageId} to room ${roomToken}`);
}
getNextcloudTalkRuntime().channel.activity.record({
channel: "nextcloud-talk",
accountId: account.accountId,
direction: "outbound",
});
return { messageId, roomToken, timestamp };
}
export async function sendReactionNextcloudTalk(
roomToken: string,
messageId: string,
reaction: string,
opts: Omit<NextcloudTalkSendOpts, "replyTo"> = {},
): Promise<{ ok: true }> {
const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig;
const account = resolveNextcloudTalkAccount({
cfg,
accountId: opts.accountId,
});
const { baseUrl, secret } = resolveCredentials(
{ baseUrl: opts.baseUrl, secret: opts.secret },
account,
);
const normalizedToken = normalizeRoomToken(roomToken);
const body = JSON.stringify({ reaction });
const { random, signature } = generateNextcloudTalkSignature({
body,
secret,
});
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${normalizedToken}/reaction/${messageId}`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"OCS-APIRequest": "true",
"X-Nextcloud-Talk-Bot-Random": random,
"X-Nextcloud-Talk-Bot-Signature": signature,
},
body,
});
if (!response.ok) {
const errorBody = await response.text().catch(() => "");
throw new Error(`Nextcloud Talk reaction failed: ${response.status} ${errorBody}`.trim());
}
return { ok: true };
}
@@ -0,0 +1,67 @@
import { createHmac, randomBytes } from "node:crypto";
import type { NextcloudTalkWebhookHeaders } from "./types.js";
const SIGNATURE_HEADER = "x-nextcloud-talk-signature";
const RANDOM_HEADER = "x-nextcloud-talk-random";
const BACKEND_HEADER = "x-nextcloud-talk-backend";
/**
* Verify the HMAC-SHA256 signature of an incoming webhook request.
* Signature is calculated as: HMAC-SHA256(random + body, secret)
*/
export function verifyNextcloudTalkSignature(params: {
signature: string;
random: string;
body: string;
secret: string;
}): boolean {
const { signature, random, body, secret } = params;
if (!signature || !random || !secret) return false;
const expected = createHmac("sha256", secret)
.update(random + body)
.digest("hex");
if (signature.length !== expected.length) return false;
let result = 0;
for (let i = 0; i < signature.length; i++) {
result |= signature.charCodeAt(i) ^ expected.charCodeAt(i);
}
return result === 0;
}
/**
* Extract webhook headers from an incoming request.
*/
export function extractNextcloudTalkHeaders(
headers: Record<string, string | string[] | undefined>,
): NextcloudTalkWebhookHeaders | null {
const getHeader = (name: string): string | undefined => {
const value = headers[name] ?? headers[name.toLowerCase()];
return Array.isArray(value) ? value[0] : value;
};
const signature = getHeader(SIGNATURE_HEADER);
const random = getHeader(RANDOM_HEADER);
const backend = getHeader(BACKEND_HEADER);
if (!signature || !random || !backend) return null;
return { signature, random, backend };
}
/**
* Generate signature headers for an outbound request to Nextcloud Talk.
*/
export function generateNextcloudTalkSignature(params: { body: string; secret: string }): {
random: string;
signature: string;
} {
const { body, secret } = params;
const random = randomBytes(32).toString("hex");
const signature = createHmac("sha256", secret)
.update(random + body)
.digest("hex");
return { random, signature };
}
+175
View File
@@ -0,0 +1,175 @@
import type {
BlockStreamingCoalesceConfig,
DmConfig,
DmPolicy,
GroupPolicy,
} from "clawdbot/plugin-sdk";
export type NextcloudTalkRoomConfig = {
requireMention?: boolean;
/** If specified, only load these skills for this room. Omit = all skills; empty = no skills. */
skills?: string[];
/** If false, disable the bot for this room. */
enabled?: boolean;
/** Optional allowlist for room senders (user ids). */
allowFrom?: string[];
/** Optional system prompt snippet for this room. */
systemPrompt?: string;
};
export type NextcloudTalkAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** If false, do not start this Nextcloud Talk account. Default: true. */
enabled?: boolean;
/** Base URL of the Nextcloud instance (e.g., "https://cloud.example.com"). */
baseUrl?: string;
/** Bot shared secret from occ talk:bot:install output. */
botSecret?: string;
/** Path to file containing bot secret (for secret managers). */
botSecretFile?: string;
/** Optional API user for room lookups (DM detection). */
apiUser?: string;
/** Optional API password/app password for room lookups. */
apiPassword?: string;
/** Path to file containing API password/app password. */
apiPasswordFile?: string;
/** Direct message policy (default: pairing). */
dmPolicy?: DmPolicy;
/** Webhook server port. Default: 8788. */
webhookPort?: number;
/** Webhook server host. Default: "0.0.0.0". */
webhookHost?: string;
/** Webhook endpoint path. Default: "/nextcloud-talk-webhook". */
webhookPath?: string;
/** Public URL for the webhook (used if behind reverse proxy). */
webhookPublicUrl?: string;
/** Optional allowlist of user IDs allowed to DM the bot. */
allowFrom?: string[];
/** Optional allowlist for Nextcloud Talk room senders (user ids). */
groupAllowFrom?: string[];
/** Group message policy (default: allowlist). */
groupPolicy?: GroupPolicy;
/** Per-room configuration (key is room token). */
rooms?: Record<string, NextcloudTalkRoomConfig>;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */
dmHistoryLimit?: number;
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Media upload max size in MB. */
mediaMaxMb?: number;
};
export type NextcloudTalkConfig = {
/** Optional per-account Nextcloud Talk configuration (multi-account). */
accounts?: Record<string, NextcloudTalkAccountConfig>;
} & NextcloudTalkAccountConfig;
export type CoreConfig = {
channels?: {
"nextcloud-talk"?: NextcloudTalkConfig;
};
[key: string]: unknown;
};
/**
* Nextcloud Talk webhook payload types based on Activity Streams 2.0 format.
* Reference: https://nextcloud-talk.readthedocs.io/en/latest/bots/
*/
/** Actor in the activity (the message sender). */
export type NextcloudTalkActor = {
type: "Person";
/** User ID in Nextcloud. */
id: string;
/** Display name of the user. */
name: string;
};
/** The message object in the activity. */
export type NextcloudTalkObject = {
type: "Note";
/** Message ID. */
id: string;
/** Message text (same as content for text/plain). */
name: string;
/** Message content. */
content: string;
/** Media type of the content. */
mediaType: string;
};
/** Target conversation/room. */
export type NextcloudTalkTarget = {
type: "Collection";
/** Room token. */
id: string;
/** Room display name. */
name: string;
};
/** Incoming webhook payload from Nextcloud Talk. */
export type NextcloudTalkWebhookPayload = {
type: "Create" | "Update" | "Delete";
actor: NextcloudTalkActor;
object: NextcloudTalkObject;
target: NextcloudTalkTarget;
};
/** Result from sending a message to Nextcloud Talk. */
export type NextcloudTalkSendResult = {
messageId: string;
roomToken: string;
timestamp?: number;
};
/** Parsed incoming message context. */
export type NextcloudTalkInboundMessage = {
messageId: string;
roomToken: string;
roomName: string;
senderId: string;
senderName: string;
text: string;
mediaType: string;
timestamp: number;
isGroupChat: boolean;
};
/** Headers sent by Nextcloud Talk webhook. */
export type NextcloudTalkWebhookHeaders = {
/** HMAC-SHA256 signature of the request. */
signature: string;
/** Random string used in signature calculation. */
random: string;
/** Backend Nextcloud server URL. */
backend: string;
};
/** Options for the webhook server. */
export type NextcloudTalkWebhookServerOptions = {
port: number;
host: string;
path: string;
secret: string;
onMessage: (message: NextcloudTalkInboundMessage) => void | Promise<void>;
onError?: (error: Error) => void;
abortSignal?: AbortSignal;
};
/** Options for sending a message. */
export type NextcloudTalkSendOptions = {
baseUrl: string;
secret: string;
roomToken: string;
message: string;
replyTo?: string;
};
@@ -0,0 +1,11 @@
{
"id": "qwen-portal-auth",
"providers": [
"qwen-portal"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "signal",
"channels": [
"signal"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "slack",
"channels": [
"slack"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "telegram",
"channels": [
"telegram"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+405
View File
@@ -0,0 +1,405 @@
{
"id": "voice-call",
"uiHints": {
"provider": {
"label": "Provider",
"help": "Use twilio, telnyx, or mock for dev/no-network."
},
"fromNumber": {
"label": "From Number",
"placeholder": "+15550001234"
},
"toNumber": {
"label": "Default To Number",
"placeholder": "+15550001234"
},
"inboundPolicy": {
"label": "Inbound Policy"
},
"allowFrom": {
"label": "Inbound Allowlist"
},
"inboundGreeting": {
"label": "Inbound Greeting",
"advanced": true
},
"telnyx.apiKey": {
"label": "Telnyx API Key",
"sensitive": true
},
"telnyx.connectionId": {
"label": "Telnyx Connection ID"
},
"telnyx.publicKey": {
"label": "Telnyx Public Key",
"sensitive": true
},
"twilio.accountSid": {
"label": "Twilio Account SID"
},
"twilio.authToken": {
"label": "Twilio Auth Token",
"sensitive": true
},
"outbound.defaultMode": {
"label": "Default Call Mode"
},
"outbound.notifyHangupDelaySec": {
"label": "Notify Hangup Delay (sec)",
"advanced": true
},
"serve.port": {
"label": "Webhook Port"
},
"serve.bind": {
"label": "Webhook Bind"
},
"serve.path": {
"label": "Webhook Path"
},
"tailscale.mode": {
"label": "Tailscale Mode",
"advanced": true
},
"tailscale.path": {
"label": "Tailscale Path",
"advanced": true
},
"tunnel.provider": {
"label": "Tunnel Provider",
"advanced": true
},
"tunnel.ngrokAuthToken": {
"label": "ngrok Auth Token",
"sensitive": true,
"advanced": true
},
"tunnel.ngrokDomain": {
"label": "ngrok Domain",
"advanced": true
},
"tunnel.allowNgrokFreeTier": {
"label": "Allow ngrok Free Tier",
"advanced": true
},
"streaming.enabled": {
"label": "Enable Streaming",
"advanced": true
},
"streaming.openaiApiKey": {
"label": "OpenAI Realtime API Key",
"sensitive": true,
"advanced": true
},
"streaming.sttModel": {
"label": "Realtime STT Model",
"advanced": true
},
"streaming.streamPath": {
"label": "Media Stream Path",
"advanced": true
},
"tts.model": {
"label": "TTS Model",
"advanced": true
},
"tts.voice": {
"label": "TTS Voice",
"advanced": true
},
"tts.instructions": {
"label": "TTS Instructions",
"advanced": true
},
"publicUrl": {
"label": "Public Webhook URL",
"advanced": true
},
"skipSignatureVerification": {
"label": "Skip Signature Verification",
"advanced": true
},
"store": {
"label": "Call Log Store Path",
"advanced": true
},
"responseModel": {
"label": "Response Model",
"advanced": true
},
"responseSystemPrompt": {
"label": "Response System Prompt",
"advanced": true
},
"responseTimeoutMs": {
"label": "Response Timeout (ms)",
"advanced": true
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"provider": {
"type": "string",
"enum": [
"telnyx",
"twilio",
"plivo",
"mock"
]
},
"telnyx": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"connectionId": {
"type": "string"
},
"publicKey": {
"type": "string"
}
}
},
"twilio": {
"type": "object",
"additionalProperties": false,
"properties": {
"accountSid": {
"type": "string"
},
"authToken": {
"type": "string"
}
}
},
"plivo": {
"type": "object",
"additionalProperties": false,
"properties": {
"authId": {
"type": "string"
},
"authToken": {
"type": "string"
}
}
},
"fromNumber": {
"type": "string",
"pattern": "^\\+[1-9]\\d{1,14}$"
},
"toNumber": {
"type": "string",
"pattern": "^\\+[1-9]\\d{1,14}$"
},
"inboundPolicy": {
"type": "string",
"enum": [
"disabled",
"allowlist",
"pairing",
"open"
]
},
"allowFrom": {
"type": "array",
"items": {
"type": "string",
"pattern": "^\\+[1-9]\\d{1,14}$"
}
},
"inboundGreeting": {
"type": "string"
},
"outbound": {
"type": "object",
"additionalProperties": false,
"properties": {
"defaultMode": {
"type": "string",
"enum": [
"notify",
"conversation"
]
},
"notifyHangupDelaySec": {
"type": "integer",
"minimum": 0
}
}
},
"maxDurationSeconds": {
"type": "integer",
"minimum": 1
},
"silenceTimeoutMs": {
"type": "integer",
"minimum": 1
},
"transcriptTimeoutMs": {
"type": "integer",
"minimum": 1
},
"ringTimeoutMs": {
"type": "integer",
"minimum": 1
},
"maxConcurrentCalls": {
"type": "integer",
"minimum": 1
},
"serve": {
"type": "object",
"additionalProperties": false,
"properties": {
"port": {
"type": "integer",
"minimum": 1
},
"bind": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"tailscale": {
"type": "object",
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": [
"off",
"serve",
"funnel"
]
},
"path": {
"type": "string"
}
}
},
"tunnel": {
"type": "object",
"additionalProperties": false,
"properties": {
"provider": {
"type": "string",
"enum": [
"none",
"ngrok",
"tailscale-serve",
"tailscale-funnel"
]
},
"ngrokAuthToken": {
"type": "string"
},
"ngrokDomain": {
"type": "string"
},
"allowNgrokFreeTier": {
"type": "boolean"
}
}
},
"streaming": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"sttProvider": {
"type": "string",
"enum": [
"openai-realtime"
]
},
"openaiApiKey": {
"type": "string"
},
"sttModel": {
"type": "string"
},
"silenceDurationMs": {
"type": "integer",
"minimum": 1
},
"vadThreshold": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"streamPath": {
"type": "string"
}
}
},
"publicUrl": {
"type": "string"
},
"skipSignatureVerification": {
"type": "boolean"
},
"stt": {
"type": "object",
"additionalProperties": false,
"properties": {
"provider": {
"type": "string",
"enum": [
"openai"
]
},
"model": {
"type": "string"
}
}
},
"tts": {
"type": "object",
"additionalProperties": false,
"properties": {
"provider": {
"type": "string",
"enum": [
"openai"
]
},
"model": {
"type": "string"
},
"voice": {
"type": "string"
},
"instructions": {
"type": "string"
}
}
},
"store": {
"type": "string"
},
"responseModel": {
"type": "string"
},
"responseSystemPrompt": {
"type": "string"
},
"responseTimeoutMs": {
"type": "integer",
"minimum": 1
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "whatsapp",
"channels": [
"whatsapp"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "zalo",
"channels": [
"zalo"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+17 -1
View File
@@ -6,7 +6,23 @@
"clawdbot": { "clawdbot": {
"extensions": [ "extensions": [
"./index.ts" "./index.ts"
] ],
"channel": {
"id": "zalo",
"label": "Zalo",
"selectionLabel": "Zalo (Bot API)",
"docsPath": "/channels/zalo",
"docsLabel": "zalo",
"blurb": "Vietnam-focused messaging platform with Bot API.",
"aliases": ["zl"],
"order": 80,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/zalo",
"localPath": "extensions/zalo",
"defaultChoice": "npm"
}
}, },
"dependencies": { "dependencies": {
"clawdbot": "workspace:*", "clawdbot": "workspace:*",
+11
View File
@@ -0,0 +1,11 @@
{
"id": "zalouser",
"channels": [
"zalouser"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+2 -1
View File
@@ -10,7 +10,8 @@
"longmaba", "longmaba",
"manmal", "manmal",
"thesash", "thesash",
"rhjoh" "rhjoh",
"ysqander"
], ],
"seedCommit": "d6863f87", "seedCommit": "d6863f87",
"placeholderAvatar": "assets/avatar-placeholder.svg", "placeholderAvatar": "assets/avatar-placeholder.svg",
@@ -150,6 +150,7 @@ describe("getDmHistoryLimitFromSessionKey", () => {
"signal", "signal",
"imessage", "imessage",
"msteams", "msteams",
"nextcloud-talk",
] as const; ] as const;
for (const provider of providers) { for (const provider of providers) {
@@ -168,6 +169,7 @@ describe("getDmHistoryLimitFromSessionKey", () => {
"signal", "signal",
"imessage", "imessage",
"msteams", "msteams",
"nextcloud-talk",
] as const; ] as const;
for (const provider of providers) { for (const provider of providers) {
+12 -18
View File
@@ -62,22 +62,16 @@ export function getDmHistoryLimitFromSessionKey(
return providerConfig.dmHistoryLimit; return providerConfig.dmHistoryLimit;
}; };
switch (provider) { const resolveProviderConfig = (
case "telegram": cfg: ClawdbotConfig | undefined,
return getLimit(config.channels?.telegram); providerId: string,
case "whatsapp": ): { dmHistoryLimit?: number; dms?: Record<string, { historyLimit?: number }> } | undefined => {
return getLimit(config.channels?.whatsapp); const channels = cfg?.channels;
case "discord": if (!channels || typeof channels !== "object") return undefined;
return getLimit(config.channels?.discord); const entry = (channels as Record<string, unknown>)[providerId];
case "slack": if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined;
return getLimit(config.channels?.slack); return entry as { dmHistoryLimit?: number; dms?: Record<string, { historyLimit?: number }> };
case "signal": };
return getLimit(config.channels?.signal);
case "imessage": return getLimit(resolveProviderConfig(config, provider));
return getLimit(config.channels?.imessage);
case "msteams":
return getLimit(config.channels?.msteams);
default:
return undefined;
}
} }
+2 -1
View File
@@ -48,6 +48,7 @@ export const DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS = 12_000;
export const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent"; export const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent";
export const SANDBOX_STATE_DIR = path.join(STATE_DIR_CLAWDBOT, "sandbox"); const resolvedSandboxStateDir = STATE_DIR_CLAWDBOT ?? path.join(os.homedir(), ".clawdbot");
export const SANDBOX_STATE_DIR = path.join(resolvedSandboxStateDir, "sandbox");
export const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json"); export const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json");
export const SANDBOX_BROWSER_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "browsers.json"); export const SANDBOX_BROWSER_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "browsers.json");
@@ -10,15 +10,25 @@ import { loadClawdbotPlugins } from "../plugins/loader.js";
import { resetGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { resetGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { guardSessionManager } from "./session-tool-result-guard-wrapper.js"; import { guardSessionManager } from "./session-tool-result-guard-wrapper.js";
const EMPTY_CONFIG_SCHEMA = `configSchema: { const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
validate: () => ({ ok: true }),
jsonSchema: { type: "object", additionalProperties: true },
uiHints: {}
}`;
function writeTempPlugin(params: { dir: string; id: string; body: string }): string { function writeTempPlugin(params: { dir: string; id: string; body: string }): string {
const file = path.join(params.dir, `${params.id}.mjs`); const pluginDir = path.join(params.dir, params.id);
fs.mkdirSync(pluginDir, { recursive: true });
const file = path.join(pluginDir, `${params.id}.mjs`);
fs.writeFileSync(file, params.body, "utf-8"); fs.writeFileSync(file, params.body, "utf-8");
fs.writeFileSync(
path.join(pluginDir, "clawdbot.plugin.json"),
JSON.stringify(
{
id: params.id,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
return file; return file;
} }
@@ -63,7 +73,7 @@ describe("tool_result_persist hook", () => {
const pluginA = writeTempPlugin({ const pluginA = writeTempPlugin({
dir: tmp, dir: tmp,
id: "persist-a", id: "persist-a",
body: `export default { id: "persist-a", ${EMPTY_CONFIG_SCHEMA}, register(api) { body: `export default { id: "persist-a", register(api) {
api.on("tool_result_persist", (event, ctx) => { api.on("tool_result_persist", (event, ctx) => {
const msg = event.message; const msg = event.message;
// Example: remove large diagnostic payloads before persistence. // Example: remove large diagnostic payloads before persistence.
@@ -76,7 +86,7 @@ describe("tool_result_persist hook", () => {
const pluginB = writeTempPlugin({ const pluginB = writeTempPlugin({
dir: tmp, dir: tmp,
id: "persist-b", id: "persist-b",
body: `export default { id: "persist-b", ${EMPTY_CONFIG_SCHEMA}, register(api) { body: `export default { id: "persist-b", register(api) {
api.on("tool_result_persist", (event) => { api.on("tool_result_persist", (event) => {
const prior = (event.message && event.message.persistOrder) ? event.message.persistOrder : []; const prior = (event.message && event.message.persistOrder) ? event.message.persistOrder : [];
return { message: { ...event.message, persistOrder: [...prior, "b"] } }; return { message: { ...event.message, persistOrder: [...prior, "b"] } };
+82
View File
@@ -0,0 +1,82 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getShellConfig } from "./shell-utils.js";
const isWin = process.platform === "win32";
describe("getShellConfig", () => {
const originalShell = process.env.SHELL;
const originalPath = process.env.PATH;
const tempDirs: string[] = [];
const createTempBin = (files: string[]) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-shell-"));
tempDirs.push(dir);
for (const name of files) {
const filePath = path.join(dir, name);
fs.writeFileSync(filePath, "");
fs.chmodSync(filePath, 0o755);
}
return dir;
};
beforeEach(() => {
if (!isWin) {
process.env.SHELL = "/usr/bin/fish";
}
});
afterEach(() => {
if (originalShell == null) {
delete process.env.SHELL;
} else {
process.env.SHELL = originalShell;
}
if (originalPath == null) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
if (isWin) {
it("uses PowerShell on Windows", () => {
const { shell } = getShellConfig();
expect(shell.toLowerCase()).toContain("powershell");
});
return;
}
it("prefers bash when fish is default and bash is on PATH", () => {
const binDir = createTempBin(["bash"]);
process.env.PATH = binDir;
const { shell } = getShellConfig();
expect(shell).toBe(path.join(binDir, "bash"));
});
it("falls back to sh when fish is default and bash is missing", () => {
const binDir = createTempBin(["sh"]);
process.env.PATH = binDir;
const { shell } = getShellConfig();
expect(shell).toBe(path.join(binDir, "sh"));
});
it("falls back to env shell when fish is default and no sh is available", () => {
process.env.PATH = "";
const { shell } = getShellConfig();
expect(shell).toBe("/usr/bin/fish");
});
it("uses sh when SHELL is unset", () => {
delete process.env.SHELL;
process.env.PATH = "";
const { shell } = getShellConfig();
expect(shell).toBe("sh");
});
});
+26 -1
View File
@@ -30,10 +30,35 @@ export function getShellConfig(): { shell: string; args: string[] } {
}; };
} }
const shell = process.env.SHELL?.trim() || "sh"; const envShell = process.env.SHELL?.trim();
const shellName = envShell ? path.basename(envShell) : "";
// Fish rejects common bashisms used by tools, so prefer bash when detected.
if (shellName === "fish") {
const bash = resolveShellFromPath("bash");
if (bash) return { shell: bash, args: ["-c"] };
const sh = resolveShellFromPath("sh");
if (sh) return { shell: sh, args: ["-c"] };
}
const shell = envShell && envShell.length > 0 ? envShell : "sh";
return { shell, args: ["-c"] }; return { shell, args: ["-c"] };
} }
function resolveShellFromPath(name: string): string | undefined {
const envPath = process.env.PATH ?? "";
if (!envPath) return undefined;
const entries = envPath.split(path.delimiter).filter(Boolean);
for (const entry of entries) {
const candidate = path.join(entry, name);
try {
fs.accessSync(candidate, fs.constants.X_OK);
return candidate;
} catch {
// ignore missing or non-executable entries
}
}
return undefined;
}
export function sanitizeBinaryOutput(text: string): string { export function sanitizeBinaryOutput(text: string): string {
const scrubbed = text.replace(/[\p{Format}\p{Surrogate}]/gu, ""); const scrubbed = text.replace(/[\p{Format}\p{Surrogate}]/gu, "");
if (!scrubbed) return scrubbed; if (!scrubbed) return scrubbed;
+3 -3
View File
@@ -1,6 +1,6 @@
import { import {
readConfigFileSnapshot, readConfigFileSnapshot,
validateConfigObject, validateConfigObjectWithPlugins,
writeConfigFile, writeConfigFile,
} from "../../config/config.js"; } from "../../config/config.js";
import { import {
@@ -120,7 +120,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
reply: { text: `⚙️ No config value found for ${configCommand.path}.` }, reply: { text: `⚙️ No config value found for ${configCommand.path}.` },
}; };
} }
const validated = validateConfigObject(parsedBase); const validated = validateConfigObjectWithPlugins(parsedBase);
if (!validated.ok) { if (!validated.ok) {
const issue = validated.issues[0]; const issue = validated.issues[0];
return { return {
@@ -146,7 +146,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
}; };
} }
setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value); setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value);
const validated = validateConfigObject(parsedBase); const validated = validateConfigObjectWithPlugins(parsedBase);
if (!validated.ok) { if (!validated.ok) {
const issue = validated.issues[0]; const issue = validated.issues[0];
return { return {
+129 -77
View File
@@ -1,3 +1,8 @@
import path from "node:path";
import { discoverClawdbotPlugins } from "../../plugins/discovery.js";
import type { PluginOrigin } from "../../plugins/types.js";
import type { ClawdbotPackageManifest } from "../../plugins/manifest.js";
import type { ChannelMeta } from "./types.js"; import type { ChannelMeta } from "./types.js";
export type ChannelPluginCatalogEntry = { export type ChannelPluginCatalogEntry = {
@@ -10,86 +15,133 @@ export type ChannelPluginCatalogEntry = {
}; };
}; };
const CATALOG: ChannelPluginCatalogEntry[] = [ type CatalogOptions = {
{ workspaceDir?: string;
id: "msteams", };
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
docsLabel: "msteams",
blurb: "Bot Framework; enterprise support.",
aliases: ["teams"],
order: 60,
},
install: {
npmSpec: "@clawdbot/msteams",
localPath: "extensions/msteams",
defaultChoice: "npm",
},
},
{
id: "matrix",
meta: {
id: "matrix",
label: "Matrix",
selectionLabel: "Matrix (plugin)",
docsPath: "/channels/matrix",
docsLabel: "matrix",
blurb: "open protocol; install the plugin to enable.",
order: 70,
quickstartAllowFrom: true,
},
install: {
npmSpec: "@clawdbot/matrix",
localPath: "extensions/matrix",
defaultChoice: "npm",
},
},
{
id: "bluebubbles",
meta: {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles (macOS app)",
docsPath: "/channels/bluebubbles",
docsLabel: "bluebubbles",
blurb: "iMessage via the BlueBubbles mac app + REST API.",
order: 75,
},
install: {
npmSpec: "@clawdbot/bluebubbles",
localPath: "extensions/bluebubbles",
defaultChoice: "npm",
},
},
{
id: "zalo",
meta: {
id: "zalo",
label: "Zalo",
selectionLabel: "Zalo (Bot API)",
docsPath: "/channels/zalo",
docsLabel: "zalo",
blurb: "Vietnam-focused messaging platform with Bot API.",
aliases: ["zl"],
order: 80,
quickstartAllowFrom: true,
},
install: {
npmSpec: "@clawdbot/zalo",
localPath: "extensions/zalo",
},
},
];
export function listChannelPluginCatalogEntries(): ChannelPluginCatalogEntry[] { const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
return [...CATALOG]; config: 0,
workspace: 1,
global: 2,
bundled: 3,
};
function toChannelMeta(params: {
channel: NonNullable<ClawdbotPackageManifest["channel"]>;
id: string;
}): ChannelMeta | null {
const label = params.channel.label?.trim();
if (!label) return null;
const selectionLabel = params.channel.selectionLabel?.trim() || label;
const docsPath = params.channel.docsPath?.trim() || `/channels/${params.id}`;
const blurb = params.channel.blurb?.trim() || "";
return {
id: params.id,
label,
selectionLabel,
docsPath,
docsLabel: params.channel.docsLabel?.trim() || undefined,
blurb,
...(params.channel.aliases ? { aliases: params.channel.aliases } : {}),
...(params.channel.order !== undefined ? { order: params.channel.order } : {}),
...(params.channel.selectionDocsPrefix
? { selectionDocsPrefix: params.channel.selectionDocsPrefix }
: {}),
...(params.channel.selectionDocsOmitLabel !== undefined
? { selectionDocsOmitLabel: params.channel.selectionDocsOmitLabel }
: {}),
...(params.channel.selectionExtras ? { selectionExtras: params.channel.selectionExtras } : {}),
...(params.channel.showConfigured !== undefined
? { showConfigured: params.channel.showConfigured }
: {}),
...(params.channel.quickstartAllowFrom !== undefined
? { quickstartAllowFrom: params.channel.quickstartAllowFrom }
: {}),
...(params.channel.forceAccountBinding !== undefined
? { forceAccountBinding: params.channel.forceAccountBinding }
: {}),
...(params.channel.preferSessionLookupForAnnounceTarget !== undefined
? {
preferSessionLookupForAnnounceTarget: params.channel.preferSessionLookupForAnnounceTarget,
}
: {}),
};
} }
export function getChannelPluginCatalogEntry(id: string): ChannelPluginCatalogEntry | undefined { function resolveInstallInfo(params: {
manifest: ClawdbotPackageManifest;
packageName?: string;
packageDir?: string;
workspaceDir?: string;
}): ChannelPluginCatalogEntry["install"] | null {
const npmSpec = params.manifest.install?.npmSpec?.trim() ?? params.packageName?.trim();
if (!npmSpec) return null;
let localPath = params.manifest.install?.localPath?.trim() || undefined;
if (!localPath && params.workspaceDir && params.packageDir) {
localPath = path.relative(params.workspaceDir, params.packageDir) || undefined;
}
const defaultChoice = params.manifest.install?.defaultChoice ?? (localPath ? "local" : "npm");
return {
npmSpec,
...(localPath ? { localPath } : {}),
...(defaultChoice ? { defaultChoice } : {}),
};
}
function buildCatalogEntry(candidate: {
packageName?: string;
packageDir?: string;
workspaceDir?: string;
packageClawdbot?: ClawdbotPackageManifest;
}): ChannelPluginCatalogEntry | null {
const manifest = candidate.packageClawdbot;
if (!manifest?.channel) return null;
const id = manifest.channel.id?.trim();
if (!id) return null;
const meta = toChannelMeta({ channel: manifest.channel, id });
if (!meta) return null;
const install = resolveInstallInfo({
manifest,
packageName: candidate.packageName,
packageDir: candidate.packageDir,
workspaceDir: candidate.workspaceDir,
});
if (!install) return null;
return { id, meta, install };
}
export function listChannelPluginCatalogEntries(
options: CatalogOptions = {},
): ChannelPluginCatalogEntry[] {
const discovery = discoverClawdbotPlugins({ workspaceDir: options.workspaceDir });
const resolved = new Map<string, { entry: ChannelPluginCatalogEntry; priority: number }>();
for (const candidate of discovery.candidates) {
const entry = buildCatalogEntry(candidate);
if (!entry) continue;
const priority = ORIGIN_PRIORITY[candidate.origin] ?? 99;
const existing = resolved.get(entry.id);
if (!existing || priority < existing.priority) {
resolved.set(entry.id, { entry, priority });
}
}
return Array.from(resolved.values())
.map(({ entry }) => entry)
.sort((a, b) => {
const orderA = a.meta.order ?? 999;
const orderB = b.meta.order ?? 999;
if (orderA !== orderB) return orderA - orderB;
return a.meta.label.localeCompare(b.meta.label);
});
}
export function getChannelPluginCatalogEntry(
id: string,
options: CatalogOptions = {},
): ChannelPluginCatalogEntry | undefined {
const trimmed = id.trim(); const trimmed = id.trim();
if (!trimmed) return undefined; if (!trimmed) return undefined;
return CATALOG.find((entry) => entry.id === trimmed); return listChannelPluginCatalogEntries(options).find((entry) => entry.id === trimmed);
} }
+5 -2
View File
@@ -123,7 +123,8 @@ export function registerDevicesCli(program: Command) {
const name = req.displayName || req.deviceId; const name = req.displayName || req.deviceId;
const repair = req.isRepair ? " (repair)" : ""; const repair = req.isRepair ? " (repair)" : "";
const ip = req.remoteIp ? ` · ${req.remoteIp}` : ""; const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
const age = typeof req.ts === "number" ? ` · ${formatAge(Date.now() - req.ts)} ago` : ""; const age =
typeof req.ts === "number" ? ` · ${formatAge(Date.now() - req.ts)} ago` : "";
const role = req.role ? ` · role: ${req.role}` : ""; const role = req.role ? ` · role: ${req.role}` : "";
defaultRuntime.log(`- ${req.requestId}: ${name}${repair}${role}${ip}${age}`); defaultRuntime.log(`- ${req.requestId}: ${name}${repair}${role}${ip}${age}`);
} }
@@ -133,7 +134,9 @@ export function registerDevicesCli(program: Command) {
for (const device of list.paired) { for (const device of list.paired) {
const name = device.displayName || device.deviceId; const name = device.displayName || device.deviceId;
const roles = device.roles?.length ? `roles: ${device.roles.join(", ")}` : "roles: -"; const roles = device.roles?.length ? `roles: ${device.roles.join(", ")}` : "roles: -";
const scopes = device.scopes?.length ? `scopes: ${device.scopes.join(", ")}` : "scopes: -"; const scopes = device.scopes?.length
? `scopes: ${device.scopes.join(", ")}`
: "scopes: -";
const ip = device.remoteIp ? ` · ${device.remoteIp}` : ""; const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
const tokens = formatTokenSummary(device.tokens); const tokens = formatTokenSummary(device.tokens);
defaultRuntime.log(`- ${name} · ${roles} · ${scopes} · ${tokens}${ip}`); defaultRuntime.log(`- ${name} · ${roles} · ${scopes} · ${tokens}${ip}`);
+2 -27
View File
@@ -1,8 +1,6 @@
import { readConfigFileSnapshot } from "../../config/config.js"; import { readConfigFileSnapshot } from "../../config/config.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-flow.js"; import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-flow.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { colorize, isRich, theme } from "../../terminal/theme.js";
import { loadClawdbotPlugins } from "../../plugins/loader.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { formatCliCommand } from "../command-format.js"; import { formatCliCommand } from "../command-format.js";
@@ -30,26 +28,7 @@ export async function ensureConfigReady(params: {
? snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`) ? snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`)
: []; : [];
const pluginIssues: string[] = []; const invalid = snapshot.exists && !snapshot.valid;
if (snapshot.valid) {
const workspaceDir = resolveAgentWorkspaceDir(
snapshot.config,
resolveDefaultAgentId(snapshot.config),
);
const registry = loadClawdbotPlugins({
config: snapshot.config,
workspaceDir: workspaceDir ?? undefined,
cache: false,
mode: "validate",
});
for (const diag of registry.diagnostics) {
if (diag.level !== "error") continue;
const id = diag.pluginId ? ` ${diag.pluginId}` : "";
pluginIssues.push(`- plugin${id}: ${diag.message}`);
}
}
const invalid = snapshot.exists && (!snapshot.valid || pluginIssues.length > 0);
if (!invalid) return; if (!invalid) return;
const rich = isRich(); const rich = isRich();
@@ -68,10 +47,6 @@ export async function ensureConfigReady(params: {
params.runtime.error(muted("Legacy config keys detected:")); params.runtime.error(muted("Legacy config keys detected:"));
params.runtime.error(legacyIssues.map((issue) => ` ${error(issue)}`).join("\n")); params.runtime.error(legacyIssues.map((issue) => ` ${error(issue)}`).join("\n"));
} }
if (pluginIssues.length > 0) {
params.runtime.error(muted("Plugin config errors:"));
params.runtime.error(pluginIssues.map((issue) => ` ${error(issue)}`).join("\n"));
}
params.runtime.error(""); params.runtime.error("");
params.runtime.error( params.runtime.error(
`${muted("Run:")} ${commandText(formatCliCommand("clawdbot doctor --fix"))}`, `${muted("Run:")} ${commandText(formatCliCommand("clawdbot doctor --fix"))}`,
+5
View File
@@ -128,6 +128,11 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) { if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
note("Config invalid; doctor will run with best-effort config.", "Config"); note("Config invalid; doctor will run with best-effort config.", "Config");
} }
const warnings = snapshot.warnings ?? [];
if (warnings.length > 0) {
const lines = warnings.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n");
note(lines, "Config warnings");
}
if (snapshot.legacyIssues.length > 0) { if (snapshot.legacyIssues.length > 0) {
note( note(
+1 -9
View File
@@ -1,11 +1,9 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path";
import JSON5 from "json5"; import JSON5 from "json5";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT } from "../config/config.js"; import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import { applyModelDefaults } from "../config/defaults.js";
import { resolveSessionTranscriptsDir } from "../config/sessions.js"; import { resolveSessionTranscriptsDir } from "../config/sessions.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
@@ -26,12 +24,6 @@ async function readConfigFileRaw(): Promise<{
} }
} }
async function writeConfigFile(cfg: ClawdbotConfig) {
await fs.mkdir(path.dirname(CONFIG_PATH_CLAWDBOT), { recursive: true });
const json = JSON.stringify(applyModelDefaults(cfg), null, 2).trimEnd().concat("\n");
await fs.writeFile(CONFIG_PATH_CLAWDBOT, json, "utf-8");
}
export async function setupCommand( export async function setupCommand(
opts?: { workspace?: string }, opts?: { workspace?: string },
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
+5 -2
View File
@@ -12,7 +12,7 @@ describe("config backup rotation", () => {
const configPath = resolveConfigPath(); const configPath = resolveConfigPath();
const buildConfig = (version: number): ClawdbotConfig => const buildConfig = (version: number): ClawdbotConfig =>
({ ({
identity: { name: `v${version}` }, agents: { list: [{ id: `v${version}` }] },
}) as ClawdbotConfig; }) as ClawdbotConfig;
for (let version = 0; version <= 6; version += 1) { for (let version = 0; version <= 6; version += 1) {
@@ -21,7 +21,10 @@ describe("config backup rotation", () => {
const readName = async (suffix = "") => { const readName = async (suffix = "") => {
const raw = await fs.readFile(`${configPath}${suffix}`, "utf-8"); const raw = await fs.readFile(`${configPath}${suffix}`, "utf-8");
return (JSON.parse(raw) as { identity?: { name?: string } }).identity?.name ?? null; return (
(JSON.parse(raw) as { agents?: { list?: Array<{ id?: string }> } }).agents?.list?.[0]
?.id ?? null
);
}; };
await expect(readName()).resolves.toBe("v6"); await expect(readName()).resolves.toBe("v6");
+3 -1
View File
@@ -309,7 +309,9 @@ describe("config identity defaults", () => {
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined(); expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
expect(cfg.agents?.list).toBeUndefined(); expect(cfg.agents?.list).toBeUndefined();
expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT); expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT);
expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT); expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(
DEFAULT_SUBAGENT_MAX_CONCURRENT,
);
expect(cfg.session).toBeUndefined(); expect(cfg.session).toBeUndefined();
}); });
}); });
@@ -96,6 +96,25 @@ describe("Nix integration (U3, U5, U9)", () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot"); const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true }); await fs.mkdir(configDir, { recursive: true });
const pluginDir = path.join(home, "plugins", "demo-plugin");
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(
path.join(pluginDir, "index.js"),
'export default { id: "demo-plugin", register() {} };',
"utf-8",
);
await fs.writeFile(
path.join(pluginDir, "clawdbot.plugin.json"),
JSON.stringify(
{
id: "demo-plugin",
configSchema: { type: "object", additionalProperties: false, properties: {} },
},
null,
2,
),
"utf-8",
);
await fs.writeFile( await fs.writeFile(
path.join(configDir, "clawdbot.json"), path.join(configDir, "clawdbot.json"),
JSON.stringify( JSON.stringify(
+152
View File
@@ -0,0 +1,152 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "./test-helpers.js";
async function writePluginFixture(params: {
dir: string;
id: string;
schema: Record<string, unknown>;
}) {
await fs.mkdir(params.dir, { recursive: true });
await fs.writeFile(
path.join(params.dir, "index.js"),
`export default { id: "${params.id}", register() {} };`,
"utf-8",
);
await fs.writeFile(
path.join(params.dir, "clawdbot.plugin.json"),
JSON.stringify(
{
id: params.id,
configSchema: params.schema,
},
null,
2,
),
"utf-8",
);
}
describe("config plugin validation", () => {
it("rejects missing plugin load paths", async () => {
await withTempHome(async (home) => {
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const missingPath = path.join(home, "missing-plugin");
const res = validateConfigObjectWithPlugins({
agents: { list: [{ id: "pi" }] },
plugins: { enabled: false, load: { paths: [missingPath] } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
const hasIssue = res.issues.some(
(issue) =>
issue.path === "plugins.load.paths" && issue.message.includes("plugin path not found"),
);
expect(hasIssue).toBe(true);
}
});
});
it("rejects missing plugin ids in entries", async () => {
await withTempHome(async (home) => {
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { list: [{ id: "pi" }] },
plugins: { enabled: false, entries: { "missing-plugin": { enabled: true } } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues).toContainEqual({
path: "plugins.entries.missing-plugin",
message: "plugin not found: missing-plugin",
});
}
});
});
it("rejects missing plugin ids in allow/deny/slots", async () => {
await withTempHome(async (home) => {
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: false,
allow: ["missing-allow"],
deny: ["missing-deny"],
slots: { memory: "missing-slot" },
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues).toEqual(
expect.arrayContaining([
{ path: "plugins.allow", message: "plugin not found: missing-allow" },
{ path: "plugins.deny", message: "plugin not found: missing-deny" },
{ path: "plugins.slots.memory", message: "plugin not found: missing-slot" },
]),
);
}
});
});
it("surfaces plugin config diagnostics", async () => {
await withTempHome(async (home) => {
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
const pluginDir = path.join(home, "bad-plugin");
await writePluginFixture({
dir: pluginDir,
id: "bad-plugin",
schema: {
type: "object",
additionalProperties: false,
properties: {
value: { type: "boolean" },
},
required: ["value"],
},
});
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: true,
load: { paths: [pluginDir] },
entries: { "bad-plugin": { config: { value: "nope" } } },
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
const hasIssue = res.issues.some(
(issue) =>
issue.path === "plugins.entries.bad-plugin.config" &&
issue.message.includes("invalid config"),
);
expect(hasIssue).toBe(true);
}
});
});
it("accepts known plugin ids", async () => {
await withTempHome(async (home) => {
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { list: [{ id: "pi" }] },
plugins: { enabled: false, entries: { discord: { enabled: true } } },
});
expect(res.ok).toBe(true);
});
});
});
+1 -1
View File
@@ -10,5 +10,5 @@ export { migrateLegacyConfig } from "./legacy-migrate.js";
export * from "./paths.js"; export * from "./paths.js";
export * from "./runtime-overrides.js"; export * from "./runtime-overrides.js";
export * from "./types.js"; export * from "./types.js";
export { validateConfigObject } from "./validation.js"; export { validateConfigObject, validateConfigObjectWithPlugins } from "./validation.js";
export { ClawdbotSchema } from "./zod-schema.js"; export { ClawdbotSchema } from "./zod-schema.js";
+43 -12
View File
@@ -30,8 +30,7 @@ import { normalizeConfigPaths } from "./normalize-paths.js";
import { resolveConfigPath, resolveStateDir } from "./paths.js"; import { resolveConfigPath, resolveStateDir } from "./paths.js";
import { applyConfigOverrides } from "./runtime-overrides.js"; import { applyConfigOverrides } from "./runtime-overrides.js";
import type { ClawdbotConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; import type { ClawdbotConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
import { validateConfigObject } from "./validation.js"; import { validateConfigObjectWithPlugins } from "./validation.js";
import { ClawdbotSchema } from "./zod-schema.js";
import { compareClawdbotVersions } from "./version.js"; import { compareClawdbotVersions } from "./version.js";
// Re-export for backwards compatibility // Re-export for backwards compatibility
@@ -233,21 +232,34 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const resolvedConfig = substituted; const resolvedConfig = substituted;
warnOnConfigMiskeys(resolvedConfig, deps.logger); warnOnConfigMiskeys(resolvedConfig, deps.logger);
if (typeof resolvedConfig !== "object" || resolvedConfig === null) return {}; if (typeof resolvedConfig !== "object" || resolvedConfig === null) return {};
const validated = ClawdbotSchema.safeParse(resolvedConfig); const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig as ClawdbotConfig, {
if (!validated.success) { env: deps.env,
deps.logger.error("Invalid config:"); homedir: deps.homedir,
for (const iss of validated.error.issues) { });
deps.logger.error(`- ${iss.path.join(".")}: ${iss.message}`); if (preValidationDuplicates.length > 0) {
} throw new DuplicateAgentDirError(preValidationDuplicates);
return {};
} }
warnIfConfigFromFuture(validated.data as ClawdbotConfig, deps.logger); const validated = validateConfigObjectWithPlugins(resolvedConfig);
if (!validated.ok) {
const details = validated.issues
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
.join("\n");
deps.logger.error(`Invalid config:\\n${details}`);
throw new Error("Invalid config");
}
if (validated.warnings.length > 0) {
const details = validated.warnings
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
.join("\n");
deps.logger.warn(`Config warnings:\\n${details}`);
}
warnIfConfigFromFuture(validated.config, deps.logger);
const cfg = applyModelDefaults( const cfg = applyModelDefaults(
applyCompactionDefaults( applyCompactionDefaults(
applyContextPruningDefaults( applyContextPruningDefaults(
applyAgentDefaults( applyAgentDefaults(
applySessionDefaults( applySessionDefaults(
applyLoggingDefaults(applyMessageDefaults(validated.data as ClawdbotConfig)), applyLoggingDefaults(applyMessageDefaults(validated.config)),
), ),
), ),
), ),
@@ -310,6 +322,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config, config,
hash, hash,
issues: [], issues: [],
warnings: [],
legacyIssues, legacyIssues,
}; };
} }
@@ -328,6 +341,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config: {}, config: {},
hash, hash,
issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }], issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }],
warnings: [],
legacyIssues: [], legacyIssues: [],
}; };
} }
@@ -353,6 +367,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config: coerceConfig(parsedRes.parsed), config: coerceConfig(parsedRes.parsed),
hash, hash,
issues: [{ path: "", message }], issues: [{ path: "", message }],
warnings: [],
legacyIssues: [], legacyIssues: [],
}; };
} }
@@ -375,6 +390,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config: coerceConfig(resolved), config: coerceConfig(resolved),
hash, hash,
issues: [{ path: "", message }], issues: [{ path: "", message }],
warnings: [],
legacyIssues: [], legacyIssues: [],
}; };
} }
@@ -382,7 +398,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const resolvedConfigRaw = substituted; const resolvedConfigRaw = substituted;
const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw); const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw);
const validated = validateConfigObject(resolvedConfigRaw); const validated = validateConfigObjectWithPlugins(resolvedConfigRaw);
if (!validated.ok) { if (!validated.ok) {
return { return {
path: configPath, path: configPath,
@@ -393,6 +409,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config: coerceConfig(resolvedConfigRaw), config: coerceConfig(resolvedConfigRaw),
hash, hash,
issues: validated.issues, issues: validated.issues,
warnings: validated.warnings,
legacyIssues, legacyIssues,
}; };
} }
@@ -415,6 +432,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
), ),
hash, hash,
issues: [], issues: [],
warnings: validated.warnings,
legacyIssues, legacyIssues,
}; };
} catch (err) { } catch (err) {
@@ -427,6 +445,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config: {}, config: {},
hash: hashConfigRaw(null), hash: hashConfigRaw(null),
issues: [{ path: "", message: `read failed: ${String(err)}` }], issues: [{ path: "", message: `read failed: ${String(err)}` }],
warnings: [],
legacyIssues: [], legacyIssues: [],
}; };
} }
@@ -434,6 +453,18 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
async function writeConfigFile(cfg: ClawdbotConfig) { async function writeConfigFile(cfg: ClawdbotConfig) {
clearConfigCache(); clearConfigCache();
const validated = validateConfigObjectWithPlugins(cfg);
if (!validated.ok) {
const issue = validated.issues[0];
const pathLabel = issue?.path ? issue.path : "<root>";
throw new Error(`Config validation failed: ${pathLabel}: ${issue?.message ?? "invalid"}`);
}
if (validated.warnings.length > 0) {
const details = validated.warnings
.map((warning) => `- ${warning.path}: ${warning.message}`)
.join("\n");
deps.logger.warn(`Config warnings:\n${details}`);
}
const dir = path.dirname(configPath); const dir = path.dirname(configPath);
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2) const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2)
+2 -2
View File
@@ -1,6 +1,6 @@
import { applyLegacyMigrations } from "./legacy.js"; import { applyLegacyMigrations } from "./legacy.js";
import type { ClawdbotConfig } from "./types.js"; import type { ClawdbotConfig } from "./types.js";
import { validateConfigObject } from "./validation.js"; import { validateConfigObjectWithPlugins } from "./validation.js";
export function migrateLegacyConfig(raw: unknown): { export function migrateLegacyConfig(raw: unknown): {
config: ClawdbotConfig | null; config: ClawdbotConfig | null;
@@ -8,7 +8,7 @@ export function migrateLegacyConfig(raw: unknown): {
} { } {
const { next, changes } = applyLegacyMigrations(raw); const { next, changes } = applyLegacyMigrations(raw);
if (!next) return { config: null, changes: [] }; if (!next) return { config: null, changes: [] };
const validated = validateConfigObject(next); const validated = validateConfigObjectWithPlugins(next);
if (!validated.ok) { if (!validated.ok) {
changes.push("Migration applied, but config still invalid; fix remaining issues manually."); changes.push("Migration applied, but config still invalid; fix remaining issues manually.");
return { config: null, changes }; return { config: null, changes };
+15 -16
View File
@@ -1,6 +1,7 @@
import type { ClawdbotConfig } from "./config.js"; import type { ClawdbotConfig } from "./config.js";
import { hasAnyWhatsAppAuth } from "../web/accounts.js";
import { normalizeProviderId } from "../agents/model-selection.js"; import { normalizeProviderId } from "../agents/model-selection.js";
import { listChatChannels } from "../channels/registry.js";
import { hasAnyWhatsAppAuth } from "../web/accounts.js";
type PluginEnableChange = { type PluginEnableChange = {
pluginId: string; pluginId: string;
@@ -12,20 +13,6 @@ export type PluginAutoEnableResult = {
changes: string[]; changes: string[];
}; };
const CHANNEL_PLUGIN_IDS = [
"whatsapp",
"telegram",
"discord",
"slack",
"signal",
"imessage",
"msteams",
"matrix",
"zalo",
"zalouser",
"bluebubbles",
] as const;
const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [ const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [
{ pluginId: "google-antigravity-auth", providerId: "google-antigravity" }, { pluginId: "google-antigravity-auth", providerId: "google-antigravity" },
{ pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" }, { pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" },
@@ -239,7 +226,19 @@ function resolveConfiguredPlugins(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
): PluginEnableChange[] { ): PluginEnableChange[] {
const changes: PluginEnableChange[] = []; const changes: PluginEnableChange[] = [];
for (const channelId of CHANNEL_PLUGIN_IDS) { const channelIds = new Set<string>();
for (const meta of listChatChannels()) {
channelIds.add(meta.id);
}
const configuredChannels = cfg.channels as Record<string, unknown> | undefined;
if (configuredChannels && typeof configuredChannels === "object") {
for (const key of Object.keys(configuredChannels)) {
if (key === "defaults") continue;
channelIds.add(key);
}
}
for (const channelId of channelIds) {
if (!channelId) continue;
if (isChannelConfigured(cfg, channelId, env)) { if (isChannelConfigured(cfg, channelId, env)) {
changes.push({ changes.push({
pluginId: channelId, pluginId: channelId,
+1
View File
@@ -105,5 +105,6 @@ export type ConfigFileSnapshot = {
config: ClawdbotConfig; config: ClawdbotConfig;
hash?: string; hash?: string;
issues: ConfigValidationIssue[]; issues: ConfigValidationIssue[];
warnings: ConfigValidationIssue[];
legacyIssues: LegacyConfigIssue[]; legacyIssues: LegacyConfigIssue[];
}; };
+189
View File
@@ -1,3 +1,12 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { CHANNEL_IDS } from "../channels/registry.js";
import {
normalizePluginsConfig,
resolveEnableState,
resolveMemorySlotDecision,
} from "../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js"; import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js"; import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js";
import { findLegacyConfigIssues } from "./legacy.js"; import { findLegacyConfigIssues } from "./legacy.js";
@@ -46,3 +55,183 @@ export function validateConfigObject(
), ),
}; };
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function validateConfigObjectWithPlugins(raw: unknown):
| {
ok: true;
config: ClawdbotConfig;
warnings: ConfigValidationIssue[];
}
| {
ok: false;
issues: ConfigValidationIssue[];
warnings: ConfigValidationIssue[];
} {
const base = validateConfigObject(raw);
if (!base.ok) {
return { ok: false, issues: base.issues, warnings: [] };
}
const config = base.config;
const issues: ConfigValidationIssue[] = [];
const warnings: ConfigValidationIssue[] = [];
const pluginsConfig = config.plugins;
const normalizedPlugins = normalizePluginsConfig(pluginsConfig);
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const registry = loadPluginManifestRegistry({
config,
workspaceDir: workspaceDir ?? undefined,
});
const knownIds = new Set(registry.plugins.map((record) => record.id));
for (const diag of registry.diagnostics) {
let path = diag.pluginId ? `plugins.entries.${diag.pluginId}` : "plugins";
if (!diag.pluginId && diag.message.includes("plugin path not found")) {
path = "plugins.load.paths";
}
const pluginLabel = diag.pluginId ? `plugin ${diag.pluginId}` : "plugin";
const message = `${pluginLabel}: ${diag.message}`;
if (diag.level === "error") {
issues.push({ path, message });
} else {
warnings.push({ path, message });
}
}
const entries = pluginsConfig?.entries;
if (entries && isRecord(entries)) {
for (const pluginId of Object.keys(entries)) {
if (!knownIds.has(pluginId)) {
issues.push({
path: `plugins.entries.${pluginId}`,
message: `plugin not found: ${pluginId}`,
});
}
}
}
const allow = pluginsConfig?.allow ?? [];
for (const pluginId of allow) {
if (typeof pluginId !== "string" || !pluginId.trim()) continue;
if (!knownIds.has(pluginId)) {
issues.push({
path: "plugins.allow",
message: `plugin not found: ${pluginId}`,
});
}
}
const deny = pluginsConfig?.deny ?? [];
for (const pluginId of deny) {
if (typeof pluginId !== "string" || !pluginId.trim()) continue;
if (!knownIds.has(pluginId)) {
issues.push({
path: "plugins.deny",
message: `plugin not found: ${pluginId}`,
});
}
}
const memorySlot = normalizedPlugins.slots.memory;
if (typeof memorySlot === "string" && memorySlot.trim() && !knownIds.has(memorySlot)) {
issues.push({
path: "plugins.slots.memory",
message: `plugin not found: ${memorySlot}`,
});
}
const allowedChannels = new Set<string>(["defaults", ...CHANNEL_IDS]);
for (const record of registry.plugins) {
for (const channelId of record.channels) {
allowedChannels.add(channelId);
}
}
if (config.channels && isRecord(config.channels)) {
for (const key of Object.keys(config.channels)) {
const trimmed = key.trim();
if (!trimmed) continue;
if (!allowedChannels.has(trimmed)) {
issues.push({
path: `channels.${trimmed}`,
message: `unknown channel id: ${trimmed}`,
});
}
}
}
let selectedMemoryPluginId: string | null = null;
const seenPlugins = new Set<string>();
for (const record of registry.plugins) {
const pluginId = record.id;
if (seenPlugins.has(pluginId)) {
continue;
}
seenPlugins.add(pluginId);
const entry = normalizedPlugins.entries[pluginId];
const entryHasConfig = Boolean(entry?.config);
const enableState = resolveEnableState(pluginId, record.origin, normalizedPlugins);
let enabled = enableState.enabled;
let reason = enableState.reason;
if (enabled) {
const memoryDecision = resolveMemorySlotDecision({
id: pluginId,
kind: record.kind,
slot: memorySlot,
selectedId: selectedMemoryPluginId,
});
if (!memoryDecision.enabled) {
enabled = false;
reason = memoryDecision.reason;
}
if (memoryDecision.selected && record.kind === "memory") {
selectedMemoryPluginId = pluginId;
}
}
const shouldValidate = enabled || entryHasConfig;
if (shouldValidate) {
if (record.configSchema) {
const res = validateJsonSchemaValue({
schema: record.configSchema,
cacheKey: record.schemaCacheKey ?? record.manifestPath ?? pluginId,
value: entry?.config ?? {},
});
if (!res.ok) {
for (const error of res.errors) {
issues.push({
path: `plugins.entries.${pluginId}.config`,
message: `invalid config: ${error}`,
});
}
}
} else {
issues.push({
path: `plugins.entries.${pluginId}`,
message: `plugin schema missing for ${pluginId}`,
});
}
}
if (!enabled && entryHasConfig) {
warnings.push({
path: `plugins.entries.${pluginId}`,
message: `plugin disabled (${reason ?? "disabled"}) but config is present`,
});
}
}
if (issues.length > 0) {
return { ok: false, issues, warnings };
}
return { ok: true, config, warnings };
}
+1 -1
View File
@@ -30,5 +30,5 @@ export const ChannelsSchema = z
imessage: IMessageConfigSchema.optional(), imessage: IMessageConfigSchema.optional(),
msteams: MSTeamsConfigSchema.optional(), msteams: MSTeamsConfigSchema.optional(),
}) })
.strict() .passthrough()
.optional(); .optional();
+18 -20
View File
@@ -72,7 +72,7 @@ export function describeGatewayCloseCode(code: number): string | undefined {
export class GatewayClient { export class GatewayClient {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private opts: GatewayClientOptions; private opts: GatewayClientOptions & { deviceIdentity: DeviceIdentity };
private pending = new Map<string, Pending>(); private pending = new Map<string, Pending>();
private backoffMs = 1000; private backoffMs = 1000;
private closed = false; private closed = false;
@@ -161,25 +161,23 @@ export class GatewayClient {
: undefined; : undefined;
const signedAtMs = Date.now(); const signedAtMs = Date.now();
const scopes = this.opts.scopes ?? ["operator.admin"]; const scopes = this.opts.scopes ?? ["operator.admin"];
const device = (() => { const deviceIdentity = this.opts.deviceIdentity;
if (!this.opts.deviceIdentity) return undefined; const payload = buildDeviceAuthPayload({
const payload = buildDeviceAuthPayload({ deviceId: deviceIdentity.deviceId,
deviceId: this.opts.deviceIdentity.deviceId, clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND, role,
role, scopes,
scopes, signedAtMs,
signedAtMs, token: authToken ?? null,
token: authToken ?? null, });
}); const signature = signDevicePayload(deviceIdentity.privateKeyPem, payload);
const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload); const device = {
return { id: deviceIdentity.deviceId,
id: this.opts.deviceIdentity.deviceId, publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem),
publicKey: publicKeyRawBase64UrlFromPem(this.opts.deviceIdentity.publicKeyPem), signature,
signature, signedAt: signedAtMs,
signedAt: signedAtMs, };
};
})();
const params: ConnectParams = { const params: ConnectParams = {
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION, minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION, maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
+4 -4
View File
@@ -5,7 +5,7 @@ import {
parseConfigJson5, parseConfigJson5,
readConfigFileSnapshot, readConfigFileSnapshot,
resolveConfigSnapshotHash, resolveConfigSnapshotHash,
validateConfigObject, validateConfigObjectWithPlugins,
writeConfigFile, writeConfigFile,
} from "../../config/config.js"; } from "../../config/config.js";
import { applyLegacyMigrations } from "../../config/legacy.js"; import { applyLegacyMigrations } from "../../config/legacy.js";
@@ -170,7 +170,7 @@ export const configHandlers: GatewayRequestHandlers = {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error)); respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error));
return; return;
} }
const validated = validateConfigObject(parsedRes.parsed); const validated = validateConfigObjectWithPlugins(parsedRes.parsed);
if (!validated.ok) { if (!validated.ok) {
respond( respond(
false, false,
@@ -248,7 +248,7 @@ export const configHandlers: GatewayRequestHandlers = {
const merged = applyMergePatch(snapshot.config, parsedRes.parsed); const merged = applyMergePatch(snapshot.config, parsedRes.parsed);
const migrated = applyLegacyMigrations(merged); const migrated = applyLegacyMigrations(merged);
const resolved = migrated.next ?? merged; const resolved = migrated.next ?? merged;
const validated = validateConfigObject(resolved); const validated = validateConfigObjectWithPlugins(resolved);
if (!validated.ok) { if (!validated.ok) {
respond( respond(
false, false,
@@ -303,7 +303,7 @@ export const configHandlers: GatewayRequestHandlers = {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error)); respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error));
return; return;
} }
const validated = validateConfigObject(parsedRes.parsed); const validated = validateConfigObjectWithPlugins(parsedRes.parsed);
if (!validated.ok) { if (!validated.ok) {
respond( respond(
false, false,
+2 -3
View File
@@ -102,9 +102,8 @@ describe("gateway server auth/connect", () => {
test("accepts device token auth for paired device", async () => { test("accepts device token auth for paired device", async () => {
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing } = await import( const { approveDevicePairing, getPairedDevice, listDevicePairing } =
"../infra/device-pairing.js" await import("../infra/device-pairing.js");
);
const { server, ws, port, prevToken } = await startServerWithClient("secret"); const { server, ws, port, prevToken } = await startServerWithClient("secret");
const res = await connectReq(ws, { token: "secret" }); const res = await connectReq(ws, { token: "secret" });
if (!res.ok) { if (!res.ok) {
+1 -1
View File
@@ -41,7 +41,7 @@ describe("gateway config.apply", () => {
id, id,
method: "config.apply", method: "config.apply",
params: { params: {
raw: '{ "agent": { "workspace": "~/clawd" } }', raw: '{ "agents": { "list": [{ "id": "main", "workspace": "~/clawd" }] } }',
sessionKey: "agent:main:whatsapp:dm:+15555550123", sessionKey: "agent:main:whatsapp:dm:+15555550123",
restartDelayMs: 0, restartDelayMs: 0,
}, },
+2 -2
View File
@@ -6,7 +6,7 @@ import {
loadConfig, loadConfig,
readConfigFileSnapshot, readConfigFileSnapshot,
resolveGatewayPort, resolveGatewayPort,
validateConfigObject, validateConfigObjectWithPlugins,
writeConfigFile, writeConfigFile,
} from "../config/config.js"; } from "../config/config.js";
import { runCommandWithTimeout } from "../process/exec.js"; import { runCommandWithTimeout } from "../process/exec.js";
@@ -244,7 +244,7 @@ export async function runGmailSetup(opts: GmailSetupOptions) {
}, },
}; };
const validated = validateConfigObject(nextConfig); const validated = validateConfigObjectWithPlugins(nextConfig);
if (!validated.ok) { if (!validated.ok) {
throw new Error(`Config validation failed: ${validated.issues[0]?.message ?? "invalid"}`); throw new Error(`Config validation failed: ${validated.issues[0]?.message ?? "invalid"}`);
} }
+14 -1
View File
@@ -60,7 +60,9 @@ export type { ClawdbotConfig } from "../config/config.js";
export type { ChannelDock } from "../channels/dock.js"; export type { ChannelDock } from "../channels/dock.js";
export { getChatChannelMeta } from "../channels/registry.js"; export { getChatChannelMeta } from "../channels/registry.js";
export type { export type {
BlockStreamingCoalesceConfig,
DmPolicy, DmPolicy,
DmConfig,
GroupPolicy, GroupPolicy,
MSTeamsChannelConfig, MSTeamsChannelConfig,
MSTeamsConfig, MSTeamsConfig,
@@ -76,6 +78,14 @@ export {
TelegramConfigSchema, TelegramConfigSchema,
} from "../config/zod-schema.providers-core.js"; } from "../config/zod-schema.providers-core.js";
export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js"; export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js";
export {
BlockStreamingCoalesceSchema,
DmConfigSchema,
DmPolicySchema,
GroupPolicySchema,
normalizeAllowFrom,
requireOpenAllowFrom,
} from "../config/zod-schema.core.js";
export type { RuntimeEnv } from "../runtime.js"; export type { RuntimeEnv } from "../runtime.js";
export type { WizardPrompter } from "../wizard/prompts.js"; export type { WizardPrompter } from "../wizard/prompts.js";
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
@@ -89,7 +99,10 @@ export {
} from "../auto-reply/reply/history.js"; } from "../auto-reply/reply/history.js";
export type { HistoryEntry } from "../auto-reply/reply/history.js"; export type { HistoryEntry } from "../auto-reply/reply/history.js";
export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js"; export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js";
export { resolveMentionGating } from "../channels/mention-gating.js"; export {
resolveMentionGating,
resolveMentionGatingWithBypass,
} from "../channels/mention-gating.js";
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
export { export {
resolveDiscordGroupRequireMention, resolveDiscordGroupRequireMention,
+126
View File
@@ -0,0 +1,126 @@
import type { ClawdbotConfig } from "../config/config.js";
import { defaultSlotIdForKey } from "./slots.js";
import type { PluginRecord } from "./registry.js";
export type NormalizedPluginsConfig = {
enabled: boolean;
allow: string[];
deny: string[];
loadPaths: string[];
slots: {
memory?: string | null;
};
entries: Record<string, { enabled?: boolean; config?: unknown }>;
};
export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>();
const normalizeList = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
};
const normalizeSlotValue = (value: unknown): string | null | undefined => {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.toLowerCase() === "none") return null;
return trimmed;
};
const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entries"] => {
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
return {};
}
const normalized: NormalizedPluginsConfig["entries"] = {};
for (const [key, value] of Object.entries(entries)) {
if (!key.trim()) continue;
if (!value || typeof value !== "object" || Array.isArray(value)) {
normalized[key] = {};
continue;
}
const entry = value as Record<string, unknown>;
normalized[key] = {
enabled: typeof entry.enabled === "boolean" ? entry.enabled : undefined,
config: "config" in entry ? entry.config : undefined,
};
}
return normalized;
};
export const normalizePluginsConfig = (
config?: ClawdbotConfig["plugins"],
): NormalizedPluginsConfig => {
const memorySlot = normalizeSlotValue(config?.slots?.memory);
return {
enabled: config?.enabled !== false,
allow: normalizeList(config?.allow),
deny: normalizeList(config?.deny),
loadPaths: normalizeList(config?.load?.paths),
slots: {
memory: memorySlot ?? defaultSlotIdForKey("memory"),
},
entries: normalizePluginEntries(config?.entries),
};
};
export function resolveEnableState(
id: string,
origin: PluginRecord["origin"],
config: NormalizedPluginsConfig,
): { enabled: boolean; reason?: string } {
if (!config.enabled) {
return { enabled: false, reason: "plugins disabled" };
}
if (config.deny.includes(id)) {
return { enabled: false, reason: "blocked by denylist" };
}
if (config.allow.length > 0 && !config.allow.includes(id)) {
return { enabled: false, reason: "not in allowlist" };
}
if (config.slots.memory === id) {
return { enabled: true };
}
const entry = config.entries[id];
if (entry?.enabled === true) {
return { enabled: true };
}
if (entry?.enabled === false) {
return { enabled: false, reason: "disabled in config" };
}
if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) {
return { enabled: true };
}
if (origin === "bundled") {
return { enabled: false, reason: "bundled (disabled by default)" };
}
return { enabled: true };
}
export function resolveMemorySlotDecision(params: {
id: string;
kind?: string;
slot: string | null | undefined;
selectedId: string | null;
}): { enabled: boolean; reason?: string; selected?: boolean } {
if (params.kind !== "memory") return { enabled: true };
if (params.slot === null) {
return { enabled: false, reason: "memory slot disabled" };
}
if (typeof params.slot === "string") {
if (params.slot === params.id) {
return { enabled: true, selected: true };
}
return {
enabled: false,
reason: `memory slot set to "${params.slot}"`,
};
}
if (params.selectedId && params.selectedId !== params.id) {
return {
enabled: false,
reason: `memory slot already filled by "${params.selectedId}"`,
};
}
return { enabled: true, selected: true };
}
+25 -13
View File
@@ -1,8 +1,9 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { resolveConfigDir, resolveUserPath } from "../utils.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js";
import type { ClawdbotPackageManifest, PackageManifest } from "./manifest.js";
import type { PluginDiagnostic, PluginOrigin } from "./types.js"; import type { PluginDiagnostic, PluginOrigin } from "./types.js";
const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]); const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
@@ -10,11 +11,14 @@ const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
export type PluginCandidate = { export type PluginCandidate = {
idHint: string; idHint: string;
source: string; source: string;
rootDir: string;
origin: PluginOrigin; origin: PluginOrigin;
workspaceDir?: string; workspaceDir?: string;
packageName?: string; packageName?: string;
packageVersion?: string; packageVersion?: string;
packageDescription?: string; packageDescription?: string;
packageDir?: string;
packageClawdbot?: ClawdbotPackageManifest;
}; };
export type PluginDiscoveryResult = { export type PluginDiscoveryResult = {
@@ -22,15 +26,6 @@ export type PluginDiscoveryResult = {
diagnostics: PluginDiagnostic[]; diagnostics: PluginDiagnostic[];
}; };
type PackageManifest = {
name?: string;
version?: string;
description?: string;
clawdbot?: {
extensions?: string[];
};
};
function isExtensionFile(filePath: string): boolean { function isExtensionFile(filePath: string): boolean {
const ext = path.extname(filePath); const ext = path.extname(filePath);
if (!EXTENSION_EXTS.has(ext)) return false; if (!EXTENSION_EXTS.has(ext)) return false;
@@ -78,9 +73,11 @@ function addCandidate(params: {
seen: Set<string>; seen: Set<string>;
idHint: string; idHint: string;
source: string; source: string;
rootDir: string;
origin: PluginOrigin; origin: PluginOrigin;
workspaceDir?: string; workspaceDir?: string;
manifest?: PackageManifest | null; manifest?: PackageManifest | null;
packageDir?: string;
}) { }) {
const resolved = path.resolve(params.source); const resolved = path.resolve(params.source);
if (params.seen.has(resolved)) return; if (params.seen.has(resolved)) return;
@@ -89,11 +86,14 @@ function addCandidate(params: {
params.candidates.push({ params.candidates.push({
idHint: params.idHint, idHint: params.idHint,
source: resolved, source: resolved,
rootDir: path.resolve(params.rootDir),
origin: params.origin, origin: params.origin,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
packageName: manifest?.name?.trim() || undefined, packageName: manifest?.name?.trim() || undefined,
packageVersion: manifest?.version?.trim() || undefined, packageVersion: manifest?.version?.trim() || undefined,
packageDescription: manifest?.description?.trim() || undefined, packageDescription: manifest?.description?.trim() || undefined,
packageDir: params.packageDir,
packageClawdbot: manifest?.clawdbot,
}); });
} }
@@ -127,6 +127,7 @@ function discoverInDirectory(params: {
seen: params.seen, seen: params.seen,
idHint: path.basename(entry.name, path.extname(entry.name)), idHint: path.basename(entry.name, path.extname(entry.name)),
source: fullPath, source: fullPath,
rootDir: path.dirname(fullPath),
origin: params.origin, origin: params.origin,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
}); });
@@ -148,9 +149,11 @@ function discoverInDirectory(params: {
hasMultipleExtensions: extensions.length > 1, hasMultipleExtensions: extensions.length > 1,
}), }),
source: resolved, source: resolved,
rootDir: fullPath,
origin: params.origin, origin: params.origin,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
manifest, manifest,
packageDir: fullPath,
}); });
} }
continue; continue;
@@ -166,8 +169,11 @@ function discoverInDirectory(params: {
seen: params.seen, seen: params.seen,
idHint: entry.name, idHint: entry.name,
source: indexFile, source: indexFile,
rootDir: fullPath,
origin: params.origin, origin: params.origin,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
manifest,
packageDir: fullPath,
}); });
} }
} }
@@ -184,7 +190,7 @@ function discoverFromPath(params: {
const resolved = resolveUserPath(params.rawPath); const resolved = resolveUserPath(params.rawPath);
if (!fs.existsSync(resolved)) { if (!fs.existsSync(resolved)) {
params.diagnostics.push({ params.diagnostics.push({
level: "warn", level: "error",
message: `plugin path not found: ${resolved}`, message: `plugin path not found: ${resolved}`,
source: resolved, source: resolved,
}); });
@@ -195,7 +201,7 @@ function discoverFromPath(params: {
if (stat.isFile()) { if (stat.isFile()) {
if (!isExtensionFile(resolved)) { if (!isExtensionFile(resolved)) {
params.diagnostics.push({ params.diagnostics.push({
level: "warn", level: "error",
message: `plugin path is not a supported file: ${resolved}`, message: `plugin path is not a supported file: ${resolved}`,
source: resolved, source: resolved,
}); });
@@ -206,6 +212,7 @@ function discoverFromPath(params: {
seen: params.seen, seen: params.seen,
idHint: path.basename(resolved, path.extname(resolved)), idHint: path.basename(resolved, path.extname(resolved)),
source: resolved, source: resolved,
rootDir: path.dirname(resolved),
origin: params.origin, origin: params.origin,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
}); });
@@ -228,9 +235,11 @@ function discoverFromPath(params: {
hasMultipleExtensions: extensions.length > 1, hasMultipleExtensions: extensions.length > 1,
}), }),
source, source,
rootDir: resolved,
origin: params.origin, origin: params.origin,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
manifest, manifest,
packageDir: resolved,
}); });
} }
return; return;
@@ -247,8 +256,11 @@ function discoverFromPath(params: {
seen: params.seen, seen: params.seen,
idHint: path.basename(resolved), idHint: path.basename(resolved),
source: indexFile, source: indexFile,
rootDir: resolved,
origin: params.origin, origin: params.origin,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
manifest,
packageDir: resolved,
}); });
return; return;
} }
@@ -301,7 +313,7 @@ export function discoverClawdbotPlugins(params: {
}); });
} }
const globalDir = path.join(CONFIG_DIR, "extensions"); const globalDir = path.join(resolveConfigDir(), "extensions");
discoverInDirectory({ discoverInDirectory({
dir: globalDir, dir: globalDir,
origin: "global", origin: "global",
+56 -36
View File
@@ -10,7 +10,7 @@ type TempPlugin = { dir: string; file: string; id: string };
const tempDirs: string[] = []; const tempDirs: string[] = [];
const prevBundledDir = process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR; const prevBundledDir = process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR;
const EMPTY_CONFIG_SCHEMA = `configSchema: { safeParse() { return { success: true, data: {} }; }, jsonSchema: { type: "object", additionalProperties: false, properties: {} } },`; const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
function makeTempDir() { function makeTempDir() {
const dir = path.join(os.tmpdir(), `clawdbot-plugin-${randomUUID()}`); const dir = path.join(os.tmpdir(), `clawdbot-plugin-${randomUUID()}`);
@@ -19,10 +19,28 @@ function makeTempDir() {
return dir; return dir;
} }
function writePlugin(params: { id: string; body: string }): TempPlugin { function writePlugin(params: {
const dir = makeTempDir(); id: string;
const file = path.join(dir, `${params.id}.js`); body: string;
dir?: string;
filename?: string;
}): TempPlugin {
const dir = params.dir ?? makeTempDir();
const filename = params.filename ?? `${params.id}.js`;
const file = path.join(dir, filename);
fs.writeFileSync(file, params.body, "utf-8"); fs.writeFileSync(file, params.body, "utf-8");
fs.writeFileSync(
path.join(dir, "clawdbot.plugin.json"),
JSON.stringify(
{
id: params.id,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
return { dir, file, id: params.id }; return { dir, file, id: params.id };
} }
@@ -44,12 +62,12 @@ afterEach(() => {
describe("loadClawdbotPlugins", () => { describe("loadClawdbotPlugins", () => {
it("disables bundled plugins by default", () => { it("disables bundled plugins by default", () => {
const bundledDir = makeTempDir(); const bundledDir = makeTempDir();
const bundledPath = path.join(bundledDir, "bundled.ts"); writePlugin({
fs.writeFileSync( id: "bundled",
bundledPath, body: `export default { id: "bundled", register() {} };`,
`export default { id: "bundled", ${EMPTY_CONFIG_SCHEMA} register() {} };`, dir: bundledDir,
"utf-8", filename: "bundled.ts",
); });
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -102,12 +120,12 @@ describe("loadClawdbotPlugins", () => {
it("enables bundled memory plugin when selected by slot", () => { it("enables bundled memory plugin when selected by slot", () => {
const bundledDir = makeTempDir(); const bundledDir = makeTempDir();
const bundledPath = path.join(bundledDir, "memory-core.ts"); writePlugin({
fs.writeFileSync( id: "memory-core",
bundledPath, body: `export default { id: "memory-core", kind: "memory", register() {} };`,
`export default { id: "memory-core", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`, dir: bundledDir,
"utf-8", filename: "memory-core.ts",
); });
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -140,11 +158,12 @@ describe("loadClawdbotPlugins", () => {
}), }),
"utf-8", "utf-8",
); );
fs.writeFileSync( writePlugin({
path.join(pluginDir, "index.ts"), id: "memory-core",
`export default { id: "memory-core", kind: "memory", name: "Memory (Core)", ${EMPTY_CONFIG_SCHEMA} register() {} };`, body: `export default { id: "memory-core", kind: "memory", name: "Memory (Core)", register() {} };`,
"utf-8", dir: pluginDir,
); filename: "index.ts",
});
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
@@ -169,7 +188,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({ const plugin = writePlugin({
id: "allowed", id: "allowed",
body: `export default { id: "allowed", ${EMPTY_CONFIG_SCHEMA} register(api) { api.registerGatewayMethod("allowed.ping", ({ respond }) => respond(true, { ok: true })); } };`, body: `export default { id: "allowed", register(api) { api.registerGatewayMethod("allowed.ping", ({ respond }) => respond(true, { ok: true })); } };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -192,7 +211,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({ const plugin = writePlugin({
id: "blocked", id: "blocked",
body: `export default { id: "blocked", ${EMPTY_CONFIG_SCHEMA} register() {} };`, body: `export default { id: "blocked", register() {} };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -215,7 +234,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({ const plugin = writePlugin({
id: "configurable", id: "configurable",
body: `export default {\n id: "configurable",\n configSchema: {\n parse(value) {\n if (!value || typeof value !== "object" || Array.isArray(value)) {\n throw new Error("bad config");\n }\n return value;\n }\n },\n register() {}\n};`, body: `export default { id: "configurable", register() {} };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -242,7 +261,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({ const plugin = writePlugin({
id: "channel-demo", id: "channel-demo",
body: `export default { id: "channel-demo", ${EMPTY_CONFIG_SCHEMA} register(api) { body: `export default { id: "channel-demo", register(api) {
api.registerChannel({ api.registerChannel({
plugin: { plugin: {
id: "demo", id: "demo",
@@ -283,7 +302,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({ const plugin = writePlugin({
id: "http-demo", id: "http-demo",
body: `export default { id: "http-demo", ${EMPTY_CONFIG_SCHEMA} register(api) { body: `export default { id: "http-demo", register(api) {
api.registerHttpHandler(async () => false); api.registerHttpHandler(async () => false);
} };`, } };`,
}); });
@@ -309,7 +328,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({ const plugin = writePlugin({
id: "config-disable", id: "config-disable",
body: `export default { id: "config-disable", ${EMPTY_CONFIG_SCHEMA} register() {} };`, body: `export default { id: "config-disable", register() {} };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -332,11 +351,11 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const memoryA = writePlugin({ const memoryA = writePlugin({
id: "memory-a", id: "memory-a",
body: `export default { id: "memory-a", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`, body: `export default { id: "memory-a", kind: "memory", register() {} };`,
}); });
const memoryB = writePlugin({ const memoryB = writePlugin({
id: "memory-b", id: "memory-b",
body: `export default { id: "memory-b", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`, body: `export default { id: "memory-b", kind: "memory", register() {} };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -359,7 +378,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const memory = writePlugin({ const memory = writePlugin({
id: "memory-off", id: "memory-off",
body: `export default { id: "memory-off", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`, body: `export default { id: "memory-off", kind: "memory", register() {} };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -378,16 +397,17 @@ describe("loadClawdbotPlugins", () => {
it("prefers higher-precedence plugins with the same id", () => { it("prefers higher-precedence plugins with the same id", () => {
const bundledDir = makeTempDir(); const bundledDir = makeTempDir();
fs.writeFileSync( writePlugin({
path.join(bundledDir, "shadow.js"), id: "shadow",
`export default { id: "shadow", ${EMPTY_CONFIG_SCHEMA} register() {} };`, body: `export default { id: "shadow", register() {} };`,
"utf-8", dir: bundledDir,
); filename: "shadow.js",
});
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
const override = writePlugin({ const override = writePlugin({
id: "shadow", id: "shadow",
body: `export default { id: "shadow", ${EMPTY_CONFIG_SCHEMA} register() {} };`, body: `export default { id: "shadow", register() {} };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
+83 -237
View File
@@ -8,16 +8,21 @@ import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { createSubsystemLogger } from "../logging/subsystem.js"; import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { discoverClawdbotPlugins } from "./discovery.js"; import { discoverClawdbotPlugins } from "./discovery.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import {
normalizePluginsConfig,
resolveEnableState,
resolveMemorySlotDecision,
type NormalizedPluginsConfig,
} from "./config-state.js";
import { initializeGlobalHookRunner } from "./hook-runner-global.js"; import { initializeGlobalHookRunner } from "./hook-runner-global.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { createPluginRuntime } from "./runtime/index.js"; import { createPluginRuntime } from "./runtime/index.js";
import { setActivePluginRegistry } from "./runtime.js"; import { setActivePluginRegistry } from "./runtime.js";
import { defaultSlotIdForKey } from "./slots.js"; import { validateJsonSchemaValue } from "./schema-validator.js";
import type { import type {
ClawdbotPluginConfigSchema,
ClawdbotPluginDefinition, ClawdbotPluginDefinition,
ClawdbotPluginModule, ClawdbotPluginModule,
PluginConfigUiHint,
PluginDiagnostic, PluginDiagnostic,
PluginLogger, PluginLogger,
} from "./types.js"; } from "./types.js";
@@ -33,73 +38,10 @@ export type PluginLoadOptions = {
mode?: "full" | "validate"; mode?: "full" | "validate";
}; };
type NormalizedPluginsConfig = {
enabled: boolean;
allow: string[];
deny: string[];
loadPaths: string[];
slots: {
memory?: string | null;
};
entries: Record<string, { enabled?: boolean; config?: Record<string, unknown> }>;
};
const registryCache = new Map<string, PluginRegistry>(); const registryCache = new Map<string, PluginRegistry>();
const defaultLogger = () => createSubsystemLogger("plugins"); const defaultLogger = () => createSubsystemLogger("plugins");
const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>();
const normalizeList = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
};
const normalizeSlotValue = (value: unknown): string | null | undefined => {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.toLowerCase() === "none") return null;
return trimmed;
};
const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entries"] => {
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
return {};
}
const normalized: NormalizedPluginsConfig["entries"] = {};
for (const [key, value] of Object.entries(entries)) {
if (!key.trim()) continue;
if (!value || typeof value !== "object" || Array.isArray(value)) {
normalized[key] = {};
continue;
}
const entry = value as Record<string, unknown>;
normalized[key] = {
enabled: typeof entry.enabled === "boolean" ? entry.enabled : undefined,
config:
entry.config && typeof entry.config === "object" && !Array.isArray(entry.config)
? (entry.config as Record<string, unknown>)
: undefined,
};
}
return normalized;
};
const normalizePluginsConfig = (config?: ClawdbotConfig["plugins"]): NormalizedPluginsConfig => {
const memorySlot = normalizeSlotValue(config?.slots?.memory);
return {
enabled: config?.enabled !== false,
allow: normalizeList(config?.allow),
deny: normalizeList(config?.deny),
loadPaths: normalizeList(config?.load?.paths),
slots: {
memory: memorySlot ?? defaultSlotIdForKey("memory"),
},
entries: normalizePluginEntries(config?.entries),
};
};
const resolvePluginSdkAlias = (): string | null => { const resolvePluginSdkAlias = (): string | null => {
try { try {
const modulePath = fileURLToPath(import.meta.url); const modulePath = fileURLToPath(import.meta.url);
@@ -133,105 +75,25 @@ function buildCacheKey(params: {
return `${workspaceKey}::${JSON.stringify(params.plugins)}`; return `${workspaceKey}::${JSON.stringify(params.plugins)}`;
} }
function resolveMemorySlotDecision(params: {
id: string;
kind?: string;
slot: string | null | undefined;
selectedId: string | null;
}): { enabled: boolean; reason?: string; selected?: boolean } {
if (params.kind !== "memory") return { enabled: true };
if (params.slot === null) {
return { enabled: false, reason: "memory slot disabled" };
}
if (typeof params.slot === "string") {
if (params.slot === params.id) {
return { enabled: true, selected: true };
}
return {
enabled: false,
reason: `memory slot set to "${params.slot}"`,
};
}
if (params.selectedId && params.selectedId !== params.id) {
return {
enabled: false,
reason: `memory slot already filled by "${params.selectedId}"`,
};
}
return { enabled: true, selected: true };
}
function resolveEnableState(
id: string,
origin: PluginRecord["origin"],
config: NormalizedPluginsConfig,
): { enabled: boolean; reason?: string } {
if (!config.enabled) {
return { enabled: false, reason: "plugins disabled" };
}
if (config.deny.includes(id)) {
return { enabled: false, reason: "blocked by denylist" };
}
if (config.allow.length > 0 && !config.allow.includes(id)) {
return { enabled: false, reason: "not in allowlist" };
}
if (config.slots.memory === id) {
return { enabled: true };
}
const entry = config.entries[id];
if (entry?.enabled === true) {
return { enabled: true };
}
if (entry?.enabled === false) {
return { enabled: false, reason: "disabled in config" };
}
if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) {
return { enabled: true };
}
if (origin === "bundled") {
return { enabled: false, reason: "bundled (disabled by default)" };
}
return { enabled: true };
}
function validatePluginConfig(params: { function validatePluginConfig(params: {
schema?: ClawdbotPluginConfigSchema; schema?: Record<string, unknown>;
value?: Record<string, unknown>; cacheKey?: string;
value?: unknown;
}): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } { }): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } {
const schema = params.schema; const schema = params.schema;
if (!schema) return { ok: true, value: params.value }; if (!schema) {
return { ok: true, value: params.value as Record<string, unknown> | undefined };
if (typeof schema.validate === "function") {
const result = schema.validate(params.value);
if (result.ok) {
return { ok: true, value: result.value as Record<string, unknown> };
}
return { ok: false, errors: result.errors };
} }
const cacheKey = params.cacheKey ?? JSON.stringify(schema);
if (typeof schema.safeParse === "function") { const result = validateJsonSchemaValue({
const result = schema.safeParse(params.value); schema,
if (result.success) { cacheKey,
return { ok: true, value: result.data as Record<string, unknown> }; value: params.value ?? {},
} });
const issues = result.error?.issues ?? []; if (result.ok) {
const errors = issues.map((issue) => { return { ok: true, value: params.value as Record<string, unknown> | undefined };
const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
return `${path}: ${issue.message}`;
});
return { ok: false, errors };
} }
return { ok: false, errors: result.errors };
if (typeof schema.parse === "function") {
try {
const parsed = schema.parse(params.value);
return { ok: true, value: parsed as Record<string, unknown> };
} catch (err) {
return { ok: false, errors: [String(err)] };
}
}
return { ok: true, value: params.value };
} }
function resolvePluginModuleExport(moduleExport: unknown): { function resolvePluginModuleExport(moduleExport: unknown): {
@@ -326,7 +188,14 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
workspaceDir: options.workspaceDir, workspaceDir: options.workspaceDir,
extraPaths: normalized.loadPaths, extraPaths: normalized.loadPaths,
}); });
pushDiagnostics(registry.diagnostics, discovery.diagnostics); const manifestRegistry = loadPluginManifestRegistry({
config: cfg,
workspaceDir: options.workspaceDir,
cache: options.cache,
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
});
pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
const pluginSdkAlias = resolvePluginSdkAlias(); const pluginSdkAlias = resolvePluginSdkAlias();
const jiti = createJiti(import.meta.url, { const jiti = createJiti(import.meta.url, {
@@ -335,10 +204,8 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
...(pluginSdkAlias ? { alias: { "clawdbot/plugin-sdk": pluginSdkAlias } } : {}), ...(pluginSdkAlias ? { alias: { "clawdbot/plugin-sdk": pluginSdkAlias } } : {}),
}); });
const bundledIds = new Set( const manifestByRoot = new Map(
discovery.candidates manifestRegistry.plugins.map((record) => [record.rootDir, record]),
.filter((candidate) => candidate.origin === "bundled")
.map((candidate) => candidate.idHint),
); );
const seenIds = new Map<string, PluginRecord["origin"]>(); const seenIds = new Map<string, PluginRecord["origin"]>();
@@ -347,18 +214,23 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
let memorySlotMatched = false; let memorySlotMatched = false;
for (const candidate of discovery.candidates) { for (const candidate of discovery.candidates) {
const existingOrigin = seenIds.get(candidate.idHint); const manifestRecord = manifestByRoot.get(candidate.rootDir);
if (!manifestRecord) {
continue;
}
const pluginId = manifestRecord.id;
const existingOrigin = seenIds.get(pluginId);
if (existingOrigin) { if (existingOrigin) {
const record = createPluginRecord({ const record = createPluginRecord({
id: candidate.idHint, id: pluginId,
name: candidate.packageName ?? candidate.idHint, name: manifestRecord.name ?? pluginId,
description: candidate.packageDescription, description: manifestRecord.description,
version: candidate.packageVersion, version: manifestRecord.version,
source: candidate.source, source: candidate.source,
origin: candidate.origin, origin: candidate.origin,
workspaceDir: candidate.workspaceDir, workspaceDir: candidate.workspaceDir,
enabled: false, enabled: false,
configSchema: false, configSchema: Boolean(manifestRecord.configSchema),
}); });
record.status = "disabled"; record.status = "disabled";
record.error = `overridden by ${existingOrigin} plugin`; record.error = `overridden by ${existingOrigin} plugin`;
@@ -366,25 +238,42 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
continue; continue;
} }
const enableState = resolveEnableState(candidate.idHint, candidate.origin, normalized); const enableState = resolveEnableState(pluginId, candidate.origin, normalized);
const entry = normalized.entries[candidate.idHint]; const entry = normalized.entries[pluginId];
const record = createPluginRecord({ const record = createPluginRecord({
id: candidate.idHint, id: pluginId,
name: candidate.packageName ?? candidate.idHint, name: manifestRecord.name ?? pluginId,
description: candidate.packageDescription, description: manifestRecord.description,
version: candidate.packageVersion, version: manifestRecord.version,
source: candidate.source, source: candidate.source,
origin: candidate.origin, origin: candidate.origin,
workspaceDir: candidate.workspaceDir, workspaceDir: candidate.workspaceDir,
enabled: enableState.enabled, enabled: enableState.enabled,
configSchema: false, configSchema: Boolean(manifestRecord.configSchema),
}); });
record.kind = manifestRecord.kind;
record.configUiHints = manifestRecord.configUiHints;
record.configJsonSchema = manifestRecord.configSchema;
if (!enableState.enabled) { if (!enableState.enabled) {
record.status = "disabled"; record.status = "disabled";
record.error = enableState.reason; record.error = enableState.reason;
registry.plugins.push(record); registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin); seenIds.set(pluginId, candidate.origin);
continue;
}
if (!manifestRecord.configSchema) {
record.status = "error";
record.error = "missing config schema";
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
source: record.source,
message: record.error,
});
continue; continue;
} }
@@ -396,7 +285,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "error"; record.status = "error";
record.error = String(err); record.error = String(err);
registry.plugins.push(record); registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin); seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({ registry.diagnostics.push({
level: "error", level: "error",
pluginId: record.id, pluginId: record.id,
@@ -422,61 +311,17 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.name = definition?.name ?? record.name; record.name = definition?.name ?? record.name;
record.description = definition?.description ?? record.description; record.description = definition?.description ?? record.description;
record.version = definition?.version ?? record.version; record.version = definition?.version ?? record.version;
record.kind = definition?.kind; const manifestKind = record.kind as string | undefined;
record.configSchema = Boolean(definition?.configSchema); const exportKind = definition?.kind as string | undefined;
record.configUiHints = if (manifestKind && exportKind && exportKind !== manifestKind) {
definition?.configSchema &&
typeof definition.configSchema === "object" &&
(definition.configSchema as { uiHints?: unknown }).uiHints &&
typeof (definition.configSchema as { uiHints?: unknown }).uiHints === "object" &&
!Array.isArray((definition.configSchema as { uiHints?: unknown }).uiHints)
? ((definition.configSchema as { uiHints?: unknown }).uiHints as Record<
string,
PluginConfigUiHint
>)
: undefined;
record.configJsonSchema =
definition?.configSchema &&
typeof definition.configSchema === "object" &&
(definition.configSchema as { jsonSchema?: unknown }).jsonSchema &&
typeof (definition.configSchema as { jsonSchema?: unknown }).jsonSchema === "object" &&
!Array.isArray((definition.configSchema as { jsonSchema?: unknown }).jsonSchema)
? ((definition.configSchema as { jsonSchema?: unknown }).jsonSchema as Record<
string,
unknown
>)
: undefined;
if (!definition?.configSchema) {
const hasBundledFallback =
candidate.origin !== "bundled" && bundledIds.has(candidate.idHint);
if (hasBundledFallback) {
record.enabled = false;
record.status = "disabled";
record.error = "missing config schema (using bundled plugin)";
registry.plugins.push(record);
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message: record.error,
});
continue;
}
logger.error(`[plugins] ${record.id} missing config schema`);
record.status = "error";
record.error = "missing config schema";
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
registry.diagnostics.push({ registry.diagnostics.push({
level: "error", level: "warn",
pluginId: record.id, pluginId: record.id,
source: record.source, source: record.source,
message: record.error, message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`,
}); });
continue;
} }
record.kind = definition?.kind ?? record.kind;
if (record.kind === "memory" && memorySlot === record.id) { if (record.kind === "memory" && memorySlot === record.id) {
memorySlotMatched = true; memorySlotMatched = true;
@@ -494,7 +339,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "disabled"; record.status = "disabled";
record.error = memoryDecision.reason; record.error = memoryDecision.reason;
registry.plugins.push(record); registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin); seenIds.set(pluginId, candidate.origin);
continue; continue;
} }
@@ -503,7 +348,8 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
} }
const validatedConfig = validatePluginConfig({ const validatedConfig = validatePluginConfig({
schema: definition?.configSchema, schema: manifestRecord.configSchema,
cacheKey: manifestRecord.schemaCacheKey,
value: entry?.config, value: entry?.config,
}); });
@@ -512,7 +358,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "error"; record.status = "error";
record.error = `invalid config: ${validatedConfig.errors?.join(", ")}`; record.error = `invalid config: ${validatedConfig.errors?.join(", ")}`;
registry.plugins.push(record); registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin); seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({ registry.diagnostics.push({
level: "error", level: "error",
pluginId: record.id, pluginId: record.id,
@@ -524,7 +370,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
if (validateOnly) { if (validateOnly) {
registry.plugins.push(record); registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin); seenIds.set(pluginId, candidate.origin);
continue; continue;
} }
@@ -533,7 +379,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "error"; record.status = "error";
record.error = "plugin export missing register/activate"; record.error = "plugin export missing register/activate";
registry.plugins.push(record); registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin); seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({ registry.diagnostics.push({
level: "error", level: "error",
pluginId: record.id, pluginId: record.id,
@@ -559,7 +405,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
}); });
} }
registry.plugins.push(record); registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin); seenIds.set(pluginId, candidate.origin);
} catch (err) { } catch (err) {
logger.error( logger.error(
`[plugins] ${record.id} failed during register from ${record.source}: ${String(err)}`, `[plugins] ${record.id} failed during register from ${record.source}: ${String(err)}`,
@@ -567,7 +413,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "error"; record.status = "error";
record.error = String(err); record.error = String(err);
registry.plugins.push(record); registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin); seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({ registry.diagnostics.push({
level: "error", level: "error",
pluginId: record.id, pluginId: record.id,
+189
View File
@@ -0,0 +1,189 @@
import fs from "node:fs";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
import { discoverClawdbotPlugins, type PluginCandidate } from "./discovery.js";
import { loadPluginManifest, type PluginManifest } from "./manifest.js";
import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js";
export type PluginManifestRecord = {
id: string;
name?: string;
description?: string;
version?: string;
kind?: PluginKind;
channels: string[];
providers: string[];
origin: PluginOrigin;
workspaceDir?: string;
rootDir: string;
source: string;
manifestPath: string;
schemaCacheKey?: string;
configSchema?: Record<string, unknown>;
configUiHints?: Record<string, PluginConfigUiHint>;
};
export type PluginManifestRegistry = {
plugins: PluginManifestRecord[];
diagnostics: PluginDiagnostic[];
};
const registryCache = new Map<string, { expiresAt: number; registry: PluginManifestRegistry }>();
const DEFAULT_MANIFEST_CACHE_MS = 200;
function resolveManifestCacheMs(env: NodeJS.ProcessEnv): number {
const raw = env.CLAWDBOT_PLUGIN_MANIFEST_CACHE_MS?.trim();
if (raw === "" || raw === "0") return 0;
if (!raw) return DEFAULT_MANIFEST_CACHE_MS;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) return DEFAULT_MANIFEST_CACHE_MS;
return Math.max(0, parsed);
}
function shouldUseManifestCache(env: NodeJS.ProcessEnv): boolean {
const disabled = env.CLAWDBOT_DISABLE_PLUGIN_MANIFEST_CACHE?.trim();
if (disabled) return false;
return resolveManifestCacheMs(env) > 0;
}
function buildCacheKey(params: {
workspaceDir?: string;
plugins: NormalizedPluginsConfig;
}): string {
const workspaceKey = params.workspaceDir ? resolveUserPath(params.workspaceDir) : "";
return `${workspaceKey}::${JSON.stringify(params.plugins)}`;
}
function safeStatMtimeMs(filePath: string): number | null {
try {
return fs.statSync(filePath).mtimeMs;
} catch {
return null;
}
}
function normalizeManifestLabel(raw: string | undefined): string | undefined {
const trimmed = raw?.trim();
return trimmed ? trimmed : undefined;
}
function buildRecord(params: {
manifest: PluginManifest;
candidate: PluginCandidate;
manifestPath: string;
schemaCacheKey?: string;
configSchema?: Record<string, unknown>;
}): PluginManifestRecord {
return {
id: params.manifest.id,
name: normalizeManifestLabel(params.manifest.name) ?? params.candidate.packageName,
description:
normalizeManifestLabel(params.manifest.description) ?? params.candidate.packageDescription,
version: normalizeManifestLabel(params.manifest.version) ?? params.candidate.packageVersion,
kind: params.manifest.kind,
channels: params.manifest.channels ?? [],
providers: params.manifest.providers ?? [],
origin: params.candidate.origin,
workspaceDir: params.candidate.workspaceDir,
rootDir: params.candidate.rootDir,
source: params.candidate.source,
manifestPath: params.manifestPath,
schemaCacheKey: params.schemaCacheKey,
configSchema: params.configSchema,
configUiHints: params.manifest.uiHints,
};
}
export function loadPluginManifestRegistry(params: {
config?: ClawdbotConfig;
workspaceDir?: string;
cache?: boolean;
env?: NodeJS.ProcessEnv;
candidates?: PluginCandidate[];
diagnostics?: PluginDiagnostic[];
}): PluginManifestRegistry {
const config = params.config ?? {};
const normalized = normalizePluginsConfig(config.plugins);
const cacheKey = buildCacheKey({ workspaceDir: params.workspaceDir, plugins: normalized });
const env = params.env ?? process.env;
const cacheEnabled = params.cache !== false && shouldUseManifestCache(env);
if (cacheEnabled) {
const cached = registryCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) return cached.registry;
}
const discovery = params.candidates
? {
candidates: params.candidates,
diagnostics: params.diagnostics ?? [],
}
: discoverClawdbotPlugins({
workspaceDir: params.workspaceDir,
extraPaths: normalized.loadPaths,
});
const diagnostics: PluginDiagnostic[] = [...discovery.diagnostics];
const candidates: PluginCandidate[] = discovery.candidates;
const records: PluginManifestRecord[] = [];
const seenIds = new Set<string>();
for (const candidate of candidates) {
const manifestRes = loadPluginManifest(candidate.rootDir);
if (!manifestRes.ok) {
diagnostics.push({
level: "error",
message: manifestRes.error,
source: manifestRes.manifestPath,
});
continue;
}
const manifest = manifestRes.manifest;
if (candidate.idHint && candidate.idHint !== manifest.id) {
diagnostics.push({
level: "warn",
pluginId: manifest.id,
source: candidate.source,
message: `plugin id mismatch (manifest uses "${manifest.id}", entry hints "${candidate.idHint}")`,
});
}
if (seenIds.has(manifest.id)) {
diagnostics.push({
level: "warn",
pluginId: manifest.id,
source: candidate.source,
message: `duplicate plugin id detected; later plugin may be overridden (${candidate.source})`,
});
} else {
seenIds.add(manifest.id);
}
const configSchema = manifest.configSchema;
const manifestMtime = safeStatMtimeMs(manifestRes.manifestPath);
const schemaCacheKey = manifestMtime
? `${manifestRes.manifestPath}:${manifestMtime}`
: manifestRes.manifestPath;
records.push(
buildRecord({
manifest,
candidate,
manifestPath: manifestRes.manifestPath,
schemaCacheKey,
configSchema,
}),
);
}
const registry = { plugins: records, diagnostics };
if (cacheEnabled) {
const ttl = resolveManifestCacheMs(env);
if (ttl > 0) {
registryCache.set(cacheKey, { expiresAt: Date.now() + ttl, registry });
}
}
return registry;
}
+129
View File
@@ -0,0 +1,129 @@
import fs from "node:fs";
import path from "node:path";
import type { PluginConfigUiHint, PluginKind } from "./types.js";
export const PLUGIN_MANIFEST_FILENAME = "clawdbot.plugin.json";
export type PluginManifest = {
id: string;
configSchema: Record<string, unknown>;
kind?: PluginKind;
channels?: string[];
providers?: string[];
name?: string;
description?: string;
version?: string;
uiHints?: Record<string, PluginConfigUiHint>;
};
export type PluginManifestLoadResult =
| { ok: true; manifest: PluginManifest; manifestPath: string }
| { ok: false; error: string; manifestPath: string };
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function resolvePluginManifestPath(rootDir: string): string {
return path.join(rootDir, PLUGIN_MANIFEST_FILENAME);
}
export function loadPluginManifest(rootDir: string): PluginManifestLoadResult {
const manifestPath = resolvePluginManifestPath(rootDir);
if (!fs.existsSync(manifestPath)) {
return { ok: false, error: `plugin manifest not found: ${manifestPath}`, manifestPath };
}
let raw: unknown;
try {
raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as unknown;
} catch (err) {
return {
ok: false,
error: `failed to parse plugin manifest: ${String(err)}`,
manifestPath,
};
}
if (!isRecord(raw)) {
return { ok: false, error: "plugin manifest must be an object", manifestPath };
}
const id = typeof raw.id === "string" ? raw.id.trim() : "";
if (!id) {
return { ok: false, error: "plugin manifest requires id", manifestPath };
}
const configSchema = isRecord(raw.configSchema) ? raw.configSchema : null;
if (!configSchema) {
return { ok: false, error: "plugin manifest requires configSchema", manifestPath };
}
const kind = typeof raw.kind === "string" ? (raw.kind as PluginKind) : undefined;
const name = typeof raw.name === "string" ? raw.name.trim() : undefined;
const description = typeof raw.description === "string" ? raw.description.trim() : undefined;
const version = typeof raw.version === "string" ? raw.version.trim() : undefined;
const channels = normalizeStringList(raw.channels);
const providers = normalizeStringList(raw.providers);
let uiHints: Record<string, PluginConfigUiHint> | undefined;
if (isRecord(raw.uiHints)) {
uiHints = raw.uiHints as Record<string, PluginConfigUiHint>;
}
return {
ok: true,
manifest: {
id,
configSchema,
kind,
channels,
providers,
name,
description,
version,
uiHints,
},
manifestPath,
};
}
// package.json "clawdbot" metadata (used for onboarding/catalog)
export type PluginPackageChannel = {
id?: string;
label?: string;
selectionLabel?: string;
docsPath?: string;
docsLabel?: string;
blurb?: string;
order?: number;
aliases?: string[];
selectionDocsPrefix?: string;
selectionDocsOmitLabel?: boolean;
selectionExtras?: string[];
showConfigured?: boolean;
quickstartAllowFrom?: boolean;
forceAccountBinding?: boolean;
preferSessionLookupForAnnounceTarget?: boolean;
};
export type PluginPackageInstall = {
npmSpec?: string;
localPath?: string;
defaultChoice?: "npm" | "local";
};
export type ClawdbotPackageManifest = {
extensions?: string[];
channel?: PluginPackageChannel;
install?: PluginPackageInstall;
};
export type PackageManifest = {
name?: string;
version?: string;
description?: string;
clawdbot?: ClawdbotPackageManifest;
};
+5
View File
@@ -52,6 +52,7 @@ import { probeDiscord } from "../../discord/probe.js";
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js"; import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js"; import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js"; import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js";
import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js";
import { enqueueSystemEvent } from "../../infra/system-events.js"; import { enqueueSystemEvent } from "../../infra/system-events.js";
import { monitorIMessageProvider } from "../../imessage/monitor.js"; import { monitorIMessageProvider } from "../../imessage/monitor.js";
import { probeIMessage } from "../../imessage/probe.js"; import { probeIMessage } from "../../imessage/probe.js";
@@ -177,6 +178,10 @@ export function createPluginRuntime(): PluginRuntime {
fetchRemoteMedia, fetchRemoteMedia,
saveMediaBuffer, saveMediaBuffer,
}, },
activity: {
record: recordChannelActivity,
get: getChannelActivity,
},
session: { session: {
resolveStorePath, resolveStorePath,
readSessionUpdatedAt, readSessionUpdatedAt,
+6
View File
@@ -55,6 +55,8 @@ type ReadSessionUpdatedAt = typeof import("../../config/sessions.js").readSessio
type UpdateLastRoute = typeof import("../../config/sessions.js").updateLastRoute; type UpdateLastRoute = typeof import("../../config/sessions.js").updateLastRoute;
type LoadConfig = typeof import("../../config/config.js").loadConfig; type LoadConfig = typeof import("../../config/config.js").loadConfig;
type WriteConfigFile = typeof import("../../config/config.js").writeConfigFile; type WriteConfigFile = typeof import("../../config/config.js").writeConfigFile;
type RecordChannelActivity = typeof import("../../infra/channel-activity.js").recordChannelActivity;
type GetChannelActivity = typeof import("../../infra/channel-activity.js").getChannelActivity;
type EnqueueSystemEvent = typeof import("../../infra/system-events.js").enqueueSystemEvent; type EnqueueSystemEvent = typeof import("../../infra/system-events.js").enqueueSystemEvent;
type RunCommandWithTimeout = typeof import("../../process/exec.js").runCommandWithTimeout; type RunCommandWithTimeout = typeof import("../../process/exec.js").runCommandWithTimeout;
type LoadWebMedia = typeof import("../../web/media.js").loadWebMedia; type LoadWebMedia = typeof import("../../web/media.js").loadWebMedia;
@@ -188,6 +190,10 @@ export type PluginRuntime = {
fetchRemoteMedia: FetchRemoteMedia; fetchRemoteMedia: FetchRemoteMedia;
saveMediaBuffer: SaveMediaBuffer; saveMediaBuffer: SaveMediaBuffer;
}; };
activity: {
record: RecordChannelActivity;
get: GetChannelActivity;
};
session: { session: {
resolveStorePath: ResolveStorePath; resolveStorePath: ResolveStorePath;
readSessionUpdatedAt: ReadSessionUpdatedAt; readSessionUpdatedAt: ReadSessionUpdatedAt;
+40
View File
@@ -0,0 +1,40 @@
import AjvPkg, { type ErrorObject, type ValidateFunction } from "ajv";
const ajv = new (AjvPkg as unknown as new (opts?: object) => import("ajv").default)({
allErrors: true,
strict: false,
removeAdditional: false,
});
type CachedValidator = {
validate: ValidateFunction;
schema: Record<string, unknown>;
};
const schemaCache = new Map<string, CachedValidator>();
function formatAjvErrors(errors: ErrorObject[] | null | undefined): string[] {
if (!errors || errors.length === 0) return ["invalid config"];
return errors.map((error) => {
const path = error.instancePath?.replace(/^\//, "").replace(/\//g, ".") || "<root>";
const message = error.message ?? "invalid";
return `${path}: ${message}`;
});
}
export function validateJsonSchemaValue(params: {
schema: Record<string, unknown>;
cacheKey: string;
value: unknown;
}): { ok: true } | { ok: false; errors: string[] } {
let cached = schemaCache.get(params.cacheKey);
if (!cached || cached.schema !== params.schema) {
const validate = ajv.compile(params.schema) as ValidateFunction;
cached = { validate, schema: params.schema };
schemaCache.set(params.cacheKey, cached);
}
const ok = cached.validate(params.value);
if (ok) return { ok: true };
return { ok: false, errors: formatAjvErrors(cached.validate.errors) };
}
+15 -4
View File
@@ -10,6 +10,7 @@ import { resolvePluginTools } from "./tools.js";
type TempPlugin = { dir: string; file: string; id: string }; type TempPlugin = { dir: string; file: string; id: string };
const tempDirs: string[] = []; const tempDirs: string[] = [];
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
function makeTempDir() { function makeTempDir() {
const dir = path.join(os.tmpdir(), `clawdbot-plugin-tools-${randomUUID()}`); const dir = path.join(os.tmpdir(), `clawdbot-plugin-tools-${randomUUID()}`);
@@ -22,6 +23,18 @@ function writePlugin(params: { id: string; body: string }): TempPlugin {
const dir = makeTempDir(); const dir = makeTempDir();
const file = path.join(dir, `${params.id}.js`); const file = path.join(dir, `${params.id}.js`);
fs.writeFileSync(file, params.body, "utf-8"); fs.writeFileSync(file, params.body, "utf-8");
fs.writeFileSync(
path.join(dir, "clawdbot.plugin.json"),
JSON.stringify(
{
id: params.id,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
return { dir, file, id: params.id }; return { dir, file, id: params.id };
} }
@@ -36,10 +49,8 @@ afterEach(() => {
}); });
describe("resolvePluginTools optional tools", () => { describe("resolvePluginTools optional tools", () => {
const emptyConfigSchema =
'configSchema: { safeParse() { return { success: true, data: {} }; }, jsonSchema: { type: "object", additionalProperties: false, properties: {} } },';
const pluginBody = ` const pluginBody = `
export default { ${emptyConfigSchema} register(api) { export default { register(api) {
api.registerTool( api.registerTool(
{ {
name: "optional_tool", name: "optional_tool",
@@ -140,7 +151,7 @@ export default { ${emptyConfigSchema} register(api) {
const plugin = writePlugin({ const plugin = writePlugin({
id: "multi", id: "multi",
body: ` body: `
export default { ${emptyConfigSchema} register(api) { export default { register(api) {
api.registerTool({ api.registerTool({
name: "message", name: "message",
description: "conflict", description: "conflict",
-93
View File
@@ -1,93 +0,0 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { GatewaySessionRow, SessionsListResult } from "../types";
import { renderSessions, type SessionsProps } from "./sessions";
function createRow(overrides: Partial<GatewaySessionRow> = {}): GatewaySessionRow {
return {
key: "session-1",
kind: "direct",
updatedAt: 0,
...overrides,
};
}
function createResult(rows: GatewaySessionRow[]): SessionsListResult {
return {
ts: 0,
path: "/sessions",
count: rows.length,
defaults: {
model: null,
contextTokens: null,
},
sessions: rows,
};
}
function createProps(overrides: Partial<SessionsProps> = {}): SessionsProps {
return {
loading: false,
result: createResult([]),
error: null,
activeMinutes: "",
limit: "",
includeGlobal: true,
includeUnknown: true,
basePath: "/",
onFiltersChange: () => undefined,
onRefresh: () => undefined,
onPatch: () => undefined,
onDelete: () => undefined,
...overrides,
};
}
describe("sessions view", () => {
it("skips patching when the label is unchanged", () => {
const container = document.createElement("div");
const onPatch = vi.fn();
const row = createRow({ label: "Alpha" });
render(
renderSessions(
createProps({
result: createResult([row]),
onPatch,
}),
),
container,
);
const input = container.querySelector("input") as HTMLInputElement | null;
expect(input).not.toBeNull();
input?.dispatchEvent(new Event("change", { bubbles: true }));
expect(onPatch).not.toHaveBeenCalled();
});
it("clears labels when the input is empty", () => {
const container = document.createElement("div");
const onPatch = vi.fn();
const row = createRow({ label: "Alpha" });
render(
renderSessions(
createProps({
result: createResult([row]),
onPatch,
}),
),
container,
);
const input = container.querySelector("input") as HTMLInputElement | null;
expect(input).not.toBeNull();
if (!input) return;
input.value = " ";
input.dispatchEvent(new Event("change", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith("session-1", { label: null });
});
});
-2
View File
@@ -203,8 +203,6 @@ function renderRow(
placeholder="(optional)" placeholder="(optional)"
@change=${(e: Event) => { @change=${(e: Event) => {
const value = (e.target as HTMLInputElement).value.trim(); const value = (e.target as HTMLInputElement).value.trim();
const current = (row.label ?? "").trim();
if (value === current) return;
onPatch(row.key, { label: value || null }); onPatch(row.key, { label: value || null });
}} }}
/> />