Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
432d1481dc | ||
|
|
c9e3c14f9c | ||
|
|
636a8e3181 | ||
|
|
660f87278c | ||
|
|
9ec1fb4a80 | ||
|
|
74757cd5af | ||
|
|
9f75550702 | ||
|
|
8214ab507c | ||
|
|
2f6d5805de | ||
|
|
8ddedc3fc5 | ||
|
|
51f1f23235 |
No files matched your search
+4
-9
@@ -6,19 +6,14 @@ Docs: https://docs.clawd.bot
|
||||
|
||||
### Changes
|
||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||
- Gateway: raise default lane concurrency for main and sub-agent runs.
|
||||
- Config: centralize default agent concurrency limits.
|
||||
|
||||
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
||||
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
||||
### 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).
|
||||
- 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.
|
||||
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
||||
- Auth: dedupe codex-cli profiles when tokens match custom openai-codex entries. (#1264) — thanks @odrobnik.
|
||||
- Agents: avoid misclassifying context-window-too-small errors as context overflow. (#1266) — thanks @humanwritten.
|
||||
- Slack: resolve Bolt default-export shapes for monitor startup. (#1208) — thanks @24601.
|
||||
- UI: allow editing session labels in the Sessions table. (#1294) — thanks @bradleypriest.
|
||||
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
|
||||
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
|
||||
|
||||
## 2026.1.19-3
|
||||
|
||||
|
||||
@@ -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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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/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/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/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/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/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/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/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/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/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/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/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/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=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>
|
||||
@@ -19,6 +19,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [iMessage](/channels/imessage) — macOS only; native integration.
|
||||
- [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default).
|
||||
- [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).
|
||||
- [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).
|
||||
|
||||
@@ -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).
|
||||
@@ -11,6 +11,7 @@ Manage Gateway plugins/extensions (loaded in-process).
|
||||
|
||||
Related:
|
||||
- Plugin system: [Plugins](/plugin)
|
||||
- Plugin manifest + schema: [Plugin manifest](/plugins/manifest)
|
||||
- Security hardening: [Security](/gateway/security)
|
||||
|
||||
## Commands
|
||||
@@ -28,6 +29,10 @@ clawdbot plugins update --all
|
||||
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
|
||||
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
|
||||
|
||||
```bash
|
||||
|
||||
+64
-14
@@ -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)
|
||||
- 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
|
||||
register:
|
||||
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. **Config
|
||||
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 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,
|
||||
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
|
||||
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
|
||||
`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
|
||||
|
||||
Default plugin ids:
|
||||
@@ -140,6 +178,14 @@ Fields:
|
||||
|
||||
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)
|
||||
|
||||
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>`
|
||||
|
||||
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:
|
||||
|
||||
```ts
|
||||
export default {
|
||||
id: "my-plugin",
|
||||
configSchema: {
|
||||
parse: (v) => v,
|
||||
uiHints: {
|
||||
"apiKey": { label: "API Key", sensitive: true },
|
||||
"region": { label: "Region", placeholder: "us-east-1" },
|
||||
},
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": { "type": "string" },
|
||||
"region": { "type": "string" }
|
||||
}
|
||||
},
|
||||
register(api) {},
|
||||
};
|
||||
"uiHints": {
|
||||
"apiKey": { "label": "API Key", "sensitive": true },
|
||||
"region": { "label": "Region", "placeholder": "us-east-1" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -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.
|
||||
@@ -22,17 +22,20 @@ read_when:
|
||||
- Unknown keys are validation errors (no passthrough at root or nested).
|
||||
- `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.
|
||||
- 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
|
||||
- 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:
|
||||
1) Resolve plugin schema by plugin id.
|
||||
1) Resolve plugin manifest + schema (`clawdbot.plugin.json`).
|
||||
2) Validate config against the schema.
|
||||
3) If missing schema or invalid config: block plugin load, record error.
|
||||
- Error message includes:
|
||||
- Plugin id
|
||||
- Reason (missing schema / invalid config)
|
||||
- Path(s) that failed validation
|
||||
- Disabled plugins keep their config, but Doctor + logs surface a warning.
|
||||
|
||||
## Doctor flow
|
||||
- Doctor runs **every time** config is loaded (dry-run by default).
|
||||
|
||||
@@ -32,6 +32,8 @@ Notes:
|
||||
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
|
||||
- `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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "bluebubbles",
|
||||
"channels": [
|
||||
"bluebubbles"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,20 @@
|
||||
"type": "module",
|
||||
"description": "Clawdbot BlueBubbles channel plugin",
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "imessage",
|
||||
"channels": [
|
||||
"imessage"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "matrix",
|
||||
"channels": [
|
||||
"matrix"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,22 @@
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./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": {
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "msteams",
|
||||
"channels": [
|
||||
"msteams"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,22 @@
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./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": {
|
||||
"@microsoft/agents-hosting": "^1.2.2",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "nextcloud-talk",
|
||||
"channels": [
|
||||
"nextcloud-talk"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 "*"',
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "signal",
|
||||
"channels": [
|
||||
"signal"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "slack",
|
||||
"channels": [
|
||||
"slack"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "telegram",
|
||||
"channels": [
|
||||
"telegram"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "whatsapp",
|
||||
"channels": [
|
||||
"whatsapp"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "zalo",
|
||||
"channels": [
|
||||
"zalo"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,23 @@
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./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": {
|
||||
"clawdbot": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "zalouser",
|
||||
"channels": [
|
||||
"zalouser"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
"longmaba",
|
||||
"manmal",
|
||||
"thesash",
|
||||
"rhjoh"
|
||||
"rhjoh",
|
||||
"ysqander"
|
||||
],
|
||||
"seedCommit": "d6863f87",
|
||||
"placeholderAvatar": "assets/avatar-placeholder.svg",
|
||||
|
||||
+2
@@ -150,6 +150,7 @@ describe("getDmHistoryLimitFromSessionKey", () => {
|
||||
"signal",
|
||||
"imessage",
|
||||
"msteams",
|
||||
"nextcloud-talk",
|
||||
] as const;
|
||||
|
||||
for (const provider of providers) {
|
||||
@@ -168,6 +169,7 @@ describe("getDmHistoryLimitFromSessionKey", () => {
|
||||
"signal",
|
||||
"imessage",
|
||||
"msteams",
|
||||
"nextcloud-talk",
|
||||
] as const;
|
||||
|
||||
for (const provider of providers) {
|
||||
|
||||
@@ -62,22 +62,16 @@ export function getDmHistoryLimitFromSessionKey(
|
||||
return providerConfig.dmHistoryLimit;
|
||||
};
|
||||
|
||||
switch (provider) {
|
||||
case "telegram":
|
||||
return getLimit(config.channels?.telegram);
|
||||
case "whatsapp":
|
||||
return getLimit(config.channels?.whatsapp);
|
||||
case "discord":
|
||||
return getLimit(config.channels?.discord);
|
||||
case "slack":
|
||||
return getLimit(config.channels?.slack);
|
||||
case "signal":
|
||||
return getLimit(config.channels?.signal);
|
||||
case "imessage":
|
||||
return getLimit(config.channels?.imessage);
|
||||
case "msteams":
|
||||
return getLimit(config.channels?.msteams);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
const resolveProviderConfig = (
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
providerId: string,
|
||||
): { dmHistoryLimit?: number; dms?: Record<string, { historyLimit?: number }> } | undefined => {
|
||||
const channels = cfg?.channels;
|
||||
if (!channels || typeof channels !== "object") return undefined;
|
||||
const entry = (channels as Record<string, unknown>)[providerId];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined;
|
||||
return entry as { dmHistoryLimit?: number; dms?: Record<string, { historyLimit?: number }> };
|
||||
};
|
||||
|
||||
return getLimit(resolveProviderConfig(config, provider));
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export const DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS = 12_000;
|
||||
|
||||
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_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 { guardSessionManager } from "./session-tool-result-guard-wrapper.js";
|
||||
|
||||
const EMPTY_CONFIG_SCHEMA = `configSchema: {
|
||||
validate: () => ({ ok: true }),
|
||||
jsonSchema: { type: "object", additionalProperties: true },
|
||||
uiHints: {}
|
||||
}`;
|
||||
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
|
||||
|
||||
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(
|
||||
path.join(pluginDir, "clawdbot.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: params.id,
|
||||
configSchema: EMPTY_PLUGIN_SCHEMA,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
return file;
|
||||
}
|
||||
|
||||
@@ -63,7 +73,7 @@ describe("tool_result_persist hook", () => {
|
||||
const pluginA = writeTempPlugin({
|
||||
dir: tmp,
|
||||
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) => {
|
||||
const msg = event.message;
|
||||
// Example: remove large diagnostic payloads before persistence.
|
||||
@@ -76,7 +86,7 @@ describe("tool_result_persist hook", () => {
|
||||
const pluginB = writeTempPlugin({
|
||||
dir: tmp,
|
||||
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) => {
|
||||
const prior = (event.message && event.message.persistOrder) ? event.message.persistOrder : [];
|
||||
return { message: { ...event.message, persistOrder: [...prior, "b"] } };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"] };
|
||||
}
|
||||
|
||||
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 {
|
||||
const scrubbed = text.replace(/[\p{Format}\p{Surrogate}]/gu, "");
|
||||
if (!scrubbed) return scrubbed;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
readConfigFileSnapshot,
|
||||
validateConfigObject,
|
||||
validateConfigObjectWithPlugins,
|
||||
writeConfigFile,
|
||||
} from "../../config/config.js";
|
||||
import {
|
||||
@@ -120,7 +120,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
|
||||
reply: { text: `⚙️ No config value found for ${configCommand.path}.` },
|
||||
};
|
||||
}
|
||||
const validated = validateConfigObject(parsedBase);
|
||||
const validated = validateConfigObjectWithPlugins(parsedBase);
|
||||
if (!validated.ok) {
|
||||
const issue = validated.issues[0];
|
||||
return {
|
||||
@@ -146,7 +146,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
|
||||
};
|
||||
}
|
||||
setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value);
|
||||
const validated = validateConfigObject(parsedBase);
|
||||
const validated = validateConfigObjectWithPlugins(parsedBase);
|
||||
if (!validated.ok) {
|
||||
const issue = validated.issues[0];
|
||||
return {
|
||||
|
||||
+129
-77
@@ -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";
|
||||
|
||||
export type ChannelPluginCatalogEntry = {
|
||||
@@ -10,86 +15,133 @@ export type ChannelPluginCatalogEntry = {
|
||||
};
|
||||
};
|
||||
|
||||
const CATALOG: ChannelPluginCatalogEntry[] = [
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
];
|
||||
type CatalogOptions = {
|
||||
workspaceDir?: string;
|
||||
};
|
||||
|
||||
export function listChannelPluginCatalogEntries(): ChannelPluginCatalogEntry[] {
|
||||
return [...CATALOG];
|
||||
const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
|
||||
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();
|
||||
if (!trimmed) return undefined;
|
||||
return CATALOG.find((entry) => entry.id === trimmed);
|
||||
return listChannelPluginCatalogEntries(options).find((entry) => entry.id === trimmed);
|
||||
}
|
||||
@@ -123,7 +123,8 @@ export function registerDevicesCli(program: Command) {
|
||||
const name = req.displayName || req.deviceId;
|
||||
const repair = req.isRepair ? " (repair)" : "";
|
||||
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}` : "";
|
||||
defaultRuntime.log(`- ${req.requestId}: ${name}${repair}${role}${ip}${age}`);
|
||||
}
|
||||
@@ -133,7 +134,9 @@ export function registerDevicesCli(program: Command) {
|
||||
for (const device of list.paired) {
|
||||
const name = device.displayName || device.deviceId;
|
||||
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 tokens = formatTokenSummary(device.tokens);
|
||||
defaultRuntime.log(`- ${name} · ${roles} · ${scopes} · ${tokens}${ip}`);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { readConfigFileSnapshot } from "../../config/config.js";
|
||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-flow.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { loadClawdbotPlugins } from "../../plugins/loader.js";
|
||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { formatCliCommand } from "../command-format.js";
|
||||
|
||||
@@ -30,26 +28,7 @@ export async function ensureConfigReady(params: {
|
||||
? snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`)
|
||||
: [];
|
||||
|
||||
const pluginIssues: string[] = [];
|
||||
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);
|
||||
const invalid = snapshot.exists && !snapshot.valid;
|
||||
if (!invalid) return;
|
||||
|
||||
const rich = isRich();
|
||||
@@ -68,10 +47,6 @@ export async function ensureConfigReady(params: {
|
||||
params.runtime.error(muted("Legacy config keys detected:"));
|
||||
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(
|
||||
`${muted("Run:")} ${commandText(formatCliCommand("clawdbot doctor --fix"))}`,
|
||||
|
||||
@@ -128,6 +128,11 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
|
||||
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) {
|
||||
note(
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import JSON5 from "json5";
|
||||
|
||||
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
|
||||
import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT } from "../config/config.js";
|
||||
import { applyModelDefaults } from "../config/defaults.js";
|
||||
import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
||||
import { resolveSessionTranscriptsDir } from "../config/sessions.js";
|
||||
import type { RuntimeEnv } 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(
|
||||
opts?: { workspace?: string },
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("config backup rotation", () => {
|
||||
const configPath = resolveConfigPath();
|
||||
const buildConfig = (version: number): ClawdbotConfig =>
|
||||
({
|
||||
identity: { name: `v${version}` },
|
||||
agents: { list: [{ id: `v${version}` }] },
|
||||
}) as ClawdbotConfig;
|
||||
|
||||
for (let version = 0; version <= 6; version += 1) {
|
||||
@@ -21,7 +21,10 @@ describe("config backup rotation", () => {
|
||||
|
||||
const readName = async (suffix = "") => {
|
||||
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");
|
||||
|
||||
@@ -309,7 +309,9 @@ describe("config identity defaults", () => {
|
||||
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
|
||||
expect(cfg.agents?.list).toBeUndefined();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,6 +96,25 @@ describe("Nix integration (U3, U5, U9)", () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".clawdbot");
|
||||
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(
|
||||
path.join(configDir, "clawdbot.json"),
|
||||
JSON.stringify(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,5 +10,5 @@ export { migrateLegacyConfig } from "./legacy-migrate.js";
|
||||
export * from "./paths.js";
|
||||
export * from "./runtime-overrides.js";
|
||||
export * from "./types.js";
|
||||
export { validateConfigObject } from "./validation.js";
|
||||
export { validateConfigObject, validateConfigObjectWithPlugins } from "./validation.js";
|
||||
export { ClawdbotSchema } from "./zod-schema.js";
|
||||
+43
-12
@@ -30,8 +30,7 @@ import { normalizeConfigPaths } from "./normalize-paths.js";
|
||||
import { resolveConfigPath, resolveStateDir } from "./paths.js";
|
||||
import { applyConfigOverrides } from "./runtime-overrides.js";
|
||||
import type { ClawdbotConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
|
||||
import { validateConfigObject } from "./validation.js";
|
||||
import { ClawdbotSchema } from "./zod-schema.js";
|
||||
import { validateConfigObjectWithPlugins } from "./validation.js";
|
||||
import { compareClawdbotVersions } from "./version.js";
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
@@ -233,21 +232,34 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
const resolvedConfig = substituted;
|
||||
warnOnConfigMiskeys(resolvedConfig, deps.logger);
|
||||
if (typeof resolvedConfig !== "object" || resolvedConfig === null) return {};
|
||||
const validated = ClawdbotSchema.safeParse(resolvedConfig);
|
||||
if (!validated.success) {
|
||||
deps.logger.error("Invalid config:");
|
||||
for (const iss of validated.error.issues) {
|
||||
deps.logger.error(`- ${iss.path.join(".")}: ${iss.message}`);
|
||||
}
|
||||
return {};
|
||||
const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig as ClawdbotConfig, {
|
||||
env: deps.env,
|
||||
homedir: deps.homedir,
|
||||
});
|
||||
if (preValidationDuplicates.length > 0) {
|
||||
throw new DuplicateAgentDirError(preValidationDuplicates);
|
||||
}
|
||||
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(
|
||||
applyCompactionDefaults(
|
||||
applyContextPruningDefaults(
|
||||
applyAgentDefaults(
|
||||
applySessionDefaults(
|
||||
applyLoggingDefaults(applyMessageDefaults(validated.data as ClawdbotConfig)),
|
||||
applyLoggingDefaults(applyMessageDefaults(validated.config)),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -310,6 +322,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
config,
|
||||
hash,
|
||||
issues: [],
|
||||
warnings: [],
|
||||
legacyIssues,
|
||||
};
|
||||
}
|
||||
@@ -328,6 +341,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
config: {},
|
||||
hash,
|
||||
issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
@@ -353,6 +367,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
config: coerceConfig(parsedRes.parsed),
|
||||
hash,
|
||||
issues: [{ path: "", message }],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
@@ -375,6 +390,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
config: coerceConfig(resolved),
|
||||
hash,
|
||||
issues: [{ path: "", message }],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
@@ -382,7 +398,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
const resolvedConfigRaw = substituted;
|
||||
const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw);
|
||||
|
||||
const validated = validateConfigObject(resolvedConfigRaw);
|
||||
const validated = validateConfigObjectWithPlugins(resolvedConfigRaw);
|
||||
if (!validated.ok) {
|
||||
return {
|
||||
path: configPath,
|
||||
@@ -393,6 +409,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
config: coerceConfig(resolvedConfigRaw),
|
||||
hash,
|
||||
issues: validated.issues,
|
||||
warnings: validated.warnings,
|
||||
legacyIssues,
|
||||
};
|
||||
}
|
||||
@@ -415,6 +432,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
),
|
||||
hash,
|
||||
issues: [],
|
||||
warnings: validated.warnings,
|
||||
legacyIssues,
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -427,6 +445,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
config: {},
|
||||
hash: hashConfigRaw(null),
|
||||
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
@@ -434,6 +453,18 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
|
||||
async function writeConfigFile(cfg: ClawdbotConfig) {
|
||||
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);
|
||||
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { applyLegacyMigrations } from "./legacy.js";
|
||||
import type { ClawdbotConfig } from "./types.js";
|
||||
import { validateConfigObject } from "./validation.js";
|
||||
import { validateConfigObjectWithPlugins } from "./validation.js";
|
||||
|
||||
export function migrateLegacyConfig(raw: unknown): {
|
||||
config: ClawdbotConfig | null;
|
||||
@@ -8,7 +8,7 @@ export function migrateLegacyConfig(raw: unknown): {
|
||||
} {
|
||||
const { next, changes } = applyLegacyMigrations(raw);
|
||||
if (!next) return { config: null, changes: [] };
|
||||
const validated = validateConfigObject(next);
|
||||
const validated = validateConfigObjectWithPlugins(next);
|
||||
if (!validated.ok) {
|
||||
changes.push("Migration applied, but config still invalid; fix remaining issues manually.");
|
||||
return { config: null, changes };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ClawdbotConfig } from "./config.js";
|
||||
import { hasAnyWhatsAppAuth } from "../web/accounts.js";
|
||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||
import { listChatChannels } from "../channels/registry.js";
|
||||
import { hasAnyWhatsAppAuth } from "../web/accounts.js";
|
||||
|
||||
type PluginEnableChange = {
|
||||
pluginId: string;
|
||||
@@ -12,20 +13,6 @@ export type PluginAutoEnableResult = {
|
||||
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 }> = [
|
||||
{ pluginId: "google-antigravity-auth", providerId: "google-antigravity" },
|
||||
{ pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" },
|
||||
@@ -239,7 +226,19 @@ function resolveConfiguredPlugins(
|
||||
env: NodeJS.ProcessEnv,
|
||||
): 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)) {
|
||||
changes.push({
|
||||
pluginId: channelId,
|
||||
|
||||
@@ -105,5 +105,6 @@ export type ConfigFileSnapshot = {
|
||||
config: ClawdbotConfig;
|
||||
hash?: string;
|
||||
issues: ConfigValidationIssue[];
|
||||
warnings: ConfigValidationIssue[];
|
||||
legacyIssues: LegacyConfigIssue[];
|
||||
};
|
||||
@@ -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 { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.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 };
|
||||
}
|
||||
@@ -30,5 +30,5 @@ export const ChannelsSchema = z
|
||||
imessage: IMessageConfigSchema.optional(),
|
||||
msteams: MSTeamsConfigSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.passthrough()
|
||||
.optional();
|
||||
+18
-20
@@ -72,7 +72,7 @@ export function describeGatewayCloseCode(code: number): string | undefined {
|
||||
|
||||
export class GatewayClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private opts: GatewayClientOptions;
|
||||
private opts: GatewayClientOptions & { deviceIdentity: DeviceIdentity };
|
||||
private pending = new Map<string, Pending>();
|
||||
private backoffMs = 1000;
|
||||
private closed = false;
|
||||
@@ -161,25 +161,23 @@ export class GatewayClient {
|
||||
: undefined;
|
||||
const signedAtMs = Date.now();
|
||||
const scopes = this.opts.scopes ?? ["operator.admin"];
|
||||
const device = (() => {
|
||||
if (!this.opts.deviceIdentity) return undefined;
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: this.opts.deviceIdentity.deviceId,
|
||||
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: authToken ?? null,
|
||||
});
|
||||
const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
|
||||
return {
|
||||
id: this.opts.deviceIdentity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(this.opts.deviceIdentity.publicKeyPem),
|
||||
signature,
|
||||
signedAt: signedAtMs,
|
||||
};
|
||||
})();
|
||||
const deviceIdentity = this.opts.deviceIdentity;
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: authToken ?? null,
|
||||
});
|
||||
const signature = signDevicePayload(deviceIdentity.privateKeyPem, payload);
|
||||
const device = {
|
||||
id: deviceIdentity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem),
|
||||
signature,
|
||||
signedAt: signedAtMs,
|
||||
};
|
||||
const params: ConnectParams = {
|
||||
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
||||
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
parseConfigJson5,
|
||||
readConfigFileSnapshot,
|
||||
resolveConfigSnapshotHash,
|
||||
validateConfigObject,
|
||||
validateConfigObjectWithPlugins,
|
||||
writeConfigFile,
|
||||
} from "../../config/config.js";
|
||||
import { applyLegacyMigrations } from "../../config/legacy.js";
|
||||
@@ -170,7 +170,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error));
|
||||
return;
|
||||
}
|
||||
const validated = validateConfigObject(parsedRes.parsed);
|
||||
const validated = validateConfigObjectWithPlugins(parsedRes.parsed);
|
||||
if (!validated.ok) {
|
||||
respond(
|
||||
false,
|
||||
@@ -248,7 +248,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
const merged = applyMergePatch(snapshot.config, parsedRes.parsed);
|
||||
const migrated = applyLegacyMigrations(merged);
|
||||
const resolved = migrated.next ?? merged;
|
||||
const validated = validateConfigObject(resolved);
|
||||
const validated = validateConfigObjectWithPlugins(resolved);
|
||||
if (!validated.ok) {
|
||||
respond(
|
||||
false,
|
||||
@@ -303,7 +303,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error));
|
||||
return;
|
||||
}
|
||||
const validated = validateConfigObject(parsedRes.parsed);
|
||||
const validated = validateConfigObjectWithPlugins(parsedRes.parsed);
|
||||
if (!validated.ok) {
|
||||
respond(
|
||||
false,
|
||||
|
||||
@@ -102,9 +102,8 @@ describe("gateway server auth/connect", () => {
|
||||
|
||||
test("accepts device token auth for paired device", async () => {
|
||||
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
||||
const { approveDevicePairing, getPairedDevice, listDevicePairing } = await import(
|
||||
"../infra/device-pairing.js"
|
||||
);
|
||||
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||
const res = await connectReq(ws, { token: "secret" });
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("gateway config.apply", () => {
|
||||
id,
|
||||
method: "config.apply",
|
||||
params: {
|
||||
raw: '{ "agent": { "workspace": "~/clawd" } }',
|
||||
raw: '{ "agents": { "list": [{ "id": "main", "workspace": "~/clawd" }] } }',
|
||||
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||
restartDelayMs: 0,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
loadConfig,
|
||||
readConfigFileSnapshot,
|
||||
resolveGatewayPort,
|
||||
validateConfigObject,
|
||||
validateConfigObjectWithPlugins,
|
||||
writeConfigFile,
|
||||
} from "../config/config.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) {
|
||||
throw new Error(`Config validation failed: ${validated.issues[0]?.message ?? "invalid"}`);
|
||||
}
|
||||
|
||||
+14
-1
@@ -60,7 +60,9 @@ export type { ClawdbotConfig } from "../config/config.js";
|
||||
export type { ChannelDock } from "../channels/dock.js";
|
||||
export { getChatChannelMeta } from "../channels/registry.js";
|
||||
export type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
DmConfig,
|
||||
GroupPolicy,
|
||||
MSTeamsChannelConfig,
|
||||
MSTeamsConfig,
|
||||
@@ -76,6 +78,14 @@ export {
|
||||
TelegramConfigSchema,
|
||||
} from "../config/zod-schema.providers-core.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 { WizardPrompter } from "../wizard/prompts.js";
|
||||
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
@@ -89,7 +99,10 @@ export {
|
||||
} 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 { resolveMentionGating } from "../channels/mention-gating.js";
|
||||
export {
|
||||
resolveMentionGating,
|
||||
resolveMentionGatingWithBypass,
|
||||
} from "../channels/mention-gating.js";
|
||||
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
|
||||
export {
|
||||
resolveDiscordGroupRequireMention,
|
||||
|
||||
@@ -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
@@ -1,8 +1,9 @@
|
||||
import fs from "node:fs";
|
||||
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 type { ClawdbotPackageManifest, PackageManifest } from "./manifest.js";
|
||||
import type { PluginDiagnostic, PluginOrigin } from "./types.js";
|
||||
|
||||
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 = {
|
||||
idHint: string;
|
||||
source: string;
|
||||
rootDir: string;
|
||||
origin: PluginOrigin;
|
||||
workspaceDir?: string;
|
||||
packageName?: string;
|
||||
packageVersion?: string;
|
||||
packageDescription?: string;
|
||||
packageDir?: string;
|
||||
packageClawdbot?: ClawdbotPackageManifest;
|
||||
};
|
||||
|
||||
export type PluginDiscoveryResult = {
|
||||
@@ -22,15 +26,6 @@ export type PluginDiscoveryResult = {
|
||||
diagnostics: PluginDiagnostic[];
|
||||
};
|
||||
|
||||
type PackageManifest = {
|
||||
name?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
clawdbot?: {
|
||||
extensions?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
function isExtensionFile(filePath: string): boolean {
|
||||
const ext = path.extname(filePath);
|
||||
if (!EXTENSION_EXTS.has(ext)) return false;
|
||||
@@ -78,9 +73,11 @@ function addCandidate(params: {
|
||||
seen: Set<string>;
|
||||
idHint: string;
|
||||
source: string;
|
||||
rootDir: string;
|
||||
origin: PluginOrigin;
|
||||
workspaceDir?: string;
|
||||
manifest?: PackageManifest | null;
|
||||
packageDir?: string;
|
||||
}) {
|
||||
const resolved = path.resolve(params.source);
|
||||
if (params.seen.has(resolved)) return;
|
||||
@@ -89,11 +86,14 @@ function addCandidate(params: {
|
||||
params.candidates.push({
|
||||
idHint: params.idHint,
|
||||
source: resolved,
|
||||
rootDir: path.resolve(params.rootDir),
|
||||
origin: params.origin,
|
||||
workspaceDir: params.workspaceDir,
|
||||
packageName: manifest?.name?.trim() || undefined,
|
||||
packageVersion: manifest?.version?.trim() || undefined,
|
||||
packageDescription: manifest?.description?.trim() || undefined,
|
||||
packageDir: params.packageDir,
|
||||
packageClawdbot: manifest?.clawdbot,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ function discoverInDirectory(params: {
|
||||
seen: params.seen,
|
||||
idHint: path.basename(entry.name, path.extname(entry.name)),
|
||||
source: fullPath,
|
||||
rootDir: path.dirname(fullPath),
|
||||
origin: params.origin,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
@@ -148,9 +149,11 @@ function discoverInDirectory(params: {
|
||||
hasMultipleExtensions: extensions.length > 1,
|
||||
}),
|
||||
source: resolved,
|
||||
rootDir: fullPath,
|
||||
origin: params.origin,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
packageDir: fullPath,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -166,8 +169,11 @@ function discoverInDirectory(params: {
|
||||
seen: params.seen,
|
||||
idHint: entry.name,
|
||||
source: indexFile,
|
||||
rootDir: fullPath,
|
||||
origin: params.origin,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
packageDir: fullPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -184,7 +190,7 @@ function discoverFromPath(params: {
|
||||
const resolved = resolveUserPath(params.rawPath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
params.diagnostics.push({
|
||||
level: "warn",
|
||||
level: "error",
|
||||
message: `plugin path not found: ${resolved}`,
|
||||
source: resolved,
|
||||
});
|
||||
@@ -195,7 +201,7 @@ function discoverFromPath(params: {
|
||||
if (stat.isFile()) {
|
||||
if (!isExtensionFile(resolved)) {
|
||||
params.diagnostics.push({
|
||||
level: "warn",
|
||||
level: "error",
|
||||
message: `plugin path is not a supported file: ${resolved}`,
|
||||
source: resolved,
|
||||
});
|
||||
@@ -206,6 +212,7 @@ function discoverFromPath(params: {
|
||||
seen: params.seen,
|
||||
idHint: path.basename(resolved, path.extname(resolved)),
|
||||
source: resolved,
|
||||
rootDir: path.dirname(resolved),
|
||||
origin: params.origin,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
@@ -228,9 +235,11 @@ function discoverFromPath(params: {
|
||||
hasMultipleExtensions: extensions.length > 1,
|
||||
}),
|
||||
source,
|
||||
rootDir: resolved,
|
||||
origin: params.origin,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
packageDir: resolved,
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -247,8 +256,11 @@ function discoverFromPath(params: {
|
||||
seen: params.seen,
|
||||
idHint: path.basename(resolved),
|
||||
source: indexFile,
|
||||
rootDir: resolved,
|
||||
origin: params.origin,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
packageDir: resolved,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -301,7 +313,7 @@ export function discoverClawdbotPlugins(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const globalDir = path.join(CONFIG_DIR, "extensions");
|
||||
const globalDir = path.join(resolveConfigDir(), "extensions");
|
||||
discoverInDirectory({
|
||||
dir: globalDir,
|
||||
origin: "global",
|
||||
|
||||
+56
-36
@@ -10,7 +10,7 @@ type TempPlugin = { dir: string; file: string; id: string };
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
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() {
|
||||
const dir = path.join(os.tmpdir(), `clawdbot-plugin-${randomUUID()}`);
|
||||
@@ -19,10 +19,28 @@ function makeTempDir() {
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writePlugin(params: { id: string; body: string }): TempPlugin {
|
||||
const dir = makeTempDir();
|
||||
const file = path.join(dir, `${params.id}.js`);
|
||||
function writePlugin(params: {
|
||||
id: string;
|
||||
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(
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -44,12 +62,12 @@ afterEach(() => {
|
||||
describe("loadClawdbotPlugins", () => {
|
||||
it("disables bundled plugins by default", () => {
|
||||
const bundledDir = makeTempDir();
|
||||
const bundledPath = path.join(bundledDir, "bundled.ts");
|
||||
fs.writeFileSync(
|
||||
bundledPath,
|
||||
`export default { id: "bundled", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
|
||||
"utf-8",
|
||||
);
|
||||
writePlugin({
|
||||
id: "bundled",
|
||||
body: `export default { id: "bundled", register() {} };`,
|
||||
dir: bundledDir,
|
||||
filename: "bundled.ts",
|
||||
});
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
|
||||
|
||||
const registry = loadClawdbotPlugins({
|
||||
@@ -102,12 +120,12 @@ describe("loadClawdbotPlugins", () => {
|
||||
|
||||
it("enables bundled memory plugin when selected by slot", () => {
|
||||
const bundledDir = makeTempDir();
|
||||
const bundledPath = path.join(bundledDir, "memory-core.ts");
|
||||
fs.writeFileSync(
|
||||
bundledPath,
|
||||
`export default { id: "memory-core", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
|
||||
"utf-8",
|
||||
);
|
||||
writePlugin({
|
||||
id: "memory-core",
|
||||
body: `export default { id: "memory-core", kind: "memory", register() {} };`,
|
||||
dir: bundledDir,
|
||||
filename: "memory-core.ts",
|
||||
});
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
|
||||
|
||||
const registry = loadClawdbotPlugins({
|
||||
@@ -140,11 +158,12 @@ describe("loadClawdbotPlugins", () => {
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "index.ts"),
|
||||
`export default { id: "memory-core", kind: "memory", name: "Memory (Core)", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
|
||||
"utf-8",
|
||||
);
|
||||
writePlugin({
|
||||
id: "memory-core",
|
||||
body: `export default { id: "memory-core", kind: "memory", name: "Memory (Core)", register() {} };`,
|
||||
dir: pluginDir,
|
||||
filename: "index.ts",
|
||||
});
|
||||
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
|
||||
|
||||
@@ -169,7 +188,7 @@ describe("loadClawdbotPlugins", () => {
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||
const plugin = writePlugin({
|
||||
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({
|
||||
@@ -192,7 +211,7 @@ describe("loadClawdbotPlugins", () => {
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||
const plugin = writePlugin({
|
||||
id: "blocked",
|
||||
body: `export default { id: "blocked", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
|
||||
body: `export default { id: "blocked", register() {} };`,
|
||||
});
|
||||
|
||||
const registry = loadClawdbotPlugins({
|
||||
@@ -215,7 +234,7 @@ describe("loadClawdbotPlugins", () => {
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||
const plugin = writePlugin({
|
||||
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({
|
||||
@@ -242,7 +261,7 @@ describe("loadClawdbotPlugins", () => {
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||
const plugin = writePlugin({
|
||||
id: "channel-demo",
|
||||
body: `export default { id: "channel-demo", ${EMPTY_CONFIG_SCHEMA} register(api) {
|
||||
body: `export default { id: "channel-demo", register(api) {
|
||||
api.registerChannel({
|
||||
plugin: {
|
||||
id: "demo",
|
||||
@@ -283,7 +302,7 @@ describe("loadClawdbotPlugins", () => {
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||
const plugin = writePlugin({
|
||||
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);
|
||||
} };`,
|
||||
});
|
||||
@@ -309,7 +328,7 @@ describe("loadClawdbotPlugins", () => {
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||
const plugin = writePlugin({
|
||||
id: "config-disable",
|
||||
body: `export default { id: "config-disable", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
|
||||
body: `export default { id: "config-disable", register() {} };`,
|
||||
});
|
||||
|
||||
const registry = loadClawdbotPlugins({
|
||||
@@ -332,11 +351,11 @@ describe("loadClawdbotPlugins", () => {
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||
const memoryA = writePlugin({
|
||||
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({
|
||||
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({
|
||||
@@ -359,7 +378,7 @@ describe("loadClawdbotPlugins", () => {
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||
const memory = writePlugin({
|
||||
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({
|
||||
@@ -378,16 +397,17 @@ describe("loadClawdbotPlugins", () => {
|
||||
|
||||
it("prefers higher-precedence plugins with the same id", () => {
|
||||
const bundledDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(bundledDir, "shadow.js"),
|
||||
`export default { id: "shadow", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
|
||||
"utf-8",
|
||||
);
|
||||
writePlugin({
|
||||
id: "shadow",
|
||||
body: `export default { id: "shadow", register() {} };`,
|
||||
dir: bundledDir,
|
||||
filename: "shadow.js",
|
||||
});
|
||||
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
|
||||
|
||||
const override = writePlugin({
|
||||
id: "shadow",
|
||||
body: `export default { id: "shadow", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
|
||||
body: `export default { id: "shadow", register() {} };`,
|
||||
});
|
||||
|
||||
const registry = loadClawdbotPlugins({
|
||||
|
||||
+83
-237
@@ -8,16 +8,21 @@ import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { resolveUserPath } from "../utils.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 { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
||||
import { createPluginRuntime } from "./runtime/index.js";
|
||||
import { setActivePluginRegistry } from "./runtime.js";
|
||||
import { defaultSlotIdForKey } from "./slots.js";
|
||||
import { validateJsonSchemaValue } from "./schema-validator.js";
|
||||
import type {
|
||||
ClawdbotPluginConfigSchema,
|
||||
ClawdbotPluginDefinition,
|
||||
ClawdbotPluginModule,
|
||||
PluginConfigUiHint,
|
||||
PluginDiagnostic,
|
||||
PluginLogger,
|
||||
} from "./types.js";
|
||||
@@ -33,73 +38,10 @@ export type PluginLoadOptions = {
|
||||
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 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 => {
|
||||
try {
|
||||
const modulePath = fileURLToPath(import.meta.url);
|
||||
@@ -133,105 +75,25 @@ function buildCacheKey(params: {
|
||||
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: {
|
||||
schema?: ClawdbotPluginConfigSchema;
|
||||
value?: Record<string, unknown>;
|
||||
schema?: Record<string, unknown>;
|
||||
cacheKey?: string;
|
||||
value?: unknown;
|
||||
}): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } {
|
||||
const schema = params.schema;
|
||||
if (!schema) return { ok: true, value: params.value };
|
||||
|
||||
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 };
|
||||
if (!schema) {
|
||||
return { ok: true, value: params.value as Record<string, unknown> | undefined };
|
||||
}
|
||||
|
||||
if (typeof schema.safeParse === "function") {
|
||||
const result = schema.safeParse(params.value);
|
||||
if (result.success) {
|
||||
return { ok: true, value: result.data as Record<string, unknown> };
|
||||
}
|
||||
const issues = result.error?.issues ?? [];
|
||||
const errors = issues.map((issue) => {
|
||||
const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
|
||||
return `${path}: ${issue.message}`;
|
||||
});
|
||||
return { ok: false, errors };
|
||||
const cacheKey = params.cacheKey ?? JSON.stringify(schema);
|
||||
const result = validateJsonSchemaValue({
|
||||
schema,
|
||||
cacheKey,
|
||||
value: params.value ?? {},
|
||||
});
|
||||
if (result.ok) {
|
||||
return { ok: true, value: params.value as Record<string, unknown> | undefined };
|
||||
}
|
||||
|
||||
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 };
|
||||
return { ok: false, errors: result.errors };
|
||||
}
|
||||
|
||||
function resolvePluginModuleExport(moduleExport: unknown): {
|
||||
@@ -326,7 +188,14 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
workspaceDir: options.workspaceDir,
|
||||
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 jiti = createJiti(import.meta.url, {
|
||||
@@ -335,10 +204,8 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
...(pluginSdkAlias ? { alias: { "clawdbot/plugin-sdk": pluginSdkAlias } } : {}),
|
||||
});
|
||||
|
||||
const bundledIds = new Set(
|
||||
discovery.candidates
|
||||
.filter((candidate) => candidate.origin === "bundled")
|
||||
.map((candidate) => candidate.idHint),
|
||||
const manifestByRoot = new Map(
|
||||
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
|
||||
);
|
||||
|
||||
const seenIds = new Map<string, PluginRecord["origin"]>();
|
||||
@@ -347,18 +214,23 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
let memorySlotMatched = false;
|
||||
|
||||
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) {
|
||||
const record = createPluginRecord({
|
||||
id: candidate.idHint,
|
||||
name: candidate.packageName ?? candidate.idHint,
|
||||
description: candidate.packageDescription,
|
||||
version: candidate.packageVersion,
|
||||
id: pluginId,
|
||||
name: manifestRecord.name ?? pluginId,
|
||||
description: manifestRecord.description,
|
||||
version: manifestRecord.version,
|
||||
source: candidate.source,
|
||||
origin: candidate.origin,
|
||||
workspaceDir: candidate.workspaceDir,
|
||||
enabled: false,
|
||||
configSchema: false,
|
||||
configSchema: Boolean(manifestRecord.configSchema),
|
||||
});
|
||||
record.status = "disabled";
|
||||
record.error = `overridden by ${existingOrigin} plugin`;
|
||||
@@ -366,25 +238,42 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
continue;
|
||||
}
|
||||
|
||||
const enableState = resolveEnableState(candidate.idHint, candidate.origin, normalized);
|
||||
const entry = normalized.entries[candidate.idHint];
|
||||
const enableState = resolveEnableState(pluginId, candidate.origin, normalized);
|
||||
const entry = normalized.entries[pluginId];
|
||||
const record = createPluginRecord({
|
||||
id: candidate.idHint,
|
||||
name: candidate.packageName ?? candidate.idHint,
|
||||
description: candidate.packageDescription,
|
||||
version: candidate.packageVersion,
|
||||
id: pluginId,
|
||||
name: manifestRecord.name ?? pluginId,
|
||||
description: manifestRecord.description,
|
||||
version: manifestRecord.version,
|
||||
source: candidate.source,
|
||||
origin: candidate.origin,
|
||||
workspaceDir: candidate.workspaceDir,
|
||||
enabled: enableState.enabled,
|
||||
configSchema: false,
|
||||
configSchema: Boolean(manifestRecord.configSchema),
|
||||
});
|
||||
record.kind = manifestRecord.kind;
|
||||
record.configUiHints = manifestRecord.configUiHints;
|
||||
record.configJsonSchema = manifestRecord.configSchema;
|
||||
|
||||
if (!enableState.enabled) {
|
||||
record.status = "disabled";
|
||||
record.error = enableState.reason;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -396,7 +285,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
record.status = "error";
|
||||
record.error = String(err);
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
registry.diagnostics.push({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
@@ -422,61 +311,17 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
record.name = definition?.name ?? record.name;
|
||||
record.description = definition?.description ?? record.description;
|
||||
record.version = definition?.version ?? record.version;
|
||||
record.kind = definition?.kind;
|
||||
record.configSchema = Boolean(definition?.configSchema);
|
||||
record.configUiHints =
|
||||
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);
|
||||
const manifestKind = record.kind as string | undefined;
|
||||
const exportKind = definition?.kind as string | undefined;
|
||||
if (manifestKind && exportKind && exportKind !== manifestKind) {
|
||||
registry.diagnostics.push({
|
||||
level: "error",
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
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) {
|
||||
memorySlotMatched = true;
|
||||
@@ -494,7 +339,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
record.status = "disabled";
|
||||
record.error = memoryDecision.reason;
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -503,7 +348,8 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
}
|
||||
|
||||
const validatedConfig = validatePluginConfig({
|
||||
schema: definition?.configSchema,
|
||||
schema: manifestRecord.configSchema,
|
||||
cacheKey: manifestRecord.schemaCacheKey,
|
||||
value: entry?.config,
|
||||
});
|
||||
|
||||
@@ -512,7 +358,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
record.status = "error";
|
||||
record.error = `invalid config: ${validatedConfig.errors?.join(", ")}`;
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
registry.diagnostics.push({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
@@ -524,7 +370,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
|
||||
if (validateOnly) {
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -533,7 +379,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
record.status = "error";
|
||||
record.error = "plugin export missing register/activate";
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
registry.diagnostics.push({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
@@ -559,7 +405,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
});
|
||||
}
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[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.error = String(err);
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
registry.diagnostics.push({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -52,6 +52,7 @@ import { probeDiscord } from "../../discord/probe.js";
|
||||
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
|
||||
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
|
||||
import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js";
|
||||
import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { monitorIMessageProvider } from "../../imessage/monitor.js";
|
||||
import { probeIMessage } from "../../imessage/probe.js";
|
||||
@@ -177,6 +178,10 @@ export function createPluginRuntime(): PluginRuntime {
|
||||
fetchRemoteMedia,
|
||||
saveMediaBuffer,
|
||||
},
|
||||
activity: {
|
||||
record: recordChannelActivity,
|
||||
get: getChannelActivity,
|
||||
},
|
||||
session: {
|
||||
resolveStorePath,
|
||||
readSessionUpdatedAt,
|
||||
|
||||
@@ -55,6 +55,8 @@ type ReadSessionUpdatedAt = typeof import("../../config/sessions.js").readSessio
|
||||
type UpdateLastRoute = typeof import("../../config/sessions.js").updateLastRoute;
|
||||
type LoadConfig = typeof import("../../config/config.js").loadConfig;
|
||||
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 RunCommandWithTimeout = typeof import("../../process/exec.js").runCommandWithTimeout;
|
||||
type LoadWebMedia = typeof import("../../web/media.js").loadWebMedia;
|
||||
@@ -188,6 +190,10 @@ export type PluginRuntime = {
|
||||
fetchRemoteMedia: FetchRemoteMedia;
|
||||
saveMediaBuffer: SaveMediaBuffer;
|
||||
};
|
||||
activity: {
|
||||
record: RecordChannelActivity;
|
||||
get: GetChannelActivity;
|
||||
};
|
||||
session: {
|
||||
resolveStorePath: ResolveStorePath;
|
||||
readSessionUpdatedAt: ReadSessionUpdatedAt;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { resolvePluginTools } from "./tools.js";
|
||||
type TempPlugin = { dir: string; file: string; id: string };
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
|
||||
|
||||
function makeTempDir() {
|
||||
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 file = path.join(dir, `${params.id}.js`);
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -36,10 +49,8 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("resolvePluginTools optional tools", () => {
|
||||
const emptyConfigSchema =
|
||||
'configSchema: { safeParse() { return { success: true, data: {} }; }, jsonSchema: { type: "object", additionalProperties: false, properties: {} } },';
|
||||
const pluginBody = `
|
||||
export default { ${emptyConfigSchema} register(api) {
|
||||
export default { register(api) {
|
||||
api.registerTool(
|
||||
{
|
||||
name: "optional_tool",
|
||||
@@ -140,7 +151,7 @@ export default { ${emptyConfigSchema} register(api) {
|
||||
const plugin = writePlugin({
|
||||
id: "multi",
|
||||
body: `
|
||||
export default { ${emptyConfigSchema} register(api) {
|
||||
export default { register(api) {
|
||||
api.registerTool({
|
||||
name: "message",
|
||||
description: "conflict",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -203,8 +203,6 @@ function renderRow(
|
||||
placeholder="(optional)"
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
const current = (row.label ?? "").trim();
|
||||
if (value === current) return;
|
||||
onPatch(row.key, { label: value || null });
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user