Merge branch 'main' into fix/secondary-agent-oauth-fallback

This commit is contained in:
Shakker 2026-01-27 02:17:50 +00:00 committed by GitHub
commit 45ca0d9052
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
148 changed files with 2602 additions and 450 deletions

View File

@ -21,3 +21,4 @@ jobs:
with: with:
configuration-path: .github/labeler.yml configuration-path: .github/labeler.yml
repo-token: ${{ steps.app-token.outputs.token }} repo-token: ${{ steps.app-token.outputs.token }}
sync-labels: true

View File

@ -6,9 +6,12 @@ Docs: https://docs.clawd.bot
Status: unreleased. Status: unreleased.
### Changes ### Changes
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281) - Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
- Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk.
- 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.
@ -36,12 +39,14 @@ Status: unreleased.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra. - Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon. - Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco. - Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99.
- Security: use Windows ACLs for permission audits and fixes on Windows. (#1957) - Security: use Windows ACLs for permission audits and fixes on Windows. (#1957)
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla. - Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907. - Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
- 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.
@ -50,8 +55,19 @@ 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
- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg.
- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg.
- 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.
- CLI: recognize versioned Node executables when parsing argv. (#2490) Thanks @David-Marsh-Photo.
- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204.
- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein.
- CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481.
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. - Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
- 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.
- Telegram: log fetch/API errors in delivery to avoid unhandled rejections. (#2492) Thanks @altryne.
- 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.
- Build: align memory-core peer dependency with lockfile. - Build: align memory-core peer dependency with lockfile.

View File

@ -477,35 +477,36 @@ 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/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/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/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/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/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/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/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></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/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/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/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/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/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/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/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/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a>
<a href="https://github.com/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/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a>
<a href="https://github.com/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=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
<a href="https://github.com/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/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=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/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/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/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/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/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/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=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/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/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/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/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
<a href="https://github.com/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/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/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/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/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/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/chenyuan99"><img src="https://avatars.githubusercontent.com/u/25518100?v=4&s=48" width="48" height="48" alt="chenyuan99" title="chenyuan99"/></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/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></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/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=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=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/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/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/Kiwitwitter"><img src="https://avatars.githubusercontent.com/u/25277769?v=4&s=48" width="48" height="48" alt="Kiwitwitter" title="Kiwitwitter"/></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=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/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/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/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/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/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/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/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/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></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/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/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/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/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/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/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>

View File

@ -115,6 +115,9 @@ body::after {
} }
.shell { .shell {
position: sticky;
top: 0;
z-index: 100;
padding: 22px 16px 10px; padding: 22px 16px 10px;
} }

View File

@ -10,7 +10,7 @@ on any homeserver, so you need a Matrix account for the bot. Once it is logged i
the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too, the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too,
but it requires E2EE to be enabled. but it requires E2EE to be enabled.
Status: supported via plugin (matrix-bot-sdk). Direct messages, rooms, threads, media, reactions, Status: supported via plugin (@vector-im/matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
polls (send + poll-start as text), location, and E2EE (with crypto support). polls (send + poll-start as text), location, and E2EE (with crypto support).
## Plugin required ## Plugin required

View File

@ -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).

View File

@ -345,10 +345,6 @@
"source": "/auth-monitoring", "source": "/auth-monitoring",
"destination": "/automation/auth-monitoring" "destination": "/automation/auth-monitoring"
}, },
{
"source": "/scripts",
"destination": "/scripts"
},
{ {
"source": "/camera", "source": "/camera",
"destination": "/nodes/camera" "destination": "/nodes/camera"
@ -805,6 +801,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 +852,7 @@
"install/docker", "install/docker",
"railway", "railway",
"render", "render",
"northflank",
"install/bun" "install/bun"
] ]
}, },

View File

@ -954,6 +954,8 @@ Notes:
- `commands.debug: true` enables `/debug` (runtime-only overrides). - `commands.debug: true` enables `/debug` (runtime-only overrides).
- `commands.restart: true` enables `/restart` and the gateway tool restart action. - `commands.restart: true` enables `/restart` and the gateway tool restart action.
- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies. - `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies.
- Slash commands and directives are only honored for **authorized senders**. Authorization is derived from
channel allowlists/pairing plus `commands.useAccessGroups`.
### `web` (WhatsApp web channel runtime) ### `web` (WhatsApp web channel runtime)
@ -1027,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",

View File

@ -59,6 +59,8 @@ Two layers matter:
Rules of thumb: Rules of thumb:
- `deny` always wins. - `deny` always wins.
- If `allow` is non-empty, everything else is treated as blocked. - If `allow` is non-empty, everything else is treated as blocked.
- Tool policy is the hard stop: `/exec` cannot override a denied `exec` tool.
- `/exec` only changes session defaults for authorized senders; it does not grant tool access.
Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.2`). Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.2`).
### Tool groups (shorthands) ### Tool groups (shorthands)
@ -95,6 +97,7 @@ Elevated does **not** grant extra tools; it only affects `exec`.
- Use `/elevated full` to skip exec approvals for the session. - Use `/elevated full` to skip exec approvals for the session.
- If youre already running direct, elevated is effectively a no-op (still gated). - If youre already running direct, elevated is effectively a no-op (still gated).
- Elevated is **not** skill-scoped and does **not** override tool allow/deny. - Elevated is **not** skill-scoped and does **not** override tool allow/deny.
- `/exec` is separate from elevated. It only adjusts per-session exec defaults for authorized senders.
Gates: Gates:
- Enablement: `tools.elevated.enabled` (and optionally `agents.list[].tools.elevated.enabled`) - Enablement: `tools.elevated.enabled` (and optionally `agents.list[].tools.elevated.enabled`)

View File

@ -142,6 +142,8 @@ Tool allow/deny policies still apply before sandbox rules. If a tool is denied
globally or per-agent, sandboxing doesnt bring it back. globally or per-agent, sandboxing doesnt bring it back.
`tools.elevated` is an explicit escape hatch that runs `exec` on the host. `tools.elevated` is an explicit escape hatch that runs `exec` on the host.
`/exec` directives only apply for authorized senders and persist per session; to hard-disable
`exec`, use tool policy deny (see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)).
Debugging: Debugging:
- Use `clawdbot sandbox explain` to inspect effective sandbox mode, tool policy, and fix-it config keys. - Use `clawdbot sandbox explain` to inspect effective sandbox mode, tool policy, and fix-it config keys.

View File

@ -142,6 +142,16 @@ Clawdbots stance:
- **Scope next:** decide where the bot is allowed to act (group allowlists + mention gating, tools, sandboxing, device permissions). - **Scope next:** decide where the bot is allowed to act (group allowlists + mention gating, tools, sandboxing, device permissions).
- **Model last:** assume the model can be manipulated; design so manipulation has limited blast radius. - **Model last:** assume the model can be manipulated; design so manipulation has limited blast radius.
## Command authorization model
Slash commands and directives are only honored for **authorized senders**. Authorization is derived from
channel allowlists/pairing plus `commands.useAccessGroups` (see [Configuration](/gateway/configuration)
and [Slash commands](/tools/slash-commands)). If a channel allowlist is empty or includes `"*"`,
commands are effectively open for that channel.
`/exec` is a session-only convenience for authorized operators. It does **not** write config or
change other sessions.
## Plugins/extensions ## Plugins/extensions
Plugins run **in-process** with the Gateway. Treat them as trusted code: Plugins run **in-process** with the Gateway. Treat them as trusted code:

View File

@ -1,9 +1,10 @@
--- ---
title: "Node.js + npm (PATH sanity)"
summary: "Node.js + npm install sanity: versions, PATH, and global installs" summary: "Node.js + npm install sanity: versions, PATH, and global installs"
read_when: read_when:
- You installed Clawdbot but `clawdbot` is “command not found” - "You installed Clawdbot but `clawdbot` is “command not found”"
- Youre setting up Node.js/npm on a new machine - "Youre setting up Node.js/npm on a new machine"
- `npm install -g ...` fails with permissions or PATH issues - "npm install -g ... fails with permissions or PATH issues"
--- ---
# Node.js + npm (PATH sanity) # Node.js + npm (PATH sanity)

53
docs/northflank.mdx Normal file
View 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 dont 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`)

View File

@ -104,6 +104,7 @@ Notes:
- `mock` is a local dev provider (no network calls). - `mock` is a local dev provider (no network calls).
- `skipSignatureVerification` is for local testing only. - `skipSignatureVerification` is for local testing only.
- If you use ngrok free tier, set `publicUrl` to the exact ngrok URL; signature verification is always enforced. - If you use ngrok free tier, set `publicUrl` to the exact ngrok URL; signature verification is always enforced.
- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
- Ngrok free tier URLs can change or add interstitial behavior; if `publicUrl` drifts, Twilio signatures will fail. For production, prefer a stable domain or Tailscale funnel. - Ngrok free tier URLs can change or add interstitial behavior; if `publicUrl` drifts, Twilio signatures will fail. For production, prefer a stable domain or Tailscale funnel.
## TTS for calls ## TTS for calls

View File

@ -23,6 +23,7 @@ read_when:
- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require. - **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require.
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status. - **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used. - **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
- **Separate from `/exec`**: `/exec` adjusts per-session defaults for authorized senders and does not require elevated.
## Resolution order ## Resolution order
1. Inline directive on the message (applies only to that message). 1. Inline directive on the message (applies only to that message).

View File

@ -216,6 +216,9 @@ Approval-gated execs reuse the approval id as the `runId` in these messages for
- **full** is powerful; prefer allowlists when possible. - **full** is powerful; prefer allowlists when possible.
- **ask** keeps you in the loop while still allowing fast approvals. - **ask** keeps you in the loop while still allowing fast approvals.
- Per-agent allowlists prevent one agents approvals from leaking into others. - Per-agent allowlists prevent one agents approvals from leaking into others.
- Approvals only apply to host exec requests from **authorized senders**. Unauthorized senders cannot issue `/exec`.
- `/exec security=full` is a session-level convenience for authorized operators and skips approvals by design.
To hard-block host exec, set approvals security to `deny` or deny the `exec` tool via tool policy.
Related: Related:
- [Exec tool](/tools/exec) - [Exec tool](/tools/exec)

View File

@ -91,6 +91,13 @@ Example:
/exec host=gateway security=allowlist ask=on-miss node=mac-1 /exec host=gateway security=allowlist ask=on-miss node=mac-1
``` ```
## Authorization model
`/exec` is only honored for **authorized senders** (channel allowlists/pairing plus `commands.useAccessGroups`).
It updates **session state only** and does not write config. To hard-disable exec, deny it via tool
policy (`tools.deny: ["exec"]` or per-agent). Host approvals still apply unless you explicitly set
`security=full` and `ask=off`.
## Exec approvals (companion app / node host) ## Exec approvals (companion app / node host)
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host. Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.

View File

@ -16,6 +16,8 @@ There are two related systems:
- Directives are stripped from the message before the model sees it. - Directives are stripped from the message before the model sees it.
- In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings. - In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings.
- In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement. - In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement.
- Directives are only applied for **authorized senders** (channel allowlists/pairing plus `commands.useAccessGroups`).
Unauthorized senders see directives treated as plain text.
There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status`, `/whoami` (`/id`). There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status`, `/whoami` (`/id`).
They run immediately, are stripped before the model sees the message, and the remaining text continues through the normal flow. They run immediately, are stripped before the model sees the message, and the remaining text continues through the normal flow.

View File

@ -11,6 +11,8 @@ deployments work at a high level.
## Pick a provider ## Pick a provider
- **Railway** (oneclick + browser setup): [Railway](/railway)
- **Northflank** (oneclick + 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)

View File

