Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50faccaba8 | ||
|
|
4d59d1758c |
No files matched your search
+1
-4
@@ -10,7 +10,6 @@ Docs: https://docs.clawd.bot
|
||||
|
||||
### Changes
|
||||
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
|
||||
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||
- Docs: add verbose installer troubleshooting guidance.
|
||||
- Docs: update Fly.io guide notes.
|
||||
@@ -25,8 +24,6 @@ Docs: https://docs.clawd.bot
|
||||
- Heartbeat: normalize target identifiers for consistent routing.
|
||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||
- Agents: use the active auth profile for auto-compaction recovery.
|
||||
@@ -37,7 +34,7 @@ Docs: https://docs.clawd.bot
|
||||
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||
- Google Chat: normalize space targets without double `spaces/` prefix.
|
||||
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
||||
- CLI: register lazy subcommands before parse so non-help invocations work. (#1683) Thanks @grrowl.
|
||||
|
||||
## 2026.1.23-1
|
||||
|
||||
|
||||
@@ -477,32 +477,31 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></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/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a>
|
||||
<a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/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/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/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></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/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></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/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/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></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/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/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/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/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></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/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></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/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></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/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/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/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/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/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></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/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></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/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/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/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a>
|
||||
<a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a>
|
||||
<a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a>
|
||||
<a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></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/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
|
||||
<a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></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/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></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/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/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/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a>
|
||||
<a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></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/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/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/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/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a>
|
||||
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a>
|
||||
<a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/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/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/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/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/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/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a>
|
||||
<a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></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/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a>
|
||||
<a href="https://github.com/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/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
|
||||
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></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/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></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/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></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/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></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/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
|
||||
<a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></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/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></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/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></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/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a>
|
||||
<a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
|
||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a>
|
||||
<a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></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/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></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/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></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/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></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/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></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/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a>
|
||||
<a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/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/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/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/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/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></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/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/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/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/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/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/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></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/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/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/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/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/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
|
||||
<a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/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/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></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/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></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/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a>
|
||||
<a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a>
|
||||
<a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></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/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></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/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a>
|
||||
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></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/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a>
|
||||
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
|
||||
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/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/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
||||
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/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/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/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/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/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></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/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/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a>
|
||||
<a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></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/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/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/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a>
|
||||
<a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
|
||||
<a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></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/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></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/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a>
|
||||
<a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></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/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></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/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
|
||||
<a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
|
||||
<a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
|
||||
<a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></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/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></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/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></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/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></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/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></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/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
|
||||
<a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
@@ -205,7 +205,6 @@ Notes:
|
||||
## Capabilities & limits
|
||||
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
||||
- Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17).
|
||||
- Optional newline chunking: set `channels.discord.chunkMode="newline"` to split on each line before length chunking.
|
||||
- File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB).
|
||||
- Mention-gated guild replies by default to avoid noisy bots.
|
||||
- Reply context is injected when a message references another message (quoted content + ids).
|
||||
@@ -307,7 +306,6 @@ ack reaction after the bot replies.
|
||||
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
||||
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
|
||||
- `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline before length chunking.
|
||||
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
|
||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
|
||||
|
||||
@@ -219,7 +219,6 @@ This is useful when you want an isolated personality/model for a specific thread
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.imessage.chunkMode="newline"` to split on each line before length chunking.
|
||||
- Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16).
|
||||
|
||||
## Addressing / delivery targets
|
||||
@@ -254,7 +253,6 @@ Provider options:
|
||||
- `channels.imessage.includeAttachments`: ingest attachments into context.
|
||||
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.imessage.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.imessage.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
|
||||
Related global options:
|
||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
|
||||
|
||||
@@ -32,8 +32,6 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
## Notes
|
||||
|
||||
- Channels can run simultaneously; configure multiple and Clawdbot will route per chat.
|
||||
- Fastest setup is usually **Telegram** (simple bot token). WhatsApp requires QR pairing and
|
||||
stores more state on disk.
|
||||
- Group behavior varies by channel; see [Groups](/concepts/groups).
|
||||
- DM pairing and allowlists are enforced for safety; see [Security](/gateway/security).
|
||||
- Telegram internals: [grammY notes](/channels/grammy).
|
||||
|
||||
@@ -215,7 +215,6 @@ Provider options:
|
||||
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
||||
- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
||||
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
||||
|
||||
@@ -415,7 +415,6 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
||||
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
||||
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
|
||||
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
||||
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
||||
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
|
||||
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
||||
|
||||
@@ -114,7 +114,6 @@ Provider options:
|
||||
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
|
||||
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
|
||||
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
|
||||
- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
|
||||
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
|
||||
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
|
||||
@@ -95,7 +95,6 @@ Groups:
|
||||
|
||||
## Media + limits
|
||||
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on each line before length chunking.
|
||||
- Attachments supported (base64 fetched from `signal-cli`).
|
||||
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
|
||||
- Use `channels.signal.ignoreAttachments` to skip downloading media.
|
||||
@@ -106,29 +105,8 @@ Groups:
|
||||
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
|
||||
- Signal-cli does not expose read receipts for groups.
|
||||
|
||||
## Reactions (message tool)
|
||||
- Use `message action=react` with `channel=signal`.
|
||||
- Targets: sender E.164 or UUID (use `uuid:<id>` from pairing output; bare UUID works too).
|
||||
- `messageId` is the Signal timestamp for the message you’re reacting to.
|
||||
- Group reactions require `targetAuthor` or `targetAuthorUuid`.
|
||||
|
||||
Examples:
|
||||
```
|
||||
message action=react channel=signal target=uuid:123e4567-e89b-12d3-a456-426614174000 messageId=1737630212345 emoji=🔥
|
||||
message action=react channel=signal target=+15551234567 messageId=1737630212345 emoji=🔥 remove=true
|
||||
message action=react channel=signal target=signal:group:<groupId> targetAuthor=uuid:<sender-uuid> messageId=1737630212345 emoji=✅
|
||||
```
|
||||
|
||||
Config:
|
||||
- `channels.signal.actions.reactions`: enable/disable reaction actions (default true).
|
||||
- `channels.signal.reactionLevel`: `off | ack | minimal | extensive`.
|
||||
- `off`/`ack` disables agent reactions (message tool `react` will error).
|
||||
- `minimal`/`extensive` enables agent reactions and sets the guidance level.
|
||||
- Per-account overrides: `channels.signal.accounts.<id>.actions.reactions`, `channels.signal.accounts.<id>.reactionLevel`.
|
||||
|
||||
## Delivery targets (CLI/cron)
|
||||
- DMs: `signal:+15551234567` (or plain E.164).
|
||||
- UUID DMs: `uuid:<id>` (or bare UUID).
|
||||
- Groups: `signal:group:<groupId>`.
|
||||
- Usernames: `username:<name>` (if supported by your Signal account).
|
||||
|
||||
@@ -153,7 +131,6 @@ Provider options:
|
||||
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
||||
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
|
||||
- `channels.signal.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.signal.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
|
||||
Related global options:
|
||||
|
||||
@@ -349,7 +349,6 @@ ack reaction after the bot replies.
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on each line before length chunking.
|
||||
- Media uploads are capped by `channels.slack.mediaMaxMb` (default 20).
|
||||
|
||||
## Reply threading
|
||||
|
||||
@@ -128,7 +128,6 @@ Notes:
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.telegram.chunkMode="newline"` to split on each line before length chunking.
|
||||
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
|
||||
- Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs.
|
||||
- Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||
@@ -517,7 +516,6 @@ Provider options:
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
|
||||
@@ -271,13 +271,12 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.whatsapp.chunkMode="newline"` to split on each line before length chunking.
|
||||
- Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB).
|
||||
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
|
||||
|
||||
## Outbound send (text + media)
|
||||
- Uses active web listener; error if gateway not running.
|
||||
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`, optional `channels.whatsapp.chunkMode`).
|
||||
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`).
|
||||
- Media:
|
||||
- Image/video/audio/document supported.
|
||||
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
|
||||
|
||||
+2
-10
@@ -66,12 +66,11 @@ Name lookup:
|
||||
- Discord only: `--poll-duration-hours`, `--message`
|
||||
|
||||
- `react`
|
||||
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal
|
||||
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp
|
||||
- Required: `--message-id`, `--target`
|
||||
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--target-author`, `--target-author-uuid`
|
||||
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
|
||||
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
|
||||
- WhatsApp only: `--participant`, `--from-me`
|
||||
- Signal group reactions: `--target-author` or `--target-author-uuid` required
|
||||
|
||||
- `reactions`
|
||||
- Channels: Discord/Google Chat/Slack
|
||||
@@ -214,13 +213,6 @@ clawdbot message react --channel slack \
|
||||
--target C123 --message-id 456 --emoji "✅"
|
||||
```
|
||||
|
||||
React in a Signal group:
|
||||
```
|
||||
clawdbot message react --channel signal \
|
||||
--target signal:group:abc123 --message-id 1737630212345 \
|
||||
--emoji "✅" --target-author-uuid 123e4567-e89b-12d3-a456-426614174000
|
||||
```
|
||||
|
||||
Send Telegram inline buttons:
|
||||
```
|
||||
clawdbot message send --channel telegram --target @mychat --message "Choose:" \
|
||||
|
||||
@@ -31,7 +31,6 @@ These files live under the workspace (`agents.defaults.workspace`, default
|
||||
- Decisions, preferences, and durable facts go to `MEMORY.md`.
|
||||
- Day-to-day notes and running context go to `memory/YYYY-MM-DD.md`.
|
||||
- If someone says "remember this," write it down (do not keep it in RAM).
|
||||
- If you want something to stick, **ask the bot to write it** into memory.
|
||||
|
||||
## Automatic memory flush (pre-compaction ping)
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ Legend:
|
||||
- `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
|
||||
- `agents.defaults.blockStreamingCoalesce`: `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send).
|
||||
- Channel hard cap: `*.textChunkLimit` (e.g., `channels.whatsapp.textChunkLimit`).
|
||||
- Channel chunk mode: `*.chunkMode` (`length` default, `newline` splits on each line before length chunking).
|
||||
- Discord soft cap: `channels.discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping.
|
||||
|
||||
**Boundary semantics:**
|
||||
|
||||
@@ -504,7 +504,6 @@ For groups, use `channels.whatsapp.groupPolicy` + `channels.whatsapp.groupAllowF
|
||||
dmPolicy: "pairing", // pairing | allowlist | open | disabled
|
||||
allowFrom: ["+15555550123", "+447700900123"],
|
||||
textChunkLimit: 4000, // optional outbound chunk size (chars)
|
||||
chunkMode: "length", // optional chunking mode (length | newline)
|
||||
mediaMaxMb: 50 // optional inbound media cap (MB)
|
||||
}
|
||||
}
|
||||
@@ -1106,7 +1105,6 @@ Multi-account support lives under `channels.discord.accounts` (see the multi-acc
|
||||
},
|
||||
historyLimit: 20, // include last N guild messages as context
|
||||
textChunkLimit: 2000, // optional outbound text chunk size (chars)
|
||||
chunkMode: "length", // optional chunking mode (length | newline)
|
||||
maxLinesPerMessage: 17, // soft max lines per message (Discord UI clipping)
|
||||
retry: { // outbound retry policy
|
||||
attempts: 3,
|
||||
@@ -1127,7 +1125,7 @@ Reaction notification modes:
|
||||
- `own`: reactions on the bot's own messages (default).
|
||||
- `all`: all reactions on all messages.
|
||||
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
||||
Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Set `channels.discord.chunkMode="newline"` to split on line boundaries before length chunking. Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars.
|
||||
Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars.
|
||||
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
|
||||
|
||||
### `channels.googlechat` (Chat API webhook)
|
||||
@@ -1220,7 +1218,6 @@ Slack runs in Socket Mode and requires both a bot token and app token:
|
||||
ephemeral: true
|
||||
},
|
||||
textChunkLimit: 4000,
|
||||
chunkMode: "length",
|
||||
mediaMaxMb: 20
|
||||
}
|
||||
}
|
||||
@@ -1270,8 +1267,7 @@ Mattermost requires a bot token plus the base URL for your server:
|
||||
dmPolicy: "pairing",
|
||||
chatmode: "oncall", // oncall | onmessage | onchar
|
||||
oncharPrefixes: [">", "!"],
|
||||
textChunkLimit: 4000,
|
||||
chunkMode: "length"
|
||||
textChunkLimit: 4000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,6 @@ Clawdbot is both a product and an experiment: you’re wiring frontier-model beh
|
||||
- where the bot is allowed to act
|
||||
- what the bot can touch
|
||||
|
||||
Start with the smallest access that still works, then widen it as you gain confidence.
|
||||
|
||||
### What the audit checks (high level)
|
||||
|
||||
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
|
||||
|
||||
@@ -48,7 +48,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [What’s the difference between the hackable (git) install and npm install?](#whats-the-difference-between-the-hackable-git-install-and-npm-install)
|
||||
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
|
||||
- [Should I run the Gateway on my laptop or a VPS?](#should-i-run-the-gateway-on-my-laptop-or-a-vps)
|
||||
- [How important is it to run Clawdbot on a dedicated machine?](#how-important-is-it-to-run-clawdbot-on-a-dedicated-machine)
|
||||
- [What is Clawdbot?](#what-is-clawdbot)
|
||||
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
|
||||
- [What’s the value proposition?](#whats-the-value-proposition)
|
||||
@@ -86,7 +85,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [Remote gateways + nodes](#remote-gateways-nodes)
|
||||
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
||||
- [How can my agent access my computer if the Gateway is hosted remotely?](#how-can-my-agent-access-my-computer-if-the-gateway-is-hosted-remotely)
|
||||
- [Can two Clawdbots talk to each other (local + VPS)?](#can-two-clawdbots-talk-to-each-other-local--vps)
|
||||
- [Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?](#is-there-a-benefit-to-using-a-node-on-my-personal-laptop-instead-of-ssh-from-a-vps)
|
||||
- [Do nodes run a gateway service?](#do-nodes-run-a-gateway-service)
|
||||
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
|
||||
@@ -144,7 +142,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [Logging and debugging](#logging-and-debugging)
|
||||
- [Where are logs?](#where-are-logs)
|
||||
- [How do I start/stop/restart the Gateway service?](#how-do-i-startstoprestart-the-gateway-service)
|
||||
- [I closed my terminal on Windows — how do I restart Clawdbot?](#i-closed-my-terminal-on-windows--how-do-i-restart-clawdbot)
|
||||
- [How do I completely stop then start the Gateway?](#how-do-i-completely-stop-then-start-the-gateway)
|
||||
- [ELI5: `clawdbot gateway restart` vs `clawdbot gateway`](#eli5-clawdbot-gateway-restart-vs-clawdbot-gateway)
|
||||
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
|
||||
@@ -233,9 +230,6 @@ This installs Clawdbot **from a git checkout**, so the agent can read the code +
|
||||
reason about the exact version you are running. You can always switch back to stable later
|
||||
by re-running the installer without `--install-method git`.
|
||||
|
||||
Tip: ask the agent to **plan and supervise** the fix (step-by-step), then execute only the
|
||||
necessary commands. That keeps changes small and easier to audit.
|
||||
|
||||
If you discover a real bug or fix, please file a GitHub issue or send a PR:
|
||||
https://github.com/clawdbot/clawdbot/issues
|
||||
https://github.com/clawdbot/clawdbot/pulls
|
||||
@@ -313,9 +307,6 @@ disk as enough for personal use, and note that a **Raspberry Pi 4 can run it**.
|
||||
If you want extra headroom (logs, media, other services), **2GB is recommended**, but it’s
|
||||
not a hard minimum.
|
||||
|
||||
Tip: a small Pi/VPS can host the Gateway, and you can pair **nodes** on your laptop/phone for
|
||||
local screen/camera/canvas or command execution. See [Nodes](/nodes).
|
||||
|
||||
### Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?
|
||||
|
||||
Yes. Copy the **state directory** and **workspace**, then run Doctor once. This
|
||||
@@ -729,15 +720,6 @@ lowest friction and you’re okay with sleep/restarts, run it locally.
|
||||
|
||||
**Recommended default:** VPS if you had gateway disconnects before. Local is great when you’re actively using the Mac and want local file access or UI automation with a visible browser.
|
||||
|
||||
### How important is it to run Clawdbot on a dedicated machine?
|
||||
|
||||
Not required, but **recommended for reliability and isolation**.
|
||||
|
||||
- **Dedicated host (VPS/Mac mini/Pi):** always‑on, fewer sleep/reboot interruptions, cleaner permissions, easier to keep running.
|
||||
- **Shared laptop/desktop:** totally fine for testing and active use, but expect pauses when the machine sleeps or updates.
|
||||
|
||||
If you want the best of both worlds, keep the Gateway on a dedicated host and pair your laptop as a **node** for local screen/camera/exec tools. See [Nodes](/nodes).
|
||||
|
||||
## What is Clawdbot?
|
||||
|
||||
### What is Clawdbot, in one paragraph?
|
||||
@@ -991,9 +973,6 @@ If the bot “forgets” after a restart, confirm the Gateway is using the same
|
||||
workspace on every launch (and remember: remote mode uses the **gateway host’s**
|
||||
workspace, not your local laptop).
|
||||
|
||||
Tip: if you want a durable behavior or preference, ask the bot to **write it into
|
||||
AGENTS.md or MEMORY.md** rather than relying on chat history.
|
||||
|
||||
See [Agent workspace](/concepts/agent-workspace) and [Memory](/concepts/memory).
|
||||
|
||||
### What’s the recommended backup strategy?
|
||||
@@ -1188,29 +1167,6 @@ pair devices you trust, and review [Security](/gateway/security).
|
||||
|
||||
Docs: [Nodes](/nodes), [Gateway protocol](/gateway/protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security).
|
||||
|
||||
### Can two Clawdbots talk to each other (local + VPS)?
|
||||
|
||||
Yes. There is no built-in "bot-to-bot" bridge, but you can wire it up in a few
|
||||
reliable ways:
|
||||
|
||||
**Simplest:** use a normal chat channel both bots can access (Telegram/Slack/WhatsApp).
|
||||
Have Bot A send a message to Bot B, then let Bot B reply as usual.
|
||||
|
||||
**CLI bridge (generic):** run a script that calls the other Gateway with
|
||||
`clawdbot agent --message ... --deliver`, targeting a chat where the other bot
|
||||
listens. If one bot is on Railway/VPS, point your CLI at that remote Gateway
|
||||
via SSH/Tailscale (see [Remote access](/gateway/remote)).
|
||||
|
||||
Example pattern (run from a machine that can reach the target Gateway):
|
||||
```bash
|
||||
clawdbot agent --message "Hello from local bot" --deliver --channel telegram --reply-to <chat-id>
|
||||
```
|
||||
|
||||
Tip: add a guardrail so the two bots do not loop endlessly (mention-only, channel
|
||||
allowlists, or a "do not reply to bot messages" rule).
|
||||
|
||||
Docs: [Remote access](/gateway/remote), [Agent CLI](/cli/agent), [Agent send](/tools/agent-send).
|
||||
|
||||
### Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?
|
||||
|
||||
Yes — nodes are the first‑class way to reach your laptop from a remote Gateway, and they
|
||||
@@ -1559,11 +1515,6 @@ Models are referenced as `provider/model` (example: `anthropic/claude-opus-4-5`)
|
||||
MiniMax M2.1 has its own docs: [MiniMax](/providers/minimax) and
|
||||
[Local models](/gateway/local-models).
|
||||
|
||||
Rule of thumb: use the **best model you can afford** for high-stakes work, and a cheaper
|
||||
model for routine chat or summaries. You can route models per agent and use sub-agents to
|
||||
parallelize long tasks (each sub-agent consumes tokens). See [Models](/concepts/models) and
|
||||
[Sub-agents](/tools/subagents).
|
||||
|
||||
Strong warning: weaker/over-quantized models are more vulnerable to prompt
|
||||
injection and unsafe behavior. See [Security](/gateway/security).
|
||||
|
||||
@@ -2047,43 +1998,6 @@ clawdbot gateway restart
|
||||
|
||||
If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway).
|
||||
|
||||
### I closed my terminal on Windows — how do I restart Clawdbot?
|
||||
|
||||
There are **two Windows install modes**:
|
||||
|
||||
**1) WSL2 (recommended):** the Gateway runs inside Linux.
|
||||
|
||||
Open PowerShell, enter WSL, then restart:
|
||||
|
||||
```powershell
|
||||
wsl
|
||||
clawdbot gateway status
|
||||
clawdbot gateway restart
|
||||
```
|
||||
|
||||
If you never installed the service, start it in the foreground:
|
||||
|
||||
```bash
|
||||
clawdbot gateway run
|
||||
```
|
||||
|
||||
**2) Native Windows (not recommended):** the Gateway runs directly in Windows.
|
||||
|
||||
Open PowerShell and run:
|
||||
|
||||
```powershell
|
||||
clawdbot gateway status
|
||||
clawdbot gateway restart
|
||||
```
|
||||
|
||||
If you run it manually (no service), use:
|
||||
|
||||
```powershell
|
||||
clawdbot gateway run
|
||||
```
|
||||
|
||||
Docs: [Windows (WSL2)](/platforms/windows), [Gateway service runbook](/gateway).
|
||||
|
||||
### How do I completely stop then start the Gateway?
|
||||
|
||||
If you installed the service:
|
||||
|
||||
@@ -26,10 +26,6 @@ Primary goals:
|
||||
- Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default.
|
||||
- Avoid nested fan-out: sub-agents cannot spawn sub-agents.
|
||||
|
||||
Cost note: each sub-agent has its **own** context and token usage. For heavy or repetitive
|
||||
tasks, set a cheaper model for sub-agents and keep your main agent on a higher-quality model.
|
||||
You can configure this via `agents.defaults.subagents.model` or per-agent overrides.
|
||||
|
||||
## Tool
|
||||
|
||||
Use `sessions_spawn`:
|
||||
|
||||
@@ -174,7 +174,6 @@ Search the web using your configured provider.
|
||||
- `country` (optional): 2-letter country code for region-specific results (e.g., "DE", "US", "ALL"). If omitted, Brave chooses its default region.
|
||||
- `search_lang` (optional): ISO language code for search results (e.g., "de", "en", "fr")
|
||||
- `ui_lang` (optional): ISO language code for UI elements
|
||||
- `freshness` (optional, Brave only): filter by discovery time (`pd`, `pw`, `pm`, `py`, or `YYYY-MM-DDtoYYYY-MM-DD`)
|
||||
|
||||
**Examples:**
|
||||
|
||||
@@ -194,12 +193,6 @@ await web_search({
|
||||
search_lang: "fr",
|
||||
ui_lang: "fr"
|
||||
});
|
||||
|
||||
// Recent results (past week)
|
||||
await web_search({
|
||||
query: "TMBG interview",
|
||||
freshness: "pw"
|
||||
});
|
||||
```
|
||||
|
||||
## web_fetch
|
||||
|
||||
@@ -374,7 +374,6 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) =>
|
||||
getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
||||
const trimmed = to?.trim() ?? "";
|
||||
|
||||
@@ -684,7 +684,6 @@ async function processMessageWithPipeline(params: {
|
||||
spaceId,
|
||||
runtime,
|
||||
core,
|
||||
config,
|
||||
statusSink,
|
||||
typingMessageName,
|
||||
});
|
||||
@@ -726,11 +725,10 @@ async function deliverGoogleChatReply(params: {
|
||||
spaceId: string;
|
||||
runtime: GoogleChatRuntimeEnv;
|
||||
core: GoogleChatCoreRuntime;
|
||||
config: ClawdbotConfig;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
typingMessageName?: string;
|
||||
}): Promise<void> {
|
||||
const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = params;
|
||||
const { payload, account, spaceId, runtime, core, statusSink, typingMessageName } = params;
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
@@ -801,16 +799,7 @@ async function deliverGoogleChatReply(params: {
|
||||
|
||||
if (payload.text) {
|
||||
const chunkLimit = account.config.textChunkLimit ?? 4000;
|
||||
const chunkMode = core.channel.text.resolveChunkMode(
|
||||
config,
|
||||
"googlechat",
|
||||
account.accountId,
|
||||
);
|
||||
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
||||
payload.text,
|
||||
chunkLimit,
|
||||
chunkMode,
|
||||
);
|
||||
const chunks = core.channel.text.chunkMarkdownText(payload.text, chunkLimit);
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
try {
|
||||
|
||||
@@ -186,7 +186,6 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
|
||||
|
||||
@@ -50,7 +50,6 @@ export const MatrixConfigSchema = z.object({
|
||||
replyToMode: z.enum(["off", "first", "all"]).optional(),
|
||||
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
|
||||
textChunkLimit: z.number().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
||||
autoJoinAllowlist: z.array(allowFromEntry).optional(),
|
||||
|
||||
@@ -16,11 +16,10 @@ export async function deliverMatrixReplies(params: {
|
||||
tableMode?: MarkdownTableMode;
|
||||
}): Promise<void> {
|
||||
const core = getMatrixRuntime();
|
||||
const cfg = core.config.loadConfig();
|
||||
const tableMode =
|
||||
params.tableMode ??
|
||||
core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
cfg: core.config.loadConfig(),
|
||||
channel: "matrix",
|
||||
accountId: params.accountId,
|
||||
});
|
||||
@@ -30,7 +29,6 @@ export async function deliverMatrixReplies(params: {
|
||||
}
|
||||
};
|
||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId);
|
||||
let hasReplied = false;
|
||||
for (const reply of params.replies) {
|
||||
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
||||
@@ -56,11 +54,7 @@ export async function deliverMatrixReplies(params: {
|
||||
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of core.channel.text.chunkMarkdownTextWithMode(
|
||||
text,
|
||||
chunkLimit,
|
||||
chunkMode,
|
||||
)) {
|
||||
for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) continue;
|
||||
await sendMessageMatrix(params.roomId, trimmed, {
|
||||
|
||||
@@ -42,9 +42,7 @@ const runtimeStub = {
|
||||
channel: {
|
||||
text: {
|
||||
resolveTextChunkLimit: () => 4000,
|
||||
resolveChunkMode: () => "length",
|
||||
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
||||
chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []),
|
||||
resolveMarkdownTableMode: () => "code",
|
||||
convertMarkdownTables: (text: string) => text,
|
||||
},
|
||||
|
||||
@@ -61,12 +61,7 @@ export async function sendMessageMatrix(
|
||||
);
|
||||
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
||||
const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId);
|
||||
const chunks = getCore().channel.text.chunkMarkdownTextWithMode(
|
||||
convertedMessage,
|
||||
chunkLimit,
|
||||
chunkMode,
|
||||
);
|
||||
const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit);
|
||||
const threadId = normalizeThreadId(opts.threadId);
|
||||
const relation = threadId
|
||||
? buildThreadRelation(threadId, opts.replyToId)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
|
||||
export const matrixOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, deps, replyToId, threadId }) => {
|
||||
const send = deps?.sendMatrix ?? sendMessageMatrix;
|
||||
|
||||
@@ -69,8 +69,6 @@ export type MatrixConfig = {
|
||||
threadReplies?: "off" | "inbound" | "always";
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/** Max outbound media size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
/** Auto-join invites (always|allowlist|off). Default: always. */
|
||||
|
||||
@@ -158,7 +158,6 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
resolveTarget: ({ to }) => {
|
||||
const trimmed = to?.trim();
|
||||
|
||||
@@ -25,7 +25,6 @@ const MattermostAccountSchemaBase = z
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
})
|
||||
|
||||
@@ -738,12 +738,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
if (mediaUrls.length === 0) {
|
||||
const chunkMode = core.channel.text.resolveChunkMode(
|
||||
cfg,
|
||||
"mattermost",
|
||||
account.accountId,
|
||||
);
|
||||
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
|
||||
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
|
||||
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
||||
if (!chunk) continue;
|
||||
await sendMessageMattermost(to, chunk, {
|
||||
|
||||
@@ -36,8 +36,6 @@ export type MattermostAccountConfig = {
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
|
||||
@@ -9,21 +9,18 @@ import {
|
||||
} from "./messenger.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
const chunkMarkdownText = (text: string, limit: number) => {
|
||||
if (!text) return [];
|
||||
if (limit <= 0 || text.length <= limit) return [text];
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < text.length; index += limit) {
|
||||
chunks.push(text.slice(index, index + limit));
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
|
||||
const runtimeStub = {
|
||||
channel: {
|
||||
text: {
|
||||
chunkMarkdownText,
|
||||
chunkMarkdownTextWithMode: chunkMarkdownText,
|
||||
chunkMarkdownText: (text: string, limit: number) => {
|
||||
if (!text) return [];
|
||||
if (limit <= 0 || text.length <= limit) return [text];
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < text.length; index += limit) {
|
||||
chunks.push(text.slice(index, index + limit));
|
||||
}
|
||||
return chunks;
|
||||
},
|
||||
resolveMarkdownTableMode: () => "code",
|
||||
convertMarkdownTables: (text: string) => text,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
type ChunkMode,
|
||||
isSilentReplyText,
|
||||
loadWebMedia,
|
||||
type MarkdownTableMode,
|
||||
@@ -64,7 +63,6 @@ export type MSTeamsReplyRenderOptions = {
|
||||
chunkText?: boolean;
|
||||
mediaMode?: "split" | "inline";
|
||||
tableMode?: MarkdownTableMode;
|
||||
chunkMode?: ChunkMode;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -131,16 +129,11 @@ function pushTextMessages(
|
||||
opts: {
|
||||
chunkText: boolean;
|
||||
chunkLimit: number;
|
||||
chunkMode: ChunkMode;
|
||||
},
|
||||
) {
|
||||
if (!text) return;
|
||||
if (opts.chunkText) {
|
||||
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownTextWithMode(
|
||||
text,
|
||||
opts.chunkLimit,
|
||||
opts.chunkMode,
|
||||
)) {
|
||||
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
||||
out.push({ text: trimmed });
|
||||
@@ -204,7 +197,6 @@ export function renderReplyPayloadsToMessages(
|
||||
const out: MSTeamsRenderedMessage[] = [];
|
||||
const chunkLimit = Math.min(options.textChunkLimit, 4000);
|
||||
const chunkText = options.chunkText !== false;
|
||||
const chunkMode = options.chunkMode ?? "length";
|
||||
const mediaMode = options.mediaMode ?? "split";
|
||||
const tableMode =
|
||||
options.tableMode ??
|
||||
@@ -223,7 +215,7 @@ export function renderReplyPayloadsToMessages(
|
||||
if (!text && mediaList.length === 0) continue;
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
|
||||
pushTextMessages(out, text, { chunkText, chunkLimit });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -237,13 +229,13 @@ export function renderReplyPayloadsToMessages(
|
||||
if (mediaList[i]) out.push({ mediaUrl: mediaList[i] });
|
||||
}
|
||||
} else {
|
||||
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
|
||||
pushTextMessages(out, text, { chunkText, chunkLimit });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// mediaMode === "split"
|
||||
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
|
||||
pushTextMessages(out, text, { chunkText, chunkLimit });
|
||||
for (const mediaUrl of mediaList) {
|
||||
if (!mediaUrl) continue;
|
||||
out.push({ mediaUrl });
|
||||
|
||||
@@ -7,7 +7,6 @@ import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
|
||||
export const msteamsOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
pollMaxOptions: 12,
|
||||
sendText: async ({ cfg, to, text, deps }) => {
|
||||
|
||||
@@ -59,7 +59,6 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "msteams");
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
@@ -76,7 +75,6 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
chunkText: true,
|
||||
mediaMode: "split",
|
||||
tableMode,
|
||||
chunkMode,
|
||||
});
|
||||
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg: params.cfg,
|
||||
|
||||
@@ -247,7 +247,6 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, accountId, replyToId }) => {
|
||||
const result = await sendMessageNextcloudTalk(to, text, {
|
||||
|
||||
@@ -44,7 +44,6 @@ export const NextcloudTalkAccountSchemaBase = z
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
|
||||
@@ -62,8 +62,6 @@ export type NextcloudTalkAccountConfig = {
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
|
||||
@@ -18,20 +18,12 @@ import {
|
||||
setAccountEnabledInConfigSection,
|
||||
signalOnboardingAdapter,
|
||||
SignalConfigSchema,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
type ResolvedSignalAccount,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getSignalRuntime } from "./runtime.js";
|
||||
|
||||
const signalMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: (ctx) => getSignalRuntime().channel.signal.messageActions.listActions(ctx),
|
||||
supportsAction: (ctx) => getSignalRuntime().channel.signal.messageActions.supportsAction?.(ctx),
|
||||
handleAction: async (ctx) =>
|
||||
await getSignalRuntime().channel.signal.messageActions.handleAction(ctx),
|
||||
};
|
||||
|
||||
const meta = getChatChannelMeta("signal");
|
||||
|
||||
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
@@ -50,9 +42,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
reactions: true,
|
||||
},
|
||||
actions: signalMessageActions,
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
@@ -125,7 +115,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
normalizeTarget: normalizeSignalMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeSignalTargetId,
|
||||
hint: "<E.164|uuid:ID|group:ID|signal:group:ID|signal:+E.164>",
|
||||
hint: "<E.164|group:ID|signal:group:ID|signal:+E.164>",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
@@ -207,7 +197,6 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit),
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||
const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
|
||||
|
||||
@@ -251,7 +251,6 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
|
||||
const send =
|
||||
|
||||
@@ -276,7 +276,6 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
outbound: {
|
||||
deliveryMode: "gateway",
|
||||
chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit),
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
pollMaxOptions: 12,
|
||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
||||
|
||||
@@ -288,7 +288,6 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
||||
if (remaining.length) chunks.push(remaining);
|
||||
return chunks;
|
||||
},
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 2000,
|
||||
sendText: async ({ to, text, accountId, cfg }) => {
|
||||
const result = await sendMessageZalo(to, text, {
|
||||
|
||||
@@ -596,8 +596,6 @@ async function processMessageWithPipeline(params: {
|
||||
chatId,
|
||||
runtime,
|
||||
core,
|
||||
config,
|
||||
accountId: account.accountId,
|
||||
statusSink,
|
||||
fetcher,
|
||||
tableMode,
|
||||
@@ -616,13 +614,11 @@ async function deliverZaloReply(params: {
|
||||
chatId: string;
|
||||
runtime: ZaloRuntimeEnv;
|
||||
core: ZaloCoreRuntime;
|
||||
config: ClawdbotConfig;
|
||||
accountId?: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
fetcher?: ZaloFetch;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}): Promise<void> {
|
||||
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
|
||||
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
|
||||
const tableMode = params.tableMode ?? "code";
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
|
||||
@@ -648,12 +644,7 @@ async function deliverZaloReply(params: {
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
|
||||
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
||||
text,
|
||||
ZALO_TEXT_LIMIT,
|
||||
chunkMode,
|
||||
);
|
||||
const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT);
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
|
||||
|
||||
@@ -506,7 +506,6 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||
if (remaining.length) chunks.push(remaining);
|
||||
return chunks;
|
||||
},
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 2000,
|
||||
sendText: async ({ to, text, accountId, cfg }) => {
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
|
||||
|
||||
@@ -332,8 +332,6 @@ async function processMessage(
|
||||
isGroup,
|
||||
runtime,
|
||||
core,
|
||||
config,
|
||||
accountId: account.accountId,
|
||||
statusSink,
|
||||
tableMode: core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: config,
|
||||
@@ -358,13 +356,10 @@ async function deliverZalouserReply(params: {
|
||||
isGroup: boolean;
|
||||
runtime: RuntimeEnv;
|
||||
core: ZalouserCoreRuntime;
|
||||
config: ClawdbotConfig;
|
||||
accountId?: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}): Promise<void> {
|
||||
const { payload, profile, chatId, isGroup, runtime, core, config, accountId, statusSink } =
|
||||
params;
|
||||
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
|
||||
const tableMode = params.tableMode ?? "code";
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
|
||||
@@ -395,12 +390,7 @@ async function deliverZalouserReply(params: {
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
|
||||
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
||||
text,
|
||||
ZALOUSER_TEXT_LIMIT,
|
||||
chunkMode,
|
||||
);
|
||||
const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT);
|
||||
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
|
||||
@@ -15,8 +15,6 @@ import { resolveChannelCapabilities } from "../../config/channel-capabilities.js
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { getMachineDisplayName } from "../../infra/machine-name.js";
|
||||
import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js";
|
||||
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
|
||||
import { resolveSignalReactionLevel } from "../../signal/reaction-level.js";
|
||||
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
|
||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
||||
@@ -257,28 +255,6 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
}
|
||||
}
|
||||
}
|
||||
const reactionGuidance =
|
||||
runtimeChannel && params.config
|
||||
? (() => {
|
||||
if (runtimeChannel === "telegram") {
|
||||
const resolved = resolveTelegramReactionLevel({
|
||||
cfg: params.config,
|
||||
accountId: params.agentAccountId ?? undefined,
|
||||
});
|
||||
const level = resolved.agentReactionGuidance;
|
||||
return level ? { level, channel: "Telegram" } : undefined;
|
||||
}
|
||||
if (runtimeChannel === "signal") {
|
||||
const resolved = resolveSignalReactionLevel({
|
||||
cfg: params.config,
|
||||
accountId: params.agentAccountId ?? undefined,
|
||||
});
|
||||
const level = resolved.agentReactionGuidance;
|
||||
return level ? { level, channel: "Signal" } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
})()
|
||||
: undefined;
|
||||
// Resolve channel-specific message actions for system prompt
|
||||
const channelActions = runtimeChannel
|
||||
? listChannelSupportedActions({
|
||||
@@ -337,7 +313,6 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
ttsHint,
|
||||
promptMode,
|
||||
runtimeInfo,
|
||||
reactionGuidance,
|
||||
messageToolHints,
|
||||
sandboxInfo,
|
||||
tools,
|
||||
|
||||
@@ -15,7 +15,6 @@ import { resolveChannelCapabilities } from "../../../config/channel-capabilities
|
||||
import { getMachineDisplayName } from "../../../infra/machine-name.js";
|
||||
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
|
||||
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
|
||||
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
|
||||
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
|
||||
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
||||
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
||||
@@ -256,25 +255,14 @@ export async function runEmbeddedAttempt(
|
||||
}
|
||||
}
|
||||
const reactionGuidance =
|
||||
runtimeChannel && params.config
|
||||
runtimeChannel === "telegram" && params.config
|
||||
? (() => {
|
||||
if (runtimeChannel === "telegram") {
|
||||
const resolved = resolveTelegramReactionLevel({
|
||||
cfg: params.config,
|
||||
accountId: params.agentAccountId ?? undefined,
|
||||
});
|
||||
const level = resolved.agentReactionGuidance;
|
||||
return level ? { level, channel: "Telegram" } : undefined;
|
||||
}
|
||||
if (runtimeChannel === "signal") {
|
||||
const resolved = resolveSignalReactionLevel({
|
||||
cfg: params.config,
|
||||
accountId: params.agentAccountId ?? undefined,
|
||||
});
|
||||
const level = resolved.agentReactionGuidance;
|
||||
return level ? { level, channel: "Signal" } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
const resolved = resolveTelegramReactionLevel({
|
||||
cfg: params.config,
|
||||
accountId: params.agentAccountId ?? undefined,
|
||||
});
|
||||
const level = resolved.agentReactionGuidance;
|
||||
return level ? { level, channel: "Telegram" } : undefined;
|
||||
})()
|
||||
: undefined;
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
|
||||
@@ -94,9 +94,6 @@ function buildReactionSchema() {
|
||||
messageId: Type.Optional(Type.String()),
|
||||
emoji: Type.Optional(Type.String()),
|
||||
remove: Type.Optional(Type.Boolean()),
|
||||
targetAuthor: Type.Optional(Type.String()),
|
||||
targetAuthorUuid: Type.Optional(Type.String()),
|
||||
groupId: Type.Optional(Type.String()),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { __testing } from "./web-search.js";
|
||||
|
||||
const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness } =
|
||||
__testing;
|
||||
const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl } = __testing;
|
||||
|
||||
describe("web_search perplexity baseUrl defaults", () => {
|
||||
it("detects a Perplexity key prefix", () => {
|
||||
@@ -52,20 +51,3 @@ describe("web_search perplexity baseUrl defaults", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_search freshness normalization", () => {
|
||||
it("accepts Brave shortcut values", () => {
|
||||
expect(normalizeFreshness("pd")).toBe("pd");
|
||||
expect(normalizeFreshness("PW")).toBe("pw");
|
||||
});
|
||||
|
||||
it("accepts valid date ranges", () => {
|
||||
expect(normalizeFreshness("2024-01-01to2024-01-31")).toBe("2024-01-01to2024-01-31");
|
||||
});
|
||||
|
||||
it("rejects invalid date ranges", () => {
|
||||
expect(normalizeFreshness("2024-13-01to2024-01-31")).toBeUndefined();
|
||||
expect(normalizeFreshness("2024-02-30to2024-03-01")).toBeUndefined();
|
||||
expect(normalizeFreshness("2024-03-10to2024-03-01")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -29,8 +29,6 @@ const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
|
||||
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
|
||||
|
||||
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
|
||||
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
|
||||
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;
|
||||
|
||||
const WebSearchSchema = Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
@@ -57,12 +55,6 @@ const WebSearchSchema = Type.Object({
|
||||
description: "ISO language code for UI elements.",
|
||||
}),
|
||||
),
|
||||
freshness: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Filter results by discovery time (Brave only). Values: 'pd' (past 24h), 'pw' (past week), 'pm' (past month), 'py' (past year), or date range 'YYYY-MM-DDtoYYYY-MM-DD'.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type WebSearchConfig = NonNullable<ClawdbotConfig["tools"]>["web"] extends infer Web
|
||||
@@ -227,37 +219,6 @@ function resolveSearchCount(value: unknown, fallback: number): number {
|
||||
return clamped;
|
||||
}
|
||||
|
||||
function normalizeFreshness(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) return lower;
|
||||
|
||||
const match = trimmed.match(BRAVE_FRESHNESS_RANGE);
|
||||
if (!match) return undefined;
|
||||
|
||||
const [, start, end] = match;
|
||||
if (!isValidIsoDate(start) || !isValidIsoDate(end)) return undefined;
|
||||
if (start > end) return undefined;
|
||||
|
||||
return `${start}to${end}`;
|
||||
}
|
||||
|
||||
function isValidIsoDate(value: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
const [year, month, day] = value.split("-").map((part) => Number.parseInt(part, 10));
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return false;
|
||||
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
return (
|
||||
date.getUTCFullYear() === year &&
|
||||
date.getUTCMonth() === month - 1 &&
|
||||
date.getUTCDate() === day
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSiteName(url: string | undefined): string | undefined {
|
||||
if (!url) return undefined;
|
||||
try {
|
||||
@@ -318,14 +279,11 @@ async function runWebSearch(params: {
|
||||
country?: string;
|
||||
search_lang?: string;
|
||||
ui_lang?: string;
|
||||
freshness?: string;
|
||||
perplexityBaseUrl?: string;
|
||||
perplexityModel?: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const cacheKey = normalizeCacheKey(
|
||||
params.provider === "brave"
|
||||
? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}`
|
||||
: `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
|
||||
`${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
|
||||
);
|
||||
const cached = readCache(SEARCH_CACHE, cacheKey);
|
||||
if (cached) return { ...cached.value, cached: true };
|
||||
@@ -369,9 +327,6 @@ async function runWebSearch(params: {
|
||||
if (params.ui_lang) {
|
||||
url.searchParams.set("ui_lang", params.ui_lang);
|
||||
}
|
||||
if (params.freshness) {
|
||||
url.searchParams.set("freshness", params.freshness);
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
@@ -444,23 +399,6 @@ export function createWebSearchTool(options?: {
|
||||
const country = readStringParam(params, "country");
|
||||
const search_lang = readStringParam(params, "search_lang");
|
||||
const ui_lang = readStringParam(params, "ui_lang");
|
||||
const rawFreshness = readStringParam(params, "freshness");
|
||||
if (rawFreshness && provider !== "brave") {
|
||||
return jsonResult({
|
||||
error: "unsupported_freshness",
|
||||
message: "freshness is only supported by the Brave web_search provider.",
|
||||
docs: "https://docs.clawd.bot/tools/web",
|
||||
});
|
||||
}
|
||||
const freshness = rawFreshness ? normalizeFreshness(rawFreshness) : undefined;
|
||||
if (rawFreshness && !freshness) {
|
||||
return jsonResult({
|
||||
error: "invalid_freshness",
|
||||
message:
|
||||
"freshness must be one of pd, pw, pm, py, or a range like YYYY-MM-DDtoYYYY-MM-DD.",
|
||||
docs: "https://docs.clawd.bot/tools/web",
|
||||
});
|
||||
}
|
||||
const result = await runWebSearch({
|
||||
query,
|
||||
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
@@ -471,7 +409,6 @@ export function createWebSearchTool(options?: {
|
||||
country,
|
||||
search_lang,
|
||||
ui_lang,
|
||||
freshness,
|
||||
perplexityBaseUrl: resolvePerplexityBaseUrl(
|
||||
perplexityConfig,
|
||||
perplexityAuth?.source,
|
||||
@@ -487,5 +424,4 @@ export function createWebSearchTool(options?: {
|
||||
export const __testing = {
|
||||
inferPerplexityBaseUrlFromApiKey,
|
||||
resolvePerplexityBaseUrl,
|
||||
normalizeFreshness,
|
||||
} as const;
|
||||
@@ -88,40 +88,6 @@ describe("web_search country and language parameters", () => {
|
||||
const url = new URL(mockFetch.mock.calls[0][0] as string);
|
||||
expect(url.searchParams.get("ui_lang")).toBe("de");
|
||||
});
|
||||
|
||||
it("should pass freshness parameter to Brave API", async () => {
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ web: { results: [] } }),
|
||||
} as Response),
|
||||
);
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
|
||||
await tool?.execute?.(1, { query: "test", freshness: "pw" });
|
||||
|
||||
const url = new URL(mockFetch.mock.calls[0][0] as string);
|
||||
expect(url.searchParams.get("freshness")).toBe("pw");
|
||||
});
|
||||
|
||||
it("rejects invalid freshness values", async () => {
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ web: { results: [] } }),
|
||||
} as Response),
|
||||
);
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
|
||||
const result = await tool?.execute?.(1, { query: "test", freshness: "yesterday" });
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(result?.details).toMatchObject({ error: "invalid_freshness" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_search perplexity baseUrl defaults", () => {
|
||||
@@ -154,27 +120,6 @@ describe("web_search perplexity baseUrl defaults", () => {
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://api.perplexity.ai/chat/completions");
|
||||
});
|
||||
|
||||
it("rejects freshness for Perplexity provider", async () => {
|
||||
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
|
||||
} as Response),
|
||||
);
|
||||
// @ts-expect-error mock fetch
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const tool = createWebSearchTool({
|
||||
config: { tools: { web: { search: { provider: "perplexity" } } } },
|
||||
sandboxed: true,
|
||||
});
|
||||
const result = await tool?.execute?.(1, { query: "test", freshness: "pw" });
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(result?.details).toMatchObject({ error: "unsupported_freshness" });
|
||||
});
|
||||
|
||||
it("defaults to OpenRouter when OPENROUTER_API_KEY is set", async () => {
|
||||
vi.stubEnv("PERPLEXITY_API_KEY", "");
|
||||
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
chunkByNewline,
|
||||
chunkMarkdownText,
|
||||
chunkMarkdownTextWithMode,
|
||||
chunkText,
|
||||
chunkTextWithMode,
|
||||
resolveChunkMode,
|
||||
@@ -247,10 +246,10 @@ describe("chunkByNewline", () => {
|
||||
expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
|
||||
});
|
||||
|
||||
it("preserves blank lines by folding into the next chunk", () => {
|
||||
it("filters empty lines", () => {
|
||||
const text = "Line one\n\n\nLine two\n\nLine three";
|
||||
const chunks = chunkByNewline(text, 1000);
|
||||
expect(chunks).toEqual(["Line one", "\n\nLine two", "\nLine three"]);
|
||||
expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
|
||||
});
|
||||
|
||||
it("trims whitespace from lines", () => {
|
||||
@@ -259,12 +258,6 @@ describe("chunkByNewline", () => {
|
||||
expect(chunks).toEqual(["Line one", "Line two"]);
|
||||
});
|
||||
|
||||
it("preserves leading blank lines on the first chunk", () => {
|
||||
const text = "\n\nLine one\nLine two";
|
||||
const chunks = chunkByNewline(text, 1000);
|
||||
expect(chunks).toEqual(["\n\nLine one", "Line two"]);
|
||||
});
|
||||
|
||||
it("falls back to length-based for long lines", () => {
|
||||
const text = "Short line\n" + "a".repeat(50) + "\nAnother short";
|
||||
const chunks = chunkByNewline(text, 20);
|
||||
@@ -276,12 +269,6 @@ describe("chunkByNewline", () => {
|
||||
expect(chunks[4]).toBe("Another short");
|
||||
});
|
||||
|
||||
it("does not split long lines when splitLongLines is false", () => {
|
||||
const text = "a".repeat(50);
|
||||
const chunks = chunkByNewline(text, 20, { splitLongLines: false });
|
||||
expect(chunks).toEqual([text]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(chunkByNewline("", 100)).toEqual([]);
|
||||
});
|
||||
@@ -289,18 +276,6 @@ describe("chunkByNewline", () => {
|
||||
it("returns empty array for whitespace-only input", () => {
|
||||
expect(chunkByNewline(" \n\n ", 100)).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves trailing blank lines on the last chunk", () => {
|
||||
const text = "Line one\n\n";
|
||||
const chunks = chunkByNewline(text, 1000);
|
||||
expect(chunks).toEqual(["Line one\n\n"]);
|
||||
});
|
||||
|
||||
it("keeps whitespace when trimLines is false", () => {
|
||||
const text = " indented line \nNext";
|
||||
const chunks = chunkByNewline(text, 1000, { trimLines: false });
|
||||
expect(chunks).toEqual([" indented line ", "Next"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkTextWithMode", () => {
|
||||
@@ -317,26 +292,6 @@ describe("chunkTextWithMode", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkMarkdownTextWithMode", () => {
|
||||
it("uses markdown-aware chunking for length mode", () => {
|
||||
const text = "Line one\nLine two";
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "length")).toEqual(chunkMarkdownText(text, 1000));
|
||||
});
|
||||
|
||||
it("uses newline-based chunking for newline mode", () => {
|
||||
const text = "Line one\nLine two";
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual(["Line one", "Line two"]);
|
||||
});
|
||||
|
||||
it("does not split inside code fences for newline mode", () => {
|
||||
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([
|
||||
"```js\nconst a = 1;\nconst b = 2;\n```",
|
||||
"After",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveChunkMode", () => {
|
||||
it("returns length as default", () => {
|
||||
expect(resolveChunkMode(undefined, "telegram")).toBe("length");
|
||||
@@ -349,16 +304,16 @@ describe("resolveChunkMode", () => {
|
||||
expect(resolveChunkMode(cfg, "__internal__")).toBe("length");
|
||||
});
|
||||
|
||||
it("supports provider-level overrides for slack", () => {
|
||||
const cfg = { channels: { slack: { chunkMode: "newline" as const } } };
|
||||
expect(resolveChunkMode(cfg, "slack")).toBe("newline");
|
||||
it("supports provider-level overrides for bluebubbles", () => {
|
||||
const cfg = { channels: { bluebubbles: { chunkMode: "newline" as const } } };
|
||||
expect(resolveChunkMode(cfg, "bluebubbles")).toBe("newline");
|
||||
expect(resolveChunkMode(cfg, "discord")).toBe("length");
|
||||
});
|
||||
|
||||
it("supports account-level overrides for slack", () => {
|
||||
it("supports account-level overrides for bluebubbles", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
bluebubbles: {
|
||||
chunkMode: "length" as const,
|
||||
accounts: {
|
||||
primary: { chunkMode: "newline" as const },
|
||||
@@ -366,7 +321,12 @@ describe("resolveChunkMode", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(resolveChunkMode(cfg, "slack", "primary")).toBe("newline");
|
||||
expect(resolveChunkMode(cfg, "slack", "other")).toBe("length");
|
||||
expect(resolveChunkMode(cfg, "bluebubbles", "primary")).toBe("newline");
|
||||
expect(resolveChunkMode(cfg, "bluebubbles", "other")).toBe("length");
|
||||
});
|
||||
|
||||
it("ignores chunkMode for non-bluebubbles providers", () => {
|
||||
const cfg = { channels: { ["telegram" as string]: { chunkMode: "newline" as const } } };
|
||||
expect(resolveChunkMode(cfg, "telegram")).toBe("length");
|
||||
});
|
||||
});
|
||||
+15
-92
@@ -101,6 +101,8 @@ export function resolveChunkMode(
|
||||
accountId?: string | null,
|
||||
): ChunkMode {
|
||||
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return DEFAULT_CHUNK_MODE;
|
||||
// Chunk mode is only supported for BlueBubbles.
|
||||
if (provider !== "bluebubbles") return DEFAULT_CHUNK_MODE;
|
||||
const channelsConfig = cfg?.channels as Record<string, unknown> | undefined;
|
||||
const providerConfig = (channelsConfig?.[provider] ??
|
||||
(cfg as Record<string, unknown> | undefined)?.[provider]) as ProviderChunkConfig | undefined;
|
||||
@@ -109,56 +111,25 @@ export function resolveChunkMode(
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text on newlines, trimming line whitespace.
|
||||
* Blank lines are folded into the next non-empty line as leading "\n" prefixes.
|
||||
* Long lines can be split by length (default) or kept intact via splitLongLines:false.
|
||||
* Split text on newlines, filtering empty lines.
|
||||
* Lines exceeding maxLineLength are further split using length-based chunking.
|
||||
*/
|
||||
export function chunkByNewline(
|
||||
text: string,
|
||||
maxLineLength: number,
|
||||
opts?: {
|
||||
splitLongLines?: boolean;
|
||||
trimLines?: boolean;
|
||||
isSafeBreak?: (index: number) => boolean;
|
||||
},
|
||||
): string[] {
|
||||
export function chunkByNewline(text: string, maxLineLength: number): string[] {
|
||||
if (!text) return [];
|
||||
if (maxLineLength <= 0) return text.trim() ? [text] : [];
|
||||
const splitLongLines = opts?.splitLongLines !== false;
|
||||
const trimLines = opts?.trimLines !== false;
|
||||
const lines = splitByNewline(text, opts?.isSafeBreak);
|
||||
const lines = text.split("\n");
|
||||
const chunks: string[] = [];
|
||||
let pendingBlankLines = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
pendingBlankLines += 1;
|
||||
continue;
|
||||
if (!trimmed) continue; // skip empty lines
|
||||
|
||||
if (trimmed.length <= maxLineLength) {
|
||||
chunks.push(trimmed);
|
||||
} else {
|
||||
// Long line: fall back to length-based chunking
|
||||
const subChunks = chunkText(trimmed, maxLineLength);
|
||||
chunks.push(...subChunks);
|
||||
}
|
||||
|
||||
const maxPrefix = Math.max(0, maxLineLength - 1);
|
||||
const cappedBlankLines = pendingBlankLines > 0 ? Math.min(pendingBlankLines, maxPrefix) : 0;
|
||||
const prefix = cappedBlankLines > 0 ? "\n".repeat(cappedBlankLines) : "";
|
||||
pendingBlankLines = 0;
|
||||
|
||||
const lineValue = trimLines ? trimmed : line;
|
||||
if (!splitLongLines || lineValue.length + prefix.length <= maxLineLength) {
|
||||
chunks.push(prefix + lineValue);
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstLimit = Math.max(1, maxLineLength - prefix.length);
|
||||
const first = lineValue.slice(0, firstLimit);
|
||||
chunks.push(prefix + first);
|
||||
const remaining = lineValue.slice(firstLimit);
|
||||
if (remaining) {
|
||||
chunks.push(...chunkText(remaining, maxLineLength));
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingBlankLines > 0 && chunks.length > 0) {
|
||||
chunks[chunks.length - 1] += "\n".repeat(pendingBlankLines);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
@@ -169,59 +140,11 @@ export function chunkByNewline(
|
||||
*/
|
||||
export function chunkTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
|
||||
if (mode === "newline") {
|
||||
const chunks: string[] = [];
|
||||
const lineChunks = chunkByNewline(text, limit, { splitLongLines: false });
|
||||
for (const line of lineChunks) {
|
||||
const nested = chunkText(line, limit);
|
||||
if (!nested.length && line) {
|
||||
chunks.push(line);
|
||||
continue;
|
||||
}
|
||||
chunks.push(...nested);
|
||||
}
|
||||
return chunks;
|
||||
return chunkByNewline(text, limit);
|
||||
}
|
||||
return chunkText(text, limit);
|
||||
}
|
||||
|
||||
export function chunkMarkdownTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
|
||||
if (mode === "newline") {
|
||||
const spans = parseFenceSpans(text);
|
||||
const chunks: string[] = [];
|
||||
const lineChunks = chunkByNewline(text, limit, {
|
||||
splitLongLines: false,
|
||||
trimLines: false,
|
||||
isSafeBreak: (index) => isSafeFenceBreak(spans, index),
|
||||
});
|
||||
for (const line of lineChunks) {
|
||||
const nested = chunkMarkdownText(line, limit);
|
||||
if (!nested.length && line) {
|
||||
chunks.push(line);
|
||||
continue;
|
||||
}
|
||||
chunks.push(...nested);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
return chunkMarkdownText(text, limit);
|
||||
}
|
||||
|
||||
function splitByNewline(
|
||||
text: string,
|
||||
isSafeBreak: (index: number) => boolean = () => true,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
let start = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === "\n" && isSafeBreak(i)) {
|
||||
lines.push(text.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
lines.push(text.slice(start));
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function chunkText(text: string, limit: number): string[] {
|
||||
if (!text) return [];
|
||||
if (limit <= 0) return [text];
|
||||
|
||||
@@ -69,7 +69,7 @@ export function resolveBlockStreamingChunking(
|
||||
});
|
||||
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
|
||||
|
||||
// When chunkMode is "newline", use newline-based streaming
|
||||
// BlueBubbles-only: if chunkMode is "newline", use newline-based streaming
|
||||
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
|
||||
if (channelChunkMode === "newline") {
|
||||
// For newline mode: use very low minChars to flush quickly on newlines
|
||||
@@ -103,7 +103,7 @@ export function resolveBlockStreamingCoalescing(
|
||||
): BlockStreamingCoalescing | undefined {
|
||||
const providerKey = normalizeChunkProvider(provider);
|
||||
|
||||
// When chunkMode is "newline", disable coalescing to send each line immediately
|
||||
// BlueBubbles-only: when chunkMode is "newline", disable coalescing to send each line immediately
|
||||
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
|
||||
if (channelChunkMode === "newline") {
|
||||
return undefined;
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import { signalMessageActions } from "./signal.js";
|
||||
|
||||
const sendReactionSignal = vi.fn(async () => ({ ok: true }));
|
||||
const removeReactionSignal = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
vi.mock("../../../signal/send-reactions.js", () => ({
|
||||
sendReactionSignal: (...args: unknown[]) => sendReactionSignal(...args),
|
||||
removeReactionSignal: (...args: unknown[]) => removeReactionSignal(...args),
|
||||
}));
|
||||
|
||||
describe("signalMessageActions", () => {
|
||||
it("returns no actions when no configured accounts exist", () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
expect(signalMessageActions.listActions({ cfg })).toEqual([]);
|
||||
});
|
||||
|
||||
it("hides react when reactions are disabled", () => {
|
||||
const cfg = {
|
||||
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
|
||||
} as ClawdbotConfig;
|
||||
expect(signalMessageActions.listActions({ cfg })).toEqual(["send"]);
|
||||
});
|
||||
|
||||
it("enables react when at least one account allows reactions", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
actions: { reactions: false },
|
||||
accounts: {
|
||||
work: { account: "+15550001111", actions: { reactions: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(signalMessageActions.listActions({ cfg })).toEqual(["send", "react"]);
|
||||
});
|
||||
|
||||
it("skips send for plugin dispatch", () => {
|
||||
expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false);
|
||||
expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks reactions when action gate is disabled", async () => {
|
||||
const cfg = {
|
||||
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
await expect(
|
||||
signalMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: { to: "+15550001111", messageId: "123", emoji: "✅" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
}),
|
||||
).rejects.toThrow(/actions\.reactions/);
|
||||
});
|
||||
|
||||
it("uses account-level actions when enabled", async () => {
|
||||
sendReactionSignal.mockClear();
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
actions: { reactions: false },
|
||||
accounts: {
|
||||
work: { account: "+15550001111", actions: { reactions: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
await signalMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: { to: "+15550001111", messageId: "123", emoji: "👍" },
|
||||
cfg,
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(sendReactionSignal).toHaveBeenCalledWith("+15550001111", 123, "👍", {
|
||||
accountId: "work",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes uuid recipients", async () => {
|
||||
sendReactionSignal.mockClear();
|
||||
const cfg = {
|
||||
channels: { signal: { account: "+15550001111" } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
await signalMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
recipient: "uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
messageId: "123",
|
||||
emoji: "🔥",
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
});
|
||||
|
||||
expect(sendReactionSignal).toHaveBeenCalledWith(
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
123,
|
||||
"🔥",
|
||||
{ accountId: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it("requires targetAuthor for group reactions", async () => {
|
||||
const cfg = {
|
||||
channels: { signal: { account: "+15550001111" } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
await expect(
|
||||
signalMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
}),
|
||||
).rejects.toThrow(/targetAuthor/);
|
||||
});
|
||||
|
||||
it("passes groupId and targetAuthor for group reactions", async () => {
|
||||
sendReactionSignal.mockClear();
|
||||
const cfg = {
|
||||
channels: { signal: { account: "+15550001111" } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
await signalMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
to: "signal:group:group-id",
|
||||
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
messageId: "123",
|
||||
emoji: "✅",
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
});
|
||||
|
||||
expect(sendReactionSignal).toHaveBeenCalledWith("", 123, "✅", {
|
||||
accountId: undefined,
|
||||
groupId: "group-id",
|
||||
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
targetAuthorUuid: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import { createActionGate, jsonResult, readStringParam } from "../../../agents/tools/common.js";
|
||||
import { listEnabledSignalAccounts, resolveSignalAccount } from "../../../signal/accounts.js";
|
||||
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
|
||||
import { sendReactionSignal, removeReactionSignal } from "../../../signal/send-reactions.js";
|
||||
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
|
||||
|
||||
const providerId = "signal";
|
||||
const GROUP_PREFIX = "group:";
|
||||
|
||||
function normalizeSignalReactionRecipient(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
const withoutSignal = trimmed.replace(/^signal:/i, "").trim();
|
||||
if (!withoutSignal) return withoutSignal;
|
||||
if (withoutSignal.toLowerCase().startsWith("uuid:")) {
|
||||
return withoutSignal.slice("uuid:".length).trim();
|
||||
}
|
||||
return withoutSignal;
|
||||
}
|
||||
|
||||
function resolveSignalReactionTarget(raw: string): { recipient?: string; groupId?: string } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
const withoutSignal = trimmed.replace(/^signal:/i, "").trim();
|
||||
if (!withoutSignal) return {};
|
||||
if (withoutSignal.toLowerCase().startsWith(GROUP_PREFIX)) {
|
||||
const groupId = withoutSignal.slice(GROUP_PREFIX.length).trim();
|
||||
return groupId ? { groupId } : {};
|
||||
}
|
||||
return { recipient: normalizeSignalReactionRecipient(withoutSignal) };
|
||||
}
|
||||
|
||||
export const signalMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
const accounts = listEnabledSignalAccounts(cfg);
|
||||
if (accounts.length === 0) return [];
|
||||
const configuredAccounts = accounts.filter((account) => account.configured);
|
||||
if (configuredAccounts.length === 0) return [];
|
||||
|
||||
const actions = new Set<ChannelMessageActionName>(["send"]);
|
||||
|
||||
const reactionsEnabled = configuredAccounts.some((account) =>
|
||||
createActionGate(account.config.actions)("reactions"),
|
||||
);
|
||||
if (reactionsEnabled) {
|
||||
actions.add("react");
|
||||
}
|
||||
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsAction: ({ action }) => action !== "send",
|
||||
|
||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||
if (action === "send") {
|
||||
throw new Error("Send should be handled by outbound, not actions handler.");
|
||||
}
|
||||
|
||||
if (action === "react") {
|
||||
// Check reaction level first
|
||||
const reactionLevelInfo = resolveSignalReactionLevel({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
if (!reactionLevelInfo.agentReactionsEnabled) {
|
||||
throw new Error(
|
||||
`Signal agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` +
|
||||
`Set channels.signal.reactionLevel to "minimal" or "extensive" to enable.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Also check the action gate for backward compatibility
|
||||
const actionConfig = resolveSignalAccount({ cfg, accountId }).config.actions;
|
||||
const isActionEnabled = createActionGate(actionConfig);
|
||||
if (!isActionEnabled("reactions")) {
|
||||
throw new Error("Signal reactions are disabled via actions.reactions.");
|
||||
}
|
||||
|
||||
const recipientRaw =
|
||||
readStringParam(params, "recipient") ??
|
||||
readStringParam(params, "to", {
|
||||
required: true,
|
||||
label: "recipient (UUID, phone number, or group)",
|
||||
});
|
||||
const target = resolveSignalReactionTarget(recipientRaw);
|
||||
if (!target.recipient && !target.groupId) {
|
||||
throw new Error("recipient or group required");
|
||||
}
|
||||
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
label: "messageId (timestamp)",
|
||||
});
|
||||
const targetAuthor = readStringParam(params, "targetAuthor");
|
||||
const targetAuthorUuid = readStringParam(params, "targetAuthorUuid");
|
||||
if (target.groupId && !targetAuthor && !targetAuthorUuid) {
|
||||
throw new Error("targetAuthor or targetAuthorUuid required for group reactions.");
|
||||
}
|
||||
|
||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
|
||||
|
||||
const timestamp = parseInt(messageId, 10);
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
throw new Error(`Invalid messageId: ${messageId}. Expected numeric timestamp.`);
|
||||
}
|
||||
|
||||
if (remove) {
|
||||
if (!emoji) throw new Error("Emoji required to remove reaction.");
|
||||
await removeReactionSignal(target.recipient ?? "", timestamp, emoji, {
|
||||
accountId: accountId ?? undefined,
|
||||
groupId: target.groupId,
|
||||
targetAuthor,
|
||||
targetAuthorUuid,
|
||||
});
|
||||
return jsonResult({ ok: true, removed: emoji });
|
||||
}
|
||||
|
||||
if (!emoji) throw new Error("Emoji required to add reaction.");
|
||||
await sendReactionSignal(target.recipient ?? "", timestamp, emoji, {
|
||||
accountId: accountId ?? undefined,
|
||||
groupId: target.groupId,
|
||||
targetAuthor,
|
||||
targetAuthorUuid,
|
||||
});
|
||||
return jsonResult({ ok: true, added: emoji });
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} not supported for ${providerId}.`);
|
||||
},
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./signal.js";
|
||||
|
||||
describe("signal target normalization", () => {
|
||||
it("normalizes uuid targets by stripping uuid:", () => {
|
||||
expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes signal:uuid targets", () => {
|
||||
expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts uuid prefixes for target detection", () => {
|
||||
expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
|
||||
expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts compact UUIDs for target detection", () => {
|
||||
expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true);
|
||||
expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid uuid prefixes", () => {
|
||||
expect(looksLikeSignalTargetId("uuid:")).toBe(false);
|
||||
expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,30 +19,12 @@ export function normalizeSignalMessagingTarget(raw: string): string | undefined
|
||||
const id = normalized.slice("u:".length).trim();
|
||||
return id ? `username:${id}`.toLowerCase() : undefined;
|
||||
}
|
||||
if (lower.startsWith("uuid:")) {
|
||||
const id = normalized.slice("uuid:".length).trim();
|
||||
return id ? id.toLowerCase() : undefined;
|
||||
}
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
// UUID pattern for signal-cli recipient IDs
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const UUID_COMPACT_PATTERN = /^[0-9a-f]{32}$/i;
|
||||
|
||||
export function looksLikeSignalTargetId(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(signal:)?(group:|username:|u:)/i.test(trimmed)) return true;
|
||||
if (/^(signal:)?uuid:/i.test(trimmed)) {
|
||||
const stripped = trimmed
|
||||
.replace(/^signal:/i, "")
|
||||
.replace(/^uuid:/i, "")
|
||||
.trim();
|
||||
if (!stripped) return false;
|
||||
return UUID_PATTERN.test(stripped) || UUID_COMPACT_PATTERN.test(stripped);
|
||||
}
|
||||
// Accept UUIDs (used by signal-cli for reactions)
|
||||
if (UUID_PATTERN.test(trimmed) || UUID_COMPACT_PATTERN.test(trimmed)) return true;
|
||||
return /^\+?\d{3,}$/.test(trimmed);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import type { ChannelOutboundAdapter } from "../types.js";
|
||||
export const imessageOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkText,
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||
const send = deps?.sendIMessage ?? sendMessageIMessage;
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { ChannelOutboundAdapter } from "../types.js";
|
||||
export const signalOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkText,
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||
const send = deps?.sendSignal ?? sendMessageSignal;
|
||||
|
||||
@@ -21,7 +21,6 @@ function parseThreadId(threadId?: string | number | null) {
|
||||
export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: markdownToTelegramHtmlChunks,
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
|
||||
const send = deps?.sendTelegram ?? sendMessageTelegram;
|
||||
|
||||
@@ -8,7 +8,6 @@ import { missingTargetError } from "../../../infra/outbound/target-errors.js";
|
||||
export const whatsappOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "gateway",
|
||||
chunker: chunkText,
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
pollMaxOptions: 12,
|
||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
||||
|
||||
@@ -84,7 +84,6 @@ export type ChannelOutboundContext = {
|
||||
export type ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct" | "gateway" | "hybrid";
|
||||
chunker?: ((text: string, limit: number) => string[]) | null;
|
||||
chunkerMode?: "text" | "markdown";
|
||||
textChunkLimit?: number;
|
||||
pollMaxOptions?: number;
|
||||
resolveTarget?: (params: {
|
||||
|
||||
@@ -67,28 +67,6 @@ describe("cli program (smoke)", () => {
|
||||
expect(messageCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs message react with signal author fields", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"message",
|
||||
"react",
|
||||
"--channel",
|
||||
"signal",
|
||||
"--target",
|
||||
"signal:group:abc123",
|
||||
"--message-id",
|
||||
"1737630212345",
|
||||
"--emoji",
|
||||
"✅",
|
||||
"--target-author-uuid",
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(messageCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs status command", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["status"], { from: "user" });
|
||||
|
||||
@@ -13,8 +13,6 @@ export function registerMessageReactionsCommands(message: Command, helpers: Mess
|
||||
.option("--remove", "Remove reaction", false)
|
||||
.option("--participant <id>", "WhatsApp reaction participant")
|
||||
.option("--from-me", "WhatsApp reaction fromMe", false)
|
||||
.option("--target-author <id>", "Signal reaction target author (uuid or phone)")
|
||||
.option("--target-author-uuid <uuid>", "Signal reaction target author uuid")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("react", opts);
|
||||
});
|
||||
|
||||
+28
-29
@@ -1,37 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { rewriteUpdateFlagArgv } from "./run-main.js";
|
||||
const registerSubCliByName = vi.fn(async () => true);
|
||||
const parseAsync = vi.fn(async () => undefined);
|
||||
const buildProgram = vi.fn(() => ({ parseAsync }));
|
||||
|
||||
describe("rewriteUpdateFlagArgv", () => {
|
||||
it("leaves argv unchanged when --update is absent", () => {
|
||||
const argv = ["node", "entry.js", "status"];
|
||||
expect(rewriteUpdateFlagArgv(argv)).toBe(argv);
|
||||
vi.mock("../infra/dotenv.js", () => ({ loadDotEnv: vi.fn() }));
|
||||
vi.mock("../infra/env.js", () => ({ normalizeEnv: vi.fn() }));
|
||||
vi.mock("../infra/path-env.js", () => ({ ensureClawdbotCliOnPath: vi.fn() }));
|
||||
vi.mock("../infra/runtime-guard.js", () => ({ assertSupportedRuntime: vi.fn() }));
|
||||
vi.mock("../infra/errors.js", () => ({ formatUncaughtError: vi.fn(() => "error") }));
|
||||
vi.mock("../infra/unhandled-rejections.js", () => ({ installUnhandledRejectionHandler: vi.fn() }));
|
||||
vi.mock("../logging.js", () => ({ enableConsoleCapture: vi.fn() }));
|
||||
vi.mock("./program.js", () => ({ buildProgram }));
|
||||
vi.mock("./program/register.subclis.js", () => ({ registerSubCliByName }));
|
||||
vi.mock("./route.js", () => ({ tryRouteCli: vi.fn(async () => false) }));
|
||||
|
||||
const { runCli } = await import("./run-main.js");
|
||||
|
||||
describe("runCli", () => {
|
||||
afterEach(() => {
|
||||
registerSubCliByName.mockClear();
|
||||
parseAsync.mockClear();
|
||||
buildProgram.mockClear();
|
||||
});
|
||||
|
||||
it("rewrites --update into the update command", () => {
|
||||
expect(rewriteUpdateFlagArgv(["node", "entry.js", "--update"])).toEqual([
|
||||
"node",
|
||||
"entry.js",
|
||||
"update",
|
||||
]);
|
||||
});
|
||||
it("registers the primary subcommand before parsing", async () => {
|
||||
const argv = ["/usr/bin/node-22", "/opt/clawdbot/entry.js", "gateway", "--port", "18789"];
|
||||
|
||||
it("preserves global flags that appear before --update", () => {
|
||||
expect(rewriteUpdateFlagArgv(["node", "entry.js", "--profile", "p", "--update"])).toEqual([
|
||||
"node",
|
||||
"entry.js",
|
||||
"--profile",
|
||||
"p",
|
||||
"update",
|
||||
]);
|
||||
});
|
||||
await runCli(argv);
|
||||
|
||||
it("keeps update options after the rewritten command", () => {
|
||||
expect(rewriteUpdateFlagArgv(["node", "entry.js", "--update", "--json"])).toEqual([
|
||||
"node",
|
||||
"entry.js",
|
||||
"update",
|
||||
"--json",
|
||||
]);
|
||||
expect(registerSubCliByName).toHaveBeenCalledTimes(1);
|
||||
expect(registerSubCliByName).toHaveBeenCalledWith(expect.any(Object), "gateway");
|
||||
expect(parseAsync).toHaveBeenCalledWith(argv);
|
||||
});
|
||||
});
|
||||
@@ -108,8 +108,6 @@ export type DiscordAccountConfig = {
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Outbound text chunk size (chars). Default: 2000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
|
||||
@@ -78,8 +78,6 @@ export type GoogleChatAccountConfig = {
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
|
||||
@@ -54,8 +54,6 @@ export type IMessageAccountConfig = {
|
||||
mediaMaxMb?: number;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
|
||||
@@ -72,8 +72,6 @@ export type MSTeamsConfig = {
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||
import type { DmConfig } from "./types.messages.js";
|
||||
|
||||
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
||||
export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive";
|
||||
|
||||
export type SignalAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
@@ -57,8 +56,6 @@ export type SignalAccountConfig = {
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
@@ -67,19 +64,6 @@ export type SignalAccountConfig = {
|
||||
reactionNotifications?: SignalReactionNotificationMode;
|
||||
/** Allowlist for reaction notifications when mode is allowlist. */
|
||||
reactionAllowlist?: Array<string | number>;
|
||||
/** Action toggles for message tool capabilities. */
|
||||
actions?: {
|
||||
/** Enable/disable sending reactions via message tool (default: true). */
|
||||
reactions?: boolean;
|
||||
};
|
||||
/**
|
||||
* Controls agent reaction behavior:
|
||||
* - "off": No reactions
|
||||
* - "ack": Only automatic ack reactions (👀 when processing)
|
||||
* - "minimal": Agent can react sparingly (default)
|
||||
* - "extensive": Agent can react liberally
|
||||
*/
|
||||
reactionLevel?: SignalReactionLevel;
|
||||
/** Heartbeat visibility settings for this channel. */
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
};
|
||||
|
||||
@@ -116,8 +116,6 @@ export type SlackAccountConfig = {
|
||||
/** Per-DM config overrides keyed by user ID. */
|
||||
dms?: Record<string, DmConfig>;
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
|
||||
@@ -80,8 +80,6 @@ export type TelegramAccountConfig = {
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/** Chunking config for draft streaming in `streamMode: "block"`. */
|
||||
|
||||
@@ -55,8 +55,6 @@ export type WhatsAppConfig = {
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/** Maximum media file size in MB. Default: 50. */
|
||||
mediaMaxMb?: number;
|
||||
/** Disable block streaming for this account. */
|
||||
@@ -124,8 +122,6 @@ export type WhatsAppAccountConfig = {
|
||||
/** Per-DM config overrides keyed by user ID. */
|
||||
dms?: Record<string, DmConfig>;
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
mediaMaxMb?: number;
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
|
||||
@@ -102,7 +102,6 @@ export const TelegramAccountSchemaBase = z
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
draftChunk: BlockStreamingChunkSchema.optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
@@ -213,7 +212,6 @@ export const DiscordAccountSchema = z
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
maxLinesPerMessage: z.number().int().positive().optional(),
|
||||
@@ -312,7 +310,6 @@ export const GoogleChatAccountSchema = z
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
@@ -404,7 +401,6 @@ export const SlackAccountSchema = z
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
@@ -498,19 +494,11 @@ export const SignalAccountSchemaBase = z
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
})
|
||||
.strict();
|
||||
@@ -559,7 +547,6 @@ export const IMessageAccountSchemaBase = z
|
||||
includeAttachments: z.boolean().optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
groups: z
|
||||
@@ -718,7 +705,6 @@ export const MSTeamsConfigSchema = z
|
||||
groupAllowFrom: z.array(z.string()).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
mediaAllowHosts: z.array(z.string()).optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
|
||||
@@ -30,7 +30,6 @@ export const WhatsAppAccountSchema = z
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
@@ -86,7 +85,6 @@ export const WhatsAppConfigSchema = z
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional().default(50),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { chunkDiscordText, chunkDiscordTextWithMode } from "./chunk.js";
|
||||
import { chunkDiscordText } from "./chunk.js";
|
||||
|
||||
function countLines(text: string) {
|
||||
return text.split("\n").length;
|
||||
@@ -51,16 +51,6 @@ describe("chunkDiscordText", () => {
|
||||
expect(chunks.at(-1)).toContain("Done.");
|
||||
});
|
||||
|
||||
it("keeps fenced blocks intact when chunkMode is newline", () => {
|
||||
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
|
||||
const chunks = chunkDiscordTextWithMode(text, {
|
||||
maxChars: 2000,
|
||||
maxLines: 50,
|
||||
chunkMode: "newline",
|
||||
});
|
||||
expect(chunks).toEqual(["```js\nconst a = 1;\nconst b = 2;\n```", "After"]);
|
||||
});
|
||||
|
||||
it("reserves space for closing fences when chunking", () => {
|
||||
const body = "a".repeat(120);
|
||||
const text = `\`\`\`txt\n${body}\n\`\`\``;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { chunkMarkdownTextWithMode, type ChunkMode } from "../auto-reply/chunk.js";
|
||||
|
||||
export type ChunkDiscordTextOpts = {
|
||||
/** Max characters per Discord message. Default: 2000. */
|
||||
maxChars?: number;
|
||||
@@ -180,31 +178,6 @@ export function chunkDiscordText(text: string, opts: ChunkDiscordTextOpts = {}):
|
||||
return rebalanceReasoningItalics(text, chunks);
|
||||
}
|
||||
|
||||
export function chunkDiscordTextWithMode(
|
||||
text: string,
|
||||
opts: ChunkDiscordTextOpts & { chunkMode?: ChunkMode },
|
||||
): string[] {
|
||||
const chunkMode = opts.chunkMode ?? "length";
|
||||
if (chunkMode !== "newline") {
|
||||
return chunkDiscordText(text, opts);
|
||||
}
|
||||
const lineChunks = chunkMarkdownTextWithMode(
|
||||
text,
|
||||
Math.max(1, Math.floor(opts.maxChars ?? DEFAULT_MAX_CHARS)),
|
||||
"newline",
|
||||
);
|
||||
const chunks: string[] = [];
|
||||
for (const line of lineChunks) {
|
||||
const nested = chunkDiscordText(line, opts);
|
||||
if (!nested.length && line) {
|
||||
chunks.push(line);
|
||||
continue;
|
||||
}
|
||||
chunks.push(...nested);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// Keep italics intact for reasoning payloads that are wrapped once with `_…_`.
|
||||
// When Discord chunking splits the message, we close italics at the end of
|
||||
// each chunk and reopen at the start of the next so every chunk renders
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ChannelType } from "@buape/carbon";
|
||||
import { resolveAckReaction, resolveHumanDelayConfig } from "../../agents/identity.js";
|
||||
import {
|
||||
removeAckReactionAfterReply,
|
||||
@@ -22,7 +21,6 @@ import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-di
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { recordInboundSession } from "../../channels/session.js";
|
||||
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
|
||||
import { resolveChunkMode } from "../../auto-reply/chunk.js";
|
||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
|
||||
@@ -131,15 +129,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
senderDisplay && senderTag && senderDisplay !== senderTag
|
||||
? `${senderDisplay} (${senderTag})`
|
||||
: (senderDisplay ?? senderTag ?? author.id);
|
||||
const isForumParent =
|
||||
threadParentType === ChannelType.GuildForum || threadParentType === ChannelType.GuildMedia;
|
||||
const forumParentSlug =
|
||||
isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : "";
|
||||
const threadChannelId = threadChannel?.id;
|
||||
const isForumStarter = Boolean(
|
||||
threadChannelId && isForumParent && forumParentSlug && message.id === threadChannelId,
|
||||
);
|
||||
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
|
||||
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
|
||||
const groupSubject = isDirectMessage ? undefined : groupChannel;
|
||||
const channelDescription = channelInfo?.topic?.trim();
|
||||
@@ -193,9 +182,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
if (replyContext) {
|
||||
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
|
||||
}
|
||||
if (forumContextLine) {
|
||||
combinedBody = `${combinedBody}\n${forumContextLine}`;
|
||||
}
|
||||
|
||||
let threadStarterBody: string | undefined;
|
||||
let threadLabel: string | undefined;
|
||||
@@ -349,7 +335,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
textLimit,
|
||||
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||
tableMode,
|
||||
chunkMode: resolveChunkMode(cfg, "discord", accountId),
|
||||
});
|
||||
replyReference.markSent();
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10";
|
||||
|
||||
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
|
||||
import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||
import {
|
||||
buildCommandTextFromArgs,
|
||||
findCommandByNativeName,
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} from "../../pairing/pairing-store.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import { loadWebMedia } from "../../web/media.js";
|
||||
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||
import { chunkDiscordText } from "../chunk.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
||||
import {
|
||||
allowListMatches,
|
||||
@@ -767,7 +767,6 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
}),
|
||||
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||
preferFollowUp: preferFollowUp || didReply,
|
||||
chunkMode: resolveChunkMode(cfg, "discord", accountId),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDiscordUnknownInteraction(error)) {
|
||||
@@ -798,9 +797,8 @@ async function deliverDiscordInteractionReply(params: {
|
||||
textLimit: number;
|
||||
maxLinesPerMessage?: number;
|
||||
preferFollowUp: boolean;
|
||||
chunkMode: "length" | "newline";
|
||||
}) {
|
||||
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
|
||||
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp } = params;
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
|
||||
@@ -840,12 +838,10 @@ async function deliverDiscordInteractionReply(params: {
|
||||
};
|
||||
}),
|
||||
);
|
||||
const chunks = chunkDiscordTextWithMode(text, {
|
||||
const chunks = chunkDiscordText(text, {
|
||||
maxChars: textLimit,
|
||||
maxLines: maxLinesPerMessage,
|
||||
chunkMode,
|
||||
});
|
||||
if (!chunks.length && text) chunks.push(text);
|
||||
const caption = chunks[0] ?? "";
|
||||
await sendMessage(caption, media);
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
@@ -856,12 +852,10 @@ async function deliverDiscordInteractionReply(params: {
|
||||
}
|
||||
|
||||
if (!text.trim()) return;
|
||||
const chunks = chunkDiscordTextWithMode(text, {
|
||||
const chunks = chunkDiscordText(text, {
|
||||
maxChars: textLimit,
|
||||
maxLines: maxLinesPerMessage,
|
||||
chunkMode,
|
||||
});
|
||||
if (!chunks.length && text) chunks.push(text);
|
||||
for (const chunk of chunks) {
|
||||
if (!chunk.trim()) continue;
|
||||
await sendMessage(chunk);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { RequestClient } from "@buape/carbon";
|
||||
|
||||
import type { ChunkMode } from "../../auto-reply/chunk.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { MarkdownTableMode } from "../../config/types.base.js";
|
||||
import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||
import { chunkDiscordText } from "../chunk.js";
|
||||
import { sendMessageDiscord } from "../send.js";
|
||||
|
||||
export async function deliverDiscordReply(params: {
|
||||
@@ -19,7 +18,6 @@ export async function deliverDiscordReply(params: {
|
||||
maxLinesPerMessage?: number;
|
||||
replyToId?: string;
|
||||
tableMode?: MarkdownTableMode;
|
||||
chunkMode?: ChunkMode;
|
||||
}) {
|
||||
const chunkLimit = Math.min(params.textLimit, 2000);
|
||||
for (const payload of params.replies) {
|
||||
@@ -32,14 +30,10 @@ export async function deliverDiscordReply(params: {
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
let isFirstChunk = true;
|
||||
const mode = params.chunkMode ?? "length";
|
||||
const chunks = chunkDiscordTextWithMode(text, {
|
||||
for (const chunk of chunkDiscordText(text, {
|
||||
maxChars: chunkLimit,
|
||||
maxLines: params.maxLinesPerMessage,
|
||||
chunkMode: mode,
|
||||
});
|
||||
if (!chunks.length && text) chunks.push(text);
|
||||
for (const chunk of chunks) {
|
||||
})) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) continue;
|
||||
await sendMessageDiscord(params.target, trimmed, {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { RequestClient } from "@buape/carbon";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { resolveChunkMode } from "../auto-reply/chunk.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||
@@ -46,7 +45,6 @@ export async function sendMessageDiscord(
|
||||
channel: "discord",
|
||||
accountId: accountInfo.accountId,
|
||||
});
|
||||
const chunkMode = resolveChunkMode(cfg, "discord", accountInfo.accountId);
|
||||
const textWithTables = convertMarkdownTables(text ?? "", tableMode);
|
||||
const { token, rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = parseRecipient(to);
|
||||
@@ -63,7 +61,6 @@ export async function sendMessageDiscord(
|
||||
request,
|
||||
accountInfo.config.maxLinesPerMessage,
|
||||
opts.embeds,
|
||||
chunkMode,
|
||||
);
|
||||
} else {
|
||||
result = await sendDiscordText(
|
||||
@@ -74,7 +71,6 @@ export async function sendMessageDiscord(
|
||||
request,
|
||||
accountInfo.config.maxLinesPerMessage,
|
||||
opts.embeds,
|
||||
chunkMode,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -9,8 +9,7 @@ import { createDiscordRetryRunner, type RetryRunner } from "../infra/retry-polic
|
||||
import { normalizePollDurationHours, normalizePollInput, type PollInput } from "../polls.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import type { ChunkMode } from "../auto-reply/chunk.js";
|
||||
import { chunkDiscordTextWithMode } from "./chunk.js";
|
||||
import { chunkDiscordText } from "./chunk.js";
|
||||
import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js";
|
||||
import { DiscordSendError } from "./send.types.js";
|
||||
import { parseDiscordTarget } from "./targets.js";
|
||||
@@ -232,18 +231,15 @@ async function sendDiscordText(
|
||||
request: DiscordRequest,
|
||||
maxLinesPerMessage?: number,
|
||||
embeds?: unknown[],
|
||||
chunkMode?: ChunkMode,
|
||||
) {
|
||||
if (!text.trim()) {
|
||||
throw new Error("Message must be non-empty for Discord sends");
|
||||
}
|
||||
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
||||
const chunks = chunkDiscordTextWithMode(text, {
|
||||
const chunks = chunkDiscordText(text, {
|
||||
maxChars: DISCORD_TEXT_LIMIT,
|
||||
maxLines: maxLinesPerMessage,
|
||||
chunkMode,
|
||||
});
|
||||
if (!chunks.length && text) chunks.push(text);
|
||||
if (chunks.length === 1) {
|
||||
const res = (await request(
|
||||
() =>
|
||||
@@ -289,17 +285,14 @@ async function sendDiscordMedia(
|
||||
request: DiscordRequest,
|
||||
maxLinesPerMessage?: number,
|
||||
embeds?: unknown[],
|
||||
chunkMode?: ChunkMode,
|
||||
) {
|
||||
const media = await loadWebMedia(mediaUrl);
|
||||
const chunks = text
|
||||
? chunkDiscordTextWithMode(text, {
|
||||
? chunkDiscordText(text, {
|
||||
maxChars: DISCORD_TEXT_LIMIT,
|
||||
maxLines: maxLinesPerMessage,
|
||||
chunkMode,
|
||||
})
|
||||
: [];
|
||||
if (!chunks.length && text) chunks.push(text);
|
||||
const caption = chunks[0] ?? "";
|
||||
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
||||
const res = (await request(
|
||||
@@ -321,16 +314,7 @@ async function sendDiscordMedia(
|
||||
)) as { id: string; channel_id: string };
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
if (!chunk.trim()) continue;
|
||||
await sendDiscordText(
|
||||
rest,
|
||||
channelId,
|
||||
chunk,
|
||||
undefined,
|
||||
request,
|
||||
maxLinesPerMessage,
|
||||
undefined,
|
||||
chunkMode,
|
||||
);
|
||||
await sendDiscordText(rest, channelId, chunk, undefined, request, maxLinesPerMessage);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chunkTextWithMode, resolveChunkMode } from "../../auto-reply/chunk.js";
|
||||
import { chunkText } from "../../auto-reply/chunk.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||
import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||
@@ -23,14 +23,13 @@ export async function deliverReplies(params: {
|
||||
channel: "imessage",
|
||||
accountId,
|
||||
});
|
||||
const chunkMode = resolveChunkMode(cfg, "imessage", accountId);
|
||||
for (const payload of replies) {
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const rawText = payload.text ?? "";
|
||||
const text = convertMarkdownTables(rawText, tableMode);
|
||||
if (!text && mediaList.length === 0) continue;
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
|
||||
for (const chunk of chunkText(text, textLimit)) {
|
||||
await sendMessageIMessage(target, chunk, {
|
||||
maxBytes,
|
||||
client,
|
||||
|
||||
@@ -3,20 +3,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { wrapFetchWithAbortSignal } from "./fetch.js";
|
||||
|
||||
describe("wrapFetchWithAbortSignal", () => {
|
||||
it("adds duplex for requests with a body", async () => {
|
||||
let seenInit: RequestInit | undefined;
|
||||
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seenInit = init;
|
||||
return {} as Response;
|
||||
});
|
||||
|
||||
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
|
||||
|
||||
await wrapped("https://example.com", { method: "POST", body: "hi" });
|
||||
|
||||
expect(seenInit?.duplex).toBe("half");
|
||||
});
|
||||
|
||||
it("converts foreign abort signals to native controllers", async () => {
|
||||
let seenSignal: AbortSignal | undefined;
|
||||
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
|
||||
+6
-26
@@ -2,38 +2,18 @@ type FetchWithPreconnect = typeof fetch & {
|
||||
preconnect: (url: string, init?: { credentials?: RequestCredentials }) => void;
|
||||
};
|
||||
|
||||
type RequestInitWithDuplex = RequestInit & { duplex?: "half" };
|
||||
|
||||
function withDuplex(
|
||||
init: RequestInit | undefined,
|
||||
input: RequestInfo | URL,
|
||||
): RequestInit | undefined {
|
||||
const hasInitBody = init?.body != null;
|
||||
const hasRequestBody =
|
||||
!hasInitBody &&
|
||||
typeof Request !== "undefined" &&
|
||||
input instanceof Request &&
|
||||
input.body != null;
|
||||
if (!hasInitBody && !hasRequestBody) return init;
|
||||
if (init && "duplex" in (init as Record<string, unknown>)) return init;
|
||||
return init
|
||||
? ({ ...init, duplex: "half" as const } as RequestInitWithDuplex)
|
||||
: ({ duplex: "half" as const } as RequestInitWithDuplex);
|
||||
}
|
||||
|
||||
export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch {
|
||||
const wrapped = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const patchedInit = withDuplex(init, input);
|
||||
const signal = patchedInit?.signal;
|
||||
if (!signal) return fetchImpl(input, patchedInit);
|
||||
const signal = init?.signal;
|
||||
if (!signal) return fetchImpl(input, init);
|
||||
if (typeof AbortSignal !== "undefined" && signal instanceof AbortSignal) {
|
||||
return fetchImpl(input, patchedInit);
|
||||
return fetchImpl(input, init);
|
||||
}
|
||||
if (typeof AbortController === "undefined") {
|
||||
return fetchImpl(input, patchedInit);
|
||||
return fetchImpl(input, init);
|
||||
}
|
||||
if (typeof signal.addEventListener !== "function") {
|
||||
return fetchImpl(input, patchedInit);
|
||||
return fetchImpl(input, init);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort();
|
||||
@@ -42,7 +22,7 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
const response = fetchImpl(input, { ...patchedInit, signal: controller.signal });
|
||||
const response = fetchImpl(input, { ...init, signal: controller.signal });
|
||||
if (typeof signal.removeEventListener === "function") {
|
||||
void response.finally(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
|
||||
@@ -168,84 +168,6 @@ describe("deliverOutboundPayloads", () => {
|
||||
expect(results.map((r) => r.messageId)).toEqual(["w1", "w2"]);
|
||||
});
|
||||
|
||||
it("respects newline chunk mode for WhatsApp", async () => {
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: { whatsapp: { textChunkLimit: 4000, chunkMode: "newline" } },
|
||||
};
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "Line one\n\nLine two" }],
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
|
||||
expect(sendWhatsApp).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"+1555",
|
||||
"Line one",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
expect(sendWhatsApp).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"+1555",
|
||||
"\nLine two",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves fenced blocks for markdown chunkers in newline mode", async () => {
|
||||
const chunker = vi.fn((text: string) => (text ? [text] : []));
|
||||
const sendText = vi.fn().mockImplementation(async ({ text }: { text: string }) => ({
|
||||
channel: "matrix" as const,
|
||||
messageId: text,
|
||||
roomId: "r1",
|
||||
}));
|
||||
const sendMedia = vi.fn().mockImplementation(async ({ text }: { text: string }) => ({
|
||||
channel: "matrix" as const,
|
||||
messageId: text,
|
||||
roomId: "r1",
|
||||
}));
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "matrix",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "matrix",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker,
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText,
|
||||
sendMedia,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: { matrix: { textChunkLimit: 4000, chunkMode: "newline" } },
|
||||
};
|
||||
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
to: "!room",
|
||||
payloads: [{ text }],
|
||||
});
|
||||
|
||||
expect(chunker).toHaveBeenCalledTimes(2);
|
||||
expect(chunker).toHaveBeenNthCalledWith(1, "```js\nconst a = 1;\nconst b = 2;\n```", 4000);
|
||||
expect(chunker).toHaveBeenNthCalledWith(2, "After", 4000);
|
||||
});
|
||||
|
||||
it("uses iMessage media maxBytes from agent fallback", async () => {
|
||||
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1" });
|
||||
setActivePluginRegistry(
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
chunkByNewline,
|
||||
chunkMarkdownTextWithMode,
|
||||
resolveChunkMode,
|
||||
resolveTextChunkLimit,
|
||||
} from "../../auto-reply/chunk.js";
|
||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js";
|
||||
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
|
||||
@@ -67,7 +62,6 @@ type Chunker = (text: string, limit: number) => string[];
|
||||
|
||||
type ChannelHandler = {
|
||||
chunker: Chunker | null;
|
||||
chunkerMode?: "text" | "markdown";
|
||||
textChunkLimit?: number;
|
||||
sendText: (text: string) => Promise<OutboundDeliveryResult>;
|
||||
sendMedia: (caption: string, mediaUrl: string) => Promise<OutboundDeliveryResult>;
|
||||
@@ -127,10 +121,8 @@ function createPluginHandler(params: {
|
||||
const sendText = outbound.sendText;
|
||||
const sendMedia = outbound.sendMedia;
|
||||
const chunker = outbound.chunker ?? null;
|
||||
const chunkerMode = outbound.chunkerMode;
|
||||
return {
|
||||
chunker,
|
||||
chunkerMode,
|
||||
textChunkLimit: outbound.textChunkLimit,
|
||||
sendText: async (text) =>
|
||||
sendText({
|
||||
@@ -200,7 +192,6 @@ export async function deliverOutboundPayloads(params: {
|
||||
fallbackLimit: handler.textChunkLimit,
|
||||
})
|
||||
: undefined;
|
||||
const chunkMode = handler.chunker ? resolveChunkMode(cfg, channel, accountId) : "length";
|
||||
const isSignalChannel = channel === "signal";
|
||||
const signalTableMode = isSignalChannel
|
||||
? resolveMarkdownTableMode({ cfg, channel: "signal", accountId })
|
||||
@@ -221,23 +212,6 @@ export async function deliverOutboundPayloads(params: {
|
||||
results.push(await handler.sendText(text));
|
||||
return;
|
||||
}
|
||||
if (chunkMode === "newline") {
|
||||
const mode = handler.chunkerMode ?? "text";
|
||||
const lineChunks =
|
||||
mode === "markdown"
|
||||
? chunkMarkdownTextWithMode(text, textLimit, "newline")
|
||||
: chunkByNewline(text, textLimit, { splitLongLines: false });
|
||||
if (!lineChunks.length && text) lineChunks.push(text);
|
||||
for (const lineChunk of lineChunks) {
|
||||
const chunks = handler.chunker(lineChunk, textLimit);
|
||||
if (!chunks.length && lineChunk) chunks.push(lineChunk);
|
||||
for (const chunk of chunks) {
|
||||
throwIfAborted(abortSignal);
|
||||
results.push(await handler.sendText(chunk));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const chunks = handler.chunker(text, textLimit);
|
||||
for (const chunk of chunks) {
|
||||
throwIfAborted(abortSignal);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createRequire } from "node:module";
|
||||
import {
|
||||
chunkByNewline,
|
||||
chunkMarkdownText,
|
||||
chunkMarkdownTextWithMode,
|
||||
chunkText,
|
||||
chunkTextWithMode,
|
||||
resolveChunkMode,
|
||||
@@ -41,7 +40,6 @@ import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/a
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
||||
import { recordInboundSession } from "../../channels/session.js";
|
||||
import { discordMessageActions } from "../../channels/plugins/actions/discord.js";
|
||||
import { signalMessageActions } from "../../channels/plugins/actions/signal.js";
|
||||
import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js";
|
||||
import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js";
|
||||
import { monitorWebChannel } from "../../channels/web/index.js";
|
||||
@@ -171,7 +169,6 @@ export function createPluginRuntime(): PluginRuntime {
|
||||
text: {
|
||||
chunkByNewline,
|
||||
chunkMarkdownText,
|
||||
chunkMarkdownTextWithMode,
|
||||
chunkText,
|
||||
chunkTextWithMode,
|
||||
resolveChunkMode,
|
||||
@@ -272,7 +269,6 @@ export function createPluginRuntime(): PluginRuntime {
|
||||
probeSignal,
|
||||
sendMessageSignal,
|
||||
monitorSignalProvider,
|
||||
messageActions: signalMessageActions,
|
||||
},
|
||||
imessage: {
|
||||
monitorIMessageProvider,
|
||||
|
||||
@@ -37,8 +37,6 @@ type ResolveCommandAuthorizedFromAuthorizers =
|
||||
type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit;
|
||||
type ResolveChunkMode = typeof import("../../auto-reply/chunk.js").resolveChunkMode;
|
||||
type ChunkMarkdownText = typeof import("../../auto-reply/chunk.js").chunkMarkdownText;
|
||||
type ChunkMarkdownTextWithMode =
|
||||
typeof import("../../auto-reply/chunk.js").chunkMarkdownTextWithMode;
|
||||
type ChunkText = typeof import("../../auto-reply/chunk.js").chunkText;
|
||||
type ChunkTextWithMode = typeof import("../../auto-reply/chunk.js").chunkTextWithMode;
|
||||
type ChunkByNewline = typeof import("../../auto-reply/chunk.js").chunkByNewline;
|
||||
@@ -125,8 +123,6 @@ type TelegramMessageActions =
|
||||
type ProbeSignal = typeof import("../../signal/probe.js").probeSignal;
|
||||
type SendMessageSignal = typeof import("../../signal/send.js").sendMessageSignal;
|
||||
type MonitorSignalProvider = typeof import("../../signal/index.js").monitorSignalProvider;
|
||||
type SignalMessageActions =
|
||||
typeof import("../../channels/plugins/actions/signal.js").signalMessageActions;
|
||||
type MonitorIMessageProvider = typeof import("../../imessage/monitor.js").monitorIMessageProvider;
|
||||
type ProbeIMessage = typeof import("../../imessage/probe.js").probeIMessage;
|
||||
type SendMessageIMessage = typeof import("../../imessage/send.js").sendMessageIMessage;
|
||||
@@ -182,7 +178,6 @@ export type PluginRuntime = {
|
||||
text: {
|
||||
chunkByNewline: ChunkByNewline;
|
||||
chunkMarkdownText: ChunkMarkdownText;
|
||||
chunkMarkdownTextWithMode: ChunkMarkdownTextWithMode;
|
||||
chunkText: ChunkText;
|
||||
chunkTextWithMode: ChunkTextWithMode;
|
||||
resolveChunkMode: ResolveChunkMode;
|
||||
@@ -283,7 +278,6 @@ export type PluginRuntime = {
|
||||
probeSignal: ProbeSignal;
|
||||
sendMessageSignal: SendMessageSignal;
|
||||
monitorSignalProvider: MonitorSignalProvider;
|
||||
messageActions: SignalMessageActions;
|
||||
};
|
||||
imessage: {
|
||||
monitorIMessageProvider: MonitorIMessageProvider;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export { monitorSignalProvider } from "./monitor.js";
|
||||
export { probeSignal } from "./probe.js";
|
||||
export { sendMessageSignal } from "./send.js";
|
||||
export { sendReactionSignal, removeReactionSignal } from "./send-reactions.js";
|
||||
export { resolveSignalReactionLevel } from "./reaction-level.js";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chunkTextWithMode, resolveChunkMode, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
@@ -214,16 +214,14 @@ async function deliverReplies(params: {
|
||||
runtime: RuntimeEnv;
|
||||
maxBytes: number;
|
||||
textLimit: number;
|
||||
chunkMode: "length" | "newline";
|
||||
}) {
|
||||
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } =
|
||||
params;
|
||||
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit } = params;
|
||||
for (const payload of replies) {
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
if (!text && mediaList.length === 0) continue;
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
|
||||
for (const chunk of chunkText(text, textLimit)) {
|
||||
await sendMessageSignal(target, chunk, {
|
||||
baseUrl,
|
||||
account,
|
||||
@@ -264,7 +262,6 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
);
|
||||
const groupHistories = new Map<string, HistoryEntry[]>();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "signal", accountInfo.accountId);
|
||||
const chunkMode = resolveChunkMode(cfg, "signal", accountInfo.accountId);
|
||||
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
|
||||
const account = opts.account?.trim() || accountInfo.config.account?.trim();
|
||||
const dmPolicy = accountInfo.config.dmPolicy ?? "pairing";
|
||||
@@ -343,7 +340,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
sendReadReceipts,
|
||||
readReceiptsViaDaemon,
|
||||
fetchAttachment,
|
||||
deliverReplies: (params) => deliverReplies({ ...params, chunkMode }),
|
||||
deliverReplies,
|
||||
resolveSignalReactionTargets,
|
||||
isSignalReactionMessage,
|
||||
shouldEmitSignalReactionNotification,
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
|
||||
export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive";
|
||||
|
||||
export type ResolvedSignalReactionLevel = {
|
||||
level: SignalReactionLevel;
|
||||
/** Whether ACK reactions (e.g., 👀 when processing) are enabled. */
|
||||
ackEnabled: boolean;
|
||||
/** Whether agent-controlled reactions are enabled. */
|
||||
agentReactionsEnabled: boolean;
|
||||
/** Guidance level for agent reactions (minimal = sparse, extensive = liberal). */
|
||||
agentReactionGuidance?: "minimal" | "extensive";
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the effective reaction level and its implications for Signal.
|
||||
*
|
||||
* Levels:
|
||||
* - "off": No reactions at all
|
||||
* - "ack": Only automatic ack reactions (👀 when processing), no agent reactions
|
||||
* - "minimal": Agent can react, but sparingly (default)
|
||||
* - "extensive": Agent can react liberally
|
||||
*/
|
||||
export function resolveSignalReactionLevel(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string;
|
||||
}): ResolvedSignalReactionLevel {
|
||||
const account = resolveSignalAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const level = (account.config.reactionLevel ?? "minimal") as SignalReactionLevel;
|
||||
|
||||
switch (level) {
|
||||
case "off":
|
||||
return {
|
||||
level,
|
||||
ackEnabled: false,
|
||||
agentReactionsEnabled: false,
|
||||
};
|
||||
case "ack":
|
||||
return {
|
||||
level,
|
||||
ackEnabled: true,
|
||||
agentReactionsEnabled: false,
|
||||
};
|
||||
case "minimal":
|
||||
return {
|
||||
level,
|
||||
ackEnabled: false,
|
||||
agentReactionsEnabled: true,
|
||||
agentReactionGuidance: "minimal",
|
||||
};
|
||||
case "extensive":
|
||||
return {
|
||||
level,
|
||||
ackEnabled: false,
|
||||
agentReactionsEnabled: true,
|
||||
agentReactionGuidance: "extensive",
|
||||
};
|
||||
default:
|
||||
// Fallback to minimal behavior
|
||||
return {
|
||||
level: "minimal",
|
||||
ackEnabled: false,
|
||||
agentReactionsEnabled: true,
|
||||
agentReactionGuidance: "minimal",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const rpcMock = vi.fn();
|
||||
const loadSendReactions = async () => await import("./send-reactions.js");
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveSignalAccount: () => ({
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
baseUrl: "http://signal.local",
|
||||
configured: true,
|
||||
config: { account: "+15550001111" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
signalRpcRequest: (...args: unknown[]) => rpcMock(...args),
|
||||
}));
|
||||
|
||||
describe("sendReactionSignal", () => {
|
||||
beforeEach(() => {
|
||||
rpcMock.mockReset().mockResolvedValue({ timestamp: 123 });
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("uses recipients array and targetAuthor for uuid dms", async () => {
|
||||
const { sendReactionSignal } = await loadSendReactions();
|
||||
await sendReactionSignal("uuid:123e4567-e89b-12d3-a456-426614174000", 123, "🔥");
|
||||
|
||||
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(rpcMock).toHaveBeenCalledWith("sendReaction", expect.any(Object), expect.any(Object));
|
||||
expect(params.recipients).toEqual(["123e4567-e89b-12d3-a456-426614174000"]);
|
||||
expect(params.groupIds).toBeUndefined();
|
||||
expect(params.targetAuthor).toBe("123e4567-e89b-12d3-a456-426614174000");
|
||||
expect(params).not.toHaveProperty("recipient");
|
||||
expect(params).not.toHaveProperty("groupId");
|
||||
});
|
||||
|
||||
it("uses groupIds array and maps targetAuthorUuid", async () => {
|
||||
const { sendReactionSignal } = await loadSendReactions();
|
||||
await sendReactionSignal("", 123, "✅", {
|
||||
groupId: "group-id",
|
||||
targetAuthorUuid: "uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
});
|
||||
|
||||
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(params.recipients).toBeUndefined();
|
||||
expect(params.groupIds).toEqual(["group-id"]);
|
||||
expect(params.targetAuthor).toBe("123e4567-e89b-12d3-a456-426614174000");
|
||||
});
|
||||
|
||||
it("defaults targetAuthor to recipient for removals", async () => {
|
||||
const { removeReactionSignal } = await loadSendReactions();
|
||||
await removeReactionSignal("+15551230000", 456, "❌");
|
||||
|
||||
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(params.recipients).toEqual(["+15551230000"]);
|
||||
expect(params.targetAuthor).toBe("+15551230000");
|
||||
expect(params.remove).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
/**
|
||||
* Signal reactions via signal-cli JSON-RPC API
|
||||
*/
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalRpcRequest } from "./client.js";
|
||||
|
||||
export type SignalReactionOpts = {
|
||||
baseUrl?: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
timeoutMs?: number;
|
||||
targetAuthor?: string;
|
||||
targetAuthorUuid?: string;
|
||||
groupId?: string;
|
||||
};
|
||||
|
||||
export type SignalReactionResult = {
|
||||
ok: boolean;
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
function normalizeSignalId(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return "";
|
||||
return trimmed.replace(/^signal:/i, "").trim();
|
||||
}
|
||||
|
||||
function normalizeSignalUuid(raw: string): string {
|
||||
const trimmed = normalizeSignalId(raw);
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.toLowerCase().startsWith("uuid:")) {
|
||||
return trimmed.slice("uuid:".length).trim();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveTargetAuthorParams(params: {
|
||||
targetAuthor?: string;
|
||||
targetAuthorUuid?: string;
|
||||
fallback?: string;
|
||||
}): { targetAuthor?: string } {
|
||||
const candidates = [params.targetAuthor, params.targetAuthorUuid, params.fallback];
|
||||
for (const candidate of candidates) {
|
||||
const raw = candidate?.trim();
|
||||
if (!raw) continue;
|
||||
const normalized = normalizeSignalUuid(raw);
|
||||
if (normalized) return { targetAuthor: normalized };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function resolveReactionRpcContext(
|
||||
opts: SignalReactionOpts,
|
||||
accountInfo?: ReturnType<typeof resolveSignalAccount>,
|
||||
) {
|
||||
const hasBaseUrl = Boolean(opts.baseUrl?.trim());
|
||||
const hasAccount = Boolean(opts.account?.trim());
|
||||
const resolvedAccount =
|
||||
accountInfo ||
|
||||
(!hasBaseUrl || !hasAccount
|
||||
? resolveSignalAccount({
|
||||
cfg: loadConfig(),
|
||||
accountId: opts.accountId,
|
||||
})
|
||||
: undefined);
|
||||
const baseUrl = opts.baseUrl?.trim() || resolvedAccount?.baseUrl;
|
||||
if (!baseUrl) {
|
||||
throw new Error("Signal base URL is required");
|
||||
}
|
||||
const account = opts.account?.trim() || resolvedAccount?.config.account?.trim();
|
||||
return { baseUrl, account };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Signal reaction to a message
|
||||
* @param recipient - UUID or E.164 phone number of the message author
|
||||
* @param targetTimestamp - Message ID (timestamp) to react to
|
||||
* @param emoji - Emoji to react with
|
||||
* @param opts - Optional account/connection overrides
|
||||
*/
|
||||
export async function sendReactionSignal(
|
||||
recipient: string,
|
||||
targetTimestamp: number,
|
||||
emoji: string,
|
||||
opts: SignalReactionOpts = {},
|
||||
): Promise<SignalReactionResult> {
|
||||
const accountInfo = resolveSignalAccount({
|
||||
cfg: loadConfig(),
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const { baseUrl, account } = resolveReactionRpcContext(opts, accountInfo);
|
||||
|
||||
const normalizedRecipient = normalizeSignalUuid(recipient);
|
||||
const groupId = opts.groupId?.trim();
|
||||
if (!normalizedRecipient && !groupId) {
|
||||
throw new Error("Recipient or groupId is required for Signal reaction");
|
||||
}
|
||||
if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) {
|
||||
throw new Error("Valid targetTimestamp is required for Signal reaction");
|
||||
}
|
||||
if (!emoji?.trim()) {
|
||||
throw new Error("Emoji is required for Signal reaction");
|
||||
}
|
||||
|
||||
const targetAuthorParams = resolveTargetAuthorParams({
|
||||
targetAuthor: opts.targetAuthor,
|
||||
targetAuthorUuid: opts.targetAuthorUuid,
|
||||
fallback: normalizedRecipient,
|
||||
});
|
||||
if (groupId && !targetAuthorParams.targetAuthor) {
|
||||
throw new Error("targetAuthor is required for group reactions");
|
||||
}
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
emoji: emoji.trim(),
|
||||
targetTimestamp,
|
||||
...targetAuthorParams,
|
||||
};
|
||||
if (normalizedRecipient) params.recipients = [normalizedRecipient];
|
||||
if (groupId) params.groupIds = [groupId];
|
||||
if (account) params.account = account;
|
||||
|
||||
const result = await signalRpcRequest<{ timestamp?: number }>("sendReaction", params, {
|
||||
baseUrl,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
timestamp: result?.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a Signal reaction from a message
|
||||
* @param recipient - UUID or E.164 phone number of the message author
|
||||
* @param targetTimestamp - Message ID (timestamp) to remove reaction from
|
||||
* @param emoji - Emoji to remove
|
||||
* @param opts - Optional account/connection overrides
|
||||
*/
|
||||
export async function removeReactionSignal(
|
||||
recipient: string,
|
||||
targetTimestamp: number,
|
||||
emoji: string,
|
||||
opts: SignalReactionOpts = {},
|
||||
): Promise<SignalReactionResult> {
|
||||
const accountInfo = resolveSignalAccount({
|
||||
cfg: loadConfig(),
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const { baseUrl, account } = resolveReactionRpcContext(opts, accountInfo);
|
||||
|
||||
const normalizedRecipient = normalizeSignalUuid(recipient);
|
||||
const groupId = opts.groupId?.trim();
|
||||
if (!normalizedRecipient && !groupId) {
|
||||
throw new Error("Recipient or groupId is required for Signal reaction removal");
|
||||
}
|
||||
if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) {
|
||||
throw new Error("Valid targetTimestamp is required for Signal reaction removal");
|
||||
}
|
||||
if (!emoji?.trim()) {
|
||||
throw new Error("Emoji is required for Signal reaction removal");
|
||||
}
|
||||
|
||||
const targetAuthorParams = resolveTargetAuthorParams({
|
||||
targetAuthor: opts.targetAuthor,
|
||||
targetAuthorUuid: opts.targetAuthorUuid,
|
||||
fallback: normalizedRecipient,
|
||||
});
|
||||
if (groupId && !targetAuthorParams.targetAuthor) {
|
||||
throw new Error("targetAuthor is required for group reaction removal");
|
||||
}
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
emoji: emoji.trim(),
|
||||
targetTimestamp,
|
||||
remove: true,
|
||||
...targetAuthorParams,
|
||||
};
|
||||
if (normalizedRecipient) params.recipients = [normalizedRecipient];
|
||||
if (groupId) params.groupIds = [groupId];
|
||||
if (account) params.account = account;
|
||||
|
||||
const result = await signalRpcRequest<{ timestamp?: number }>("sendReaction", params, {
|
||||
baseUrl,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
timestamp: result?.timestamp,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
|
||||
import type { ChunkMode } from "../../auto-reply/chunk.js";
|
||||
import { chunkMarkdownTextWithMode } from "../../auto-reply/chunk.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { MarkdownTableMode } from "../../config/types.base.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
@@ -120,7 +118,6 @@ export async function deliverSlackSlashReplies(params: {
|
||||
ephemeral: boolean;
|
||||
textLimit: number;
|
||||
tableMode?: MarkdownTableMode;
|
||||
chunkMode?: ChunkMode;
|
||||
}) {
|
||||
const messages: string[] = [];
|
||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||
@@ -132,16 +129,9 @@ export async function deliverSlackSlashReplies(params: {
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (!combined) continue;
|
||||
const chunkMode = params.chunkMode ?? "length";
|
||||
const markdownChunks =
|
||||
chunkMode === "newline"
|
||||
? chunkMarkdownTextWithMode(combined, chunkLimit, chunkMode)
|
||||
: [combined];
|
||||
const chunks = markdownChunks.flatMap((markdown) =>
|
||||
markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode: params.tableMode }),
|
||||
);
|
||||
if (!chunks.length && combined) chunks.push(combined);
|
||||
for (const chunk of chunks) {
|
||||
for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit, {
|
||||
tableMode: params.tableMode,
|
||||
})) {
|
||||
messages.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt";
|
||||
import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js";
|
||||
import { resolveChunkMode } from "../../auto-reply/chunk.js";
|
||||
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
||||
import {
|
||||
buildCommandTextFromArgs,
|
||||
@@ -430,7 +429,6 @@ export function registerSlackMonitorSlashCommands(params: {
|
||||
respond,
|
||||
ephemeral: slashCommand.ephemeral,
|
||||
textLimit: ctx.textLimit,
|
||||
chunkMode: resolveChunkMode(cfg, "slack", route.accountId),
|
||||
tableMode: resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
@@ -450,7 +448,6 @@ export function registerSlackMonitorSlashCommands(params: {
|
||||
respond,
|
||||
ephemeral: slashCommand.ephemeral,
|
||||
textLimit: ctx.textLimit,
|
||||
chunkMode: resolveChunkMode(cfg, "slack", route.accountId),
|
||||
tableMode: resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
|
||||
+2
-14
@@ -1,10 +1,6 @@
|
||||
import { type FilesUploadV2Arguments, type WebClient } from "@slack/web-api";
|
||||
|
||||
import {
|
||||
chunkMarkdownTextWithMode,
|
||||
resolveChunkMode,
|
||||
resolveTextChunkLimit,
|
||||
} from "../auto-reply/chunk.js";
|
||||
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
@@ -153,15 +149,7 @@ export async function sendMessageSlack(
|
||||
channel: "slack",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const chunkMode = resolveChunkMode(cfg, "slack", account.accountId);
|
||||
const markdownChunks =
|
||||
chunkMode === "newline"
|
||||
? chunkMarkdownTextWithMode(trimmedMessage, chunkLimit, chunkMode)
|
||||
: [trimmedMessage];
|
||||
const chunks = markdownChunks.flatMap((markdown) =>
|
||||
markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode }),
|
||||
);
|
||||
if (!chunks.length && trimmedMessage) chunks.push(trimmedMessage);
|
||||
const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit, { tableMode });
|
||||
const mediaMaxBytes =
|
||||
typeof account.config.mediaMaxMb === "number"
|
||||
? account.config.mediaMaxMb * 1024 * 1024
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user