Merge branch 'clawdbot:main' into main
This commit is contained in:
commit
6d0cdac387
@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||||
|
- Agents: make inbound message envelopes configurable (timezone/timestamp/elapsed) and surface elapsed gaps. (#1150) — thanks @shiv19.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
||||||
|
|||||||
42
README.md
42
README.md
@ -474,25 +474,25 @@ Core contributors:
|
|||||||
Thanks to all clawtributors:
|
Thanks to all clawtributors:
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a>
|
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
||||||
<a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a>
|
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
|
||||||
<a href="https://github.com/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/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></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/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/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/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></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/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/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/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/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/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/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/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/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/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/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/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/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/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/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/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/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/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/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/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/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/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/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=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/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=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=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
<a href="https://github.com/search?q=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/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -9,7 +9,7 @@ read_when:
|
|||||||
|
|
||||||
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
||||||
|
|
||||||
## Message envelopes (UTC)
|
## Message envelopes (UTC by default)
|
||||||
|
|
||||||
Inbound messages are wrapped in an envelope like:
|
Inbound messages are wrapped in an envelope like:
|
||||||
|
|
||||||
@ -17,7 +17,46 @@ Inbound messages are wrapped in an envelope like:
|
|||||||
[Provider ... 2026-01-05T21:26Z] message text
|
[Provider ... 2026-01-05T21:26Z] message text
|
||||||
```
|
```
|
||||||
|
|
||||||
The timestamp in the envelope is **always UTC**, with minutes precision.
|
The timestamp in the envelope is **UTC by default**, with minutes precision.
|
||||||
|
|
||||||
|
You can override this with:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone
|
||||||
|
envelopeTimestamp: "on", // "on" | "off"
|
||||||
|
envelopeElapsed: "on" // "on" | "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||||
|
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
|
||||||
|
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||||
|
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**UTC (default):**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Signal Alice +1555 2026-01-18T05:19Z] hello
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fixed timezone:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Signal Alice +1555 2026-01-18 06:19 GMT+1] hello
|
||||||
|
```
|
||||||
|
|
||||||
|
**Elapsed time:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Signal Alice +1555 +2m 2026-01-18T05:19Z] follow-up
|
||||||
|
```
|
||||||
|
|
||||||
## Tool payloads (raw provider data + normalized fields)
|
## Tool payloads (raw provider data + normalized fields)
|
||||||
|
|
||||||
|
|||||||
@ -7,10 +7,10 @@ read_when:
|
|||||||
|
|
||||||
# Date & Time
|
# Date & Time
|
||||||
|
|
||||||
Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
||||||
We avoid rewriting provider timestamps so tools keep their native semantics.
|
Provider timestamps are preserved so tools keep their native semantics.
|
||||||
|
|
||||||
## Message envelopes (UTC)
|
## Message envelopes (UTC by default)
|
||||||
|
|
||||||
Inbound messages are wrapped with a UTC timestamp (minute precision):
|
Inbound messages are wrapped with a UTC timestamp (minute precision):
|
||||||
|
|
||||||
@ -18,7 +18,47 @@ Inbound messages are wrapped with a UTC timestamp (minute precision):
|
|||||||
[Provider ... 2026-01-05T21:26Z] message text
|
[Provider ... 2026-01-05T21:26Z] message text
|
||||||
```
|
```
|
||||||
|
|
||||||
This envelope timestamp is **always UTC**, regardless of the host timezone.
|
This envelope timestamp is **UTC by default**, regardless of the host timezone.
|
||||||
|
|
||||||
|
You can override this behavior:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone
|
||||||
|
envelopeTimestamp: "on", // "on" | "off"
|
||||||
|
envelopeElapsed: "on" // "on" | "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `envelopeTimezone: "local"` uses the host timezone.
|
||||||
|
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||||
|
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
|
||||||
|
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||||
|
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**UTC (default):**
|
||||||
|
|
||||||
|
```
|
||||||
|
[WhatsApp +1555 2026-01-18T05:19Z] hello
|
||||||
|
```
|
||||||
|
|
||||||
|
**User timezone:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[WhatsApp +1555 2026-01-18 00:19 CST] hello
|
||||||
|
```
|
||||||
|
|
||||||
|
**Elapsed time enabled:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[WhatsApp +1555 +30s 2026-01-18T05:19Z] follow-up
|
||||||
|
```
|
||||||
|
|
||||||
## System prompt: Current Date & Time
|
## System prompt: Current Date & Time
|
||||||
|
|
||||||
|
|||||||
65
docs/debug/node-issue.md
Normal file
65
docs/debug/node-issue.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# Node + tsx "__name is not a function" crash
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Running Clawdbot via Node with `tsx` fails at startup with:
|
||||||
|
|
||||||
|
```
|
||||||
|
[clawdbot] Failed to start CLI: TypeError: __name is not a function
|
||||||
|
at createSubsystemLogger (.../src/logging/subsystem.ts:203:25)
|
||||||
|
at .../src/agents/auth-profiles/constants.ts:25:20
|
||||||
|
```
|
||||||
|
|
||||||
|
This began after switching dev scripts from Bun to `tsx` (commit `2871657e`, 2026-01-06). The same runtime path worked with Bun.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
- Node: v25.x (observed on v25.3.0)
|
||||||
|
- tsx: 4.21.0
|
||||||
|
- OS: macOS (repro also likely on other platforms that run Node 25)
|
||||||
|
|
||||||
|
## Repro (Node-only)
|
||||||
|
```bash
|
||||||
|
# in repo root
|
||||||
|
node --version
|
||||||
|
pnpm install
|
||||||
|
node --import tsx src/entry.ts status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal repro in repo
|
||||||
|
```bash
|
||||||
|
node --import tsx scripts/repro/tsx-name-repro.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Node version check
|
||||||
|
- Node 25.3.0: fails
|
||||||
|
- Node 22.22.0 (Homebrew `node@22`): fails
|
||||||
|
- Node 24: not installed here yet; needs verification
|
||||||
|
|
||||||
|
## Notes / hypothesis
|
||||||
|
- `tsx` uses esbuild to transform TS/ESM. esbuild’s `keepNames` emits a `__name` helper and wraps function definitions with `__name(...)`.
|
||||||
|
- The crash indicates `__name` exists but is not a function at runtime, which implies the helper is missing or overwritten for this module in the Node 25 loader path.
|
||||||
|
- Similar `__name` helper issues have been reported in other esbuild consumers when the helper is missing or rewritten.
|
||||||
|
|
||||||
|
## Regression history
|
||||||
|
- `2871657e` (2026-01-06): scripts changed from Bun to tsx to make Bun optional.
|
||||||
|
- Before that (Bun path), `pnpm clawdbot status` and `gateway:watch` worked.
|
||||||
|
|
||||||
|
## Workarounds
|
||||||
|
- Use Bun for dev scripts (current temporary revert).
|
||||||
|
- Use Node + tsc watch, then run compiled output:
|
||||||
|
```bash
|
||||||
|
pnpm exec tsc --watch --preserveWatchOutput
|
||||||
|
node --watch dist/entry.js status
|
||||||
|
```
|
||||||
|
- Confirmed locally: `pnpm exec tsc -p tsconfig.json` + `node dist/entry.js status` works on Node 25.
|
||||||
|
- Disable esbuild keepNames in the TS loader if possible (prevents `__name` helper insertion); tsx does not currently expose this.
|
||||||
|
- Test Node LTS (22/24) with `tsx` to see if the issue is Node 25–specific.
|
||||||
|
|
||||||
|
## References
|
||||||
|
- https://opennext.js.org/cloudflare/howtos/keep_names
|
||||||
|
- https://esbuild.github.io/api/#keep-names
|
||||||
|
- https://github.com/evanw/esbuild/issues/1031
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
- Repro on Node 22/24 to confirm Node 25 regression.
|
||||||
|
- Test `tsx` nightly or pin to earlier version if a known regression exists.
|
||||||
|
- If reproduces on Node LTS, file a minimal repro upstream with the `__name` stack trace.
|
||||||
@ -836,10 +836,20 @@ async function processMessage(
|
|||||||
const fromLabel = message.isGroup
|
const fromLabel = message.isGroup
|
||||||
? `group:${peerId}`
|
? `group:${peerId}`
|
||||||
: message.senderName || `user:${message.senderId}`;
|
: message.senderName || `user:${message.senderId}`;
|
||||||
|
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||||
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "BlueBubbles",
|
channel: "BlueBubbles",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
body: rawBody,
|
body: rawBody,
|
||||||
});
|
});
|
||||||
let chatGuidForActions = chatGuid;
|
let chatGuidForActions = chatGuid;
|
||||||
|
|||||||
@ -466,25 +466,34 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
isThreadRoot: event.isThreadRoot,
|
isThreadRoot: event.isThreadRoot,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
|
cfg,
|
||||||
|
channel: "matrix",
|
||||||
|
peer: {
|
||||||
|
kind: isDirectMessage ? "dm" : "channel",
|
||||||
|
id: isDirectMessage ? senderId : roomId,
|
||||||
|
},
|
||||||
|
});
|
||||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||||
|
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||||
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Matrix",
|
channel: "Matrix",
|
||||||
from: envelopeFrom,
|
from: envelopeFrom,
|
||||||
timestamp: event.getTs() ?? undefined,
|
timestamp: event.getTs() ?? undefined,
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
body: textWithId,
|
body: textWithId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const route = core.channel.routing.resolveAgentRoute({
|
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
||||||
cfg,
|
|
||||||
channel: "matrix",
|
|
||||||
peer: {
|
|
||||||
kind: isDirectMessage ? "dm" : "channel",
|
|
||||||
id: isDirectMessage ? senderId : roomId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
Body: body,
|
Body: body,
|
||||||
RawBody: bodyText,
|
RawBody: bodyText,
|
||||||
@ -517,13 +526,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
OriginatingTo: `room:${roomId}`,
|
OriginatingTo: `room:${roomId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
void core.channel.session.recordSessionMetaFromInbound({
|
||||||
agentId: route.agentId,
|
storePath,
|
||||||
});
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
void core.channel.session.recordSessionMetaFromInbound({
|
ctx: ctxPayload,
|
||||||
storePath,
|
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
||||||
ctx: ctxPayload,
|
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
|
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
|
||||||
|
|||||||
@ -406,10 +406,20 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
|
|
||||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||||
|
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||||
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Teams",
|
channel: "Teams",
|
||||||
from: envelopeFrom,
|
from: envelopeFrom,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
body: rawBody,
|
body: rawBody,
|
||||||
});
|
});
|
||||||
let combinedBody = body;
|
let combinedBody = body;
|
||||||
@ -421,15 +431,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
historyKey,
|
historyKey,
|
||||||
limit: historyLimit,
|
limit: historyLimit,
|
||||||
currentMessage: combinedBody,
|
currentMessage: combinedBody,
|
||||||
formatEntry: (entry) =>
|
formatEntry: (entry) =>
|
||||||
core.channel.reply.formatAgentEnvelope({
|
core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Teams",
|
channel: "Teams",
|
||||||
from: conversationType,
|
from: conversationType,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
||||||
}),
|
envelope: envelopeOptions,
|
||||||
});
|
}),
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
Body: combinedBody,
|
Body: combinedBody,
|
||||||
@ -455,11 +466,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
...mediaPayload,
|
...mediaPayload,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
void core.channel.session.recordSessionMetaFromInbound({
|
||||||
agentId: route.agentId,
|
storePath,
|
||||||
});
|
|
||||||
void core.channel.session.recordSessionMetaFromInbound({
|
|
||||||
storePath,
|
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
|
|||||||
@ -530,10 +530,20 @@ async function processMessageWithPipeline(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||||
|
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||||
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Zalo",
|
channel: "Zalo",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: date ? date * 1000 : undefined,
|
timestamp: date ? date * 1000 : undefined,
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
body: rawBody,
|
body: rawBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -560,9 +570,6 @@ async function processMessageWithPipeline(params: {
|
|||||||
OriginatingTo: `zalo:${chatId}`,
|
OriginatingTo: `zalo:${chatId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
});
|
|
||||||
void core.channel.session.recordSessionMetaFromInbound({
|
void core.channel.session.recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
|
|||||||
@ -274,10 +274,20 @@ async function processMessage(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||||
|
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||||
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
channel: "Zalo Personal",
|
channel: "Zalo Personal",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: timestamp ? timestamp * 1000 : undefined,
|
timestamp: timestamp ? timestamp * 1000 : undefined,
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
body: rawBody,
|
body: rawBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -301,9 +311,6 @@ async function processMessage(
|
|||||||
OriginatingTo: `zalouser:${chatId}`,
|
OriginatingTo: `zalouser:${chatId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
});
|
|
||||||
void core.channel.session.recordSessionMetaFromInbound({
|
void core.channel.session.recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
|
|||||||
36
package.json
36
package.json
@ -65,27 +65,27 @@
|
|||||||
"dist/whatsapp/**"
|
"dist/whatsapp/**"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun src/entry.ts",
|
"dev": "node scripts/run-node.mjs",
|
||||||
"postinstall": "node scripts/postinstall.js",
|
"postinstall": "node scripts/postinstall.js",
|
||||||
"prepack": "pnpm build",
|
"prepack": "pnpm build",
|
||||||
"docs:list": "bun scripts/docs-list.ts",
|
"docs:list": "node scripts/docs-list.js",
|
||||||
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list",
|
"docs:bin": "node scripts/build-docs-list.mjs",
|
||||||
"docs:dev": "cd docs && mint dev",
|
"docs:dev": "cd docs && mint dev",
|
||||||
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
||||||
"build": "tsc -p tsconfig.json && bun scripts/canvas-a2ui-copy.ts && bun scripts/copy-hook-metadata.ts && bun scripts/write-build-info.ts",
|
"build": "tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts",
|
||||||
"plugins:sync": "bun scripts/sync-plugin-versions.ts",
|
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
|
||||||
"release:check": "bun scripts/release-check.ts",
|
"release:check": "node --import tsx scripts/release-check.ts",
|
||||||
"ui:install": "node scripts/ui.js install",
|
"ui:install": "node scripts/ui.js install",
|
||||||
"ui:dev": "node scripts/ui.js dev",
|
"ui:dev": "node scripts/ui.js dev",
|
||||||
"ui:build": "node scripts/ui.js build",
|
"ui:build": "node scripts/ui.js build",
|
||||||
"start": "bun src/entry.ts",
|
"start": "node scripts/run-node.mjs",
|
||||||
"clawdbot": "bun src/entry.ts",
|
"clawdbot": "node scripts/run-node.mjs",
|
||||||
"gateway:watch": "bun --watch src/entry.ts gateway --force",
|
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
|
||||||
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway",
|
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway",
|
||||||
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway --reset",
|
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset",
|
||||||
"tui": "bun src/entry.ts tui",
|
"tui": "node scripts/run-node.mjs tui",
|
||||||
"tui:dev": "CLAWDBOT_PROFILE=dev bun src/entry.ts tui",
|
"tui:dev": "CLAWDBOT_PROFILE=dev node scripts/run-node.mjs tui",
|
||||||
"clawdbot:rpc": "bun src/entry.ts agent --mode rpc --json",
|
"clawdbot:rpc": "node scripts/run-node.mjs agent --mode rpc --json",
|
||||||
"ios:gen": "cd apps/ios && xcodegen generate",
|
"ios:gen": "cd apps/ios && xcodegen generate",
|
||||||
"ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj",
|
"ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj",
|
||||||
"ios:build": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Clawdbot.xcodeproj -scheme Clawdbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
|
"ios:build": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Clawdbot.xcodeproj -scheme Clawdbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
|
||||||
@ -108,7 +108,7 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:ui": "pnpm --dir ui test",
|
"test:ui": "pnpm --dir ui test",
|
||||||
"test:force": "bun scripts/test-force.ts",
|
"test:force": "node --import tsx scripts/test-force.ts",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||||
"test:live": "CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts",
|
"test:live": "CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts",
|
||||||
@ -126,11 +126,11 @@
|
|||||||
"test:install:smoke": "bash scripts/test-install-sh-docker.sh",
|
"test:install:smoke": "bash scripts/test-install-sh-docker.sh",
|
||||||
"test:install:e2e:openai": "CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh",
|
"test:install:e2e:openai": "CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh",
|
||||||
"test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
|
"test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
|
||||||
"protocol:gen": "bun scripts/protocol-gen.ts",
|
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
|
||||||
"protocol:gen:swift": "bun scripts/protocol-gen-swift.ts",
|
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
|
||||||
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
|
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
|
||||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||||
"check:loc": "tsx scripts/check-ts-max-loc.ts --max 500"
|
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
15
scripts/build-docs-list.mjs
Normal file
15
scripts/build-docs-list.mjs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const binDir = path.join(root, "bin");
|
||||||
|
const scriptPath = path.join(root, "scripts", "docs-list.js");
|
||||||
|
const binPath = path.join(binDir, "docs-list");
|
||||||
|
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
|
||||||
|
const wrapper = `#!/usr/bin/env node\nimport { spawnSync } from "node:child_process";\nimport path from "node:path";\nimport { fileURLToPath } from "node:url";\n\nconst here = path.dirname(fileURLToPath(import.meta.url));\nconst script = path.join(here, "..", "scripts", "docs-list.js");\n\nconst result = spawnSync(process.execPath, [script], { stdio: "inherit" });\nprocess.exit(result.status ?? 1);\n`;
|
||||||
|
|
||||||
|
fs.writeFileSync(binPath, wrapper, { mode: 0o755 });
|
||||||
@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { readdirSync, readFileSync } from 'node:fs';
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
import { join, relative } from 'node:path';
|
import { join, relative } from 'node:path';
|
||||||
|
|
||||||
process.stdout.on('error', (error) => {
|
process.stdout.on('error', (error) => {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'EPIPE') {
|
if (error?.code === 'EPIPE') {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env -S node --import tsx
|
||||||
|
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { readdirSync, readFileSync } from "node:fs";
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
|||||||
3
scripts/repro/tsx-name-repro.ts
Normal file
3
scripts/repro/tsx-name-repro.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import "../../src/logging/subsystem.js";
|
||||||
|
|
||||||
|
console.log("tsx-name-repro: loaded logging/subsystem");
|
||||||
38
scripts/run-node.mjs
Normal file
38
scripts/run-node.mjs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const env = { ...process.env };
|
||||||
|
const cwd = process.cwd();
|
||||||
|
|
||||||
|
const build = spawn("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
build.on("exit", (code, signal) => {
|
||||||
|
if (signal) {
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code !== 0 && code !== null) {
|
||||||
|
process.exit(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeProcess = spawn(process.execPath, ["dist/entry.js", ...args], {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeProcess.on("exit", (exitCode, exitSignal) => {
|
||||||
|
if (exitSignal) {
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.exit(exitCode ?? 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env -S node --import tsx
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
|||||||
@ -45,19 +45,8 @@ function which(cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveRunner() {
|
function resolveRunner() {
|
||||||
// CLAWDBOT_PREFER_PNPM=1 forces pnpm (useful in Docker on architectures where Bun fails)
|
|
||||||
const preferPnpm = process.env.CLAWDBOT_PREFER_PNPM === "1";
|
|
||||||
if (!preferPnpm) {
|
|
||||||
const bun = which("bun");
|
|
||||||
if (bun) return { cmd: bun, kind: "bun" };
|
|
||||||
}
|
|
||||||
const pnpm = which("pnpm");
|
const pnpm = which("pnpm");
|
||||||
if (pnpm) return { cmd: pnpm, kind: "pnpm" };
|
if (pnpm) return { cmd: pnpm, kind: "pnpm" };
|
||||||
if (preferPnpm) {
|
|
||||||
// Fallback to bun if pnpm not found even when preferring pnpm
|
|
||||||
const bun = which("bun");
|
|
||||||
if (bun) return { cmd: bun, kind: "bun" };
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,9 +96,7 @@ if (!action) {
|
|||||||
|
|
||||||
const runner = resolveRunner();
|
const runner = resolveRunner();
|
||||||
if (!runner) {
|
if (!runner) {
|
||||||
process.stderr.write(
|
process.stderr.write("Missing UI runner: install pnpm, then retry.\n");
|
||||||
"Missing UI runner: install bun or pnpm, then retry.\n",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,32 +116,16 @@ if (action !== "install" && !script) {
|
|||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runner.kind === "bun") {
|
if (action === "install") run(runner.cmd, ["install", ...rest]);
|
||||||
if (action === "install") run(runner.cmd, ["install", ...rest]);
|
else {
|
||||||
else {
|
if (!depsInstalled(action === "test" ? "test" : "build")) {
|
||||||
if (!depsInstalled(action === "test" ? "test" : "build")) {
|
const installEnv =
|
||||||
const installEnv =
|
action === "build"
|
||||||
action === "build"
|
? { ...process.env, NODE_ENV: "production" }
|
||||||
? { ...process.env, NODE_ENV: "production" }
|
: process.env;
|
||||||
: process.env;
|
const installArgs =
|
||||||
const installArgs =
|
action === "build" ? ["install", "--prod"] : ["install"];
|
||||||
action === "build" ? ["install", "--production"] : ["install"];
|
runSync(runner.cmd, installArgs, installEnv);
|
||||||
runSync(runner.cmd, installArgs, installEnv);
|
|
||||||
}
|
|
||||||
run(runner.cmd, ["run", script, ...rest]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (action === "install") run(runner.cmd, ["install", ...rest]);
|
|
||||||
else {
|
|
||||||
if (!depsInstalled(action === "test" ? "test" : "build")) {
|
|
||||||
const installEnv =
|
|
||||||
action === "build"
|
|
||||||
? { ...process.env, NODE_ENV: "production" }
|
|
||||||
: process.env;
|
|
||||||
const installArgs =
|
|
||||||
action === "build" ? ["install", "--prod"] : ["install"];
|
|
||||||
runSync(runner.cmd, installArgs, installEnv);
|
|
||||||
}
|
|
||||||
run(runner.cmd, ["run", script, ...rest]);
|
|
||||||
}
|
}
|
||||||
|
run(runner.cmd, ["run", script, ...rest]);
|
||||||
}
|
}
|
||||||
|
|||||||
52
scripts/watch-node.mjs
Normal file
52
scripts/watch-node.mjs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const env = { ...process.env };
|
||||||
|
const cwd = process.cwd();
|
||||||
|
|
||||||
|
const initialBuild = spawnSync("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (initialBuild.status !== 0) {
|
||||||
|
process.exit(initialBuild.status ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tsc = spawn("pnpm", ["exec", "tsc", "--watch", "--preserveWatchOutput"], {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeProcess = spawn(process.execPath, ["--watch", "dist/entry.js", ...args], {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
let exiting = false;
|
||||||
|
|
||||||
|
function cleanup(code = 0) {
|
||||||
|
if (exiting) return;
|
||||||
|
exiting = true;
|
||||||
|
nodeProcess.kill("SIGTERM");
|
||||||
|
tsc.kill("SIGTERM");
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", () => cleanup(130));
|
||||||
|
process.on("SIGTERM", () => cleanup(143));
|
||||||
|
|
||||||
|
tsc.on("exit", (code) => {
|
||||||
|
if (exiting) return;
|
||||||
|
cleanup(code ?? 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeProcess.on("exit", (code, signal) => {
|
||||||
|
if (signal || exiting) return;
|
||||||
|
cleanup(code ?? 1);
|
||||||
|
});
|
||||||
@ -5,7 +5,7 @@ import { isImageDimensionErrorMessage, parseImageDimensionError } from "./pi-emb
|
|||||||
describe("image dimension errors", () => {
|
describe("image dimension errors", () => {
|
||||||
it("parses anthropic image dimension errors", () => {
|
it("parses anthropic image dimension errors", () => {
|
||||||
const raw =
|
const raw =
|
||||||
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}";
|
'400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}';
|
||||||
const parsed = parseImageDimensionError(raw);
|
const parsed = parseImageDimensionError(raw);
|
||||||
expect(parsed).not.toBeNull();
|
expect(parsed).not.toBeNull();
|
||||||
expect(parsed?.maxDimensionPx).toBe(2000);
|
expect(parsed?.maxDimensionPx).toBe(2000);
|
||||||
|
|||||||
@ -25,7 +25,7 @@ describe("isCloudCodeAssistFormatError", () => {
|
|||||||
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
|
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
isCloudCodeAssistFormatError(
|
isCloudCodeAssistFormatError(
|
||||||
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}",
|
'400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}',
|
||||||
),
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -89,7 +89,6 @@ let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAg
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.resetModules();
|
|
||||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -91,7 +91,6 @@ let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAg
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.resetModules();
|
|
||||||
mockPiAi();
|
mockPiAi();
|
||||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|||||||
@ -41,7 +41,7 @@ describe("detectImageReferences", () => {
|
|||||||
expect(refs[0]?.raw).toBe("~/Pictures/vacation.png");
|
expect(refs[0]?.raw).toBe("~/Pictures/vacation.png");
|
||||||
expect(refs[0]?.type).toBe("path");
|
expect(refs[0]?.type).toBe("path");
|
||||||
// Resolved path should expand ~
|
// Resolved path should expand ~
|
||||||
expect(refs[0]?.resolved).not.toContain("~");
|
expect(refs[0]?.resolved?.startsWith("~")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects multiple image references in a prompt", () => {
|
it("detects multiple image references in a prompt", () => {
|
||||||
|
|||||||
@ -109,11 +109,7 @@ function buildMessagingSection(params: {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDocsSection(params: {
|
function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readToolName: string }) {
|
||||||
docsPath?: string;
|
|
||||||
isMinimal: boolean;
|
|
||||||
readToolName: string;
|
|
||||||
}) {
|
|
||||||
const docsPath = params.docsPath?.trim();
|
const docsPath = params.docsPath?.trim();
|
||||||
if (!docsPath || params.isMinimal) return [];
|
if (!docsPath || params.isMinimal) return [];
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -58,7 +58,12 @@ async function resizeImageBase64IfNeeded(params: {
|
|||||||
const height = meta?.height;
|
const height = meta?.height;
|
||||||
const overBytes = buf.byteLength > params.maxBytes;
|
const overBytes = buf.byteLength > params.maxBytes;
|
||||||
const hasDimensions = typeof width === "number" && typeof height === "number";
|
const hasDimensions = typeof width === "number" && typeof height === "number";
|
||||||
if (hasDimensions && !overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
|
if (
|
||||||
|
hasDimensions &&
|
||||||
|
!overBytes &&
|
||||||
|
width <= params.maxDimensionPx &&
|
||||||
|
height <= params.maxDimensionPx
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
base64: params.base64,
|
base64: params.base64,
|
||||||
mimeType: params.mimeType,
|
mimeType: params.mimeType,
|
||||||
@ -67,7 +72,10 @@ async function resizeImageBase64IfNeeded(params: {
|
|||||||
height,
|
height,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (hasDimensions && (width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)) {
|
if (
|
||||||
|
hasDimensions &&
|
||||||
|
(width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)
|
||||||
|
) {
|
||||||
log.warn("Image exceeds limits; resizing", {
|
log.warn("Image exceeds limits; resizing", {
|
||||||
label: params.label,
|
label: params.label,
|
||||||
width,
|
width,
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
import {
|
||||||
|
getChannelPlugin,
|
||||||
|
normalizeChannelId as normalizeAnyChannelId,
|
||||||
|
} from "../../channels/plugins/index.js";
|
||||||
|
import { normalizeChannelId as normalizeChatChannelId } from "../../channels/registry.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
|
||||||
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
|
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
|
||||||
@ -21,7 +25,8 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
|
|||||||
const id = rest.join(":").trim();
|
const id = rest.join(":").trim();
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
if (!channelRaw) return null;
|
if (!channelRaw) return null;
|
||||||
const normalizedChannel = normalizeChannelId(channelRaw);
|
const normalizedChannel =
|
||||||
|
normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw);
|
||||||
const channel = normalizedChannel ?? channelRaw.toLowerCase();
|
const channel = normalizedChannel ?? channelRaw.toLowerCase();
|
||||||
const kindTarget = (() => {
|
const kindTarget = (() => {
|
||||||
if (!normalizedChannel) return id;
|
if (!normalizedChannel) return id;
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { formatAgentEnvelope, formatInboundEnvelope } from "./envelope.js";
|
import {
|
||||||
|
formatAgentEnvelope,
|
||||||
|
formatInboundEnvelope,
|
||||||
|
resolveEnvelopeFormatOptions,
|
||||||
|
} from "./envelope.js";
|
||||||
|
|
||||||
describe("formatAgentEnvelope", () => {
|
describe("formatAgentEnvelope", () => {
|
||||||
it("includes channel, from, ip, host, and timestamp", () => {
|
it("includes channel, from, ip, host, and timestamp", () => {
|
||||||
@ -38,6 +42,46 @@ describe("formatAgentEnvelope", () => {
|
|||||||
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
|
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("formats timestamps in local timezone when configured", () => {
|
||||||
|
const originalTz = process.env.TZ;
|
||||||
|
process.env.TZ = "America/Los_Angeles";
|
||||||
|
|
||||||
|
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z (19:04 PST)
|
||||||
|
const body = formatAgentEnvelope({
|
||||||
|
channel: "WebChat",
|
||||||
|
timestamp: ts,
|
||||||
|
envelope: { timezone: "local" },
|
||||||
|
body: "hello",
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env.TZ = originalTz;
|
||||||
|
|
||||||
|
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats timestamps in user timezone when configured", () => {
|
||||||
|
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z (04:04 CET)
|
||||||
|
const body = formatAgentEnvelope({
|
||||||
|
channel: "WebChat",
|
||||||
|
timestamp: ts,
|
||||||
|
envelope: { timezone: "user", userTimezone: "Europe/Vienna" },
|
||||||
|
body: "hello",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(body).toMatch(/\[WebChat 2025-01-02 04:04 [^\]]+\] hello/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits timestamps when configured", () => {
|
||||||
|
const ts = Date.UTC(2025, 0, 2, 3, 4);
|
||||||
|
const body = formatAgentEnvelope({
|
||||||
|
channel: "WebChat",
|
||||||
|
timestamp: ts,
|
||||||
|
envelope: { includeTimestamp: false },
|
||||||
|
body: "hello",
|
||||||
|
});
|
||||||
|
expect(body).toBe("[WebChat] hello");
|
||||||
|
});
|
||||||
|
|
||||||
it("handles missing optional fields", () => {
|
it("handles missing optional fields", () => {
|
||||||
const body = formatAgentEnvelope({ channel: "Telegram", body: "hi" });
|
const body = formatAgentEnvelope({ channel: "Telegram", body: "hi" });
|
||||||
expect(body).toBe("[Telegram] hi");
|
expect(body).toBe("[Telegram] hi");
|
||||||
@ -77,4 +121,53 @@ describe("formatInboundEnvelope", () => {
|
|||||||
});
|
});
|
||||||
expect(body).toBe("[iMessage +1555] hello");
|
expect(body).toBe("[iMessage +1555] hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes elapsed time when previousTimestamp is provided", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const twoMinutesAgo = now - 2 * 60 * 1000;
|
||||||
|
const body = formatInboundEnvelope({
|
||||||
|
channel: "Telegram",
|
||||||
|
from: "Alice",
|
||||||
|
body: "follow-up message",
|
||||||
|
timestamp: now,
|
||||||
|
previousTimestamp: twoMinutesAgo,
|
||||||
|
chatType: "direct",
|
||||||
|
envelope: { includeTimestamp: false },
|
||||||
|
});
|
||||||
|
expect(body).toContain("Alice +2m");
|
||||||
|
expect(body).toContain("follow-up message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits elapsed time when disabled", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const body = formatInboundEnvelope({
|
||||||
|
channel: "Telegram",
|
||||||
|
from: "Alice",
|
||||||
|
body: "follow-up message",
|
||||||
|
timestamp: now,
|
||||||
|
previousTimestamp: now - 2 * 60 * 1000,
|
||||||
|
chatType: "direct",
|
||||||
|
envelope: { includeElapsed: false, includeTimestamp: false },
|
||||||
|
});
|
||||||
|
expect(body).toBe("[Telegram Alice] follow-up message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves envelope options from config", () => {
|
||||||
|
const options = resolveEnvelopeFormatOptions({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
envelopeTimezone: "user",
|
||||||
|
envelopeTimestamp: "off",
|
||||||
|
envelopeElapsed: "off",
|
||||||
|
userTimezone: "Europe/Vienna",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(options).toEqual({
|
||||||
|
timezone: "user",
|
||||||
|
includeTimestamp: false,
|
||||||
|
includeElapsed: false,
|
||||||
|
userTimezone: "Europe/Vienna",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
import { resolveUserTimezone } from "../agents/date-time.js";
|
||||||
import { normalizeChatType } from "../channels/chat-type.js";
|
import { normalizeChatType } from "../channels/chat-type.js";
|
||||||
import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js";
|
import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
|
||||||
export type AgentEnvelopeParams = {
|
export type AgentEnvelopeParams = {
|
||||||
channel: string;
|
channel: string;
|
||||||
@ -8,31 +10,162 @@ export type AgentEnvelopeParams = {
|
|||||||
host?: string;
|
host?: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
previousTimestamp?: number | Date;
|
||||||
|
envelope?: EnvelopeFormatOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatTimestamp(ts?: number | Date): string | undefined {
|
export type EnvelopeFormatOptions = {
|
||||||
if (!ts) return undefined;
|
/**
|
||||||
const date = ts instanceof Date ? ts : new Date(ts);
|
* "utc" (default), "local", "user", or an explicit IANA timezone string.
|
||||||
if (Number.isNaN(date.getTime())) return undefined;
|
*/
|
||||||
|
timezone?: string;
|
||||||
|
/**
|
||||||
|
* Include absolute timestamps in the envelope (default: true).
|
||||||
|
*/
|
||||||
|
includeTimestamp?: boolean;
|
||||||
|
/**
|
||||||
|
* Include elapsed time suffix when previousTimestamp is provided (default: true).
|
||||||
|
*/
|
||||||
|
includeElapsed?: boolean;
|
||||||
|
/**
|
||||||
|
* Optional user timezone used when timezone="user".
|
||||||
|
*/
|
||||||
|
userTimezone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolvedEnvelopeTimezone =
|
||||||
|
| { mode: "utc" }
|
||||||
|
| { mode: "local" }
|
||||||
|
| { mode: "iana"; timeZone: string };
|
||||||
|
|
||||||
|
export function resolveEnvelopeFormatOptions(cfg?: ClawdbotConfig): EnvelopeFormatOptions {
|
||||||
|
const defaults = cfg?.agents?.defaults;
|
||||||
|
return {
|
||||||
|
timezone: defaults?.envelopeTimezone,
|
||||||
|
includeTimestamp: defaults?.envelopeTimestamp !== "off",
|
||||||
|
includeElapsed: defaults?.envelopeElapsed !== "off",
|
||||||
|
userTimezone: defaults?.userTimezone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): Required<EnvelopeFormatOptions> {
|
||||||
|
const includeTimestamp = options?.includeTimestamp !== false;
|
||||||
|
const includeElapsed = options?.includeElapsed !== false;
|
||||||
|
return {
|
||||||
|
timezone: options?.timezone?.trim() || "utc",
|
||||||
|
includeTimestamp,
|
||||||
|
includeElapsed,
|
||||||
|
userTimezone: options?.userTimezone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExplicitTimezone(value: string): string | undefined {
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
|
||||||
|
return value;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEnvelopeTimezone(options: EnvelopeFormatOptions): ResolvedEnvelopeTimezone {
|
||||||
|
const trimmed = options.timezone?.trim();
|
||||||
|
if (!trimmed) return { mode: "utc" };
|
||||||
|
const lowered = trimmed.toLowerCase();
|
||||||
|
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" };
|
||||||
|
if (lowered === "local" || lowered === "host") return { mode: "local" };
|
||||||
|
if (lowered === "user") {
|
||||||
|
return { mode: "iana", timeZone: resolveUserTimezone(options.userTimezone) };
|
||||||
|
}
|
||||||
|
const explicit = resolveExplicitTimezone(trimmed);
|
||||||
|
return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUtcTimestamp(date: Date): string {
|
||||||
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
||||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||||
const hh = String(date.getUTCHours()).padStart(2, "0");
|
const hh = String(date.getUTCHours()).padStart(2, "0");
|
||||||
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
||||||
|
|
||||||
// Compact ISO-like UTC timestamp with minutes precision.
|
|
||||||
// Example: 2025-01-02T03:04Z
|
|
||||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
|
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
timeZoneName: "short",
|
||||||
|
}).formatToParts(date);
|
||||||
|
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
||||||
|
const yyyy = pick("year");
|
||||||
|
const mm = pick("month");
|
||||||
|
const dd = pick("day");
|
||||||
|
const hh = pick("hour");
|
||||||
|
const min = pick("minute");
|
||||||
|
const tz = [...parts]
|
||||||
|
.reverse()
|
||||||
|
.find((part) => part.type === "timeZoneName")
|
||||||
|
?.value?.trim();
|
||||||
|
if (!yyyy || !mm || !dd || !hh || !min) return undefined;
|
||||||
|
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts: number | Date | undefined, options?: EnvelopeFormatOptions): string | undefined {
|
||||||
|
if (!ts) return undefined;
|
||||||
|
const resolved = normalizeEnvelopeOptions(options);
|
||||||
|
if (!resolved.includeTimestamp) return undefined;
|
||||||
|
const date = ts instanceof Date ? ts : new Date(ts);
|
||||||
|
if (Number.isNaN(date.getTime())) return undefined;
|
||||||
|
const zone = resolveEnvelopeTimezone(resolved);
|
||||||
|
if (zone.mode === "utc") return formatUtcTimestamp(date);
|
||||||
|
if (zone.mode === "local") return formatZonedTimestamp(date);
|
||||||
|
return formatZonedTimestamp(date, zone.timeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsedTime(currentMs: number, previousMs: number): string | undefined {
|
||||||
|
const elapsedMs = currentMs - previousMs;
|
||||||
|
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return undefined;
|
||||||
|
|
||||||
|
const seconds = Math.floor(elapsedMs / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h`;
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
||||||
const channel = params.channel?.trim() || "Channel";
|
const channel = params.channel?.trim() || "Channel";
|
||||||
const parts: string[] = [channel];
|
const parts: string[] = [channel];
|
||||||
if (params.from?.trim()) parts.push(params.from.trim());
|
const resolved = normalizeEnvelopeOptions(params.envelope);
|
||||||
|
const elapsed =
|
||||||
|
resolved.includeElapsed && params.timestamp && params.previousTimestamp
|
||||||
|
? formatElapsedTime(
|
||||||
|
params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp,
|
||||||
|
params.previousTimestamp instanceof Date
|
||||||
|
? params.previousTimestamp.getTime()
|
||||||
|
: params.previousTimestamp,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
if (params.from?.trim()) {
|
||||||
|
const from = params.from.trim();
|
||||||
|
parts.push(elapsed ? `${from} +${elapsed}` : from);
|
||||||
|
} else if (elapsed) {
|
||||||
|
parts.push(`+${elapsed}`);
|
||||||
|
}
|
||||||
if (params.host?.trim()) parts.push(params.host.trim());
|
if (params.host?.trim()) parts.push(params.host.trim());
|
||||||
if (params.ip?.trim()) parts.push(params.ip.trim());
|
if (params.ip?.trim()) parts.push(params.ip.trim());
|
||||||
const ts = formatTimestamp(params.timestamp);
|
const ts = formatTimestamp(params.timestamp, resolved);
|
||||||
if (ts) parts.push(ts);
|
if (ts) parts.push(ts);
|
||||||
const header = `[${parts.join(" ")}]`;
|
const header = `[${parts.join(" ")}]`;
|
||||||
return `${header} ${params.body}`;
|
return `${header} ${params.body}`;
|
||||||
@ -46,6 +179,8 @@ export function formatInboundEnvelope(params: {
|
|||||||
chatType?: string;
|
chatType?: string;
|
||||||
senderLabel?: string;
|
senderLabel?: string;
|
||||||
sender?: SenderLabelParams;
|
sender?: SenderLabelParams;
|
||||||
|
previousTimestamp?: number | Date;
|
||||||
|
envelope?: EnvelopeFormatOptions;
|
||||||
}): string {
|
}): string {
|
||||||
const chatType = normalizeChatType(params.chatType);
|
const chatType = normalizeChatType(params.chatType);
|
||||||
const isDirect = !chatType || chatType === "direct";
|
const isDirect = !chatType || chatType === "direct";
|
||||||
@ -55,6 +190,8 @@ export function formatInboundEnvelope(params: {
|
|||||||
channel: params.channel,
|
channel: params.channel,
|
||||||
from: params.from,
|
from: params.from,
|
||||||
timestamp: params.timestamp,
|
timestamp: params.timestamp,
|
||||||
|
previousTimestamp: params.previousTimestamp,
|
||||||
|
envelope: params.envelope,
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -85,11 +222,13 @@ export function formatThreadStarterEnvelope(params: {
|
|||||||
author?: string;
|
author?: string;
|
||||||
timestamp?: number | Date;
|
timestamp?: number | Date;
|
||||||
body: string;
|
body: string;
|
||||||
|
envelope?: EnvelopeFormatOptions;
|
||||||
}): string {
|
}): string {
|
||||||
return formatAgentEnvelope({
|
return formatAgentEnvelope({
|
||||||
channel: params.channel,
|
channel: params.channel,
|
||||||
from: params.author,
|
from: params.author,
|
||||||
timestamp: params.timestamp,
|
timestamp: params.timestamp,
|
||||||
|
envelope: params.envelope,
|
||||||
body: params.body,
|
body: params.body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,17 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const callGatewayFromCli = vi.fn(
|
const callGatewayFromCli = vi.fn(async (method: string, _opts: unknown, params?: unknown) => {
|
||||||
async (method: string, _opts: unknown, params?: unknown) => {
|
if (method.endsWith(".get")) {
|
||||||
if (method.endsWith(".get")) {
|
return {
|
||||||
return {
|
path: "/tmp/exec-approvals.json",
|
||||||
path: "/tmp/exec-approvals.json",
|
exists: true,
|
||||||
exists: true,
|
hash: "hash-1",
|
||||||
hash: "hash-1",
|
file: { version: 1, agents: {} },
|
||||||
file: { version: 1, agents: {} },
|
};
|
||||||
};
|
}
|
||||||
}
|
return { method, params };
|
||||||
return { method, params };
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const runtimeLogs: string[] = [];
|
const runtimeLogs: string[] = [];
|
||||||
const runtimeErrors: string[] = [];
|
const runtimeErrors: string[] = [];
|
||||||
@ -31,9 +29,7 @@ vi.mock("./gateway-rpc.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./nodes-cli/rpc.js", async () => {
|
vi.mock("./nodes-cli/rpc.js", async () => {
|
||||||
const actual = await vi.importActual<typeof import("./nodes-cli/rpc.js")>(
|
const actual = await vi.importActual<typeof import("./nodes-cli/rpc.js")>("./nodes-cli/rpc.js");
|
||||||
"./nodes-cli/rpc.js",
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
resolveNodeId: vi.fn(async () => "node-1"),
|
resolveNodeId: vi.fn(async () => "node-1"),
|
||||||
@ -57,11 +53,7 @@ describe("exec approvals CLI", () => {
|
|||||||
|
|
||||||
await program.parseAsync(["approvals", "get"], { from: "user" });
|
await program.parseAsync(["approvals", "get"], { from: "user" });
|
||||||
|
|
||||||
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.get", expect.anything(), {});
|
||||||
"exec.approvals.get",
|
|
||||||
expect.anything(),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
expect(runtimeErrors).toHaveLength(0);
|
expect(runtimeErrors).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -77,11 +69,9 @@ describe("exec approvals CLI", () => {
|
|||||||
|
|
||||||
await program.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" });
|
await program.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" });
|
||||||
|
|
||||||
expect(callGatewayFromCli).toHaveBeenCalledWith(
|
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.node.get", expect.anything(), {
|
||||||
"exec.approvals.node.get",
|
nodeId: "node-1",
|
||||||
expect.anything(),
|
});
|
||||||
{ nodeId: "node-1" },
|
|
||||||
);
|
|
||||||
expect(runtimeErrors).toHaveLength(0);
|
expect(runtimeErrors).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -151,15 +151,13 @@ export function registerExecApprovalsCli(program: Command) {
|
|||||||
});
|
});
|
||||||
nodesCallOpts(setCmd);
|
nodesCallOpts(setCmd);
|
||||||
|
|
||||||
const allowlist = approvals
|
const allowlist = approvals.command("allowlist").description("Edit the per-agent allowlist");
|
||||||
.command("allowlist")
|
|
||||||
.description("Edit the per-agent allowlist");
|
|
||||||
|
|
||||||
const allowlistAdd = allowlist
|
const allowlistAdd = allowlist
|
||||||
.command("add <pattern>")
|
.command("add <pattern>")
|
||||||
.description("Add a glob pattern to an allowlist")
|
.description("Add a glob pattern to an allowlist")
|
||||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
||||||
.option("--agent <id>", "Agent id (defaults to \"default\")")
|
.option("--agent <id>", 'Agent id (defaults to "default")')
|
||||||
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
||||||
const trimmed = pattern.trim();
|
const trimmed = pattern.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@ -196,7 +194,7 @@ export function registerExecApprovalsCli(program: Command) {
|
|||||||
.command("remove <pattern>")
|
.command("remove <pattern>")
|
||||||
.description("Remove a glob pattern from an allowlist")
|
.description("Remove a glob pattern from an allowlist")
|
||||||
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
|
||||||
.option("--agent <id>", "Agent id (defaults to \"default\")")
|
.option("--agent <id>", 'Agent id (defaults to "default")')
|
||||||
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
|
||||||
const trimmed = pattern.trim();
|
const trimmed = pattern.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|||||||
@ -87,19 +87,13 @@ describe("gateway SIGTERM", () => {
|
|||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
const err: string[] = [];
|
const err: string[] = [];
|
||||||
|
|
||||||
|
const bunBin = process.env.BUN_INSTALL
|
||||||
|
? path.join(process.env.BUN_INSTALL, "bin", "bun")
|
||||||
|
: "bun";
|
||||||
|
|
||||||
child = spawn(
|
child = spawn(
|
||||||
process.execPath,
|
bunBin,
|
||||||
[
|
["src/entry.ts", "gateway", "--port", String(port), "--bind", "loopback", "--allow-unconfigured"],
|
||||||
"--import",
|
|
||||||
"tsx",
|
|
||||||
"src/index.ts",
|
|
||||||
"gateway",
|
|
||||||
"--port",
|
|
||||||
String(port),
|
|
||||||
"--bind",
|
|
||||||
"loopback",
|
|
||||||
"--allow-unconfigured",
|
|
||||||
],
|
|
||||||
{
|
{
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
env: {
|
env: {
|
||||||
|
|||||||
@ -156,9 +156,7 @@ export function registerMemoryCli(program: Command) {
|
|||||||
for (const result of allResults) {
|
for (const result of allResults) {
|
||||||
const { agentId, status, embeddingProbe, indexError } = result;
|
const { agentId, status, embeddingProbe, indexError } = result;
|
||||||
if (opts.index) {
|
if (opts.index) {
|
||||||
const line = indexError
|
const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete.";
|
||||||
? `Memory index failed: ${indexError}`
|
|
||||||
: "Memory index complete.";
|
|
||||||
defaultRuntime.log(line);
|
defaultRuntime.log(line);
|
||||||
}
|
}
|
||||||
const lines = [
|
const lines = [
|
||||||
@ -167,9 +165,7 @@ export function registerMemoryCli(program: Command) {
|
|||||||
`(requested: ${status.requestedProvider})`,
|
`(requested: ${status.requestedProvider})`,
|
||||||
)}`,
|
)}`,
|
||||||
`${label("Model")} ${info(status.model)}`,
|
`${label("Model")} ${info(status.model)}`,
|
||||||
status.sources?.length
|
status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null,
|
||||||
? `${label("Sources")} ${info(status.sources.join(", "))}`
|
|
||||||
: null,
|
|
||||||
`${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`,
|
`${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`,
|
||||||
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
|
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
|
||||||
`${label("Store")} ${info(status.dbPath)}`,
|
`${label("Store")} ${info(status.dbPath)}`,
|
||||||
|
|||||||
@ -116,12 +116,14 @@ export function registerNodesStatusCommands(nodes: Command) {
|
|||||||
const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
|
const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
|
||||||
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
|
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
|
||||||
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
|
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
|
||||||
const versions = formatNodeVersions(obj as {
|
const versions = formatNodeVersions(
|
||||||
platform?: string;
|
obj as {
|
||||||
version?: string;
|
platform?: string;
|
||||||
coreVersion?: string;
|
version?: string;
|
||||||
uiVersion?: string;
|
coreVersion?: string;
|
||||||
});
|
uiVersion?: string;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const parts: string[] = ["Node:", displayName, nodeId];
|
const parts: string[] = ["Node:", displayName, nodeId];
|
||||||
if (ip) parts.push(ip);
|
if (ip) parts.push(ip);
|
||||||
|
|||||||
@ -11,10 +11,8 @@ import {
|
|||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
const CHANNELS: PairingChannel[] = listPairingChannels();
|
|
||||||
|
|
||||||
/** Parse channel, allowing extension channels not in core registry. */
|
/** Parse channel, allowing extension channels not in core registry. */
|
||||||
function parseChannel(raw: unknown): PairingChannel {
|
function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel {
|
||||||
const value = (
|
const value = (
|
||||||
typeof raw === "string"
|
typeof raw === "string"
|
||||||
? raw
|
? raw
|
||||||
@ -28,7 +26,7 @@ function parseChannel(raw: unknown): PairingChannel {
|
|||||||
|
|
||||||
const normalized = normalizeChannelId(value);
|
const normalized = normalizeChannelId(value);
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
if (!CHANNELS.includes(normalized as PairingChannel)) {
|
if (!channels.includes(normalized as PairingChannel)) {
|
||||||
throw new Error(`Channel ${normalized} does not support pairing`);
|
throw new Error(`Channel ${normalized} does not support pairing`);
|
||||||
}
|
}
|
||||||
return normalized as PairingChannel;
|
return normalized as PairingChannel;
|
||||||
@ -45,6 +43,7 @@ async function notifyApproved(channel: PairingChannel, id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerPairingCli(program: Command) {
|
export function registerPairingCli(program: Command) {
|
||||||
|
const channels = listPairingChannels();
|
||||||
const pairing = program
|
const pairing = program
|
||||||
.command("pairing")
|
.command("pairing")
|
||||||
.description("Secure DM pairing (approve inbound requests)")
|
.description("Secure DM pairing (approve inbound requests)")
|
||||||
@ -57,17 +56,17 @@ export function registerPairingCli(program: Command) {
|
|||||||
pairing
|
pairing
|
||||||
.command("list")
|
.command("list")
|
||||||
.description("List pending pairing requests")
|
.description("List pending pairing requests")
|
||||||
.option("--channel <channel>", `Channel (${CHANNELS.join(", ")})`)
|
.option("--channel <channel>", `Channel (${channels.join(", ")})`)
|
||||||
.argument("[channel]", `Channel (${CHANNELS.join(", ")})`)
|
.argument("[channel]", `Channel (${channels.join(", ")})`)
|
||||||
.option("--json", "Print JSON", false)
|
.option("--json", "Print JSON", false)
|
||||||
.action(async (channelArg, opts) => {
|
.action(async (channelArg, opts) => {
|
||||||
const channelRaw = opts.channel ?? channelArg;
|
const channelRaw = opts.channel ?? channelArg;
|
||||||
if (!channelRaw) {
|
if (!channelRaw) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Channel required. Use --channel <channel> or pass it as the first argument (expected one of: ${CHANNELS.join(", ")})`,
|
`Channel required. Use --channel <channel> or pass it as the first argument (expected one of: ${channels.join(", ")})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const channel = parseChannel(channelRaw);
|
const channel = parseChannel(channelRaw, channels);
|
||||||
const requests = await listChannelPairingRequests(channel);
|
const requests = await listChannelPairingRequests(channel);
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
console.log(JSON.stringify({ channel, requests }, null, 2));
|
console.log(JSON.stringify({ channel, requests }, null, 2));
|
||||||
@ -87,7 +86,7 @@ export function registerPairingCli(program: Command) {
|
|||||||
pairing
|
pairing
|
||||||
.command("approve")
|
.command("approve")
|
||||||
.description("Approve a pairing code and allow that sender")
|
.description("Approve a pairing code and allow that sender")
|
||||||
.option("--channel <channel>", `Channel (${CHANNELS.join(", ")})`)
|
.option("--channel <channel>", `Channel (${channels.join(", ")})`)
|
||||||
.argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)")
|
.argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)")
|
||||||
.argument("[code]", "Pairing code (when channel is passed as the 1st arg)")
|
.argument("[code]", "Pairing code (when channel is passed as the 1st arg)")
|
||||||
.option("--notify", "Notify the requester on the same channel", false)
|
.option("--notify", "Notify the requester on the same channel", false)
|
||||||
@ -104,7 +103,7 @@ export function registerPairingCli(program: Command) {
|
|||||||
`Too many arguments. Use: clawdbot pairing approve --channel <channel> <code>`,
|
`Too many arguments. Use: clawdbot pairing approve --channel <channel> <code>`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const channel = parseChannel(channelRaw);
|
const channel = parseChannel(channelRaw, channels);
|
||||||
const approved = await approveChannelPairingCode({
|
const approved = await approveChannelPairingCode({
|
||||||
channel,
|
channel,
|
||||||
code: String(resolvedCode),
|
code: String(resolvedCode),
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
resolvePluginInstallDir,
|
resolvePluginInstallDir,
|
||||||
} from "../plugins/install.js";
|
} from "../plugins/install.js";
|
||||||
import { recordPluginInstall } from "../plugins/installs.js";
|
import { recordPluginInstall } from "../plugins/installs.js";
|
||||||
|
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
|
||||||
import type { PluginRecord } from "../plugins/registry.js";
|
import type { PluginRecord } from "../plugins/registry.js";
|
||||||
import { buildPluginStatusReport } from "../plugins/status.js";
|
import { buildPluginStatusReport } from "../plugins/status.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
@ -79,6 +80,31 @@ async function readInstalledPackageVersion(dir: string): Promise<string | undefi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applySlotSelectionForPlugin(
|
||||||
|
config: ClawdbotConfig,
|
||||||
|
pluginId: string,
|
||||||
|
): { config: ClawdbotConfig; warnings: string[] } {
|
||||||
|
const report = buildPluginStatusReport({ config });
|
||||||
|
const plugin = report.plugins.find((entry) => entry.id === pluginId);
|
||||||
|
if (!plugin) {
|
||||||
|
return { config, warnings: [] };
|
||||||
|
}
|
||||||
|
const result = applyExclusiveSlotSelection({
|
||||||
|
config,
|
||||||
|
selectedId: plugin.id,
|
||||||
|
selectedKind: plugin.kind,
|
||||||
|
registry: report,
|
||||||
|
});
|
||||||
|
return { config: result.config, warnings: result.warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSlotWarnings(warnings: string[]) {
|
||||||
|
if (warnings.length === 0) return;
|
||||||
|
for (const warning of warnings) {
|
||||||
|
defaultRuntime.log(chalk.yellow(warning));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function registerPluginsCli(program: Command) {
|
export function registerPluginsCli(program: Command) {
|
||||||
const plugins = program
|
const plugins = program
|
||||||
.command("plugins")
|
.command("plugins")
|
||||||
@ -197,7 +223,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
.argument("<id>", "Plugin id")
|
.argument("<id>", "Plugin id")
|
||||||
.action(async (id: string) => {
|
.action(async (id: string) => {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const next = {
|
let next: ClawdbotConfig = {
|
||||||
...cfg,
|
...cfg,
|
||||||
plugins: {
|
plugins: {
|
||||||
...cfg.plugins,
|
...cfg.plugins,
|
||||||
@ -210,7 +236,10 @@ export function registerPluginsCli(program: Command) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const slotResult = applySlotSelectionForPlugin(next, id);
|
||||||
|
next = slotResult.config;
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
|
logSlotWarnings(slotResult.warnings);
|
||||||
defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`);
|
defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -280,7 +309,10 @@ export function registerPluginsCli(program: Command) {
|
|||||||
installPath: resolved,
|
installPath: resolved,
|
||||||
version: probe.version,
|
version: probe.version,
|
||||||
});
|
});
|
||||||
|
const slotResult = applySlotSelectionForPlugin(next, probe.pluginId);
|
||||||
|
next = slotResult.config;
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
|
logSlotWarnings(slotResult.warnings);
|
||||||
defaultRuntime.log(`Linked plugin path: ${resolved}`);
|
defaultRuntime.log(`Linked plugin path: ${resolved}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
return;
|
return;
|
||||||
@ -319,7 +351,10 @@ export function registerPluginsCli(program: Command) {
|
|||||||
installPath: result.targetDir,
|
installPath: result.targetDir,
|
||||||
version: result.version,
|
version: result.version,
|
||||||
});
|
});
|
||||||
|
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
|
||||||
|
next = slotResult.config;
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
|
logSlotWarnings(slotResult.warnings);
|
||||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
return;
|
return;
|
||||||
@ -379,7 +414,10 @@ export function registerPluginsCli(program: Command) {
|
|||||||
installPath: result.targetDir,
|
installPath: result.targetDir,
|
||||||
version: result.version,
|
version: result.version,
|
||||||
});
|
});
|
||||||
|
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
|
||||||
|
next = slotResult.config;
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
|
logSlotWarnings(slotResult.warnings);
|
||||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -176,10 +176,7 @@ export async function applyAuthChoicePluginProvider(
|
|||||||
if (result.defaultModel) {
|
if (result.defaultModel) {
|
||||||
if (params.setDefaultModel) {
|
if (params.setDefaultModel) {
|
||||||
nextConfig = applyDefaultModel(nextConfig, result.defaultModel);
|
nextConfig = applyDefaultModel(nextConfig, result.defaultModel);
|
||||||
await params.prompter.note(
|
await params.prompter.note(`Default model set to ${result.defaultModel}`, "Model configured");
|
||||||
`Default model set to ${result.defaultModel}`,
|
|
||||||
"Model configured",
|
|
||||||
);
|
|
||||||
} else if (params.agentId) {
|
} else if (params.agentId) {
|
||||||
agentModelOverride = result.defaultModel;
|
agentModelOverride = result.defaultModel;
|
||||||
await params.prompter.note(
|
await params.prompter.note(
|
||||||
|
|||||||
@ -417,6 +417,22 @@ describe("channels command", () => {
|
|||||||
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
|
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes Telegram bot username from probe data", () => {
|
||||||
|
const lines = formatGatewayChannelsStatusLines({
|
||||||
|
channelAccounts: {
|
||||||
|
telegram: [
|
||||||
|
{
|
||||||
|
accountId: "default",
|
||||||
|
enabled: true,
|
||||||
|
configured: true,
|
||||||
|
probe: { ok: true, bot: { username: "clawdbot_bot" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(lines.join("\n")).toMatch(/bot:@clawdbot_bot/);
|
||||||
|
});
|
||||||
|
|
||||||
it("surfaces Telegram group membership audit issues in channels status output", () => {
|
it("surfaces Telegram group membership audit issues in channels status output", () => {
|
||||||
const lines = formatGatewayChannelsStatusLines({
|
const lines = formatGatewayChannelsStatusLines({
|
||||||
channelAccounts: {
|
channelAccounts: {
|
||||||
|
|||||||
@ -51,6 +51,18 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
|
|||||||
if (typeof account.mode === "string" && account.mode.length > 0) {
|
if (typeof account.mode === "string" && account.mode.length > 0) {
|
||||||
bits.push(`mode:${account.mode}`);
|
bits.push(`mode:${account.mode}`);
|
||||||
}
|
}
|
||||||
|
const botUsername = (() => {
|
||||||
|
const bot = account.bot as { username?: string | null } | undefined;
|
||||||
|
const probeBot = (account.probe as { bot?: { username?: string | null } } | undefined)?.bot;
|
||||||
|
const raw = bot?.username ?? probeBot?.username ?? "";
|
||||||
|
if (typeof raw !== "string") return "";
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
return trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
|
||||||
|
})();
|
||||||
|
if (botUsername) {
|
||||||
|
bits.push(`bot:${botUsername}`);
|
||||||
|
}
|
||||||
if (typeof account.dmPolicy === "string" && account.dmPolicy.length > 0) {
|
if (typeof account.dmPolicy === "string" && account.dmPolicy.length > 0) {
|
||||||
bits.push(`dm:${account.dmPolicy}`);
|
bits.push(`dm:${account.dmPolicy}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { resolveGatewayPort } from "../config/config.js";
|
import { resolveGatewayPort } from "../config/config.js";
|
||||||
import { resolveGatewayLaunchAgentLabel, resolveNodeLaunchAgentLabel } from "../daemon/constants.js";
|
import {
|
||||||
|
resolveGatewayLaunchAgentLabel,
|
||||||
|
resolveNodeLaunchAgentLabel,
|
||||||
|
} from "../daemon/constants.js";
|
||||||
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||||
import {
|
import {
|
||||||
isLaunchAgentListed,
|
isLaunchAgentListed,
|
||||||
@ -44,10 +47,7 @@ async function maybeRepairLaunchAgentBootstrap(params: {
|
|||||||
const plistExists = await launchAgentPlistExists(params.env);
|
const plistExists = await launchAgentPlistExists(params.env);
|
||||||
if (!plistExists) return false;
|
if (!plistExists) return false;
|
||||||
|
|
||||||
note(
|
note("LaunchAgent is listed but not loaded in launchd.", `${params.title} LaunchAgent`);
|
||||||
"LaunchAgent is listed but not loaded in launchd.",
|
|
||||||
`${params.title} LaunchAgent`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const shouldFix = await params.prompter.confirmSkipInNonInteractive({
|
const shouldFix = await params.prompter.confirmSkipInNonInteractive({
|
||||||
message: `Repair ${params.title} LaunchAgent bootstrap now?`,
|
message: `Repair ${params.title} LaunchAgent bootstrap now?`,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { createServer } from "node:net";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
async function getFreePort(): Promise<number> {
|
async function getFreePort(): Promise<number> {
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
@ -50,7 +50,6 @@ describe("onboard (non-interactive): remote gateway config", () => {
|
|||||||
process.env.HOME = tempHome;
|
process.env.HOME = tempHome;
|
||||||
delete process.env.CLAWDBOT_STATE_DIR;
|
delete process.env.CLAWDBOT_STATE_DIR;
|
||||||
delete process.env.CLAWDBOT_CONFIG_PATH;
|
delete process.env.CLAWDBOT_CONFIG_PATH;
|
||||||
vi.resetModules();
|
|
||||||
|
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const token = "tok_remote_123";
|
const token = "tok_remote_123";
|
||||||
@ -85,8 +84,8 @@ describe("onboard (non-interactive): remote gateway config", () => {
|
|||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
|
const { resolveConfigPath } = await import("../config/config.js");
|
||||||
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as {
|
const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as {
|
||||||
gateway?: { mode?: string; remote?: { url?: string; token?: string } };
|
gateway?: { mode?: string; remote?: { url?: string; token?: string } };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ describe("applyPluginAutoEnable", () => {
|
|||||||
channels: { slack: { botToken: "x" } },
|
channels: { slack: { botToken: "x" } },
|
||||||
plugins: { allow: ["telegram"] },
|
plugins: { allow: ["telegram"] },
|
||||||
},
|
},
|
||||||
|
env: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.config.plugins?.entries?.slack?.enabled).toBe(true);
|
expect(result.config.plugins?.entries?.slack?.enabled).toBe(true);
|
||||||
@ -21,6 +22,7 @@ describe("applyPluginAutoEnable", () => {
|
|||||||
channels: { slack: { botToken: "x" } },
|
channels: { slack: { botToken: "x" } },
|
||||||
plugins: { entries: { slack: { enabled: false } } },
|
plugins: { entries: { slack: { enabled: false } } },
|
||||||
},
|
},
|
||||||
|
env: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.config.plugins?.entries?.slack?.enabled).toBe(false);
|
expect(result.config.plugins?.entries?.slack?.enabled).toBe(false);
|
||||||
@ -39,6 +41,7 @@ describe("applyPluginAutoEnable", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
env: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.config.plugins?.entries?.["google-antigravity-auth"]?.enabled).toBe(true);
|
expect(result.config.plugins?.entries?.["google-antigravity-auth"]?.enabled).toBe(true);
|
||||||
@ -50,6 +53,7 @@ describe("applyPluginAutoEnable", () => {
|
|||||||
channels: { slack: { botToken: "x" } },
|
channels: { slack: { botToken: "x" } },
|
||||||
plugins: { enabled: false },
|
plugins: { enabled: false },
|
||||||
},
|
},
|
||||||
|
env: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.config.plugins?.entries?.slack?.enabled).toBeUndefined();
|
expect(result.config.plugins?.entries?.slack?.enabled).toBeUndefined();
|
||||||
|
|||||||
@ -45,10 +45,7 @@ function recordHasKeys(value: unknown): boolean {
|
|||||||
return isRecord(value) && Object.keys(value).length > 0;
|
return isRecord(value) && Object.keys(value).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function accountsHaveKeys(
|
function accountsHaveKeys(value: unknown, keys: string[]): boolean {
|
||||||
value: unknown,
|
|
||||||
keys: string[],
|
|
||||||
): boolean {
|
|
||||||
if (!isRecord(value)) return false;
|
if (!isRecord(value)) return false;
|
||||||
for (const account of Object.values(value)) {
|
for (const account of Object.values(value)) {
|
||||||
if (!isRecord(account)) continue;
|
if (!isRecord(account)) continue;
|
||||||
@ -59,7 +56,10 @@ function accountsHaveKeys(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveChannelConfig(cfg: ClawdbotConfig, channelId: string): Record<string, unknown> | null {
|
function resolveChannelConfig(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
channelId: string,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||||
const entry = channels?.[channelId];
|
const entry = channels?.[channelId];
|
||||||
return isRecord(entry) ? entry : null;
|
return isRecord(entry) ? entry : null;
|
||||||
@ -234,7 +234,10 @@ function isProviderConfigured(cfg: ClawdbotConfig, providerId: string): boolean
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveConfiguredPlugins(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): PluginEnableChange[] {
|
function resolveConfiguredPlugins(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
): PluginEnableChange[] {
|
||||||
const changes: PluginEnableChange[] = [];
|
const changes: PluginEnableChange[] = [];
|
||||||
for (const channelId of CHANNEL_PLUGIN_IDS) {
|
for (const channelId of CHANNEL_PLUGIN_IDS) {
|
||||||
if (isChannelConfigured(cfg, channelId, env)) {
|
if (isChannelConfigured(cfg, channelId, env)) {
|
||||||
|
|||||||
@ -172,6 +172,9 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
|
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
|
||||||
"agents.defaults.workspace": "Workspace",
|
"agents.defaults.workspace": "Workspace",
|
||||||
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
|
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
|
||||||
|
"agents.defaults.envelopeTimezone": "Envelope Timezone",
|
||||||
|
"agents.defaults.envelopeTimestamp": "Envelope Timestamp",
|
||||||
|
"agents.defaults.envelopeElapsed": "Envelope Elapsed",
|
||||||
"agents.defaults.memorySearch": "Memory Search",
|
"agents.defaults.memorySearch": "Memory Search",
|
||||||
"agents.defaults.memorySearch.enabled": "Enable Memory Search",
|
"agents.defaults.memorySearch.enabled": "Enable Memory Search",
|
||||||
"agents.defaults.memorySearch.sources": "Memory Search Sources",
|
"agents.defaults.memorySearch.sources": "Memory Search Sources",
|
||||||
@ -371,6 +374,12 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
|
"auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
|
||||||
"agents.defaults.bootstrapMaxChars":
|
"agents.defaults.bootstrapMaxChars":
|
||||||
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).",
|
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).",
|
||||||
|
"agents.defaults.envelopeTimezone":
|
||||||
|
'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).',
|
||||||
|
"agents.defaults.envelopeTimestamp":
|
||||||
|
'Include absolute timestamps in message envelopes ("on" or "off").',
|
||||||
|
"agents.defaults.envelopeElapsed":
|
||||||
|
'Include elapsed time in message envelopes ("on" or "off").',
|
||||||
"agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).",
|
"agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).",
|
||||||
"agents.defaults.memorySearch":
|
"agents.defaults.memorySearch":
|
||||||
"Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).",
|
"Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).",
|
||||||
|
|||||||
@ -155,6 +155,18 @@ export function loadSessionStore(
|
|||||||
return structuredClone(store);
|
return structuredClone(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readSessionUpdatedAt(params: {
|
||||||
|
storePath: string;
|
||||||
|
sessionKey: string;
|
||||||
|
}): number | undefined {
|
||||||
|
try {
|
||||||
|
const store = loadSessionStore(params.storePath);
|
||||||
|
return store[params.sessionKey]?.updatedAt;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSessionStoreUnlocked(
|
async function saveSessionStoreUnlocked(
|
||||||
storePath: string,
|
storePath: string,
|
||||||
store: Record<string, SessionEntry>,
|
store: Record<string, SessionEntry>,
|
||||||
|
|||||||
@ -105,6 +105,18 @@ export type AgentDefaultsConfig = {
|
|||||||
userTimezone?: string;
|
userTimezone?: string;
|
||||||
/** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */
|
/** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */
|
||||||
timeFormat?: "auto" | "12" | "24";
|
timeFormat?: "auto" | "12" | "24";
|
||||||
|
/**
|
||||||
|
* Envelope timestamp timezone: "utc" (default), "local", "user", or an IANA timezone string.
|
||||||
|
*/
|
||||||
|
envelopeTimezone?: string;
|
||||||
|
/**
|
||||||
|
* Include absolute timestamps in message envelopes ("on" | "off", default: "on").
|
||||||
|
*/
|
||||||
|
envelopeTimestamp?: "on" | "off";
|
||||||
|
/**
|
||||||
|
* Include elapsed time in message envelopes ("on" | "off", default: "on").
|
||||||
|
*/
|
||||||
|
envelopeElapsed?: "on" | "off";
|
||||||
/** Optional display-only context window override (used for % in status UIs). */
|
/** Optional display-only context window override (used for % in status UIs). */
|
||||||
contextTokens?: number;
|
contextTokens?: number;
|
||||||
/** Optional CLI backends for text-only fallback (claude-cli, etc.). */
|
/** Optional CLI backends for text-only fallback (claude-cli, etc.). */
|
||||||
|
|||||||
@ -20,7 +20,10 @@ export function parseClawdbotVersion(raw: string | null | undefined): ClawdbotVe
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compareClawdbotVersions(a: string | null | undefined, b: string | null | undefined): number | null {
|
export function compareClawdbotVersions(
|
||||||
|
a: string | null | undefined,
|
||||||
|
b: string | null | undefined,
|
||||||
|
): number | null {
|
||||||
const parsedA = parseClawdbotVersion(a);
|
const parsedA = parseClawdbotVersion(a);
|
||||||
const parsedB = parseClawdbotVersion(b);
|
const parsedB = parseClawdbotVersion(b);
|
||||||
if (!parsedA || !parsedB) return null;
|
if (!parsedA || !parsedB) return null;
|
||||||
|
|||||||
@ -42,6 +42,9 @@ export const AgentDefaultsSchema = z
|
|||||||
bootstrapMaxChars: z.number().int().positive().optional(),
|
bootstrapMaxChars: z.number().int().positive().optional(),
|
||||||
userTimezone: z.string().optional(),
|
userTimezone: z.string().optional(),
|
||||||
timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(),
|
timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(),
|
||||||
|
envelopeTimezone: z.string().optional(),
|
||||||
|
envelopeTimestamp: z.union([z.literal("on"), z.literal("off")]).optional(),
|
||||||
|
envelopeElapsed: z.union([z.literal("on"), z.literal("off")]).optional(),
|
||||||
contextTokens: z.number().int().positive().optional(),
|
contextTokens: z.number().int().positive().optional(),
|
||||||
cliBackends: z.record(z.string(), CliBackendSchema).optional(),
|
cliBackends: z.record(z.string(), CliBackendSchema).optional(),
|
||||||
memorySearch: MemorySearchSchema,
|
memorySearch: MemorySearchSchema,
|
||||||
|
|||||||
@ -40,7 +40,7 @@ async function withLaunchctlStub(
|
|||||||
' fs.appendFileSync(logPath, JSON.stringify(args) + "\\n", "utf8");',
|
' fs.appendFileSync(logPath, JSON.stringify(args) + "\\n", "utf8");',
|
||||||
"}",
|
"}",
|
||||||
'if (args[0] === "list") {',
|
'if (args[0] === "list") {',
|
||||||
" const output = process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT || \"\";",
|
' const output = process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT || "";',
|
||||||
" process.stdout.write(output);",
|
" process.stdout.write(output);",
|
||||||
"}",
|
"}",
|
||||||
"process.exit(0);",
|
"process.exit(0);",
|
||||||
@ -107,13 +107,10 @@ describe("launchd runtime parsing", () => {
|
|||||||
|
|
||||||
describe("launchctl list detection", () => {
|
describe("launchctl list detection", () => {
|
||||||
it("detects the resolved label in launchctl list", async () => {
|
it("detects the resolved label in launchctl list", async () => {
|
||||||
await withLaunchctlStub(
|
await withLaunchctlStub({ listOutput: "123 0 com.clawdbot.gateway\n" }, async ({ env }) => {
|
||||||
{ listOutput: "123 0 com.clawdbot.gateway\n" },
|
const listed = await isLaunchAgentListed({ env });
|
||||||
async ({ env }) => {
|
expect(listed).toBe(true);
|
||||||
const listed = await isLaunchAgentListed({ env });
|
});
|
||||||
expect(listed).toBe(true);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false when the label is missing", async () => {
|
it("returns false when the label is missing", async () => {
|
||||||
|
|||||||
@ -176,9 +176,7 @@ export async function isLaunchAgentListed(args: {
|
|||||||
const label = resolveLaunchAgentLabel({ env: args.env });
|
const label = resolveLaunchAgentLabel({ env: args.env });
|
||||||
const res = await execLaunchctl(["list"]);
|
const res = await execLaunchctl(["list"]);
|
||||||
if (res.code !== 0) return false;
|
if (res.code !== 0) return false;
|
||||||
return res.stdout
|
return res.stdout.split(/\r?\n/).some((line) => line.trim().split(/\s+/).at(-1) === label);
|
||||||
.split(/\r?\n/)
|
|
||||||
.some((line) => line.trim().split(/\s+/).at(-1) === label);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function launchAgentPlistExists(
|
export async function launchAgentPlistExists(
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { ChannelType, MessageType } from "@buape/carbon";
|
|||||||
import { Routes } from "discord-api-types/v10";
|
import { Routes } from "discord-api-types/v10";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js";
|
||||||
|
|
||||||
const sendMock = vi.fn();
|
const sendMock = vi.fn();
|
||||||
const reactMock = vi.fn();
|
const reactMock = vi.fn();
|
||||||
const updateLastRouteMock = vi.fn();
|
const updateLastRouteMock = vi.fn();
|
||||||
@ -42,7 +44,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||||
vi.resetModules();
|
__resetDiscordChannelInfoCacheForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("discord tool result dispatch", () => {
|
describe("discord tool result dispatch", () => {
|
||||||
|
|||||||
@ -8,7 +8,11 @@ import {
|
|||||||
extractShortModelName,
|
extractShortModelName,
|
||||||
type ResponsePrefixContext,
|
type ResponsePrefixContext,
|
||||||
} from "../../auto-reply/reply/response-prefix-template.js";
|
} from "../../auto-reply/reply/response-prefix-template.js";
|
||||||
import { formatInboundEnvelope, formatThreadStarterEnvelope } from "../../auto-reply/envelope.js";
|
import {
|
||||||
|
formatInboundEnvelope,
|
||||||
|
formatThreadStarterEnvelope,
|
||||||
|
resolveEnvelopeFormatOptions,
|
||||||
|
} from "../../auto-reply/envelope.js";
|
||||||
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
|
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
|
||||||
import {
|
import {
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
@ -18,6 +22,7 @@ import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.j
|
|||||||
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
import {
|
import {
|
||||||
|
readSessionUpdatedAt,
|
||||||
recordSessionMetaFromInbound,
|
recordSessionMetaFromInbound,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
updateLastRoute,
|
updateLastRoute,
|
||||||
@ -137,6 +142,14 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
].filter((entry): entry is string => Boolean(entry));
|
].filter((entry): entry is string => Boolean(entry));
|
||||||
const groupSystemPrompt =
|
const groupSystemPrompt =
|
||||||
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
||||||
|
const storePath = resolveStorePath(cfg.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||||
|
const previousTimestamp = readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
let combinedBody = formatInboundEnvelope({
|
let combinedBody = formatInboundEnvelope({
|
||||||
channel: "Discord",
|
channel: "Discord",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
@ -144,6 +157,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
body: text,
|
body: text,
|
||||||
chatType: isDirectMessage ? "direct" : "channel",
|
chatType: isDirectMessage ? "direct" : "channel",
|
||||||
senderLabel,
|
senderLabel,
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
});
|
});
|
||||||
const shouldIncludeChannelHistory =
|
const shouldIncludeChannelHistory =
|
||||||
!isDirectMessage && !(isGuildMessage && channelConfig?.autoThread && !threadChannel);
|
!isDirectMessage && !(isGuildMessage && channelConfig?.autoThread && !threadChannel);
|
||||||
@ -161,10 +176,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`,
|
body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`,
|
||||||
chatType: "channel",
|
chatType: "channel",
|
||||||
senderLabel: entry.sender,
|
senderLabel: entry.sender,
|
||||||
|
envelope: envelopeOptions,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const replyContext = resolveReplyContext(message, resolveDiscordMessageText);
|
const replyContext = resolveReplyContext(message, resolveDiscordMessageText, {
|
||||||
|
envelope: envelopeOptions,
|
||||||
|
});
|
||||||
if (replyContext) {
|
if (replyContext) {
|
||||||
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
|
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
|
||||||
}
|
}
|
||||||
@ -186,6 +204,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
author: starter.author,
|
author: starter.author,
|
||||||
timestamp: starter.timestamp,
|
timestamp: starter.timestamp,
|
||||||
body: starter.text,
|
body: starter.text,
|
||||||
|
envelope: envelopeOptions,
|
||||||
});
|
});
|
||||||
threadStarterBody = starterEnvelope;
|
threadStarterBody = starterEnvelope;
|
||||||
}
|
}
|
||||||
@ -268,9 +287,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget,
|
OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = resolveStorePath(cfg.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
});
|
|
||||||
void recordSessionMetaFromInbound({
|
void recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
|
|||||||
@ -44,6 +44,10 @@ const DISCORD_CHANNEL_INFO_CACHE = new Map<
|
|||||||
{ value: DiscordChannelInfo | null; expiresAt: number }
|
{ value: DiscordChannelInfo | null; expiresAt: number }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
|
export function __resetDiscordChannelInfoCacheForTest() {
|
||||||
|
DISCORD_CHANNEL_INFO_CACHE.clear();
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveDiscordChannelInfo(
|
export async function resolveDiscordChannelInfo(
|
||||||
client: Client,
|
client: Client,
|
||||||
channelId: string,
|
channelId: string,
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import type { Guild, Message, User } from "@buape/carbon";
|
import type { Guild, Message, User } from "@buape/carbon";
|
||||||
|
|
||||||
import { formatAgentEnvelope } from "../../auto-reply/envelope.js";
|
import { formatAgentEnvelope, type EnvelopeFormatOptions } from "../../auto-reply/envelope.js";
|
||||||
import { formatDiscordUserTag, resolveTimestampMs } from "./format.js";
|
import { formatDiscordUserTag, resolveTimestampMs } from "./format.js";
|
||||||
|
|
||||||
export function resolveReplyContext(
|
export function resolveReplyContext(
|
||||||
message: Message,
|
message: Message,
|
||||||
resolveDiscordMessageText: (message: Message, options?: { includeForwarded?: boolean }) => string,
|
resolveDiscordMessageText: (message: Message, options?: { includeForwarded?: boolean }) => string,
|
||||||
|
options?: { envelope?: EnvelopeFormatOptions },
|
||||||
): string | null {
|
): string | null {
|
||||||
const referenced = message.referencedMessage;
|
const referenced = message.referencedMessage;
|
||||||
if (!referenced?.author) return null;
|
if (!referenced?.author) return null;
|
||||||
@ -20,6 +21,7 @@ export function resolveReplyContext(
|
|||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: resolveTimestampMs(referenced.timestamp),
|
timestamp: resolveTimestampMs(referenced.timestamp),
|
||||||
body,
|
body,
|
||||||
|
envelope: options?.envelope,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,9 +27,10 @@ describe("runBootOnce", () => {
|
|||||||
|
|
||||||
it("skips when BOOT.md is missing", async () => {
|
it("skips when BOOT.md is missing", async () => {
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
|
||||||
await expect(
|
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
|
||||||
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
|
status: "skipped",
|
||||||
).resolves.toEqual({ status: "skipped", reason: "missing" });
|
reason: "missing",
|
||||||
|
});
|
||||||
expect(agentCommand).not.toHaveBeenCalled();
|
expect(agentCommand).not.toHaveBeenCalled();
|
||||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
@ -37,9 +38,10 @@ describe("runBootOnce", () => {
|
|||||||
it("skips when BOOT.md is empty", async () => {
|
it("skips when BOOT.md is empty", async () => {
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
|
||||||
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), " \n", "utf-8");
|
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), " \n", "utf-8");
|
||||||
await expect(
|
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
|
||||||
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
|
status: "skipped",
|
||||||
).resolves.toEqual({ status: "skipped", reason: "empty" });
|
reason: "empty",
|
||||||
|
});
|
||||||
expect(agentCommand).not.toHaveBeenCalled();
|
expect(agentCommand).not.toHaveBeenCalled();
|
||||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
@ -50,9 +52,9 @@ describe("runBootOnce", () => {
|
|||||||
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8");
|
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8");
|
||||||
|
|
||||||
agentCommand.mockResolvedValue(undefined);
|
agentCommand.mockResolvedValue(undefined);
|
||||||
await expect(
|
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
|
||||||
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
|
status: "ran",
|
||||||
).resolves.toEqual({ status: "ran" });
|
});
|
||||||
|
|
||||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||||
const call = agentCommand.mock.calls[0]?.[0];
|
const call = agentCommand.mock.calls[0]?.[0];
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
|
|
||||||
import { rawDataToString } from "../infra/ws.js";
|
import { rawDataToString } from "../infra/ws.js";
|
||||||
@ -141,7 +141,6 @@ describe("gateway wizard (e2e)", () => {
|
|||||||
process.env.HOME = tempHome;
|
process.env.HOME = tempHome;
|
||||||
delete process.env.CLAWDBOT_STATE_DIR;
|
delete process.env.CLAWDBOT_STATE_DIR;
|
||||||
delete process.env.CLAWDBOT_CONFIG_PATH;
|
delete process.env.CLAWDBOT_CONFIG_PATH;
|
||||||
vi.resetModules();
|
|
||||||
|
|
||||||
const wizardToken = `wiz-${randomUUID()}`;
|
const wizardToken = `wiz-${randomUUID()}`;
|
||||||
const port = await getFreeGatewayPort();
|
const port = await getFreeGatewayPort();
|
||||||
@ -187,8 +186,8 @@ describe("gateway wizard (e2e)", () => {
|
|||||||
expect(didSendToken).toBe(true);
|
expect(didSendToken).toBe(true);
|
||||||
expect(next.status).toBe("done");
|
expect(next.status).toBe("done");
|
||||||
|
|
||||||
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
|
const { resolveConfigPath } = await import("../config/config.js");
|
||||||
const parsed = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8"));
|
const parsed = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8"));
|
||||||
const token = (parsed as Record<string, unknown>)?.gateway as
|
const token = (parsed as Record<string, unknown>)?.gateway as
|
||||||
| Record<string, unknown>
|
| Record<string, unknown>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
|
|
||||||
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
||||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||||
|
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js";
|
||||||
import { agentCommand } from "../../commands/agent.js";
|
import { agentCommand } from "../../commands/agent.js";
|
||||||
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
|
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
@ -300,10 +301,20 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
};
|
};
|
||||||
respond(true, ackPayload, undefined, { runId: clientRunId });
|
respond(true, ackPayload, undefined, { runId: clientRunId });
|
||||||
|
|
||||||
|
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||||
|
const envelopedMessage = formatInboundEnvelope({
|
||||||
|
channel: "WebChat",
|
||||||
|
from: p.sessionKey,
|
||||||
|
timestamp: now,
|
||||||
|
body: parsedMessage,
|
||||||
|
chatType: "direct",
|
||||||
|
previousTimestamp: entry?.updatedAt,
|
||||||
|
envelope: envelopeOptions,
|
||||||
|
});
|
||||||
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
|
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
|
||||||
void agentCommand(
|
void agentCommand(
|
||||||
{
|
{
|
||||||
message: parsedMessage,
|
message: envelopedMessage,
|
||||||
images: parsedImages.length > 0 ? parsedImages : undefined,
|
images: parsedImages.length > 0 ? parsedImages : undefined,
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionKey: p.sessionKey,
|
sessionKey: p.sessionKey,
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||||
|
import type { PluginRegistry } from "../plugins/registry.js";
|
||||||
import {
|
import {
|
||||||
agentCommand,
|
agentCommand,
|
||||||
connectOk,
|
connectOk,
|
||||||
@ -14,6 +16,33 @@ import {
|
|||||||
|
|
||||||
installGatewayTestHooks();
|
installGatewayTestHooks();
|
||||||
|
|
||||||
|
const registryState = vi.hoisted(() => ({
|
||||||
|
registry: {
|
||||||
|
plugins: [],
|
||||||
|
tools: [],
|
||||||
|
channels: [],
|
||||||
|
providers: [],
|
||||||
|
gatewayHandlers: {},
|
||||||
|
httpHandlers: [],
|
||||||
|
cliRegistrars: [],
|
||||||
|
services: [],
|
||||||
|
diagnostics: [],
|
||||||
|
} as PluginRegistry,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./server-plugins.js", async () => {
|
||||||
|
const { setActivePluginRegistry } = await import("../plugins/runtime.js");
|
||||||
|
return {
|
||||||
|
loadGatewayPlugins: (params: { baseMethods: string[] }) => {
|
||||||
|
setActivePluginRegistry(registryState.registry);
|
||||||
|
return {
|
||||||
|
pluginRegistry: registryState.registry,
|
||||||
|
gatewayMethods: params.baseMethods ?? [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const BASE_IMAGE_PNG =
|
const BASE_IMAGE_PNG =
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
|
||||||
|
|
||||||
@ -22,8 +51,96 @@ function expectChannels(call: Record<string, unknown>, channel: string) {
|
|||||||
expect(call.messageChannel).toBe(channel);
|
expect(call.messageChannel).toBe(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
|
||||||
|
plugins: [],
|
||||||
|
tools: [],
|
||||||
|
channels,
|
||||||
|
providers: [],
|
||||||
|
gatewayHandlers: {},
|
||||||
|
httpHandlers: [],
|
||||||
|
cliRegistrars: [],
|
||||||
|
services: [],
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStubChannelPlugin = (params: {
|
||||||
|
id: ChannelPlugin["id"];
|
||||||
|
label: string;
|
||||||
|
resolveAllowFrom?: (cfg: Record<string, unknown>) => string[];
|
||||||
|
}): ChannelPlugin => ({
|
||||||
|
id: params.id,
|
||||||
|
meta: {
|
||||||
|
id: params.id,
|
||||||
|
label: params.label,
|
||||||
|
selectionLabel: params.label,
|
||||||
|
docsPath: `/channels/${params.id}`,
|
||||||
|
blurb: "test stub.",
|
||||||
|
},
|
||||||
|
capabilities: { chatTypes: ["direct"] },
|
||||||
|
config: {
|
||||||
|
listAccountIds: () => ["default"],
|
||||||
|
resolveAccount: () => ({}),
|
||||||
|
resolveAllowFrom: params.resolveAllowFrom
|
||||||
|
? ({ cfg }) => params.resolveAllowFrom?.(cfg as Record<string, unknown>) ?? []
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
resolveTarget: ({ to, allowFrom }) => {
|
||||||
|
const trimmed = to?.trim() ?? "";
|
||||||
|
if (trimmed) return { ok: true, to: trimmed };
|
||||||
|
const first = allowFrom?.[0];
|
||||||
|
if (first) return { ok: true, to: String(first) };
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: new Error(`missing target for ${params.id}`),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
sendText: async () => ({ channel: params.id, messageId: "msg-test" }),
|
||||||
|
sendMedia: async () => ({ channel: params.id, messageId: "msg-test" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultRegistry = createRegistry([
|
||||||
|
{
|
||||||
|
pluginId: "whatsapp",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({
|
||||||
|
id: "whatsapp",
|
||||||
|
label: "WhatsApp",
|
||||||
|
resolveAllowFrom: (cfg) => {
|
||||||
|
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||||
|
const entry = channels?.whatsapp as Record<string, unknown> | undefined;
|
||||||
|
const allow = entry?.allowFrom;
|
||||||
|
return Array.isArray(allow) ? allow.map((value) => String(value)) : [];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "telegram",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({ id: "telegram", label: "Telegram" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "discord",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({ id: "discord", label: "Discord" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "slack",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({ id: "slack", label: "Slack" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "signal",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({ id: "signal", label: "Signal" }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
describe("gateway server agent", () => {
|
describe("gateway server agent", () => {
|
||||||
test("agent marks implicit delivery when lastTo is stale", async () => {
|
test("agent marks implicit delivery when lastTo is stale", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
testState.allowFrom = ["+436769770569"];
|
testState.allowFrom = ["+436769770569"];
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
@ -63,6 +180,7 @@ describe("gateway server agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("agent forwards sessionKey to agentCommand", async () => {
|
test("agent forwards sessionKey to agentCommand", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
await writeSessionStore({
|
await writeSessionStore({
|
||||||
@ -97,6 +215,7 @@ describe("gateway server agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("agent forwards accountId to agentCommand", async () => {
|
test("agent forwards accountId to agentCommand", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
testState.allowFrom = ["+1555"];
|
testState.allowFrom = ["+1555"];
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
@ -136,6 +255,7 @@ describe("gateway server agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("agent avoids lastAccountId when explicit to is provided", async () => {
|
test("agent avoids lastAccountId when explicit to is provided", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
testState.allowFrom = ["+1555"];
|
testState.allowFrom = ["+1555"];
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
@ -175,6 +295,7 @@ describe("gateway server agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("agent keeps explicit accountId when explicit to is provided", async () => {
|
test("agent keeps explicit accountId when explicit to is provided", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
testState.allowFrom = ["+1555"];
|
testState.allowFrom = ["+1555"];
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
@ -215,6 +336,7 @@ describe("gateway server agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("agent falls back to lastAccountId for implicit delivery", async () => {
|
test("agent falls back to lastAccountId for implicit delivery", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
testState.allowFrom = ["+1555"];
|
testState.allowFrom = ["+1555"];
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
@ -253,6 +375,7 @@ describe("gateway server agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("agent forwards image attachments as images[]", async () => {
|
test("agent forwards image attachments as images[]", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
await writeSessionStore({
|
await writeSessionStore({
|
||||||
@ -299,6 +422,7 @@ describe("gateway server agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("agent falls back to whatsapp when delivery requested and no last channel exists", async () => {
|
test("agent falls back to whatsapp when delivery requested and no last channel exists", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
testState.allowFrom = ["+1555"];
|
testState.allowFrom = ["+1555"];
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
@ -335,6 +459,7 @@ describe("gateway server agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("agent routes main last-channel whatsapp", async () => {
|
test("agent routes main last-channel whatsapp", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
await writeSessionStore({
|
await writeSessionStore({
|
||||||
@ -374,6 +499,7 @@ describe("gateway server agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("agent routes main last-channel telegram", async () => {
|
test("agent routes main last-channel telegram", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
await writeSessionStore({
|
await writeSessionStore({
|
||||||
@ -412,6 +538,7 @@ describe("gateway server agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("agent routes main last-channel discord", async () => {
|
test("agent routes main last-channel discord", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
await writeSessionStore({
|
await writeSessionStore({
|
||||||
@ -450,6 +577,7 @@ describe("gateway server agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("agent routes main last-channel slack", async () => {
|
test("agent routes main last-channel slack", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
await writeSessionStore({
|
await writeSessionStore({
|
||||||
@ -488,6 +616,7 @@ describe("gateway server agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("agent routes main last-channel signal", async () => {
|
test("agent routes main last-channel signal", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
await writeSessionStore({
|
await writeSessionStore({
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||||
|
import type { PluginRegistry } from "../plugins/registry.js";
|
||||||
import {
|
import {
|
||||||
connectOk,
|
connectOk,
|
||||||
installGatewayTestHooks,
|
installGatewayTestHooks,
|
||||||
@ -10,6 +12,125 @@ const loadConfigHelpers = async () => await import("../config/config.js");
|
|||||||
|
|
||||||
installGatewayTestHooks();
|
installGatewayTestHooks();
|
||||||
|
|
||||||
|
const registryState = vi.hoisted(() => ({
|
||||||
|
registry: {
|
||||||
|
plugins: [],
|
||||||
|
tools: [],
|
||||||
|
channels: [],
|
||||||
|
providers: [],
|
||||||
|
gatewayHandlers: {},
|
||||||
|
httpHandlers: [],
|
||||||
|
cliRegistrars: [],
|
||||||
|
services: [],
|
||||||
|
diagnostics: [],
|
||||||
|
} as PluginRegistry,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./server-plugins.js", async () => {
|
||||||
|
const { setActivePluginRegistry } = await import("../plugins/runtime.js");
|
||||||
|
return {
|
||||||
|
loadGatewayPlugins: (params: { baseMethods: string[] }) => {
|
||||||
|
setActivePluginRegistry(registryState.registry);
|
||||||
|
return {
|
||||||
|
pluginRegistry: registryState.registry,
|
||||||
|
gatewayMethods: params.baseMethods ?? [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
|
||||||
|
plugins: [],
|
||||||
|
tools: [],
|
||||||
|
channels,
|
||||||
|
providers: [],
|
||||||
|
gatewayHandlers: {},
|
||||||
|
httpHandlers: [],
|
||||||
|
cliRegistrars: [],
|
||||||
|
services: [],
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStubChannelPlugin = (params: {
|
||||||
|
id: ChannelPlugin["id"];
|
||||||
|
label: string;
|
||||||
|
summary?: Record<string, unknown>;
|
||||||
|
logoutCleared?: boolean;
|
||||||
|
}): ChannelPlugin => ({
|
||||||
|
id: params.id,
|
||||||
|
meta: {
|
||||||
|
id: params.id,
|
||||||
|
label: params.label,
|
||||||
|
selectionLabel: params.label,
|
||||||
|
docsPath: `/channels/${params.id}`,
|
||||||
|
blurb: "test stub.",
|
||||||
|
},
|
||||||
|
capabilities: { chatTypes: ["direct"] },
|
||||||
|
config: {
|
||||||
|
listAccountIds: () => ["default"],
|
||||||
|
resolveAccount: () => ({}),
|
||||||
|
isConfigured: async () => false,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
buildChannelSummary: async () => ({
|
||||||
|
configured: false,
|
||||||
|
...params.summary,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
logoutAccount: async () => ({
|
||||||
|
cleared: params.logoutCleared ?? false,
|
||||||
|
envToken: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const telegramPlugin: ChannelPlugin = {
|
||||||
|
...createStubChannelPlugin({
|
||||||
|
id: "telegram",
|
||||||
|
label: "Telegram",
|
||||||
|
summary: { tokenSource: "none", lastProbeAt: null },
|
||||||
|
logoutCleared: true,
|
||||||
|
}),
|
||||||
|
gateway: {
|
||||||
|
logoutAccount: async ({ cfg }) => {
|
||||||
|
const { writeConfigFile } = await import("../config/config.js");
|
||||||
|
const nextTelegram = cfg.channels?.telegram ? { ...cfg.channels.telegram } : {};
|
||||||
|
delete nextTelegram.botToken;
|
||||||
|
await writeConfigFile({
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
telegram: nextTelegram,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { cleared: true, envToken: false, loggedOut: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultRegistry = createRegistry([
|
||||||
|
{
|
||||||
|
pluginId: "whatsapp",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({ id: "whatsapp", label: "WhatsApp" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "telegram",
|
||||||
|
source: "test",
|
||||||
|
plugin: telegramPlugin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "signal",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({
|
||||||
|
id: "signal",
|
||||||
|
label: "Signal",
|
||||||
|
summary: { lastProbeAt: null },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
|
const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@ -28,6 +149,7 @@ afterEach(async () => {
|
|||||||
describe("gateway server channels", () => {
|
describe("gateway server channels", () => {
|
||||||
test("channels.status returns snapshot without probe", async () => {
|
test("channels.status returns snapshot without probe", async () => {
|
||||||
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
|
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
const result = await startServerWithClient();
|
const result = await startServerWithClient();
|
||||||
servers.push(result);
|
servers.push(result);
|
||||||
const { ws } = result;
|
const { ws } = result;
|
||||||
@ -59,6 +181,7 @@ describe("gateway server channels", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("channels.logout reports no session when missing", async () => {
|
test("channels.logout reports no session when missing", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
const result = await startServerWithClient();
|
const result = await startServerWithClient();
|
||||||
servers.push(result);
|
servers.push(result);
|
||||||
const { ws } = result;
|
const { ws } = result;
|
||||||
@ -74,6 +197,7 @@ describe("gateway server channels", () => {
|
|||||||
|
|
||||||
test("channels.logout clears telegram bot token from config", async () => {
|
test("channels.logout clears telegram bot token from config", async () => {
|
||||||
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
|
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers();
|
const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers();
|
||||||
await writeConfigFile({
|
await writeConfigFile({
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { createServer } from "node:net";
|
import { createServer } from "node:net";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js";
|
import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js";
|
||||||
|
import { getChannelPlugin } from "../channels/plugins/index.js";
|
||||||
import { GatewayLockError } from "../infra/gateway-lock.js";
|
import { GatewayLockError } from "../infra/gateway-lock.js";
|
||||||
|
import type { ChannelOutboundAdapter } from "../channels/plugins/types.js";
|
||||||
|
import type { PluginRegistry } from "../plugins/registry.js";
|
||||||
|
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
|
import { createOutboundTestPlugin } from "../test-utils/channel-plugins.js";
|
||||||
import {
|
import {
|
||||||
connectOk,
|
connectOk,
|
||||||
getFreePort,
|
getFreePort,
|
||||||
@ -16,6 +21,49 @@ import {
|
|||||||
|
|
||||||
installGatewayTestHooks();
|
installGatewayTestHooks();
|
||||||
|
|
||||||
|
const whatsappOutbound: ChannelOutboundAdapter = {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
sendText: async ({ deps, to, text }) => {
|
||||||
|
if (!deps?.sendWhatsApp) {
|
||||||
|
throw new Error("Missing sendWhatsApp dep");
|
||||||
|
}
|
||||||
|
return { channel: "whatsapp", ...(await deps.sendWhatsApp(to, text, {})) };
|
||||||
|
},
|
||||||
|
sendMedia: async ({ deps, to, text, mediaUrl }) => {
|
||||||
|
if (!deps?.sendWhatsApp) {
|
||||||
|
throw new Error("Missing sendWhatsApp dep");
|
||||||
|
}
|
||||||
|
return { channel: "whatsapp", ...(await deps.sendWhatsApp(to, text, { mediaUrl })) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const whatsappPlugin = createOutboundTestPlugin({
|
||||||
|
id: "whatsapp",
|
||||||
|
outbound: whatsappOutbound,
|
||||||
|
label: "WhatsApp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
|
||||||
|
plugins: [],
|
||||||
|
tools: [],
|
||||||
|
channels,
|
||||||
|
providers: [],
|
||||||
|
gatewayHandlers: {},
|
||||||
|
httpHandlers: [],
|
||||||
|
cliRegistrars: [],
|
||||||
|
services: [],
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const whatsappRegistry = createRegistry([
|
||||||
|
{
|
||||||
|
pluginId: "whatsapp",
|
||||||
|
source: "test",
|
||||||
|
plugin: whatsappPlugin,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const emptyRegistry = createRegistry([]);
|
||||||
|
|
||||||
describe("gateway server misc", () => {
|
describe("gateway server misc", () => {
|
||||||
test("hello-ok advertises the gateway port for canvas host", async () => {
|
test("hello-ok advertises the gateway port for canvas host", async () => {
|
||||||
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
@ -47,31 +95,38 @@ describe("gateway server misc", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("send dedupes by idempotencyKey", { timeout: 60_000 }, async () => {
|
test("send dedupes by idempotencyKey", { timeout: 60_000 }, async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const prevRegistry = getActivePluginRegistry() ?? emptyRegistry;
|
||||||
await connectOk(ws);
|
try {
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
setActivePluginRegistry(whatsappRegistry);
|
||||||
|
expect(getChannelPlugin("whatsapp")).toBeDefined();
|
||||||
|
|
||||||
const idem = "same-key";
|
const idem = "same-key";
|
||||||
const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
|
const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
|
||||||
const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
|
const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
|
||||||
const sendReq = (id: string) =>
|
const sendReq = (id: string) =>
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "req",
|
type: "req",
|
||||||
id,
|
id,
|
||||||
method: "send",
|
method: "send",
|
||||||
params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
|
params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
sendReq("a1");
|
sendReq("a1");
|
||||||
sendReq("a2");
|
sendReq("a2");
|
||||||
|
|
||||||
const res1 = await res1P;
|
const res1 = await res1P;
|
||||||
const res2 = await res2P;
|
const res2 = await res2P;
|
||||||
expect(res1.ok).toBe(true);
|
expect(res1.ok).toBe(true);
|
||||||
expect(res2.ok).toBe(true);
|
expect(res2.ok).toBe(true);
|
||||||
expect(res1.payload).toEqual(res2.payload);
|
expect(res1.payload).toEqual(res2.payload);
|
||||||
ws.close();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
|
} finally {
|
||||||
|
setActivePluginRegistry(prevRegistry);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("refuses to start when port already bound", async () => {
|
test("refuses to start when port already bound", async () => {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||||
|
|
||||||
export type BridgeClientInfo = {
|
export type BridgeClientInfo = {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
@ -91,6 +92,7 @@ export const testState = {
|
|||||||
agentConfig: undefined as Record<string, unknown> | undefined,
|
agentConfig: undefined as Record<string, unknown> | undefined,
|
||||||
agentsConfig: undefined as Record<string, unknown> | undefined,
|
agentsConfig: undefined as Record<string, unknown> | undefined,
|
||||||
bindingsConfig: undefined as Array<Record<string, unknown>> | undefined,
|
bindingsConfig: undefined as Array<Record<string, unknown>> | undefined,
|
||||||
|
channelsConfig: undefined as Record<string, unknown> | undefined,
|
||||||
sessionStorePath: undefined as string | undefined,
|
sessionStorePath: undefined as string | undefined,
|
||||||
sessionConfig: undefined as Record<string, unknown> | undefined,
|
sessionConfig: undefined as Record<string, unknown> | undefined,
|
||||||
allowFrom: undefined as string[] | undefined,
|
allowFrom: undefined as string[] | undefined,
|
||||||
@ -259,49 +261,63 @@ vi.mock("../config/config.js", async () => {
|
|||||||
config: testState.migrationConfig ?? (raw as Record<string, unknown>),
|
config: testState.migrationConfig ?? (raw as Record<string, unknown>),
|
||||||
changes: testState.migrationChanges,
|
changes: testState.migrationChanges,
|
||||||
}),
|
}),
|
||||||
loadConfig: () => ({
|
loadConfig: () => {
|
||||||
agents: (() => {
|
const base = {
|
||||||
const defaults = {
|
agents: (() => {
|
||||||
model: "anthropic/claude-opus-4-5",
|
const defaults = {
|
||||||
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
model: "anthropic/claude-opus-4-5",
|
||||||
...testState.agentConfig,
|
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
||||||
};
|
...testState.agentConfig,
|
||||||
if (testState.agentsConfig) {
|
};
|
||||||
return { ...testState.agentsConfig, defaults };
|
if (testState.agentsConfig) {
|
||||||
}
|
return { ...testState.agentsConfig, defaults };
|
||||||
return { defaults };
|
}
|
||||||
})(),
|
return { defaults };
|
||||||
bindings: testState.bindingsConfig,
|
})(),
|
||||||
channels: {
|
bindings: testState.bindingsConfig,
|
||||||
whatsapp: {
|
channels: (() => {
|
||||||
allowFrom: testState.allowFrom,
|
const baseChannels =
|
||||||
|
testState.channelsConfig && typeof testState.channelsConfig === "object"
|
||||||
|
? { ...testState.channelsConfig }
|
||||||
|
: {};
|
||||||
|
const existing = baseChannels.whatsapp;
|
||||||
|
const mergedWhatsApp: Record<string, unknown> =
|
||||||
|
existing && typeof existing === "object" && !Array.isArray(existing)
|
||||||
|
? { ...existing }
|
||||||
|
: {};
|
||||||
|
if (testState.allowFrom !== undefined) {
|
||||||
|
mergedWhatsApp.allowFrom = testState.allowFrom;
|
||||||
|
}
|
||||||
|
baseChannels.whatsapp = mergedWhatsApp;
|
||||||
|
return baseChannels;
|
||||||
|
})(),
|
||||||
|
session: {
|
||||||
|
mainKey: "main",
|
||||||
|
store: testState.sessionStorePath,
|
||||||
|
...testState.sessionConfig,
|
||||||
},
|
},
|
||||||
},
|
gateway: (() => {
|
||||||
session: {
|
const gateway: Record<string, unknown> = {};
|
||||||
mainKey: "main",
|
if (testState.gatewayBind) gateway.bind = testState.gatewayBind;
|
||||||
store: testState.sessionStorePath,
|
if (testState.gatewayAuth) gateway.auth = testState.gatewayAuth;
|
||||||
...testState.sessionConfig,
|
return Object.keys(gateway).length > 0 ? gateway : undefined;
|
||||||
},
|
})(),
|
||||||
gateway: (() => {
|
canvasHost: (() => {
|
||||||
const gateway: Record<string, unknown> = {};
|
const canvasHost: Record<string, unknown> = {};
|
||||||
if (testState.gatewayBind) gateway.bind = testState.gatewayBind;
|
if (typeof testState.canvasHostPort === "number")
|
||||||
if (testState.gatewayAuth) gateway.auth = testState.gatewayAuth;
|
canvasHost.port = testState.canvasHostPort;
|
||||||
return Object.keys(gateway).length > 0 ? gateway : undefined;
|
return Object.keys(canvasHost).length > 0 ? canvasHost : undefined;
|
||||||
})(),
|
})(),
|
||||||
canvasHost: (() => {
|
hooks: testState.hooksConfig,
|
||||||
const canvasHost: Record<string, unknown> = {};
|
cron: (() => {
|
||||||
if (typeof testState.canvasHostPort === "number")
|
const cron: Record<string, unknown> = {};
|
||||||
canvasHost.port = testState.canvasHostPort;
|
if (typeof testState.cronEnabled === "boolean") cron.enabled = testState.cronEnabled;
|
||||||
return Object.keys(canvasHost).length > 0 ? canvasHost : undefined;
|
if (typeof testState.cronStorePath === "string") cron.store = testState.cronStorePath;
|
||||||
})(),
|
return Object.keys(cron).length > 0 ? cron : undefined;
|
||||||
hooks: testState.hooksConfig,
|
})(),
|
||||||
cron: (() => {
|
} as ReturnType<typeof actual.loadConfig>;
|
||||||
const cron: Record<string, unknown> = {};
|
return applyPluginAutoEnable({ config: base }).config;
|
||||||
if (typeof testState.cronEnabled === "boolean") cron.enabled = testState.cronEnabled;
|
},
|
||||||
if (typeof testState.cronStorePath === "string") cron.store = testState.cronStorePath;
|
|
||||||
return Object.keys(cron).length > 0 ? cron : undefined;
|
|
||||||
})(),
|
|
||||||
}),
|
|
||||||
parseConfigJson5: (raw: string) => {
|
parseConfigJson5: (raw: string) => {
|
||||||
try {
|
try {
|
||||||
return { ok: true, parsed: JSON.parse(raw) as unknown };
|
return { ok: true, parsed: JSON.parse(raw) as unknown };
|
||||||
|
|||||||
@ -99,6 +99,7 @@ export function installGatewayTestHooks() {
|
|||||||
testState.agentConfig = undefined;
|
testState.agentConfig = undefined;
|
||||||
testState.agentsConfig = undefined;
|
testState.agentsConfig = undefined;
|
||||||
testState.bindingsConfig = undefined;
|
testState.bindingsConfig = undefined;
|
||||||
|
testState.channelsConfig = undefined;
|
||||||
testState.allowFrom = undefined;
|
testState.allowFrom = undefined;
|
||||||
testIsNixMode.value = false;
|
testIsNixMode.value = false;
|
||||||
cronIsolatedRun.mockClear();
|
cronIsolatedRun.mockClear();
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import * as tar from "tar";
|
import * as tar from "tar";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
@ -15,22 +15,6 @@ function makeTempDir() {
|
|||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withStateDir<T>(stateDir: string, fn: () => Promise<T>) {
|
|
||||||
const prev = process.env.CLAWDBOT_STATE_DIR;
|
|
||||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
|
||||||
vi.resetModules();
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} finally {
|
|
||||||
if (prev === undefined) {
|
|
||||||
delete process.env.CLAWDBOT_STATE_DIR;
|
|
||||||
} else {
|
|
||||||
process.env.CLAWDBOT_STATE_DIR = prev;
|
|
||||||
}
|
|
||||||
vi.resetModules();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
try {
|
try {
|
||||||
@ -72,10 +56,9 @@ describe("installHooksFromArchive", () => {
|
|||||||
const buffer = await zip.generateAsync({ type: "nodebuffer" });
|
const buffer = await zip.generateAsync({ type: "nodebuffer" });
|
||||||
fs.writeFileSync(archivePath, buffer);
|
fs.writeFileSync(archivePath, buffer);
|
||||||
|
|
||||||
const result = await withStateDir(stateDir, async () => {
|
const hooksDir = path.join(stateDir, "hooks");
|
||||||
const { installHooksFromArchive } = await import("./install.js");
|
const { installHooksFromArchive } = await import("./install.js");
|
||||||
return await installHooksFromArchive({ archivePath });
|
const result = await installHooksFromArchive({ archivePath, hooksDir });
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
@ -121,10 +104,9 @@ describe("installHooksFromArchive", () => {
|
|||||||
);
|
);
|
||||||
await tar.c({ cwd: workDir, file: archivePath }, ["package"]);
|
await tar.c({ cwd: workDir, file: archivePath }, ["package"]);
|
||||||
|
|
||||||
const result = await withStateDir(stateDir, async () => {
|
const hooksDir = path.join(stateDir, "hooks");
|
||||||
const { installHooksFromArchive } = await import("./install.js");
|
const { installHooksFromArchive } = await import("./install.js");
|
||||||
return await installHooksFromArchive({ archivePath });
|
const result = await installHooksFromArchive({ archivePath, hooksDir });
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
@ -155,10 +137,9 @@ describe("installHooksFromPath", () => {
|
|||||||
);
|
);
|
||||||
fs.writeFileSync(path.join(hookDir, "handler.ts"), "export default async () => {};\n");
|
fs.writeFileSync(path.join(hookDir, "handler.ts"), "export default async () => {};\n");
|
||||||
|
|
||||||
const result = await withStateDir(stateDir, async () => {
|
const hooksDir = path.join(stateDir, "hooks");
|
||||||
const { installHooksFromPath } = await import("./install.js");
|
const { installHooksFromPath } = await import("./install.js");
|
||||||
return await installHooksFromPath({ path: hookDir });
|
const result = await installHooksFromPath({ path: hookDir, hooksDir });
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
|
|||||||
@ -11,7 +11,11 @@ import {
|
|||||||
} from "../../auto-reply/reply/response-prefix-template.js";
|
} from "../../auto-reply/reply/response-prefix-template.js";
|
||||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||||
import { hasControlCommand } from "../../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../../auto-reply/command-detection.js";
|
||||||
import { formatInboundEnvelope, formatInboundFromLabel } from "../../auto-reply/envelope.js";
|
import {
|
||||||
|
formatInboundEnvelope,
|
||||||
|
formatInboundFromLabel,
|
||||||
|
resolveEnvelopeFormatOptions,
|
||||||
|
} from "../../auto-reply/envelope.js";
|
||||||
import {
|
import {
|
||||||
createInboundDebouncer,
|
createInboundDebouncer,
|
||||||
resolveInboundDebounceMs,
|
resolveInboundDebounceMs,
|
||||||
@ -33,6 +37,7 @@ import {
|
|||||||
resolveChannelGroupRequireMention,
|
resolveChannelGroupRequireMention,
|
||||||
} from "../../config/group-policy.js";
|
} from "../../config/group-policy.js";
|
||||||
import {
|
import {
|
||||||
|
readSessionUpdatedAt,
|
||||||
recordSessionMetaFromInbound,
|
recordSessionMetaFromInbound,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
updateLastRoute,
|
updateLastRoute,
|
||||||
@ -401,6 +406,14 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
directLabel: senderNormalized,
|
directLabel: senderNormalized,
|
||||||
directId: sender,
|
directId: sender,
|
||||||
});
|
});
|
||||||
|
const storePath = resolveStorePath(cfg.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||||
|
const previousTimestamp = readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
const body = formatInboundEnvelope({
|
const body = formatInboundEnvelope({
|
||||||
channel: "iMessage",
|
channel: "iMessage",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
@ -408,6 +421,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
body: bodyText,
|
body: bodyText,
|
||||||
chatType: isGroup ? "group" : "direct",
|
chatType: isGroup ? "group" : "direct",
|
||||||
sender: { name: senderNormalized, id: sender },
|
sender: { name: senderNormalized, id: sender },
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
});
|
});
|
||||||
let combinedBody = body;
|
let combinedBody = body;
|
||||||
if (isGroup && historyKey && historyLimit > 0) {
|
if (isGroup && historyKey && historyLimit > 0) {
|
||||||
@ -424,6 +439,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
body: `${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
body: `${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
||||||
chatType: "group",
|
chatType: "group",
|
||||||
senderLabel: entry.sender,
|
senderLabel: entry.sender,
|
||||||
|
envelope: envelopeOptions,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -461,9 +477,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
OriginatingTo: imessageTo,
|
OriginatingTo: imessageTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = resolveStorePath(cfg.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
});
|
|
||||||
void recordSessionMetaFromInbound({
|
void recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import * as logging from "../logging.js";
|
||||||
|
|
||||||
const createService = vi.fn();
|
const createService = vi.fn();
|
||||||
const shutdown = vi.fn();
|
const shutdown = vi.fn();
|
||||||
@ -23,14 +25,6 @@ vi.mock("../logger.js", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../logging.js", async () => {
|
|
||||||
const actual = await vi.importActual<typeof import("../logging.js")>("../logging.js");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
getLogger: () => ({ info: (...args: unknown[]) => getLoggerInfo(...args) }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@homebridge/ciao", () => {
|
vi.mock("@homebridge/ciao", () => {
|
||||||
return {
|
return {
|
||||||
Protocol: { TCP: "tcp" },
|
Protocol: { TCP: "tcp" },
|
||||||
@ -60,6 +54,12 @@ describe("gateway bonjour advertiser", () => {
|
|||||||
|
|
||||||
const prevEnv = { ...process.env };
|
const prevEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(logging, "getLogger").mockReturnValue({
|
||||||
|
info: (...args: unknown[]) => getLoggerInfo(...args),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const key of Object.keys(process.env)) {
|
for (const key of Object.keys(process.env)) {
|
||||||
if (!(key in prevEnv)) delete process.env[key];
|
if (!(key in prevEnv)) delete process.env[key];
|
||||||
|
|||||||
@ -90,7 +90,7 @@ describe("exec approvals command resolution", () => {
|
|||||||
const script = path.join(cwd, "bin", "tool");
|
const script = path.join(cwd, "bin", "tool");
|
||||||
fs.mkdirSync(path.dirname(script), { recursive: true });
|
fs.mkdirSync(path.dirname(script), { recursive: true });
|
||||||
fs.writeFileSync(script, "");
|
fs.writeFileSync(script, "");
|
||||||
const res = resolveCommandResolution("\"./bin/tool\" --version", cwd, undefined);
|
const res = resolveCommandResolution('"./bin/tool" --version', cwd, undefined);
|
||||||
expect(res?.resolvedPath).toBe(script);
|
expect(res?.resolvedPath).toBe(script);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -86,7 +86,12 @@ export async function requestExecHostViaSocket(params: {
|
|||||||
idx = buffer.indexOf("\n");
|
idx = buffer.indexOf("\n");
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(line) as { type?: string; ok?: boolean; payload?: unknown; error?: unknown };
|
const msg = JSON.parse(line) as {
|
||||||
|
type?: string;
|
||||||
|
ok?: boolean;
|
||||||
|
payload?: unknown;
|
||||||
|
error?: unknown;
|
||||||
|
};
|
||||||
if (msg?.type === "exec-res") {
|
if (msg?.type === "exec-res") {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (msg.ok === true && msg.payload) {
|
if (msg.ok === true && msg.payload) {
|
||||||
|
|||||||
@ -1,4 +1,31 @@
|
|||||||
export * from "./logging/console.js";
|
export {
|
||||||
|
enableConsoleCapture,
|
||||||
|
getConsoleSettings,
|
||||||
|
getResolvedConsoleSettings,
|
||||||
|
routeLogsToStderr,
|
||||||
|
setConsoleSubsystemFilter,
|
||||||
|
setConsoleTimestampPrefix,
|
||||||
|
shouldLogSubsystemToConsole,
|
||||||
|
} from "./logging/console.js";
|
||||||
|
export type { ConsoleLoggerSettings, ConsoleStyle } from "./logging/console.js";
|
||||||
export type { LogLevel } from "./logging/levels.js";
|
export type { LogLevel } from "./logging/levels.js";
|
||||||
export * from "./logging/logger.js";
|
export { ALLOWED_LOG_LEVELS, levelToMinLevel, normalizeLogLevel } from "./logging/levels.js";
|
||||||
export * from "./logging/subsystem.js";
|
export {
|
||||||
|
DEFAULT_LOG_DIR,
|
||||||
|
DEFAULT_LOG_FILE,
|
||||||
|
getChildLogger,
|
||||||
|
getLogger,
|
||||||
|
getResolvedLoggerSettings,
|
||||||
|
isFileLogLevelEnabled,
|
||||||
|
resetLogger,
|
||||||
|
setLoggerOverride,
|
||||||
|
toPinoLikeLogger,
|
||||||
|
} from "./logging/logger.js";
|
||||||
|
export type { LoggerResolvedSettings, LoggerSettings, PinoLikeLogger } from "./logging/logger.js";
|
||||||
|
export {
|
||||||
|
createSubsystemLogger,
|
||||||
|
createSubsystemRuntime,
|
||||||
|
runtimeForLogger,
|
||||||
|
stripRedundantSubsystemPrefixForConsole,
|
||||||
|
} from "./logging/subsystem.js";
|
||||||
|
export type { SubsystemLogger } from "./logging/subsystem.js";
|
||||||
|
|||||||
@ -1,21 +1,60 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { isPathWithinBase } from "../../test/helpers/paths.js";
|
import { isPathWithinBase } from "../../test/helpers/paths.js";
|
||||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
|
||||||
|
|
||||||
describe("media store", () => {
|
describe("media store", () => {
|
||||||
|
let store: typeof import("./store.js");
|
||||||
|
let home = "";
|
||||||
|
const envSnapshot: Record<string, string | undefined> = {};
|
||||||
|
|
||||||
|
const snapshotEnv = () => {
|
||||||
|
for (const key of ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "CLAWDBOT_STATE_DIR"]) {
|
||||||
|
envSnapshot[key] = process.env[key];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreEnv = () => {
|
||||||
|
for (const [key, value] of Object.entries(envSnapshot)) {
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
snapshotEnv();
|
||||||
|
home = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-home-"));
|
||||||
|
process.env.HOME = home;
|
||||||
|
process.env.USERPROFILE = home;
|
||||||
|
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const match = home.match(/^([A-Za-z]:)(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
process.env.HOMEDRIVE = match[1];
|
||||||
|
process.env.HOMEPATH = match[2] || "\\";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fs.mkdir(path.join(home, ".clawdbot"), { recursive: true });
|
||||||
|
store = await import("./store.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
restoreEnv();
|
||||||
|
try {
|
||||||
|
await fs.rm(home, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup failures in tests
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function withTempStore<T>(
|
async function withTempStore<T>(
|
||||||
fn: (store: typeof import("./store.js"), home: string) => Promise<T>,
|
fn: (store: typeof import("./store.js"), home: string) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return await withTempHome(async (home) => {
|
return await fn(store, home);
|
||||||
vi.resetModules();
|
|
||||||
const store = await import("./store.js");
|
|
||||||
return await fn(store, home);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it("creates and returns media directory", async () => {
|
it("creates and returns media directory", async () => {
|
||||||
|
|||||||
@ -4,29 +4,30 @@ import fs from "node:fs/promises";
|
|||||||
import { request } from "node:https";
|
import { request } from "node:https";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pipeline } from "node:stream/promises";
|
import { pipeline } from "node:stream/promises";
|
||||||
import { CONFIG_DIR } from "../utils.js";
|
import { resolveConfigDir } from "../utils.js";
|
||||||
import { detectMime, extensionForMime } from "./mime.js";
|
import { detectMime, extensionForMime } from "./mime.js";
|
||||||
|
|
||||||
const MEDIA_DIR = path.join(CONFIG_DIR, "media");
|
const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
|
||||||
const MAX_BYTES = 5 * 1024 * 1024; // 5MB default
|
const MAX_BYTES = 5 * 1024 * 1024; // 5MB default
|
||||||
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
||||||
|
|
||||||
export function getMediaDir() {
|
export function getMediaDir() {
|
||||||
return MEDIA_DIR;
|
return resolveMediaDir();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureMediaDir() {
|
export async function ensureMediaDir() {
|
||||||
await fs.mkdir(MEDIA_DIR, { recursive: true });
|
const mediaDir = resolveMediaDir();
|
||||||
return MEDIA_DIR;
|
await fs.mkdir(mediaDir, { recursive: true });
|
||||||
|
return mediaDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
|
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
|
||||||
await ensureMediaDir();
|
const mediaDir = await ensureMediaDir();
|
||||||
const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
|
const entries = await fs.readdir(mediaDir).catch(() => []);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
entries.map(async (file) => {
|
entries.map(async (file) => {
|
||||||
const full = path.join(MEDIA_DIR, file);
|
const full = path.join(mediaDir, file);
|
||||||
const stat = await fs.stat(full).catch(() => null);
|
const stat = await fs.stat(full).catch(() => null);
|
||||||
if (!stat) return;
|
if (!stat) return;
|
||||||
if (now - stat.mtimeMs > ttlMs) {
|
if (now - stat.mtimeMs > ttlMs) {
|
||||||
@ -110,7 +111,8 @@ export async function saveMediaSource(
|
|||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
subdir = "",
|
subdir = "",
|
||||||
): Promise<SavedMedia> {
|
): Promise<SavedMedia> {
|
||||||
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
|
const baseDir = resolveMediaDir();
|
||||||
|
const dir = subdir ? path.join(baseDir, subdir) : baseDir;
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
await cleanOldMedia();
|
await cleanOldMedia();
|
||||||
const baseId = crypto.randomUUID();
|
const baseId = crypto.randomUUID();
|
||||||
@ -154,7 +156,7 @@ export async function saveMediaBuffer(
|
|||||||
if (buffer.byteLength > maxBytes) {
|
if (buffer.byteLength > maxBytes) {
|
||||||
throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`);
|
throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`);
|
||||||
}
|
}
|
||||||
const dir = path.join(MEDIA_DIR, subdir);
|
const dir = path.join(resolveMediaDir(), subdir);
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
const baseId = crypto.randomUUID();
|
const baseId = crypto.randomUUID();
|
||||||
const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined);
|
const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined);
|
||||||
|
|||||||
@ -183,7 +183,9 @@ async function fetchGeminiBatchStatus(params: {
|
|||||||
batchName: string;
|
batchName: string;
|
||||||
}): Promise<GeminiBatchStatus> {
|
}): Promise<GeminiBatchStatus> {
|
||||||
const baseUrl = getGeminiBaseUrl(params.gemini);
|
const baseUrl = getGeminiBaseUrl(params.gemini);
|
||||||
const name = params.batchName.startsWith("batches/") ? params.batchName : `batches/${params.batchName}`;
|
const name = params.batchName.startsWith("batches/")
|
||||||
|
? params.batchName
|
||||||
|
: `batches/${params.batchName}`;
|
||||||
const statusUrl = `${baseUrl}/${name}`;
|
const statusUrl = `${baseUrl}/${name}`;
|
||||||
debugLog("memory embeddings: gemini batch status", { statusUrl });
|
debugLog("memory embeddings: gemini batch status", { statusUrl });
|
||||||
const res = await fetch(statusUrl, {
|
const res = await fetch(statusUrl, {
|
||||||
@ -328,7 +330,11 @@ export async function runGeminiEmbeddingBatches(params: {
|
|||||||
requests: group.length,
|
requests: group.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!params.wait && batchInfo.state && !["SUCCEEDED", "COMPLETED", "DONE"].includes(batchInfo.state)) {
|
if (
|
||||||
|
!params.wait &&
|
||||||
|
batchInfo.state &&
|
||||||
|
!["SUCCEEDED", "COMPLETED", "DONE"].includes(batchInfo.state)
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`gemini batch ${batchName} submitted; enable remote.batch.wait to await completion`,
|
`gemini batch ${batchName} submitted; enable remote.batch.wait to await completion`,
|
||||||
);
|
);
|
||||||
@ -376,8 +382,7 @@ export async function runGeminiEmbeddingBatches(params: {
|
|||||||
errors.push(`${customId}: ${line.response.error.message}`);
|
errors.push(`${customId}: ${line.response.error.message}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const embedding =
|
const embedding = line.embedding?.values ?? line.response?.embedding?.values ?? [];
|
||||||
line.embedding?.values ?? line.response?.embedding?.values ?? [];
|
|
||||||
if (embedding.length === 0) {
|
if (embedding.length === 0) {
|
||||||
errors.push(`${customId}: empty embedding`);
|
errors.push(`${customId}: empty embedding`);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -3,14 +3,8 @@ import fsSync from "node:fs";
|
|||||||
import type { Llama, LlamaEmbeddingContext, LlamaModel } from "node-llama-cpp";
|
import type { Llama, LlamaEmbeddingContext, LlamaModel } from "node-llama-cpp";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
import {
|
import { createGeminiEmbeddingProvider, type GeminiEmbeddingClient } from "./embeddings-gemini.js";
|
||||||
createGeminiEmbeddingProvider,
|
import { createOpenAiEmbeddingProvider, type OpenAiEmbeddingClient } from "./embeddings-openai.js";
|
||||||
type GeminiEmbeddingClient,
|
|
||||||
} from "./embeddings-gemini.js";
|
|
||||||
import {
|
|
||||||
createOpenAiEmbeddingProvider,
|
|
||||||
type OpenAiEmbeddingClient,
|
|
||||||
} from "./embeddings-openai.js";
|
|
||||||
import { importNodeLlamaCpp } from "./node-llama.js";
|
import { importNodeLlamaCpp } from "./node-llama.js";
|
||||||
|
|
||||||
export type { GeminiEmbeddingClient } from "./embeddings-gemini.js";
|
export type { GeminiEmbeddingClient } from "./embeddings-gemini.js";
|
||||||
@ -68,7 +62,6 @@ function isMissingApiKeyError(err: unknown): boolean {
|
|||||||
return message.includes("No API key found for provider");
|
return message.includes("No API key found for provider");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function createLocalEmbeddingProvider(
|
async function createLocalEmbeddingProvider(
|
||||||
options: EmbeddingProviderOptions,
|
options: EmbeddingProviderOptions,
|
||||||
): Promise<EmbeddingProvider> {
|
): Promise<EmbeddingProvider> {
|
||||||
@ -188,9 +181,7 @@ export async function createEmbeddingProvider(
|
|||||||
fallbackReason: reason,
|
fallbackReason: reason,
|
||||||
};
|
};
|
||||||
} catch (fallbackErr) {
|
} catch (fallbackErr) {
|
||||||
throw new Error(
|
throw new Error(`${reason}\n\nFallback to ${fallback} failed: ${formatError(fallbackErr)}`);
|
||||||
`${reason}\n\nFallback to ${fallback} failed: ${formatError(fallbackErr)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(reason);
|
throw new Error(reason);
|
||||||
|
|||||||
@ -697,9 +697,7 @@ export class MemoryIndexManager {
|
|||||||
|
|
||||||
private async removeIndexFiles(basePath: string): Promise<void> {
|
private async removeIndexFiles(basePath: string): Promise<void> {
|
||||||
const suffixes = ["", "-wal", "-shm"];
|
const suffixes = ["", "-wal", "-shm"];
|
||||||
await Promise.all(
|
await Promise.all(suffixes.map((suffix) => fs.rm(`${basePath}${suffix}`, { force: true })));
|
||||||
suffixes.map((suffix) => fs.rm(`${basePath}${suffix}`, { force: true })),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureSchema() {
|
private ensureSchema() {
|
||||||
@ -1064,8 +1062,8 @@ export class MemoryIndexManager {
|
|||||||
const batch = this.settings.remote?.batch;
|
const batch = this.settings.remote?.batch;
|
||||||
const enabled = Boolean(
|
const enabled = Boolean(
|
||||||
batch?.enabled &&
|
batch?.enabled &&
|
||||||
((this.openAi && this.provider.id === "openai") ||
|
((this.openAi && this.provider.id === "openai") ||
|
||||||
(this.gemini && this.provider.id === "gemini")),
|
(this.gemini && this.provider.id === "gemini")),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
|
|||||||
@ -63,7 +63,11 @@ describe("memory vector dedupe", () => {
|
|||||||
if (!result.manager) throw new Error("manager missing");
|
if (!result.manager) throw new Error("manager missing");
|
||||||
manager = result.manager;
|
manager = result.manager;
|
||||||
|
|
||||||
const db = (manager as unknown as { db: { exec: (sql: string) => void; prepare: (sql: string) => unknown } }).db;
|
const db = (
|
||||||
|
manager as unknown as {
|
||||||
|
db: { exec: (sql: string) => void; prepare: (sql: string) => unknown };
|
||||||
|
}
|
||||||
|
).db;
|
||||||
db.exec("CREATE TABLE IF NOT EXISTS chunks_vec (id TEXT PRIMARY KEY, embedding BLOB)");
|
db.exec("CREATE TABLE IF NOT EXISTS chunks_vec (id TEXT PRIMARY KEY, embedding BLOB)");
|
||||||
|
|
||||||
const sqlSeen: string[] = [];
|
const sqlSeen: string[] = [];
|
||||||
@ -75,16 +79,20 @@ describe("memory vector dedupe", () => {
|
|||||||
return originalPrepare(sql);
|
return originalPrepare(sql);
|
||||||
};
|
};
|
||||||
|
|
||||||
(manager as unknown as { ensureVectorReady: (dims?: number) => Promise<boolean> }).ensureVectorReady =
|
(
|
||||||
async () => true;
|
manager as unknown as { ensureVectorReady: (dims?: number) => Promise<boolean> }
|
||||||
|
).ensureVectorReady = async () => true;
|
||||||
|
|
||||||
const entry = await buildFileEntry(path.join(workspaceDir, "MEMORY.md"), workspaceDir);
|
const entry = await buildFileEntry(path.join(workspaceDir, "MEMORY.md"), workspaceDir);
|
||||||
await (manager as unknown as { indexFile: (entry: unknown, options: { source: "memory" }) => Promise<void> }).indexFile(
|
await (
|
||||||
entry,
|
manager as unknown as {
|
||||||
{ source: "memory" },
|
indexFile: (entry: unknown, options: { source: "memory" }) => Promise<void>;
|
||||||
);
|
}
|
||||||
|
).indexFile(entry, { source: "memory" });
|
||||||
|
|
||||||
const deleteIndex = sqlSeen.findIndex((sql) => sql.includes("DELETE FROM chunks_vec WHERE id = ?"));
|
const deleteIndex = sqlSeen.findIndex((sql) =>
|
||||||
|
sql.includes("DELETE FROM chunks_vec WHERE id = ?"),
|
||||||
|
);
|
||||||
const insertIndex = sqlSeen.findIndex((sql) => sql.includes("INSERT INTO chunks_vec"));
|
const insertIndex = sqlSeen.findIndex((sql) => sql.includes("INSERT INTO chunks_vec"));
|
||||||
expect(deleteIndex).toBeGreaterThan(-1);
|
expect(deleteIndex).toBeGreaterThan(-1);
|
||||||
expect(insertIndex).toBeGreaterThan(-1);
|
expect(insertIndex).toBeGreaterThan(-1);
|
||||||
|
|||||||
@ -576,7 +576,8 @@ async function handleInvoke(
|
|||||||
const skillAllow =
|
const skillAllow =
|
||||||
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
|
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
|
||||||
|
|
||||||
const useMacAppExec = process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed);
|
const useMacAppExec =
|
||||||
|
process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed);
|
||||||
if (useMacAppExec) {
|
if (useMacAppExec) {
|
||||||
const execRequest: ExecHostRequest = {
|
const execRequest: ExecHostRequest = {
|
||||||
command: argv,
|
command: argv,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
@ -77,22 +77,6 @@ function packToArchive({
|
|||||||
return dest;
|
return dest;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withStateDir<T>(stateDir: string, fn: () => Promise<T>) {
|
|
||||||
const prev = process.env.CLAWDBOT_STATE_DIR;
|
|
||||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
|
||||||
vi.resetModules();
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} finally {
|
|
||||||
if (prev === undefined) {
|
|
||||||
delete process.env.CLAWDBOT_STATE_DIR;
|
|
||||||
} else {
|
|
||||||
process.env.CLAWDBOT_STATE_DIR = prev;
|
|
||||||
}
|
|
||||||
vi.resetModules();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
try {
|
try {
|
||||||
@ -126,10 +110,9 @@ describe("installPluginFromArchive", () => {
|
|||||||
outName: "plugin.tgz",
|
outName: "plugin.tgz",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await withStateDir(stateDir, async () => {
|
const extensionsDir = path.join(stateDir, "extensions");
|
||||||
const { installPluginFromArchive } = await import("./install.js");
|
const { installPluginFromArchive } = await import("./install.js");
|
||||||
return await installPluginFromArchive({ archivePath });
|
const result = await installPluginFromArchive({ archivePath, extensionsDir });
|
||||||
});
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
expect(result.pluginId).toBe("voice-call");
|
expect(result.pluginId).toBe("voice-call");
|
||||||
@ -160,12 +143,10 @@ describe("installPluginFromArchive", () => {
|
|||||||
outName: "plugin.tgz",
|
outName: "plugin.tgz",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { first, second } = await withStateDir(stateDir, async () => {
|
const extensionsDir = path.join(stateDir, "extensions");
|
||||||
const { installPluginFromArchive } = await import("./install.js");
|
const { installPluginFromArchive } = await import("./install.js");
|
||||||
const first = await installPluginFromArchive({ archivePath });
|
const first = await installPluginFromArchive({ archivePath, extensionsDir });
|
||||||
const second = await installPluginFromArchive({ archivePath });
|
const second = await installPluginFromArchive({ archivePath, extensionsDir });
|
||||||
return { first, second };
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(first.ok).toBe(true);
|
expect(first.ok).toBe(true);
|
||||||
expect(second.ok).toBe(false);
|
expect(second.ok).toBe(false);
|
||||||
@ -191,10 +172,9 @@ describe("installPluginFromArchive", () => {
|
|||||||
const buffer = await zip.generateAsync({ type: "nodebuffer" });
|
const buffer = await zip.generateAsync({ type: "nodebuffer" });
|
||||||
fs.writeFileSync(archivePath, buffer);
|
fs.writeFileSync(archivePath, buffer);
|
||||||
|
|
||||||
const result = await withStateDir(stateDir, async () => {
|
const extensionsDir = path.join(stateDir, "extensions");
|
||||||
const { installPluginFromArchive } = await import("./install.js");
|
const { installPluginFromArchive } = await import("./install.js");
|
||||||
return await installPluginFromArchive({ archivePath });
|
const result = await installPluginFromArchive({ archivePath, extensionsDir });
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
@ -243,18 +223,23 @@ describe("installPluginFromArchive", () => {
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const result = await withStateDir(stateDir, async () => {
|
const extensionsDir = path.join(stateDir, "extensions");
|
||||||
const { installPluginFromArchive } = await import("./install.js");
|
const { installPluginFromArchive } = await import("./install.js");
|
||||||
const first = await installPluginFromArchive({ archivePath: archiveV1 });
|
const first = await installPluginFromArchive({
|
||||||
const second = await installPluginFromArchive({ archivePath: archiveV2, mode: "update" });
|
archivePath: archiveV1,
|
||||||
return { first, second };
|
extensionsDir,
|
||||||
|
});
|
||||||
|
const second = await installPluginFromArchive({
|
||||||
|
archivePath: archiveV2,
|
||||||
|
extensionsDir,
|
||||||
|
mode: "update",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.first.ok).toBe(true);
|
expect(first.ok).toBe(true);
|
||||||
expect(result.second.ok).toBe(true);
|
expect(second.ok).toBe(true);
|
||||||
if (!result.second.ok) return;
|
if (!second.ok) return;
|
||||||
const manifest = JSON.parse(
|
const manifest = JSON.parse(
|
||||||
fs.readFileSync(path.join(result.second.targetDir, "package.json"), "utf-8"),
|
fs.readFileSync(path.join(second.targetDir, "package.json"), "utf-8"),
|
||||||
) as { version?: string };
|
) as { version?: string };
|
||||||
expect(manifest.version).toBe("0.0.2");
|
expect(manifest.version).toBe("0.0.2");
|
||||||
});
|
});
|
||||||
@ -276,10 +261,9 @@ describe("installPluginFromArchive", () => {
|
|||||||
outName: "bad.tgz",
|
outName: "bad.tgz",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await withStateDir(stateDir, async () => {
|
const extensionsDir = path.join(stateDir, "extensions");
|
||||||
const { installPluginFromArchive } = await import("./install.js");
|
const { installPluginFromArchive } = await import("./install.js");
|
||||||
return await installPluginFromArchive({ archivePath });
|
const result = await installPluginFromArchive({ archivePath, extensionsDir });
|
||||||
});
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (result.ok) return;
|
if (result.ok) return;
|
||||||
expect(result.error).toContain("clawdbot.extensions");
|
expect(result.error).toContain("clawdbot.extensions");
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { initializeGlobalHookRunner } from "./hook-runner-global.js";
|
|||||||
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
||||||
import { createPluginRuntime } from "./runtime/index.js";
|
import { createPluginRuntime } from "./runtime/index.js";
|
||||||
import { setActivePluginRegistry } from "./runtime.js";
|
import { setActivePluginRegistry } from "./runtime.js";
|
||||||
|
import { defaultSlotIdForKey } from "./slots.js";
|
||||||
import type {
|
import type {
|
||||||
ClawdbotPluginConfigSchema,
|
ClawdbotPluginConfigSchema,
|
||||||
ClawdbotPluginDefinition,
|
ClawdbotPluginDefinition,
|
||||||
@ -92,7 +93,7 @@ const normalizePluginsConfig = (config?: ClawdbotConfig["plugins"]): NormalizedP
|
|||||||
deny: normalizeList(config?.deny),
|
deny: normalizeList(config?.deny),
|
||||||
loadPaths: normalizeList(config?.load?.paths),
|
loadPaths: normalizeList(config?.load?.paths),
|
||||||
slots: {
|
slots: {
|
||||||
memory: memorySlot ?? "memory-core",
|
memory: memorySlot ?? defaultSlotIdForKey("memory"),
|
||||||
},
|
},
|
||||||
entries: normalizePluginEntries(config?.entries),
|
entries: normalizePluginEntries(config?.entries),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,7 +11,11 @@ import {
|
|||||||
createInboundDebouncer,
|
createInboundDebouncer,
|
||||||
resolveInboundDebounceMs,
|
resolveInboundDebounceMs,
|
||||||
} from "../../auto-reply/inbound-debounce.js";
|
} from "../../auto-reply/inbound-debounce.js";
|
||||||
import { formatAgentEnvelope } from "../../auto-reply/envelope.js";
|
import {
|
||||||
|
formatAgentEnvelope,
|
||||||
|
formatInboundEnvelope,
|
||||||
|
resolveEnvelopeFormatOptions,
|
||||||
|
} from "../../auto-reply/envelope.js";
|
||||||
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
|
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
|
||||||
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
|
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
|
||||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
||||||
@ -33,12 +37,16 @@ import {
|
|||||||
import { resolveStateDir } from "../../config/paths.js";
|
import { resolveStateDir } from "../../config/paths.js";
|
||||||
import { loadConfig, writeConfigFile } from "../../config/config.js";
|
import { loadConfig, writeConfigFile } from "../../config/config.js";
|
||||||
import {
|
import {
|
||||||
|
readSessionUpdatedAt,
|
||||||
recordSessionMetaFromInbound,
|
recordSessionMetaFromInbound,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
updateLastRoute,
|
updateLastRoute,
|
||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
import { auditDiscordChannelPermissions } from "../../discord/audit.js";
|
import { auditDiscordChannelPermissions } from "../../discord/audit.js";
|
||||||
import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "../../discord/directory-live.js";
|
import {
|
||||||
|
listDiscordDirectoryGroupsLive,
|
||||||
|
listDiscordDirectoryPeersLive,
|
||||||
|
} from "../../discord/directory-live.js";
|
||||||
import { monitorDiscordProvider } from "../../discord/monitor.js";
|
import { monitorDiscordProvider } from "../../discord/monitor.js";
|
||||||
import { probeDiscord } from "../../discord/probe.js";
|
import { probeDiscord } from "../../discord/probe.js";
|
||||||
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
|
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
|
||||||
@ -68,7 +76,10 @@ import { monitorSignalProvider } from "../../signal/index.js";
|
|||||||
import { probeSignal } from "../../signal/probe.js";
|
import { probeSignal } from "../../signal/probe.js";
|
||||||
import { sendMessageSignal } from "../../signal/send.js";
|
import { sendMessageSignal } from "../../signal/send.js";
|
||||||
import { monitorSlackProvider } from "../../slack/index.js";
|
import { monitorSlackProvider } from "../../slack/index.js";
|
||||||
import { listSlackDirectoryGroupsLive, listSlackDirectoryPeersLive } from "../../slack/directory-live.js";
|
import {
|
||||||
|
listSlackDirectoryGroupsLive,
|
||||||
|
listSlackDirectoryPeersLive,
|
||||||
|
} from "../../slack/directory-live.js";
|
||||||
import { probeSlack } from "../../slack/probe.js";
|
import { probeSlack } from "../../slack/probe.js";
|
||||||
import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js";
|
import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js";
|
||||||
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
|
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
|
||||||
@ -137,12 +148,12 @@ export function createPluginRuntime(): PluginRuntime {
|
|||||||
registerMemoryCli,
|
registerMemoryCli,
|
||||||
},
|
},
|
||||||
channel: {
|
channel: {
|
||||||
text: {
|
text: {
|
||||||
chunkMarkdownText,
|
chunkMarkdownText,
|
||||||
chunkText,
|
chunkText,
|
||||||
resolveTextChunkLimit,
|
resolveTextChunkLimit,
|
||||||
hasControlCommand,
|
hasControlCommand,
|
||||||
},
|
},
|
||||||
reply: {
|
reply: {
|
||||||
dispatchReplyWithBufferedBlockDispatcher,
|
dispatchReplyWithBufferedBlockDispatcher,
|
||||||
createReplyDispatcherWithTyping,
|
createReplyDispatcherWithTyping,
|
||||||
@ -151,6 +162,8 @@ export function createPluginRuntime(): PluginRuntime {
|
|||||||
dispatchReplyFromConfig,
|
dispatchReplyFromConfig,
|
||||||
finalizeInboundContext,
|
finalizeInboundContext,
|
||||||
formatAgentEnvelope,
|
formatAgentEnvelope,
|
||||||
|
formatInboundEnvelope,
|
||||||
|
resolveEnvelopeFormatOptions,
|
||||||
},
|
},
|
||||||
routing: {
|
routing: {
|
||||||
resolveAgentRoute,
|
resolveAgentRoute,
|
||||||
@ -166,6 +179,7 @@ export function createPluginRuntime(): PluginRuntime {
|
|||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
|
readSessionUpdatedAt,
|
||||||
recordSessionMetaFromInbound,
|
recordSessionMetaFromInbound,
|
||||||
updateLastRoute,
|
updateLastRoute,
|
||||||
},
|
},
|
||||||
@ -181,12 +195,12 @@ export function createPluginRuntime(): PluginRuntime {
|
|||||||
createInboundDebouncer,
|
createInboundDebouncer,
|
||||||
resolveInboundDebounceMs,
|
resolveInboundDebounceMs,
|
||||||
},
|
},
|
||||||
commands: {
|
commands: {
|
||||||
resolveCommandAuthorizedFromAuthorizers,
|
resolveCommandAuthorizedFromAuthorizers,
|
||||||
isControlCommandMessage,
|
isControlCommandMessage,
|
||||||
shouldComputeCommandAuthorized,
|
shouldComputeCommandAuthorized,
|
||||||
shouldHandleTextCommands,
|
shouldHandleTextCommands,
|
||||||
},
|
},
|
||||||
discord: {
|
discord: {
|
||||||
messageActions: discordMessageActions,
|
messageActions: discordMessageActions,
|
||||||
auditChannelPermissions: auditDiscordChannelPermissions,
|
auditChannelPermissions: auditDiscordChannelPermissions,
|
||||||
|
|||||||
@ -44,10 +44,14 @@ type DispatchReplyFromConfig =
|
|||||||
type FinalizeInboundContext =
|
type FinalizeInboundContext =
|
||||||
typeof import("../../auto-reply/reply/inbound-context.js").finalizeInboundContext;
|
typeof import("../../auto-reply/reply/inbound-context.js").finalizeInboundContext;
|
||||||
type FormatAgentEnvelope = typeof import("../../auto-reply/envelope.js").formatAgentEnvelope;
|
type FormatAgentEnvelope = typeof import("../../auto-reply/envelope.js").formatAgentEnvelope;
|
||||||
|
type FormatInboundEnvelope = typeof import("../../auto-reply/envelope.js").formatInboundEnvelope;
|
||||||
|
type ResolveEnvelopeFormatOptions =
|
||||||
|
typeof import("../../auto-reply/envelope.js").resolveEnvelopeFormatOptions;
|
||||||
type ResolveStateDir = typeof import("../../config/paths.js").resolveStateDir;
|
type ResolveStateDir = typeof import("../../config/paths.js").resolveStateDir;
|
||||||
type RecordSessionMetaFromInbound =
|
type RecordSessionMetaFromInbound =
|
||||||
typeof import("../../config/sessions.js").recordSessionMetaFromInbound;
|
typeof import("../../config/sessions.js").recordSessionMetaFromInbound;
|
||||||
type ResolveStorePath = typeof import("../../config/sessions.js").resolveStorePath;
|
type ResolveStorePath = typeof import("../../config/sessions.js").resolveStorePath;
|
||||||
|
type ReadSessionUpdatedAt = typeof import("../../config/sessions.js").readSessionUpdatedAt;
|
||||||
type UpdateLastRoute = typeof import("../../config/sessions.js").updateLastRoute;
|
type UpdateLastRoute = typeof import("../../config/sessions.js").updateLastRoute;
|
||||||
type LoadConfig = typeof import("../../config/config.js").loadConfig;
|
type LoadConfig = typeof import("../../config/config.js").loadConfig;
|
||||||
type WriteConfigFile = typeof import("../../config/config.js").writeConfigFile;
|
type WriteConfigFile = typeof import("../../config/config.js").writeConfigFile;
|
||||||
@ -59,8 +63,7 @@ type MediaKindFromMime = typeof import("../../media/constants.js").mediaKindFrom
|
|||||||
type IsVoiceCompatibleAudio = typeof import("../../media/audio.js").isVoiceCompatibleAudio;
|
type IsVoiceCompatibleAudio = typeof import("../../media/audio.js").isVoiceCompatibleAudio;
|
||||||
type GetImageMetadata = typeof import("../../media/image-ops.js").getImageMetadata;
|
type GetImageMetadata = typeof import("../../media/image-ops.js").getImageMetadata;
|
||||||
type ResizeToJpeg = typeof import("../../media/image-ops.js").resizeToJpeg;
|
type ResizeToJpeg = typeof import("../../media/image-ops.js").resizeToJpeg;
|
||||||
type CreateMemoryGetTool =
|
type CreateMemoryGetTool = typeof import("../../agents/tools/memory-tool.js").createMemoryGetTool;
|
||||||
typeof import("../../agents/tools/memory-tool.js").createMemoryGetTool;
|
|
||||||
type CreateMemorySearchTool =
|
type CreateMemorySearchTool =
|
||||||
typeof import("../../agents/tools/memory-tool.js").createMemorySearchTool;
|
typeof import("../../agents/tools/memory-tool.js").createMemorySearchTool;
|
||||||
type RegisterMemoryCli = typeof import("../../cli/memory-cli.js").registerMemoryCli;
|
type RegisterMemoryCli = typeof import("../../cli/memory-cli.js").registerMemoryCli;
|
||||||
@ -170,6 +173,8 @@ export type PluginRuntime = {
|
|||||||
dispatchReplyFromConfig: DispatchReplyFromConfig;
|
dispatchReplyFromConfig: DispatchReplyFromConfig;
|
||||||
finalizeInboundContext: FinalizeInboundContext;
|
finalizeInboundContext: FinalizeInboundContext;
|
||||||
formatAgentEnvelope: FormatAgentEnvelope;
|
formatAgentEnvelope: FormatAgentEnvelope;
|
||||||
|
formatInboundEnvelope: FormatInboundEnvelope;
|
||||||
|
resolveEnvelopeFormatOptions: ResolveEnvelopeFormatOptions;
|
||||||
};
|
};
|
||||||
routing: {
|
routing: {
|
||||||
resolveAgentRoute: ResolveAgentRoute;
|
resolveAgentRoute: ResolveAgentRoute;
|
||||||
@ -185,6 +190,7 @@ export type PluginRuntime = {
|
|||||||
};
|
};
|
||||||
session: {
|
session: {
|
||||||
resolveStorePath: ResolveStorePath;
|
resolveStorePath: ResolveStorePath;
|
||||||
|
readSessionUpdatedAt: ReadSessionUpdatedAt;
|
||||||
recordSessionMetaFromInbound: RecordSessionMetaFromInbound;
|
recordSessionMetaFromInbound: RecordSessionMetaFromInbound;
|
||||||
updateLastRoute: UpdateLastRoute;
|
updateLastRoute: UpdateLastRoute;
|
||||||
};
|
};
|
||||||
|
|||||||
96
src/plugins/slots.test.ts
Normal file
96
src/plugins/slots.test.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { applyExclusiveSlotSelection } from "./slots.js";
|
||||||
|
|
||||||
|
describe("applyExclusiveSlotSelection", () => {
|
||||||
|
it("selects the slot and disables other entries for the same kind", () => {
|
||||||
|
const config: ClawdbotConfig = {
|
||||||
|
plugins: {
|
||||||
|
slots: { memory: "memory-core" },
|
||||||
|
entries: {
|
||||||
|
"memory-core": { enabled: true },
|
||||||
|
memory: { enabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyExclusiveSlotSelection({
|
||||||
|
config,
|
||||||
|
selectedId: "memory",
|
||||||
|
selectedKind: "memory",
|
||||||
|
registry: {
|
||||||
|
plugins: [
|
||||||
|
{ id: "memory-core", kind: "memory" },
|
||||||
|
{ id: "memory", kind: "memory" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.changed).toBe(true);
|
||||||
|
expect(result.config.plugins?.slots?.memory).toBe("memory");
|
||||||
|
expect(result.config.plugins?.entries?.["memory-core"]?.enabled).toBe(false);
|
||||||
|
expect(result.warnings).toContain(
|
||||||
|
'Exclusive slot "memory" switched from "memory-core" to "memory".',
|
||||||
|
);
|
||||||
|
expect(result.warnings).toContain(
|
||||||
|
'Disabled other "memory" slot plugins: memory-core.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the slot already matches", () => {
|
||||||
|
const config: ClawdbotConfig = {
|
||||||
|
plugins: {
|
||||||
|
slots: { memory: "memory" },
|
||||||
|
entries: {
|
||||||
|
memory: { enabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyExclusiveSlotSelection({
|
||||||
|
config,
|
||||||
|
selectedId: "memory",
|
||||||
|
selectedKind: "memory",
|
||||||
|
registry: { plugins: [{ id: "memory", kind: "memory" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.changed).toBe(false);
|
||||||
|
expect(result.warnings).toHaveLength(0);
|
||||||
|
expect(result.config).toBe(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when the slot falls back to a default", () => {
|
||||||
|
const config: ClawdbotConfig = {
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
memory: { enabled: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyExclusiveSlotSelection({
|
||||||
|
config,
|
||||||
|
selectedId: "memory",
|
||||||
|
selectedKind: "memory",
|
||||||
|
registry: { plugins: [{ id: "memory", kind: "memory" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.changed).toBe(true);
|
||||||
|
expect(result.warnings).toContain(
|
||||||
|
'Exclusive slot "memory" switched from "memory-core" to "memory".',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips changes when no exclusive slot applies", () => {
|
||||||
|
const config: ClawdbotConfig = {};
|
||||||
|
const result = applyExclusiveSlotSelection({
|
||||||
|
config,
|
||||||
|
selectedId: "custom",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.changed).toBe(false);
|
||||||
|
expect(result.warnings).toHaveLength(0);
|
||||||
|
expect(result.config).toBe(config);
|
||||||
|
});
|
||||||
|
});
|
||||||
102
src/plugins/slots.ts
Normal file
102
src/plugins/slots.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import type { PluginSlotsConfig } from "../config/types.plugins.js";
|
||||||
|
import type { PluginKind } from "./types.js";
|
||||||
|
|
||||||
|
export type PluginSlotKey = keyof PluginSlotsConfig;
|
||||||
|
|
||||||
|
type SlotPluginRecord = {
|
||||||
|
id: string;
|
||||||
|
kind?: PluginKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SLOT_BY_KIND: Record<PluginKind, PluginSlotKey> = {
|
||||||
|
memory: "memory",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SLOT_BY_KEY: Record<PluginSlotKey, string> = {
|
||||||
|
memory: "memory-core",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function slotKeyForPluginKind(kind?: PluginKind): PluginSlotKey | null {
|
||||||
|
if (!kind) return null;
|
||||||
|
return SLOT_BY_KIND[kind] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultSlotIdForKey(slotKey: PluginSlotKey): string {
|
||||||
|
return DEFAULT_SLOT_BY_KEY[slotKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SlotSelectionResult = {
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
warnings: string[];
|
||||||
|
changed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function applyExclusiveSlotSelection(params: {
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
selectedId: string;
|
||||||
|
selectedKind?: PluginKind;
|
||||||
|
registry?: { plugins: SlotPluginRecord[] };
|
||||||
|
}): SlotSelectionResult {
|
||||||
|
const slotKey = slotKeyForPluginKind(params.selectedKind);
|
||||||
|
if (!slotKey) {
|
||||||
|
return { config: params.config, warnings: [], changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const pluginsConfig = params.config.plugins ?? {};
|
||||||
|
const prevSlot = pluginsConfig.slots?.[slotKey];
|
||||||
|
const slots = {
|
||||||
|
...pluginsConfig.slots,
|
||||||
|
[slotKey]: params.selectedId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inferredPrevSlot = prevSlot ?? defaultSlotIdForKey(slotKey);
|
||||||
|
if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) {
|
||||||
|
warnings.push(
|
||||||
|
`Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = { ...pluginsConfig.entries };
|
||||||
|
const disabledIds: string[] = [];
|
||||||
|
if (params.registry) {
|
||||||
|
for (const plugin of params.registry.plugins) {
|
||||||
|
if (plugin.id === params.selectedId) continue;
|
||||||
|
if (plugin.kind !== params.selectedKind) continue;
|
||||||
|
const entry = entries[plugin.id];
|
||||||
|
if (!entry || entry.enabled !== false) {
|
||||||
|
entries[plugin.id] = {
|
||||||
|
...entry,
|
||||||
|
enabled: false,
|
||||||
|
};
|
||||||
|
disabledIds.push(plugin.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disabledIds.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Disabled other "${slotKey}" slot plugins: ${disabledIds.sort().join(", ")}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = prevSlot !== params.selectedId || disabledIds.length > 0;
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return { config: params.config, warnings: [], changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
...params.config,
|
||||||
|
plugins: {
|
||||||
|
...pluginsConfig,
|
||||||
|
slots,
|
||||||
|
entries,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
warnings,
|
||||||
|
changed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -8,7 +8,11 @@ import {
|
|||||||
type ResponsePrefixContext,
|
type ResponsePrefixContext,
|
||||||
} from "../../auto-reply/reply/response-prefix-template.js";
|
} from "../../auto-reply/reply/response-prefix-template.js";
|
||||||
import { hasControlCommand } from "../../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../../auto-reply/command-detection.js";
|
||||||
import { formatInboundEnvelope, formatInboundFromLabel } from "../../auto-reply/envelope.js";
|
import {
|
||||||
|
formatInboundEnvelope,
|
||||||
|
formatInboundFromLabel,
|
||||||
|
resolveEnvelopeFormatOptions,
|
||||||
|
} from "../../auto-reply/envelope.js";
|
||||||
import {
|
import {
|
||||||
createInboundDebouncer,
|
createInboundDebouncer,
|
||||||
resolveInboundDebounceMs,
|
resolveInboundDebounceMs,
|
||||||
@ -21,6 +25,7 @@ import {
|
|||||||
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
||||||
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||||
import {
|
import {
|
||||||
|
readSessionUpdatedAt,
|
||||||
recordSessionMetaFromInbound,
|
recordSessionMetaFromInbound,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
updateLastRoute,
|
updateLastRoute,
|
||||||
@ -77,6 +82,23 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
|||||||
directLabel: entry.senderName,
|
directLabel: entry.senderName,
|
||||||
directId: entry.senderDisplay,
|
directId: entry.senderDisplay,
|
||||||
});
|
});
|
||||||
|
const route = resolveAgentRoute({
|
||||||
|
cfg: deps.cfg,
|
||||||
|
channel: "signal",
|
||||||
|
accountId: deps.accountId,
|
||||||
|
peer: {
|
||||||
|
kind: entry.isGroup ? "group" : "dm",
|
||||||
|
id: entry.isGroup ? (entry.groupId ?? "unknown") : entry.senderPeerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const storePath = resolveStorePath(deps.cfg.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = resolveEnvelopeFormatOptions(deps.cfg);
|
||||||
|
const previousTimestamp = readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
const body = formatInboundEnvelope({
|
const body = formatInboundEnvelope({
|
||||||
channel: "Signal",
|
channel: "Signal",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
@ -84,6 +106,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
|||||||
body: entry.bodyText,
|
body: entry.bodyText,
|
||||||
chatType: entry.isGroup ? "group" : "direct",
|
chatType: entry.isGroup ? "group" : "direct",
|
||||||
sender: { name: entry.senderName, id: entry.senderDisplay },
|
sender: { name: entry.senderName, id: entry.senderDisplay },
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
});
|
});
|
||||||
let combinedBody = body;
|
let combinedBody = body;
|
||||||
const historyKey = entry.isGroup ? String(entry.groupId ?? "unknown") : undefined;
|
const historyKey = entry.isGroup ? String(entry.groupId ?? "unknown") : undefined;
|
||||||
@ -103,19 +127,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
|||||||
}`,
|
}`,
|
||||||
chatType: "group",
|
chatType: "group",
|
||||||
senderLabel: historyEntry.sender,
|
senderLabel: historyEntry.sender,
|
||||||
|
envelope: envelopeOptions,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = resolveAgentRoute({
|
|
||||||
cfg: deps.cfg,
|
|
||||||
channel: "signal",
|
|
||||||
accountId: deps.accountId,
|
|
||||||
peer: {
|
|
||||||
kind: entry.isGroup ? "group" : "dm",
|
|
||||||
id: entry.isGroup ? (entry.groupId ?? "unknown") : entry.senderPeerId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const signalTo = entry.isGroup ? `group:${entry.groupId}` : `signal:${entry.senderRecipient}`;
|
const signalTo = entry.isGroup ? `group:${entry.groupId}` : `signal:${entry.senderRecipient}`;
|
||||||
const ctxPayload = finalizeInboundContext({
|
const ctxPayload = finalizeInboundContext({
|
||||||
Body: combinedBody,
|
Body: combinedBody,
|
||||||
@ -144,9 +159,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
|||||||
OriginatingTo: signalTo,
|
OriginatingTo: signalTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = resolveStorePath(deps.cfg.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
});
|
|
||||||
void recordSessionMetaFromInbound({
|
void recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type { FinalizedMsgContext } from "../../../auto-reply/templating.js";
|
|||||||
import {
|
import {
|
||||||
formatInboundEnvelope,
|
formatInboundEnvelope,
|
||||||
formatThreadStarterEnvelope,
|
formatThreadStarterEnvelope,
|
||||||
|
resolveEnvelopeFormatOptions,
|
||||||
} from "../../../auto-reply/envelope.js";
|
} from "../../../auto-reply/envelope.js";
|
||||||
import {
|
import {
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
@ -21,7 +22,11 @@ import { resolveThreadSessionKeys } from "../../../routing/session-key.js";
|
|||||||
import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js";
|
import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js";
|
||||||
import { resolveConversationLabel } from "../../../channels/conversation-label.js";
|
import { resolveConversationLabel } from "../../../channels/conversation-label.js";
|
||||||
import { resolveControlCommandGate } from "../../../channels/command-gating.js";
|
import { resolveControlCommandGate } from "../../../channels/command-gating.js";
|
||||||
import { recordSessionMetaFromInbound, resolveStorePath } from "../../../config/sessions.js";
|
import {
|
||||||
|
readSessionUpdatedAt,
|
||||||
|
recordSessionMetaFromInbound,
|
||||||
|
resolveStorePath,
|
||||||
|
} from "../../../config/sessions.js";
|
||||||
|
|
||||||
import type { ResolvedSlackAccount } from "../../accounts.js";
|
import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||||
import { reactSlackMessage } from "../../actions.js";
|
import { reactSlackMessage } from "../../actions.js";
|
||||||
@ -372,6 +377,14 @@ export async function prepareSlackMessage(params: {
|
|||||||
From: slackFrom,
|
From: slackFrom,
|
||||||
}) ?? (isDirectMessage ? senderName : roomLabel);
|
}) ?? (isDirectMessage ? senderName : roomLabel);
|
||||||
const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`;
|
const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`;
|
||||||
|
const storePath = resolveStorePath(ctx.cfg.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
|
||||||
|
const previousTimestamp = readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
const body = formatInboundEnvelope({
|
const body = formatInboundEnvelope({
|
||||||
channel: "Slack",
|
channel: "Slack",
|
||||||
from: envelopeFrom,
|
from: envelopeFrom,
|
||||||
@ -379,6 +392,8 @@ export async function prepareSlackMessage(params: {
|
|||||||
body: textWithId,
|
body: textWithId,
|
||||||
chatType: isDirectMessage ? "direct" : "channel",
|
chatType: isDirectMessage ? "direct" : "channel",
|
||||||
sender: { name: senderName, id: senderId },
|
sender: { name: senderName, id: senderId },
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
let combinedBody = body;
|
let combinedBody = body;
|
||||||
@ -389,17 +404,18 @@ export async function prepareSlackMessage(params: {
|
|||||||
limit: ctx.historyLimit,
|
limit: ctx.historyLimit,
|
||||||
currentMessage: combinedBody,
|
currentMessage: combinedBody,
|
||||||
formatEntry: (entry) =>
|
formatEntry: (entry) =>
|
||||||
formatInboundEnvelope({
|
formatInboundEnvelope({
|
||||||
channel: "Slack",
|
channel: "Slack",
|
||||||
from: roomLabel,
|
from: roomLabel,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
body: `${entry.body}${
|
body: `${entry.body}${
|
||||||
entry.messageId ? ` [id:${entry.messageId} channel:${message.channel}]` : ""
|
entry.messageId ? ` [id:${entry.messageId} channel:${message.channel}]` : ""
|
||||||
}`,
|
}`,
|
||||||
chatType: "channel",
|
chatType: "channel",
|
||||||
senderLabel: entry.sender,
|
senderLabel: entry.sender,
|
||||||
}),
|
envelope: envelopeOptions,
|
||||||
});
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const slackTo = isDirectMessage ? `user:${message.user}` : `channel:${message.channel}`;
|
const slackTo = isDirectMessage ? `user:${message.user}` : `channel:${message.channel}`;
|
||||||
@ -433,6 +449,7 @@ export async function prepareSlackMessage(params: {
|
|||||||
author: starterName,
|
author: starterName,
|
||||||
timestamp: starter.ts ? Math.round(Number(starter.ts) * 1000) : undefined,
|
timestamp: starter.ts ? Math.round(Number(starter.ts) * 1000) : undefined,
|
||||||
body: starterWithId,
|
body: starterWithId,
|
||||||
|
envelope: envelopeOptions,
|
||||||
});
|
});
|
||||||
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
|
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
|
||||||
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
|
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
|
||||||
@ -472,9 +489,6 @@ export async function prepareSlackMessage(params: {
|
|||||||
OriginatingTo: slackTo,
|
OriginatingTo: slackTo,
|
||||||
}) satisfies FinalizedMsgContext;
|
}) satisfies FinalizedMsgContext;
|
||||||
|
|
||||||
const storePath = resolveStorePath(ctx.cfg.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
});
|
|
||||||
void recordSessionMetaFromInbound({
|
void recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { Bot } from "grammy";
|
|||||||
import { resolveAckReaction } from "../agents/identity.js";
|
import { resolveAckReaction } from "../agents/identity.js";
|
||||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||||
import { normalizeCommandBody } from "../auto-reply/commands-registry.js";
|
import { normalizeCommandBody } from "../auto-reply/commands-registry.js";
|
||||||
import { formatInboundEnvelope } from "../auto-reply/envelope.js";
|
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js";
|
||||||
import {
|
import {
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
recordPendingHistoryEntry,
|
recordPendingHistoryEntry,
|
||||||
@ -13,6 +13,7 @@ import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
|||||||
import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js";
|
import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js";
|
||||||
import { formatLocationText, toLocationContext } from "../channels/location.js";
|
import { formatLocationText, toLocationContext } from "../channels/location.js";
|
||||||
import {
|
import {
|
||||||
|
readSessionUpdatedAt,
|
||||||
recordSessionMetaFromInbound,
|
recordSessionMetaFromInbound,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
updateLastRoute,
|
updateLastRoute,
|
||||||
@ -417,6 +418,14 @@ export const buildTelegramMessageContext = async ({
|
|||||||
const conversationLabel = isGroup
|
const conversationLabel = isGroup
|
||||||
? (groupLabel ?? `group:${chatId}`)
|
? (groupLabel ?? `group:${chatId}`)
|
||||||
: buildSenderLabel(msg, senderId || chatId);
|
: buildSenderLabel(msg, senderId || chatId);
|
||||||
|
const storePath = resolveStorePath(cfg.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||||
|
const previousTimestamp = readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
});
|
||||||
const body = formatInboundEnvelope({
|
const body = formatInboundEnvelope({
|
||||||
channel: "Telegram",
|
channel: "Telegram",
|
||||||
from: conversationLabel,
|
from: conversationLabel,
|
||||||
@ -428,6 +437,8 @@ export const buildTelegramMessageContext = async ({
|
|||||||
username: senderUsername || undefined,
|
username: senderUsername || undefined,
|
||||||
id: senderId || undefined,
|
id: senderId || undefined,
|
||||||
},
|
},
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
});
|
});
|
||||||
let combinedBody = body;
|
let combinedBody = body;
|
||||||
if (isGroup && historyKey && historyLimit > 0) {
|
if (isGroup && historyKey && historyLimit > 0) {
|
||||||
@ -444,6 +455,7 @@ export const buildTelegramMessageContext = async ({
|
|||||||
body: `${entry.body} [id:${entry.messageId ?? "unknown"} chat:${chatId}]`,
|
body: `${entry.body} [id:${entry.messageId ?? "unknown"} chat:${chatId}]`,
|
||||||
chatType: "group",
|
chatType: "group",
|
||||||
senderLabel: entry.sender,
|
senderLabel: entry.sender,
|
||||||
|
envelope: envelopeOptions,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -504,9 +516,6 @@ export const buildTelegramMessageContext = async ({
|
|||||||
OriginatingTo: `telegram:${chatId}`,
|
OriginatingTo: `telegram:${chatId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = resolveStorePath(cfg.session?.store, {
|
|
||||||
agentId: route.agentId,
|
|
||||||
});
|
|
||||||
void recordSessionMetaFromInbound({
|
void recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
|
|||||||
@ -176,7 +176,7 @@ describe("createTelegramBot", () => {
|
|||||||
expect(payload.WasMentioned).toBe(true);
|
expect(payload.WasMentioned).toBe(true);
|
||||||
expect(payload.SenderName).toBe("Ada");
|
expect(payload.SenderName).toBe("Ada");
|
||||||
expect(payload.SenderId).toBe("9");
|
expect(payload.SenderId).toBe("9");
|
||||||
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 2025-01-09T00:00Z\]/);
|
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
|
||||||
});
|
});
|
||||||
it("keeps group envelope headers stable (sender identity is separate)", async () => {
|
it("keeps group envelope headers stable (sender identity is separate)", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
@ -217,7 +217,7 @@ describe("createTelegramBot", () => {
|
|||||||
expect(payload.SenderName).toBe("Ada Lovelace");
|
expect(payload.SenderName).toBe("Ada Lovelace");
|
||||||
expect(payload.SenderId).toBe("99");
|
expect(payload.SenderId).toBe("99");
|
||||||
expect(payload.SenderUsername).toBe("ada");
|
expect(payload.SenderUsername).toBe("ada");
|
||||||
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 2025-01-09T00:00Z\]/);
|
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
|
||||||
});
|
});
|
||||||
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
|
|||||||
@ -330,7 +330,7 @@ describe("createTelegramBot", () => {
|
|||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
const payload = replySpy.mock.calls[0][0];
|
const payload = replySpy.mock.calls[0][0];
|
||||||
expect(payload.Body).toMatch(
|
expect(payload.Body).toMatch(
|
||||||
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T00:00Z\]/,
|
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
|
||||||
);
|
);
|
||||||
expect(payload.Body).toContain("hello world");
|
expect(payload.Body).toContain("hello world");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -452,7 +452,7 @@ describe("createTelegramBot", () => {
|
|||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
const payload = replySpy.mock.calls[0][0];
|
const payload = replySpy.mock.calls[0][0];
|
||||||
expect(payload.Body).toMatch(
|
expect(payload.Body).toMatch(
|
||||||
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T00:00Z\]/,
|
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
|
||||||
);
|
);
|
||||||
expect(payload.Body).toContain("hello world");
|
expect(payload.Body).toContain("hello world");
|
||||||
} finally {
|
} finally {
|
||||||
@ -586,7 +586,7 @@ describe("createTelegramBot", () => {
|
|||||||
const payload = replySpy.mock.calls[0][0];
|
const payload = replySpy.mock.calls[0][0];
|
||||||
expectInboundContextContract(payload);
|
expectInboundContextContract(payload);
|
||||||
expect(payload.WasMentioned).toBe(true);
|
expect(payload.WasMentioned).toBe(true);
|
||||||
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 2025-01-09T00:00Z\]/);
|
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
|
||||||
expect(payload.SenderName).toBe("Ada");
|
expect(payload.SenderName).toBe("Ada");
|
||||||
expect(payload.SenderId).toBe("9");
|
expect(payload.SenderId).toBe("9");
|
||||||
});
|
});
|
||||||
@ -628,7 +628,7 @@ describe("createTelegramBot", () => {
|
|||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
const payload = replySpy.mock.calls[0][0];
|
const payload = replySpy.mock.calls[0][0];
|
||||||
expectInboundContextContract(payload);
|
expectInboundContextContract(payload);
|
||||||
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 2025-01-09T00:00Z\]/);
|
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
|
||||||
expect(payload.SenderName).toBe("Ada Lovelace");
|
expect(payload.SenderName).toBe("Ada Lovelace");
|
||||||
expect(payload.SenderId).toBe("99");
|
expect(payload.SenderId).toBe("99");
|
||||||
expect(payload.SenderUsername).toBe("ada");
|
expect(payload.SenderUsername).toBe("ada");
|
||||||
|
|||||||
@ -328,9 +328,13 @@ describe("web auto-reply", () => {
|
|||||||
expect(resolver).toHaveBeenCalledTimes(2);
|
expect(resolver).toHaveBeenCalledTimes(2);
|
||||||
const firstArgs = resolver.mock.calls[0][0];
|
const firstArgs = resolver.mock.calls[0][0];
|
||||||
const secondArgs = resolver.mock.calls[1][0];
|
const secondArgs = resolver.mock.calls[1][0];
|
||||||
expect(firstArgs.Body).toContain("[WhatsApp +1 2025-01-01T00:00Z] [clawdbot] first");
|
expect(firstArgs.Body).toMatch(
|
||||||
|
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T00:00Z\] \[clawdbot\] first/,
|
||||||
|
);
|
||||||
expect(firstArgs.Body).not.toContain("second");
|
expect(firstArgs.Body).not.toContain("second");
|
||||||
expect(secondArgs.Body).toContain("[WhatsApp +1 2025-01-01T01:00Z] [clawdbot] second");
|
expect(secondArgs.Body).toMatch(
|
||||||
|
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T01:00Z\] \[clawdbot\] second/,
|
||||||
|
);
|
||||||
expect(secondArgs.Body).not.toContain("first");
|
expect(secondArgs.Body).not.toContain("first");
|
||||||
|
|
||||||
// Max listeners bumped to avoid warnings in multi-instance test runs
|
// Max listeners bumped to avoid warnings in multi-instance test runs
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { resolveMessagePrefix } from "../../../agents/identity.js";
|
import { resolveMessagePrefix } from "../../../agents/identity.js";
|
||||||
import { formatInboundEnvelope } from "../../../auto-reply/envelope.js";
|
import {
|
||||||
|
formatInboundEnvelope,
|
||||||
|
type EnvelopeFormatOptions,
|
||||||
|
} from "../../../auto-reply/envelope.js";
|
||||||
import type { loadConfig } from "../../../config/config.js";
|
import type { loadConfig } from "../../../config/config.js";
|
||||||
import type { WebInboundMsg } from "../types.js";
|
import type { WebInboundMsg } from "../types.js";
|
||||||
|
|
||||||
@ -14,8 +17,10 @@ export function buildInboundLine(params: {
|
|||||||
cfg: ReturnType<typeof loadConfig>;
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
msg: WebInboundMsg;
|
msg: WebInboundMsg;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
previousTimestamp?: number;
|
||||||
|
envelope?: EnvelopeFormatOptions;
|
||||||
}) {
|
}) {
|
||||||
const { cfg, msg, agentId } = params;
|
const { cfg, msg, agentId, previousTimestamp, envelope } = params;
|
||||||
// WhatsApp inbound prefix: channels.whatsapp.messagePrefix > legacy messages.messagePrefix > identity/defaults
|
// WhatsApp inbound prefix: channels.whatsapp.messagePrefix > legacy messages.messagePrefix > identity/defaults
|
||||||
const messagePrefix = resolveMessagePrefix(cfg, agentId, {
|
const messagePrefix = resolveMessagePrefix(cfg, agentId, {
|
||||||
configured: cfg.channels?.whatsapp?.messagePrefix,
|
configured: cfg.channels?.whatsapp?.messagePrefix,
|
||||||
@ -37,5 +42,7 @@ export function buildInboundLine(params: {
|
|||||||
e164: msg.senderE164,
|
e164: msg.senderE164,
|
||||||
id: msg.senderJid,
|
id: msg.senderJid,
|
||||||
},
|
},
|
||||||
|
previousTimestamp,
|
||||||
|
envelope,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,10 @@ import {
|
|||||||
type ResponsePrefixContext,
|
type ResponsePrefixContext,
|
||||||
} from "../../../auto-reply/reply/response-prefix-template.js";
|
} from "../../../auto-reply/reply/response-prefix-template.js";
|
||||||
import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
|
import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
|
||||||
import { formatInboundEnvelope } from "../../../auto-reply/envelope.js";
|
import {
|
||||||
|
formatInboundEnvelope,
|
||||||
|
resolveEnvelopeFormatOptions,
|
||||||
|
} from "../../../auto-reply/envelope.js";
|
||||||
import {
|
import {
|
||||||
buildHistoryContextFromEntries,
|
buildHistoryContextFromEntries,
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
@ -20,7 +23,11 @@ import { shouldComputeCommandAuthorized } from "../../../auto-reply/command-dete
|
|||||||
import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js";
|
import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js";
|
||||||
import { toLocationContext } from "../../../channels/location.js";
|
import { toLocationContext } from "../../../channels/location.js";
|
||||||
import type { loadConfig } from "../../../config/config.js";
|
import type { loadConfig } from "../../../config/config.js";
|
||||||
import { recordSessionMetaFromInbound, resolveStorePath } from "../../../config/sessions.js";
|
import {
|
||||||
|
readSessionUpdatedAt,
|
||||||
|
recordSessionMetaFromInbound,
|
||||||
|
resolveStorePath,
|
||||||
|
} from "../../../config/sessions.js";
|
||||||
import { logVerbose, shouldLogVerbose } from "../../../globals.js";
|
import { logVerbose, shouldLogVerbose } from "../../../globals.js";
|
||||||
import type { getChildLogger } from "../../../logging.js";
|
import type { getChildLogger } from "../../../logging.js";
|
||||||
import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js";
|
import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js";
|
||||||
@ -121,10 +128,20 @@ export async function processMessage(params: {
|
|||||||
suppressGroupHistoryClear?: boolean;
|
suppressGroupHistoryClear?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const conversationId = params.msg.conversationId ?? params.msg.from;
|
const conversationId = params.msg.conversationId ?? params.msg.from;
|
||||||
|
const storePath = resolveStorePath(params.cfg.session?.store, {
|
||||||
|
agentId: params.route.agentId,
|
||||||
|
});
|
||||||
|
const envelopeOptions = resolveEnvelopeFormatOptions(params.cfg);
|
||||||
|
const previousTimestamp = readSessionUpdatedAt({
|
||||||
|
storePath,
|
||||||
|
sessionKey: params.route.sessionKey,
|
||||||
|
});
|
||||||
let combinedBody = buildInboundLine({
|
let combinedBody = buildInboundLine({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
msg: params.msg,
|
msg: params.msg,
|
||||||
agentId: params.route.agentId,
|
agentId: params.route.agentId,
|
||||||
|
previousTimestamp,
|
||||||
|
envelope: envelopeOptions,
|
||||||
});
|
});
|
||||||
let shouldClearGroupHistory = false;
|
let shouldClearGroupHistory = false;
|
||||||
|
|
||||||
@ -152,6 +169,7 @@ export async function processMessage(params: {
|
|||||||
body: bodyWithId,
|
body: bodyWithId,
|
||||||
chatType: "group",
|
chatType: "group",
|
||||||
senderLabel: entry.sender,
|
senderLabel: entry.sender,
|
||||||
|
envelope: envelopeOptions,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -288,9 +306,6 @@ export async function processMessage(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const storePath = resolveStorePath(params.cfg.session?.store, {
|
|
||||||
agentId: params.route.agentId,
|
|
||||||
});
|
|
||||||
const metaTask = recordSessionMetaFromInbound({
|
const metaTask = recordSessionMetaFromInbound({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: params.route.sessionKey,
|
sessionKey: params.route.sessionKey,
|
||||||
|
|||||||
@ -23,23 +23,23 @@ describe("web logout", () => {
|
|||||||
|
|
||||||
it("deletes cached credentials when present", { timeout: 60_000 }, async () => {
|
it("deletes cached credentials when present", { timeout: 60_000 }, async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.resetModules();
|
const { logoutWeb } = await import("./session.js");
|
||||||
const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js");
|
const { resolveDefaultWebAuthDir } = await import("./auth-store.js");
|
||||||
|
const authDir = resolveDefaultWebAuthDir();
|
||||||
|
|
||||||
expect(isPathWithinBase(home, WA_WEB_AUTH_DIR)).toBe(true);
|
expect(isPathWithinBase(home, authDir)).toBe(true);
|
||||||
|
|
||||||
fs.mkdirSync(WA_WEB_AUTH_DIR, { recursive: true });
|
fs.mkdirSync(authDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}");
|
fs.writeFileSync(path.join(authDir, "creds.json"), "{}");
|
||||||
const result = await logoutWeb({ runtime: runtime as never });
|
const result = await logoutWeb({ runtime: runtime as never });
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(fs.existsSync(WA_WEB_AUTH_DIR)).toBe(false);
|
expect(fs.existsSync(authDir)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no-ops when nothing to delete", { timeout: 60_000 }, async () => {
|
it("no-ops when nothing to delete", { timeout: 60_000 }, async () => {
|
||||||
await withTempHome(async () => {
|
await withTempHome(async () => {
|
||||||
vi.resetModules();
|
|
||||||
const { logoutWeb } = await import("./session.js");
|
const { logoutWeb } = await import("./session.js");
|
||||||
const result = await logoutWeb({ runtime: runtime as never });
|
const result = await logoutWeb({ runtime: runtime as never });
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
@ -49,7 +49,6 @@ describe("web logout", () => {
|
|||||||
|
|
||||||
it("keeps shared oauth.json when using legacy auth dir", async () => {
|
it("keeps shared oauth.json when using legacy auth dir", async () => {
|
||||||
await withTempHome(async () => {
|
await withTempHome(async () => {
|
||||||
vi.resetModules();
|
|
||||||
const { logoutWeb } = await import("./session.js");
|
const { logoutWeb } = await import("./session.js");
|
||||||
|
|
||||||
const { resolveOAuthDir } = await import("../config/paths.js");
|
const { resolveOAuthDir } = await import("../config/paths.js");
|
||||||
|
|||||||
34
ui/src/ui/chat/message-extract.test.ts
Normal file
34
ui/src/ui/chat/message-extract.test.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { stripEnvelope } from "./message-extract";
|
||||||
|
|
||||||
|
describe("stripEnvelope", () => {
|
||||||
|
it("strips UTC envelope", () => {
|
||||||
|
const text = "[WebChat agent:main:main 2026-01-18T05:19Z] hello world";
|
||||||
|
expect(stripEnvelope(text)).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips local-time envelope", () => {
|
||||||
|
const text = "[Telegram Ada Lovelace (@ada) id:1234 2026-01-18 19:29 GMT+1] test";
|
||||||
|
expect(stripEnvelope(text)).toBe("test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips envelopes without timestamps for known channels", () => {
|
||||||
|
const text = "[WhatsApp +1234567890] hi there";
|
||||||
|
expect(stripEnvelope(text)).toBe("hi there");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multi-line messages", () => {
|
||||||
|
const text = "[Slack #general 2026-01-18T05:19Z] first line\nsecond line";
|
||||||
|
expect(stripEnvelope(text)).toBe("first line\nsecond line");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns text as-is when no envelope present", () => {
|
||||||
|
const text = "just a regular message";
|
||||||
|
expect(stripEnvelope(text)).toBe("just a regular message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not strip non-envelope brackets", () => {
|
||||||
|
expect(stripEnvelope("[OK] hello")).toBe("[OK] hello");
|
||||||
|
expect(stripEnvelope("[1/2] step one")).toBe("[1/2] step one");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,11 +1,42 @@
|
|||||||
import { stripThinkingTags } from "../format";
|
import { stripThinkingTags } from "../format";
|
||||||
|
|
||||||
|
const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/;
|
||||||
|
const ENVELOPE_CHANNELS = [
|
||||||
|
"WebChat",
|
||||||
|
"WhatsApp",
|
||||||
|
"Telegram",
|
||||||
|
"Signal",
|
||||||
|
"Slack",
|
||||||
|
"Discord",
|
||||||
|
"iMessage",
|
||||||
|
"Teams",
|
||||||
|
"Matrix",
|
||||||
|
"Zalo",
|
||||||
|
"Zalo Personal",
|
||||||
|
"BlueBubbles",
|
||||||
|
];
|
||||||
|
|
||||||
|
function looksLikeEnvelopeHeader(header: string): boolean {
|
||||||
|
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true;
|
||||||
|
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true;
|
||||||
|
return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripEnvelope(text: string): string {
|
||||||
|
const match = text.match(ENVELOPE_PREFIX);
|
||||||
|
if (!match) return text;
|
||||||
|
const header = match[1] ?? "";
|
||||||
|
if (!looksLikeEnvelopeHeader(header)) return text;
|
||||||
|
return text.slice(match[0].length);
|
||||||
|
}
|
||||||
|
|
||||||
export function extractText(message: unknown): string | null {
|
export function extractText(message: unknown): string | null {
|
||||||
const m = message as Record<string, unknown>;
|
const m = message as Record<string, unknown>;
|
||||||
const role = typeof m.role === "string" ? m.role : "";
|
const role = typeof m.role === "string" ? m.role : "";
|
||||||
const content = m.content;
|
const content = m.content;
|
||||||
if (typeof content === "string") {
|
if (typeof content === "string") {
|
||||||
return role === "assistant" ? stripThinkingTags(content) : content;
|
const processed = role === "assistant" ? stripThinkingTags(content) : stripEnvelope(content);
|
||||||
|
return processed;
|
||||||
}
|
}
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
const parts = content
|
const parts = content
|
||||||
@ -17,11 +48,13 @@ export function extractText(message: unknown): string | null {
|
|||||||
.filter((v): v is string => typeof v === "string");
|
.filter((v): v is string => typeof v === "string");
|
||||||
if (parts.length > 0) {
|
if (parts.length > 0) {
|
||||||
const joined = parts.join("\n");
|
const joined = parts.join("\n");
|
||||||
return role === "assistant" ? stripThinkingTags(joined) : joined;
|
const processed = role === "assistant" ? stripThinkingTags(joined) : stripEnvelope(joined);
|
||||||
|
return processed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof m.text === "string") {
|
if (typeof m.text === "string") {
|
||||||
return role === "assistant" ? stripThinkingTags(m.text) : m.text;
|
const processed = role === "assistant" ? stripThinkingTags(m.text) : stripEnvelope(m.text);
|
||||||
|
return processed;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -83,4 +116,3 @@ export function formatReasoningMarkdown(text: string): string {
|
|||||||
.map((line) => `_${line}_`);
|
.map((line) => `_${line}_`);
|
||||||
return lines.length ? ["_Reasoning:_", ...lines].join("\n") : "";
|
return lines.length ? ["_Reasoning:_", ...lines].join("\n") : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { GatewayBrowserClient } from "../gateway";
|
import type { GatewayBrowserClient } from "../gateway";
|
||||||
import { stripThinkingTags } from "../format";
|
import { extractText } from "../chat/message-extract";
|
||||||
import { generateUUID } from "../uuid";
|
import { generateUUID } from "../uuid";
|
||||||
|
|
||||||
export type ChatState = {
|
export type ChatState = {
|
||||||
@ -142,29 +142,3 @@ export function handleChatEvent(
|
|||||||
}
|
}
|
||||||
return payload.state;
|
return payload.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractText(message: unknown): string | null {
|
|
||||||
const m = message as Record<string, unknown>;
|
|
||||||
const role = typeof m.role === "string" ? m.role : "";
|
|
||||||
const content = m.content;
|
|
||||||
if (typeof content === "string") {
|
|
||||||
return role === "assistant" ? stripThinkingTags(content) : content;
|
|
||||||
}
|
|
||||||
if (Array.isArray(content)) {
|
|
||||||
const parts = content
|
|
||||||
.map((p) => {
|
|
||||||
const item = p as Record<string, unknown>;
|
|
||||||
if (item.type === "text" && typeof item.text === "string") return item.text;
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter((v): v is string => typeof v === "string");
|
|
||||||
if (parts.length > 0) {
|
|
||||||
const joined = parts.join("\n");
|
|
||||||
return role === "assistant" ? stripThinkingTags(joined) : joined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof m.text === "string") {
|
|
||||||
return role === "assistant" ? stripThinkingTags(m.text) : m.text;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user