@ -146,8 +146,14 @@ function createMockRuntime(): PluginRuntime {
resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"], resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
}, },
debounce: { debounce: {
createInboundDebouncer: vi.fn() as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"], // Create a pass-through debouncer that immediately calls onFlush
resolveInboundDebounceMs: vi.fn() as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"], createInboundDebouncer: vi.fn((params: { onFlush: (items: unknown[]) => Promise<void> }) => ({
enqueue: async (item: unknown) => {
await params.onFlush([item]);
},
flushKey: vi.fn(),
})) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
resolveInboundDebounceMs: vi.fn(() => 0) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
}, },
commands: { commands: {
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"], resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],

View File

@ -250,8 +250,178 @@ type WebhookTarget = {
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}; };
/**
* Entry type for debouncing inbound messages.
* Captures the normalized message and its target for later combined processing.
*/
type BlueBubblesDebounceEntry = {
message: NormalizedWebhookMessage;
target: WebhookTarget;
};
/**
* Default debounce window for inbound message coalescing (ms).
* This helps combine URL text + link preview balloon messages that BlueBubbles
* sends as separate webhook events when no explicit inbound debounce config exists.
*/
const DEFAULT_INBOUND_DEBOUNCE_MS = 350;
/**
* Combines multiple debounced messages into a single message for processing.
* Used when multiple webhook events arrive within the debounce window.
*/
function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage {
if (entries.length === 0) {
throw new Error("Cannot combine empty entries");
}
if (entries.length === 1) {
return entries[0].message;
}
// Use the first message as the base (typically the text message)
const first = entries[0].message;
// Combine text from all entries, filtering out duplicates and empty strings
const seenTexts = new Set<string>();
const textParts: string[] = [];
for (const entry of entries) {
const text = entry.message.text.trim();
if (!text) continue;
// Skip duplicate text (URL might be in both text message and balloon)
const normalizedText = text.toLowerCase();
if (seenTexts.has(normalizedText)) continue;
seenTexts.add(normalizedText);
textParts.push(text);
}
// Merge attachments from all entries
const allAttachments = entries.flatMap((e) => e.message.attachments ?? []);
// Use the latest timestamp
const timestamps = entries
.map((e) => e.message.timestamp)
.filter((t): t is number => typeof t === "number");
const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp;
// Collect all message IDs for reference
const messageIds = entries
.map((e) => e.message.messageId)
.filter((id): id is string => Boolean(id));
// Prefer reply context from any entry that has it
const entryWithReply = entries.find((e) => e.message.replyToId);
return {
...first,
text: textParts.join(" "),
attachments: allAttachments.length > 0 ? allAttachments : first.attachments,
timestamp: latestTimestamp,
// Use first message's ID as primary (for reply reference), but we've coalesced others
messageId: messageIds[0] ?? first.messageId,
// Preserve reply context if present
replyToId: entryWithReply?.message.replyToId ?? first.replyToId,
replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody,
replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender,
// Clear balloonBundleId since we've combined (the combined message is no longer just a balloon)
balloonBundleId: undefined,
};
}
const webhookTargets = new Map<string, WebhookTarget[]>(); const webhookTargets = new Map<string, WebhookTarget[]>();
/**
* Maps webhook targets to their inbound debouncers.
* Each target gets its own debouncer keyed by a unique identifier.
*/
const targetDebouncers = new Map<
WebhookTarget,
ReturnType<BlueBubblesCoreRuntime["channel"]["debounce"]["createInboundDebouncer"]>
>();
function resolveBlueBubblesDebounceMs(
config: ClawdbotConfig,
core: BlueBubblesCoreRuntime,
): number {
const inbound = config.messages?.inbound;
const hasExplicitDebounce =
typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
if (!hasExplicitDebounce) return DEFAULT_INBOUND_DEBOUNCE_MS;
return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
}
/**
* Creates or retrieves a debouncer for a webhook target.
*/
function getOrCreateDebouncer(target: WebhookTarget) {
const existing = targetDebouncers.get(target);
if (existing) return existing;
const { account, config, runtime, core } = target;
const debouncer = core.channel.debounce.createInboundDebouncer<BlueBubblesDebounceEntry>({
debounceMs: resolveBlueBubblesDebounceMs(config, core),
buildKey: (entry) => {
const msg = entry.message;
// Build key from account + chat + sender to coalesce messages from same source
const chatKey =
msg.chatGuid?.trim() ??
msg.chatIdentifier?.trim() ??
(msg.chatId ? String(msg.chatId) : "dm");
return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`;
},
shouldDebounce: (entry) => {
const msg = entry.message;
// Skip debouncing for messages with attachments - process immediately
if (msg.attachments && msg.attachments.length > 0) return false;
// Skip debouncing for from-me messages (they're just cached, not processed)
if (msg.fromMe) return false;
// Skip debouncing for control commands - process immediately
if (core.channel.text.hasControlCommand(msg.text, config)) return false;
// Debounce normal text messages and URL balloon messages
return true;
},
onFlush: async (entries) => {
if (entries.length === 0) return;
// Use target from first entry (all entries have same target due to key structure)
const flushTarget = entries[0].target;
if (entries.length === 1) {
// Single message - process normally
await processMessage(entries[0].message, flushTarget);
return;
}
// Multiple messages - combine and process
const combined = combineDebounceEntries(entries);
if (core.logging.shouldLogVerbose()) {
const count = entries.length;
const preview = combined.text.slice(0, 50);
runtime.log?.(
`[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
);
}
await processMessage(combined, flushTarget);
},
onError: (err) => {
runtime.error?.(`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`);
},
});
targetDebouncers.set(target, debouncer);
return debouncer;
}
/**
* Removes a debouncer for a target (called during unregistration).
*/
function removeDebouncer(target: WebhookTarget): void {
targetDebouncers.delete(target);
}
function normalizeWebhookPath(raw: string): string { function normalizeWebhookPath(raw: string): string {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return "/"; if (!trimmed) return "/";
@ -275,6 +445,8 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v
} else { } else {
webhookTargets.delete(key); webhookTargets.delete(key);
} }
// Clean up debouncer when target is unregistered
removeDebouncer(normalizedTarget);
}; };
} }
@ -1205,7 +1377,10 @@ export async function handleBlueBubblesWebhookRequest(
); );
}); });
} else if (message) { } else if (message) {
processMessage(message, target).catch((err) => { // Route messages through debouncer to coalesce rapid-fire events
// (e.g., text message + URL balloon arriving as separate webhooks)
const debouncer = getOrCreateDebouncer(target);
debouncer.enqueue({ message, target }).catch((err) => {
target.runtime.error?.( target.runtime.error?.(
`[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`, `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
); );

View File

@ -26,7 +26,7 @@
"dependencies": { "dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"markdown-it": "14.1.0", "markdown-it": "14.1.0",
"matrix-bot-sdk": "0.8.0", "@vector-im/matrix-bot-sdk": "0.8.0-element.3",
"music-metadata": "^11.10.6", "music-metadata": "^11.10.6",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },

View File

@ -95,7 +95,7 @@ export async function readMatrixMessages(
: 20; : 20;
const token = opts.before?.trim() || opts.after?.trim() || undefined; const token = opts.before?.trim() || opts.after?.trim() || undefined;
const dir = opts.after ? "f" : "b"; const dir = opts.after ? "f" : "b";
// matrix-bot-sdk uses doRequest for room messages // @vector-im/matrix-bot-sdk uses doRequest for room messages
const res = await client.doRequest( const res = await client.doRequest(
"GET", "GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`, `/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,

View File

@ -21,7 +21,7 @@ export async function listMatrixReactions(
typeof opts.limit === "number" && Number.isFinite(opts.limit) typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit)) ? Math.max(1, Math.floor(opts.limit))
: 100; : 100;
// matrix-bot-sdk uses doRequest for relations // @vector-im/matrix-bot-sdk uses doRequest for relations
const res = await client.doRequest( const res = await client.doRequest(
"GET", "GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`, `/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,

View File

@ -9,9 +9,9 @@ export async function getMatrixMemberInfo(
const { client, stopOnDone } = await resolveActionClient(opts); const { client, stopOnDone } = await resolveActionClient(opts);
try { try {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined; const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
// matrix-bot-sdk uses getUserProfile // @vector-im/matrix-bot-sdk uses getUserProfile
const profile = await client.getUserProfile(userId); const profile = await client.getUserProfile(userId);
// Note: matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk // Note: @vector-im/matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
// We'd need to fetch room state separately if needed // We'd need to fetch room state separately if needed
return { return {
userId, userId,
@ -36,7 +36,7 @@ export async function getMatrixRoomInfo(
const { client, stopOnDone } = await resolveActionClient(opts); const { client, stopOnDone } = await resolveActionClient(opts);
try { try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId); const resolvedRoom = await resolveMatrixRoomId(client, roomId);
// matrix-bot-sdk uses getRoomState for state events // @vector-im/matrix-bot-sdk uses getRoomState for state events
let name: string | null = null; let name: string | null = null;
let topic: string | null = null; let topic: string | null = null;
let canonicalAlias: string | null = null; let canonicalAlias: string | null = null;

View File

@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { import {
EventType, EventType,

View File

@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
export const MsgType = { export const MsgType = {
Text: "m.text", Text: "m.text",

View File

@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
let activeClient: MatrixClient | null = null; let activeClient: MatrixClient | null = null;

View File

@ -1,4 +1,4 @@
import { MatrixClient } from "matrix-bot-sdk"; import { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { CoreConfig } from "../types.js"; import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../../runtime.js"; import { getMatrixRuntime } from "../../runtime.js";

View File

@ -5,8 +5,8 @@ import {
MatrixClient, MatrixClient,
SimpleFsStorageProvider, SimpleFsStorageProvider,
RustSdkCryptoStorageProvider, RustSdkCryptoStorageProvider,
} from "matrix-bot-sdk"; } from "@vector-im/matrix-bot-sdk";
import type { IStorageProvider, ICryptoStorageProvider } from "matrix-bot-sdk"; import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import { import {

View File

@ -1,4 +1,4 @@
import { ConsoleLogger, LogService } from "matrix-bot-sdk"; import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk";
let matrixSdkLoggingConfigured = false; let matrixSdkLoggingConfigured = false;
const matrixSdkBaseLogger = new ConsoleLogger(); const matrixSdkBaseLogger = new ConsoleLogger();

View File

@ -1,5 +1,5 @@
import { LogService } from "matrix-bot-sdk"; import { LogService } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { CoreConfig } from "../types.js"; import type { CoreConfig } from "../types.js";
import { createMatrixClient } from "./create-client.js"; import { createMatrixClient } from "./create-client.js";
@ -157,7 +157,7 @@ export async function waitForMatrixSync(_params: {
timeoutMs?: number; timeoutMs?: number;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
}): Promise<void> { }): Promise<void> {
// matrix-bot-sdk handles sync internally in start() // @vector-im/matrix-bot-sdk handles sync internally in start()
// This is kept for API compatibility but is essentially a no-op now // This is kept for API compatibility but is essentially a no-op now
} }

View File

@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
import type { RuntimeEnv } from "clawdbot/plugin-sdk"; import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js"; import { getMatrixRuntime } from "../runtime.js";
const MATRIX_SDK_PACKAGE = "matrix-bot-sdk"; const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
export function isMatrixSdkAvailable(): boolean { export function isMatrixSdkAvailable(): boolean {
try { try {
@ -30,9 +30,9 @@ export async function ensureMatrixSdkInstalled(params: {
if (isMatrixSdkAvailable()) return; if (isMatrixSdkAvailable()) return;
const confirm = params.confirm; const confirm = params.confirm;
if (confirm) { if (confirm) {
const ok = await confirm("Matrix requires matrix-bot-sdk. Install now?"); const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?");
if (!ok) { if (!ok) {
throw new Error("Matrix requires matrix-bot-sdk (install dependencies first)."); throw new Error("Matrix requires @vector-im/matrix-bot-sdk (install dependencies first).");
} }
} }
@ -52,6 +52,6 @@ export async function ensureMatrixSdkInstalled(params: {
); );
} }
if (!isMatrixSdkAvailable()) { if (!isMatrixSdkAvailable()) {
throw new Error("Matrix dependency install completed but matrix-bot-sdk is still missing."); throw new Error("Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing.");
} }
} }

View File

@ -1,5 +1,5 @@
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { AutojoinRoomsMixin } from "matrix-bot-sdk"; import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk";
import type { RuntimeEnv } from "clawdbot/plugin-sdk"; import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "../../types.js"; import type { CoreConfig } from "../../types.js";

View File

@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
type DirectMessageCheck = { type DirectMessageCheck = {
roomId: string; roomId: string;

View File

@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { PluginRuntime } from "clawdbot/plugin-sdk"; import type { PluginRuntime } from "clawdbot/plugin-sdk";
import type { MatrixAuth } from "../client.js"; import type { MatrixAuth } from "../client.js";

View File

@ -1,4 +1,4 @@
import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk"; import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
import { import {
createReplyPrefixContext, createReplyPrefixContext,
@ -110,7 +110,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
try { try {
const eventType = event.type; const eventType = event.type;
if (eventType === EventType.RoomMessageEncrypted) { if (eventType === EventType.RoomMessageEncrypted) {
// Encrypted messages are decrypted automatically by matrix-bot-sdk with crypto enabled // Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled
return; return;
} }
@ -436,7 +436,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
threadReplies, threadReplies,
messageId, messageId,
threadRootId, threadRootId,
isThreadRoot: false, // matrix-bot-sdk doesn't have this info readily available isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available
}); });
const route = core.channel.routing.resolveAgentRoute({ const route = core.channel.routing.resolveAgentRoute({

View File

@ -244,7 +244,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}); });
logVerboseMessage("matrix: client started"); logVerboseMessage("matrix: client started");
// matrix-bot-sdk client is already started via resolveSharedMatrixClient // @vector-im/matrix-bot-sdk client is already started via resolveSharedMatrixClient
logger.info(`matrix: logged in as ${auth.userId}`); logger.info(`matrix: logged in as ${auth.userId}`);
// If E2EE is enabled, trigger device verification // If E2EE is enabled, trigger device verification

View File

@ -1,4 +1,4 @@
import type { LocationMessageEventContent } from "matrix-bot-sdk"; import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk";
import { import {
formatLocationText, formatLocationText,

View File

@ -29,7 +29,7 @@ describe("downloadMatrixMedia", () => {
const client = { const client = {
crypto: { decryptMedia }, crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("matrix-bot-sdk").MatrixClient; } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
const file = { const file = {
url: "mxc://example/file", url: "mxc://example/file",
@ -70,7 +70,7 @@ describe("downloadMatrixMedia", () => {
const client = { const client = {
crypto: { decryptMedia }, crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("matrix-bot-sdk").MatrixClient; } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
const file = { const file = {
url: "mxc://example/file", url: "mxc://example/file",

View File

@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { getMatrixRuntime } from "../../runtime.js"; import { getMatrixRuntime } from "../../runtime.js";
@ -22,7 +22,7 @@ async function fetchMatrixMediaBuffer(params: {
mxcUrl: string; mxcUrl: string;
maxBytes: number; maxBytes: number;
}): Promise<{ buffer: Buffer; headerType?: string } | null> { }): Promise<{ buffer: Buffer; headerType?: string } | null> {
// matrix-bot-sdk provides mxcToHttp helper // @vector-im/matrix-bot-sdk provides mxcToHttp helper
const url = params.client.mxcToHttp(params.mxcUrl); const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) return null; if (!url) return null;
@ -40,7 +40,7 @@ async function fetchMatrixMediaBuffer(params: {
/** /**
* Download and decrypt encrypted media from a Matrix room. * Download and decrypt encrypted media from a Matrix room.
* Uses matrix-bot-sdk's decryptMedia which handles both download and decryption. * Uses @vector-im/matrix-bot-sdk's decryptMedia which handles both download and decryption.
*/ */
async function fetchEncryptedMediaBuffer(params: { async function fetchEncryptedMediaBuffer(params: {
client: MatrixClient; client: MatrixClient;

View File

@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk"; import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
import { sendMessageMatrix } from "../send.js"; import { sendMessageMatrix } from "../send.js";

View File

@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
export type MatrixRoomInfo = { export type MatrixRoomInfo = {
name?: string; name?: string;

View File

@ -1,4 +1,4 @@
// Type for raw Matrix event from matrix-bot-sdk // Type for raw Matrix event from @vector-im/matrix-bot-sdk
type MatrixRawEvent = { type MatrixRawEvent = {
event_id: string; event_id: string;
sender: string; sender: string;

View File

@ -1,4 +1,4 @@
import type { EncryptedFile, MessageEventContent } from "matrix-bot-sdk"; import type { EncryptedFile, MessageEventContent } from "@vector-im/matrix-bot-sdk";
export const EventType = { export const EventType = {
RoomMessage: "m.room.message", RoomMessage: "m.room.message",

View File

@ -49,7 +49,7 @@ export async function probeMatrix(params: {
accessToken: params.accessToken, accessToken: params.accessToken,
localTimeoutMs: params.timeoutMs, localTimeoutMs: params.timeoutMs,
}); });
// matrix-bot-sdk uses getUserId() which calls whoami internally // @vector-im/matrix-bot-sdk uses getUserId() which calls whoami internally
const userId = await client.getUserId(); const userId = await client.getUserId();
result.ok = true; result.ok = true;
result.userId = userId ?? null; result.userId = userId ?? null;

View File

@ -3,7 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk"; import type { PluginRuntime } from "clawdbot/plugin-sdk";
import { setMatrixRuntime } from "../runtime.js"; import { setMatrixRuntime } from "../runtime.js";
vi.mock("matrix-bot-sdk", () => ({ vi.mock("@vector-im/matrix-bot-sdk", () => ({
ConsoleLogger: class { ConsoleLogger: class {
trace = vi.fn(); trace = vi.fn();
debug = vi.fn(); debug = vi.fn();
@ -60,7 +60,7 @@ const makeClient = () => {
sendMessage, sendMessage,
uploadContent, uploadContent,
getUserId: vi.fn().mockResolvedValue("@bot:example.org"), getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
} as unknown as import("matrix-bot-sdk").MatrixClient; } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
return { client, sendMessage, uploadContent }; return { client, sendMessage, uploadContent };
}; };

View File

@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { PollInput } from "clawdbot/plugin-sdk"; import type { PollInput } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js"; import { getMatrixRuntime } from "../runtime.js";
@ -72,7 +72,7 @@ export async function sendMessageMatrix(
? buildThreadRelation(threadId, opts.replyToId) ? buildThreadRelation(threadId, opts.replyToId)
: buildReplyRelation(opts.replyToId); : buildReplyRelation(opts.replyToId);
const sendContent = async (content: MatrixOutboundContent) => { const sendContent = async (content: MatrixOutboundContent) => {
// matrix-bot-sdk uses sendMessage differently // @vector-im/matrix-bot-sdk uses sendMessage differently
const eventId = await client.sendMessage(roomId, content); const eventId = await client.sendMessage(roomId, content);
return eventId; return eventId;
}; };
@ -172,7 +172,7 @@ export async function sendPollMatrix(
const pollPayload = threadId const pollPayload = threadId
? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) } ? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) }
: pollContent; : pollContent;
// matrix-bot-sdk sendEvent returns eventId string directly // @vector-im/matrix-bot-sdk sendEvent returns eventId string directly
const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload); const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload);
return { return {

View File

@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { getMatrixRuntime } from "../../runtime.js"; import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient } from "../active-client.js"; import { getActiveMatrixClient } from "../active-client.js";
@ -57,7 +57,7 @@ export async function resolveMatrixClient(opts: {
// Ignore crypto prep failures for one-off sends; normal sync will retry. // Ignore crypto prep failures for one-off sends; normal sync will retry.
} }
} }
// matrix-bot-sdk uses start() instead of startClient() // @vector-im/matrix-bot-sdk uses start() instead of startClient()
await client.start(); await client.start();
return { client, stopOnDone: true }; return { client, stopOnDone: true };
} }

View File

@ -5,7 +5,7 @@ import type {
MatrixClient, MatrixClient,
TimedFileInfo, TimedFileInfo,
VideoFileInfo, VideoFileInfo,
} from "matrix-bot-sdk"; } from "@vector-im/matrix-bot-sdk";
import { parseBuffer, type IFileInfo } from "music-metadata"; import { parseBuffer, type IFileInfo } from "music-metadata";
import { getMatrixRuntime } from "../../runtime.js"; import { getMatrixRuntime } from "../../runtime.js";

View File

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { EventType } from "./types.js"; import { EventType } from "./types.js";
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId; let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;

View File

@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { EventType, type MatrixDirectAccountData } from "./types.js"; import { EventType, type MatrixDirectAccountData } from "./types.js";

View File

@ -6,7 +6,7 @@ import type {
TextualMessageEventContent, TextualMessageEventContent,
TimedFileInfo, TimedFileInfo,
VideoFileInfo, VideoFileInfo,
} from "matrix-bot-sdk"; } from "@vector-im/matrix-bot-sdk";
// Message types // Message types
export const MsgType = { export const MsgType = {
@ -85,7 +85,7 @@ export type MatrixSendResult = {
}; };
export type MatrixSendOpts = { export type MatrixSendOpts = {
client?: import("matrix-bot-sdk").MatrixClient; client?: import("@vector-im/matrix-bot-sdk").MatrixClient;
mediaUrl?: string; mediaUrl?: string;
accountId?: string; accountId?: string;
replyToId?: string; replyToId?: string;

View File

@ -185,7 +185,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
`Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`,
], ],
selectionHint: !sdkReady selectionHint: !sdkReady
? "install matrix-bot-sdk" ? "install @vector-im/matrix-bot-sdk"
: configured : configured
? "configured" ? "configured"
: "needs auth", : "needs auth",

View File

@ -53,7 +53,7 @@ export type MatrixConfig = {
password?: string; password?: string;
/** Optional device name when logging in via password. */ /** Optional device name when logging in via password. */
deviceName?: string; deviceName?: string;
/** Initial sync limit for startup (default: matrix-bot-sdk default). */ /** Initial sync limit for startup (default: @vector-im/matrix-bot-sdk default). */
initialSyncLimit?: number; initialSyncLimit?: number;
/** Enable end-to-end encryption (E2EE). Default: false. */ /** Enable end-to-end encryption (E2EE). Default: false. */
encryption?: boolean; encryption?: boolean;

View File

@ -9,6 +9,6 @@
] ]
}, },
"peerDependencies": { "peerDependencies": {
"clawdbot": ">=2026.1.25" "clawdbot": ">=2026.1.24-3"
} }
} }

View File

@ -42,7 +42,7 @@ export function createMSTeamsReplyDispatcher(params: {
}) { }) {
const core = getMSTeamsRuntime(); const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => { const sendTypingIndicator = async () => {
await params.context.sendActivities([{ type: "typing" }]); await params.context.sendActivity({ type: "typing" });
}; };
const typingCallbacks = createTypingCallbacks({ const typingCallbacks = createTypingCallbacks({
start: sendTypingIndicator, start: sendTypingIndicator,

View File

@ -6,6 +6,7 @@
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deepmerges with core). - Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deepmerges with core).
- Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls. - Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls.
- Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields. - Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields.
- Ngrok free-tier bypass renamed to `tunnel.allowNgrokFreeTierLoopbackBypass` and gated to loopback + `tunnel.provider="ngrok"`.
## 2026.1.23 ## 2026.1.23

View File

@ -74,6 +74,7 @@ Put under `plugins.entries.voice-call.config`:
Notes: Notes:
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL. - Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
- `mock` is a local dev provider (no network calls). - `mock` is a local dev provider (no network calls).
- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
## TTS for calls ## TTS for calls

View File

@ -78,8 +78,8 @@
"label": "ngrok Domain", "label": "ngrok Domain",
"advanced": true "advanced": true
}, },
"tunnel.allowNgrokFreeTier": { "tunnel.allowNgrokFreeTierLoopbackBypass": {
"label": "Allow ngrok Free Tier", "label": "Allow ngrok Free Tier (Loopback Bypass)",
"advanced": true "advanced": true
}, },
"streaming.enabled": { "streaming.enabled": {
@ -330,7 +330,7 @@
"ngrokDomain": { "ngrokDomain": {
"type": "string" "type": "string"
}, },
"allowNgrokFreeTier": { "allowNgrokFreeTierLoopbackBypass": {
"type": "boolean" "type": "boolean"
} }
} }

View File

@ -62,8 +62,8 @@ const voiceCallConfigSchema = {
advanced: true, advanced: true,
}, },
"tunnel.ngrokDomain": { label: "ngrok Domain", advanced: true }, "tunnel.ngrokDomain": { label: "ngrok Domain", advanced: true },
"tunnel.allowNgrokFreeTier": { "tunnel.allowNgrokFreeTierLoopbackBypass": {
label: "Allow ngrok Free Tier", label: "Allow ngrok Free Tier (Loopback Bypass)",
advanced: true, advanced: true,
}, },
"streaming.enabled": { label: "Enable Streaming", advanced: true }, "streaming.enabled": { label: "Enable Streaming", advanced: true },

View File

@ -19,7 +19,7 @@ function createBaseConfig(
maxConcurrentCalls: 1, maxConcurrentCalls: 1,
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" }, serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
tailscale: { mode: "off", path: "/voice/webhook" }, tailscale: { mode: "off", path: "/voice/webhook" },
tunnel: { provider: "none", allowNgrokFreeTier: false }, tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false },
streaming: { streaming: {
enabled: false, enabled: false,
sttProvider: "openai-realtime", sttProvider: "openai-realtime",

View File

@ -217,12 +217,17 @@ export const VoiceCallTunnelConfigSchema = z
/** /**
* Allow ngrok free tier compatibility mode. * Allow ngrok free tier compatibility mode.
* When true, signature verification failures on ngrok-free.app URLs * When true, signature verification failures on ngrok-free.app URLs
* will include extra diagnostics. Signature verification is still required. * will be allowed only for loopback requests (ngrok local agent).
*/ */
allowNgrokFreeTier: z.boolean().default(false), allowNgrokFreeTierLoopbackBypass: z.boolean().default(false),
/**
* Legacy ngrok free tier compatibility mode (deprecated).
* Use allowNgrokFreeTierLoopbackBypass instead.
*/
allowNgrokFreeTier: z.boolean().optional(),
}) })
.strict() .strict()
.default({ provider: "none", allowNgrokFreeTier: false }); .default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false });
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>; export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -419,8 +424,12 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
// Tunnel Config // Tunnel Config
resolved.tunnel = resolved.tunnel ?? { resolved.tunnel = resolved.tunnel ?? {
provider: "none", provider: "none",
allowNgrokFreeTier: false, allowNgrokFreeTierLoopbackBypass: false,
}; };
resolved.tunnel.allowNgrokFreeTierLoopbackBypass =
resolved.tunnel.allowNgrokFreeTierLoopbackBypass ||
resolved.tunnel.allowNgrokFreeTier ||
false;
resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken =
resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN; resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain =

View File

@ -31,8 +31,8 @@ import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
* @see https://www.twilio.com/docs/voice/media-streams * @see https://www.twilio.com/docs/voice/media-streams
*/ */
export interface TwilioProviderOptions { export interface TwilioProviderOptions {
/** Allow ngrok free tier compatibility mode (less secure) */ /** Allow ngrok free tier compatibility mode (loopback only, less secure) */
allowNgrokFreeTier?: boolean; allowNgrokFreeTierLoopbackBypass?: boolean;
/** Override public URL for signature verification */ /** Override public URL for signature verification */
publicUrl?: string; publicUrl?: string;
/** Path for media stream WebSocket (e.g., /voice/stream) */ /** Path for media stream WebSocket (e.g., /voice/stream) */

View File

@ -11,7 +11,8 @@ export function verifyTwilioProviderWebhook(params: {
}): WebhookVerificationResult { }): WebhookVerificationResult {
const result = verifyTwilioWebhook(params.ctx, params.authToken, { const result = verifyTwilioWebhook(params.ctx, params.authToken, {
publicUrl: params.currentPublicUrl || undefined, publicUrl: params.currentPublicUrl || undefined,
allowNgrokFreeTier: params.options.allowNgrokFreeTier ?? false, allowNgrokFreeTierLoopbackBypass:
params.options.allowNgrokFreeTierLoopbackBypass ?? false,
skipVerification: params.options.skipVerification, skipVerification: params.options.skipVerification,
}); });

View File

@ -33,7 +33,19 @@ type Logger = {
debug: (message: string) => void; debug: (message: string) => void;
}; };
function isLoopbackBind(bind: string | undefined): boolean {
if (!bind) return false;
return bind === "127.0.0.1" || bind === "::1" || bind === "localhost";
}
function resolveProvider(config: VoiceCallConfig): VoiceCallProvider { function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
const allowNgrokFreeTierLoopbackBypass =
config.tunnel?.provider === "ngrok" &&
isLoopbackBind(config.serve?.bind) &&
(config.tunnel?.allowNgrokFreeTierLoopbackBypass ||
config.tunnel?.allowNgrokFreeTier ||
false);
switch (config.provider) { switch (config.provider) {
case "telnyx": case "telnyx":
return new TelnyxProvider({ return new TelnyxProvider({
@ -48,7 +60,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
authToken: config.twilio?.authToken, authToken: config.twilio?.authToken,
}, },
{ {
allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? false, allowNgrokFreeTierLoopbackBypass,
publicUrl: config.publicUrl, publicUrl: config.publicUrl,
skipVerification: config.skipSignatureVerification, skipVerification: config.skipSignatureVerification,
streamPath: config.streaming?.enabled streamPath: config.streaming?.enabled

View File

@ -180,6 +180,7 @@ export type WebhookContext = {
url: string; url: string;
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
query?: Record<string, string | string[] | undefined>; query?: Record<string, string | string[] | undefined>;
remoteAddress?: string;
}; };
export type ProviderWebhookParseResult = { export type ProviderWebhookParseResult = {

View File

@ -221,13 +221,40 @@ describe("verifyTwilioWebhook", () => {
rawBody: postBody, rawBody: postBody,
url: "http://127.0.0.1:3334/voice/webhook", url: "http://127.0.0.1:3334/voice/webhook",
method: "POST", method: "POST",
remoteAddress: "203.0.113.10",
}, },
authToken, authToken,
{ allowNgrokFreeTier: true }, { allowNgrokFreeTierLoopbackBypass: true },
); );
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.isNgrokFreeTier).toBe(true); expect(result.isNgrokFreeTier).toBe(true);
expect(result.reason).toMatch(/Invalid signature/); expect(result.reason).toMatch(/Invalid signature/);
}); });
it("allows invalid signatures for ngrok free tier only on loopback", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const result = verifyTwilioWebhook(
{
headers: {
host: "127.0.0.1:3334",
"x-forwarded-proto": "https",
"x-forwarded-host": "local.ngrok-free.app",
"x-twilio-signature": "invalid",
},
rawBody: postBody,
url: "http://127.0.0.1:3334/voice/webhook",
method: "POST",
remoteAddress: "127.0.0.1",
},
authToken,
{ allowNgrokFreeTierLoopbackBypass: true },
);
expect(result.ok).toBe(true);
expect(result.isNgrokFreeTier).toBe(true);
expect(result.reason).toMatch(/compatibility mode/);
});
}); });

View File

@ -131,6 +131,13 @@ function getHeader(
return value; return value;
} }
function isLoopbackAddress(address?: string): boolean {
if (!address) return false;
if (address === "127.0.0.1" || address === "::1") return true;
if (address.startsWith("::ffff:127.")) return true;
return false;
}
/** /**
* Result of Twilio webhook verification with detailed info. * Result of Twilio webhook verification with detailed info.
*/ */
@ -155,8 +162,8 @@ export function verifyTwilioWebhook(
options?: { options?: {
/** Override the public URL (e.g., from config) */ /** Override the public URL (e.g., from config) */
publicUrl?: string; publicUrl?: string;
/** Allow ngrok free tier compatibility mode (less secure) */ /** Allow ngrok free tier compatibility mode (loopback only, less secure) */
allowNgrokFreeTier?: boolean; allowNgrokFreeTierLoopbackBypass?: boolean;
/** Skip verification entirely (only for development) */ /** Skip verification entirely (only for development) */
skipVerification?: boolean; skipVerification?: boolean;
}, },
@ -195,6 +202,22 @@ export function verifyTwilioWebhook(
verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok-free.app") ||
verificationUrl.includes(".ngrok.io"); verificationUrl.includes(".ngrok.io");
if (
isNgrokFreeTier &&
options?.allowNgrokFreeTierLoopbackBypass &&
isLoopbackAddress(ctx.remoteAddress)
) {
console.warn(
"[voice-call] Twilio signature validation failed (ngrok free tier compatibility, loopback only)",
);
return {
ok: true,
reason: "ngrok free tier compatibility mode (loopback only)",
verificationUrl,
isNgrokFreeTier: true,
};
}
return { return {
ok: false, ok: false,
reason: `Invalid signature for URL: ${verificationUrl}`, reason: `Invalid signature for URL: ${verificationUrl}`,

View File

@ -252,6 +252,7 @@ export class VoiceCallWebhookServer {
url: `http://${req.headers.host}${req.url}`, url: `http://${req.headers.host}${req.url}`,
method: "POST", method: "POST",
query: Object.fromEntries(url.searchParams), query: Object.fromEntries(url.searchParams),
remoteAddress: req.socket.remoteAddress ?? undefined,
}; };
// Verify signature // Verify signature

View File

@ -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": {

196
pnpm-lock.yaml generated
View File

@ -172,13 +172,6 @@ importers:
zod: zod:
specifier: ^4.3.6 specifier: ^4.3.6
version: 4.3.6 version: 4.3.6
optionalDependencies:
'@napi-rs/canvas':
specifier: ^0.1.88
version: 0.1.88
node-llama-cpp:
specifier: 3.15.0
version: 3.15.0(typescript@5.9.3)
devDependencies: devDependencies:
'@grammyjs/types': '@grammyjs/types':
specifier: ^3.23.0 specifier: ^3.23.0
@ -261,6 +254,13 @@ importers:
wireit: wireit:
specifier: ^0.14.12 specifier: ^0.14.12
version: 0.14.12 version: 0.14.12
optionalDependencies:
'@napi-rs/canvas':
specifier: ^0.1.88
version: 0.1.88
node-llama-cpp:
specifier: 3.15.0
version: 3.15.0(typescript@5.9.3)
extensions/bluebubbles: {} extensions/bluebubbles: {}
@ -335,12 +335,12 @@ importers:
'@matrix-org/matrix-sdk-crypto-nodejs': '@matrix-org/matrix-sdk-crypto-nodejs':
specifier: ^0.4.0 specifier: ^0.4.0
version: 0.4.0 version: 0.4.0
'@vector-im/matrix-bot-sdk':
specifier: 0.8.0-element.3
version: 0.8.0-element.3
markdown-it: markdown-it:
specifier: 14.1.0 specifier: 14.1.0
version: 14.1.0 version: 14.1.0
matrix-bot-sdk:
specifier: 0.8.0
version: 0.8.0
music-metadata: music-metadata:
specifier: ^11.10.6 specifier: ^11.10.6
version: 11.10.6 version: 11.10.6
@ -357,8 +357,8 @@ importers:
extensions/memory-core: extensions/memory-core:
dependencies: dependencies:
clawdbot: clawdbot:
specifier: '>=2026.1.25' specifier: '>=2026.1.24-3'
version: link:../.. version: 2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3)
extensions/memory-lancedb: extensions/memory-lancedb:
dependencies: dependencies:
@ -1316,6 +1316,7 @@ packages:
'@lancedb/lancedb@0.23.0': '@lancedb/lancedb@0.23.0':
resolution: {integrity: sha512-aYrIoEG24AC+wILCL57Ius/Y4yU+xFHDPKLvmjzzN4byAjzeIGF0TC86S5RBt4Ji+dxS7yIWV5Q/gE5/fybIFQ==} resolution: {integrity: sha512-aYrIoEG24AC+wILCL57Ius/Y4yU+xFHDPKLvmjzzN4byAjzeIGF0TC86S5RBt4Ji+dxS7yIWV5Q/gE5/fybIFQ==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
cpu: [x64, arm64]
os: [darwin, linux, win32] os: [darwin, linux, win32]
peerDependencies: peerDependencies:
apache-arrow: '>=15.0.0 <=18.1.0' apache-arrow: '>=15.0.0 <=18.1.0'
@ -2667,6 +2668,9 @@ packages:
'@types/bun@1.3.6': '@types/bun@1.3.6':
resolution: {integrity: sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==} resolution: {integrity: sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==}
'@types/caseless@0.12.5':
resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==}
'@types/chai@5.2.3': '@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@ -2748,6 +2752,9 @@ packages:
'@types/range-parser@1.2.7': '@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/request@2.48.13':
resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==}
'@types/retry@0.12.0': '@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
@ -2766,6 +2773,9 @@ packages:
'@types/serve-static@2.2.0': '@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@ -2822,6 +2832,10 @@ packages:
'@urbit/http-api@3.0.0': '@urbit/http-api@3.0.0':
resolution: {integrity: sha512-EmyPbWHWXhfYQ/9wWFcLT53VvCn8ct9ljd6QEe+UBjNPEhUPOFBLpDsDp3iPLQgg8ykSU8JMMHxp95LHCorExA==} resolution: {integrity: sha512-EmyPbWHWXhfYQ/9wWFcLT53VvCn8ct9ljd6QEe+UBjNPEhUPOFBLpDsDp3iPLQgg8ykSU8JMMHxp95LHCorExA==}
'@vector-im/matrix-bot-sdk@0.8.0-element.3':
resolution: {integrity: sha512-2FFo/Kz2vTnOZDv59Q0s803LHf7KzuQ2EwOYYAtO0zUKJ8pV5CPsVC/IHyFb+Fsxl3R9XWFiX529yhslb4v9cQ==}
engines: {node: '>=22.0.0'}
'@vitest/browser-playwright@4.0.18': '@vitest/browser-playwright@4.0.18':
resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==} resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==}
peerDependencies: peerDependencies:
@ -3194,6 +3208,11 @@ packages:
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
clawdbot@2026.1.24-3:
resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==}
engines: {node: '>=22.12.0'}
hasBin: true
cli-cursor@5.0.0: cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -3611,6 +3630,10 @@ packages:
resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
engines: {node: '>= 0.12'} engines: {node: '>= 0.12'}
form-data@2.5.5:
resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==}
engines: {node: '>= 0.12'}
form-data@4.0.5: form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@ -4235,10 +4258,6 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
matrix-bot-sdk@0.8.0:
resolution: {integrity: sha512-sCY5UvZfsZhJdCjSc8wZhGhIHOe5cSFSILxx9Zp5a/NEXtmQ6W/bIhefIk4zFAZXetFwXsgvKh1960k1hG5WDw==}
engines: {node: '>=22.0.0'}
mdurl@2.0.0: mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
@ -8419,6 +8438,8 @@ snapshots:
bun-types: 1.3.6 bun-types: 1.3.6
optional: true optional: true
'@types/caseless@0.12.5': {}
'@types/chai@5.2.3': '@types/chai@5.2.3':
dependencies: dependencies:
'@types/deep-eql': 4.0.2 '@types/deep-eql': 4.0.2
@ -8511,6 +8532,13 @@ snapshots:
'@types/range-parser@1.2.7': {} '@types/range-parser@1.2.7': {}
'@types/request@2.48.13':
dependencies:
'@types/caseless': 0.12.5
'@types/node': 25.0.10
'@types/tough-cookie': 4.0.5
form-data: 2.5.5
'@types/retry@0.12.0': {} '@types/retry@0.12.0': {}
'@types/retry@0.12.5': {} '@types/retry@0.12.5': {}
@ -8535,6 +8563,8 @@ snapshots:
'@types/http-errors': 2.0.5 '@types/http-errors': 2.0.5
'@types/node': 25.0.10 '@types/node': 25.0.10
'@types/tough-cookie@4.0.5': {}
'@types/trusted-types@2.0.7': {} '@types/trusted-types@2.0.7': {}
'@types/ws@8.18.1': '@types/ws@8.18.1':
@ -8588,6 +8618,30 @@ snapshots:
browser-or-node: 1.3.0 browser-or-node: 1.3.0
core-js: 3.48.0 core-js: 3.48.0
'@vector-im/matrix-bot-sdk@0.8.0-element.3':
dependencies:
'@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0
'@types/express': 4.17.25
'@types/request': 2.48.13
another-json: 0.2.0
async-lock: 1.4.1
chalk: 4.1.2
express: 4.22.1
glob-to-regexp: 0.4.1
hash.js: 1.1.7
html-to-text: 9.0.5
htmlencode: 0.0.4
lowdb: 1.0.0
lru-cache: 10.4.3
mkdirp: 3.0.1
morgan: 1.10.1
postgres: 3.4.8
request: 2.88.2
request-promise: 4.2.6(request@2.88.2)
sanitize-html: 2.17.0
transitivePeerDependencies:
- supports-color
'@vitest/browser-playwright@4.0.18(playwright@1.58.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': '@vitest/browser-playwright@4.0.18(playwright@1.58.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)':
dependencies: dependencies:
'@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
@ -9038,6 +9092,84 @@ snapshots:
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1
clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3):
dependencies:
'@agentclientprotocol/sdk': 0.13.1(zod@4.3.6)
'@aws-sdk/client-bedrock': 3.975.0
'@buape/carbon': 0.14.0(hono@4.11.4)
'@clack/prompts': 0.11.0
'@grammyjs/runner': 2.0.3(grammy@1.39.3)
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3)
'@homebridge/ciao': 1.3.4
'@line/bot-sdk': 10.6.0
'@lydell/node-pty': 1.2.0-beta.3
'@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.49.3
'@mozilla/readability': 0.6.0
'@sinclair/typebox': 0.34.47
'@slack/bolt': 4.6.0(@types/express@5.0.6)
'@slack/web-api': 7.13.0
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
ajv: 8.17.1
body-parser: 2.2.2
chalk: 5.6.2
chokidar: 5.0.0
chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482)
cli-highlight: 2.1.11
commander: 14.0.2
croner: 9.1.0
detect-libc: 2.1.2
discord-api-types: 0.38.37
dotenv: 17.2.3
express: 5.2.1
file-type: 21.3.0
grammy: 1.39.3
hono: 4.11.4
jiti: 2.6.1
json5: 2.2.3
jszip: 3.10.1
linkedom: 0.18.12
long: 5.3.2
markdown-it: 14.1.0
node-edge-tts: 1.2.9
osc-progress: 0.3.0
pdfjs-dist: 5.4.530
playwright-core: 1.58.0
proper-lockfile: 4.1.2
qrcode-terminal: 0.12.0
sharp: 0.34.5
sqlite-vec: 0.1.7-alpha.2
tar: 7.5.4
tslog: 4.10.2
undici: 7.19.0
ws: 8.19.0
yaml: 2.8.2
zod: 4.3.6
optionalDependencies:
'@napi-rs/canvas': 0.1.88
node-llama-cpp: 3.15.0(typescript@5.9.3)
transitivePeerDependencies:
- '@discordjs/opus'
- '@modelcontextprotocol/sdk'
- '@types/express'
- audio-decode
- aws-crt
- bufferutil
- canvas
- debug
- devtools-protocol
- encoding
- ffmpeg-static
- jimp
- link-preview-js
- node-opus
- opusscript
- supports-color
- typescript
- utf-8-validate
cli-cursor@5.0.0: cli-cursor@5.0.0:
dependencies: dependencies:
restore-cursor: 5.1.0 restore-cursor: 5.1.0
@ -9518,6 +9650,15 @@ snapshots:
combined-stream: 1.0.8 combined-stream: 1.0.8
mime-types: 2.1.35 mime-types: 2.1.35
form-data@2.5.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
safe-buffer: 5.2.1
form-data@4.0.5: form-data@4.0.5:
dependencies: dependencies:
asynckit: 0.4.0 asynckit: 0.4.0
@ -10197,29 +10338,6 @@ snapshots:
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
matrix-bot-sdk@0.8.0:
dependencies:
'@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0
'@types/express': 4.17.25
another-json: 0.2.0
async-lock: 1.4.1
chalk: 4.1.2
express: 4.22.1
glob-to-regexp: 0.4.1
hash.js: 1.1.7
html-to-text: 9.0.5
htmlencode: 0.0.4
lowdb: 1.0.0
lru-cache: 10.4.3
mkdirp: 3.0.1
morgan: 1.10.1
postgres: 3.4.8
request: 2.88.2
request-promise: 4.2.6(request@2.88.2)
sanitize-html: 2.17.0
transitivePeerDependencies:
- supports-color
mdurl@2.0.0: {} mdurl@2.0.0: {}
media-typer@0.3.0: {} media-typer@0.3.0: {}

