feat: mirror delivered outbound messages (#1031)
Co-authored-by: T Savo <TSavo@users.noreply.github.com>
This commit is contained in:
parent
3fb699a84b
commit
fdaeada3ec
26 changed files with 697 additions and 29 deletions
|
|
@ -22,6 +22,7 @@
|
||||||
- Skills: add user-invocable skill commands and expanded skill command registration.
|
- Skills: add user-invocable skill commands and expanded skill command registration.
|
||||||
- Telegram: default reaction level to minimal and enable reaction notifications by default.
|
- Telegram: default reaction level to minimal and enable reaction notifications by default.
|
||||||
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
|
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
|
||||||
|
- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo.
|
||||||
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
|
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
|
||||||
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
|
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
|
||||||
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
|
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
|
||||||
|
|
|
||||||
35
README.md
35
README.md
|
|
@ -470,7 +470,6 @@ Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
|
||||||
|
|
||||||
Core contributors:
|
Core contributors:
|
||||||
- @cpojer — Telegram onboarding UX + docs
|
- @cpojer — Telegram onboarding UX + docs
|
||||||
- @ThomsenDrake — Internal hooks system
|
|
||||||
|
|
||||||
Thanks to all clawtributors:
|
Thanks to all clawtributors:
|
||||||
|
|
||||||
|
|
@ -478,21 +477,21 @@ Thanks to all clawtributors:
|
||||||
<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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
|
||||||
<a href="https://github.com/search?q=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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/ThomsenDrake"><img src="https://avatars.githubusercontent.com/u/120344051?v=4&s=48" 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=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/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=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/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/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/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></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/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/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/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/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/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
|
||||||
<a href="https://github.com/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/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/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/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/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/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
|
||||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/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/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/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a>
|
||||||
<a href="https://github.com/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=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/Szpadel"><img src="https://avatars.githubusercontent.com/u/1857251?v=4&s=48" width="48" height="48" alt="Szpadel" title="Szpadel"/></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/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>
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,7 @@ public struct SendParams: Codable, Sendable {
|
||||||
public let gifplayback: Bool?
|
public let gifplayback: Bool?
|
||||||
public let channel: String?
|
public let channel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
|
public let sessionkey: String?
|
||||||
public let idempotencykey: String
|
public let idempotencykey: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
|
|
@ -356,6 +357,7 @@ public struct SendParams: Codable, Sendable {
|
||||||
gifplayback: Bool?,
|
gifplayback: Bool?,
|
||||||
channel: String?,
|
channel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
|
sessionkey: String? = nil,
|
||||||
idempotencykey: String
|
idempotencykey: String
|
||||||
) {
|
) {
|
||||||
self.to = to
|
self.to = to
|
||||||
|
|
@ -364,6 +366,7 @@ public struct SendParams: Codable, Sendable {
|
||||||
self.gifplayback = gifplayback
|
self.gifplayback = gifplayback
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
|
self.sessionkey = sessionkey
|
||||||
self.idempotencykey = idempotencykey
|
self.idempotencykey = idempotencykey
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
|
@ -373,6 +376,7 @@ public struct SendParams: Codable, Sendable {
|
||||||
case gifplayback = "gifPlayback"
|
case gifplayback = "gifPlayback"
|
||||||
case channel
|
case channel
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
|
case sessionkey = "sessionKey"
|
||||||
case idempotencykey = "idempotencyKey"
|
case idempotencykey = "idempotencyKey"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,9 @@ Common `agentTurn` fields:
|
||||||
- `bestEffortDeliver`: avoid failing the job if delivery fails.
|
- `bestEffortDeliver`: avoid failing the job if delivery fails.
|
||||||
|
|
||||||
Isolation options (only for `session=isolated`):
|
Isolation options (only for `session=isolated`):
|
||||||
- `postToMainPrefix` (CLI: `--post-prefix`): prefix for the summary system event in main.
|
- `postToMainPrefix` (CLI: `--post-prefix`): prefix for the system event in main.
|
||||||
|
- `postToMainMode`: `summary` (default) or `full`.
|
||||||
|
- `postToMainMaxChars`: max chars when `postToMainMode=full` (default 8000).
|
||||||
|
|
||||||
### Model and thinking overrides
|
### Model and thinking overrides
|
||||||
Isolated jobs (`agentTurn`) can override the model and thinking level:
|
Isolated jobs (`agentTurn`) can override the model and thinking level:
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ export function createClawdbotTools(options?: {
|
||||||
}),
|
}),
|
||||||
createMessageTool({
|
createMessageTool({
|
||||||
agentAccountId: options?.agentAccountId,
|
agentAccountId: options?.agentAccountId,
|
||||||
|
agentSessionKey: options?.agentSessionKey,
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
currentChannelId: options?.currentChannelId,
|
currentChannelId: options?.currentChannelId,
|
||||||
currentThreadTs: options?.currentThreadTs,
|
currentThreadTs: options?.currentThreadTs,
|
||||||
|
|
|
||||||
78
src/agents/tools/message-tool.test.ts
Normal file
78
src/agents/tools/message-tool.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
|
||||||
|
import { createMessageTool } from "./message-tool.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
runMessageAction: vi.fn(),
|
||||||
|
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../infra/outbound/message-action-runner.js", () => ({
|
||||||
|
runMessageAction: mocks.runMessageAction,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../config/sessions.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
||||||
|
"../../config/sessions.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("message tool mirroring", () => {
|
||||||
|
it("mirrors media filename for plugin-handled sends", async () => {
|
||||||
|
mocks.appendAssistantMessageToSessionTranscript.mockClear();
|
||||||
|
mocks.runMessageAction.mockResolvedValue({
|
||||||
|
kind: "send",
|
||||||
|
action: "send",
|
||||||
|
channel: "telegram",
|
||||||
|
handledBy: "plugin",
|
||||||
|
payload: {},
|
||||||
|
dryRun: false,
|
||||||
|
} satisfies MessageActionRunResult);
|
||||||
|
|
||||||
|
const tool = createMessageTool({
|
||||||
|
agentSessionKey: "agent:main:main",
|
||||||
|
config: {} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tool.execute("1", {
|
||||||
|
action: "send",
|
||||||
|
to: "telegram:123",
|
||||||
|
message: "",
|
||||||
|
media: "https://example.com/files/report.pdf?sig=1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ text: "report.pdf" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mirror on dry-run", async () => {
|
||||||
|
mocks.appendAssistantMessageToSessionTranscript.mockClear();
|
||||||
|
mocks.runMessageAction.mockResolvedValue({
|
||||||
|
kind: "send",
|
||||||
|
action: "send",
|
||||||
|
channel: "telegram",
|
||||||
|
handledBy: "plugin",
|
||||||
|
payload: {},
|
||||||
|
dryRun: true,
|
||||||
|
} satisfies MessageActionRunResult);
|
||||||
|
|
||||||
|
const tool = createMessageTool({
|
||||||
|
agentSessionKey: "agent:main:main",
|
||||||
|
config: {} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tool.execute("1", {
|
||||||
|
action: "send",
|
||||||
|
to: "telegram:123",
|
||||||
|
message: "hi",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -9,8 +9,13 @@ import {
|
||||||
} from "../../channels/plugins/types.js";
|
} from "../../channels/plugins/types.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
|
import {
|
||||||
|
appendAssistantMessageToSessionTranscript,
|
||||||
|
resolveMirroredTranscriptText,
|
||||||
|
} from "../../config/sessions.js";
|
||||||
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
|
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
|
||||||
import { runMessageAction } from "../../infra/outbound/message-action-runner.js";
|
import { runMessageAction } from "../../infra/outbound/message-action-runner.js";
|
||||||
|
import { resolveSessionAgentId } from "../agent-scope.js";
|
||||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||||
import { stringEnum } from "../schema/typebox.js";
|
import { stringEnum } from "../schema/typebox.js";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
|
|
@ -119,6 +124,7 @@ const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
|
||||||
|
|
||||||
type MessageToolOptions = {
|
type MessageToolOptions = {
|
||||||
agentAccountId?: string;
|
agentAccountId?: string;
|
||||||
|
agentSessionKey?: string;
|
||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
currentChannelId?: string;
|
currentChannelId?: string;
|
||||||
currentThreadTs?: string;
|
currentThreadTs?: string;
|
||||||
|
|
@ -187,8 +193,36 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||||
defaultAccountId: accountId ?? undefined,
|
defaultAccountId: accountId ?? undefined,
|
||||||
gateway,
|
gateway,
|
||||||
toolContext,
|
toolContext,
|
||||||
|
sessionKey: options?.agentSessionKey,
|
||||||
|
agentId: options?.agentSessionKey
|
||||||
|
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
action === "send" &&
|
||||||
|
options?.agentSessionKey &&
|
||||||
|
!result.dryRun &&
|
||||||
|
result.handledBy === "plugin"
|
||||||
|
) {
|
||||||
|
const mediaUrl = typeof params.media === "string" ? params.media : undefined;
|
||||||
|
const mirrorText = resolveMirroredTranscriptText({
|
||||||
|
text: typeof params.message === "string" ? params.message : undefined,
|
||||||
|
mediaUrls: mediaUrl ? [mediaUrl] : undefined,
|
||||||
|
});
|
||||||
|
if (mirrorText) {
|
||||||
|
const agentId = resolveSessionAgentId({
|
||||||
|
sessionKey: options.agentSessionKey,
|
||||||
|
config: cfg,
|
||||||
|
});
|
||||||
|
await appendAssistantMessageToSessionTranscript({
|
||||||
|
agentId,
|
||||||
|
sessionKey: options.agentSessionKey,
|
||||||
|
text: mirrorText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (result.toolResult) return result.toolResult;
|
if (result.toolResult) return result.toolResult;
|
||||||
return jsonResult(result.payload);
|
return jsonResult(result.payload);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
|
||||||
sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
|
sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
|
||||||
sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })),
|
sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })),
|
||||||
sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1", toJid: "jid" })),
|
sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1", toJid: "jid" })),
|
||||||
|
deliverOutboundPayloads: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../discord/send.js", () => ({
|
vi.mock("../../discord/send.js", () => ({
|
||||||
|
|
@ -37,12 +38,25 @@ vi.mock("../../telegram/send.js", () => ({
|
||||||
vi.mock("../../web/outbound.js", () => ({
|
vi.mock("../../web/outbound.js", () => ({
|
||||||
sendMessageWhatsApp: mocks.sendMessageWhatsApp,
|
sendMessageWhatsApp: mocks.sendMessageWhatsApp,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../../infra/outbound/deliver.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../infra/outbound/deliver.js")>(
|
||||||
|
"../../infra/outbound/deliver.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const actualDeliver = await vi.importActual<typeof import("../../infra/outbound/deliver.js")>(
|
||||||
|
"../../infra/outbound/deliver.js",
|
||||||
|
);
|
||||||
|
|
||||||
const { routeReply } = await import("./route-reply.js");
|
const { routeReply } = await import("./route-reply.js");
|
||||||
|
|
||||||
describe("routeReply", () => {
|
describe("routeReply", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePluginRegistry(emptyRegistry);
|
setActivePluginRegistry(emptyRegistry);
|
||||||
|
mocks.deliverOutboundPayloads.mockImplementation(actualDeliver.deliverOutboundPayloads);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -261,6 +275,25 @@ describe("routeReply", () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes mirror data when sessionKey is set", async () => {
|
||||||
|
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||||
|
await routeReply({
|
||||||
|
payload: { text: "hi" },
|
||||||
|
channel: "slack",
|
||||||
|
to: "channel:C123",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
cfg: {} as never,
|
||||||
|
});
|
||||||
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
mirror: expect.objectContaining({
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
text: "hi",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
|
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,16 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||||
replyToId: replyToId ?? null,
|
replyToId: replyToId ?? null,
|
||||||
threadId: threadId ?? null,
|
threadId: threadId ?? null,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
mirror: params.sessionKey
|
||||||
|
? {
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
|
||||||
|
text,
|
||||||
|
mediaUrls,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const last = results.at(-1);
|
const last = results.at(-1);
|
||||||
return { ok: true, messageId: last?.messageId };
|
return { ok: true, messageId: last?.messageId };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,13 @@ export function registerCronAddCommand(cron: Command) {
|
||||||
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
|
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
|
||||||
)
|
)
|
||||||
.option("--best-effort-deliver", "Do not fail the job if delivery fails", false)
|
.option("--best-effort-deliver", "Do not fail the job if delivery fails", false)
|
||||||
.option("--post-prefix <prefix>", "Prefix for summary system event", "Cron")
|
.option("--post-prefix <prefix>", "Prefix for main-session post", "Cron")
|
||||||
|
.option(
|
||||||
|
"--post-mode <mode>",
|
||||||
|
"What to post back to main for isolated jobs (summary|full)",
|
||||||
|
"summary",
|
||||||
|
)
|
||||||
|
.option("--post-max-chars <n>", "Max chars when --post-mode=full (default 8000)", "8000")
|
||||||
.option("--json", "Output JSON", false)
|
.option("--json", "Output JSON", false)
|
||||||
.action(async (opts: GatewayRpcOpts & Record<string, unknown>) => {
|
.action(async (opts: GatewayRpcOpts & Record<string, unknown>) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -174,6 +180,14 @@ export function registerCronAddCommand(cron: Command) {
|
||||||
typeof opts.postPrefix === "string" && opts.postPrefix.trim()
|
typeof opts.postPrefix === "string" && opts.postPrefix.trim()
|
||||||
? opts.postPrefix.trim()
|
? opts.postPrefix.trim()
|
||||||
: "Cron",
|
: "Cron",
|
||||||
|
postToMainMode:
|
||||||
|
opts.postMode === "full" || opts.postMode === "summary"
|
||||||
|
? opts.postMode
|
||||||
|
: undefined,
|
||||||
|
postToMainMaxChars:
|
||||||
|
typeof opts.postMaxChars === "string" && /^\d+$/.test(opts.postMaxChars)
|
||||||
|
? Number.parseInt(opts.postMaxChars, 10)
|
||||||
|
: undefined,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,4 @@ export * from "./sessions/paths.js";
|
||||||
export * from "./sessions/session-key.js";
|
export * from "./sessions/session-key.js";
|
||||||
export * from "./sessions/store.js";
|
export * from "./sessions/store.js";
|
||||||
export * from "./sessions/types.js";
|
export * from "./sessions/types.js";
|
||||||
|
export * from "./sessions/transcript.js";
|
||||||
|
|
|
||||||
114
src/config/sessions/transcript.test.ts
Normal file
114
src/config/sessions/transcript.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
appendAssistantMessageToSessionTranscript,
|
||||||
|
resolveMirroredTranscriptText,
|
||||||
|
} from "./transcript.js";
|
||||||
|
|
||||||
|
describe("resolveMirroredTranscriptText", () => {
|
||||||
|
it("prefers media filenames over text", () => {
|
||||||
|
const result = resolveMirroredTranscriptText({
|
||||||
|
text: "caption here",
|
||||||
|
mediaUrls: ["https://example.com/files/report.pdf?sig=123"],
|
||||||
|
});
|
||||||
|
expect(result).toBe("report.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns trimmed text when no media", () => {
|
||||||
|
const result = resolveMirroredTranscriptText({ text: " hello " });
|
||||||
|
expect(result).toBe("hello");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("appendAssistantMessageToSessionTranscript", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let storePath: string;
|
||||||
|
let sessionsDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "transcript-test-"));
|
||||||
|
sessionsDir = path.join(tempDir, "agents", "main", "sessions");
|
||||||
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
storePath = path.join(sessionsDir, "sessions.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for missing sessionKey", async () => {
|
||||||
|
const result = await appendAssistantMessageToSessionTranscript({
|
||||||
|
sessionKey: "",
|
||||||
|
text: "test",
|
||||||
|
storePath,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.reason).toBe("missing sessionKey");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for empty text", async () => {
|
||||||
|
const result = await appendAssistantMessageToSessionTranscript({
|
||||||
|
sessionKey: "test-session",
|
||||||
|
text: " ",
|
||||||
|
storePath,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.reason).toBe("empty text");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for unknown sessionKey", async () => {
|
||||||
|
fs.writeFileSync(storePath, JSON.stringify({}), "utf-8");
|
||||||
|
const result = await appendAssistantMessageToSessionTranscript({
|
||||||
|
sessionKey: "nonexistent",
|
||||||
|
text: "test message",
|
||||||
|
storePath,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.reason).toContain("unknown sessionKey");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates transcript file and appends message for valid session", async () => {
|
||||||
|
const sessionId = "test-session-id";
|
||||||
|
const sessionKey = "test-session";
|
||||||
|
const store = {
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId,
|
||||||
|
chatType: "direct",
|
||||||
|
channel: "discord",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
fs.writeFileSync(storePath, JSON.stringify(store), "utf-8");
|
||||||
|
|
||||||
|
const result = await appendAssistantMessageToSessionTranscript({
|
||||||
|
sessionKey,
|
||||||
|
text: "Hello from delivery mirror!",
|
||||||
|
storePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(fs.existsSync(result.sessionFile)).toBe(true);
|
||||||
|
|
||||||
|
const lines = fs.readFileSync(result.sessionFile, "utf-8").trim().split("\n");
|
||||||
|
expect(lines.length).toBe(2); // header + message
|
||||||
|
|
||||||
|
const header = JSON.parse(lines[0]);
|
||||||
|
expect(header.type).toBe("session");
|
||||||
|
expect(header.id).toBe(sessionId);
|
||||||
|
|
||||||
|
const messageLine = JSON.parse(lines[1]);
|
||||||
|
expect(messageLine.type).toBe("message");
|
||||||
|
expect(messageLine.message.role).toBe("assistant");
|
||||||
|
expect(messageLine.message.content[0].type).toBe("text");
|
||||||
|
expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
131
src/config/sessions/transcript.ts
Normal file
131
src/config/sessions/transcript.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
import type { SessionEntry } from "./types.js";
|
||||||
|
import { loadSessionStore, updateSessionStore } from "./store.js";
|
||||||
|
import { resolveDefaultSessionStorePath, resolveSessionTranscriptPath } from "./paths.js";
|
||||||
|
|
||||||
|
function stripQuery(value: string): string {
|
||||||
|
const noHash = value.split("#")[0] ?? value;
|
||||||
|
return noHash.split("?")[0] ?? noHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFileNameFromMediaUrl(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const cleaned = stripQuery(trimmed);
|
||||||
|
try {
|
||||||
|
const parsed = new URL(cleaned);
|
||||||
|
const base = path.basename(parsed.pathname);
|
||||||
|
if (!base) return null;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(base);
|
||||||
|
} catch {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const base = path.basename(cleaned);
|
||||||
|
if (!base || base === "/" || base === ".") return null;
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMirroredTranscriptText(params: {
|
||||||
|
text?: string;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
}): string | null {
|
||||||
|
const mediaUrls = params.mediaUrls?.filter((url) => url && url.trim()) ?? [];
|
||||||
|
if (mediaUrls.length > 0) {
|
||||||
|
const names = mediaUrls
|
||||||
|
.map((url) => extractFileNameFromMediaUrl(url))
|
||||||
|
.filter((name): name is string => Boolean(name && name.trim()));
|
||||||
|
if (names.length > 0) return names.join(", ");
|
||||||
|
return "media";
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = params.text ?? "";
|
||||||
|
const trimmed = text.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSessionHeader(params: {
|
||||||
|
sessionFile: string;
|
||||||
|
sessionId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (fs.existsSync(params.sessionFile)) return;
|
||||||
|
await fs.promises.mkdir(path.dirname(params.sessionFile), { recursive: true });
|
||||||
|
const header = {
|
||||||
|
type: "session",
|
||||||
|
version: CURRENT_SESSION_VERSION,
|
||||||
|
id: params.sessionId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
cwd: process.cwd(),
|
||||||
|
};
|
||||||
|
await fs.promises.writeFile(params.sessionFile, `${JSON.stringify(header)}\n`, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendAssistantMessageToSessionTranscript(params: {
|
||||||
|
agentId?: string;
|
||||||
|
sessionKey: string;
|
||||||
|
text?: string;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
/** Optional override for store path (mostly for tests). */
|
||||||
|
storePath?: string;
|
||||||
|
}): Promise<{ ok: true; sessionFile: string } | { ok: false; reason: string }> {
|
||||||
|
const sessionKey = params.sessionKey.trim();
|
||||||
|
if (!sessionKey) return { ok: false, reason: "missing sessionKey" };
|
||||||
|
|
||||||
|
const mirrorText = resolveMirroredTranscriptText({
|
||||||
|
text: params.text,
|
||||||
|
mediaUrls: params.mediaUrls,
|
||||||
|
});
|
||||||
|
if (!mirrorText) return { ok: false, reason: "empty text" };
|
||||||
|
|
||||||
|
const storePath = params.storePath ?? resolveDefaultSessionStorePath(params.agentId);
|
||||||
|
const store = loadSessionStore(storePath, { skipCache: true });
|
||||||
|
const entry = store[sessionKey] as SessionEntry | undefined;
|
||||||
|
if (!entry?.sessionId) return { ok: false, reason: `unknown sessionKey: ${sessionKey}` };
|
||||||
|
|
||||||
|
const sessionFile =
|
||||||
|
entry.sessionFile?.trim() || resolveSessionTranscriptPath(entry.sessionId, params.agentId);
|
||||||
|
|
||||||
|
await ensureSessionHeader({ sessionFile, sessionId: entry.sessionId });
|
||||||
|
|
||||||
|
const sessionManager = SessionManager.open(sessionFile);
|
||||||
|
sessionManager.appendMessage({
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: mirrorText }],
|
||||||
|
api: "openai-responses",
|
||||||
|
provider: "clawdbot",
|
||||||
|
model: "delivery-mirror",
|
||||||
|
usage: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!entry.sessionFile || entry.sessionFile !== sessionFile) {
|
||||||
|
await updateSessionStore(storePath, (current) => {
|
||||||
|
current[sessionKey] = {
|
||||||
|
...entry,
|
||||||
|
sessionFile,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, sessionFile };
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,14 @@ export function pickSummaryFromPayloads(payloads: Array<{ text?: string | undefi
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function pickLastNonEmptyTextFromPayloads(payloads: Array<{ text?: string | undefined }>) {
|
||||||
|
for (let i = payloads.length - 1; i >= 0; i--) {
|
||||||
|
const clean = (payloads[i]?.text ?? "").trim();
|
||||||
|
if (clean) return clean;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if all payloads are just heartbeat ack responses (HEARTBEAT_OK).
|
* Check if all payloads are just heartbeat ack responses (HEARTBEAT_OK).
|
||||||
* Returns true if delivery should be skipped because there's no real content.
|
* Returns true if delivery should be skipped because there's no real content.
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import type { CronJob } from "../types.js";
|
||||||
import { resolveDeliveryTarget } from "./delivery-target.js";
|
import { resolveDeliveryTarget } from "./delivery-target.js";
|
||||||
import {
|
import {
|
||||||
isHeartbeatOnlyResponse,
|
isHeartbeatOnlyResponse,
|
||||||
|
pickLastNonEmptyTextFromPayloads,
|
||||||
pickSummaryFromOutput,
|
pickSummaryFromOutput,
|
||||||
pickSummaryFromPayloads,
|
pickSummaryFromPayloads,
|
||||||
resolveHeartbeatAckMaxChars,
|
resolveHeartbeatAckMaxChars,
|
||||||
|
|
@ -50,6 +51,8 @@ import { resolveCronSession } from "./session.js";
|
||||||
export type RunCronAgentTurnResult = {
|
export type RunCronAgentTurnResult = {
|
||||||
status: "ok" | "error" | "skipped";
|
status: "ok" | "error" | "skipped";
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
/** Last non-empty agent text output (not truncated). */
|
||||||
|
outputText?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -333,6 +336,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||||
}
|
}
|
||||||
const firstText = payloads[0]?.text ?? "";
|
const firstText = payloads[0]?.text ?? "";
|
||||||
const summary = pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText);
|
const summary = pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText);
|
||||||
|
const outputText = pickLastNonEmptyTextFromPayloads(payloads);
|
||||||
|
|
||||||
// Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content).
|
// Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content).
|
||||||
const ackMaxChars = resolveHeartbeatAckMaxChars(agentCfg);
|
const ackMaxChars = resolveHeartbeatAckMaxChars(agentCfg);
|
||||||
|
|
@ -346,12 +350,14 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||||
return {
|
return {
|
||||||
status: "error",
|
status: "error",
|
||||||
summary,
|
summary,
|
||||||
|
outputText,
|
||||||
error: reason,
|
error: reason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
status: "skipped",
|
status: "skipped",
|
||||||
summary: `Delivery skipped (${reason}).`,
|
summary: `Delivery skipped (${reason}).`,
|
||||||
|
outputText,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -366,11 +372,11 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!bestEffortDeliver) {
|
if (!bestEffortDeliver) {
|
||||||
return { status: "error", summary, error: String(err) };
|
return { status: "error", summary, outputText, error: String(err) };
|
||||||
}
|
}
|
||||||
return { status: "ok", summary };
|
return { status: "ok", summary, outputText };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: "ok", summary };
|
return { status: "ok", summary, outputText };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ export type CronServiceDeps = {
|
||||||
runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{
|
runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{
|
||||||
status: "ok" | "error" | "skipped";
|
status: "ok" | "error" | "skipped";
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
/** Last non-empty agent text output (not truncated). */
|
||||||
|
outputText?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
onEvent?: (evt: CronEvent) => void;
|
onEvent?: (evt: CronEvent) => void;
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,12 @@ export async function executeJob(
|
||||||
|
|
||||||
let deleted = false;
|
let deleted = false;
|
||||||
|
|
||||||
const finish = async (status: "ok" | "error" | "skipped", err?: string, summary?: string) => {
|
const finish = async (
|
||||||
|
status: "ok" | "error" | "skipped",
|
||||||
|
err?: string,
|
||||||
|
summary?: string,
|
||||||
|
outputText?: string,
|
||||||
|
) => {
|
||||||
const endedAt = state.deps.nowMs();
|
const endedAt = state.deps.nowMs();
|
||||||
job.state.runningAtMs = undefined;
|
job.state.runningAtMs = undefined;
|
||||||
job.state.lastRunAtMs = startedAt;
|
job.state.lastRunAtMs = startedAt;
|
||||||
|
|
@ -108,7 +113,19 @@ export async function executeJob(
|
||||||
|
|
||||||
if (job.sessionTarget === "isolated") {
|
if (job.sessionTarget === "isolated") {
|
||||||
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
|
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
|
||||||
const body = (summary ?? err ?? status).trim();
|
const mode = job.isolation?.postToMainMode ?? "summary";
|
||||||
|
|
||||||
|
let body = (summary ?? err ?? status).trim();
|
||||||
|
if (mode === "full") {
|
||||||
|
// Prefer full agent output if available; fall back to summary.
|
||||||
|
const maxCharsRaw = job.isolation?.postToMainMaxChars;
|
||||||
|
const maxChars = Number.isFinite(maxCharsRaw) ? Math.max(0, maxCharsRaw as number) : 8000;
|
||||||
|
const fullText = (outputText ?? "").trim();
|
||||||
|
if (fullText) {
|
||||||
|
body = fullText.length > maxChars ? `${fullText.slice(0, maxChars)}…` : fullText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`;
|
const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`;
|
||||||
state.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`, {
|
state.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`, {
|
||||||
agentId: job.agentId,
|
agentId: job.agentId,
|
||||||
|
|
@ -182,9 +199,10 @@ export async function executeJob(
|
||||||
job,
|
job,
|
||||||
message: job.payload.message,
|
message: job.payload.message,
|
||||||
});
|
});
|
||||||
if (res.status === "ok") await finish("ok", undefined, res.summary);
|
if (res.status === "ok") await finish("ok", undefined, res.summary, res.outputText);
|
||||||
else if (res.status === "skipped") await finish("skipped", undefined, res.summary);
|
else if (res.status === "skipped")
|
||||||
else await finish("error", res.error ?? "cron job failed", res.summary);
|
await finish("skipped", undefined, res.summary, res.outputText);
|
||||||
|
else await finish("error", res.error ?? "cron job failed", res.summary, res.outputText);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await finish("error", String(err));
|
await finish("error", String(err));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,14 @@ export type CronPayload =
|
||||||
|
|
||||||
export type CronIsolation = {
|
export type CronIsolation = {
|
||||||
postToMainPrefix?: string;
|
postToMainPrefix?: string;
|
||||||
|
/**
|
||||||
|
* What to post back into the main session after an isolated run.
|
||||||
|
* - summary: small status/summary line (default)
|
||||||
|
* - full: the agent's final text output (optionally truncated)
|
||||||
|
*/
|
||||||
|
postToMainMode?: "summary" | "full";
|
||||||
|
/** Max chars when postToMainMode="full". Default: 8000. */
|
||||||
|
postToMainMaxChars?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CronJobState = {
|
export type CronJobState = {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ export const SendParamsSchema = Type.Object(
|
||||||
gifPlayback: Type.Optional(Type.Boolean()),
|
gifPlayback: Type.Optional(Type.Boolean()),
|
||||||
channel: Type.Optional(Type.String()),
|
channel: Type.Optional(Type.String()),
|
||||||
accountId: Type.Optional(Type.String()),
|
accountId: Type.Optional(Type.String()),
|
||||||
|
/** Optional session key for mirroring delivered output back into the transcript. */
|
||||||
|
sessionKey: Type.Optional(Type.String()),
|
||||||
idempotencyKey: NonEmptyString,
|
idempotencyKey: NonEmptyString,
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ export const CronPayloadSchema = Type.Union([
|
||||||
export const CronIsolationSchema = Type.Object(
|
export const CronIsolationSchema = Type.Object(
|
||||||
{
|
{
|
||||||
postToMainPrefix: Type.Optional(Type.String()),
|
postToMainPrefix: Type.Optional(Type.String()),
|
||||||
|
postToMainMode: Type.Optional(Type.Union([Type.Literal("summary"), Type.Literal("full")])),
|
||||||
|
postToMainMaxChars: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
102
src/gateway/server-methods/send.test.ts
Normal file
102
src/gateway/server-methods/send.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { GatewayRequestContext } from "./types.js";
|
||||||
|
import { sendHandlers } from "./send.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
deliverOutboundPayloads: vi.fn(),
|
||||||
|
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../config/config.js", () => ({
|
||||||
|
loadConfig: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../channels/plugins/index.js", () => ({
|
||||||
|
getChannelPlugin: () => ({ outbound: {} }),
|
||||||
|
normalizeChannelId: (value: string) => value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../infra/outbound/targets.js", () => ({
|
||||||
|
resolveOutboundTarget: () => ({ ok: true, to: "resolved" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../infra/outbound/deliver.js", () => ({
|
||||||
|
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../config/sessions.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
||||||
|
"../../config/sessions.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeContext = (): GatewayRequestContext =>
|
||||||
|
({
|
||||||
|
dedupe: new Map(),
|
||||||
|
}) as unknown as GatewayRequestContext;
|
||||||
|
|
||||||
|
describe("gateway send mirroring", () => {
|
||||||
|
it("does not mirror when delivery returns no results", async () => {
|
||||||
|
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const respond = vi.fn();
|
||||||
|
await sendHandlers.send({
|
||||||
|
params: {
|
||||||
|
to: "channel:C1",
|
||||||
|
message: "hi",
|
||||||
|
channel: "slack",
|
||||||
|
idempotencyKey: "idem-1",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
},
|
||||||
|
respond,
|
||||||
|
context: makeContext(),
|
||||||
|
req: { type: "req", id: "1", method: "send" },
|
||||||
|
client: null,
|
||||||
|
isWebchatConnect: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
mirror: expect.objectContaining({
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mirrors media filenames when delivery succeeds", async () => {
|
||||||
|
mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m1", channel: "slack" }]);
|
||||||
|
|
||||||
|
const respond = vi.fn();
|
||||||
|
await sendHandlers.send({
|
||||||
|
params: {
|
||||||
|
to: "channel:C1",
|
||||||
|
message: "caption",
|
||||||
|
mediaUrl: "https://example.com/files/report.pdf?sig=1",
|
||||||
|
channel: "slack",
|
||||||
|
idempotencyKey: "idem-2",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
},
|
||||||
|
respond,
|
||||||
|
context: makeContext(),
|
||||||
|
req: { type: "req", id: "1", method: "send" },
|
||||||
|
client: null,
|
||||||
|
isWebchatConnect: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
mirror: expect.objectContaining({
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
text: "caption",
|
||||||
|
mediaUrls: ["https://example.com/files/report.pdf?sig=1"],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,7 @@ import type { ChannelId } from "../../channels/plugins/types.js";
|
||||||
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
|
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
|
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
|
||||||
|
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||||
import type { OutboundChannel } from "../../infra/outbound/targets.js";
|
import type { OutboundChannel } from "../../infra/outbound/targets.js";
|
||||||
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
|
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
|
||||||
import { normalizePollInput } from "../../polls.js";
|
import { normalizePollInput } from "../../polls.js";
|
||||||
|
|
@ -37,6 +38,7 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||||
gifPlayback?: boolean;
|
gifPlayback?: boolean;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
sessionKey?: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
};
|
};
|
||||||
const idem = request.idempotencyKey;
|
const idem = request.idempotencyKey;
|
||||||
|
|
@ -94,7 +96,20 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||||
accountId,
|
accountId,
|
||||||
payloads: [{ text: message, mediaUrl: request.mediaUrl }],
|
payloads: [{ text: message, mediaUrl: request.mediaUrl }],
|
||||||
gifPlayback: request.gifPlayback,
|
gifPlayback: request.gifPlayback,
|
||||||
|
mirror:
|
||||||
|
typeof request.sessionKey === "string" && request.sessionKey.trim()
|
||||||
|
? {
|
||||||
|
sessionKey: request.sessionKey.trim(),
|
||||||
|
agentId: resolveSessionAgentId({
|
||||||
|
sessionKey: request.sessionKey.trim(),
|
||||||
|
config: cfg,
|
||||||
|
}),
|
||||||
|
text: message,
|
||||||
|
mediaUrls: request.mediaUrl ? [request.mediaUrl] : undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = results.at(-1);
|
const result = results.at(-1);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error("No delivery result");
|
throw new Error("No delivery result");
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,22 @@ import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { markdownToSignalTextChunks } from "../../signal/format.js";
|
import { markdownToSignalTextChunks } from "../../signal/format.js";
|
||||||
import { deliverOutboundPayloads, normalizeOutboundPayloads } from "./deliver.js";
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../config/sessions.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
||||||
|
"../../config/sessions.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { deliverOutboundPayloads, normalizeOutboundPayloads } = await import("./deliver.js");
|
||||||
|
|
||||||
describe("deliverOutboundPayloads", () => {
|
describe("deliverOutboundPayloads", () => {
|
||||||
it("chunks telegram markdown and passes through accountId", async () => {
|
it("chunks telegram markdown and passes through accountId", async () => {
|
||||||
|
|
@ -193,4 +208,29 @@ describe("deliverOutboundPayloads", () => {
|
||||||
expect(onError).toHaveBeenCalledTimes(1);
|
expect(onError).toHaveBeenCalledTimes(1);
|
||||||
expect(results).toEqual([{ channel: "whatsapp", messageId: "w2", toJid: "jid" }]);
|
expect(results).toEqual([{ channel: "whatsapp", messageId: "w2", toJid: "jid" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("mirrors delivered output when mirror options are provided", async () => {
|
||||||
|
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } },
|
||||||
|
};
|
||||||
|
mocks.appendAssistantMessageToSessionTranscript.mockClear();
|
||||||
|
|
||||||
|
await deliverOutboundPayloads({
|
||||||
|
cfg,
|
||||||
|
channel: "telegram",
|
||||||
|
to: "123",
|
||||||
|
payloads: [{ text: "caption", mediaUrl: "https://example.com/files/report.pdf?sig=1" }],
|
||||||
|
deps: { sendTelegram },
|
||||||
|
mirror: {
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
text: "caption",
|
||||||
|
mediaUrls: ["https://example.com/files/report.pdf?sig=1"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ text: "report.pdf" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ import { sendMessageSignal } from "../../signal/send.js";
|
||||||
import type { sendMessageSlack } from "../../slack/send.js";
|
import type { sendMessageSlack } from "../../slack/send.js";
|
||||||
import type { sendMessageTelegram } from "../../telegram/send.js";
|
import type { sendMessageTelegram } from "../../telegram/send.js";
|
||||||
import type { sendMessageWhatsApp } from "../../web/outbound.js";
|
import type { sendMessageWhatsApp } from "../../web/outbound.js";
|
||||||
|
import {
|
||||||
|
appendAssistantMessageToSessionTranscript,
|
||||||
|
resolveMirroredTranscriptText,
|
||||||
|
} from "../../config/sessions.js";
|
||||||
import type { NormalizedOutboundPayload } from "./payloads.js";
|
import type { NormalizedOutboundPayload } from "./payloads.js";
|
||||||
import { normalizeOutboundPayloads } from "./payloads.js";
|
import { normalizeOutboundPayloads } from "./payloads.js";
|
||||||
import type { OutboundChannel } from "./targets.js";
|
import type { OutboundChannel } from "./targets.js";
|
||||||
|
|
@ -159,6 +163,12 @@ export async function deliverOutboundPayloads(params: {
|
||||||
bestEffort?: boolean;
|
bestEffort?: boolean;
|
||||||
onError?: (err: unknown, payload: NormalizedOutboundPayload) => void;
|
onError?: (err: unknown, payload: NormalizedOutboundPayload) => void;
|
||||||
onPayload?: (payload: NormalizedOutboundPayload) => void;
|
onPayload?: (payload: NormalizedOutboundPayload) => void;
|
||||||
|
mirror?: {
|
||||||
|
sessionKey: string;
|
||||||
|
agentId?: string;
|
||||||
|
text?: string;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
};
|
||||||
}): Promise<OutboundDeliveryResult[]> {
|
}): Promise<OutboundDeliveryResult[]> {
|
||||||
const { cfg, channel, to, payloads } = params;
|
const { cfg, channel, to, payloads } = params;
|
||||||
const accountId = params.accountId;
|
const accountId = params.accountId;
|
||||||
|
|
@ -279,5 +289,18 @@ export async function deliverOutboundPayloads(params: {
|
||||||
params.onError?.(err, payload);
|
params.onError?.(err, payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (params.mirror && results.length > 0) {
|
||||||
|
const mirrorText = resolveMirroredTranscriptText({
|
||||||
|
text: params.mirror.text,
|
||||||
|
mediaUrls: params.mirror.mediaUrls,
|
||||||
|
});
|
||||||
|
if (mirrorText) {
|
||||||
|
await appendAssistantMessageToSessionTranscript({
|
||||||
|
agentId: params.mirror.agentId,
|
||||||
|
sessionKey: params.mirror.sessionKey,
|
||||||
|
text: mirrorText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ export type RunMessageActionParams = {
|
||||||
toolContext?: ChannelThreadingToolContext;
|
toolContext?: ChannelThreadingToolContext;
|
||||||
gateway?: MessageActionRunnerGateway;
|
gateway?: MessageActionRunnerGateway;
|
||||||
deps?: OutboundSendDeps;
|
deps?: OutboundSendDeps;
|
||||||
|
sessionKey?: string;
|
||||||
|
agentId?: string;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -265,6 +267,13 @@ export async function runMessageAction(
|
||||||
bestEffort: bestEffort ?? undefined,
|
bestEffort: bestEffort ?? undefined,
|
||||||
deps: input.deps,
|
deps: input.deps,
|
||||||
gateway,
|
gateway,
|
||||||
|
mirror:
|
||||||
|
input.sessionKey && !dryRun
|
||||||
|
? {
|
||||||
|
sessionKey: input.sessionKey,
|
||||||
|
agentId: input.agentId,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ type MessageSendParams = {
|
||||||
cfg?: ClawdbotConfig;
|
cfg?: ClawdbotConfig;
|
||||||
gateway?: MessageGatewayOptions;
|
gateway?: MessageGatewayOptions;
|
||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
|
mirror?: {
|
||||||
|
sessionKey: string;
|
||||||
|
agentId?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageSendResult = {
|
export type MessageSendResult = {
|
||||||
|
|
@ -142,6 +146,13 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
||||||
gifPlayback: params.gifPlayback,
|
gifPlayback: params.gifPlayback,
|
||||||
deps: params.deps,
|
deps: params.deps,
|
||||||
bestEffort: params.bestEffort,
|
bestEffort: params.bestEffort,
|
||||||
|
mirror: params.mirror
|
||||||
|
? {
|
||||||
|
...params.mirror,
|
||||||
|
text: params.content,
|
||||||
|
mediaUrls: params.mediaUrl ? [params.mediaUrl] : undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -165,6 +176,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
||||||
gifPlayback: params.gifPlayback,
|
gifPlayback: params.gifPlayback,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
channel,
|
channel,
|
||||||
|
sessionKey: params.mirror?.sessionKey,
|
||||||
idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(),
|
idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(),
|
||||||
},
|
},
|
||||||
timeoutMs: gateway.timeoutMs,
|
timeoutMs: gateway.timeoutMs,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue