Merge branch 'clawdbot:main' into main
This commit is contained in:
commit
69d38b9bce
@ -10,6 +10,7 @@ Status: unreleased.
|
|||||||
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
||||||
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
|
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
|
||||||
- Docs: add migration guide for moving to a new machine. (#2381)
|
- Docs: add migration guide for moving to a new machine. (#2381)
|
||||||
|
- Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN.
|
||||||
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
|
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
|
||||||
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
|
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
|
||||||
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
|
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
|
||||||
@ -43,6 +44,7 @@ Status: unreleased.
|
|||||||
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
|
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
|
||||||
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
|
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
|
||||||
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
|
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
|
||||||
|
- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21.
|
||||||
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
|
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
|
||||||
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
|
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
|
||||||
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
|
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
|
||||||
@ -51,8 +53,12 @@ Status: unreleased.
|
|||||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Security: pin npm overrides to keep tar@7.5.4 for install toolchains.
|
||||||
|
- Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez.
|
||||||
- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204.
|
- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204.
|
||||||
|
- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein.
|
||||||
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
|
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
|
||||||
|
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
|
||||||
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
|
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
|
||||||
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
|
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
|
||||||
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
||||||
|
|||||||
57
README.md
57
README.md
@ -477,34 +477,35 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
|
|||||||
Thanks to all clawtributors:
|
Thanks to all clawtributors:
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></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/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/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></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/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></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/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/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/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/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/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/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/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/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/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/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/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></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/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/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/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/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/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></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/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></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/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/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></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/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></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/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/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/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/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/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/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/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/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/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a>
|
||||||
<a href="https://github.com/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/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/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></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/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/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/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/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/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/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></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/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/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></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/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/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/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/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/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/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/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></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/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/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/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/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></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/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/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/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/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/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/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></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/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/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/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></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/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/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></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/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/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></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/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></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/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/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/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/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/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/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/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/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></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/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/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></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/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=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></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/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/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/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></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/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/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></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=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></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=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></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/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/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></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/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/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></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/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></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/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></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/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/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></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/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/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></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/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></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/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/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></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/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></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/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/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></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/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=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/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></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/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></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/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></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=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></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/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></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/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></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/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></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/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></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/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></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/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></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/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></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/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||||
|
|||||||
@ -529,6 +529,7 @@ Provider options:
|
|||||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||||
|
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts.
|
||||||
- `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
|
- `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
|
||||||
- `channels.telegram.webhookUrl`: enable webhook mode.
|
- `channels.telegram.webhookUrl`: enable webhook mode.
|
||||||
- `channels.telegram.webhookSecret`: webhook secret (optional).
|
- `channels.telegram.webhookSecret`: webhook secret (optional).
|
||||||
|
|||||||
@ -805,6 +805,10 @@
|
|||||||
"source": "/install/railway/",
|
"source": "/install/railway/",
|
||||||
"destination": "/railway"
|
"destination": "/railway"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"source": "/install/northflank/",
|
||||||
|
"destination": "/northflank"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"source": "/gcp",
|
"source": "/gcp",
|
||||||
"destination": "/platforms/gcp"
|
"destination": "/platforms/gcp"
|
||||||
@ -852,6 +856,7 @@
|
|||||||
"install/docker",
|
"install/docker",
|
||||||
"railway",
|
"railway",
|
||||||
"render",
|
"render",
|
||||||
|
"northflank",
|
||||||
"install/bun"
|
"install/bun"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1029,6 +1029,9 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
|
|||||||
maxDelayMs: 30000,
|
maxDelayMs: 30000,
|
||||||
jitter: 0.1
|
jitter: 0.1
|
||||||
},
|
},
|
||||||
|
network: { // transport overrides
|
||||||
|
autoSelectFamily: false
|
||||||
|
},
|
||||||
proxy: "socks5://localhost:9050",
|
proxy: "socks5://localhost:9050",
|
||||||
webhookUrl: "https://example.com/telegram-webhook",
|
webhookUrl: "https://example.com/telegram-webhook",
|
||||||
webhookSecret: "secret",
|
webhookSecret: "secret",
|
||||||
|
|||||||
53
docs/northflank.mdx
Normal file
53
docs/northflank.mdx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
title: Deploy on Northflank
|
||||||
|
---
|
||||||
|
|
||||||
|
Deploy Clawdbot on Northflank with a one-click template and finish setup in your browser.
|
||||||
|
This is the easiest “no terminal on the server” path: Northflank runs the Gateway for you,
|
||||||
|
and you configure everything via the `/setup` web wizard.
|
||||||
|
|
||||||
|
## How to get started
|
||||||
|
|
||||||
|
1. Click [Deploy Clawdbot](https://northflank.com/stacks/deploy-clawdbot) to open the template.
|
||||||
|
2. Create an [account on Northflank](https://app.northflank.com/signup) if you don’t already have one.
|
||||||
|
3. Click **Deploy Clawdbot now**.
|
||||||
|
4. Set the required environment variable: `SETUP_PASSWORD`.
|
||||||
|
5. Click **Deploy stack** to build and run the Clawdbot template.
|
||||||
|
6. Wait for the deployment to complete, then click **View resources**.
|
||||||
|
7. Open the Clawdbot service.
|
||||||
|
8. Open the public Clawdbot URL and complete setup at `/setup`.
|
||||||
|
9. Open the Control UI at `/clawdbot`.
|
||||||
|
|
||||||
|
## What you get
|
||||||
|
|
||||||
|
- Hosted Clawdbot Gateway + Control UI
|
||||||
|
- Web setup wizard at `/setup` (no terminal commands)
|
||||||
|
- Persistent storage via Northflank Volume (`/data`) so config/credentials/workspace survive redeploys
|
||||||
|
|
||||||
|
## Setup flow
|
||||||
|
|
||||||
|
1) Visit `https://<your-northflank-domain>/setup` and enter your `SETUP_PASSWORD`.
|
||||||
|
2) Choose a model/auth provider and paste your key.
|
||||||
|
3) (Optional) Add Telegram/Discord/Slack tokens.
|
||||||
|
4) Click **Run setup**.
|
||||||
|
5) Open the Control UI at `https://<your-northflank-domain>/clawdbot`
|
||||||
|
|
||||||
|
If Telegram DMs are set to pairing, the setup wizard can approve the pairing code.
|
||||||
|
|
||||||
|
## Getting chat tokens
|
||||||
|
|
||||||
|
### Telegram bot token
|
||||||
|
|
||||||
|
1) Message `@BotFather` in Telegram
|
||||||
|
2) Run `/newbot`
|
||||||
|
3) Copy the token (looks like `123456789:AA...`)
|
||||||
|
4) Paste it into `/setup`
|
||||||
|
|
||||||
|
### Discord bot token
|
||||||
|
|
||||||
|
1) Go to https://discord.com/developers/applications
|
||||||
|
2) **New Application** → choose a name
|
||||||
|
3) **Bot** → **Add Bot**
|
||||||
|
4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup)
|
||||||
|
5) Copy the **Bot Token** and paste into `/setup`
|
||||||
|
6) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
|
||||||
@ -11,6 +11,8 @@ deployments work at a high level.
|
|||||||
|
|
||||||
## Pick a provider
|
## Pick a provider
|
||||||
|
|
||||||
|
- **Railway** (one‑click + browser setup): [Railway](/railway)
|
||||||
|
- **Northflank** (one‑click + browser setup): [Northflank](/northflank)
|
||||||
- **Oracle Cloud (Always Free)**: [Oracle](/platforms/oracle) — $0/month (Always Free, ARM; capacity/signup can be finicky)
|
- **Oracle Cloud (Always Free)**: [Oracle](/platforms/oracle) — $0/month (Always Free, ARM; capacity/signup can be finicky)
|
||||||
- **Fly.io**: [Fly.io](/platforms/fly)
|
- **Fly.io**: [Fly.io](/platforms/fly)
|
||||||
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
|
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
|
||||||
|
|||||||
@ -237,6 +237,9 @@
|
|||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
"wireit": "^0.14.12"
|
"wireit": "^0.14.12"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"tar": "7.5.4"
|
||||||
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"minimumReleaseAge": 2880,
|
"minimumReleaseAge": 2880,
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
|||||||
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
|
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
|
||||||
import {
|
import {
|
||||||
deleteMessageTelegram,
|
deleteMessageTelegram,
|
||||||
|
editMessageTelegram,
|
||||||
reactMessageTelegram,
|
reactMessageTelegram,
|
||||||
sendMessageTelegram,
|
sendMessageTelegram,
|
||||||
} from "../../telegram/send.js";
|
} from "../../telegram/send.js";
|
||||||
@ -209,5 +210,50 @@ export async function handleTelegramAction(
|
|||||||
return jsonResult({ ok: true, deleted: true });
|
return jsonResult({ ok: true, deleted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "editMessage") {
|
||||||
|
if (!isActionEnabled("editMessage")) {
|
||||||
|
throw new Error("Telegram editMessage is disabled.");
|
||||||
|
}
|
||||||
|
const chatId = readStringOrNumberParam(params, "chatId", {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
const messageId = readNumberParam(params, "messageId", {
|
||||||
|
required: true,
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
const content = readStringParam(params, "content", {
|
||||||
|
required: true,
|
||||||
|
allowEmpty: false,
|
||||||
|
});
|
||||||
|
const buttons = readTelegramButtons(params);
|
||||||
|
if (buttons) {
|
||||||
|
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
||||||
|
cfg,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
if (inlineButtonsScope === "off") {
|
||||||
|
throw new Error(
|
||||||
|
'Telegram inline buttons are disabled. Set channels.telegram.capabilities.inlineButtons to "dm", "group", "all", or "allowlist".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(
|
||||||
|
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await editMessageTelegram(chatId ?? "", messageId ?? 0, content, {
|
||||||
|
token,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
buttons,
|
||||||
|
});
|
||||||
|
return jsonResult({
|
||||||
|
ok: true,
|
||||||
|
messageId: result.messageId,
|
||||||
|
chatId: result.chatId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`Unsupported Telegram action: ${action}`);
|
throw new Error(`Unsupported Telegram action: ${action}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -89,6 +89,7 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
registerAgentRunContext(runId, {
|
registerAgentRunContext(runId, {
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
verboseLevel: params.resolvedVerboseLevel,
|
verboseLevel: params.resolvedVerboseLevel,
|
||||||
|
isHeartbeat: params.isHeartbeat,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||||
|
|||||||
@ -3,6 +3,26 @@ import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
|
|||||||
export const HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]";
|
export const HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]";
|
||||||
export const DEFAULT_GROUP_HISTORY_LIMIT = 50;
|
export const DEFAULT_GROUP_HISTORY_LIMIT = 50;
|
||||||
|
|
||||||
|
/** Maximum number of group history keys to retain (LRU eviction when exceeded). */
|
||||||
|
export const MAX_HISTORY_KEYS = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict oldest keys from a history map when it exceeds MAX_HISTORY_KEYS.
|
||||||
|
* Uses Map's insertion order for LRU-like behavior.
|
||||||
|
*/
|
||||||
|
export function evictOldHistoryKeys<T>(
|
||||||
|
historyMap: Map<string, T[]>,
|
||||||
|
maxKeys: number = MAX_HISTORY_KEYS,
|
||||||
|
): void {
|
||||||
|
if (historyMap.size <= maxKeys) return;
|
||||||
|
const keysToDelete = historyMap.size - maxKeys;
|
||||||
|
const iterator = historyMap.keys();
|
||||||
|
for (let i = 0; i < keysToDelete; i++) {
|
||||||
|
const key = iterator.next().value;
|
||||||
|
if (key !== undefined) historyMap.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type HistoryEntry = {
|
export type HistoryEntry = {
|
||||||
sender: string;
|
sender: string;
|
||||||
body: string;
|
body: string;
|
||||||
@ -34,7 +54,13 @@ export function appendHistoryEntry<T extends HistoryEntry>(params: {
|
|||||||
const history = historyMap.get(historyKey) ?? [];
|
const history = historyMap.get(historyKey) ?? [];
|
||||||
history.push(entry);
|
history.push(entry);
|
||||||
while (history.length > params.limit) history.shift();
|
while (history.length > params.limit) history.shift();
|
||||||
|
if (historyMap.has(historyKey)) {
|
||||||
|
// Refresh insertion order so eviction keeps recently used histories.
|
||||||
|
historyMap.delete(historyKey);
|
||||||
|
}
|
||||||
historyMap.set(historyKey, history);
|
historyMap.set(historyKey, history);
|
||||||
|
// Evict oldest keys if map exceeds max size to prevent unbounded memory growth
|
||||||
|
evictOldHistoryKeys(historyMap);
|
||||||
return history;
|
return history;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,11 @@ export async function prependSystemEvents(params: {
|
|||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
const lower = trimmed.toLowerCase();
|
const lower = trimmed.toLowerCase();
|
||||||
if (lower.includes("reason periodic")) return null;
|
if (lower.includes("reason periodic")) return null;
|
||||||
if (lower.includes("heartbeat")) return null;
|
// Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat"
|
||||||
|
// The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this
|
||||||
|
if (lower.startsWith("read heartbeat.md")) return null;
|
||||||
|
// Also filter heartbeat poll/wake noise
|
||||||
|
if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) return null;
|
||||||
if (trimmed.startsWith("Node:")) {
|
if (trimmed.startsWith("Node:")) {
|
||||||
return trimmed.replace(/ · last input [^·]+/i, "").trim();
|
return trimmed.replace(/ · last input [^·]+/i, "").trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,4 +62,53 @@ describe("telegramMessageActions", () => {
|
|||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps edit action params into editMessage", async () => {
|
||||||
|
handleTelegramAction.mockClear();
|
||||||
|
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
await telegramMessageActions.handleAction({
|
||||||
|
action: "edit",
|
||||||
|
params: {
|
||||||
|
chatId: "123",
|
||||||
|
messageId: 42,
|
||||||
|
message: "Updated",
|
||||||
|
buttons: [],
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
accountId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handleTelegramAction).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
action: "editMessage",
|
||||||
|
chatId: "123",
|
||||||
|
messageId: 42,
|
||||||
|
content: "Updated",
|
||||||
|
buttons: [],
|
||||||
|
accountId: undefined,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-integer messageId for edit before reaching telegram-actions", async () => {
|
||||||
|
handleTelegramAction.mockClear();
|
||||||
|
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
telegramMessageActions.handleAction({
|
||||||
|
action: "edit",
|
||||||
|
params: {
|
||||||
|
chatId: "123",
|
||||||
|
messageId: "nope",
|
||||||
|
message: "Updated",
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
accountId: undefined,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(handleTelegramAction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
createActionGate,
|
createActionGate,
|
||||||
|
readNumberParam,
|
||||||
readStringOrNumberParam,
|
readStringOrNumberParam,
|
||||||
readStringParam,
|
readStringParam,
|
||||||
} from "../../../agents/tools/common.js";
|
} from "../../../agents/tools/common.js";
|
||||||
@ -43,6 +44,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const actions = new Set<ChannelMessageActionName>(["send"]);
|
const actions = new Set<ChannelMessageActionName>(["send"]);
|
||||||
if (gate("reactions")) actions.add("react");
|
if (gate("reactions")) actions.add("react");
|
||||||
if (gate("deleteMessage")) actions.add("delete");
|
if (gate("deleteMessage")) actions.add("delete");
|
||||||
|
if (gate("editMessage")) actions.add("edit");
|
||||||
return Array.from(actions);
|
return Array.from(actions);
|
||||||
},
|
},
|
||||||
supportsButtons: ({ cfg }) => {
|
supportsButtons: ({ cfg }) => {
|
||||||
@ -100,14 +102,39 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
|||||||
readStringOrNumberParam(params, "chatId") ??
|
readStringOrNumberParam(params, "chatId") ??
|
||||||
readStringOrNumberParam(params, "channelId") ??
|
readStringOrNumberParam(params, "channelId") ??
|
||||||
readStringParam(params, "to", { required: true });
|
readStringParam(params, "to", { required: true });
|
||||||
const messageId = readStringParam(params, "messageId", {
|
const messageId = readNumberParam(params, "messageId", {
|
||||||
required: true,
|
required: true,
|
||||||
|
integer: true,
|
||||||
});
|
});
|
||||||
return await handleTelegramAction(
|
return await handleTelegramAction(
|
||||||
{
|
{
|
||||||
action: "deleteMessage",
|
action: "deleteMessage",
|
||||||
chatId,
|
chatId,
|
||||||
messageId: Number(messageId),
|
messageId,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "edit") {
|
||||||
|
const chatId =
|
||||||
|
readStringOrNumberParam(params, "chatId") ??
|
||||||
|
readStringOrNumberParam(params, "channelId") ??
|
||||||
|
readStringParam(params, "to", { required: true });
|
||||||
|
const messageId = readNumberParam(params, "messageId", {
|
||||||
|
required: true,
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
const message = readStringParam(params, "message", { required: true, allowEmpty: false });
|
||||||
|
const buttons = params.buttons;
|
||||||
|
return await handleTelegramAction(
|
||||||
|
{
|
||||||
|
action: "editMessage",
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
content: message,
|
||||||
|
buttons,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
|
|||||||
@ -310,6 +310,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
|
"channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
|
||||||
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
|
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
|
||||||
"channels.telegram.retry.jitter": "Telegram Retry Jitter",
|
"channels.telegram.retry.jitter": "Telegram Retry Jitter",
|
||||||
|
"channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily",
|
||||||
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
|
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
|
||||||
"channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons",
|
"channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons",
|
||||||
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
|
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
|
||||||
@ -643,6 +644,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"channels.telegram.retry.maxDelayMs":
|
"channels.telegram.retry.maxDelayMs":
|
||||||
"Maximum retry delay cap in ms for Telegram outbound calls.",
|
"Maximum retry delay cap in ms for Telegram outbound calls.",
|
||||||
"channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.",
|
"channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.",
|
||||||
|
"channels.telegram.network.autoSelectFamily":
|
||||||
|
"Override Node autoSelectFamily for Telegram (true=enable, false=disable).",
|
||||||
"channels.telegram.timeoutSeconds":
|
"channels.telegram.timeoutSeconds":
|
||||||
"Max seconds before Telegram API requests are aborted (default: 500 per grammY).",
|
"Max seconds before Telegram API requests are aborted (default: 500 per grammY).",
|
||||||
"channels.whatsapp.dmPolicy":
|
"channels.whatsapp.dmPolicy":
|
||||||
|
|||||||
@ -15,6 +15,12 @@ export type TelegramActionConfig = {
|
|||||||
reactions?: boolean;
|
reactions?: boolean;
|
||||||
sendMessage?: boolean;
|
sendMessage?: boolean;
|
||||||
deleteMessage?: boolean;
|
deleteMessage?: boolean;
|
||||||
|
editMessage?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TelegramNetworkConfig = {
|
||||||
|
/** Override Node's autoSelectFamily behavior (true = enable, false = disable). */
|
||||||
|
autoSelectFamily?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist";
|
export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist";
|
||||||
@ -95,6 +101,8 @@ export type TelegramAccountConfig = {
|
|||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
/** Retry policy for outbound Telegram API calls. */
|
/** Retry policy for outbound Telegram API calls. */
|
||||||
retry?: OutboundRetryConfig;
|
retry?: OutboundRetryConfig;
|
||||||
|
/** Network transport overrides for Telegram. */
|
||||||
|
network?: TelegramNetworkConfig;
|
||||||
proxy?: string;
|
proxy?: string;
|
||||||
webhookUrl?: string;
|
webhookUrl?: string;
|
||||||
webhookSecret?: string;
|
webhookSecret?: string;
|
||||||
|
|||||||
@ -110,6 +110,12 @@ export const TelegramAccountSchemaBase = z
|
|||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
retry: RetryConfigSchema,
|
retry: RetryConfigSchema,
|
||||||
|
network: z
|
||||||
|
.object({
|
||||||
|
autoSelectFamily: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
proxy: z.string().optional(),
|
proxy: z.string().optional(),
|
||||||
webhookUrl: z.string().optional(),
|
webhookUrl: z.string().optional(),
|
||||||
webhookSecret: z.string().optional(),
|
webhookSecret: z.string().optional(),
|
||||||
|
|||||||
@ -1,8 +1,28 @@
|
|||||||
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
|
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js";
|
import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js";
|
||||||
|
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
|
||||||
import { loadSessionEntry } from "./session-utils.js";
|
import { loadSessionEntry } from "./session-utils.js";
|
||||||
import { formatForLog } from "./ws-log.js";
|
import { formatForLog } from "./ws-log.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if webchat broadcasts should be suppressed for heartbeat runs.
|
||||||
|
* Returns true if the run is a heartbeat and showOk is false.
|
||||||
|
*/
|
||||||
|
function shouldSuppressHeartbeatBroadcast(runId: string): boolean {
|
||||||
|
const runContext = getAgentRunContext(runId);
|
||||||
|
if (!runContext?.isHeartbeat) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const visibility = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
|
||||||
|
return !visibility.showOk;
|
||||||
|
} catch {
|
||||||
|
// Default to suppressing if we can't load config
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type ChatRunEntry = {
|
export type ChatRunEntry = {
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
clientRunId: string;
|
clientRunId: string;
|
||||||
@ -130,7 +150,10 @@ export function createAgentEventHandler({
|
|||||||
timestamp: now,
|
timestamp: now,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
broadcast("chat", payload, { dropIfSlow: true });
|
// Suppress webchat broadcast for heartbeat runs when showOk is false
|
||||||
|
if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
|
||||||
|
broadcast("chat", payload, { dropIfSlow: true });
|
||||||
|
}
|
||||||
nodeSendToSession(sessionKey, "chat", payload);
|
nodeSendToSession(sessionKey, "chat", payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -158,7 +181,10 @@ export function createAgentEventHandler({
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
broadcast("chat", payload);
|
// Suppress webchat broadcast for heartbeat runs when showOk is false
|
||||||
|
if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
|
||||||
|
broadcast("chat", payload);
|
||||||
|
}
|
||||||
nodeSendToSession(sessionKey, "chat", payload);
|
nodeSendToSession(sessionKey, "chat", payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -291,10 +291,10 @@ export function createGatewayHttpServer(opts: {
|
|||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
res.end("Not Found");
|
res.end("Not Found");
|
||||||
} catch (err) {
|
} catch {
|
||||||
res.statusCode = 500;
|
res.statusCode = 500;
|
||||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
res.end(String(err));
|
res.end("Internal Server Error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export type AgentEventPayload = {
|
|||||||
export type AgentRunContext = {
|
export type AgentRunContext = {
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
verboseLevel?: VerboseLevel;
|
verboseLevel?: VerboseLevel;
|
||||||
|
isHeartbeat?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep per-run counters so streams stay strictly monotonic per runId.
|
// Keep per-run counters so streams stay strictly monotonic per runId.
|
||||||
@ -34,6 +35,9 @@ export function registerAgentRunContext(runId: string, context: AgentRunContext)
|
|||||||
if (context.verboseLevel && existing.verboseLevel !== context.verboseLevel) {
|
if (context.verboseLevel && existing.verboseLevel !== context.verboseLevel) {
|
||||||
existing.verboseLevel = context.verboseLevel;
|
existing.verboseLevel = context.verboseLevel;
|
||||||
}
|
}
|
||||||
|
if (context.isHeartbeat !== undefined && existing.isHeartbeat !== context.isHeartbeat) {
|
||||||
|
existing.isHeartbeat = context.isHeartbeat;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAgentRunContext(runId: string) {
|
export function getAgentRunContext(runId: string) {
|
||||||
|
|||||||
@ -333,6 +333,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
|
workspace: tmpDir,
|
||||||
heartbeat: { every: "5m", target: "whatsapp" },
|
heartbeat: { every: "5m", target: "whatsapp" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -461,6 +462,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
|
workspace: tmpDir,
|
||||||
heartbeat: {
|
heartbeat: {
|
||||||
every: "5m",
|
every: "5m",
|
||||||
target: "last",
|
target: "last",
|
||||||
@ -542,6 +544,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
|
workspace: tmpDir,
|
||||||
heartbeat: { every: "5m", target: "whatsapp" },
|
heartbeat: { every: "5m", target: "whatsapp" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -597,6 +600,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
|
workspace: tmpDir,
|
||||||
heartbeat: {
|
heartbeat: {
|
||||||
every: "5m",
|
every: "5m",
|
||||||
target: "whatsapp",
|
target: "whatsapp",
|
||||||
@ -668,6 +672,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
|
workspace: tmpDir,
|
||||||
heartbeat: {
|
heartbeat: {
|
||||||
every: "5m",
|
every: "5m",
|
||||||
target: "whatsapp",
|
target: "whatsapp",
|
||||||
@ -737,7 +742,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
try {
|
try {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: { heartbeat: { every: "5m" } },
|
defaults: { workspace: tmpDir, heartbeat: { every: "5m" } },
|
||||||
list: [{ id: "work", default: true }],
|
list: [{ id: "work", default: true }],
|
||||||
},
|
},
|
||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
|||||||
@ -247,4 +247,58 @@ describe("resolveHeartbeatVisibility", () => {
|
|||||||
useIndicator: true,
|
useIndicator: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("webchat uses channel defaults only (no per-channel config)", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: {
|
||||||
|
showOk: true,
|
||||||
|
showAlerts: false,
|
||||||
|
useIndicator: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const result = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
showOk: true,
|
||||||
|
showAlerts: false,
|
||||||
|
useIndicator: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("webchat returns defaults when no channel defaults configured", () => {
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const result = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
showOk: false,
|
||||||
|
showAlerts: true,
|
||||||
|
useIndicator: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("webchat ignores accountId (only uses defaults)", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: {
|
||||||
|
showOk: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const result = resolveHeartbeatVisibility({
|
||||||
|
cfg,
|
||||||
|
channel: "webchat",
|
||||||
|
accountId: "some-account",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.showOk).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import type { ChannelHeartbeatVisibilityConfig } from "../config/types.channels.js";
|
import type { ChannelHeartbeatVisibilityConfig } from "../config/types.channels.js";
|
||||||
import type { DeliverableMessageChannel } from "../utils/message-channel.js";
|
import type { GatewayMessageChannel } from "../utils/message-channel.js";
|
||||||
|
|
||||||
export type ResolvedHeartbeatVisibility = {
|
export type ResolvedHeartbeatVisibility = {
|
||||||
showOk: boolean;
|
showOk: boolean;
|
||||||
@ -14,13 +14,28 @@ const DEFAULT_VISIBILITY: ResolvedHeartbeatVisibility = {
|
|||||||
useIndicator: true, // Emit indicator events
|
useIndicator: true, // Emit indicator events
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve heartbeat visibility settings for a channel.
|
||||||
|
* Supports both deliverable channels (telegram, signal, etc.) and webchat.
|
||||||
|
* For webchat, uses channels.defaults.heartbeat since webchat doesn't have per-channel config.
|
||||||
|
*/
|
||||||
export function resolveHeartbeatVisibility(params: {
|
export function resolveHeartbeatVisibility(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
channel: DeliverableMessageChannel;
|
channel: GatewayMessageChannel;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
}): ResolvedHeartbeatVisibility {
|
}): ResolvedHeartbeatVisibility {
|
||||||
const { cfg, channel, accountId } = params;
|
const { cfg, channel, accountId } = params;
|
||||||
|
|
||||||
|
// Webchat uses channel defaults only (no per-channel or per-account config)
|
||||||
|
if (channel === "webchat") {
|
||||||
|
const channelDefaults = cfg.channels?.defaults?.heartbeat;
|
||||||
|
return {
|
||||||
|
showOk: channelDefaults?.showOk ?? DEFAULT_VISIBILITY.showOk,
|
||||||
|
showAlerts: channelDefaults?.showAlerts ?? DEFAULT_VISIBILITY.showAlerts,
|
||||||
|
useIndicator: channelDefaults?.useIndicator ?? DEFAULT_VISIBILITY.useIndicator,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Layer 1: Global channel defaults
|
// Layer 1: Global channel defaults
|
||||||
const channelDefaults = cfg.channels?.defaults?.heartbeat;
|
const channelDefaults = cfg.channels?.defaults?.heartbeat;
|
||||||
|
|
||||||
|
|||||||
@ -37,10 +37,10 @@ function schedule(coalesceMs: number) {
|
|||||||
pendingReason = reason ?? "retry";
|
pendingReason = reason ?? "retry";
|
||||||
schedule(DEFAULT_RETRY_MS);
|
schedule(DEFAULT_RETRY_MS);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
|
// Error is already logged by the heartbeat runner; schedule a retry.
|
||||||
pendingReason = reason ?? "retry";
|
pendingReason = reason ?? "retry";
|
||||||
schedule(DEFAULT_RETRY_MS);
|
schedule(DEFAULT_RETRY_MS);
|
||||||
throw err;
|
|
||||||
} finally {
|
} finally {
|
||||||
running = false;
|
running = false;
|
||||||
if (pendingReason || scheduled) schedule(coalesceMs);
|
if (pendingReason || scheduled) schedule(coalesceMs);
|
||||||
|
|||||||
27
src/infra/retry-policy.test.ts
Normal file
27
src/infra/retry-policy.test.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createTelegramRetryRunner } from "./retry-policy.js";
|
||||||
|
|
||||||
|
describe("createTelegramRetryRunner", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries when custom shouldRetry matches non-telegram error", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const runner = createTelegramRetryRunner({
|
||||||
|
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||||
|
shouldRetry: (err) => err instanceof Error && err.message === "boom",
|
||||||
|
});
|
||||||
|
const fn = vi
|
||||||
|
.fn<[], Promise<string>>()
|
||||||
|
.mockRejectedValueOnce(new Error("boom"))
|
||||||
|
.mockResolvedValue("ok");
|
||||||
|
|
||||||
|
const promise = runner(fn, "request");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
await expect(promise).resolves.toBe("ok");
|
||||||
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -72,16 +72,21 @@ export function createTelegramRetryRunner(params: {
|
|||||||
retry?: RetryConfig;
|
retry?: RetryConfig;
|
||||||
configRetry?: RetryConfig;
|
configRetry?: RetryConfig;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
|
shouldRetry?: (err: unknown) => boolean;
|
||||||
}): RetryRunner {
|
}): RetryRunner {
|
||||||
const retryConfig = resolveRetryConfig(TELEGRAM_RETRY_DEFAULTS, {
|
const retryConfig = resolveRetryConfig(TELEGRAM_RETRY_DEFAULTS, {
|
||||||
...params.configRetry,
|
...params.configRetry,
|
||||||
...params.retry,
|
...params.retry,
|
||||||
});
|
});
|
||||||
|
const shouldRetry = params.shouldRetry
|
||||||
|
? (err: unknown) => params.shouldRetry?.(err) || TELEGRAM_RETRY_RE.test(formatErrorMessage(err))
|
||||||
|
: (err: unknown) => TELEGRAM_RETRY_RE.test(formatErrorMessage(err));
|
||||||
|
|
||||||
return <T>(fn: () => Promise<T>, label?: string) =>
|
return <T>(fn: () => Promise<T>, label?: string) =>
|
||||||
retryAsync(fn, {
|
retryAsync(fn, {
|
||||||
...retryConfig,
|
...retryConfig,
|
||||||
label,
|
label,
|
||||||
shouldRetry: (err) => TELEGRAM_RETRY_RE.test(formatErrorMessage(err)),
|
shouldRetry,
|
||||||
retryAfterMs: getTelegramRetryAfterMs,
|
retryAfterMs: getTelegramRetryAfterMs,
|
||||||
onRetry: params.verbose
|
onRetry: params.verbose
|
||||||
? (info) => {
|
? (info) => {
|
||||||
|
|||||||
@ -13,6 +13,36 @@ export function registerUnhandledRejectionHandler(handler: UnhandledRejectionHan
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is a recoverable/transient error that shouldn't crash the process.
|
||||||
|
* These include network errors and abort signals during shutdown.
|
||||||
|
*/
|
||||||
|
function isRecoverableError(reason: unknown): boolean {
|
||||||
|
if (!reason) return false;
|
||||||
|
|
||||||
|
// Check error name for AbortError
|
||||||
|
if (reason instanceof Error && reason.name === "AbortError") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = reason instanceof Error ? reason.message : String(reason);
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
return (
|
||||||
|
lowerMessage.includes("fetch failed") ||
|
||||||
|
lowerMessage.includes("network request") ||
|
||||||
|
lowerMessage.includes("econnrefused") ||
|
||||||
|
lowerMessage.includes("econnreset") ||
|
||||||
|
lowerMessage.includes("etimedout") ||
|
||||||
|
lowerMessage.includes("socket hang up") ||
|
||||||
|
lowerMessage.includes("enotfound") ||
|
||||||
|
lowerMessage.includes("network error") ||
|
||||||
|
lowerMessage.includes("getaddrinfo") ||
|
||||||
|
lowerMessage.includes("client network socket disconnected") ||
|
||||||
|
lowerMessage.includes("this operation was aborted") ||
|
||||||
|
lowerMessage.includes("aborted")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function isUnhandledRejectionHandled(reason: unknown): boolean {
|
export function isUnhandledRejectionHandled(reason: unknown): boolean {
|
||||||
for (const handler of handlers) {
|
for (const handler of handlers) {
|
||||||
try {
|
try {
|
||||||
@ -30,6 +60,13 @@ export function isUnhandledRejectionHandled(reason: unknown): boolean {
|
|||||||
export function installUnhandledRejectionHandler(): void {
|
export function installUnhandledRejectionHandler(): void {
|
||||||
process.on("unhandledRejection", (reason, _promise) => {
|
process.on("unhandledRejection", (reason, _promise) => {
|
||||||
if (isUnhandledRejectionHandled(reason)) return;
|
if (isUnhandledRejectionHandled(reason)) return;
|
||||||
|
|
||||||
|
// Don't crash on recoverable/transient errors - log them and continue
|
||||||
|
if (isRecoverableError(reason)) {
|
||||||
|
console.error("[clawdbot] Recoverable error (not crashing):", formatUncaughtError(reason));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.error("[clawdbot] Unhandled promise rejection:", formatUncaughtError(reason));
|
console.error("[clawdbot] Unhandled promise rejection:", formatUncaughtError(reason));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -44,6 +44,7 @@ describe("security audit", () => {
|
|||||||
|
|
||||||
const res = await runSecurityAudit({
|
const res = await runSecurityAudit({
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
env: {},
|
||||||
includeFilesystem: false,
|
includeFilesystem: false,
|
||||||
includeChannelSecurity: false,
|
includeChannelSecurity: false,
|
||||||
});
|
});
|
||||||
@ -88,6 +89,7 @@ describe("security audit", () => {
|
|||||||
|
|
||||||
const res = await runSecurityAudit({
|
const res = await runSecurityAudit({
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
env: {},
|
||||||
includeFilesystem: false,
|
includeFilesystem: false,
|
||||||
includeChannelSecurity: false,
|
includeChannelSecurity: false,
|
||||||
});
|
});
|
||||||
@ -855,51 +857,62 @@ describe("security audit", () => {
|
|||||||
|
|
||||||
const includePath = path.join(stateDir, "extra.json5");
|
const includePath = path.join(stateDir, "extra.json5");
|
||||||
await fs.writeFile(includePath, "{ logging: { redactSensitive: 'off' } }\n", "utf-8");
|
await fs.writeFile(includePath, "{ logging: { redactSensitive: 'off' } }\n", "utf-8");
|
||||||
await fs.chmod(includePath, 0o644);
|
if (isWindows) {
|
||||||
|
// Grant "Everyone" write access to trigger the perms_writable check on Windows
|
||||||
|
const { execSync } = await import("node:child_process");
|
||||||
|
execSync(`icacls "${includePath}" /grant Everyone:W`, { stdio: "ignore" });
|
||||||
|
} else {
|
||||||
|
await fs.chmod(includePath, 0o644);
|
||||||
|
}
|
||||||
|
|
||||||
const configPath = path.join(stateDir, "clawdbot.json");
|
const configPath = path.join(stateDir, "clawdbot.json");
|
||||||
await fs.writeFile(configPath, `{ "$include": "./extra.json5" }\n`, "utf-8");
|
await fs.writeFile(configPath, `{ "$include": "./extra.json5" }\n`, "utf-8");
|
||||||
await fs.chmod(configPath, 0o600);
|
await fs.chmod(configPath, 0o600);
|
||||||
|
|
||||||
const cfg: ClawdbotConfig = { logging: { redactSensitive: "off" } };
|
try {
|
||||||
const user = "DESKTOP-TEST\\Tester";
|
const cfg: ClawdbotConfig = { logging: { redactSensitive: "off" } };
|
||||||
const execIcacls = isWindows
|
const user = "DESKTOP-TEST\\Tester";
|
||||||
? async (_cmd: string, args: string[]) => {
|
const execIcacls = isWindows
|
||||||
const target = args[0];
|
? async (_cmd: string, args: string[]) => {
|
||||||
if (target === includePath) {
|
const target = args[0];
|
||||||
|
if (target === includePath) {
|
||||||
|
return {
|
||||||
|
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(W)\n ${user}:(F)\n`,
|
||||||
|
stderr: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(W)\n ${user}:(F)\n`,
|
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
|
||||||
stderr: "",
|
stderr: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
: undefined;
|
||||||
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
|
const res = await runSecurityAudit({
|
||||||
stderr: "",
|
config: cfg,
|
||||||
};
|
includeFilesystem: true,
|
||||||
}
|
includeChannelSecurity: false,
|
||||||
: undefined;
|
stateDir,
|
||||||
const res = await runSecurityAudit({
|
configPath,
|
||||||
config: cfg,
|
platform: isWindows ? "win32" : undefined,
|
||||||
includeFilesystem: true,
|
env: isWindows
|
||||||
includeChannelSecurity: false,
|
? { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" }
|
||||||
stateDir,
|
: undefined,
|
||||||
configPath,
|
execIcacls,
|
||||||
platform: isWindows ? "win32" : undefined,
|
});
|
||||||
env: isWindows
|
|
||||||
? { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" }
|
|
||||||
: undefined,
|
|
||||||
execIcacls,
|
|
||||||
});
|
|
||||||
|
|
||||||
const expectedCheckId = isWindows
|
const expectedCheckId = isWindows
|
||||||
? "fs.config_include.perms_writable"
|
? "fs.config_include.perms_writable"
|
||||||
: "fs.config_include.perms_world_readable";
|
: "fs.config_include.perms_world_readable";
|
||||||
|
|
||||||
expect(res.findings).toEqual(
|
expect(res.findings).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ checkId: expectedCheckId, severity: "critical" }),
|
expect.objectContaining({ checkId: expectedCheckId, severity: "critical" }),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory with world-writable file
|
||||||
|
await fs.rm(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flags extensions without plugins.allow", async () => {
|
it("flags extensions without plugins.allow", async () => {
|
||||||
|
|||||||
@ -247,12 +247,15 @@ async function collectFilesystemFindings(params: {
|
|||||||
return findings;
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] {
|
function collectGatewayConfigFindings(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
): SecurityAuditFinding[] {
|
||||||
const findings: SecurityAuditFinding[] = [];
|
const findings: SecurityAuditFinding[] = [];
|
||||||
|
|
||||||
const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback";
|
const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback";
|
||||||
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
|
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
|
||||||
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode });
|
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env });
|
||||||
const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false;
|
const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false;
|
||||||
const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies)
|
const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies)
|
||||||
? cfg.gateway.trustedProxies
|
? cfg.gateway.trustedProxies
|
||||||
@ -905,7 +908,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
|||||||
findings.push(...collectAttackSurfaceSummaryFindings(cfg));
|
findings.push(...collectAttackSurfaceSummaryFindings(cfg));
|
||||||
findings.push(...collectSyncedFolderFindings({ stateDir, configPath }));
|
findings.push(...collectSyncedFolderFindings({ stateDir, configPath }));
|
||||||
|
|
||||||
findings.push(...collectGatewayConfigFindings(cfg));
|
findings.push(...collectGatewayConfigFindings(cfg, env));
|
||||||
findings.push(...collectBrowserControlFindings(cfg));
|
findings.push(...collectBrowserControlFindings(cfg));
|
||||||
findings.push(...collectLoggingFindings(cfg));
|
findings.push(...collectLoggingFindings(cfg));
|
||||||
findings.push(...collectElevatedFindings(cfg));
|
findings.push(...collectElevatedFindings(cfg));
|
||||||
|
|||||||
@ -89,6 +89,7 @@ vi.mock("grammy", () => ({
|
|||||||
on = onSpy;
|
on = onSpy;
|
||||||
stop = stopSpy;
|
stop = stopSpy;
|
||||||
command = commandSpy;
|
command = commandSpy;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(
|
constructor(
|
||||||
public token: string,
|
public token: string,
|
||||||
public options?: { client?: { fetch?: typeof fetch } },
|
public options?: { client?: { fetch?: typeof fetch } },
|
||||||
|
|||||||
@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
|
|||||||
on = onSpy;
|
on = onSpy;
|
||||||
stop = stopSpy;
|
stop = stopSpy;
|
||||||
command = commandSpy;
|
command = commandSpy;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(
|
constructor(
|
||||||
public token: string,
|
public token: string,
|
||||||
public options?: { client?: { fetch?: typeof fetch } },
|
public options?: { client?: { fetch?: typeof fetch } },
|
||||||
|
|||||||
@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
|
|||||||
on = onSpy;
|
on = onSpy;
|
||||||
stop = stopSpy;
|
stop = stopSpy;
|
||||||
command = commandSpy;
|
command = commandSpy;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(
|
constructor(
|
||||||
public token: string,
|
public token: string,
|
||||||
public options?: { client?: { fetch?: typeof fetch } },
|
public options?: { client?: { fetch?: typeof fetch } },
|
||||||
|
|||||||
@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
|
|||||||
on = onSpy;
|
on = onSpy;
|
||||||
stop = stopSpy;
|
stop = stopSpy;
|
||||||
command = commandSpy;
|
command = commandSpy;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(
|
constructor(
|
||||||
public token: string,
|
public token: string,
|
||||||
public options?: { client?: { fetch?: typeof fetch } },
|
public options?: { client?: { fetch?: typeof fetch } },
|
||||||
|
|||||||
@ -90,6 +90,7 @@ vi.mock("grammy", () => ({
|
|||||||
on = onSpy;
|
on = onSpy;
|
||||||
stop = stopSpy;
|
stop = stopSpy;
|
||||||
command = commandSpy;
|
command = commandSpy;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(
|
constructor(
|
||||||
public token: string,
|
public token: string,
|
||||||
public options?: {
|
public options?: {
|
||||||
|
|||||||
@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
|
|||||||
on = onSpy;
|
on = onSpy;
|
||||||
stop = stopSpy;
|
stop = stopSpy;
|
||||||
command = commandSpy;
|
command = commandSpy;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(
|
constructor(
|
||||||
public token: string,
|
public token: string,
|
||||||
public options?: { client?: { fetch?: typeof fetch } },
|
public options?: { client?: { fetch?: typeof fetch } },
|
||||||
|
|||||||
@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
|
|||||||
on = onSpy;
|
on = onSpy;
|
||||||
stop = stopSpy;
|
stop = stopSpy;
|
||||||
command = commandSpy;
|
command = commandSpy;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(
|
constructor(
|
||||||
public token: string,
|
public token: string,
|
||||||
public options?: { client?: { fetch?: typeof fetch } },
|
public options?: { client?: { fetch?: typeof fetch } },
|
||||||
|
|||||||
@ -88,6 +88,7 @@ vi.mock("grammy", () => ({
|
|||||||
on = onSpy;
|
on = onSpy;
|
||||||
stop = stopSpy;
|
stop = stopSpy;
|
||||||
command = commandSpy;
|
command = commandSpy;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(
|
constructor(
|
||||||
public token: string,
|
public token: string,
|
||||||
public options?: { client?: { fetch?: typeof fetch } },
|
public options?: { client?: { fetch?: typeof fetch } },
|
||||||
|
|||||||
@ -93,6 +93,7 @@ vi.mock("grammy", () => ({
|
|||||||
on = onSpy;
|
on = onSpy;
|
||||||
stop = stopSpy;
|
stop = stopSpy;
|
||||||
command = commandSpy;
|
command = commandSpy;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(
|
constructor(
|
||||||
public token: string,
|
public token: string,
|
||||||
public options?: { client?: { fetch?: typeof fetch } },
|
public options?: { client?: { fetch?: typeof fetch } },
|
||||||
|
|||||||
@ -32,6 +32,7 @@ vi.mock("grammy", () => ({
|
|||||||
on = onSpy;
|
on = onSpy;
|
||||||
command = vi.fn();
|
command = vi.fn();
|
||||||
stop = stopSpy;
|
stop = stopSpy;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(public token: string) {}
|
constructor(public token: string) {}
|
||||||
},
|
},
|
||||||
InputFile: class {},
|
InputFile: class {},
|
||||||
|
|||||||
@ -30,6 +30,7 @@ vi.mock("grammy", () => ({
|
|||||||
on = onSpy;
|
on = onSpy;
|
||||||
command = vi.fn();
|
command = vi.fn();
|
||||||
stop = stopSpy;
|
stop = stopSpy;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(public token: string) {}
|
constructor(public token: string) {}
|
||||||
},
|
},
|
||||||
InputFile: class {},
|
InputFile: class {},
|
||||||
|
|||||||
@ -126,6 +126,7 @@ vi.mock("grammy", () => ({
|
|||||||
on = onSpy;
|
on = onSpy;
|
||||||
stop = stopSpy;
|
stop = stopSpy;
|
||||||
command = commandSpy;
|
command = commandSpy;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(
|
constructor(
|
||||||
public token: string,
|
public token: string,
|
||||||
public options?: { client?: { fetch?: typeof fetch } },
|
public options?: { client?: { fetch?: typeof fetch } },
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import {
|
|||||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
|
import { formatUncaughtError } from "../infra/errors.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
@ -118,7 +119,9 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
});
|
});
|
||||||
const telegramCfg = account.config;
|
const telegramCfg = account.config;
|
||||||
|
|
||||||
const fetchImpl = resolveTelegramFetch(opts.proxyFetch);
|
const fetchImpl = resolveTelegramFetch(opts.proxyFetch, {
|
||||||
|
network: telegramCfg.network,
|
||||||
|
});
|
||||||
const shouldProvideFetch = Boolean(fetchImpl);
|
const shouldProvideFetch = Boolean(fetchImpl);
|
||||||
const timeoutSeconds =
|
const timeoutSeconds =
|
||||||
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
|
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
|
||||||
@ -137,6 +140,15 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
const bot = new Bot(opts.token, client ? { client } : undefined);
|
const bot = new Bot(opts.token, client ? { client } : undefined);
|
||||||
bot.api.config.use(apiThrottler());
|
bot.api.config.use(apiThrottler());
|
||||||
bot.use(sequentialize(getTelegramSequentialKey));
|
bot.use(sequentialize(getTelegramSequentialKey));
|
||||||
|
bot.catch((err) => {
|
||||||
|
runtime.error?.(danger(`telegram bot error: ${formatUncaughtError(err)}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catch all errors from bot middleware to prevent unhandled rejections
|
||||||
|
bot.catch((err) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
runtime.error?.(danger(`telegram bot error: ${message}`));
|
||||||
|
});
|
||||||
|
|
||||||
const recentUpdates = createTelegramUpdateDedupe();
|
const recentUpdates = createTelegramUpdateDedupe();
|
||||||
let lastUpdateId =
|
let lastUpdateId =
|
||||||
|
|||||||
@ -1,11 +1,21 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { resolveTelegramFetch } from "./fetch.js";
|
|
||||||
|
|
||||||
describe("resolveTelegramFetch", () => {
|
describe("resolveTelegramFetch", () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
const loadModule = async () => {
|
||||||
|
const setDefaultAutoSelectFamily = vi.fn();
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("node:net", () => ({
|
||||||
|
setDefaultAutoSelectFamily,
|
||||||
|
}));
|
||||||
|
const mod = await import("./fetch.js");
|
||||||
|
return { resolveTelegramFetch: mod.resolveTelegramFetch, setDefaultAutoSelectFamily };
|
||||||
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.clearAllMocks();
|
||||||
if (originalFetch) {
|
if (originalFetch) {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
} else {
|
} else {
|
||||||
@ -13,16 +23,41 @@ describe("resolveTelegramFetch", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns wrapped global fetch when available", () => {
|
it("returns wrapped global fetch when available", async () => {
|
||||||
const fetchMock = vi.fn(async () => ({}));
|
const fetchMock = vi.fn(async () => ({}));
|
||||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
const { resolveTelegramFetch } = await loadModule();
|
||||||
const resolved = resolveTelegramFetch();
|
const resolved = resolveTelegramFetch();
|
||||||
expect(resolved).toBeTypeOf("function");
|
expect(resolved).toBeTypeOf("function");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers proxy fetch when provided", () => {
|
it("prefers proxy fetch when provided", async () => {
|
||||||
const fetchMock = vi.fn(async () => ({}));
|
const fetchMock = vi.fn(async () => ({}));
|
||||||
|
const { resolveTelegramFetch } = await loadModule();
|
||||||
const resolved = resolveTelegramFetch(fetchMock as unknown as typeof fetch);
|
const resolved = resolveTelegramFetch(fetchMock as unknown as typeof fetch);
|
||||||
expect(resolved).toBeTypeOf("function");
|
expect(resolved).toBeTypeOf("function");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("honors env enable override", async () => {
|
||||||
|
vi.stubEnv("CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY", "1");
|
||||||
|
globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch;
|
||||||
|
const { resolveTelegramFetch, setDefaultAutoSelectFamily } = await loadModule();
|
||||||
|
resolveTelegramFetch();
|
||||||
|
expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses config override when provided", async () => {
|
||||||
|
globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch;
|
||||||
|
const { resolveTelegramFetch, setDefaultAutoSelectFamily } = await loadModule();
|
||||||
|
resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } });
|
||||||
|
expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("env disable override wins over config", async () => {
|
||||||
|
vi.stubEnv("CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY", "1");
|
||||||
|
globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch;
|
||||||
|
const { resolveTelegramFetch, setDefaultAutoSelectFamily } = await loadModule();
|
||||||
|
resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } });
|
||||||
|
expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,36 @@
|
|||||||
|
import * as net from "node:net";
|
||||||
import { resolveFetch } from "../infra/fetch.js";
|
import { resolveFetch } from "../infra/fetch.js";
|
||||||
|
import type { TelegramNetworkConfig } from "../config/types.telegram.js";
|
||||||
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
|
import { resolveTelegramAutoSelectFamilyDecision } from "./network-config.js";
|
||||||
|
|
||||||
|
let appliedAutoSelectFamily: boolean | null = null;
|
||||||
|
const log = createSubsystemLogger("telegram/network");
|
||||||
|
|
||||||
|
// Node 22 workaround: disable autoSelectFamily to avoid Happy Eyeballs timeouts.
|
||||||
|
// See: https://github.com/nodejs/node/issues/54359
|
||||||
|
function applyTelegramNetworkWorkarounds(network?: TelegramNetworkConfig): void {
|
||||||
|
const decision = resolveTelegramAutoSelectFamilyDecision({ network });
|
||||||
|
if (decision.value === null || decision.value === appliedAutoSelectFamily) return;
|
||||||
|
appliedAutoSelectFamily = decision.value;
|
||||||
|
|
||||||
|
if (typeof net.setDefaultAutoSelectFamily === "function") {
|
||||||
|
try {
|
||||||
|
net.setDefaultAutoSelectFamily(decision.value);
|
||||||
|
const label = decision.source ? ` (${decision.source})` : "";
|
||||||
|
log.info(`telegram: autoSelectFamily=${decision.value}${label}`);
|
||||||
|
} catch {
|
||||||
|
// ignore if unsupported by the runtime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Prefer wrapped fetch when available to normalize AbortSignal across runtimes.
|
// Prefer wrapped fetch when available to normalize AbortSignal across runtimes.
|
||||||
export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined {
|
export function resolveTelegramFetch(
|
||||||
|
proxyFetch?: typeof fetch,
|
||||||
|
options?: { network?: TelegramNetworkConfig },
|
||||||
|
): typeof fetch | undefined {
|
||||||
|
applyTelegramNetworkWorkarounds(options?.network);
|
||||||
if (proxyFetch) return resolveFetch(proxyFetch);
|
if (proxyFetch) return resolveFetch(proxyFetch);
|
||||||
const fetchImpl = resolveFetch();
|
const fetchImpl = resolveFetch();
|
||||||
if (!fetchImpl) {
|
if (!fetchImpl) {
|
||||||
|
|||||||
@ -35,6 +35,11 @@ const { initSpy, runSpy, loadConfig } = vi.hoisted(() => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { computeBackoff, sleepWithAbort } = vi.hoisted(() => ({
|
||||||
|
computeBackoff: vi.fn(() => 0),
|
||||||
|
sleepWithAbort: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../config/config.js", async (importOriginal) => {
|
vi.mock("../config/config.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||||
return {
|
return {
|
||||||
@ -70,6 +75,11 @@ vi.mock("@grammyjs/runner", () => ({
|
|||||||
run: runSpy,
|
run: runSpy,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../infra/backoff.js", () => ({
|
||||||
|
computeBackoff,
|
||||||
|
sleepWithAbort,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../auto-reply/reply.js", () => ({
|
vi.mock("../auto-reply/reply.js", () => ({
|
||||||
getReplyFromConfig: async (ctx: { Body?: string }) => ({
|
getReplyFromConfig: async (ctx: { Body?: string }) => ({
|
||||||
text: `echo:${ctx.Body}`,
|
text: `echo:${ctx.Body}`,
|
||||||
@ -84,6 +94,8 @@ describe("monitorTelegramProvider (grammY)", () => {
|
|||||||
});
|
});
|
||||||
initSpy.mockClear();
|
initSpy.mockClear();
|
||||||
runSpy.mockClear();
|
runSpy.mockClear();
|
||||||
|
computeBackoff.mockClear();
|
||||||
|
sleepWithAbort.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("processes a DM and sends reply", async () => {
|
it("processes a DM and sends reply", async () => {
|
||||||
@ -119,7 +131,11 @@ describe("monitorTelegramProvider (grammY)", () => {
|
|||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
sink: { concurrency: 3 },
|
sink: { concurrency: 3 },
|
||||||
runner: expect.objectContaining({ silent: true }),
|
runner: expect.objectContaining({
|
||||||
|
silent: true,
|
||||||
|
maxRetryTime: 5 * 60 * 1000,
|
||||||
|
retryInterval: "exponential",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -140,4 +156,32 @@ describe("monitorTelegramProvider (grammY)", () => {
|
|||||||
});
|
});
|
||||||
expect(api.sendMessage).not.toHaveBeenCalled();
|
expect(api.sendMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retries on recoverable network errors", async () => {
|
||||||
|
const networkError = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" });
|
||||||
|
runSpy
|
||||||
|
.mockImplementationOnce(() => ({
|
||||||
|
task: () => Promise.reject(networkError),
|
||||||
|
stop: vi.fn(),
|
||||||
|
}))
|
||||||
|
.mockImplementationOnce(() => ({
|
||||||
|
task: () => Promise.resolve(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await monitorTelegramProvider({ token: "tok" });
|
||||||
|
|
||||||
|
expect(computeBackoff).toHaveBeenCalled();
|
||||||
|
expect(sleepWithAbort).toHaveBeenCalled();
|
||||||
|
expect(runSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces non-recoverable errors", async () => {
|
||||||
|
runSpy.mockImplementationOnce(() => ({
|
||||||
|
task: () => Promise.reject(new Error("bad token")),
|
||||||
|
stop: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(monitorTelegramProvider({ token: "tok" })).rejects.toThrow("bad token");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,11 +3,13 @@ import type { ClawdbotConfig } from "../config/config.js";
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { resolveAgentMaxConcurrent } from "../config/agent-limits.js";
|
import { resolveAgentMaxConcurrent } from "../config/agent-limits.js";
|
||||||
import { computeBackoff, sleepWithAbort } from "../infra/backoff.js";
|
import { computeBackoff, sleepWithAbort } from "../infra/backoff.js";
|
||||||
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
import { formatDurationMs } from "../infra/format-duration.js";
|
import { formatDurationMs } from "../infra/format-duration.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { resolveTelegramAccount } from "./accounts.js";
|
import { resolveTelegramAccount } from "./accounts.js";
|
||||||
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
|
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
|
||||||
import { createTelegramBot } from "./bot.js";
|
import { createTelegramBot } from "./bot.js";
|
||||||
|
import { isRecoverableTelegramNetworkError } from "./network-errors.js";
|
||||||
import { makeProxyFetch } from "./proxy.js";
|
import { makeProxyFetch } from "./proxy.js";
|
||||||
import { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js";
|
import { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js";
|
||||||
import { startTelegramWebhook } from "./webhook.js";
|
import { startTelegramWebhook } from "./webhook.js";
|
||||||
@ -40,6 +42,9 @@ export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions<unk
|
|||||||
},
|
},
|
||||||
// Suppress grammY getUpdates stack traces; we log concise errors ourselves.
|
// Suppress grammY getUpdates stack traces; we log concise errors ourselves.
|
||||||
silent: true,
|
silent: true,
|
||||||
|
// Retry transient failures for a limited window before surfacing errors.
|
||||||
|
maxRetryTime: 5 * 60 * 1000,
|
||||||
|
retryInterval: "exponential",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -133,7 +138,6 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use grammyjs/runner for concurrent update processing
|
// Use grammyjs/runner for concurrent update processing
|
||||||
const log = opts.runtime?.log ?? console.log;
|
|
||||||
let restartAttempts = 0;
|
let restartAttempts = 0;
|
||||||
|
|
||||||
while (!opts.abortSignal?.aborted) {
|
while (!opts.abortSignal?.aborted) {
|
||||||
@ -152,12 +156,18 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
|
|||||||
if (opts.abortSignal?.aborted) {
|
if (opts.abortSignal?.aborted) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (!isGetUpdatesConflict(err)) {
|
const isConflict = isGetUpdatesConflict(err);
|
||||||
|
const isRecoverable = isRecoverableTelegramNetworkError(err, { context: "polling" });
|
||||||
|
if (!isConflict && !isRecoverable) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
restartAttempts += 1;
|
restartAttempts += 1;
|
||||||
const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts);
|
const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts);
|
||||||
log(`Telegram getUpdates conflict; retrying in ${formatDurationMs(delayMs)}.`);
|
const reason = isConflict ? "getUpdates conflict" : "network error";
|
||||||
|
const errMsg = formatErrorMessage(err);
|
||||||
|
(opts.runtime?.error ?? console.error)(
|
||||||
|
`Telegram ${reason}: ${errMsg}; retrying in ${formatDurationMs(delayMs)}.`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await sleepWithAbort(delayMs, opts.abortSignal);
|
await sleepWithAbort(delayMs, opts.abortSignal);
|
||||||
} catch (sleepErr) {
|
} catch (sleepErr) {
|
||||||
|
|||||||
48
src/telegram/network-config.test.ts
Normal file
48
src/telegram/network-config.test.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { resolveTelegramAutoSelectFamilyDecision } from "./network-config.js";
|
||||||
|
|
||||||
|
describe("resolveTelegramAutoSelectFamilyDecision", () => {
|
||||||
|
it("prefers env enable over env disable", () => {
|
||||||
|
const decision = resolveTelegramAutoSelectFamilyDecision({
|
||||||
|
env: {
|
||||||
|
CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY: "1",
|
||||||
|
CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY: "1",
|
||||||
|
},
|
||||||
|
nodeMajor: 22,
|
||||||
|
});
|
||||||
|
expect(decision).toEqual({
|
||||||
|
value: true,
|
||||||
|
source: "env:CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses env disable when set", () => {
|
||||||
|
const decision = resolveTelegramAutoSelectFamilyDecision({
|
||||||
|
env: { CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY: "1" },
|
||||||
|
nodeMajor: 22,
|
||||||
|
});
|
||||||
|
expect(decision).toEqual({
|
||||||
|
value: false,
|
||||||
|
source: "env:CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses config override when provided", () => {
|
||||||
|
const decision = resolveTelegramAutoSelectFamilyDecision({
|
||||||
|
network: { autoSelectFamily: true },
|
||||||
|
nodeMajor: 22,
|
||||||
|
});
|
||||||
|
expect(decision).toEqual({ value: true, source: "config" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to disable on Node 22", () => {
|
||||||
|
const decision = resolveTelegramAutoSelectFamilyDecision({ nodeMajor: 22 });
|
||||||
|
expect(decision).toEqual({ value: false, source: "default-node22" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no decision applies", () => {
|
||||||
|
const decision = resolveTelegramAutoSelectFamilyDecision({ nodeMajor: 20 });
|
||||||
|
expect(decision).toEqual({ value: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
39
src/telegram/network-config.ts
Normal file
39
src/telegram/network-config.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
import { isTruthyEnvValue } from "../infra/env.js";
|
||||||
|
import type { TelegramNetworkConfig } from "../config/types.telegram.js";
|
||||||
|
|
||||||
|
export const TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV =
|
||||||
|
"CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY";
|
||||||
|
export const TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV = "CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY";
|
||||||
|
|
||||||
|
export type TelegramAutoSelectFamilyDecision = {
|
||||||
|
value: boolean | null;
|
||||||
|
source?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveTelegramAutoSelectFamilyDecision(params?: {
|
||||||
|
network?: TelegramNetworkConfig;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
nodeMajor?: number;
|
||||||
|
}): TelegramAutoSelectFamilyDecision {
|
||||||
|
const env = params?.env ?? process.env;
|
||||||
|
const nodeMajor =
|
||||||
|
typeof params?.nodeMajor === "number"
|
||||||
|
? params.nodeMajor
|
||||||
|
: Number(process.versions.node.split(".")[0]);
|
||||||
|
|
||||||
|
if (isTruthyEnvValue(env[TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV])) {
|
||||||
|
return { value: true, source: `env:${TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV}` };
|
||||||
|
}
|
||||||
|
if (isTruthyEnvValue(env[TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV])) {
|
||||||
|
return { value: false, source: `env:${TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV}` };
|
||||||
|
}
|
||||||
|
if (typeof params?.network?.autoSelectFamily === "boolean") {
|
||||||
|
return { value: params.network.autoSelectFamily, source: "config" };
|
||||||
|
}
|
||||||
|
if (Number.isFinite(nodeMajor) && nodeMajor >= 22) {
|
||||||
|
return { value: false, source: "default-node22" };
|
||||||
|
}
|
||||||
|
return { value: null };
|
||||||
|
}
|
||||||
31
src/telegram/network-errors.test.ts
Normal file
31
src/telegram/network-errors.test.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { isRecoverableTelegramNetworkError } from "./network-errors.js";
|
||||||
|
|
||||||
|
describe("isRecoverableTelegramNetworkError", () => {
|
||||||
|
it("detects recoverable error codes", () => {
|
||||||
|
const err = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" });
|
||||||
|
expect(isRecoverableTelegramNetworkError(err)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects AbortError names", () => {
|
||||||
|
const err = Object.assign(new Error("The operation was aborted"), { name: "AbortError" });
|
||||||
|
expect(isRecoverableTelegramNetworkError(err)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects nested causes", () => {
|
||||||
|
const cause = Object.assign(new Error("socket hang up"), { code: "ECONNRESET" });
|
||||||
|
const err = Object.assign(new TypeError("fetch failed"), { cause });
|
||||||
|
expect(isRecoverableTelegramNetworkError(err)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips message matches for send context", () => {
|
||||||
|
const err = new TypeError("fetch failed");
|
||||||
|
expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(false);
|
||||||
|
expect(isRecoverableTelegramNetworkError(err, { context: "polling" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for unrelated errors", () => {
|
||||||
|
expect(isRecoverableTelegramNetworkError(new Error("invalid token"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
112
src/telegram/network-errors.ts
Normal file
112
src/telegram/network-errors.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
|
||||||
|
|
||||||
|
const RECOVERABLE_ERROR_CODES = new Set([
|
||||||
|
"ECONNRESET",
|
||||||
|
"ECONNREFUSED",
|
||||||
|
"EPIPE",
|
||||||
|
"ETIMEDOUT",
|
||||||
|
"ESOCKETTIMEDOUT",
|
||||||
|
"ENETUNREACH",
|
||||||
|
"EHOSTUNREACH",
|
||||||
|
"ENOTFOUND",
|
||||||
|
"EAI_AGAIN",
|
||||||
|
"UND_ERR_CONNECT_TIMEOUT",
|
||||||
|
"UND_ERR_HEADERS_TIMEOUT",
|
||||||
|
"UND_ERR_BODY_TIMEOUT",
|
||||||
|
"UND_ERR_SOCKET",
|
||||||
|
"UND_ERR_ABORTED",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RECOVERABLE_ERROR_NAMES = new Set([
|
||||||
|
"AbortError",
|
||||||
|
"TimeoutError",
|
||||||
|
"ConnectTimeoutError",
|
||||||
|
"HeadersTimeoutError",
|
||||||
|
"BodyTimeoutError",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RECOVERABLE_MESSAGE_SNIPPETS = [
|
||||||
|
"fetch failed",
|
||||||
|
"network error",
|
||||||
|
"network request",
|
||||||
|
"client network socket disconnected",
|
||||||
|
"socket hang up",
|
||||||
|
"getaddrinfo",
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeCode(code?: string): string {
|
||||||
|
return code?.trim().toUpperCase() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorName(err: unknown): string {
|
||||||
|
if (!err || typeof err !== "object") return "";
|
||||||
|
return "name" in err ? String(err.name) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorCode(err: unknown): string | undefined {
|
||||||
|
const direct = extractErrorCode(err);
|
||||||
|
if (direct) return direct;
|
||||||
|
if (!err || typeof err !== "object") return undefined;
|
||||||
|
const errno = (err as { errno?: unknown }).errno;
|
||||||
|
if (typeof errno === "string") return errno;
|
||||||
|
if (typeof errno === "number") return String(errno);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectErrorCandidates(err: unknown): unknown[] {
|
||||||
|
const queue = [err];
|
||||||
|
const seen = new Set<unknown>();
|
||||||
|
const candidates: unknown[] = [];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift();
|
||||||
|
if (current == null || seen.has(current)) continue;
|
||||||
|
seen.add(current);
|
||||||
|
candidates.push(current);
|
||||||
|
|
||||||
|
if (typeof current === "object") {
|
||||||
|
const cause = (current as { cause?: unknown }).cause;
|
||||||
|
if (cause && !seen.has(cause)) queue.push(cause);
|
||||||
|
const reason = (current as { reason?: unknown }).reason;
|
||||||
|
if (reason && !seen.has(reason)) queue.push(reason);
|
||||||
|
const errors = (current as { errors?: unknown }).errors;
|
||||||
|
if (Array.isArray(errors)) {
|
||||||
|
for (const nested of errors) {
|
||||||
|
if (nested && !seen.has(nested)) queue.push(nested);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TelegramNetworkErrorContext = "polling" | "send" | "webhook" | "unknown";
|
||||||
|
|
||||||
|
export function isRecoverableTelegramNetworkError(
|
||||||
|
err: unknown,
|
||||||
|
options: { context?: TelegramNetworkErrorContext; allowMessageMatch?: boolean } = {},
|
||||||
|
): boolean {
|
||||||
|
if (!err) return false;
|
||||||
|
const allowMessageMatch =
|
||||||
|
typeof options.allowMessageMatch === "boolean"
|
||||||
|
? options.allowMessageMatch
|
||||||
|
: options.context !== "send";
|
||||||
|
|
||||||
|
for (const candidate of collectErrorCandidates(err)) {
|
||||||
|
const code = normalizeCode(getErrorCode(candidate));
|
||||||
|
if (code && RECOVERABLE_ERROR_CODES.has(code)) return true;
|
||||||
|
|
||||||
|
const name = getErrorName(candidate);
|
||||||
|
if (name && RECOVERABLE_ERROR_NAMES.has(name)) return true;
|
||||||
|
|
||||||
|
if (allowMessageMatch) {
|
||||||
|
const message = formatErrorMessage(candidate).toLowerCase();
|
||||||
|
if (message && RECOVERABLE_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@ -19,6 +19,7 @@ vi.mock("../web/media.js", () => ({
|
|||||||
vi.mock("grammy", () => ({
|
vi.mock("grammy", () => ({
|
||||||
Bot: class {
|
Bot: class {
|
||||||
api = botApi;
|
api = botApi;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(
|
constructor(
|
||||||
public token: string,
|
public token: string,
|
||||||
public options?: {
|
public options?: {
|
||||||
|
|||||||
91
src/telegram/send.edit-message.test.ts
Normal file
91
src/telegram/send.edit-message.test.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { botApi, botCtorSpy } = vi.hoisted(() => ({
|
||||||
|
botApi: {
|
||||||
|
editMessageText: vi.fn(),
|
||||||
|
},
|
||||||
|
botCtorSpy: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("grammy", () => ({
|
||||||
|
Bot: class {
|
||||||
|
api = botApi;
|
||||||
|
constructor(public token: string) {
|
||||||
|
botCtorSpy(token);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
InputFile: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { editMessageTelegram } from "./send.js";
|
||||||
|
|
||||||
|
describe("editMessageTelegram", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
botApi.editMessageText.mockReset();
|
||||||
|
botCtorSpy.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps existing buttons when buttons is undefined (no reply_markup)", async () => {
|
||||||
|
botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
|
||||||
|
|
||||||
|
await editMessageTelegram("123", 1, "hi", {
|
||||||
|
token: "tok",
|
||||||
|
cfg: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(botCtorSpy).toHaveBeenCalledWith("tok");
|
||||||
|
expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
|
||||||
|
const call = botApi.editMessageText.mock.calls[0] ?? [];
|
||||||
|
const params = call[3] as Record<string, unknown>;
|
||||||
|
expect(params).toEqual(expect.objectContaining({ parse_mode: "HTML" }));
|
||||||
|
expect(params).not.toHaveProperty("reply_markup");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes buttons when buttons is empty (reply_markup.inline_keyboard = [])", async () => {
|
||||||
|
botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
|
||||||
|
|
||||||
|
await editMessageTelegram("123", 1, "hi", {
|
||||||
|
token: "tok",
|
||||||
|
cfg: {},
|
||||||
|
buttons: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
|
||||||
|
const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record<string, unknown>;
|
||||||
|
expect(params).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
parse_mode: "HTML",
|
||||||
|
reply_markup: { inline_keyboard: [] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to plain text when Telegram HTML parse fails (and preserves reply_markup)", async () => {
|
||||||
|
botApi.editMessageText
|
||||||
|
.mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities"))
|
||||||
|
.mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } });
|
||||||
|
|
||||||
|
await editMessageTelegram("123", 1, "<bad> html", {
|
||||||
|
token: "tok",
|
||||||
|
cfg: {},
|
||||||
|
buttons: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(botApi.editMessageText).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record<string, unknown>;
|
||||||
|
expect(firstParams).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
parse_mode: "HTML",
|
||||||
|
reply_markup: { inline_keyboard: [] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record<string, unknown>;
|
||||||
|
expect(secondParams).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
reply_markup: { inline_keyboard: [] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -19,6 +19,7 @@ vi.mock("../web/media.js", () => ({
|
|||||||
vi.mock("grammy", () => ({
|
vi.mock("grammy", () => ({
|
||||||
Bot: class {
|
Bot: class {
|
||||||
api = botApi;
|
api = botApi;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(
|
constructor(
|
||||||
public token: string,
|
public token: string,
|
||||||
public options?: { client?: { fetch?: typeof fetch } },
|
public options?: { client?: { fetch?: typeof fetch } },
|
||||||
|
|||||||
@ -40,6 +40,7 @@ vi.mock("./fetch.js", () => ({
|
|||||||
vi.mock("grammy", () => ({
|
vi.mock("grammy", () => ({
|
||||||
Bot: class {
|
Bot: class {
|
||||||
api = botApi;
|
api = botApi;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(
|
constructor(
|
||||||
public token: string,
|
public token: string,
|
||||||
public options?: { client?: { fetch?: typeof fetch; timeoutSeconds?: number } },
|
public options?: { client?: { fetch?: typeof fetch; timeoutSeconds?: number } },
|
||||||
@ -76,7 +77,7 @@ describe("telegram proxy client", () => {
|
|||||||
await sendMessageTelegram("123", "hi", { token: "tok", accountId: "foo" });
|
await sendMessageTelegram("123", "hi", { token: "tok", accountId: "foo" });
|
||||||
|
|
||||||
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
|
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
|
||||||
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch);
|
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined });
|
||||||
expect(botCtorSpy).toHaveBeenCalledWith(
|
expect(botCtorSpy).toHaveBeenCalledWith(
|
||||||
"tok",
|
"tok",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@ -94,7 +95,7 @@ describe("telegram proxy client", () => {
|
|||||||
await reactMessageTelegram("123", "456", "✅", { token: "tok", accountId: "foo" });
|
await reactMessageTelegram("123", "456", "✅", { token: "tok", accountId: "foo" });
|
||||||
|
|
||||||
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
|
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
|
||||||
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch);
|
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined });
|
||||||
expect(botCtorSpy).toHaveBeenCalledWith(
|
expect(botCtorSpy).toHaveBeenCalledWith(
|
||||||
"tok",
|
"tok",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@ -112,7 +113,7 @@ describe("telegram proxy client", () => {
|
|||||||
await deleteMessageTelegram("123", "456", { token: "tok", accountId: "foo" });
|
await deleteMessageTelegram("123", "456", { token: "tok", accountId: "foo" });
|
||||||
|
|
||||||
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
|
expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl);
|
||||||
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch);
|
expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined });
|
||||||
expect(botCtorSpy).toHaveBeenCalledWith(
|
expect(botCtorSpy).toHaveBeenCalledWith(
|
||||||
"tok",
|
"tok",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@ -19,6 +19,7 @@ vi.mock("../web/media.js", () => ({
|
|||||||
vi.mock("grammy", () => ({
|
vi.mock("grammy", () => ({
|
||||||
Bot: class {
|
Bot: class {
|
||||||
api = botApi;
|
api = botApi;
|
||||||
|
catch = vi.fn();
|
||||||
constructor(
|
constructor(
|
||||||
public token: string,
|
public token: string,
|
||||||
public options?: {
|
public options?: {
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { resolveTelegramFetch } from "./fetch.js";
|
|||||||
import { makeProxyFetch } from "./proxy.js";
|
import { makeProxyFetch } from "./proxy.js";
|
||||||
import { renderTelegramHtmlText } from "./format.js";
|
import { renderTelegramHtmlText } from "./format.js";
|
||||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||||
|
import { isRecoverableTelegramNetworkError } from "./network-errors.js";
|
||||||
import { splitTelegramCaption } from "./caption.js";
|
import { splitTelegramCaption } from "./caption.js";
|
||||||
import { recordSentMessage } from "./sent-message-cache.js";
|
import { recordSentMessage } from "./sent-message-cache.js";
|
||||||
import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js";
|
import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js";
|
||||||
@ -84,7 +85,9 @@ function resolveTelegramClientOptions(
|
|||||||
): ApiClientOptions | undefined {
|
): ApiClientOptions | undefined {
|
||||||
const proxyUrl = account.config.proxy?.trim();
|
const proxyUrl = account.config.proxy?.trim();
|
||||||
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
|
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
|
||||||
const fetchImpl = resolveTelegramFetch(proxyFetch);
|
const fetchImpl = resolveTelegramFetch(proxyFetch, {
|
||||||
|
network: account.config.network,
|
||||||
|
});
|
||||||
const timeoutSeconds =
|
const timeoutSeconds =
|
||||||
typeof account.config.timeoutSeconds === "number" &&
|
typeof account.config.timeoutSeconds === "number" &&
|
||||||
Number.isFinite(account.config.timeoutSeconds)
|
Number.isFinite(account.config.timeoutSeconds)
|
||||||
@ -203,6 +206,7 @@ export async function sendMessageTelegram(
|
|||||||
retry: opts.retry,
|
retry: opts.retry,
|
||||||
configRetry: account.config.retry,
|
configRetry: account.config.retry,
|
||||||
verbose: opts.verbose,
|
verbose: opts.verbose,
|
||||||
|
shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
|
||||||
});
|
});
|
||||||
const logHttpError = createTelegramHttpLogger(cfg);
|
const logHttpError = createTelegramHttpLogger(cfg);
|
||||||
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
||||||
@ -434,6 +438,7 @@ export async function reactMessageTelegram(
|
|||||||
retry: opts.retry,
|
retry: opts.retry,
|
||||||
configRetry: account.config.retry,
|
configRetry: account.config.retry,
|
||||||
verbose: opts.verbose,
|
verbose: opts.verbose,
|
||||||
|
shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
|
||||||
});
|
});
|
||||||
const logHttpError = createTelegramHttpLogger(cfg);
|
const logHttpError = createTelegramHttpLogger(cfg);
|
||||||
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
||||||
@ -483,6 +488,7 @@ export async function deleteMessageTelegram(
|
|||||||
retry: opts.retry,
|
retry: opts.retry,
|
||||||
configRetry: account.config.retry,
|
configRetry: account.config.retry,
|
||||||
verbose: opts.verbose,
|
verbose: opts.verbose,
|
||||||
|
shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
|
||||||
});
|
});
|
||||||
const logHttpError = createTelegramHttpLogger(cfg);
|
const logHttpError = createTelegramHttpLogger(cfg);
|
||||||
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
||||||
@ -495,6 +501,99 @@ export async function deleteMessageTelegram(
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TelegramEditOpts = {
|
||||||
|
token?: string;
|
||||||
|
accountId?: string;
|
||||||
|
verbose?: boolean;
|
||||||
|
api?: Bot["api"];
|
||||||
|
retry?: RetryConfig;
|
||||||
|
textMode?: "markdown" | "html";
|
||||||
|
/** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */
|
||||||
|
buttons?: Array<Array<{ text: string; callback_data: string }>>;
|
||||||
|
/** Optional config injection to avoid global loadConfig() (improves testability). */
|
||||||
|
cfg?: ReturnType<typeof loadConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function editMessageTelegram(
|
||||||
|
chatIdInput: string | number,
|
||||||
|
messageIdInput: string | number,
|
||||||
|
text: string,
|
||||||
|
opts: TelegramEditOpts = {},
|
||||||
|
): Promise<{ ok: true; messageId: string; chatId: string }> {
|
||||||
|
const cfg = opts.cfg ?? loadConfig();
|
||||||
|
const account = resolveTelegramAccount({
|
||||||
|
cfg,
|
||||||
|
accountId: opts.accountId,
|
||||||
|
});
|
||||||
|
const token = resolveToken(opts.token, account);
|
||||||
|
const chatId = normalizeChatId(String(chatIdInput));
|
||||||
|
const messageId = normalizeMessageId(messageIdInput);
|
||||||
|
const client = resolveTelegramClientOptions(account);
|
||||||
|
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||||
|
const request = createTelegramRetryRunner({
|
||||||
|
retry: opts.retry,
|
||||||
|
configRetry: account.config.retry,
|
||||||
|
verbose: opts.verbose,
|
||||||
|
});
|
||||||
|
const logHttpError = createTelegramHttpLogger(cfg);
|
||||||
|
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
||||||
|
request(fn, label).catch((err) => {
|
||||||
|
logHttpError(label ?? "request", err);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
const textMode = opts.textMode ?? "markdown";
|
||||||
|
const tableMode = resolveMarkdownTableMode({
|
||||||
|
cfg,
|
||||||
|
channel: "telegram",
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
const htmlText = renderTelegramHtmlText(text, { textMode, tableMode });
|
||||||
|
|
||||||
|
// Reply markup semantics:
|
||||||
|
// - buttons === undefined → don't send reply_markup (keep existing)
|
||||||
|
// - buttons is [] (or filters to empty) → send { inline_keyboard: [] } (remove)
|
||||||
|
// - otherwise → send built inline keyboard
|
||||||
|
const shouldTouchButtons = opts.buttons !== undefined;
|
||||||
|
const builtKeyboard = shouldTouchButtons ? buildInlineKeyboard(opts.buttons) : undefined;
|
||||||
|
const replyMarkup = shouldTouchButtons ? (builtKeyboard ?? { inline_keyboard: [] }) : undefined;
|
||||||
|
|
||||||
|
const editParams: Record<string, unknown> = {
|
||||||
|
parse_mode: "HTML",
|
||||||
|
};
|
||||||
|
if (replyMarkup !== undefined) {
|
||||||
|
editParams.reply_markup = replyMarkup;
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestWithDiag(
|
||||||
|
() => api.editMessageText(chatId, messageId, htmlText, editParams),
|
||||||
|
"editMessage",
|
||||||
|
).catch(async (err) => {
|
||||||
|
// Telegram rejects malformed HTML. Fall back to plain text.
|
||||||
|
const errText = formatErrorMessage(err);
|
||||||
|
if (PARSE_ERR_RE.test(errText)) {
|
||||||
|
if (opts.verbose) {
|
||||||
|
console.warn(`telegram HTML parse failed, retrying as plain text: ${errText}`);
|
||||||
|
}
|
||||||
|
const plainParams: Record<string, unknown> = {};
|
||||||
|
if (replyMarkup !== undefined) {
|
||||||
|
plainParams.reply_markup = replyMarkup;
|
||||||
|
}
|
||||||
|
return await requestWithDiag(
|
||||||
|
() =>
|
||||||
|
Object.keys(plainParams).length > 0
|
||||||
|
? api.editMessageText(chatId, messageId, text, plainParams)
|
||||||
|
: api.editMessageText(chatId, messageId, text),
|
||||||
|
"editMessage-plain",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
logVerbose(`[telegram] Edited message ${messageId} in chat ${chatId}`);
|
||||||
|
return { ok: true, messageId: String(messageId), chatId };
|
||||||
|
}
|
||||||
|
|
||||||
function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
|
function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "image":
|
case "image":
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { type ApiClientOptions, Bot } from "grammy";
|
import { type ApiClientOptions, Bot } from "grammy";
|
||||||
|
import type { TelegramNetworkConfig } from "../config/types.telegram.js";
|
||||||
import { resolveTelegramFetch } from "./fetch.js";
|
import { resolveTelegramFetch } from "./fetch.js";
|
||||||
|
|
||||||
export async function setTelegramWebhook(opts: {
|
export async function setTelegramWebhook(opts: {
|
||||||
@ -6,8 +7,9 @@ export async function setTelegramWebhook(opts: {
|
|||||||
url: string;
|
url: string;
|
||||||
secret?: string;
|
secret?: string;
|
||||||
dropPendingUpdates?: boolean;
|
dropPendingUpdates?: boolean;
|
||||||
|
network?: TelegramNetworkConfig;
|
||||||
}) {
|
}) {
|
||||||
const fetchImpl = resolveTelegramFetch();
|
const fetchImpl = resolveTelegramFetch(undefined, { network: opts.network });
|
||||||
const client: ApiClientOptions | undefined = fetchImpl
|
const client: ApiClientOptions | undefined = fetchImpl
|
||||||
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
|
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
|
||||||
: undefined;
|
: undefined;
|
||||||
@ -18,8 +20,11 @@ export async function setTelegramWebhook(opts: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTelegramWebhook(opts: { token: string }) {
|
export async function deleteTelegramWebhook(opts: {
|
||||||
const fetchImpl = resolveTelegramFetch();
|
token: string;
|
||||||
|
network?: TelegramNetworkConfig;
|
||||||
|
}) {
|
||||||
|
const fetchImpl = resolveTelegramFetch(undefined, { network: opts.network });
|
||||||
const client: ApiClientOptions | undefined = fetchImpl
|
const client: ApiClientOptions | undefined = fetchImpl
|
||||||
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
|
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user