View File

@ -14,9 +14,14 @@ Generate
uv run {baseDir}/scripts/generate_image.py --prompt "your image description" --filename "output.png" --resolution 1K uv run {baseDir}/scripts/generate_image.py --prompt "your image description" --filename "output.png" --resolution 1K
``` ```
Edit Edit (single image)
```bash ```bash
uv run {baseDir}/scripts/generate_image.py --prompt "edit instructions" --filename "output.png" --input-image "/path/in.png" --resolution 2K uv run {baseDir}/scripts/generate_image.py --prompt "edit instructions" --filename "output.png" -i "/path/in.png" --resolution 2K
```
Multi-image composition (up to 14 images)
```bash
uv run {baseDir}/scripts/generate_image.py --prompt "combine these into one scene" --filename "output.png" -i img1.png -i img2.png -i img3.png
``` ```
API key API key

View File

@ -11,6 +11,9 @@ Generate images using Google's Nano Banana Pro (Gemini 3 Pro Image) API.
Usage: Usage:
uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY] uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY]
Multi-image editing (up to 14 images):
uv run generate_image.py --prompt "combine these images" --filename "output.png" -i img1.png -i img2.png -i img3.png
""" """
import argparse import argparse
@ -42,7 +45,10 @@ def main():
) )
parser.add_argument( parser.add_argument(
"--input-image", "-i", "--input-image", "-i",
help="Optional input image path for editing/modification" action="append",
dest="input_images",
metavar="IMAGE",
help="Input image path(s) for editing/composition. Can be specified multiple times (up to 14 images)."
) )
parser.add_argument( parser.add_argument(
"--resolution", "-r", "--resolution", "-r",
@ -78,34 +84,43 @@ def main():
output_path = Path(args.filename) output_path = Path(args.filename)
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
# Load input image if provided # Load input images if provided (up to 14 supported by Nano Banana Pro)
input_image = None input_images = []
output_resolution = args.resolution output_resolution = args.resolution
if args.input_image: if args.input_images:
try: if len(args.input_images) > 14:
input_image = PILImage.open(args.input_image) print(f"Error: Too many input images ({len(args.input_images)}). Maximum is 14.", file=sys.stderr)
print(f"Loaded input image: {args.input_image}") sys.exit(1)
# Auto-detect resolution if not explicitly set by user max_input_dim = 0
if args.resolution == "1K": # Default value for img_path in args.input_images:
# Map input image size to resolution try:
width, height = input_image.size img = PILImage.open(img_path)
max_dim = max(width, height) input_images.append(img)
if max_dim >= 3000: print(f"Loaded input image: {img_path}")
# Track largest dimension for auto-resolution
width, height = img.size
max_input_dim = max(max_input_dim, width, height)
except Exception as e:
print(f"Error loading input image '{img_path}': {e}", file=sys.stderr)
sys.exit(1)
# Auto-detect resolution from largest input if not explicitly set
if args.resolution == "1K" and max_input_dim > 0: # Default value
if max_input_dim >= 3000:
output_resolution = "4K" output_resolution = "4K"
elif max_dim >= 1500: elif max_input_dim >= 1500:
output_resolution = "2K" output_resolution = "2K"
else: else:
output_resolution = "1K" output_resolution = "1K"
print(f"Auto-detected resolution: {output_resolution} (from input {width}x{height})") print(f"Auto-detected resolution: {output_resolution} (from max input dimension {max_input_dim})")
except Exception as e:
print(f"Error loading input image: {e}", file=sys.stderr)
sys.exit(1)
# Build contents (image first if editing, prompt only if generating) # Build contents (images first if editing, prompt only if generating)
if input_image: if input_images:
contents = [input_image, args.prompt] contents = [*input_images, args.prompt]
print(f"Editing image with resolution {output_resolution}...") img_count = len(input_images)
print(f"Processing {img_count} image{'s' if img_count > 1 else ''} with resolution {output_resolution}...")
else: else:
contents = args.prompt contents = args.prompt
print(f"Generating image with resolution {output_resolution}...") print(f"Generating image with resolution {output_resolution}...")

View File

@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { acquireSessionWriteLock } from "./session-write-lock.js"; import { __testing, acquireSessionWriteLock } from "./session-write-lock.js";
describe("acquireSessionWriteLock", () => { describe("acquireSessionWriteLock", () => {
it("reuses locks across symlinked session paths", async () => { it("reuses locks across symlinked session paths", async () => {
@ -31,4 +31,132 @@ describe("acquireSessionWriteLock", () => {
await fs.rm(root, { recursive: true, force: true }); await fs.rm(root, { recursive: true, force: true });
} }
}); });
it("keeps the lock file until the last release", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
try {
const sessionFile = path.join(root, "sessions.json");
const lockPath = `${sessionFile}.lock`;
const lockA = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
const lockB = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
await expect(fs.access(lockPath)).resolves.toBeUndefined();
await lockA.release();
await expect(fs.access(lockPath)).resolves.toBeUndefined();
await lockB.release();
await expect(fs.access(lockPath)).rejects.toThrow();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("reclaims stale lock files", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
try {
const sessionFile = path.join(root, "sessions.json");
const lockPath = `${sessionFile}.lock`;
await fs.writeFile(
lockPath,
JSON.stringify({ pid: 123456, createdAt: new Date(Date.now() - 60_000).toISOString() }),
"utf8",
);
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, staleMs: 10 });
const raw = await fs.readFile(lockPath, "utf8");
const payload = JSON.parse(raw) as { pid: number };
expect(payload.pid).toBe(process.pid);
await lock.release();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("removes held locks on termination signals", async () => {
const signals = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const;
for (const signal of signals) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-cleanup-"));
try {
const sessionFile = path.join(root, "sessions.json");
const lockPath = `${sessionFile}.lock`;
await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
const keepAlive = () => {};
if (signal === "SIGINT") {
process.on(signal, keepAlive);
}
__testing.handleTerminationSignal(signal);
await expect(fs.stat(lockPath)).rejects.toThrow();
if (signal === "SIGINT") {
process.off(signal, keepAlive);
}
} finally {
await fs.rm(root, { recursive: true, force: true });
}
}
});
it("registers cleanup for SIGQUIT and SIGABRT", () => {
expect(__testing.cleanupSignals).toContain("SIGQUIT");
expect(__testing.cleanupSignals).toContain("SIGABRT");
});
it("cleans up locks on SIGINT without removing other handlers", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
const originalKill = process.kill.bind(process);
const killCalls: Array<NodeJS.Signals | undefined> = [];
let otherHandlerCalled = false;
process.kill = ((pid: number, signal?: NodeJS.Signals) => {
killCalls.push(signal);
return true;
}) as typeof process.kill;
const otherHandler = () => {
otherHandlerCalled = true;
};
process.on("SIGINT", otherHandler);
try {
const sessionFile = path.join(root, "sessions.json");
const lockPath = `${sessionFile}.lock`;
await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
process.emit("SIGINT");
await expect(fs.access(lockPath)).rejects.toThrow();
expect(otherHandlerCalled).toBe(true);
expect(killCalls).toEqual([]);
} finally {
process.off("SIGINT", otherHandler);
process.kill = originalKill;
await fs.rm(root, { recursive: true, force: true });
}
});
it("cleans up locks on exit", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
try {
const sessionFile = path.join(root, "sessions.json");
const lockPath = `${sessionFile}.lock`;
await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
process.emit("exit", 0);
await expect(fs.access(lockPath)).rejects.toThrow();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("keeps other signal listeners registered", () => {
const keepAlive = () => {};
process.on("SIGINT", keepAlive);
__testing.handleTerminationSignal("SIGINT");
expect(process.listeners("SIGINT")).toContain(keepAlive);
process.off("SIGINT", keepAlive);
});
}); });

View File

@ -1,3 +1,4 @@
import fsSync from "node:fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
@ -13,6 +14,9 @@ type HeldLock = {
}; };
const HELD_LOCKS = new Map<string, HeldLock>(); const HELD_LOCKS = new Map<string, HeldLock>();
const CLEANUP_SIGNALS = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const;
type CleanupSignal = (typeof CLEANUP_SIGNALS)[number];
const cleanupHandlers = new Map<CleanupSignal, () => void>();
function isAlive(pid: number): boolean { function isAlive(pid: number): boolean {
if (!Number.isFinite(pid) || pid <= 0) return false; if (!Number.isFinite(pid) || pid <= 0) return false;
@ -24,6 +28,65 @@ function isAlive(pid: number): boolean {
} }
} }
/**
* Synchronously release all held locks.
* Used during process exit when async operations aren't reliable.
*/
function releaseAllLocksSync(): void {
for (const [sessionFile, held] of HELD_LOCKS) {
try {
if (typeof held.handle.fd === "number") {
fsSync.closeSync(held.handle.fd);
}
} catch {
// Ignore errors during cleanup - best effort
}
try {
fsSync.rmSync(held.lockPath, { force: true });
} catch {
// Ignore errors during cleanup - best effort
}
HELD_LOCKS.delete(sessionFile);
}
}
let cleanupRegistered = false;
function handleTerminationSignal(signal: CleanupSignal): void {
releaseAllLocksSync();
const shouldReraise = process.listenerCount(signal) === 1;
if (shouldReraise) {
const handler = cleanupHandlers.get(signal);
if (handler) process.off(signal, handler);
try {
process.kill(process.pid, signal);
} catch {
// Ignore errors during shutdown
}
}
}
function registerCleanupHandlers(): void {
if (cleanupRegistered) return;
cleanupRegistered = true;
// Cleanup on normal exit and process.exit() calls
process.on("exit", () => {
releaseAllLocksSync();
});
// Handle termination signals
for (const signal of CLEANUP_SIGNALS) {
try {
const handler = () => handleTerminationSignal(signal);
cleanupHandlers.set(signal, handler);
process.on(signal, handler);
} catch {
// Ignore unsupported signals on this platform.
}
}
}
async function readLockPayload(lockPath: string): Promise<LockFilePayload | null> { async function readLockPayload(lockPath: string): Promise<LockFilePayload | null> {
try { try {
const raw = await fs.readFile(lockPath, "utf8"); const raw = await fs.readFile(lockPath, "utf8");
@ -43,6 +106,7 @@ export async function acquireSessionWriteLock(params: {
}): Promise<{ }): Promise<{
release: () => Promise<void>; release: () => Promise<void>;
}> { }> {
registerCleanupHandlers();
const timeoutMs = params.timeoutMs ?? 10_000; const timeoutMs = params.timeoutMs ?? 10_000;
const staleMs = params.staleMs ?? 30 * 60 * 1000; const staleMs = params.staleMs ?? 30 * 60 * 1000;
const sessionFile = path.resolve(params.sessionFile); const sessionFile = path.resolve(params.sessionFile);
@ -116,3 +180,9 @@ export async function acquireSessionWriteLock(params: {
const owner = payload?.pid ? `pid=${payload.pid}` : "unknown"; const owner = payload?.pid ? `pid=${payload.pid}` : "unknown";
throw new Error(`session file locked (timeout ${timeoutMs}ms): ${owner} ${lockPath}`); throw new Error(`session file locked (timeout ${timeoutMs}ms): ${owner} ${lockPath}`);
} }
export const __testing = {
cleanupSignals: [...CLEANUP_SIGNALS],
handleTerminationSignal,
releaseAllLocksSync,
};

View File

@ -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}`);
} }

View File

@ -181,9 +181,44 @@ function buildChatCommands(): ChatCommandDefinition[] {
defineChatCommand({ defineChatCommand({
key: "tts", key: "tts",
nativeName: "tts", nativeName: "tts",
description: "Configure text-to-speech.", description: "Control text-to-speech (TTS).",
textAlias: "/tts", textAlias: "/tts",
acceptsArgs: true, args: [
{
name: "action",
description: "TTS action",
type: "string",
choices: [
{ value: "on", label: "On" },
{ value: "off", label: "Off" },
{ value: "status", label: "Status" },
{ value: "provider", label: "Provider" },
{ value: "limit", label: "Limit" },
{ value: "summary", label: "Summary" },
{ value: "audio", label: "Audio" },
{ value: "help", label: "Help" },
],
},
{
name: "value",
description: "Provider, limit, or text",
type: "string",
captureRemaining: true,
},
],
argsMenu: {
arg: "action",
title:
"TTS Actions:\n" +
"• On Enable TTS for responses\n" +
"• Off Disable TTS\n" +
"• Status Show current settings\n" +
"• Provider Set voice provider (edge, elevenlabs, openai)\n" +
"• Limit Set max characters for TTS\n" +
"• Summary Toggle AI summary for long texts\n" +
"• Audio Generate TTS from custom text\n" +
"• Help Show usage guide",
},
}), }),
defineChatCommand({ defineChatCommand({
key: "whoami", key: "whoami",

View File

@ -229,7 +229,12 @@ describe("commands registry args", () => {
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode"); expect(menu?.arg.name).toBe("mode");
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]); expect(menu?.choices).toEqual([
{ label: "off", value: "off" },
{ label: "tokens", value: "tokens" },
{ label: "full", value: "full" },
{ label: "cost", value: "cost" },
]);
}); });
it("does not show menus when arg already provided", () => { it("does not show menus when arg already provided", () => {
@ -284,7 +289,10 @@ describe("commands registry args", () => {
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("level"); expect(menu?.arg.name).toBe("level");
expect(menu?.choices).toEqual(["low", "high"]); expect(menu?.choices).toEqual([
{ label: "low", value: "low" },
{ label: "high", value: "high" },
]);
expect(seen?.commandKey).toBe("think"); expect(seen?.commandKey).toBe("think");
expect(seen?.argName).toBe("level"); expect(seen?.argName).toBe("level");
expect(seen?.provider).toBeTruthy(); expect(seen?.provider).toBeTruthy();

View File

@ -255,17 +255,21 @@ function resolveDefaultCommandContext(cfg?: ClawdbotConfig): {
}; };
} }
export type ResolvedCommandArgChoice = { value: string; label: string };
export function resolveCommandArgChoices(params: { export function resolveCommandArgChoices(params: {
command: ChatCommandDefinition; command: ChatCommandDefinition;
arg: CommandArgDefinition; arg: CommandArgDefinition;
cfg?: ClawdbotConfig; cfg?: ClawdbotConfig;
provider?: string; provider?: string;
model?: string; model?: string;
}): string[] { }): ResolvedCommandArgChoice[] {
const { command, arg, cfg } = params; const { command, arg, cfg } = params;
if (!arg.choices) return []; if (!arg.choices) return [];
const provided = arg.choices; const provided = arg.choices;
if (Array.isArray(provided)) return provided; const raw = Array.isArray(provided)
? provided
: (() => {
const defaults = resolveDefaultCommandContext(cfg); const defaults = resolveDefaultCommandContext(cfg);
const context: CommandArgChoiceContext = { const context: CommandArgChoiceContext = {
cfg, cfg,
@ -275,13 +279,17 @@ export function resolveCommandArgChoices(params: {
arg, arg,
}; };
return provided(context); return provided(context);
})();
return raw.map((choice) =>
typeof choice === "string" ? { value: choice, label: choice } : choice,
);
} }
export function resolveCommandArgMenu(params: { export function resolveCommandArgMenu(params: {
command: ChatCommandDefinition; command: ChatCommandDefinition;
args?: CommandArgs; args?: CommandArgs;
cfg?: ClawdbotConfig; cfg?: ClawdbotConfig;
}): { arg: CommandArgDefinition; choices: string[]; title?: string } | null { }): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string } | null {
const { command, args, cfg } = params; const { command, args, cfg } = params;
if (!command.args || !command.argsMenu) return null; if (!command.args || !command.argsMenu) return null;
if (command.argsParsing === "none") return null; if (command.argsParsing === "none") return null;

View File

@ -12,14 +12,16 @@ export type CommandArgChoiceContext = {
arg: CommandArgDefinition; arg: CommandArgDefinition;
}; };
export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => string[]; export type CommandArgChoice = string | { value: string; label: string };
export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => CommandArgChoice[];
export type CommandArgDefinition = { export type CommandArgDefinition = {
name: string; name: string;
description: string; description: string;
type: CommandArgType; type: CommandArgType;
required?: boolean; required?: boolean;
choices?: string[] | CommandArgChoicesProvider; choices?: CommandArgChoice[] | CommandArgChoicesProvider;
captureRemaining?: boolean; captureRemaining?: boolean;
}; };

View File

@ -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>>;

View File

@ -6,20 +6,18 @@ import {
getTtsMaxLength, getTtsMaxLength,
getTtsProvider, getTtsProvider,
isSummarizationEnabled, isSummarizationEnabled,
isTtsEnabled,
isTtsProviderConfigured, isTtsProviderConfigured,
normalizeTtsAutoMode,
resolveTtsAutoMode,
resolveTtsApiKey, resolveTtsApiKey,
resolveTtsConfig, resolveTtsConfig,
resolveTtsPrefsPath, resolveTtsPrefsPath,
resolveTtsProviderOrder,
setLastTtsAttempt, setLastTtsAttempt,
setSummarizationEnabled, setSummarizationEnabled,
setTtsEnabled,
setTtsMaxLength, setTtsMaxLength,
setTtsProvider, setTtsProvider,
textToSpeech, textToSpeech,
} from "../../tts/tts.js"; } from "../../tts/tts.js";
import { updateSessionStore } from "../../config/sessions.js";
type ParsedTtsCommand = { type ParsedTtsCommand = {
action: string; action: string;
@ -40,14 +38,27 @@ function ttsUsage(): ReplyPayload {
// Keep usage in one place so help/validation stays consistent. // Keep usage in one place so help/validation stays consistent.
return { return {
text: text:
"⚙️ Usage: /tts <off|always|inbound|tagged|status|provider|limit|summary|audio> [value]" + `🔊 **TTS (Text-to-Speech) Help**\n\n` +
"\nExamples:\n" + `**Commands:**\n` +
"/tts always\n" + `• /tts on — Enable automatic TTS for replies\n` +
"/tts provider openai\n" + `• /tts off — Disable TTS\n` +
"/tts provider edge\n" + `• /tts status — Show current settings\n` +
"/tts limit 2000\n" + `• /tts provider [name] — View/change provider\n` +
"/tts summary off\n" + `• /tts limit [number] — View/change text limit\n` +
"/tts audio Hello from Clawdbot", `• /tts summary [on|off] — View/change auto-summary\n` +
`• /tts audio <text> — Generate audio from text\n\n` +
`**Providers:**\n` +
`• edge — Free, fast (default)\n` +
`• openai — High quality (requires API key)\n` +
`• elevenlabs — Premium voices (requires API key)\n\n` +
`**Text Limit (default: 1500, max: 4096):**\n` +
`When text exceeds the limit:\n` +
`• Summary ON: AI summarizes, then generates audio\n` +
`• Summary OFF: Truncates text, then generates audio\n\n` +
`**Examples:**\n` +
`/tts provider edge\n` +
`/tts limit 2000\n` +
`/tts audio Hello, this is a test!`,
}; };
} }
@ -72,35 +83,27 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
return { shouldContinue: false, reply: ttsUsage() }; return { shouldContinue: false, reply: ttsUsage() };
} }
const requestedAuto = normalizeTtsAutoMode( if (action === "on") {
action === "on" ? "always" : action === "off" ? "off" : action, setTtsEnabled(prefsPath, true);
); return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
if (requestedAuto) {
const entry = params.sessionEntry;
const sessionKey = params.sessionKey;
const store = params.sessionStore;
if (entry && store && sessionKey) {
entry.ttsAuto = requestedAuto;
entry.updatedAt = Date.now();
store[sessionKey] = entry;
if (params.storePath) {
await updateSessionStore(params.storePath, (store) => {
store[sessionKey] = entry;
});
} }
}
const label = requestedAuto === "always" ? "enabled (always)" : requestedAuto; if (action === "off") {
return { setTtsEnabled(prefsPath, false);
shouldContinue: false, return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
reply: {
text: requestedAuto === "off" ? "🔇 TTS disabled." : `🔊 TTS ${label}.`,
},
};
} }
if (action === "audio") { if (action === "audio") {
if (!args.trim()) { if (!args.trim()) {
return { shouldContinue: false, reply: ttsUsage() }; return {
shouldContinue: false,
reply: {
text:
`🎤 Generate audio from text.\n\n` +
`Usage: /tts audio <text>\n` +
`Example: /tts audio Hello, this is a test!`,
},
};
} }
const start = Date.now(); const start = Date.now();
@ -146,9 +149,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (action === "provider") { if (action === "provider") {
const currentProvider = getTtsProvider(config, prefsPath); const currentProvider = getTtsProvider(config, prefsPath);
if (!args.trim()) { if (!args.trim()) {
const fallback = resolveTtsProviderOrder(currentProvider)
.slice(1)
.filter((provider) => isTtsProviderConfigured(config, provider));
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai")); const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs")); const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
const hasEdge = isTtsProviderConfigured(config, "edge"); const hasEdge = isTtsProviderConfigured(config, "edge");
@ -158,7 +158,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
text: text:
`🎙️ TTS provider\n` + `🎙️ TTS provider\n` +
`Primary: ${currentProvider}\n` + `Primary: ${currentProvider}\n` +
`Fallbacks: ${fallback.join(", ") || "none"}\n` +
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` + `OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` + `ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
`Edge enabled: ${hasEdge ? "✅" : "❌"}\n` + `Edge enabled: ${hasEdge ? "✅" : "❌"}\n` +
@ -173,18 +172,9 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
} }
setTtsProvider(prefsPath, requested); setTtsProvider(prefsPath, requested);
const fallback = resolveTtsProviderOrder(requested)
.slice(1)
.filter((provider) => isTtsProviderConfigured(config, provider));
return { return {
shouldContinue: false, shouldContinue: false,
reply: { reply: { text: `✅ TTS provider set to ${requested}.` },
text:
`✅ TTS provider set to ${requested} (fallbacks: ${fallback.join(", ") || "none"}).` +
(requested === "edge"
? "\nEnable Edge TTS in config: messages.tts.edge.enabled = true."
: ""),
},
}; };
} }
@ -193,12 +183,22 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
const currentLimit = getTtsMaxLength(prefsPath); const currentLimit = getTtsMaxLength(prefsPath);
return { return {
shouldContinue: false, shouldContinue: false,
reply: { text: `📏 TTS limit: ${currentLimit} characters.` }, reply: {
text:
`📏 TTS limit: ${currentLimit} characters.\n\n` +
`Text longer than this triggers summary (if enabled).\n` +
`Range: 100-4096 chars (Telegram max).\n\n` +
`To change: /tts limit <number>\n` +
`Example: /tts limit 2000`,
},
}; };
} }
const next = Number.parseInt(args.trim(), 10); const next = Number.parseInt(args.trim(), 10);
if (!Number.isFinite(next) || next < 100 || next > 10_000) { if (!Number.isFinite(next) || next < 100 || next > 4096) {
return { shouldContinue: false, reply: ttsUsage() }; return {
shouldContinue: false,
reply: { text: "❌ Limit must be between 100 and 4096 characters." },
};
} }
setTtsMaxLength(prefsPath, next); setTtsMaxLength(prefsPath, next);
return { return {
@ -210,9 +210,17 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (action === "summary") { if (action === "summary") {
if (!args.trim()) { if (!args.trim()) {
const enabled = isSummarizationEnabled(prefsPath); const enabled = isSummarizationEnabled(prefsPath);
const maxLen = getTtsMaxLength(prefsPath);
return { return {
shouldContinue: false, shouldContinue: false,
reply: { text: `📝 TTS auto-summary: ${enabled ? "on" : "off"}.` }, reply: {
text:
`📝 TTS auto-summary: ${enabled ? "on" : "off"}.\n\n` +
`When text exceeds ${maxLen} chars:\n` +
`• ON: summarizes text, then generates audio\n` +
`• OFF: truncates text, then generates audio\n\n` +
`To change: /tts summary on | off`,
},
}; };
} }
const requested = args.trim().toLowerCase(); const requested = args.trim().toLowerCase();
@ -229,27 +237,16 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
} }
if (action === "status") { if (action === "status") {
const sessionAuto = params.sessionEntry?.ttsAuto; const enabled = isTtsEnabled(config, prefsPath);
const autoMode = resolveTtsAutoMode({ config, prefsPath, sessionAuto });
const enabled = autoMode !== "off";
const provider = getTtsProvider(config, prefsPath); const provider = getTtsProvider(config, prefsPath);
const hasKey = isTtsProviderConfigured(config, provider); const hasKey = isTtsProviderConfigured(config, provider);
const providerStatus =
provider === "edge"
? hasKey
? "✅ enabled"
: "❌ disabled"
: hasKey
? "✅ key"
: "❌ no key";
const maxLength = getTtsMaxLength(prefsPath); const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath); const summarize = isSummarizationEnabled(prefsPath);
const last = getLastTtsAttempt(); const last = getLastTtsAttempt();
const autoLabel = sessionAuto ? `${autoMode} (session)` : autoMode;
const lines = [ const lines = [
"📊 TTS status", "📊 TTS status",
`Auto: ${enabled ? autoLabel : "off"}`, `State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
`Provider: ${provider} (${providerStatus})`, `Provider: ${provider} (${hasKey ? "✅ configured" : "❌ not configured"})`,
`Text limit: ${maxLength} chars`, `Text limit: ${maxLength} chars`,
`Auto-summary: ${summarize ? "on" : "off"}`, `Auto-summary: ${summarize ? "on" : "off"}`,
]; ];

View File

@ -420,3 +420,17 @@ describe("handleCommands subagents", () => {
expect(result.reply?.text).toContain("Status: done"); expect(result.reply?.text).toContain("Status: done");
}); });
}); });
describe("handleCommands /tts", () => {
it("returns status for bare /tts on text command surfaces", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
messages: { tts: { prefsPath: path.join(testWorkspaceDir, "tts.json") } },
} as ClawdbotConfig;
const params = buildParams("/tts", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("TTS status");
});
});

View File

@ -16,7 +16,7 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js"; import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js"; import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
import { isRoutableChannel, routeReply } from "./route-reply.js"; import { isRoutableChannel, routeReply } from "./route-reply.js";
import { maybeApplyTtsToPayload, normalizeTtsAutoMode } from "../../tts/tts.js"; import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js";
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i; const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
const AUDIO_HEADER_RE = /^\[Audio\b/i; const AUDIO_HEADER_RE = /^\[Audio\b/i;
@ -266,12 +266,26 @@ export async function dispatchReplyFromConfig(params: {
return { queuedFinal, counts }; return { queuedFinal, counts };
} }
// Track accumulated block text for TTS generation after streaming completes.
// When block streaming succeeds, there's no final reply, so we need to generate
// TTS audio separately from the accumulated block content.
let accumulatedBlockText = "";
let blockCount = 0;
const replyResult = await (params.replyResolver ?? getReplyFromConfig)( const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
ctx, ctx,
{ {
...params.replyOptions, ...params.replyOptions,
onBlockReply: (payload: ReplyPayload, context) => { onBlockReply: (payload: ReplyPayload, context) => {
const run = async () => { const run = async () => {
// Accumulate block text for TTS generation after streaming
if (payload.text) {
if (accumulatedBlockText.length > 0) {
accumulatedBlockText += "\n";
}
accumulatedBlockText += payload.text;
blockCount++;
}
const ttsPayload = await maybeApplyTtsToPayload({ const ttsPayload = await maybeApplyTtsToPayload({
payload, payload,
cfg, cfg,
@ -327,6 +341,62 @@ export async function dispatchReplyFromConfig(params: {
queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal; queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal;
} }
} }
const ttsMode = resolveTtsConfig(cfg).mode ?? "final";
// Generate TTS-only reply after block streaming completes (when there's no final reply).
// This handles the case where block streaming succeeds and drops final payloads,
// but we still want TTS audio to be generated from the accumulated block content.
if (
ttsMode === "final" &&
replies.length === 0 &&
blockCount > 0 &&
accumulatedBlockText.trim()
) {
try {
const ttsSyntheticReply = await maybeApplyTtsToPayload({
payload: { text: accumulatedBlockText },
cfg,
channel: ttsChannel,
kind: "final",
inboundAudio,
ttsAuto: sessionTtsAuto,
});
// Only send if TTS was actually applied (mediaUrl exists)
if (ttsSyntheticReply.mediaUrl) {
// Send TTS-only payload (no text, just audio) so it doesn't duplicate the block content
const ttsOnlyPayload: ReplyPayload = {
mediaUrl: ttsSyntheticReply.mediaUrl,
audioAsVoice: ttsSyntheticReply.audioAsVoice,
};
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
const result = await routeReply({
payload: ttsOnlyPayload,
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
accountId: ctx.AccountId,
threadId: ctx.MessageThreadId,
cfg,
});
queuedFinal = result.ok || queuedFinal;
if (result.ok) routedFinalCount += 1;
if (!result.ok) {
logVerbose(
`dispatch-from-config: route-reply (tts-only) failed: ${result.error ?? "unknown error"}`,
);
}
} else {
const didQueue = dispatcher.sendFinalReply(ttsOnlyPayload);
queuedFinal = didQueue || queuedFinal;
}
}
} catch (err) {
logVerbose(
`dispatch-from-config: accumulated block TTS failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
await dispatcher.waitForIdle(); await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts(); const counts = dispatcher.getQueuedCounts();

View File

@ -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;
} }

View File

@ -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();
} }

View File

@ -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();
});
}); });

View File

@ -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,

View File

@ -80,7 +80,9 @@ async function promptTelegramAllowFrom(params: {
if (!token) return null; if (!token) return null;
const username = stripped.startsWith("@") ? stripped : `@${stripped}`; const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`; const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`;
try {
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) return null;
const data = (await res.json().catch(() => null)) as { const data = (await res.json().catch(() => null)) as {
ok?: boolean; ok?: boolean;
result?: { id?: number | string }; result?: { id?: number | string };
@ -88,6 +90,10 @@ async function promptTelegramAllowFrom(params: {
const id = data?.ok ? data?.result?.id : undefined; const id = data?.ok ? data?.result?.id : undefined;
if (typeof id === "number" || typeof id === "string") return String(id); if (typeof id === "number" || typeof id === "string") return String(id);
return null; return null;
} catch {
// Network error during username lookup - return null to prompt user for numeric ID
return null;
}
}; };
const parseInput = (value: string) => const parseInput = (value: string) =>

View File

@ -78,6 +78,48 @@ describe("argv helpers", () => {
}); });
expect(nodeArgv).toEqual(["node", "clawdbot", "status"]); expect(nodeArgv).toEqual(["node", "clawdbot", "status"]);
const versionedNodeArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node-22", "clawdbot", "status"],
});
expect(versionedNodeArgv).toEqual(["node-22", "clawdbot", "status"]);
const versionedNodeWindowsArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node-22.2.0.exe", "clawdbot", "status"],
});
expect(versionedNodeWindowsArgv).toEqual(["node-22.2.0.exe", "clawdbot", "status"]);
const versionedNodePatchlessArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node-22.2", "clawdbot", "status"],
});
expect(versionedNodePatchlessArgv).toEqual(["node-22.2", "clawdbot", "status"]);
const versionedNodeWindowsPatchlessArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node-22.2.exe", "clawdbot", "status"],
});
expect(versionedNodeWindowsPatchlessArgv).toEqual(["node-22.2.exe", "clawdbot", "status"]);
const versionedNodeWithPathArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["/usr/bin/node-22.2.0", "clawdbot", "status"],
});
expect(versionedNodeWithPathArgv).toEqual(["/usr/bin/node-22.2.0", "clawdbot", "status"]);
const nodejsArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["nodejs", "clawdbot", "status"],
});
expect(nodejsArgv).toEqual(["nodejs", "clawdbot", "status"]);
const nonVersionedNodeArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node-dev", "clawdbot", "status"],
});
expect(nonVersionedNodeArgv).toEqual(["node", "clawdbot", "node-dev", "clawdbot", "status"]);
const directArgv = buildParseArgv({ const directArgv = buildParseArgv({
programName: "clawdbot", programName: "clawdbot",
rawArgs: ["clawdbot", "status"], rawArgs: ["clawdbot", "status"],

View File

@ -96,15 +96,27 @@ export function buildParseArgv(params: {
: baseArgv; : baseArgv;
const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase(); const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase();
const looksLikeNode = const looksLikeNode =
normalizedArgv.length >= 2 && normalizedArgv.length >= 2 && (isNodeExecutable(executable) || isBunExecutable(executable));
(executable === "node" ||
executable === "node.exe" ||
executable === "bun" ||
executable === "bun.exe");
if (looksLikeNode) return normalizedArgv; if (looksLikeNode) return normalizedArgv;
return ["node", programName || "clawdbot", ...normalizedArgv]; return ["node", programName || "clawdbot", ...normalizedArgv];
} }
const nodeExecutablePattern = /^node-\d+(?:\.\d+)*(?:\.exe)?$/;
function isNodeExecutable(executable: string): boolean {
return (
executable === "node" ||
executable === "node.exe" ||
executable === "nodejs" ||
executable === "nodejs.exe" ||
nodeExecutablePattern.test(executable)
);
}
function isBunExecutable(executable: string): boolean {
return executable === "bun" || executable === "bun.exe";
}
export function shouldMigrateStateFromPath(path: string[]): boolean { export function shouldMigrateStateFromPath(path: string[]): boolean {
if (path.length === 0) return true; if (path.length === 0) return true;
const [primary, secondary] = path; const [primary, secondary] = path;

View File

@ -11,7 +11,7 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { formatUncaughtError } from "../infra/errors.js"; import { formatUncaughtError } from "../infra/errors.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js"; import { enableConsoleCapture } from "../logging.js";
import { getPrimaryCommand } from "./argv.js"; import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
import { tryRouteCli } from "./route.js"; import { tryRouteCli } from "./route.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] { export function rewriteUpdateFlagArgv(argv: string[]): string[] {
@ -56,6 +56,15 @@ export async function runCli(argv: string[] = process.argv) {
const { registerSubCliByName } = await import("./program/register.subclis.js"); const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary); await registerSubCliByName(program, primary);
} }
const shouldSkipPluginRegistration = !primary && hasHelpOrVersion(parseArgv);
if (!shouldSkipPluginRegistration) {
// Register plugin CLI commands before parsing
const { registerPluginCliCommands } = await import("../plugins/cli.js");
const { loadConfig } = await import("../config/config.js");
registerPluginCliCommands(program, loadConfig());
}
await program.parseAsync(parseArgv); await program.parseAsync(parseArgv);
} }

View File

@ -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":

View File

@ -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;

View File

@ -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(),

View File

@ -93,16 +93,18 @@ function buildDiscordCommandOptions(params: {
typeof focused?.value === "string" ? focused.value.trim().toLowerCase() : ""; typeof focused?.value === "string" ? focused.value.trim().toLowerCase() : "";
const choices = resolveCommandArgChoices({ command, arg, cfg }); const choices = resolveCommandArgChoices({ command, arg, cfg });
const filtered = focusValue const filtered = focusValue
? choices.filter((choice) => choice.toLowerCase().includes(focusValue)) ? choices.filter((choice) => choice.label.toLowerCase().includes(focusValue))
: choices; : choices;
await interaction.respond( await interaction.respond(
filtered.slice(0, 25).map((choice) => ({ name: choice, value: choice })), filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })),
); );
} }
: undefined; : undefined;
const choices = const choices =
resolvedChoices.length > 0 && !autocomplete resolvedChoices.length > 0 && !autocomplete
? resolvedChoices.slice(0, 25).map((choice) => ({ name: choice, value: choice })) ? resolvedChoices
.slice(0, 25)
.map((choice) => ({ name: choice.label, value: choice.value }))
: undefined; : undefined;
return { return {
name: arg.name, name: arg.name,
@ -351,7 +353,11 @@ export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgC
function buildDiscordCommandArgMenu(params: { function buildDiscordCommandArgMenu(params: {
command: ChatCommandDefinition; command: ChatCommandDefinition;
menu: { arg: CommandArgDefinition; choices: string[]; title?: string }; menu: {
arg: CommandArgDefinition;
choices: Array<{ value: string; label: string }>;
title?: string;
};
interaction: CommandInteraction; interaction: CommandInteraction;
cfg: ReturnType<typeof loadConfig>; cfg: ReturnType<typeof loadConfig>;
discordConfig: DiscordConfig; discordConfig: DiscordConfig;
@ -365,11 +371,11 @@ function buildDiscordCommandArgMenu(params: {
const buttons = choices.map( const buttons = choices.map(
(choice) => (choice) =>
new DiscordCommandArgButton({ new DiscordCommandArgButton({
label: choice, label: choice.label,
customId: buildDiscordCommandArgCustomId({ customId: buildDiscordCommandArgCustomId({
command: commandLabel, command: commandLabel,
arg: menu.arg.name, arg: menu.arg.name,
value: choice, value: choice.value,
userId, userId,
}), }),
cfg: params.cfg, cfg: params.cfg,

View File

@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
function readTerminalCss() {
// This test is intentionally simple: it guards against regressions where the
// docs header stops being sticky because sticky elements live inside an
// overflow-clipped container.
const path = join(process.cwd(), "docs", "assets", "terminal.css");
return readFileSync(path, "utf8");
}
describe("docs terminal.css", () => {
test("keeps the docs header sticky (shell is sticky)", () => {
const css = readTerminalCss();
expect(css).toMatch(/\.shell\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*\}/s);
});
test("does not rely on making body overflow visible", () => {
const css = readTerminalCss();
expect(css).not.toMatch(/body\s*\{[^}]*overflow-x:\s*visible;[^}]*\}/s);
});
test("does not make the terminal frame overflow visible (can break layout)", () => {
const css = readTerminalCss();
expect(css).not.toMatch(/\.shell__frame\s*\{[^}]*overflow:\s*visible;[^}]*\}/s);
});
});

View File

@ -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,
}, },
}; };
// Suppress webchat broadcast for heartbeat runs when showOk is false
if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
broadcast("chat", payload, { dropIfSlow: true }); broadcast("chat", payload, { dropIfSlow: true });
}
nodeSendToSession(sessionKey, "chat", payload); nodeSendToSession(sessionKey, "chat", payload);
}; };
@ -158,7 +181,10 @@ export function createAgentEventHandler({
} }
: undefined, : undefined,
}; };
// Suppress webchat broadcast for heartbeat runs when showOk is false
if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
broadcast("chat", payload); broadcast("chat", payload);
}
nodeSendToSession(sessionKey, "chat", payload); nodeSendToSession(sessionKey, "chat", payload);
return; return;
} }

View File

@ -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");
} }
} }

Some files were not shown because too many files have changed in this diff Show More