Compare commits

..
97 changed files with 614 additions and 3724 deletions

No files matched your search

+1 -14
View File
@@ -5,26 +5,13 @@ Docs: https://docs.clawd.bot
## 2026.1.22
### Changes
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
### Breaking
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
### Fixes
- Config: avoid stack traces for invalid configs and log the config path.
- Doctor: warn when gateway.mode is unset with configure/config guidance.
- OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416)
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
- Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
- UI: export config form section metadata for shared usage. (#1418) Thanks @MaudeBot.
- Lobster: fix plugin discovery and harden the tool runtime. (#1152) Thanks @vignesh07.
## 2026.1.21
+22 -26
View File
@@ -471,34 +471,30 @@ by Peter Steinberger and the community.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
[pi-mono](https://github.com/badlogic/pi-mono).
Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a>
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
<a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a>
<a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a>
<a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/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/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/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/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
<a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/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/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a>
<a href="https://github.com/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/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/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a>
<a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a>
<a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a>
<a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/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/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/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/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/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/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/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/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/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a>
<a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a>
<a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a>
<a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a>
<a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=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/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/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/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a>
<a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a>
<a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a>
<a href="https://github.com/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/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a>
<a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
<a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/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/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
<a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/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/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a>
<a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/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/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
<a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/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/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/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/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/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
<a href="https://github.com/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/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/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/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/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/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a>
<a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/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/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/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a>
<a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/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/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/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a>
<a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
<a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
<a href="https://github.com/search?q=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/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/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
<a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
<a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a>
<a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p>
@@ -149,7 +149,6 @@ struct ExecApprovalsResolvedDefaults {
enum ExecApprovalsStore {
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
private static let defaultAgentId = "main"
private static let defaultSecurity: ExecSecurity = .deny
private static let defaultAsk: ExecAsk = .onMiss
private static let defaultAskFallback: ExecSecurity = .deny
@@ -166,22 +165,13 @@ enum ExecApprovalsStore {
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
var agents = file.agents ?? [:]
if let legacyDefault = agents["default"] {
if let main = agents[self.defaultAgentId] {
agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault)
} else {
agents[self.defaultAgentId] = legacyDefault
}
agents.removeValue(forKey: "default")
}
return ExecApprovalsFile(
version: 1,
socket: ExecApprovalsSocketConfig(
path: socketPath.isEmpty ? nil : socketPath,
token: token.isEmpty ? nil : token),
defaults: file.defaults,
agents: agents)
agents: file.agents)
}
static func readSnapshot() -> ExecApprovalsSnapshot {
@@ -282,7 +272,9 @@ enum ExecApprovalsStore {
ask: defaults.ask ?? self.defaultAsk,
askFallback: defaults.askFallback ?? self.defaultAskFallback,
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
let key = self.agentKey(agentId)
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
: "default"
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
let resolvedAgent = ExecApprovalsResolvedDefaults(
@@ -465,36 +457,7 @@ enum ExecApprovalsStore {
private static func agentKey(_ agentId: String?) -> String {
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? self.defaultAgentId : trimmed
}
private static func normalizedPattern(_ pattern: String?) -> String? {
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed.lowercased()
}
private static func mergeAgents(
current: ExecApprovalsAgent,
legacy: ExecApprovalsAgent
) -> ExecApprovalsAgent {
var seen = Set<String>()
var allowlist: [ExecAllowlistEntry] = []
func append(_ entry: ExecAllowlistEntry) {
guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else {
return
}
seen.insert(key)
allowlist.append(entry)
}
for entry in current.allowlist ?? [] { append(entry) }
for entry in legacy.allowlist ?? [] { append(entry) }
return ExecApprovalsAgent(
security: current.security ?? legacy.security,
ask: current.ask ?? legacy.ask,
askFallback: current.askFallback ?? legacy.askFallback,
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
allowlist: allowlist.isEmpty ? nil : allowlist)
return trimmed.isEmpty ? "default" : trimmed
}
}
+3 -149
View File
@@ -8,9 +8,9 @@ read_when:
> "Abandon all hope, ye who enter here."
Updated: 2026-01-21
Updated: 2026-01-16
Status: text + DM attachments are supported; channel/group file sending requires `sharePointSiteId` + Graph permissions (see [Sending files in group chats](#sending-files-in-group-chats)). Polls are sent via Adaptive Cards.
Status: text + DM attachments are supported; channel/group attachments require Microsoft Graph permissions. Polls are sent via Adaptive Cards.
## Plugin required
Microsoft Teams ships as a plugin and is not bundled with the core install.
@@ -403,7 +403,7 @@ Clawdbot handles this by returning quickly and sending replies proactively, but
Teams markdown is more limited than Slack or Discord:
- Basic formatting works: **bold**, *italic*, `code`, links
- Complex markdown (tables, nested lists) may not render correctly
- Adaptive Cards are supported for polls and arbitrary card sends (see below)
- Adaptive Cards are used for polls; other card types are not yet supported
## Configuration
Key settings (see `/gateway/configuration` for shared channel patterns):
@@ -422,7 +422,6 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
- `channels.msteams.teams.<teamId>.requireMention`: per-team override.
- `channels.msteams.teams.<teamId>.channels.<conversationId>.replyStyle`: per-channel override.
- `channels.msteams.teams.<teamId>.channels.<conversationId>.requireMention`: per-channel override.
- `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)).
## Routing & Sessions
- Session keys follow the standard agent format (see [/concepts/session](/concepts/session)):
@@ -472,75 +471,6 @@ Teams recently introduced two channel UI styles over the same underlying data mo
Without Graph permissions, channel messages with images will be received as text-only (the image content is not accessible to the bot).
By default, Clawdbot only downloads media from Microsoft/Teams hostnames. Override with `channels.msteams.mediaAllowHosts` (use `["*"]` to allow any host).
## Sending files in group chats
Bots can send files in DMs using the FileConsentCard flow (built-in). However, **sending files in group chats/channels** requires additional setup:
| Context | How files are sent | Setup needed |
|---------|-------------------|--------------|
| **DMs** | FileConsentCard → user accepts → bot uploads | Works out of the box |
| **Group chats/channels** | Upload to SharePoint → share link | Requires `sharePointSiteId` + Graph permissions |
| **Images (any context)** | Base64-encoded inline | Works out of the box |
### Why group chats need SharePoint
Bots don't have a personal OneDrive drive (the `/me/drive` Graph API endpoint doesn't work for application identities). To send files in group chats/channels, the bot uploads to a **SharePoint site** and creates a sharing link.
### Setup
1. **Add Graph API permissions** in Entra ID (Azure AD) → App Registration:
- `Sites.ReadWrite.All` (Application) - upload files to SharePoint
- `Chat.Read.All` (Application) - optional, enables per-user sharing links
2. **Grant admin consent** for the tenant.
3. **Get your SharePoint site ID:**
```bash
# Via Graph Explorer or curl with a valid token:
curl -H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/sites/{hostname}:/{site-path}"
# Example: for a site at "contoso.sharepoint.com/sites/BotFiles"
curl -H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/BotFiles"
# Response includes: "id": "contoso.sharepoint.com,guid1,guid2"
```
4. **Configure Clawdbot:**
```json5
{
channels: {
msteams: {
// ... other config ...
sharePointSiteId: "contoso.sharepoint.com,guid1,guid2"
}
}
}
```
### Sharing behavior
| Permission | Sharing behavior |
|------------|------------------|
| `Sites.ReadWrite.All` only | Organization-wide sharing link (anyone in org can access) |
| `Sites.ReadWrite.All` + `Chat.Read.All` | Per-user sharing link (only chat members can access) |
Per-user sharing is more secure as only the chat participants can access the file. If `Chat.Read.All` permission is missing, the bot falls back to organization-wide sharing.
### Fallback behavior
| Scenario | Result |
|----------|--------|
| Group chat + file + `sharePointSiteId` configured | Upload to SharePoint, send sharing link |
| Group chat + file + no `sharePointSiteId` | Attempt OneDrive upload (may fail), send text only |
| Personal chat + file | FileConsentCard flow (works without SharePoint) |
| Any context + image | Base64-encoded inline (works without SharePoint) |
### Files stored location
Uploaded files are stored in a `/ClawdbotShared/` folder in the configured SharePoint site's default document library.
## Polls (Adaptive Cards)
Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API).
@@ -549,82 +479,6 @@ Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API)
- The gateway must stay online to record votes.
- Polls do not auto-post result summaries yet (inspect the store file if needed).
## Adaptive Cards (arbitrary)
Send any Adaptive Card JSON to Teams users or conversations using the `message` tool or CLI.
The `card` parameter accepts an Adaptive Card JSON object. When `card` is provided, the message text is optional.
**Agent tool:**
```json
{
"action": "send",
"channel": "msteams",
"target": "user:<id>",
"card": {
"type": "AdaptiveCard",
"version": "1.5",
"body": [{"type": "TextBlock", "text": "Hello!"}]
}
}
```
**CLI:**
```bash
clawdbot message send --channel msteams \
--target "conversation:19:abc...@thread.tacv2" \
--card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello!"}]}'
```
See [Adaptive Cards documentation](https://adaptivecards.io/) for card schema and examples. For target format details, see [Target formats](#target-formats) below.
## Target formats
MSTeams targets use prefixes to distinguish between users and conversations:
| Target type | Format | Example |
|-------------|--------|---------|
| User (by ID) | `user:<aad-object-id>` | `user:40a1a0ed-4ff2-4164-a219-55518990c197` |
| User (by name) | `user:<display-name>` | `user:John Smith` (requires Graph API) |
| Group/channel | `conversation:<conversation-id>` | `conversation:19:abc123...@thread.tacv2` |
| Group/channel (raw) | `<conversation-id>` | `19:abc123...@thread.tacv2` (if contains `@thread`) |
**CLI examples:**
```bash
# Send to a user by ID
clawdbot message send --channel msteams --target "user:40a1a0ed-..." --message "Hello"
# Send to a user by display name (triggers Graph API lookup)
clawdbot message send --channel msteams --target "user:John Smith" --message "Hello"
# Send to a group chat or channel
clawdbot message send --channel msteams --target "conversation:19:abc...@thread.tacv2" --message "Hello"
# Send an Adaptive Card to a conversation
clawdbot message send --channel msteams --target "conversation:19:abc...@thread.tacv2" \
--card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello"}]}'
```
**Agent tool examples:**
```json
{
"action": "send",
"channel": "msteams",
"target": "user:John Smith",
"message": "Hello!"
}
```
```json
{
"action": "send",
"channel": "msteams",
"target": "conversation:19:abc...@thread.tacv2",
"card": {"type": "AdaptiveCard", "version": "1.5", "body": [{"type": "TextBlock", "text": "Hello"}]}
}
```
Note: Without the `user:` prefix, names default to group/team resolution. Always use `user:` when targeting people by display name.
## Proactive messaging
- Proactive messages are only possible **after** a user has interacted, because we store conversation references at that point.
- See `/gateway/configuration` for `dmPolicy` and allowlist gating.
+6 -7
View File
@@ -9,15 +9,15 @@ read_when:
Clawdbot standardizes timestamps so the model sees a **single reference time**.
## Message envelopes (local by default)
## Message envelopes (UTC by default)
Inbound messages are wrapped in an envelope like:
```
[Provider ... 2026-01-05 16:26 PST] message text
[Provider ... 2026-01-05T21:26Z] message text
```
The timestamp in the envelope is **host-local by default**, with minutes precision.
The timestamp in the envelope is **UTC by default**, with minutes precision.
You can override this with:
@@ -25,7 +25,7 @@ You can override this with:
{
agents: {
defaults: {
envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone
envelopeTimestamp: "on", // "on" | "off"
envelopeElapsed: "on" // "on" | "off"
}
@@ -33,7 +33,6 @@ You can override this with:
}
```
- `envelopeTimezone: "utc"` uses UTC.
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
@@ -41,10 +40,10 @@ You can override this with:
### Examples
**Local (default):**
**UTC (default):**
```
[Signal Alice +1555 2026-01-18 00:19 PST] hello
[Signal Alice +1555 2026-01-18T05:19Z] hello
```
**Fixed timezone:**
+11 -13
View File
@@ -7,18 +7,18 @@ read_when:
# Date & Time
Clawdbot defaults to **host-local time for transport timestamps** and **user-local time only in the system prompt**.
Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**.
Provider timestamps are preserved so tools keep their native semantics.
## Message envelopes (local by default)
## Message envelopes (UTC by default)
Inbound messages are wrapped with a timestamp (minute precision):
Inbound messages are wrapped with a UTC timestamp (minute precision):
```
[Provider ... 2026-01-05 16:26 PST] message text
[Provider ... 2026-01-05T21:26Z] message text
```
This envelope timestamp is **host-local by default**, regardless of the provider timezone.
This envelope timestamp is **UTC by default**, regardless of the host timezone.
You can override this behavior:
@@ -26,7 +26,7 @@ You can override this behavior:
{
agents: {
defaults: {
envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone
envelopeTimestamp: "on", // "on" | "off"
envelopeElapsed: "on" // "on" | "off"
}
@@ -34,7 +34,6 @@ You can override this behavior:
}
```
- `envelopeTimezone: "utc"` uses UTC.
- `envelopeTimezone: "local"` uses the host timezone.
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
@@ -43,10 +42,10 @@ You can override this behavior:
### Examples
**Local (default):**
**UTC (default):**
```
[WhatsApp +1555 2026-01-18 00:19 PST] hello
[WhatsApp +1555 2026-01-18T05:19Z] hello
```
**User timezone:**
@@ -74,13 +73,12 @@ Time format: 12-hour
If only the timezone is known, we still include the section and instruct the model
to assume UTC for unknown time references.
## System event lines (local by default)
## System event lines (UTC)
Queued system events inserted into agent context are prefixed with a timestamp using the
same timezone selection as message envelopes (default: host-local).
Queued system events inserted into agent context are prefixed with a UTC timestamp:
```
System: [2026-01-12 12:19:17 PST] Model switched.
System: [2026-01-12T20:19:17Z] Model switched.
```
### Configure user timezone + format
+1
View File
@@ -977,6 +977,7 @@
"plugin",
"plugins/voice-call",
"plugins/zalouser",
"tools/lobster",
"tools/exec",
"tools/web",
"tools/apply-patch",
-1
View File
@@ -88,7 +88,6 @@ If a prompt is required but no UI is reachable, fallback decides:
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
editing in the macOS app. Patterns are **case-insensitive glob matches**.
Patterns should resolve to **binary paths** (basename-only entries are ignored).
Legacy `agents.default` entries are migrated to `agents.main` on load.
Examples:
- `~/Projects/**/bin/bird`
+1 -4
View File
@@ -154,9 +154,6 @@ See [Plugins](/plugin) for install + config, and [Skills](/tools/skills) for how
tool usage guidance is injected into prompts. Some plugins ship their own skills
alongside tools (for example, the voice-call plugin).
Optional plugin tools:
- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
## Tool inventory
### `apply_patch`
@@ -322,7 +319,7 @@ Notes:
Send messages and channel actions across Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams.
Core actions:
- `send` (text + optional media; MS Teams also supports `card` for Adaptive Cards)
- `send` (text + optional media)
- `poll` (WhatsApp/Discord/MS Teams polls)
- `react` / `reactions` / `read` / `edit` / `delete`
- `pin` / `unpin` / `list-pins`
+36 -98
View File
@@ -1,10 +1,6 @@
---
title: Lobster
summary: "Typed workflow runtime for Clawdbot with resumable approval gates."
description: Typed workflow runtime for Clawdbot — composable pipelines with approval gates.
read_when:
- You want deterministic multi-step workflows with explicit approvals
- You need to resume a workflow without re-running earlier steps
---
# Lobster
@@ -15,103 +11,77 @@ Lobster is a workflow shell that lets Clawdbot run multi-step tool sequences as
Today, complex workflows require many back-and-forth tool calls. Each call costs tokens, and the LLM has to orchestrate every step. Lobster moves that orchestration into a typed runtime:
- **One call instead of many**: Clawdbot runs one Lobster tool call and gets a structured result.
- **One call instead of many**: Clawdbot calls `lobster.run(...)` once and gets a structured result.
- **Approvals built in**: Side effects (send email, post comment) halt the workflow until explicitly approved.
- **Resumable**: Halted workflows return a token; approve and resume without re-running everything.
## How it works
Clawdbot launches the local `lobster` CLI in **tool mode** and parses a JSON envelope from stdout.
If the pipeline pauses for approval, the tool returns a `resumeToken` so you can continue later.
## Install Lobster
Install the Lobster CLI on the **same host** that runs the Clawdbot Gateway (see the [Lobster repo](https://github.com/vignesh07/lobster)), and ensure `lobster` is on `PATH`.
If you want to use a custom binary location, pass an **absolute** `lobsterPath` in the tool call.
## Enable the tool
Lobster is an **optional** plugin tool (not enabled by default). Allow it per agent:
```json
{
"agents": {
"list": [
{
"id": "main",
"tools": {
"allow": ["lobster"]
}
}
]
}
}
```
You can also allow it globally with `tools.allow` if every agent should see it.
## Example: Email triage
Without Lobster:
```
User: "Check my email and draft replies"
→ clawd calls gmail.list
→ clawdbot calls gmail.list
→ LLM summarizes
→ User: "draft replies to #2 and #5"
→ LLM drafts
→ User: "send #2"
→ clawd calls gmail.send
→ clawdbot calls gmail.send
(repeat daily, no memory of what was triaged)
```
With Lobster:
```json
{
"action": "run",
"pipeline": "email.triage --limit 20",
"timeoutMs": 30000
}
```
clawdbot calls: lobster.run("email.triage --limit 20")
Returns a JSON envelope (truncated):
```json
Returns:
{
"ok": true,
"status": "needs_approval",
"output": [{ "summary": "5 need replies, 2 need action" }],
"output": {
"summary": "5 need replies, 2 need action",
"drafts": [...]
},
"requiresApproval": {
"type": "approval_request",
"prompt": "Send 2 draft replies?",
"items": [],
"resumeToken": "..."
}
}
```
User approves → resume:
```json
{
"action": "resume",
"token": "<resumeToken>",
"approve": true
}
User approves → clawdbot calls: lobster.resume(token, approve: true)
→ Emails sent
```
One workflow. Deterministic. Safe.
## Tool parameters
## Enable
Lobster is an **optional** plugin tool. Enable it in your agent config:
```json
{
"agents": {
"list": [{
"id": "main",
"tools": {
"allow": ["lobster"]
}
}]
}
}
```
You also need the `lobster` CLI installed locally.
## Actions
### `run`
Run a pipeline in tool mode.
Execute a Lobster pipeline in tool mode.
```json
{
"action": "run",
"pipeline": "gog.gmail.search --query 'newer_than:1d' | email.triage",
"cwd": "/path/to/workspace",
"timeoutMs": 30000,
"maxStdoutBytes": 512000
"timeoutMs": 30000
}
```
@@ -127,45 +97,13 @@ Continue a halted workflow after approval.
}
```
### Optional inputs
- `lobsterPath`: Absolute path to the Lobster binary (omit to use `PATH`).
- `cwd`: Working directory for the pipeline (defaults to the current process working directory).
- `timeoutMs`: Kill the subprocess if it exceeds this duration (default: 20000).
- `maxStdoutBytes`: Kill the subprocess if stdout exceeds this size (default: 512000).
## Output envelope
Lobster returns a JSON envelope with one of three statuses:
- `ok` → finished successfully
- `needs_approval` → paused; `requiresApproval.resumeToken` is required to resume
- `cancelled` → explicitly denied or cancelled
The tool surfaces the envelope in both `content` (pretty JSON) and `details` (raw object).
## Approvals
If `requiresApproval` is present, inspect the prompt and decide:
- `approve: true` → resume and continue side effects
- `approve: false` → cancel and finalize the workflow
## Safety
## Security
- **Local subprocess only** — no network calls from the plugin itself.
- **No secrets** — Lobster doesn't manage OAuth; it calls clawd tools that do.
- **Sandbox-aware** — disabled when the tool context is sandboxed.
- **Sandbox-aware** — disabled when `ctx.sandboxed` is true.
- **Hardened** — `lobsterPath` must be absolute if specified; timeouts and output caps enforced.
## Troubleshooting
- **`lobster subprocess timed out`** → increase `timeoutMs`, or split a long pipeline.
- **`lobster output exceeded maxStdoutBytes`** → raise `maxStdoutBytes` or reduce output size.
- **`lobster returned invalid JSON`** → ensure the pipeline runs in tool mode and prints only JSON.
- **`lobster failed (code …)`** → run the same pipeline in a terminal to inspect stderr.
## Learn more
- [Plugins](/plugin)
- [Plugin tool authoring](/plugins/agent-tools)
- [Lobster repo](https://github.com/vignesh07/lobster) — runtime, commands, and workflow examples.
-2
View File
@@ -1,7 +1,5 @@
{
"id": "lobster",
"name": "Lobster",
"description": "Typed workflow tool with resumable approvals.",
"configSchema": {
"type": "object",
"additionalProperties": false,
+99 -25
View File
@@ -7,32 +7,32 @@ import { describe, expect, it } from "vitest";
import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/plugins/types.js";
import { createLobsterTool } from "./lobster-tool.js";
async function writeFakeLobsterScript(scriptBody: string, prefix = "clawdbot-lobster-plugin-") {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
const isWindows = process.platform === "win32";
if (isWindows) {
const scriptPath = path.join(dir, "lobster.js");
const cmdPath = path.join(dir, "lobster.cmd");
await fs.writeFile(scriptPath, scriptBody, { encoding: "utf8" });
const cmd = `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`;
await fs.writeFile(cmdPath, cmd, { encoding: "utf8" });
return { dir, binPath: cmdPath };
}
async function writeFakeLobster(params: {
payload?: unknown;
stdout?: string;
stderr?: string;
exitCode?: number;
delayMs?: number;
}) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-"));
const binPath = path.join(dir, "lobster");
const file = `#!/usr/bin/env node\n${scriptBody}\n`;
const payload = params.stdout ?? JSON.stringify(params.payload ?? null);
const delay = Math.max(0, params.delayMs ?? 0);
const exitCode = Number.isFinite(params.exitCode) ? params.exitCode : 0;
const stderr = params.stderr ? String(params.stderr) : "";
const file = `#!/usr/bin/env node\n` +
`setTimeout(() => {\n` +
` if (${JSON.stringify(stderr)}.length) process.stderr.write(${JSON.stringify(stderr)});\n` +
` process.stdout.write(${JSON.stringify(payload)});\n` +
` process.exit(${exitCode});\n` +
`}, ${delay});\n`;
await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 });
return { dir, binPath };
}
async function writeFakeLobster(params: { payload: unknown }) {
const scriptBody =
`const payload = ${JSON.stringify(params.payload)};\n` +
`process.stdout.write(JSON.stringify(payload));\n`;
return await writeFakeLobsterScript(scriptBody);
}
function fakeApi(): ClawdbotPluginApi {
return {
id: "lobster",
@@ -95,10 +95,12 @@ describe("lobster plugin tool", () => {
});
it("rejects invalid JSON from lobster", async () => {
const { binPath } = await writeFakeLobsterScript(
`process.stdout.write("nope");\n`,
"clawdbot-lobster-plugin-bad-",
);
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-bad-"));
const binPath = path.join(dir, "lobster");
await fs.writeFile(binPath, `#!/usr/bin/env node\nprocess.stdout.write('nope');\n`, {
encoding: "utf8",
mode: 0o755,
});
const tool = createLobsterTool(fakeApi());
await expect(
@@ -110,6 +112,78 @@ describe("lobster plugin tool", () => {
).rejects.toThrow(/invalid JSON/);
});
it("errors on timeout", async () => {
const fake = await writeFakeLobster({
payload: { ok: true, status: "ok", output: [], requiresApproval: null },
delayMs: 250,
});
const tool = createLobsterTool(fakeApi());
await expect(
tool.execute("call4", {
action: "run",
pipeline: "noop",
lobsterPath: fake.binPath,
timeoutMs: 50,
}),
).rejects.toThrow(/timed out/);
});
it("caps stdout", async () => {
const fake = await writeFakeLobster({
stdout: "x".repeat(2000),
});
const tool = createLobsterTool(fakeApi());
await expect(
tool.execute("call5", {
action: "run",
pipeline: "noop",
lobsterPath: fake.binPath,
maxStdoutBytes: 128,
}),
).rejects.toThrow(/maxStdoutBytes/);
});
it("returns stderr in non-zero exit errors", async () => {
const fake = await writeFakeLobster({
stdout: "",
stderr: "boom",
exitCode: 2,
});
const tool = createLobsterTool(fakeApi());
await expect(
tool.execute("call6", {
action: "run",
pipeline: "noop",
lobsterPath: fake.binPath,
}),
).rejects.toThrow(/boom/);
});
it("aborts via signal", async () => {
const fake = await writeFakeLobster({
payload: { ok: true, status: "ok", output: [], requiresApproval: null },
delayMs: 200,
});
const tool = createLobsterTool(fakeApi());
const controller = new AbortController();
const promise = tool.execute(
"call7",
{
action: "run",
pipeline: "noop",
lobsterPath: fake.binPath,
},
controller.signal,
);
controller.abort();
await expect(promise).rejects.toThrow(/aborted/);
});
it("can be gated off in sandboxed contexts", async () => {
const api = fakeApi();
const factoryTool = (ctx: ClawdbotPluginToolContext) => {
+38 -15
View File
@@ -35,27 +35,49 @@ async function runLobsterSubprocess(params: {
cwd: string;
timeoutMs: number;
maxStdoutBytes: number;
signal?: AbortSignal;
}) {
const { execPath, argv, cwd } = params;
const timeoutMs = Math.max(200, params.timeoutMs);
const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
const env = { ...process.env, LOBSTER_MODE: "tool" } as Record<string, string | undefined>;
const nodeOptions = env.NODE_OPTIONS ?? "";
if (nodeOptions.includes("--inspect")) {
delete env.NODE_OPTIONS;
}
return await new Promise<{ stdout: string }>((resolve, reject) => {
if (params.signal?.aborted) {
reject(new Error("lobster subprocess aborted"));
return;
}
const child = spawn(execPath, argv, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
env,
env: {
...process.env,
LOBSTER_MODE: "tool",
},
});
let stdout = "";
let stdoutBytes = 0;
let stderr = "";
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (params.signal) params.signal.removeEventListener("abort", onAbort);
fn();
};
const onAbort = () => {
try {
child.kill("SIGKILL");
} finally {
finish(() => reject(new Error("lobster subprocess aborted")));
}
};
params.signal?.addEventListener("abort", onAbort);
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
@@ -67,7 +89,7 @@ async function runLobsterSubprocess(params: {
try {
child.kill("SIGKILL");
} finally {
reject(new Error("lobster output exceeded maxStdoutBytes"));
finish(() => reject(new Error("lobster output exceeded maxStdoutBytes")));
}
return;
}
@@ -82,22 +104,22 @@ async function runLobsterSubprocess(params: {
try {
child.kill("SIGKILL");
} finally {
reject(new Error("lobster subprocess timed out"));
finish(() => reject(new Error("lobster subprocess timed out")));
}
}, timeoutMs);
child.once("error", (err) => {
clearTimeout(timer);
reject(err);
finish(() => reject(err));
});
child.once("exit", (code) => {
clearTimeout(timer);
if (code !== 0) {
reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`));
finish(() =>
reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`)),
);
return;
}
resolve({ stdout });
finish(() => resolve({ stdout }));
});
});
}
@@ -138,7 +160,7 @@ export function createLobsterTool(api: ClawdbotPluginApi) {
timeoutMs: Type.Optional(Type.Number()),
maxStdoutBytes: Type.Optional(Type.Number()),
}),
async execute(_id: string, params: Record<string, unknown>) {
async execute(_id: string, params: Record<string, unknown>, signal?: AbortSignal) {
const action = String(params.action || "").trim();
if (!action) throw new Error("action required");
@@ -175,6 +197,7 @@ export function createLobsterTool(api: ClawdbotPluginApi) {
cwd,
timeoutMs,
maxStdoutBytes,
signal,
});
const envelope = parseEnvelope(stdout);
+27 -108
View File
@@ -101,9 +101,9 @@ describe("msteams attachments", () => {
});
});
describe("downloadMSTeamsAttachments", () => {
describe("downloadMSTeamsImageAttachments", () => {
it("downloads and stores image contentUrl attachments", async () => {
const { downloadMSTeamsAttachments } = await load();
const { downloadMSTeamsImageAttachments } = await load();
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
@@ -111,7 +111,7 @@ describe("msteams attachments", () => {
});
});
const media = await downloadMSTeamsAttachments({
const media = await downloadMSTeamsImageAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
@@ -125,7 +125,7 @@ describe("msteams attachments", () => {
});
it("supports Teams file.download.info downloadUrl attachments", async () => {
const { downloadMSTeamsAttachments } = await load();
const { downloadMSTeamsImageAttachments } = await load();
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
@@ -133,7 +133,7 @@ describe("msteams attachments", () => {
});
});
const media = await downloadMSTeamsAttachments({
const media = await downloadMSTeamsImageAttachments({
attachments: [
{
contentType: "application/vnd.microsoft.teams.file.download.info",
@@ -149,35 +149,8 @@ describe("msteams attachments", () => {
expect(media).toHaveLength(1);
});
it("downloads non-image file attachments (PDF)", async () => {
const { downloadMSTeamsAttachments } = await load();
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("pdf"), {
status: 200,
headers: { "content-type": "application/pdf" },
});
});
detectMimeMock.mockResolvedValueOnce("application/pdf");
saveMediaBufferMock.mockResolvedValueOnce({
path: "/tmp/saved.pdf",
contentType: "application/pdf",
});
const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
});
expect(fetchMock).toHaveBeenCalledWith("https://x/doc.pdf");
expect(media).toHaveLength(1);
expect(media[0]?.path).toBe("/tmp/saved.pdf");
expect(media[0]?.placeholder).toBe("<media:document>");
});
it("downloads inline image URLs from html attachments", async () => {
const { downloadMSTeamsAttachments } = await load();
const { downloadMSTeamsImageAttachments } = await load();
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
@@ -185,7 +158,7 @@ describe("msteams attachments", () => {
});
});
const media = await downloadMSTeamsAttachments({
const media = await downloadMSTeamsImageAttachments({
attachments: [
{
contentType: "text/html",
@@ -202,9 +175,9 @@ describe("msteams attachments", () => {
});
it("stores inline data:image base64 payloads", async () => {
const { downloadMSTeamsAttachments } = await load();
const { downloadMSTeamsImageAttachments } = await load();
const base64 = Buffer.from("png").toString("base64");
const media = await downloadMSTeamsAttachments({
const media = await downloadMSTeamsImageAttachments({
attachments: [
{
contentType: "text/html",
@@ -220,7 +193,7 @@ describe("msteams attachments", () => {
});
it("retries with auth when the first request is unauthorized", async () => {
const { downloadMSTeamsAttachments } = await load();
const { downloadMSTeamsImageAttachments } = await load();
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
const hasAuth = Boolean(
opts &&
@@ -237,7 +210,7 @@ describe("msteams attachments", () => {
});
});
const media = await downloadMSTeamsAttachments({
const media = await downloadMSTeamsImageAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
maxBytes: 1024 * 1024,
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
@@ -251,9 +224,9 @@ describe("msteams attachments", () => {
});
it("skips urls outside the allowlist", async () => {
const { downloadMSTeamsAttachments } = await load();
const { downloadMSTeamsImageAttachments } = await load();
const fetchMock = vi.fn();
const media = await downloadMSTeamsAttachments({
const media = await downloadMSTeamsImageAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }],
maxBytes: 1024 * 1024,
allowHosts: ["graph.microsoft.com"],
@@ -263,6 +236,20 @@ describe("msteams attachments", () => {
expect(media).toHaveLength(0);
expect(fetchMock).not.toHaveBeenCalled();
});
it("ignores non-image attachments", async () => {
const { downloadMSTeamsImageAttachments } = await load();
const fetchMock = vi.fn();
const media = await downloadMSTeamsImageAttachments({
attachments: [{ contentType: "application/pdf", contentUrl: "https://x/x.pdf" }],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
});
expect(media).toHaveLength(0);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("buildMSTeamsGraphMessageUrls", () => {
@@ -337,74 +324,6 @@ describe("msteams attachments", () => {
expect(fetchMock).toHaveBeenCalled();
expect(saveMediaBufferMock).toHaveBeenCalled();
});
it("merges SharePoint reference attachments with hosted content", async () => {
const { downloadMSTeamsGraphMedia } = await load();
const hostedBase64 = Buffer.from("png").toString("base64");
const shareUrl = "https://contoso.sharepoint.com/site/file";
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith("/hostedContents")) {
return new Response(
JSON.stringify({
value: [
{
id: "hosted-1",
contentType: "image/png",
contentBytes: hostedBase64,
},
],
}),
{ status: 200 },
);
}
if (url.endsWith("/attachments")) {
return new Response(
JSON.stringify({
value: [
{
id: "ref-1",
contentType: "reference",
contentUrl: shareUrl,
name: "report.pdf",
},
],
}),
{ status: 200 },
);
}
if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) {
return new Response(Buffer.from("pdf"), {
status: 200,
headers: { "content-type": "application/pdf" },
});
}
if (url.endsWith("/messages/123")) {
return new Response(
JSON.stringify({
attachments: [
{
id: "ref-1",
contentType: "reference",
contentUrl: shareUrl,
name: "report.pdf",
},
],
}),
{ status: 200 },
);
}
return new Response("not found", { status: 404 });
});
const media = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
maxBytes: 1024 * 1024,
fetchFn: fetchMock as unknown as typeof fetch,
});
expect(media.media).toHaveLength(2);
});
});
describe("buildMSTeamsMediaPayload", () => {
+1 -5
View File
@@ -1,8 +1,4 @@
export {
downloadMSTeamsAttachments,
/** @deprecated Use `downloadMSTeamsAttachments` instead. */
downloadMSTeamsImageAttachments,
} from "./attachments/download.js";
export { downloadMSTeamsImageAttachments } from "./attachments/download.js";
export { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
export {
buildMSTeamsAttachmentPlaceholder,
+6 -21
View File
@@ -2,7 +2,7 @@ import { getMSTeamsRuntime } from "../runtime.js";
import {
extractInlineImageCandidates,
inferPlaceholder,
isDownloadableAttachment,
isLikelyImageAttachment,
isRecord,
isUrlAllowed,
normalizeContentType,
@@ -102,31 +102,23 @@ async function fetchWithAuthFallback(params: {
return firstAttempt;
}
/**
* Download all file attachments from a Teams message (images, documents, etc.).
* Renamed from downloadMSTeamsImageAttachments to support all file types.
*/
export async function downloadMSTeamsAttachments(params: {
export async function downloadMSTeamsImageAttachments(params: {
attachments: MSTeamsAttachmentLike[] | undefined;
maxBytes: number;
tokenProvider?: MSTeamsAccessTokenProvider;
allowHosts?: string[];
fetchFn?: typeof fetch;
/** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?: boolean;
}): Promise<MSTeamsInboundMedia[]> {
const list = Array.isArray(params.attachments) ? params.attachments : [];
if (list.length === 0) return [];
const allowHosts = resolveAllowedHosts(params.allowHosts);
// Download ANY downloadable attachment (not just images)
const downloadable = list.filter(isDownloadableAttachment);
const candidates: DownloadCandidate[] = downloadable
const candidates: DownloadCandidate[] = list
.filter(isLikelyImageAttachment)
.map(resolveDownloadCandidate)
.filter(Boolean) as DownloadCandidate[];
const inlineCandidates = extractInlineImageCandidates(list);
const seenUrls = new Set<string>();
for (const inline of inlineCandidates) {
if (inline.kind === "url") {
@@ -141,6 +133,7 @@ export async function downloadMSTeamsAttachments(params: {
});
}
}
if (candidates.length === 0 && inlineCandidates.length === 0) return [];
const out: MSTeamsInboundMedia[] = [];
@@ -148,7 +141,6 @@ export async function downloadMSTeamsAttachments(params: {
if (inline.kind !== "data") continue;
if (inline.data.byteLength > params.maxBytes) continue;
try {
// Data inline candidates (base64 data URLs) don't have original filenames
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
inline.data,
inline.contentType,
@@ -180,13 +172,11 @@ export async function downloadMSTeamsAttachments(params: {
headerMime: res.headers.get("content-type"),
filePath: candidate.fileHint ?? candidate.url,
});
const originalFilename = params.preserveFilenames ? candidate.fileHint : undefined;
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
buffer,
mime ?? candidate.contentTypeHint,
"inbound",
params.maxBytes,
originalFilename,
);
out.push({
path: saved.path,
@@ -194,13 +184,8 @@ export async function downloadMSTeamsAttachments(params: {
placeholder: candidate.placeholder,
});
} catch {
// Ignore download failures and continue with next candidate.
// Ignore download failures and continue.
}
}
return out;
}
/**
* @deprecated Use `downloadMSTeamsAttachments` instead (supports all file types).
*/
export const downloadMSTeamsImageAttachments = downloadMSTeamsAttachments;
+10 -100
View File
@@ -1,6 +1,6 @@
import { getMSTeamsRuntime } from "../runtime.js";
import { downloadMSTeamsAttachments } from "./download.js";
import { GRAPH_ROOT, inferPlaceholder, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
import { downloadMSTeamsImageAttachments } from "./download.js";
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
MSTeamsAttachmentLike,
@@ -128,16 +128,11 @@ function normalizeGraphAttachment(att: GraphAttachment): MSTeamsAttachmentLike {
};
}
/**
* Download all hosted content from a Teams message (images, documents, etc.).
* Renamed from downloadGraphHostedImages to support all file types.
*/
async function downloadGraphHostedContent(params: {
async function downloadGraphHostedImages(params: {
accessToken: string;
messageUrl: string;
maxBytes: number;
fetchFn?: typeof fetch;
preserveFilenames?: boolean;
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
const hosted = await fetchGraphCollection<GraphHostedContent>({
url: `${params.messageUrl}/hostedContents`,
@@ -163,7 +158,7 @@ async function downloadGraphHostedContent(params: {
buffer,
headerMime: item.contentType ?? undefined,
});
// Download any file type, not just images
if (mime && !mime.startsWith("image/")) continue;
try {
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
buffer,
@@ -174,7 +169,7 @@ async function downloadGraphHostedContent(params: {
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType }),
placeholder: "<media:image>",
});
} catch {
// Ignore save failures.
@@ -190,8 +185,6 @@ export async function downloadMSTeamsGraphMedia(params: {
maxBytes: number;
allowHosts?: string[];
fetchFn?: typeof fetch;
/** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?: boolean;
}): Promise<MSTeamsGraphMediaResult> {
if (!params.messageUrl || !params.tokenProvider) return { media: [] };
const allowHosts = resolveAllowedHosts(params.allowHosts);
@@ -203,83 +196,11 @@ export async function downloadMSTeamsGraphMedia(params: {
return { media: [], messageUrl, tokenError: true };
}
// Fetch the full message to get SharePoint file attachments (for group chats)
const fetchFn = params.fetchFn ?? fetch;
const sharePointMedia: MSTeamsInboundMedia[] = [];
const downloadedReferenceUrls = new Set<string>();
try {
const msgRes = await fetchFn(messageUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (msgRes.ok) {
const msgData = (await msgRes.json()) as {
body?: { content?: string; contentType?: string };
attachments?: Array<{
id?: string;
contentUrl?: string;
contentType?: string;
name?: string;
}>;
};
// Extract SharePoint file attachments (contentType: "reference")
// Download any file type, not just images
const spAttachments = (msgData.attachments ?? []).filter(
(a) => a.contentType === "reference" && a.contentUrl && a.name,
);
for (const att of spAttachments) {
const name = att.name ?? "file";
try {
// SharePoint URLs need to be accessed via Graph shares API
const shareUrl = att.contentUrl!;
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
const spRes = await fetchFn(sharesUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
redirect: "follow",
});
if (spRes.ok) {
const buffer = Buffer.from(await spRes.arrayBuffer());
if (buffer.byteLength <= params.maxBytes) {
const mime = await getMSTeamsRuntime().media.detectMime({
buffer,
headerMime: spRes.headers.get("content-type") ?? undefined,
filePath: name,
});
const originalFilename = params.preserveFilenames ? name : undefined;
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
buffer,
mime ?? "application/octet-stream",
"inbound",
params.maxBytes,
originalFilename,
);
sharePointMedia.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: name }),
});
downloadedReferenceUrls.add(shareUrl);
}
}
} catch {
// Ignore SharePoint download failures.
}
}
}
} catch {
// Ignore message fetch failures.
}
const hosted = await downloadGraphHostedContent({
const hosted = await downloadGraphHostedImages({
accessToken,
messageUrl,
maxBytes: params.maxBytes,
fetchFn: params.fetchFn,
preserveFilenames: params.preserveFilenames,
});
const attachments = await fetchGraphCollection<GraphAttachment>({
@@ -289,29 +210,18 @@ export async function downloadMSTeamsGraphMedia(params: {
});
const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);
const filteredAttachments =
sharePointMedia.length > 0
? normalizedAttachments.filter((att) => {
const contentType = att.contentType?.toLowerCase();
if (contentType !== "reference") return true;
const url = typeof att.contentUrl === "string" ? att.contentUrl : "";
if (!url) return true;
return !downloadedReferenceUrls.has(url);
})
: normalizedAttachments;
const attachmentMedia = await downloadMSTeamsAttachments({
attachments: filteredAttachments,
const attachmentMedia = await downloadMSTeamsImageAttachments({
attachments: normalizedAttachments,
maxBytes: params.maxBytes,
tokenProvider: params.tokenProvider,
allowHosts,
fetchFn: params.fetchFn,
preserveFilenames: params.preserveFilenames,
});
return {
media: [...sharePointMedia, ...hosted.media, ...attachmentMedia],
media: [...hosted.media, ...attachmentMedia],
hostedCount: hosted.count,
attachmentCount: filteredAttachments.length + sharePointMedia.length,
attachmentCount: attachments.items.length,
hostedStatus: hosted.status,
attachmentStatus: attachments.status,
messageUrl,
@@ -37,15 +37,6 @@ export const DEFAULT_MEDIA_HOST_ALLOWLIST = [
"statics.teams.cdn.office.net",
"office.com",
"office.net",
// Azure Media Services / Skype CDN for clipboard-pasted images
"asm.skype.com",
"ams.skype.com",
"media.ams.skype.com",
// Bot Framework attachment URLs
"trafficmanager.net",
"blob.core.windows.net",
"azureedge.net",
"microsoft.com",
] as const;
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
@@ -94,30 +85,6 @@ export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
return false;
}
/**
* Returns true if the attachment can be downloaded (any file type).
* Used when downloading all files, not just images.
*/
export function isDownloadableAttachment(att: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(att.contentType) ?? "";
// Teams file download info always has a downloadUrl
if (
contentType === "application/vnd.microsoft.teams.file.download.info" &&
isRecord(att.content) &&
typeof att.content.downloadUrl === "string"
) {
return true;
}
// Any attachment with a contentUrl can be downloaded
if (typeof att.contentUrl === "string" && att.contentUrl.trim()) {
return true;
}
return false;
}
function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(att.contentType) ?? "";
return contentType.startsWith("text/html");
+2 -64
View File
@@ -17,7 +17,7 @@ import {
resolveMSTeamsChannelAllowlist,
resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js";
import { sendAdaptiveCardMSTeams, sendMessageMSTeams } from "./send.js";
import { sendMessageMSTeams } from "./send.js";
import { resolveMSTeamsCredentials } from "./token.js";
import {
listMSTeamsDirectoryGroupsLive,
@@ -64,19 +64,6 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
threads: true,
media: true,
},
agentPrompt: {
messageToolHints: () => [
"- Adaptive Cards supported. Use `action=send` with `card={type,version,body}` to send rich cards.",
"- MSTeams targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:ID` or `user:Display Name` (requires Graph API) for DMs, `conversation:19:...@thread.tacv2` for groups/channels. Prefer IDs over display names for speed.",
],
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
},
reload: { configPrefixes: ["channels.msteams"] },
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
config: {
@@ -150,12 +137,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^conversation:/i.test(trimmed)) return true;
if (/^user:/i.test(trimmed)) {
// Only treat as ID if the value after user: looks like a UUID
const id = trimmed.slice("user:".length).trim();
return /^[0-9a-fA-F-]{16,}$/.test(id);
}
if (/^(conversation:|user:)/i.test(trimmed)) return true;
return trimmed.includes("@thread");
},
hint: "<conversationId|user:ID|conversation:ID>",
@@ -338,50 +320,6 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
if (!enabled) return [];
return ["poll"] satisfies ChannelMessageActionName[];
},
supportsCards: ({ cfg }) => {
return (
cfg.channels?.msteams?.enabled !== false &&
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams))
);
},
handleAction: async (ctx) => {
// Handle send action with card parameter
if (ctx.action === "send" && ctx.params.card) {
const card = ctx.params.card as Record<string, unknown>;
const to =
typeof ctx.params.to === "string"
? ctx.params.to.trim()
: typeof ctx.params.target === "string"
? ctx.params.target.trim()
: "";
if (!to) {
return {
isError: true,
content: [{ type: "text", text: "Card send requires a target (to)." }],
};
}
const result = await sendAdaptiveCardMSTeams({
cfg: ctx.cfg,
to,
card,
});
return {
content: [
{
type: "text",
text: JSON.stringify({
ok: true,
channel: "msteams",
messageId: result.messageId,
conversationId: result.conversationId,
}),
},
],
};
}
// Return null to fall through to default handler
return null as never;
},
},
outbound: msteamsOutbound,
status: {
@@ -1,234 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
import * as pendingUploads from "./pending-uploads.js";
describe("requiresFileConsent", () => {
const thresholdBytes = 4 * 1024 * 1024; // 4MB
it("returns true for personal chat with non-image", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: "application/pdf",
bufferSize: 1000,
thresholdBytes,
}),
).toBe(true);
});
it("returns true for personal chat with large image", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: "image/png",
bufferSize: 5 * 1024 * 1024, // 5MB
thresholdBytes,
}),
).toBe(true);
});
it("returns false for personal chat with small image", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: "image/png",
bufferSize: 1000,
thresholdBytes,
}),
).toBe(false);
});
it("returns false for group chat with large non-image", () => {
expect(
requiresFileConsent({
conversationType: "groupChat",
contentType: "application/pdf",
bufferSize: 5 * 1024 * 1024,
thresholdBytes,
}),
).toBe(false);
});
it("returns false for channel with large non-image", () => {
expect(
requiresFileConsent({
conversationType: "channel",
contentType: "application/pdf",
bufferSize: 5 * 1024 * 1024,
thresholdBytes,
}),
).toBe(false);
});
it("handles case-insensitive conversation type", () => {
expect(
requiresFileConsent({
conversationType: "Personal",
contentType: "application/pdf",
bufferSize: 1000,
thresholdBytes,
}),
).toBe(true);
expect(
requiresFileConsent({
conversationType: "PERSONAL",
contentType: "application/pdf",
bufferSize: 1000,
thresholdBytes,
}),
).toBe(true);
});
it("returns false when conversationType is undefined", () => {
expect(
requiresFileConsent({
conversationType: undefined,
contentType: "application/pdf",
bufferSize: 1000,
thresholdBytes,
}),
).toBe(false);
});
it("returns true for personal chat when contentType is undefined (non-image)", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: undefined,
bufferSize: 1000,
thresholdBytes,
}),
).toBe(true);
});
it("returns true for personal chat with file exactly at threshold", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: "image/jpeg",
bufferSize: thresholdBytes, // exactly 4MB
thresholdBytes,
}),
).toBe(true);
});
it("returns false for personal chat with file just below threshold", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: "image/jpeg",
bufferSize: thresholdBytes - 1, // 4MB - 1 byte
thresholdBytes,
}),
).toBe(false);
});
});
describe("prepareFileConsentActivity", () => {
const mockUploadId = "test-upload-id-123";
beforeEach(() => {
vi.spyOn(pendingUploads, "storePendingUpload").mockReturnValue(mockUploadId);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("creates activity with consent card attachment", () => {
const result = prepareFileConsentActivity({
media: {
buffer: Buffer.from("test content"),
filename: "test.pdf",
contentType: "application/pdf",
},
conversationId: "conv123",
description: "My file",
});
expect(result.uploadId).toBe(mockUploadId);
expect(result.activity.type).toBe("message");
expect(result.activity.attachments).toHaveLength(1);
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, unknown>;
expect(attachment.contentType).toBe("application/vnd.microsoft.teams.card.file.consent");
expect(attachment.name).toBe("test.pdf");
});
it("stores pending upload with correct data", () => {
const buffer = Buffer.from("test content");
prepareFileConsentActivity({
media: {
buffer,
filename: "test.pdf",
contentType: "application/pdf",
},
conversationId: "conv123",
description: "My file",
});
expect(pendingUploads.storePendingUpload).toHaveBeenCalledWith({
buffer,
filename: "test.pdf",
contentType: "application/pdf",
conversationId: "conv123",
});
});
it("uses default description when not provided", () => {
const result = prepareFileConsentActivity({
media: {
buffer: Buffer.from("test"),
filename: "document.docx",
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
conversationId: "conv456",
});
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { description: string }>;
expect(attachment.content.description).toBe("File: document.docx");
});
it("uses provided description", () => {
const result = prepareFileConsentActivity({
media: {
buffer: Buffer.from("test"),
filename: "report.pdf",
contentType: "application/pdf",
},
conversationId: "conv789",
description: "Q4 Financial Report",
});
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { description: string }>;
expect(attachment.content.description).toBe("Q4 Financial Report");
});
it("includes uploadId in consent card context", () => {
const result = prepareFileConsentActivity({
media: {
buffer: Buffer.from("test"),
filename: "file.txt",
contentType: "text/plain",
},
conversationId: "conv000",
});
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { acceptContext: { uploadId: string } }>;
expect(attachment.content.acceptContext.uploadId).toBe(mockUploadId);
});
it("handles media without contentType", () => {
const result = prepareFileConsentActivity({
media: {
buffer: Buffer.from("binary data"),
filename: "unknown.bin",
},
conversationId: "conv111",
});
expect(result.uploadId).toBe(mockUploadId);
expect(result.activity.type).toBe("message");
});
});
@@ -1,73 +0,0 @@
/**
* Shared helpers for FileConsentCard flow in MSTeams.
*
* FileConsentCard is required for:
* - Personal (1:1) chats with large files (>=4MB)
* - Personal chats with non-image files (PDFs, documents, etc.)
*
* This module consolidates the logic used by both send.ts (proactive sends)
* and messenger.ts (reply path) to avoid duplication.
*/
import { buildFileConsentCard } from "./file-consent.js";
import { storePendingUpload } from "./pending-uploads.js";
export type FileConsentMedia = {
buffer: Buffer;
filename: string;
contentType?: string;
};
export type FileConsentActivityResult = {
activity: Record<string, unknown>;
uploadId: string;
};
/**
* Prepare a FileConsentCard activity for large files or non-images in personal chats.
* Returns the activity object and uploadId - caller is responsible for sending.
*/
export function prepareFileConsentActivity(params: {
media: FileConsentMedia;
conversationId: string;
description?: string;
}): FileConsentActivityResult {
const { media, conversationId, description } = params;
const uploadId = storePendingUpload({
buffer: media.buffer,
filename: media.filename,
contentType: media.contentType,
conversationId,
});
const consentCard = buildFileConsentCard({
filename: media.filename,
description: description || `File: ${media.filename}`,
sizeInBytes: media.buffer.length,
context: { uploadId },
});
const activity: Record<string, unknown> = {
type: "message",
attachments: [consentCard],
};
return { activity, uploadId };
}
/**
* Check if a file requires FileConsentCard flow.
* True for: personal chat AND (large file OR non-image)
*/
export function requiresFileConsent(params: {
conversationType: string | undefined;
contentType: string | undefined;
bufferSize: number;
thresholdBytes: number;
}): boolean {
const isPersonal = params.conversationType?.toLowerCase() === "personal";
const isImage = params.contentType?.startsWith("image/") ?? false;
const isLargeFile = params.bufferSize >= params.thresholdBytes;
return isPersonal && (isLargeFile || !isImage);
}
-122
View File
@@ -1,122 +0,0 @@
/**
* FileConsentCard utilities for MS Teams large file uploads (>4MB) in personal chats.
*
* Teams requires user consent before the bot can upload large files. This module provides
* utilities for:
* - Building FileConsentCard attachments (to request upload permission)
* - Building FileInfoCard attachments (to confirm upload completion)
* - Parsing fileConsent/invoke activities
*/
export interface FileConsentCardParams {
filename: string;
description?: string;
sizeInBytes: number;
/** Custom context data to include in the card (passed back in the invoke) */
context?: Record<string, unknown>;
}
export interface FileInfoCardParams {
filename: string;
contentUrl: string;
uniqueId: string;
fileType: string;
}
/**
* Build a FileConsentCard attachment for requesting upload permission.
* Use this for files >= 4MB in personal (1:1) chats.
*/
export function buildFileConsentCard(params: FileConsentCardParams) {
return {
contentType: "application/vnd.microsoft.teams.card.file.consent",
name: params.filename,
content: {
description: params.description ?? `File: ${params.filename}`,
sizeInBytes: params.sizeInBytes,
acceptContext: { filename: params.filename, ...params.context },
declineContext: { filename: params.filename, ...params.context },
},
};
}
/**
* Build a FileInfoCard attachment for confirming upload completion.
* Send this after successfully uploading the file to the consent URL.
*/
export function buildFileInfoCard(params: FileInfoCardParams) {
return {
contentType: "application/vnd.microsoft.teams.card.file.info",
contentUrl: params.contentUrl,
name: params.filename,
content: {
uniqueId: params.uniqueId,
fileType: params.fileType,
},
};
}
export interface FileConsentUploadInfo {
name: string;
uploadUrl: string;
contentUrl: string;
uniqueId: string;
fileType: string;
}
export interface FileConsentResponse {
action: "accept" | "decline";
uploadInfo?: FileConsentUploadInfo;
context?: Record<string, unknown>;
}
/**
* Parse a fileConsent/invoke activity.
* Returns null if the activity is not a file consent invoke.
*/
export function parseFileConsentInvoke(activity: {
name?: string;
value?: unknown;
}): FileConsentResponse | null {
if (activity.name !== "fileConsent/invoke") return null;
const value = activity.value as {
type?: string;
action?: string;
uploadInfo?: FileConsentUploadInfo;
context?: Record<string, unknown>;
};
if (value?.type !== "fileUpload") return null;
return {
action: value.action === "accept" ? "accept" : "decline",
uploadInfo: value.uploadInfo,
context: value.context,
};
}
/**
* Upload a file to the consent URL provided by Teams.
* The URL is provided in the fileConsent/invoke response after user accepts.
*/
export async function uploadToConsentUrl(params: {
url: string;
buffer: Buffer;
contentType?: string;
fetchFn?: typeof fetch;
}): Promise<void> {
const fetchFn = params.fetchFn ?? fetch;
const res = await fetchFn(params.url, {
method: "PUT",
headers: {
"Content-Type": params.contentType ?? "application/octet-stream",
"Content-Range": `bytes 0-${params.buffer.length - 1}/${params.buffer.length}`,
},
body: new Uint8Array(params.buffer),
});
if (!res.ok) {
throw new Error(`File upload to consent URL failed: ${res.status} ${res.statusText}`);
}
}
-52
View File
@@ -1,52 +0,0 @@
/**
* Native Teams file card attachments for Bot Framework.
*
* The Bot Framework SDK supports `application/vnd.microsoft.teams.card.file.info`
* content type which produces native Teams file cards.
*
* @see https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4
*/
import type { DriveItemProperties } from "./graph-upload.js";
/**
* Build a native Teams file card attachment for Bot Framework.
*
* This uses the `application/vnd.microsoft.teams.card.file.info` content type
* which is supported by Bot Framework and produces native Teams file cards
* (the same display as when a user manually shares a file).
*
* @param file - DriveItem properties from getDriveItemProperties()
* @returns Attachment object for Bot Framework sendActivity()
*/
export function buildTeamsFileInfoCard(file: DriveItemProperties): {
contentType: string;
contentUrl: string;
name: string;
content: {
uniqueId: string;
fileType: string;
};
} {
// Extract unique ID from eTag (remove quotes, braces, and version suffix)
// Example eTag formats: "{GUID},version" or "\"{GUID},version\""
const rawETag = file.eTag;
const uniqueId = rawETag
.replace(/^["']|["']$/g, "") // Remove outer quotes
.replace(/[{}]/g, "") // Remove curly braces
.split(",")[0] ?? rawETag; // Take the GUID part before comma
// Extract file extension from filename
const lastDot = file.name.lastIndexOf(".");
const fileType = lastDot >= 0 ? file.name.slice(lastDot + 1).toLowerCase() : "";
return {
contentType: "application/vnd.microsoft.teams.card.file.info",
contentUrl: file.webDavUrl,
name: file.name,
content: {
uniqueId,
fileType,
},
};
}
-445
View File
@@ -1,445 +0,0 @@
/**
* OneDrive/SharePoint upload utilities for MS Teams file sending.
*
* For group chats and channels, files are uploaded to SharePoint and shared via a link.
* This module provides utilities for:
* - Uploading files to OneDrive (personal scope - now deprecated for bot use)
* - Uploading files to SharePoint (group/channel scope)
* - Creating sharing links (organization-wide or per-user)
* - Getting chat members for per-user sharing
*/
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
const GRAPH_BETA = "https://graph.microsoft.com/beta";
const GRAPH_SCOPE = "https://graph.microsoft.com/.default";
export interface OneDriveUploadResult {
id: string;
webUrl: string;
name: string;
}
/**
* Upload a file to the user's OneDrive root folder.
* For larger files, this uses the simple upload endpoint (up to 4MB).
* TODO: For files >4MB, implement resumable upload session.
*/
export async function uploadToOneDrive(params: {
buffer: Buffer;
filename: string;
contentType?: string;
tokenProvider: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
}): Promise<OneDriveUploadResult> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
// Use "ClawdbotShared" folder to organize bot-uploaded files
const uploadPath = `/ClawdbotShared/${encodeURIComponent(params.filename)}`;
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": params.contentType ?? "application/octet-stream",
},
body: new Uint8Array(params.buffer),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`OneDrive upload failed: ${res.status} ${res.statusText} - ${body}`);
}
const data = (await res.json()) as {
id?: string;
webUrl?: string;
name?: string;
};
if (!data.id || !data.webUrl || !data.name) {
throw new Error("OneDrive upload response missing required fields");
}
return {
id: data.id,
webUrl: data.webUrl,
name: data.name,
};
}
export interface OneDriveSharingLink {
webUrl: string;
}
/**
* Create a sharing link for a OneDrive file.
* The link allows organization members to view the file.
*/
export async function createSharingLink(params: {
itemId: string;
tokenProvider: MSTeamsAccessTokenProvider;
/** Sharing scope: "organization" (default) or "anonymous" */
scope?: "organization" | "anonymous";
fetchFn?: typeof fetch;
}): Promise<OneDriveSharingLink> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/items/${params.itemId}/createLink`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "view",
scope: params.scope ?? "organization",
}),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Create sharing link failed: ${res.status} ${res.statusText} - ${body}`);
}
const data = (await res.json()) as {
link?: { webUrl?: string };
};
if (!data.link?.webUrl) {
throw new Error("Create sharing link response missing webUrl");
}
return {
webUrl: data.link.webUrl,
};
}
/**
* Upload a file to OneDrive and create a sharing link.
* Convenience function for the common case.
*/
export async function uploadAndShareOneDrive(params: {
buffer: Buffer;
filename: string;
contentType?: string;
tokenProvider: MSTeamsAccessTokenProvider;
scope?: "organization" | "anonymous";
fetchFn?: typeof fetch;
}): Promise<{
itemId: string;
webUrl: string;
shareUrl: string;
name: string;
}> {
const uploaded = await uploadToOneDrive({
buffer: params.buffer,
filename: params.filename,
contentType: params.contentType,
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
});
const shareLink = await createSharingLink({
itemId: uploaded.id,
tokenProvider: params.tokenProvider,
scope: params.scope,
fetchFn: params.fetchFn,
});
return {
itemId: uploaded.id,
webUrl: uploaded.webUrl,
shareUrl: shareLink.webUrl,
name: uploaded.name,
};
}
// ============================================================================
// SharePoint upload functions for group chats and channels
// ============================================================================
/**
* Upload a file to a SharePoint site.
* This is used for group chats and channels where /me/drive doesn't work for bots.
*
* @param params.siteId - SharePoint site ID (e.g., "contoso.sharepoint.com,guid1,guid2")
*/
export async function uploadToSharePoint(params: {
buffer: Buffer;
filename: string;
contentType?: string;
tokenProvider: MSTeamsAccessTokenProvider;
siteId: string;
fetchFn?: typeof fetch;
}): Promise<OneDriveUploadResult> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
// Use "ClawdbotShared" folder to organize bot-uploaded files
const uploadPath = `/ClawdbotShared/${encodeURIComponent(params.filename)}`;
const res = await fetchFn(`${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": params.contentType ?? "application/octet-stream",
},
body: new Uint8Array(params.buffer),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`SharePoint upload failed: ${res.status} ${res.statusText} - ${body}`);
}
const data = (await res.json()) as {
id?: string;
webUrl?: string;
name?: string;
};
if (!data.id || !data.webUrl || !data.name) {
throw new Error("SharePoint upload response missing required fields");
}
return {
id: data.id,
webUrl: data.webUrl,
name: data.name,
};
}
export interface ChatMember {
aadObjectId: string;
displayName?: string;
}
/**
* Properties needed for native Teams file card attachments.
* The eTag is used as the attachment ID and webDavUrl as the contentUrl.
*/
export interface DriveItemProperties {
/** The eTag of the driveItem (used as attachment ID) */
eTag: string;
/** The WebDAV URL of the driveItem (used as contentUrl for reference attachment) */
webDavUrl: string;
/** The filename */
name: string;
}
/**
* Get driveItem properties needed for native Teams file card attachments.
* This fetches the eTag and webDavUrl which are required for "reference" type attachments.
*
* @param params.siteId - SharePoint site ID
* @param params.itemId - The driveItem ID (returned from upload)
*/
export async function getDriveItemProperties(params: {
siteId: string;
itemId: string;
tokenProvider: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
}): Promise<DriveItemProperties> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
const res = await fetchFn(
`${GRAPH_ROOT}/sites/${params.siteId}/drive/items/${params.itemId}?$select=eTag,webDavUrl,name`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Get driveItem properties failed: ${res.status} ${res.statusText} - ${body}`);
}
const data = (await res.json()) as {
eTag?: string;
webDavUrl?: string;
name?: string;
};
if (!data.eTag || !data.webDavUrl || !data.name) {
throw new Error("DriveItem response missing required properties (eTag, webDavUrl, or name)");
}
return {
eTag: data.eTag,
webDavUrl: data.webDavUrl,
name: data.name,
};
}
/**
* Get members of a Teams chat for per-user sharing.
* Used to create sharing links scoped to only the chat participants.
*/
export async function getChatMembers(params: {
chatId: string;
tokenProvider: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
}): Promise<ChatMember[]> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
const res = await fetchFn(`${GRAPH_ROOT}/chats/${params.chatId}/members`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Get chat members failed: ${res.status} ${res.statusText} - ${body}`);
}
const data = (await res.json()) as {
value?: Array<{
userId?: string;
displayName?: string;
}>;
};
return (data.value ?? [])
.map((m) => ({
aadObjectId: m.userId ?? "",
displayName: m.displayName,
}))
.filter((m) => m.aadObjectId);
}
/**
* Create a sharing link for a SharePoint drive item.
* For organization scope (default), uses v1.0 API.
* For per-user scope, uses beta API with recipients.
*/
export async function createSharePointSharingLink(params: {
siteId: string;
itemId: string;
tokenProvider: MSTeamsAccessTokenProvider;
/** Sharing scope: "organization" (default) or "users" (per-user with recipients) */
scope?: "organization" | "users";
/** Required when scope is "users": AAD object IDs of recipients */
recipientObjectIds?: string[];
fetchFn?: typeof fetch;
}): Promise<OneDriveSharingLink> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
const scope = params.scope ?? "organization";
// Per-user sharing requires beta API
const apiRoot = scope === "users" ? GRAPH_BETA : GRAPH_ROOT;
const body: Record<string, unknown> = {
type: "view",
scope: scope === "users" ? "users" : "organization",
};
// Add recipients for per-user sharing
if (scope === "users" && params.recipientObjectIds?.length) {
body.recipients = params.recipientObjectIds.map((id) => ({ objectId: id }));
}
const res = await fetchFn(`${apiRoot}/sites/${params.siteId}/drive/items/${params.itemId}/createLink`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const respBody = await res.text().catch(() => "");
throw new Error(`Create SharePoint sharing link failed: ${res.status} ${res.statusText} - ${respBody}`);
}
const data = (await res.json()) as {
link?: { webUrl?: string };
};
if (!data.link?.webUrl) {
throw new Error("Create SharePoint sharing link response missing webUrl");
}
return {
webUrl: data.link.webUrl,
};
}
/**
* Upload a file to SharePoint and create a sharing link.
*
* For group chats, this creates a per-user sharing link scoped to chat members.
* For channels, this creates an organization-wide sharing link.
*
* @param params.siteId - SharePoint site ID
* @param params.chatId - Optional chat ID for per-user sharing (group chats)
* @param params.usePerUserSharing - Whether to use per-user sharing (requires beta API + Chat.Read.All)
*/
export async function uploadAndShareSharePoint(params: {
buffer: Buffer;
filename: string;
contentType?: string;
tokenProvider: MSTeamsAccessTokenProvider;
siteId: string;
chatId?: string;
usePerUserSharing?: boolean;
fetchFn?: typeof fetch;
}): Promise<{
itemId: string;
webUrl: string;
shareUrl: string;
name: string;
}> {
// 1. Upload file to SharePoint
const uploaded = await uploadToSharePoint({
buffer: params.buffer,
filename: params.filename,
contentType: params.contentType,
tokenProvider: params.tokenProvider,
siteId: params.siteId,
fetchFn: params.fetchFn,
});
// 2. Determine sharing scope
let scope: "organization" | "users" = "organization";
let recipientObjectIds: string[] | undefined;
if (params.usePerUserSharing && params.chatId) {
try {
const members = await getChatMembers({
chatId: params.chatId,
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
});
if (members.length > 0) {
scope = "users";
recipientObjectIds = members.map((m) => m.aadObjectId);
}
} catch {
// Fall back to organization scope if we can't get chat members
// (e.g., missing Chat.Read.All permission)
}
}
// 3. Create sharing link
const shareLink = await createSharePointSharingLink({
siteId: params.siteId,
itemId: uploaded.id,
tokenProvider: params.tokenProvider,
scope,
recipientObjectIds,
fetchFn: params.fetchFn,
});
return {
itemId: uploaded.id,
webUrl: uploaded.webUrl,
shareUrl: shareLink.webUrl,
name: uploaded.name,
};
}
@@ -1,186 +0,0 @@
import { describe, expect, it } from "vitest";
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
describe("msteams media-helpers", () => {
describe("getMimeType", () => {
it("detects png from URL", async () => {
expect(await getMimeType("https://example.com/image.png")).toBe("image/png");
});
it("detects jpeg from URL (both extensions)", async () => {
expect(await getMimeType("https://example.com/photo.jpg")).toBe("image/jpeg");
expect(await getMimeType("https://example.com/photo.jpeg")).toBe("image/jpeg");
});
it("detects gif from URL", async () => {
expect(await getMimeType("https://example.com/anim.gif")).toBe("image/gif");
});
it("detects webp from URL", async () => {
expect(await getMimeType("https://example.com/modern.webp")).toBe("image/webp");
});
it("handles URLs with query strings", async () => {
expect(await getMimeType("https://example.com/image.png?v=123")).toBe("image/png");
});
it("handles data URLs", async () => {
expect(await getMimeType("data:image/png;base64,iVBORw0KGgo=")).toBe("image/png");
expect(await getMimeType("data:image/jpeg;base64,/9j/4AAQ")).toBe("image/jpeg");
expect(await getMimeType("data:image/gif;base64,R0lGOD")).toBe("image/gif");
});
it("handles data URLs without base64", async () => {
expect(await getMimeType("data:image/svg+xml,%3Csvg")).toBe("image/svg+xml");
});
it("handles local paths", async () => {
expect(await getMimeType("/tmp/image.png")).toBe("image/png");
expect(await getMimeType("/Users/test/photo.jpg")).toBe("image/jpeg");
});
it("handles tilde paths", async () => {
expect(await getMimeType("~/Downloads/image.gif")).toBe("image/gif");
});
it("defaults to application/octet-stream for unknown extensions", async () => {
expect(await getMimeType("https://example.com/image")).toBe("application/octet-stream");
expect(await getMimeType("https://example.com/image.unknown")).toBe("application/octet-stream");
});
it("is case-insensitive", async () => {
expect(await getMimeType("https://example.com/IMAGE.PNG")).toBe("image/png");
expect(await getMimeType("https://example.com/Photo.JPEG")).toBe("image/jpeg");
});
it("detects document types", async () => {
expect(await getMimeType("https://example.com/doc.pdf")).toBe("application/pdf");
expect(await getMimeType("https://example.com/doc.docx")).toBe(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
);
expect(await getMimeType("https://example.com/spreadsheet.xlsx")).toBe(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
});
});
describe("extractFilename", () => {
it("extracts filename from URL with extension", async () => {
expect(await extractFilename("https://example.com/photo.jpg")).toBe("photo.jpg");
});
it("extracts filename from URL with path", async () => {
expect(await extractFilename("https://example.com/images/2024/photo.png")).toBe("photo.png");
});
it("handles URLs without extension by deriving from MIME", async () => {
// Now defaults to application/octet-stream → .bin fallback
expect(await extractFilename("https://example.com/images/photo")).toBe("photo.bin");
});
it("handles data URLs", async () => {
expect(await extractFilename("data:image/png;base64,iVBORw0KGgo=")).toBe("image.png");
expect(await extractFilename("data:image/jpeg;base64,/9j/4AAQ")).toBe("image.jpg");
});
it("handles document data URLs", async () => {
expect(await extractFilename("data:application/pdf;base64,JVBERi0")).toBe("file.pdf");
});
it("handles local paths", async () => {
expect(await extractFilename("/tmp/screenshot.png")).toBe("screenshot.png");
expect(await extractFilename("/Users/test/photo.jpg")).toBe("photo.jpg");
});
it("handles tilde paths", async () => {
expect(await extractFilename("~/Downloads/image.gif")).toBe("image.gif");
});
it("returns fallback for empty URL", async () => {
expect(await extractFilename("")).toBe("file.bin");
});
it("extracts original filename from embedded pattern", async () => {
// Pattern: {original}---{uuid}.{ext}
expect(
await extractFilename("/media/inbound/report---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"),
).toBe("report.pdf");
});
it("extracts original filename with uppercase UUID", async () => {
expect(
await extractFilename("/media/inbound/Document---A1B2C3D4-E5F6-7890-ABCD-EF1234567890.docx"),
).toBe("Document.docx");
});
it("falls back to UUID filename for legacy paths", async () => {
// UUID-only filename (legacy format, no embedded name)
expect(
await extractFilename("/media/inbound/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"),
).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf");
});
it("handles --- in filename without valid UUID pattern", async () => {
// foo---bar.txt (bar is not a valid UUID)
expect(await extractFilename("/media/inbound/foo---bar.txt")).toBe("foo---bar.txt");
});
});
describe("isLocalPath", () => {
it("returns true for file:// URLs", () => {
expect(isLocalPath("file:///tmp/image.png")).toBe(true);
expect(isLocalPath("file://localhost/tmp/image.png")).toBe(true);
});
it("returns true for absolute paths", () => {
expect(isLocalPath("/tmp/image.png")).toBe(true);
expect(isLocalPath("/Users/test/photo.jpg")).toBe(true);
});
it("returns true for tilde paths", () => {
expect(isLocalPath("~/Downloads/image.png")).toBe(true);
});
it("returns false for http URLs", () => {
expect(isLocalPath("http://example.com/image.png")).toBe(false);
expect(isLocalPath("https://example.com/image.png")).toBe(false);
});
it("returns false for data URLs", () => {
expect(isLocalPath("data:image/png;base64,iVBORw0KGgo=")).toBe(false);
});
});
describe("extractMessageId", () => {
it("extracts id from valid response", () => {
expect(extractMessageId({ id: "msg123" })).toBe("msg123");
});
it("returns null for missing id", () => {
expect(extractMessageId({ foo: "bar" })).toBeNull();
});
it("returns null for empty id", () => {
expect(extractMessageId({ id: "" })).toBeNull();
});
it("returns null for non-string id", () => {
expect(extractMessageId({ id: 123 })).toBeNull();
expect(extractMessageId({ id: null })).toBeNull();
});
it("returns null for null response", () => {
expect(extractMessageId(null)).toBeNull();
});
it("returns null for undefined response", () => {
expect(extractMessageId(undefined)).toBeNull();
});
it("returns null for non-object response", () => {
expect(extractMessageId("string")).toBeNull();
expect(extractMessageId(123)).toBeNull();
});
});
});
-77
View File
@@ -1,77 +0,0 @@
/**
* MIME type detection and filename extraction for MSTeams media attachments.
*/
import path from "node:path";
import {
detectMime,
extensionForMime,
extractOriginalFilename,
getFileExtension,
} from "clawdbot/plugin-sdk";
/**
* Detect MIME type from URL extension or data URL.
* Uses shared MIME detection for consistency with core handling.
*/
export async function getMimeType(url: string): Promise<string> {
// Handle data URLs: data:image/png;base64,...
if (url.startsWith("data:")) {
const match = url.match(/^data:([^;,]+)/);
if (match?.[1]) return match[1];
}
// Use shared MIME detection (extension-based for URLs)
const detected = await detectMime({ filePath: url });
return detected ?? "application/octet-stream";
}
/**
* Extract filename from URL or local path.
* For local paths, extracts original filename if stored with embedded name pattern.
* Falls back to deriving the extension from MIME type when no extension present.
*/
export async function extractFilename(url: string): Promise<string> {
// Handle data URLs: derive extension from MIME
if (url.startsWith("data:")) {
const mime = await getMimeType(url);
const ext = extensionForMime(mime) ?? ".bin";
const prefix = mime.startsWith("image/") ? "image" : "file";
return `${prefix}${ext}`;
}
// Try to extract from URL pathname
try {
const pathname = new URL(url).pathname;
const basename = path.basename(pathname);
const existingExt = getFileExtension(pathname);
if (basename && existingExt) return basename;
// No extension in URL, derive from MIME
const mime = await getMimeType(url);
const ext = extensionForMime(mime) ?? ".bin";
const prefix = mime.startsWith("image/") ? "image" : "file";
return basename ? `${basename}${ext}` : `${prefix}${ext}`;
} catch {
// Local paths - use extractOriginalFilename to extract embedded original name
return extractOriginalFilename(url);
}
}
/**
* Check if a URL refers to a local file path.
*/
export function isLocalPath(url: string): boolean {
return url.startsWith("file://") || url.startsWith("/") || url.startsWith("~");
}
/**
* Extract the message ID from a Bot Framework response.
*/
export function extractMessageId(response: unknown): string | null {
if (!response || typeof response !== "object") return null;
if (!("id" in response)) return null;
const { id } = response as { id?: unknown };
if (typeof id !== "string" || !id) return null;
return id;
}
+7 -7
View File
@@ -51,7 +51,7 @@ describe("msteams messenger", () => {
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
{ textChunkLimit: 4000 },
);
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
expect(messages).toEqual(["hi", "https://example.com/a.png"]);
});
it("supports inline media mode", () => {
@@ -59,7 +59,7 @@ describe("msteams messenger", () => {
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
{ textChunkLimit: 4000, mediaMode: "inline" },
);
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
expect(messages).toEqual(["hi\n\nhttps://example.com/a.png"]);
});
it("chunks long text when enabled", () => {
@@ -101,7 +101,7 @@ describe("msteams messenger", () => {
appId: "app123",
conversationRef: baseRef,
context: ctx,
messages: [{ text: "one" }, { text: "two" }],
messages: ["one", "two"],
});
expect(sent).toEqual(["one", "two"]);
@@ -129,7 +129,7 @@ describe("msteams messenger", () => {
adapter,
appId: "app123",
conversationRef: baseRef,
messages: [{ text: "hello" }],
messages: ["hello"],
});
expect(seen.texts).toEqual(["hello"]);
@@ -168,7 +168,7 @@ describe("msteams messenger", () => {
appId: "app123",
conversationRef: baseRef,
context: ctx,
messages: [{ text: "one" }],
messages: ["one"],
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
onRetry: (e) => retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }),
});
@@ -196,7 +196,7 @@ describe("msteams messenger", () => {
appId: "app123",
conversationRef: baseRef,
context: ctx,
messages: [{ text: "one" }],
messages: ["one"],
retry: { maxAttempts: 3, baseDelayMs: 0, maxDelayMs: 0 },
}),
).rejects.toMatchObject({ statusCode: 400 });
@@ -227,7 +227,7 @@ describe("msteams messenger", () => {
adapter,
appId: "app123",
conversationRef: baseRef,
messages: [{ text: "hello" }],
messages: ["hello"],
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
});
+28 -175
View File
@@ -1,35 +1,13 @@
import {
isSilentReplyText,
loadWebMedia,
type MSTeamsReplyStyle,
type ReplyPayload,
SILENT_REPLY_TOKEN,
} from "clawdbot/plugin-sdk";
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
import type { StoredConversationReference } from "./conversation-store.js";
import { classifyMSTeamsSendError } from "./errors.js";
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
import { buildTeamsFileInfoCard } from "./graph-chat.js";
import {
getDriveItemProperties,
uploadAndShareOneDrive,
uploadAndShareSharePoint,
} from "./graph-upload.js";
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
import { getMSTeamsRuntime } from "./runtime.js";
/**
* MSTeams-specific media size limit (100MB).
* Higher than the default because OneDrive upload handles large files well.
*/
const MSTEAMS_MAX_MEDIA_BYTES = 100 * 1024 * 1024;
/**
* Threshold for large files that require FileConsentCard flow in personal chats.
* Files >= 4MB use consent flow; smaller images can use inline base64.
*/
const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024;
type SendContext = {
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
};
@@ -63,15 +41,6 @@ export type MSTeamsReplyRenderOptions = {
mediaMode?: "split" | "inline";
};
/**
* A rendered message that preserves media vs text distinction.
* When mediaUrl is present, it will be sent as a Bot Framework attachment.
*/
export type MSTeamsRenderedMessage = {
text?: string;
mediaUrl?: string;
};
export type MSTeamsSendRetryOptions = {
maxAttempts?: number;
baseDelayMs?: number;
@@ -121,8 +90,16 @@ export function buildConversationReference(
};
}
function extractMessageId(response: unknown): string | null {
if (!response || typeof response !== "object") return null;
if (!("id" in response)) return null;
const { id } = response as { id?: unknown };
if (typeof id !== "string" || !id) return null;
return id;
}
function pushTextMessages(
out: MSTeamsRenderedMessage[],
out: string[],
text: string,
opts: {
chunkText: boolean;
@@ -134,17 +111,16 @@ function pushTextMessages(
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
const trimmed = chunk.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
out.push({ text: trimmed });
out.push(trimmed);
}
return;
}
const trimmed = text.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) return;
out.push({ text: trimmed });
out.push(trimmed);
}
function clampMs(value: number, maxMs: number): number {
if (!Number.isFinite(value) || value < 0) return 0;
return Math.min(value, maxMs);
@@ -191,8 +167,8 @@ function shouldRetry(classification: ReturnType<typeof classifyMSTeamsSendError>
export function renderReplyPayloadsToMessages(
replies: ReplyPayload[],
options: MSTeamsReplyRenderOptions,
): MSTeamsRenderedMessage[] {
const out: MSTeamsRenderedMessage[] = [];
): string[] {
const out: string[] = [];
const chunkLimit = Math.min(options.textChunkLimit, 4000);
const chunkText = options.chunkText !== false;
const mediaMode = options.mediaMode ?? "split";
@@ -209,17 +185,8 @@ export function renderReplyPayloadsToMessages(
}
if (mediaMode === "inline") {
// For inline mode, combine text with first media as attachment
const firstMedia = mediaList[0];
if (firstMedia) {
out.push({ text: text || undefined, mediaUrl: firstMedia });
// Additional media URLs as separate messages
for (let i = 1; i < mediaList.length; i++) {
if (mediaList[i]) out.push({ mediaUrl: mediaList[i] });
}
} else {
pushTextMessages(out, text, { chunkText, chunkLimit });
}
const combined = text ? `${text}\n\n${mediaList.join("\n")}` : mediaList.join("\n");
pushTextMessages(out, combined, { chunkText, chunkLimit });
continue;
}
@@ -227,142 +194,26 @@ export function renderReplyPayloadsToMessages(
pushTextMessages(out, text, { chunkText, chunkLimit });
for (const mediaUrl of mediaList) {
if (!mediaUrl) continue;
out.push({ mediaUrl });
out.push(mediaUrl);
}
}
return out;
}
async function buildActivity(
msg: MSTeamsRenderedMessage,
conversationRef: StoredConversationReference,
tokenProvider?: MSTeamsAccessTokenProvider,
sharePointSiteId?: string,
mediaMaxBytes?: number,
): Promise<Record<string, unknown>> {
const activity: Record<string, unknown> = { type: "message" };
if (msg.text) {
activity.text = msg.text;
}
if (msg.mediaUrl) {
let contentUrl = msg.mediaUrl;
let contentType = await getMimeType(msg.mediaUrl);
let fileName = await extractFilename(msg.mediaUrl);
if (isLocalPath(msg.mediaUrl)) {
const maxBytes = mediaMaxBytes ?? MSTEAMS_MAX_MEDIA_BYTES;
const media = await loadWebMedia(msg.mediaUrl, maxBytes);
contentType = media.contentType ?? contentType;
fileName = media.fileName ?? fileName;
// Determine conversation type and file type
// Teams only accepts base64 data URLs for images
const conversationType = conversationRef.conversation?.conversationType?.toLowerCase();
const isPersonal = conversationType === "personal";
const isImage = contentType?.startsWith("image/") ?? false;
if (requiresFileConsent({
conversationType,
contentType,
bufferSize: media.buffer.length,
thresholdBytes: FILE_CONSENT_THRESHOLD_BYTES,
})) {
// Large file or non-image in personal chat: use FileConsentCard flow
const conversationId = conversationRef.conversation?.id ?? "unknown";
const { activity: consentActivity } = prepareFileConsentActivity({
media: { buffer: media.buffer, filename: fileName, contentType },
conversationId,
description: msg.text || undefined,
});
// Return the consent activity (caller sends it)
return consentActivity;
}
if (!isPersonal && !isImage && tokenProvider && sharePointSiteId) {
// Non-image in group chat/channel with SharePoint site configured:
// Upload to SharePoint and use native file card attachment
const chatId = conversationRef.conversation?.id;
// Upload to SharePoint
const uploaded = await uploadAndShareSharePoint({
buffer: media.buffer,
filename: fileName,
contentType,
tokenProvider,
siteId: sharePointSiteId,
chatId: chatId ?? undefined,
usePerUserSharing: conversationType === "groupchat",
});
// Get driveItem properties needed for native file card attachment
const driveItem = await getDriveItemProperties({
siteId: sharePointSiteId,
itemId: uploaded.itemId,
tokenProvider,
});
// Build native Teams file card attachment
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
activity.attachments = [fileCardAttachment];
return activity;
}
if (!isPersonal && !isImage && tokenProvider) {
// Fallback: no SharePoint site configured, try OneDrive upload
const uploaded = await uploadAndShareOneDrive({
buffer: media.buffer,
filename: fileName,
contentType,
tokenProvider,
});
// Bot Framework doesn't support "reference" attachment type for sending
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
activity.text = msg.text ? `${msg.text}\n\n${fileLink}` : fileLink;
return activity;
}
// Image (any chat): use base64 (works for images in all conversation types)
const base64 = media.buffer.toString("base64");
contentUrl = `data:${media.contentType};base64,${base64}`;
}
activity.attachments = [
{
name: fileName,
contentType,
contentUrl,
},
];
}
return activity;
}
export async function sendMSTeamsMessages(params: {
replyStyle: MSTeamsReplyStyle;
adapter: MSTeamsAdapter;
appId: string;
conversationRef: StoredConversationReference;
context?: SendContext;
messages: MSTeamsRenderedMessage[];
messages: string[];
retry?: false | MSTeamsSendRetryOptions;
onRetry?: (event: MSTeamsSendRetryEvent) => void;
/** Token provider for OneDrive/SharePoint uploads in group chats/channels */
tokenProvider?: MSTeamsAccessTokenProvider;
/** SharePoint site ID for file uploads in group chats/channels */
sharePointSiteId?: string;
/** Max media size in bytes. Default: 100MB. */
mediaMaxBytes?: number;
}): Promise<string[]> {
const messages = params.messages.filter(
(m) => (m.text && m.text.trim().length > 0) || m.mediaUrl,
);
const messages = params.messages
.map((m) => (typeof m === "string" ? m : String(m)))
.filter((m) => m.trim().length > 0);
if (messages.length === 0) return [];
const retryOptions = resolveRetryOptions(params.retry);
@@ -408,9 +259,10 @@ export async function sendMSTeamsMessages(params: {
for (const [idx, message] of messages.entries()) {
const response = await sendWithRetry(
async () =>
await ctx.sendActivity(
await buildActivity(message, params.conversationRef, params.tokenProvider, params.sharePointSiteId, params.mediaMaxBytes),
),
await ctx.sendActivity({
type: "message",
text: message,
}),
{ messageIndex: idx, messageCount: messages.length },
);
messageIds.push(extractMessageId(response) ?? "unknown");
@@ -429,9 +281,10 @@ export async function sendMSTeamsMessages(params: {
for (const [idx, message] of messages.entries()) {
const response = await sendWithRetry(
async () =>
await ctx.sendActivity(
await buildActivity(message, params.conversationRef, params.tokenProvider, params.sharePointSiteId, params.mediaMaxBytes),
),
await ctx.sendActivity({
type: "message",
text: message,
}),
{ messageIndex: idx, messageCount: messages.length },
);
messageIds.push(extractMessageId(response) ?? "unknown");
-105
View File
@@ -1,14 +1,8 @@
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import {
buildFileInfoCard,
parseFileConsentInvoke,
uploadToConsentUrl,
} from "./file-consent.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import { getPendingUpload, removePendingUpload } from "./pending-uploads.js";
import type { MSTeamsPollStore } from "./polls.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
@@ -23,7 +17,6 @@ export type MSTeamsActivityHandler = {
onMembersAdded: (
handler: (context: unknown, next: () => Promise<void>) => Promise<void>,
) => MSTeamsActivityHandler;
run?: (context: unknown) => Promise<void>;
};
export type MSTeamsMessageHandlerDeps = {
@@ -39,109 +32,11 @@ export type MSTeamsMessageHandlerDeps = {
log: MSTeamsMonitorLogger;
};
/**
* Handle fileConsent/invoke activities for large file uploads.
*/
async function handleFileConsentInvoke(
context: MSTeamsTurnContext,
log: MSTeamsMonitorLogger,
): Promise<boolean> {
const activity = context.activity;
if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") {
return false;
}
const consentResponse = parseFileConsentInvoke(activity);
if (!consentResponse) {
log.debug("invalid file consent invoke", { value: activity.value });
return false;
}
const uploadId =
typeof consentResponse.context?.uploadId === "string"
? consentResponse.context.uploadId
: undefined;
if (consentResponse.action === "accept" && consentResponse.uploadInfo) {
const pendingFile = getPendingUpload(uploadId);
if (pendingFile) {
log.debug("user accepted file consent, uploading", {
uploadId,
filename: pendingFile.filename,
size: pendingFile.buffer.length,
});
try {
// Upload file to the provided URL
await uploadToConsentUrl({
url: consentResponse.uploadInfo.uploadUrl,
buffer: pendingFile.buffer,
contentType: pendingFile.contentType,
});
// Send confirmation card
const fileInfoCard = buildFileInfoCard({
filename: consentResponse.uploadInfo.name,
contentUrl: consentResponse.uploadInfo.contentUrl,
uniqueId: consentResponse.uploadInfo.uniqueId,
fileType: consentResponse.uploadInfo.fileType,
});
await context.sendActivity({
type: "message",
attachments: [fileInfoCard],
});
log.info("file upload complete", {
uploadId,
filename: consentResponse.uploadInfo.name,
uniqueId: consentResponse.uploadInfo.uniqueId,
});
} catch (err) {
log.debug("file upload failed", { uploadId, error: String(err) });
await context.sendActivity(`File upload failed: ${String(err)}`);
} finally {
removePendingUpload(uploadId);
}
} else {
log.debug("pending file not found for consent", { uploadId });
await context.sendActivity(
"The file upload request has expired. Please try sending the file again.",
);
}
} else {
// User declined
log.debug("user declined file consent", { uploadId });
removePendingUpload(uploadId);
}
return true;
}
export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
handler: T,
deps: MSTeamsMessageHandlerDeps,
): T {
const handleTeamsMessage = createMSTeamsMessageHandler(deps);
// Wrap the original run method to intercept invokes
const originalRun = handler.run;
if (originalRun) {
handler.run = async (context: unknown) => {
const ctx = context as MSTeamsTurnContext;
// Handle file consent invokes before passing to normal flow
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "fileConsent/invoke") {
const handled = await handleFileConsentInvoke(ctx, deps.log);
if (handled) {
// Send invoke response for file consent
await ctx.sendActivity({ type: "invokeResponse", value: { status: 200 } });
return;
}
}
return originalRun.call(handler, context);
};
}
handler.onMessage(async (context, next) => {
try {
await handleTeamsMessage(context as MSTeamsTurnContext);
@@ -1,7 +1,7 @@
import {
buildMSTeamsGraphMessageUrls,
downloadMSTeamsAttachments,
downloadMSTeamsGraphMedia,
downloadMSTeamsImageAttachments,
type MSTeamsAccessTokenProvider,
type MSTeamsAttachmentLike,
type MSTeamsHtmlAttachmentSummary,
@@ -24,8 +24,6 @@ export async function resolveMSTeamsInboundMedia(params: {
conversationMessageId?: string;
activity: Pick<MSTeamsTurnContext["activity"], "id" | "replyToId" | "channelData">;
log: MSTeamsLogger;
/** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?: boolean;
}): Promise<MSTeamsInboundMedia[]> {
const {
attachments,
@@ -38,15 +36,13 @@ export async function resolveMSTeamsInboundMedia(params: {
conversationMessageId,
activity,
log,
preserveFilenames,
} = params;
let mediaList = await downloadMSTeamsAttachments({
let mediaList = await downloadMSTeamsImageAttachments({
attachments,
maxBytes,
tokenProvider,
allowHosts,
preserveFilenames,
});
if (mediaList.length === 0) {
@@ -85,7 +81,6 @@ export async function resolveMSTeamsInboundMedia(params: {
tokenProvider,
maxBytes,
allowHosts,
preserveFilenames,
});
attempts.push({
url: messageUrl,
@@ -109,7 +104,7 @@ export async function resolveMSTeamsInboundMedia(params: {
}
if (mediaList.length > 0) {
log.debug("downloaded attachments", { count: mediaList.length });
log.debug("downloaded image attachments", { count: mediaList.length });
} else if (htmlSummary?.imgTags) {
log.debug("inline images detected but none downloaded", {
imgTags: htmlSummary.imgTags,
@@ -402,8 +402,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
channelData: activity.channelData,
},
log,
preserveFilenames: cfg.media?.preserveFilenames,
});
});
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
const envelopeFrom = isDirectMessage ? senderName : conversationType;
@@ -477,7 +476,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
const sharePointSiteId = msteamsCfg?.sharePointSiteId;
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
cfg,
agentId: route.agentId,
@@ -494,8 +492,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
recordMSTeamsSentMessage(conversationId, id);
}
},
tokenProvider,
sharePointSiteId,
});
log.info("dispatching to agent", { sessionKey: route.sessionKey });
-87
View File
@@ -1,87 +0,0 @@
/**
* In-memory storage for files awaiting user consent in the FileConsentCard flow.
*
* When sending large files (>=4MB) in personal chats, Teams requires user consent
* before upload. This module stores the file data temporarily until the user
* accepts or declines, or until the TTL expires.
*/
import crypto from "node:crypto";
export interface PendingUpload {
id: string;
buffer: Buffer;
filename: string;
contentType?: string;
conversationId: string;
createdAt: number;
}
const pendingUploads = new Map<string, PendingUpload>();
/** TTL for pending uploads: 5 minutes */
const PENDING_UPLOAD_TTL_MS = 5 * 60 * 1000;
/**
* Store a file pending user consent.
* Returns the upload ID to include in the FileConsentCard context.
*/
export function storePendingUpload(
upload: Omit<PendingUpload, "id" | "createdAt">,
): string {
const id = crypto.randomUUID();
const entry: PendingUpload = {
...upload,
id,
createdAt: Date.now(),
};
pendingUploads.set(id, entry);
// Auto-cleanup after TTL
setTimeout(() => {
pendingUploads.delete(id);
}, PENDING_UPLOAD_TTL_MS);
return id;
}
/**
* Retrieve a pending upload by ID.
* Returns undefined if not found or expired.
*/
export function getPendingUpload(id?: string): PendingUpload | undefined {
if (!id) return undefined;
const entry = pendingUploads.get(id);
if (!entry) return undefined;
// Check if expired (in case timeout hasn't fired yet)
if (Date.now() - entry.createdAt > PENDING_UPLOAD_TTL_MS) {
pendingUploads.delete(id);
return undefined;
}
return entry;
}
/**
* Remove a pending upload (after successful upload or user decline).
*/
export function removePendingUpload(id?: string): void {
if (id) {
pendingUploads.delete(id);
}
}
/**
* Get the count of pending uploads (for monitoring/debugging).
*/
export function getPendingUploadCount(): number {
return pendingUploads.size;
}
/**
* Clear all pending uploads (for testing).
*/
export function clearPendingUploads(): void {
pendingUploads.clear();
}
+1 -1
View File
@@ -76,7 +76,7 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
| undefined;
try {
const graphToken = await tokenProvider.getAccessToken(
"https://graph.microsoft.com",
"https://graph.microsoft.com/.default",
);
const accessToken = readAccessToken(graphToken);
const payload = accessToken ? decodeJwtPayload(accessToken) : null;
+4 -17
View File
@@ -1,10 +1,8 @@
import {
resolveChannelMediaMaxBytes,
type ClawdbotConfig,
type MSTeamsReplyStyle,
type RuntimeEnv,
import type {
ClawdbotConfig,
MSTeamsReplyStyle,
RuntimeEnv,
} from "clawdbot/plugin-sdk";
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
import type { StoredConversationReference } from "./conversation-store.js";
import {
classifyMSTeamsSendError,
@@ -32,10 +30,6 @@ export function createMSTeamsReplyDispatcher(params: {
replyStyle: MSTeamsReplyStyle;
textLimit: number;
onSentMessageIds?: (ids: string[]) => void;
/** Token provider for OneDrive/SharePoint uploads in group chats/channels */
tokenProvider?: MSTeamsAccessTokenProvider;
/** SharePoint site ID for file uploads in group chats/channels */
sharePointSiteId?: string;
}) {
const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => {
@@ -58,10 +52,6 @@ export function createMSTeamsReplyDispatcher(params: {
chunkText: true,
mediaMode: "split",
});
const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
});
const ids = await sendMSTeamsMessages({
replyStyle: params.replyStyle,
adapter: params.adapter,
@@ -77,9 +67,6 @@ export function createMSTeamsReplyDispatcher(params: {
...event,
});
},
tokenProvider: params.tokenProvider,
sharePointSiteId: params.sharePointSiteId,
mediaMaxBytes,
});
if (ids.length > 0) params.onSentMessageIds?.(ids);
},
+12 -43
View File
@@ -1,31 +1,29 @@
import { resolveChannelMediaMaxBytes, type ClawdbotConfig, type PluginRuntime } from "clawdbot/plugin-sdk";
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import type {
MSTeamsConversationStore,
StoredConversationReference,
} from "./conversation-store.js";
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { getMSTeamsRuntime } from "./runtime.js";
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js";
export type MSTeamsConversationType = "personal" | "groupChat" | "channel";
type GetChildLogger = PluginRuntime["logging"]["getChildLogger"];
let _log: ReturnType<GetChildLogger> | undefined;
const getLog = async (): Promise<ReturnType<GetChildLogger>> => {
if (_log) return _log;
const { getChildLogger } = await import("../logging.js");
_log = getChildLogger({ name: "msteams:send" });
return _log;
};
export type MSTeamsProactiveContext = {
appId: string;
conversationId: string;
ref: StoredConversationReference;
adapter: MSTeamsAdapter;
log: ReturnType<PluginRuntime["logging"]["getChildLogger"]>;
/** The type of conversation: personal (1:1), groupChat, or channel */
conversationType: MSTeamsConversationType;
/** Token provider for Graph API / OneDrive operations */
tokenProvider: MSTeamsAccessTokenProvider;
/** SharePoint site ID for file uploads in group chats/channels */
sharePointSiteId?: string;
/** Resolved media max bytes from config (default: 100MB) */
mediaMaxBytes?: number;
log: Awaited<ReturnType<typeof getLog>>;
};
/**
@@ -112,45 +110,16 @@ export async function resolveMSTeamsSendContext(params: {
}
const { conversationId, ref } = found;
const core = getMSTeamsRuntime();
const log = core.logging.getChildLogger({ name: "msteams:send" });
const log = await getLog();
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const adapter = createMSTeamsAdapter(authConfig, sdk);
// Create token provider for Graph API / OneDrive operations
const tokenProvider = new sdk.MsalTokenProvider(authConfig) as MSTeamsAccessTokenProvider;
// Determine conversation type from stored reference
const storedConversationType = ref.conversation?.conversationType?.toLowerCase() ?? "";
let conversationType: MSTeamsConversationType;
if (storedConversationType === "personal") {
conversationType = "personal";
} else if (storedConversationType === "channel") {
conversationType = "channel";
} else {
// groupChat, or unknown defaults to groupChat behavior
conversationType = "groupChat";
}
// Get SharePoint site ID from config (required for file uploads in group chats/channels)
const sharePointSiteId = msteamsCfg.sharePointSiteId;
// Resolve media max bytes from config
const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
});
return {
appId: creds.appId,
conversationId,
ref,
adapter: adapter as unknown as MSTeamsAdapter,
log,
conversationType,
tokenProvider,
sharePointSiteId,
mediaMaxBytes,
};
}
+46 -314
View File
@@ -1,22 +1,18 @@
import { loadWebMedia, resolveChannelMediaMaxBytes } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { StoredConversationReference } from "./conversation-store.js";
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
import {
classifyMSTeamsSendError,
formatMSTeamsSendErrorHint,
formatUnknownError,
} from "./errors.js";
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
import { buildTeamsFileInfoCard } from "./graph-chat.js";
import {
getDriveItemProperties,
uploadAndShareOneDrive,
uploadAndShareSharePoint,
} from "./graph-upload.js";
import { extractFilename, extractMessageId } from "./media-helpers.js";
import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js";
buildConversationReference,
type MSTeamsAdapter,
sendMSTeamsMessages,
} from "./messenger.js";
import { buildMSTeamsPollCard } from "./polls.js";
import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
import { resolveMSTeamsSendContext } from "./send-context.js";
export type SendMSTeamsMessageParams = {
/** Full config (for credentials) */
@@ -32,19 +28,8 @@ export type SendMSTeamsMessageParams = {
export type SendMSTeamsMessageResult = {
messageId: string;
conversationId: string;
/** If a FileConsentCard was sent instead of the file, this contains the upload ID */
pendingUploadId?: string;
};
/** Threshold for large files that require FileConsentCard flow in personal chats */
const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024; // 4MB
/**
* MSTeams-specific media size limit (100MB).
* Higher than the default because OneDrive upload handles large files well.
*/
const MSTEAMS_MAX_MEDIA_BYTES = 100 * 1024 * 1024;
export type SendMSTeamsPollParams = {
/** Full config (for credentials) */
cfg: ClawdbotConfig;
@@ -64,19 +49,32 @@ export type SendMSTeamsPollResult = {
conversationId: string;
};
export type SendMSTeamsCardParams = {
/** Full config (for credentials) */
cfg: ClawdbotConfig;
/** Conversation ID or user ID to send to */
to: string;
/** Adaptive Card JSON object */
card: Record<string, unknown>;
};
function extractMessageId(response: unknown): string | null {
if (!response || typeof response !== "object") return null;
if (!("id" in response)) return null;
const { id } = response as { id?: unknown };
if (typeof id !== "string" || !id) return null;
return id;
}
export type SendMSTeamsCardResult = {
messageId: string;
conversationId: string;
};
async function sendMSTeamsActivity(params: {
adapter: MSTeamsAdapter;
appId: string;
conversationRef: StoredConversationReference;
activity: Record<string, unknown>;
}): Promise<string> {
const baseRef = buildConversationReference(params.conversationRef);
const proactiveRef = {
...baseRef,
activityId: undefined,
};
let messageId = "unknown";
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
const response = await ctx.sendActivity(params.activity);
messageId = extractMessageId(response) ?? "unknown";
});
return messageId;
}
/**
* Send a message to a Teams conversation or user.
@@ -84,225 +82,23 @@ export type SendMSTeamsCardResult = {
* Uses the stored ConversationReference from previous interactions.
* The bot must have received at least one message from the conversation
* before proactive messaging works.
*
* File handling by conversation type:
* - Personal (1:1) chats: small images (<4MB) use base64, large files and non-images use FileConsentCard
* - Group chats / channels: files are uploaded to OneDrive and shared via link
*/
export async function sendMessageMSTeams(
params: SendMSTeamsMessageParams,
): Promise<SendMSTeamsMessageResult> {
const { cfg, to, text, mediaUrl } = params;
const ctx = await resolveMSTeamsSendContext({ cfg, to });
const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx;
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
cfg,
to,
});
log.debug("sending proactive message", {
conversationId,
conversationType,
textLength: text.length,
hasMedia: Boolean(mediaUrl),
});
// Handle media if present
if (mediaUrl) {
const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
}) ?? MSTEAMS_MAX_MEDIA_BYTES;
const media = await loadWebMedia(mediaUrl, mediaMaxBytes);
const isLargeFile = media.buffer.length >= FILE_CONSENT_THRESHOLD_BYTES;
const isImage = media.contentType?.startsWith("image/") ?? false;
const fallbackFileName = await extractFilename(mediaUrl);
const fileName = media.fileName ?? fallbackFileName;
log.debug("processing media", {
fileName,
contentType: media.contentType,
size: media.buffer.length,
isLargeFile,
isImage,
conversationType,
});
// Personal chats: base64 only works for images; use FileConsentCard for large files or non-images
if (requiresFileConsent({
conversationType,
contentType: media.contentType,
bufferSize: media.buffer.length,
thresholdBytes: FILE_CONSENT_THRESHOLD_BYTES,
})) {
const { activity, uploadId } = prepareFileConsentActivity({
media: { buffer: media.buffer, filename: fileName, contentType: media.contentType },
conversationId,
description: text || undefined,
});
log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length });
const baseRef = buildConversationReference(ref);
const proactiveRef = { ...baseRef, activityId: undefined };
let messageId = "unknown";
try {
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
const response = await turnCtx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
});
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
throw new Error(
`msteams consent card send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
);
}
log.info("sent file consent card", { conversationId, messageId, uploadId });
return {
messageId,
conversationId,
pendingUploadId: uploadId,
};
}
// Personal chat with small image: use base64 (only works for images)
if (conversationType === "personal") {
// Small image in personal chat: use base64 (only works for images)
const base64 = media.buffer.toString("base64");
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
return sendTextWithMedia(ctx, text, finalMediaUrl);
}
if (isImage && !sharePointSiteId) {
// Group chat/channel without SharePoint: send image inline (avoids OneDrive failures)
const base64 = media.buffer.toString("base64");
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
return sendTextWithMedia(ctx, text, finalMediaUrl);
}
// Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive
try {
if (sharePointSiteId) {
// Use SharePoint upload + Graph API for native file card
log.debug("uploading to SharePoint for native file card", {
fileName,
conversationType,
siteId: sharePointSiteId,
});
const uploaded = await uploadAndShareSharePoint({
buffer: media.buffer,
filename: fileName,
contentType: media.contentType,
tokenProvider,
siteId: sharePointSiteId,
chatId: conversationId,
usePerUserSharing: conversationType === "groupChat",
});
log.debug("SharePoint upload complete", {
itemId: uploaded.itemId,
shareUrl: uploaded.shareUrl,
});
// Get driveItem properties needed for native file card
const driveItem = await getDriveItemProperties({
siteId: sharePointSiteId,
itemId: uploaded.itemId,
tokenProvider,
});
log.debug("driveItem properties retrieved", {
eTag: driveItem.eTag,
webDavUrl: driveItem.webDavUrl,
});
// Build native Teams file card attachment and send via Bot Framework
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
const activity = {
type: "message",
text: text || undefined,
attachments: [fileCardAttachment],
};
const baseRef = buildConversationReference(ref);
const proactiveRef = { ...baseRef, activityId: undefined };
let messageId = "unknown";
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
const response = await turnCtx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
});
log.info("sent native file card", {
conversationId,
messageId,
fileName: driveItem.name,
});
return { messageId, conversationId };
}
// Fallback: no SharePoint site configured, use OneDrive with markdown link
log.debug("uploading to OneDrive (no SharePoint site configured)", { fileName, conversationType });
const uploaded = await uploadAndShareOneDrive({
buffer: media.buffer,
filename: fileName,
contentType: media.contentType,
tokenProvider,
});
log.debug("OneDrive upload complete", {
itemId: uploaded.itemId,
shareUrl: uploaded.shareUrl,
});
// Send message with file link (Bot Framework doesn't support "reference" attachment type for sending)
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
const activity = {
type: "message",
text: text ? `${text}\n\n${fileLink}` : fileLink,
};
const baseRef = buildConversationReference(ref);
const proactiveRef = { ...baseRef, activityId: undefined };
let messageId = "unknown";
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
const response = await turnCtx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
});
log.info("sent message with OneDrive file link", { conversationId, messageId, shareUrl: uploaded.shareUrl });
return { messageId, conversationId };
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
throw new Error(
`msteams file send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
);
}
}
// No media: send text only
return sendTextWithMedia(ctx, text, undefined);
}
/**
* Send a text message with optional base64 media URL.
*/
async function sendTextWithMedia(
ctx: MSTeamsProactiveContext,
text: string,
mediaUrl: string | undefined,
): Promise<SendMSTeamsMessageResult> {
const { adapter, appId, conversationId, ref, log, tokenProvider, sharePointSiteId, mediaMaxBytes } = ctx;
const message = mediaUrl ? (text ? `${text}\n\n${mediaUrl}` : mediaUrl) : text;
let messageIds: string[];
try {
messageIds = await sendMSTeamsMessages({
@@ -310,14 +106,12 @@ async function sendTextWithMedia(
adapter,
appId,
conversationRef: ref,
messages: [{ text: text || undefined, mediaUrl }],
messages: [message],
// Enable default retry/backoff for throttling/transient failures.
retry: {},
onRetry: (event) => {
log.debug("retrying send", { conversationId, ...event });
},
tokenProvider,
sharePointSiteId,
mediaMaxBytes,
});
} catch (err) {
const classification = classifyMSTeamsSendError(err);
@@ -327,8 +121,8 @@ async function sendTextWithMedia(
`msteams send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
);
}
const messageId = messageIds[0] ?? "unknown";
log.info("sent proactive message", { conversationId, messageId });
return {
@@ -363,6 +157,7 @@ export async function sendPollMSTeams(
const activity = {
type: "message",
text: pollCard.fallbackText,
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
@@ -371,18 +166,13 @@ export async function sendPollMSTeams(
],
};
// Send poll via proactive conversation (Adaptive Cards require direct activity send)
const baseRef = buildConversationReference(ref);
const proactiveRef = {
...baseRef,
activityId: undefined,
};
let messageId = "unknown";
let messageId: string;
try {
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
const response = await ctx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
messageId = await sendMSTeamsActivity({
adapter,
appId,
conversationRef: ref,
activity,
});
} catch (err) {
const classification = classifyMSTeamsSendError(err);
@@ -402,64 +192,6 @@ export async function sendPollMSTeams(
};
}
/**
* Send an arbitrary Adaptive Card to a Teams conversation or user.
*/
export async function sendAdaptiveCardMSTeams(
params: SendMSTeamsCardParams,
): Promise<SendMSTeamsCardResult> {
const { cfg, to, card } = params;
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
cfg,
to,
});
log.debug("sending adaptive card", {
conversationId,
cardType: card.type,
cardVersion: card.version,
});
const activity = {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: card,
},
],
};
// Send card via proactive conversation
const baseRef = buildConversationReference(ref);
const proactiveRef = {
...baseRef,
activityId: undefined,
};
let messageId = "unknown";
try {
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
const response = await ctx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
});
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
throw new Error(
`msteams card send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
);
}
log.info("sent adaptive card", { conversationId, messageId });
return {
messageId,
conversationId,
};
}
/**
* List all known conversation references (for debugging/CLI).
*/
+5 -19
View File
@@ -227,29 +227,15 @@ fi
echo "📦 Copying Textual resources"
TEXTUAL_BUNDLE_DIR="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG"
TEXTUAL_BUNDLE=""
for candidate in \
"$TEXTUAL_BUNDLE_DIR/textual_Textual.bundle" \
"$TEXTUAL_BUNDLE_DIR/Textual_Textual.bundle"
do
if [ -d "$candidate" ]; then
TEXTUAL_BUNDLE="$candidate"
break
fi
done
if [ -z "$TEXTUAL_BUNDLE" ]; then
TEXTUAL_BUNDLE="$(find "$BUILD_ROOT" -type d \( -name "textual_Textual.bundle" -o -name "Textual_Textual.bundle" \) -print -quit)"
TEXTUAL_BUNDLE="$TEXTUAL_BUNDLE_DIR/textual_Textual.bundle"
if [ ! -d "$TEXTUAL_BUNDLE" ]; then
TEXTUAL_BUNDLE="$TEXTUAL_BUNDLE_DIR/Textual_Textual.bundle"
fi
if [ -n "$TEXTUAL_BUNDLE" ] && [ -d "$TEXTUAL_BUNDLE" ]; then
if [ -d "$TEXTUAL_BUNDLE" ]; then
rm -rf "$APP_ROOT/Contents/Resources/$(basename "$TEXTUAL_BUNDLE")"
cp -R "$TEXTUAL_BUNDLE" "$APP_ROOT/Contents/Resources/"
else
if [[ "${ALLOW_MISSING_TEXTUAL_BUNDLE:-0}" == "1" ]]; then
echo "WARN: Textual resource bundle not found (continuing due to ALLOW_MISSING_TEXTUAL_BUNDLE=1)" >&2
else
echo "ERROR: Textual resource bundle not found. Set ALLOW_MISSING_TEXTUAL_BUNDLE=1 to bypass." >&2
exit 1
fi
echo "WARN: Textual resource bundle not found in $TEXTUAL_BUNDLE_DIR (continuing)" >&2
fi
echo "⏹ Stopping any running Clawdbot"
-2
View File
@@ -16,7 +16,6 @@ Setup (once)
Common commands
- Gmail search: `gog gmail search 'newer_than:7d' --max 10`
- Gmail messages search (per email, ignores threading): `gog gmail messages search "in:inbox from:ryanair.com" --max 20 --account you@example.com`
- Gmail send (plain): `gog gmail send --to a@b.com --subject "Hi" --body "Hello"`
- Gmail send (multi-line): `gog gmail send --to a@b.com --subject "Hi" --body-file ./message.txt`
- Gmail send (stdin): `gog gmail send --to a@b.com --subject "Hi" --body-file -`
@@ -89,4 +88,3 @@ Notes
- Sheets values can be passed via `--values-json` (recommended) or as inline rows.
- Docs supports export/cat/copy. In-place edits require a Docs API client (not in gog).
- Confirm before sending mail or creating events.
- `gog gmail search` returns one row per thread; use `gog gmail messages search` when you need every individual email returned separately.
+8 -14
View File
@@ -54,7 +54,6 @@ import { callGatewayTool } from "./tools/gateway.js";
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
const DEFAULT_MAX_OUTPUT = clampNumber(
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
@@ -660,11 +659,6 @@ export function createExecTool(
const notifyOnExit = defaults?.notifyOnExit !== false;
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
const approvalRunningNoticeMs = resolveApprovalRunningNoticeMs(defaults?.approvalRunningNoticeMs);
// Derive agentId only when sessionKey is an agent session key.
const parsedAgentSession = parseAgentSessionKey(defaults?.sessionKey);
const agentId =
defaults?.agentId ??
(parsedAgentSession ? resolveAgentIdFromSessionKey(defaults?.sessionKey) : undefined);
return {
name: "exec",
@@ -805,7 +799,7 @@ export function createExecTool(
if (host === "node") {
const approvals = resolveExecApprovals(
agentId,
defaults?.agentId,
host === "node" ? { security: "allowlist" } : undefined,
);
const hostSecurity = minSecurity(security, approvals.agent.security);
@@ -871,7 +865,7 @@ export function createExecTool(
cwd: workdir,
env: nodeEnv,
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
agentId,
agentId: defaults?.agentId,
sessionKey: defaults?.sessionKey,
approved: approvedByAsk,
approvalDecision: approvalDecision ?? undefined,
@@ -901,7 +895,7 @@ export function createExecTool(
host: "node",
security: hostSecurity,
ask: hostAsk,
agentId,
agentId: defaults?.agentId,
resolvedPath: null,
sessionKey: defaults?.sessionKey ?? null,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
@@ -1032,7 +1026,7 @@ export function createExecTool(
}
if (host === "gateway") {
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" });
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
@@ -1086,7 +1080,7 @@ export function createExecTool(
host: "gateway",
security: hostSecurity,
ask: hostAsk,
agentId,
agentId: defaults?.agentId,
resolvedPath,
sessionKey: defaults?.sessionKey ?? null,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
@@ -1129,7 +1123,7 @@ export function createExecTool(
for (const segment of analysis.segments) {
const pattern = segment.resolution?.resolvedPath ?? "";
if (pattern) {
addAllowlistEntry(approvals.file, agentId, pattern);
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
}
}
}
@@ -1158,7 +1152,7 @@ export function createExecTool(
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
agentId,
defaults?.agentId,
match,
commandText,
resolvedPath ?? undefined,
@@ -1248,7 +1242,7 @@ export function createExecTool(
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
agentId,
defaults?.agentId,
match,
params.command,
analysis.segments[0]?.resolution?.resolvedPath,
-18
View File
@@ -1,6 +1,4 @@
import { getChannelDock } from "../channels/dock.js";
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import { normalizeAnyChannelId } from "../channels/registry.js";
import type { ChannelAgentTool, ChannelMessageActionName } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
@@ -48,19 +46,3 @@ export function listChannelAgentTools(params: { cfg?: ClawdbotConfig }): Channel
}
return tools;
}
export function resolveChannelMessageToolHints(params: {
cfg?: ClawdbotConfig;
channel?: string | null;
accountId?: string | null;
}): string[] {
const channelId = normalizeAnyChannelId(params.channel);
if (!channelId) return [];
const dock = getChannelDock(channelId);
const resolve = dock?.agentPrompt?.messageToolHints;
if (!resolve) return [];
const cfg = params.cfg ?? ({} as ClawdbotConfig);
return (resolve({ cfg, accountId: params.accountId }) ?? [])
.map((entry) => entry.trim())
.filter(Boolean);
}
-2
View File
@@ -44,8 +44,6 @@ describe("resolveOpencodeZenModelApi", () => {
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
});
+5 -5
View File
@@ -87,19 +87,19 @@ export function resolveOpencodeZenAlias(modelIdOrAlias: string): string {
}
/**
* OpenCode Zen routes models to specific API shapes by family.
* OpenCode Zen routes models to different APIs based on model family.
*/
export function resolveOpencodeZenModelApi(modelId: string): ModelApi {
const lower = modelId.toLowerCase();
if (lower.startsWith("gpt-")) {
return "openai-responses";
}
if (lower.startsWith("claude-") || lower.startsWith("minimax-")) {
if (lower.startsWith("claude-") || lower.startsWith("minimax") || lower.startsWith("alpha-gd4")) {
return "anthropic-messages";
}
if (lower.startsWith("gemini-")) {
return "google-generative-ai";
}
if (lower.startsWith("gpt-")) {
return "openai-responses";
}
return "openai-completions";
}
+1 -9
View File
@@ -5,7 +5,7 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
import { listChannelSupportedActions, resolveChannelMessageToolHints } from "../channel-tools.js";
import { listChannelSupportedActions } from "../channel-tools.js";
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { getMachineDisplayName } from "../../infra/machine-name.js";
@@ -245,13 +245,6 @@ export async function compactEmbeddedPiSession(params: {
channel: runtimeChannel,
})
: undefined;
const messageToolHints = runtimeChannel
? resolveChannelMessageToolHints({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
})
: undefined;
const runtimeInfo = {
host: machineName,
@@ -294,7 +287,6 @@ export async function compactEmbeddedPiSession(params: {
docsPath: docsPath ?? undefined,
promptMode,
runtimeInfo,
messageToolHints,
sandboxInfo,
tools,
modelAliasLines: buildModelAliasLines(params.config),
+1 -12
View File
@@ -7,10 +7,7 @@ import { streamSimple } from "@mariozechner/pi-ai";
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
import {
listChannelSupportedActions,
resolveChannelMessageToolHints,
} from "../../channel-tools.js";
import { listChannelSupportedActions } from "../../channel-tools.js";
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
import { getMachineDisplayName } from "../../../infra/machine-name.js";
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
@@ -263,13 +260,6 @@ export async function runEmbeddedAttempt(
channel: runtimeChannel,
})
: undefined;
const messageToolHints = runtimeChannel
? resolveChannelMessageToolHints({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
})
: undefined;
const defaultModelRef = resolveDefaultModelForAgent({
cfg: params.config ?? {},
@@ -315,7 +305,6 @@ export async function runEmbeddedAttempt(
reactionGuidance,
promptMode,
runtimeInfo,
messageToolHints,
sandboxInfo,
tools,
modelAliasLines: buildModelAliasLines(params.config),
@@ -35,7 +35,6 @@ export function buildEmbeddedSystemPrompt(params: {
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
channelActions?: string[];
};
messageToolHints?: string[];
sandboxInfo?: EmbeddedSandboxInfo;
tools: AgentTool[];
modelAliasLines: string[];
@@ -57,7 +56,6 @@ export function buildEmbeddedSystemPrompt(params: {
reactionGuidance: params.reactionGuidance,
promptMode: params.promptMode,
runtimeInfo: params.runtimeInfo,
messageToolHints: params.messageToolHints,
sandboxInfo: params.sandboxInfo,
toolNames: params.tools.map((tool) => tool.name),
toolSummaries: buildToolSummaryMap(params.tools),
+2 -6
View File
@@ -85,7 +85,6 @@ function buildMessagingSection(params: {
messageChannelOptions: string;
inlineButtonsEnabled: boolean;
runtimeChannel?: string;
messageToolHints?: string[];
}) {
if (params.isMinimal) return [];
return [
@@ -106,7 +105,6 @@ function buildMessagingSection(params: {
: params.runtimeChannel
? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to set ${params.runtimeChannel}.capabilities.inlineButtons ("dm"|"group"|"all"|"allowlist").`
: "",
...(params.messageToolHints ?? []),
]
.filter(Boolean)
.join("\n")
@@ -161,7 +159,6 @@ export function buildAgentSystemPrompt(params: {
channel?: string;
capabilities?: string[];
};
messageToolHints?: string[];
sandboxInfo?: {
enabled: boolean;
workspaceDir?: string;
@@ -200,7 +197,7 @@ export function buildAgentSystemPrompt(params: {
browser: "Control web browser",
canvas: "Present/eval/snapshot the Canvas",
nodes: "List/describe/notify/camera/screen on paired nodes",
cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
cron: "Manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)",
message: "Send messages and channel actions",
gateway: "Restart, apply config, or run updates on the running Clawdbot process",
agents_list: "List agent ids allowed for sessions_spawn",
@@ -351,7 +348,7 @@ export function buildAgentSystemPrompt(params: {
"- browser: control clawd's dedicated browser",
"- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes",
"- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
"- cron: manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)",
"- sessions_list: list sessions",
"- sessions_history: fetch session history",
"- sessions_send: send to another session",
@@ -471,7 +468,6 @@ export function buildAgentSystemPrompt(params: {
messageChannelOptions,
inlineButtonsEnabled,
runtimeChannel,
messageToolHints: params.messageToolHints,
}),
];
+1 -62
View File
@@ -85,7 +85,7 @@ describe("cron tool", () => {
});
});
it("adds recent context for systemEvent reminders when contextMessages > 0", async () => {
it("adds recent context for systemEvent reminders when session key is available", async () => {
callGatewayMock
.mockResolvedValueOnce({
messages: [
@@ -102,7 +102,6 @@ describe("cron tool", () => {
const tool = createCronTool({ agentSessionKey: "main" });
await tool.execute("call3", {
action: "add",
contextMessages: 3,
job: {
name: "reminder",
schedule: { atMs: 123 },
@@ -128,64 +127,4 @@ describe("cron tool", () => {
expect(text).toContain("Assistant: We agreed to review on Tuesday.");
expect(text).toContain("User: Remind me about the thing at 2pm");
});
it("caps contextMessages at 10", async () => {
const messages = Array.from({ length: 12 }, (_, idx) => ({
role: "user",
content: [{ type: "text", text: `Message ${idx + 1}` }],
}));
callGatewayMock.mockResolvedValueOnce({ messages }).mockResolvedValueOnce({ ok: true });
const tool = createCronTool({ agentSessionKey: "main" });
await tool.execute("call5", {
action: "add",
contextMessages: 20,
job: {
name: "reminder",
schedule: { atMs: 123 },
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
});
expect(callGatewayMock).toHaveBeenCalledTimes(2);
const historyCall = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: { limit?: number };
};
expect(historyCall.method).toBe("chat.history");
expect(historyCall.params?.limit).toBe(10);
const cronCall = callGatewayMock.mock.calls[1]?.[0] as {
params?: { payload?: { text?: string } };
};
const text = cronCall.params?.payload?.text ?? "";
expect(text).not.toMatch(/Message 1\\b/);
expect(text).not.toMatch(/Message 2\\b/);
expect(text).toContain("Message 3");
expect(text).toContain("Message 12");
});
it("does not add context when contextMessages is 0 (default)", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createCronTool({ agentSessionKey: "main" });
await tool.execute("call4", {
action: "add",
job: {
name: "reminder",
schedule: { atMs: 123 },
payload: { text: "Reminder: the thing." },
},
});
// Should only call cron.add, not chat.history
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const cronCall = callGatewayMock.mock.calls[0]?.[0] as {
method?: string;
params?: { payload?: { text?: string } };
};
expect(cronCall.method).toBe("cron.add");
const text = cronCall.params?.payload?.text ?? "";
expect(text).not.toContain("Recent context:");
});
});
+4 -18
View File
@@ -16,7 +16,7 @@ const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "runs"
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
const REMINDER_CONTEXT_MESSAGES_MAX = 10;
const REMINDER_CONTEXT_MESSAGES = 3;
const REMINDER_CONTEXT_PER_MESSAGE_MAX = 220;
const REMINDER_CONTEXT_TOTAL_MAX = 700;
const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n";
@@ -34,9 +34,6 @@ const CronToolSchema = Type.Object({
patch: Type.Optional(Type.Object({}, { additionalProperties: true })),
text: Type.Optional(Type.String()),
mode: optionalStringEnum(CRON_WAKE_MODES),
contextMessages: Type.Optional(
Type.Number({ minimum: 0, maximum: REMINDER_CONTEXT_MESSAGES_MAX }),
),
});
type CronToolOptions = {
@@ -89,13 +86,7 @@ function extractMessageText(message: ChatMessage): { role: string; text: string
async function buildReminderContextLines(params: {
agentSessionKey?: string;
gatewayOpts: GatewayCallOptions;
contextMessages: number;
}) {
const maxMessages = Math.min(
REMINDER_CONTEXT_MESSAGES_MAX,
Math.max(0, Math.floor(params.contextMessages)),
);
if (maxMessages <= 0) return [];
const sessionKey = params.agentSessionKey?.trim();
if (!sessionKey) return [];
const cfg = loadConfig();
@@ -104,13 +95,13 @@ async function buildReminderContextLines(params: {
try {
const res = (await callGatewayTool("chat.history", params.gatewayOpts, {
sessionKey: resolvedKey,
limit: maxMessages,
limit: 12,
})) as { messages?: unknown[] };
const messages = Array.isArray(res?.messages) ? res.messages : [];
const parsed = messages
.map((msg) => extractMessageText(msg as ChatMessage))
.filter((msg): msg is { role: string; text: string } => Boolean(msg));
const recent = parsed.slice(-maxMessages);
const recent = parsed.slice(-REMINDER_CONTEXT_MESSAGES);
if (recent.length === 0) return [];
const lines: string[] = [];
let total = 0;
@@ -133,7 +124,7 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool {
label: "Cron",
name: "cron",
description:
"Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use `jobId` as the canonical identifier; `id` is accepted for compatibility. Use `contextMessages` (0-10) to add previous messages as context to the job text.",
"Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use `jobId` as the canonical identifier; `id` is accepted for compatibility.",
parameters: CronToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
@@ -158,10 +149,6 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool {
throw new Error("job required");
}
const job = normalizeCronJobCreate(params.job) ?? params.job;
const contextMessages =
typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages)
? params.contextMessages
: 0;
if (
job &&
typeof job === "object" &&
@@ -173,7 +160,6 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool {
const contextLines = await buildReminderContextLines({
agentSessionKey: opts?.agentSessionKey,
gatewayOpts,
contextMessages,
});
if (contextLines.length > 0) {
const baseText = stripExistingContext(payload.text);
+3 -17
View File
@@ -2,7 +2,6 @@ import { Type } from "@sinclair/typebox";
import {
listChannelMessageActions,
supportsChannelMessageButtons,
supportsChannelMessageCards,
} from "../../channels/plugins/message-actions.js";
import {
CHANNEL_MESSAGE_ACTION_NAMES,
@@ -37,7 +36,7 @@ function buildRoutingSchema() {
};
}
function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) {
function buildSendSchema(options: { includeButtons: boolean }) {
const props: Record<string, unknown> = {
message: Type.Optional(Type.String()),
effectId: Type.Optional(
@@ -78,18 +77,8 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole
},
),
),
card: Type.Optional(
Type.Object(
{},
{
additionalProperties: true,
description: "Adaptive Card JSON object (when supported by the channel)",
},
),
),
};
if (!options.includeButtons) delete props.buttons;
if (!options.includeCards) delete props.card;
return props;
}
@@ -203,7 +192,7 @@ function buildChannelManagementSchema() {
};
}
function buildMessageToolSchemaProps(options: { includeButtons: boolean; includeCards: boolean }) {
function buildMessageToolSchemaProps(options: { includeButtons: boolean }) {
return {
...buildRoutingSchema(),
...buildSendSchema(options),
@@ -222,7 +211,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean; include
function buildMessageToolSchemaFromActions(
actions: readonly string[],
options: { includeButtons: boolean; includeCards: boolean },
options: { includeButtons: boolean },
) {
const props = buildMessageToolSchemaProps(options);
return Type.Object({
@@ -233,7 +222,6 @@ function buildMessageToolSchemaFromActions(
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
includeButtons: true,
includeCards: true,
});
type MessageToolOptions = {
@@ -250,10 +238,8 @@ type MessageToolOptions = {
function buildMessageToolSchema(cfg: ClawdbotConfig) {
const actions = listChannelMessageActions(cfg);
const includeButtons = supportsChannelMessageButtons(cfg);
const includeCards = supportsChannelMessageCards(cfg);
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
includeButtons,
includeCards,
});
}
+5 -6
View File
@@ -18,7 +18,6 @@ describe("formatAgentEnvelope", () => {
host: "mac-mini",
ip: "10.0.0.5",
timestamp: ts,
envelope: { timezone: "utc" },
body: "hello",
});
@@ -27,7 +26,7 @@ describe("formatAgentEnvelope", () => {
expect(body).toBe("[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello");
});
it("formats timestamps in local timezone by default", () => {
it("formats timestamps in UTC regardless of local timezone", () => {
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
@@ -40,10 +39,10 @@ describe("formatAgentEnvelope", () => {
process.env.TZ = originalTz;
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
});
it("formats timestamps in UTC when configured", () => {
it("formats timestamps in local timezone when configured", () => {
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
@@ -51,13 +50,13 @@ describe("formatAgentEnvelope", () => {
const body = formatAgentEnvelope({
channel: "WebChat",
timestamp: ts,
envelope: { timezone: "utc" },
envelope: { timezone: "local" },
body: "hello",
});
process.env.TZ = originalTz;
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
});
it("formats timestamps in user timezone when configured", () => {
+3 -3
View File
@@ -16,7 +16,7 @@ export type AgentEnvelopeParams = {
export type EnvelopeFormatOptions = {
/**
* "local" (default), "utc", "user", or an explicit IANA timezone string.
* "utc" (default), "local", "user", or an explicit IANA timezone string.
*/
timezone?: string;
/**
@@ -59,7 +59,7 @@ function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEn
const includeTimestamp = options?.includeTimestamp !== false;
const includeElapsed = options?.includeElapsed !== false;
return {
timezone: options?.timezone?.trim() || "local",
timezone: options?.timezone?.trim() || "utc",
includeTimestamp,
includeElapsed,
userTimezone: options?.userTimezone,
@@ -77,7 +77,7 @@ function resolveExplicitTimezone(value: string): string | undefined {
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
const trimmed = options.timezone?.trim();
if (!trimmed) return { mode: "local" };
if (!trimmed) return { mode: "utc" };
const lowered = trimmed.toLowerCase();
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" };
if (lowered === "local" || lowered === "host") return { mode: "local" };
+2 -2
View File
@@ -1,7 +1,7 @@
import type { NormalizedUsage } from "../../agents/usage.js";
import { getChannelDock } from "../../channels/dock.js";
import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js";
import { normalizeAnyChannelId, normalizeChannelId } from "../../channels/registry.js";
import { normalizeChannelId } from "../../channels/registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js";
@@ -23,7 +23,7 @@ export function buildThreadingToolContext(params: {
if (!config) return {};
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
if (!rawProvider) return {};
const provider = normalizeChannelId(rawProvider) ?? normalizeAnyChannelId(rawProvider);
const provider = normalizeChannelId(rawProvider);
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
const dock = provider ? getChannelDock(provider) : undefined;
if (!dock?.threading?.buildToolContext) {
+4 -5
View File
@@ -5,10 +5,8 @@ import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system
import { prependSystemEvents } from "./session-updates.js";
describe("prependSystemEvents", () => {
it("adds a local timestamp to queued system events by default", async () => {
it("adds a UTC timestamp to queued system events", async () => {
vi.useFakeTimers();
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
const timestamp = new Date("2026-01-12T20:19:17Z");
vi.setSystemTime(timestamp);
@@ -22,10 +20,11 @@ describe("prependSystemEvents", () => {
prefixedBodyBase: "User: hi",
});
expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
const expectedTimestamp = "2026-01-12T20:19:17Z";
expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`);
resetSystemEventsForTest();
process.env.TZ = originalTz;
vi.useRealTimers();
});
});
+4 -64
View File
@@ -1,6 +1,5 @@
import crypto from "node:crypto";
import { resolveUserTimezone } from "../../agents/date-time.js";
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
import type { ClawdbotConfig } from "../../config/config.js";
@@ -28,32 +27,9 @@ export async function prependSystemEvents(params: {
return trimmed;
};
const resolveExplicitTimezone = (value: string): string | undefined => {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
return value;
} catch {
return undefined;
}
};
const resolveSystemEventTimezone = (cfg: ClawdbotConfig) => {
const raw = cfg.agents?.defaults?.envelopeTimezone?.trim();
if (!raw) return { mode: "local" as const };
const lowered = raw.toLowerCase();
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" as const };
if (lowered === "local" || lowered === "host") return { mode: "local" as const };
if (lowered === "user") {
return {
mode: "iana" as const,
timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
};
}
const explicit = resolveExplicitTimezone(raw);
return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const };
};
const formatUtcTimestamp = (date: Date): string => {
const formatSystemEventTimestamp = (ts: number) => {
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return "unknown-time";
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
@@ -63,42 +39,6 @@ export async function prependSystemEvents(params: {
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
};
const formatZonedTimestamp = (date: Date, timeZone?: string): string | undefined => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
timeZoneName: "short",
}).formatToParts(date);
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
const yyyy = pick("year");
const mm = pick("month");
const dd = pick("day");
const hh = pick("hour");
const min = pick("minute");
const sec = pick("second");
const tz = [...parts]
.reverse()
.find((part) => part.type === "timeZoneName")
?.value?.trim();
if (!yyyy || !mm || !dd || !hh || !min || !sec) return undefined;
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
};
const formatSystemEventTimestamp = (ts: number, cfg: ClawdbotConfig) => {
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return "unknown-time";
const zone = resolveSystemEventTimezone(cfg);
if (zone.mode === "utc") return formatUtcTimestamp(date);
if (zone.mode === "local") return formatZonedTimestamp(date) ?? "unknown-time";
return formatZonedTimestamp(date, zone.timeZone) ?? "unknown-time";
};
const systemLines: string[] = [];
const queued = drainSystemEventEntries(params.sessionKey);
systemLines.push(
@@ -106,7 +46,7 @@ export async function prependSystemEvents(params: {
.map((event) => {
const compacted = compactSystemEvent(event.text);
if (!compacted) return null;
return `[${formatSystemEventTimestamp(event.ts, params.cfg)}] ${compacted}`;
return `[${formatSystemEventTimestamp(event.ts)}] ${compacted}`;
})
.filter((v): v is string => Boolean(v)),
);
-3
View File
@@ -22,7 +22,6 @@ import type {
ChannelElevatedAdapter,
ChannelGroupAdapter,
ChannelId,
ChannelAgentPromptAdapter,
ChannelMentionAdapter,
ChannelPlugin,
ChannelThreadingAdapter,
@@ -52,7 +51,6 @@ export type ChannelDock = {
groups?: ChannelGroupAdapter;
mentions?: ChannelMentionAdapter;
threading?: ChannelThreadingAdapter;
agentPrompt?: ChannelAgentPromptAdapter;
};
type ChannelDockStreaming = {
@@ -321,7 +319,6 @@ function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {
groups: plugin.groups,
mentions: plugin.mentions,
threading: plugin.threading,
agentPrompt: plugin.agentPrompt,
};
}
-7
View File
@@ -21,13 +21,6 @@ export function supportsChannelMessageButtons(cfg: ClawdbotConfig): boolean {
return false;
}
export function supportsChannelMessageCards(cfg: ClawdbotConfig): boolean {
for (const plugin of listChannelPlugins()) {
if (plugin.actions?.supportsCards?.({ cfg })) return true;
}
return false;
}
export async function dispatchChannelMessageAction(
ctx: ChannelMessageActionContext,
): Promise<AgentToolResult<unknown> | null> {
-5
View File
@@ -240,10 +240,6 @@ export type ChannelMessagingAdapter = {
}) => string;
};
export type ChannelAgentPromptAdapter = {
messageToolHints?: (params: { cfg: ClawdbotConfig; accountId?: string | null }) => string[];
};
export type ChannelDirectoryEntryKind = "user" | "group" | "channel";
export type ChannelDirectoryEntry = {
@@ -285,7 +281,6 @@ export type ChannelMessageActionAdapter = {
listActions?: (params: { cfg: ClawdbotConfig }) => ChannelMessageActionName[];
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
supportsButtons?: (params: { cfg: ClawdbotConfig }) => boolean;
supportsCards?: (params: { cfg: ClawdbotConfig }) => boolean;
extractToolSend?: (params: { args: Record<string, unknown> }) => ChannelToolSend | null;
handleAction?: (ctx: ChannelMessageActionContext) => Promise<AgentToolResult<unknown>>;
};
-2
View File
@@ -20,7 +20,6 @@ import type {
ChannelAgentToolFactory,
ChannelCapabilities,
ChannelId,
ChannelAgentPromptAdapter,
ChannelMentionAdapter,
ChannelMessageActionAdapter,
ChannelMessagingAdapter,
@@ -74,7 +73,6 @@ export type ChannelPlugin<ResolvedAccount = any> = {
streaming?: ChannelStreamingAdapter;
threading?: ChannelThreadingAdapter;
messaging?: ChannelMessagingAdapter;
agentPrompt?: ChannelAgentPromptAdapter;
directory?: ChannelDirectoryAdapter;
resolver?: ChannelResolverAdapter;
actions?: ChannelMessageActionAdapter;
-1
View File
@@ -31,7 +31,6 @@ export type {
export type {
ChannelAccountSnapshot,
ChannelAccountState,
ChannelAgentPromptAdapter,
ChannelAgentTool,
ChannelAgentToolFactory,
ChannelCapabilities,
+3 -5
View File
@@ -323,11 +323,9 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
}
if (status.vector) {
const vectorState = status.vector.enabled
? status.vector.available === undefined
? "unknown"
: status.vector.available
? "ready"
: "unavailable"
? status.vector.available
? "ready"
: "unavailable"
: "disabled";
const vectorColor =
vectorState === "ready"
+2 -2
View File
@@ -267,8 +267,8 @@ export function registerNodesInvokeCommands(nodes: Command) {
security: hostSecurity,
ask: hostAsk,
agentId,
resolvedPath: undefined,
sessionKey: undefined,
resolvedPath: null,
sessionKey: null,
timeoutMs: 120_000,
})) as { decision?: string } | null;
const decision =
-2
View File
@@ -5,7 +5,6 @@ import { CHANNEL_TARGET_DESCRIPTION } from "../../../infra/outbound/channel-targ
import { defaultRuntime } from "../../../runtime.js";
import { createDefaultDeps } from "../../deps.js";
import { runCommandWithRuntime } from "../../cli-utils.js";
import { ensurePluginRegistryLoaded } from "../../plugin-registry.js";
export type MessageCliHelpers = {
withMessageBase: (command: Command) => Command;
@@ -33,7 +32,6 @@ export function createMessageCliHelpers(
const runMessageAction = async (action: string, opts: Record<string, unknown>) => {
setVerbose(Boolean(opts.verbose));
ensurePluginRegistryLoaded();
const deps = createDefaultDeps();
await runCommandWithRuntime(
defaultRuntime,
-1
View File
@@ -19,7 +19,6 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli
"--buttons <json>",
"Telegram inline keyboard buttons as JSON (array of button rows)",
)
.option("--card <json>", "Adaptive Card JSON object (when supported by the channel)")
.option("--reply-to <id>", "Reply-to message id")
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false),
+24 -3
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpdateRunResult } from "../infra/update-runner.js";
import { parseSemver } from "../infra/runtime-guard.js";
// Mock the update-runner module
vi.mock("../infra/update-runner.js", () => ({
@@ -74,6 +75,23 @@ describe("update-cli", () => {
});
};
const readRepoVersion = async () => {
const raw = await fs.readFile(new URL("../../package.json", import.meta.url), "utf-8");
const parsed = JSON.parse(raw) as { version?: string };
if (!parsed.version) {
throw new Error("package.json version missing");
}
return parsed.version;
};
const bumpPatch = (version: string, delta: number) => {
const parsed = parseSemver(version);
if (!parsed) {
throw new Error(`invalid version: ${version}`);
}
return `${parsed.major}.${parsed.minor}.${Math.max(0, parsed.patch + delta)}`;
};
beforeEach(async () => {
vi.clearAllMocks();
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
@@ -236,7 +254,7 @@ describe("update-cli", () => {
durationMs: 100,
});
await updateCommand({ yes: true });
await updateCommand({});
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
expect(call?.channel).toBe("stable");
@@ -271,9 +289,12 @@ describe("update-cli", () => {
it("falls back to latest when beta tag is older than release", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
try {
const repoVersion = await readRepoVersion();
const currentVersion = bumpPatch(repoVersion, 0);
const latestVersion = bumpPatch(repoVersion, 1);
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "clawdbot", version: "1.0.0" }),
JSON.stringify({ name: "clawdbot", version: currentVersion }),
"utf-8",
);
@@ -302,7 +323,7 @@ describe("update-cli", () => {
});
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "1.2.3-1",
version: latestVersion,
});
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
+1 -5
View File
@@ -589,20 +589,16 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
let tag = explicitTag ?? channelToNpmTag(channel);
if (updateInstallKind !== "git") {
const currentVersion = switchToPackage ? null : await readPackageVersion(root);
let fallbackToLatest = false;
const targetVersion = explicitTag
? await resolveTargetVersion(tag, timeoutMs)
: await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
tag = resolved.tag;
fallbackToLatest = channel === "beta" && resolved.tag === "latest";
return resolved.version;
});
const cmp =
currentVersion && targetVersion ? compareSemverStrings(currentVersion, targetVersion) : null;
const needsConfirm =
!fallbackToLatest &&
currentVersion != null &&
(targetVersion == null || (cmp != null && cmp > 0));
currentVersion != null && (targetVersion == null || (cmp != null && cmp > 0));
if (needsConfirm && !opts.yes) {
if (!process.stdin.isTTY || opts.json) {
+1 -10
View File
@@ -34,7 +34,6 @@ export type AgentIdentity = {
creature?: string;
vibe?: string;
theme?: string;
avatar?: string;
};
export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
@@ -91,7 +90,6 @@ export function parseIdentityMarkdown(content: string): AgentIdentity {
if (label === "creature") identity.creature = value;
if (label === "vibe") identity.vibe = value;
if (label === "theme") identity.theme = value;
if (label === "avatar") identity.avatar = value;
}
return identity;
}
@@ -101,14 +99,7 @@ export function loadAgentIdentity(workspace: string): AgentIdentity | null {
try {
const content = fs.readFileSync(identityPath, "utf-8");
const parsed = parseIdentityMarkdown(content);
if (
!parsed.name &&
!parsed.emoji &&
!parsed.theme &&
!parsed.creature &&
!parsed.vibe &&
!parsed.avatar
) {
if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe) {
return null;
}
return parsed;
+1 -5
View File
@@ -247,7 +247,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
.join("\n");
if (!loggedInvalidConfigs.has(configPath)) {
loggedInvalidConfigs.add(configPath);
deps.logger.error(`Invalid config at ${configPath}:\\n${details}`);
deps.logger.error(`Invalid config:\\n${details}`);
}
const error = new Error("Invalid config");
(error as { code?: string; details?: string }).code = "INVALID_CONFIG";
@@ -299,10 +299,6 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
deps.logger.error(err.message);
throw err;
}
const error = err as { code?: string };
if (error?.code === "INVALID_CONFIG") {
return {};
}
deps.logger.error(`Failed to read config at ${configPath}`, err);
return {};
}
-2
View File
@@ -154,6 +154,4 @@ export type IdentityConfig = {
name?: string;
theme?: string;
emoji?: string;
/** Path to a custom avatar image (relative to workspace or absolute). */
avatar?: string;
};
-4
View File
@@ -78,8 +78,4 @@ export type MSTeamsConfig = {
replyStyle?: MSTeamsReplyStyle;
/** Per-team config. Key is team ID (from the /team/ URL path segment). */
teams?: Record<string, MSTeamsTeamConfig>;
/** Max media size in MB (default: 100MB for OneDrive upload support). */
mediaMaxMb?: number;
/** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2"). */
sharePointSiteId?: string;
};
-4
View File
@@ -599,10 +599,6 @@ export const MSTeamsConfigSchema = z
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
replyStyle: MSTeamsReplyStyleSchema.optional(),
teams: z.record(z.string(), MSTeamsTeamSchema.optional()).optional(),
/** Max media size in MB (default: 100MB for OneDrive upload support). */
mediaMaxMb: z.number().positive().optional(),
/** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2") */
sharePointSiteId: z.string().optional(),
})
.strict()
.superRefine((value, ctx) => {
-6
View File
@@ -191,12 +191,6 @@ export const ClawdbotSchema = z
bindings: BindingsSchema,
broadcast: BroadcastSchema,
audio: AudioSchema,
media: z
.object({
preserveFilenames: z.boolean().optional(),
})
.strict()
.optional(),
messages: MessagesSchema,
commands: CommandsSchema,
session: SessionSchema,
@@ -1,54 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { ExecApprovalManager } from "../exec-approval-manager.js";
import { createExecApprovalHandlers } from "./exec-approval.js";
import { validateExecApprovalRequestParams } from "../protocol/index.js";
const noop = () => {};
describe("exec approval handlers", () => {
describe("ExecApprovalRequestParams validation", () => {
it("accepts request with resolvedPath omitted", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
};
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
it("accepts request with resolvedPath as string", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
resolvedPath: "/usr/bin/echo",
};
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
it("accepts request with resolvedPath as undefined", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
resolvedPath: undefined,
};
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
// This documents the TypeBox/AJV behavior that caused the Discord exec bug:
// Type.Optional(Type.String()) does NOT accept null, only string or undefined.
it("rejects request with resolvedPath as null", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
resolvedPath: null,
};
expect(validateExecApprovalRequestParams(params)).toBe(false);
});
});
it("broadcasts request + resolve", async () => {
const manager = new ExecApprovalManager();
const handlers = createExecApprovalHandlers(manager);
-33
View File
@@ -14,7 +14,6 @@ import {
normalizeSafeBins,
resolveCommandResolution,
resolveExecApprovals,
resolveExecApprovalsFromFile,
type ExecAllowlistEntry,
} from "./exec-approvals.js";
@@ -228,35 +227,3 @@ describe("exec approvals wildcard agent", () => {
}
});
});
describe("exec approvals default agent migration", () => {
it("migrates legacy default agent entries to main", () => {
const file = {
version: 1,
agents: {
default: { allowlist: [{ pattern: "/bin/legacy" }] },
},
};
const resolved = resolveExecApprovalsFromFile({ file });
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/legacy"]);
expect(resolved.file.agents?.default).toBeUndefined();
expect(resolved.file.agents?.main?.allowlist?.[0]?.pattern).toBe("/bin/legacy");
});
it("prefers main agent settings when both main and default exist", () => {
const file = {
version: 1,
agents: {
main: { ask: "always", allowlist: [{ pattern: "/bin/main" }] },
default: { ask: "off", allowlist: [{ pattern: "/bin/legacy" }] },
},
};
const resolved = resolveExecApprovalsFromFile({ file });
expect(resolved.agent.ask).toBe("always");
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual([
"/bin/main",
"/bin/legacy",
]);
expect(resolved.file.agents?.default).toBeUndefined();
});
});
+4 -42
View File
@@ -4,8 +4,6 @@ import net from "node:net";
import os from "node:os";
import path from "node:path";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
export type ExecHost = "sandbox" | "gateway" | "node";
export type ExecSecurity = "deny" | "allowlist" | "full";
export type ExecAsk = "off" | "on-miss" | "always";
@@ -86,35 +84,6 @@ export function resolveExecApprovalsSocketPath(): string {
return expandHome(DEFAULT_SOCKET);
}
function normalizeAllowlistPattern(value: string | undefined): string | null {
const trimmed = value?.trim() ?? "";
return trimmed ? trimmed.toLowerCase() : null;
}
function mergeLegacyAgent(
current: ExecApprovalsAgent,
legacy: ExecApprovalsAgent,
): ExecApprovalsAgent {
const allowlist: ExecAllowlistEntry[] = [];
const seen = new Set<string>();
const pushEntry = (entry: ExecAllowlistEntry) => {
const key = normalizeAllowlistPattern(entry.pattern);
if (!key || seen.has(key)) return;
seen.add(key);
allowlist.push(entry);
};
for (const entry of current.allowlist ?? []) pushEntry(entry);
for (const entry of legacy.allowlist ?? []) pushEntry(entry);
return {
security: current.security ?? legacy.security,
ask: current.ask ?? legacy.ask,
askFallback: current.askFallback ?? legacy.askFallback,
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
allowlist: allowlist.length > 0 ? allowlist : undefined,
};
}
function ensureDir(filePath: string) {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
@@ -123,13 +92,6 @@ function ensureDir(filePath: string) {
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
const socketPath = file.socket?.path?.trim();
const token = file.socket?.token?.trim();
const agents = { ...file.agents };
const legacyDefault = agents.default;
if (legacyDefault) {
const main = agents[DEFAULT_AGENT_ID];
agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault;
delete agents.default;
}
const normalized: ExecApprovalsFile = {
version: 1,
socket: {
@@ -142,7 +104,7 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi
askFallback: file.defaults?.askFallback,
autoAllowSkills: file.defaults?.autoAllowSkills,
},
agents,
agents: file.agents ?? {},
};
return normalized;
}
@@ -269,7 +231,7 @@ export function resolveExecApprovalsFromFile(params: {
}): ExecApprovalsResolved {
const file = normalizeExecApprovals(params.file);
const defaults = file.defaults ?? {};
const agentKey = params.agentId ?? DEFAULT_AGENT_ID;
const agentKey = params.agentId ?? "default";
const agent = file.agents?.[agentKey] ?? {};
const wildcard = file.agents?.["*"] ?? {};
const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY;
@@ -734,7 +696,7 @@ export function recordAllowlistUse(
command: string,
resolvedPath?: string,
) {
const target = agentId ?? DEFAULT_AGENT_ID;
const target = agentId ?? "default";
const agents = approvals.agents ?? {};
const existing = agents[target] ?? {};
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
@@ -758,7 +720,7 @@ export function addAllowlistEntry(
agentId: string | undefined,
pattern: string,
) {
const target = agentId ?? DEFAULT_AGENT_ID;
const target = agentId ?? "default";
const agents = approvals.agents ?? {};
const existing = agents[target] ?? {};
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
+3 -25
View File
@@ -410,21 +410,6 @@ function parseButtonsParam(params: Record<string, unknown>): void {
}
}
function parseCardParam(params: Record<string, unknown>): void {
const raw = params.card;
if (typeof raw !== "string") return;
const trimmed = raw.trim();
if (!trimmed) {
delete params.card;
return;
}
try {
params.card = JSON.parse(trimmed) as unknown;
} catch {
throw new Error("--card must be valid JSON");
}
}
async function resolveChannel(cfg: ClawdbotConfig, params: Record<string, unknown>) {
const channelHint = readStringParam(params, "channel");
const selection = await resolveMessageChannelSelection({
@@ -573,15 +558,10 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx;
const action: ChannelMessageActionName = "send";
const to = readStringParam(params, "to", { required: true });
// Support media, path, and filePath parameters for attachments
const mediaHint =
readStringParam(params, "media", { trim: false }) ??
readStringParam(params, "path", { trim: false }) ??
readStringParam(params, "filePath", { trim: false });
const hasCard = params.card != null && typeof params.card === "object";
const mediaHint = readStringParam(params, "media", { trim: false });
let message =
readStringParam(params, "message", {
required: !mediaHint && !hasCard,
required: !mediaHint,
allowEmpty: true,
}) ?? "";
@@ -590,8 +570,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
params.message = message;
if (!params.replyTo && parsed.replyToId) params.replyTo = parsed.replyToId;
if (!params.media) {
// Use path/filePath if media not set, then fall back to parsed directives
params.media = mediaHint || parsed.mediaUrls?.[0] || parsed.mediaUrl || undefined;
params.media = parsed.mediaUrls?.[0] || parsed.mediaUrl || undefined;
}
message = await maybeApplyCrossContextMarker({
@@ -750,7 +729,6 @@ export async function runMessageAction(
const cfg = input.cfg;
const params = { ...input.params };
parseButtonsParam(params);
parseCardParam(params);
const action = input.action;
if (action === "broadcast") {
+3 -5
View File
@@ -32,11 +32,9 @@ const EXT_BY_MIME: Record<string, string> = {
"text/markdown": ".md",
};
const MIME_BY_EXT: Record<string, string> = {
...Object.fromEntries(Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime])),
// Additional extension aliases
".jpeg": "image/jpeg",
};
const MIME_BY_EXT: Record<string, string> = Object.fromEntries(
Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime]),
);
const AUDIO_FILE_EXTENSIONS = new Set([
".aac",
-110
View File
@@ -161,114 +161,4 @@ describe("media store", () => {
expect(path.extname(saved.path)).toBe(".xlsx");
});
});
describe("extractOriginalFilename", () => {
it("extracts original filename from embedded pattern", async () => {
await withTempStore(async (store) => {
// Pattern: {original}---{uuid}.{ext}
const filename = "report---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf";
const result = store.extractOriginalFilename(`/path/to/${filename}`);
expect(result).toBe("report.pdf");
});
});
it("handles uppercase UUID pattern", async () => {
await withTempStore(async (store) => {
const filename = "Document---A1B2C3D4-E5F6-7890-ABCD-EF1234567890.docx";
const result = store.extractOriginalFilename(`/media/inbound/${filename}`);
expect(result).toBe("Document.docx");
});
});
it("falls back to basename for non-matching patterns", async () => {
await withTempStore(async (store) => {
// UUID-only filename (legacy format)
const uuidOnly = "a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf";
expect(store.extractOriginalFilename(`/path/${uuidOnly}`)).toBe(uuidOnly);
// Regular filename without embedded pattern
expect(store.extractOriginalFilename("/path/to/regular.txt")).toBe("regular.txt");
// Filename with --- but invalid UUID part
expect(store.extractOriginalFilename("/path/to/foo---bar.txt")).toBe("foo---bar.txt");
});
});
it("preserves original name with special characters", async () => {
await withTempStore(async (store) => {
const filename = "报告_2024---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf";
const result = store.extractOriginalFilename(`/media/${filename}`);
expect(result).toBe("报告_2024.pdf");
});
});
});
describe("saveMediaBuffer with originalFilename", () => {
it("embeds original filename in stored path when provided", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test content");
const saved = await store.saveMediaBuffer(
buf,
"text/plain",
"inbound",
5 * 1024 * 1024,
"report.txt",
);
// Should contain the original name and a UUID pattern
expect(saved.id).toMatch(/^report---[a-f0-9-]{36}\.txt$/);
expect(saved.path).toContain("report---");
// Should be able to extract original name
const extracted = store.extractOriginalFilename(saved.path);
expect(extracted).toBe("report.txt");
});
});
it("sanitizes unsafe characters in original filename", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test");
// Filename with unsafe chars: < > : " / \ | ? *
const saved = await store.saveMediaBuffer(
buf,
"text/plain",
"inbound",
5 * 1024 * 1024,
"my<file>:test.txt",
);
// Unsafe chars should be replaced with underscores
expect(saved.id).toMatch(/^my_file_test---[a-f0-9-]{36}\.txt$/);
});
});
it("truncates long original filenames", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test");
const longName = "a".repeat(100) + ".txt";
const saved = await store.saveMediaBuffer(
buf,
"text/plain",
"inbound",
5 * 1024 * 1024,
longName,
);
// Original name should be truncated to 60 chars
const baseName = path.parse(saved.id).name.split("---")[0];
expect(baseName.length).toBeLessThanOrEqual(60);
});
});
it("falls back to UUID-only when originalFilename not provided", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test");
const saved = await store.saveMediaBuffer(buf, "text/plain", "inbound");
// Should be UUID-only pattern (legacy behavior)
expect(saved.id).toMatch(/^[a-f0-9-]{36}\.txt$/);
expect(saved.id).not.toContain("---");
});
});
});
});
+3 -52
View File
@@ -11,43 +11,6 @@ const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
const MAX_BYTES = 5 * 1024 * 1024; // 5MB default
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
/**
* Sanitize a filename for cross-platform safety.
* Removes chars unsafe on Windows/SharePoint/all platforms.
* Keeps: alphanumeric, dots, hyphens, underscores, Unicode letters/numbers.
*/
function sanitizeFilename(name: string): string {
// Remove: < > : " / \ | ? * and control chars (U+0000-U+001F)
// oxlint-disable-next-line no-control-regex -- Intentionally matching control chars
const unsafe = /[<>:"/\\|?*\x00-\x1f]/g;
const sanitized = name.trim().replace(unsafe, "_").replace(/\s+/g, "_"); // Replace whitespace runs with underscore
// Collapse multiple underscores, trim leading/trailing, limit length
return sanitized.replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 60);
}
/**
* Extract original filename from path if it matches the embedded format.
* Pattern: {original}---{uuid}.{ext} → returns "{original}.{ext}"
* Falls back to basename if no pattern match, or "file.bin" if empty.
*/
export function extractOriginalFilename(filePath: string): string {
const basename = path.basename(filePath);
if (!basename) return "file.bin"; // Fallback for empty input
const ext = path.extname(basename);
const nameWithoutExt = path.basename(basename, ext);
// Check for ---{uuid} pattern (36 chars: 8-4-4-4-12 with hyphens)
const match = nameWithoutExt.match(
/^(.+)---[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i,
);
if (match?.[1]) {
return `${match[1]}${ext}`;
}
return basename; // Fallback: use as-is
}
export function getMediaDir() {
return resolveMediaDir();
}
@@ -189,29 +152,17 @@ export async function saveMediaBuffer(
contentType?: string,
subdir = "inbound",
maxBytes = MAX_BYTES,
originalFilename?: string,
): Promise<SavedMedia> {
if (buffer.byteLength > maxBytes) {
throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`);
}
const dir = path.join(resolveMediaDir(), subdir);
await fs.mkdir(dir, { recursive: true });
const uuid = crypto.randomUUID();
const baseId = crypto.randomUUID();
const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined);
const mime = await detectMime({ buffer, headerMime: contentType });
const ext = headerExt ?? extensionForMime(mime) ?? "";
let id: string;
if (originalFilename) {
// Embed original name: {sanitized}---{uuid}.ext
const base = path.parse(originalFilename).name;
const sanitized = sanitizeFilename(base);
id = sanitized ? `${sanitized}---${uuid}${ext}` : `${uuid}${ext}`;
} else {
// Legacy: just UUID
id = ext ? `${uuid}${ext}` : uuid;
}
const ext = headerExt ?? extensionForMime(mime);
const id = ext ? `${baseId}${ext}` : baseId;
const dest = path.join(dir, id);
await fs.writeFile(dest, buffer);
return { id, path: dest, size: buffer.byteLength, contentType: mime };
+4 -75
View File
@@ -102,11 +102,6 @@ const EMBEDDING_RETRY_BASE_DELAY_MS = 500;
const EMBEDDING_RETRY_MAX_DELAY_MS = 8000;
const BATCH_FAILURE_LIMIT = 2;
const SESSION_DELTA_READ_CHUNK_BYTES = 64 * 1024;
const VECTOR_LOAD_TIMEOUT_MS = 30_000;
const EMBEDDING_QUERY_TIMEOUT_REMOTE_MS = 60_000;
const EMBEDDING_QUERY_TIMEOUT_LOCAL_MS = 5 * 60_000;
const EMBEDDING_BATCH_TIMEOUT_REMOTE_MS = 2 * 60_000;
const EMBEDDING_BATCH_TIMEOUT_LOCAL_MS = 10 * 60_000;
const log = createSubsystemLogger("memory");
@@ -286,7 +281,7 @@ export class MemoryIndexManager {
? await this.searchKeyword(cleaned, candidates).catch(() => [])
: [];
const queryVec = await this.embedQueryWithTimeout(cleaned);
const queryVec = await this.provider.embedQuery(cleaned);
const hasVector = queryVec.some((v) => v !== 0);
const vectorResults = hasVector
? await this.searchVector(queryVec, candidates).catch(() => [])
@@ -585,23 +580,9 @@ export class MemoryIndexManager {
private async ensureVectorReady(dimensions?: number): Promise<boolean> {
if (!this.vector.enabled) return false;
if (!this.vectorReady) {
this.vectorReady = this.withTimeout(
this.loadVectorExtension(),
VECTOR_LOAD_TIMEOUT_MS,
`sqlite-vec load timed out after ${Math.round(VECTOR_LOAD_TIMEOUT_MS / 1000)}s`,
);
}
let ready = false;
try {
ready = await this.vectorReady;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.vector.available = false;
this.vector.loadError = message;
this.vectorReady = null;
log.warn(`sqlite-vec unavailable: ${message}`);
return false;
this.vectorReady = this.loadVectorExtension();
}
const ready = await this.vectorReady;
if (ready && typeof dimensions === "number" && dimensions > 0) {
this.ensureVectorTable(dimensions);
}
@@ -1172,13 +1153,6 @@ export class MemoryIndexManager {
progress?: (update: MemorySyncProgressUpdate) => void;
}) {
const progress = params?.progress ? this.createSyncProgress(params.progress) : undefined;
if (progress) {
progress.report({
completed: progress.completed,
total: progress.total,
label: "Loading vector extension…",
});
}
const vectorReady = await this.ensureVectorReady();
const meta = this.readMeta();
const needsFullReindex =
@@ -1870,17 +1844,7 @@ export class MemoryIndexManager {
let delayMs = EMBEDDING_RETRY_BASE_DELAY_MS;
while (true) {
try {
const timeoutMs = this.resolveEmbeddingTimeout("batch");
log.debug("memory embeddings: batch start", {
provider: this.provider.id,
items: texts.length,
timeoutMs,
});
return await this.withTimeout(
this.provider.embedBatch(texts),
timeoutMs,
`memory embeddings batch timed out after ${Math.round(timeoutMs / 1000)}s`,
);
return await this.provider.embedBatch(texts);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!this.isRetryableEmbeddingError(message) || attempt >= EMBEDDING_RETRY_MAX_ATTEMPTS) {
@@ -1904,41 +1868,6 @@ export class MemoryIndexManager {
);
}
private resolveEmbeddingTimeout(kind: "query" | "batch"): number {
const isLocal = this.provider.id === "local";
if (kind === "query") {
return isLocal ? EMBEDDING_QUERY_TIMEOUT_LOCAL_MS : EMBEDDING_QUERY_TIMEOUT_REMOTE_MS;
}
return isLocal ? EMBEDDING_BATCH_TIMEOUT_LOCAL_MS : EMBEDDING_BATCH_TIMEOUT_REMOTE_MS;
}
private async embedQueryWithTimeout(text: string): Promise<number[]> {
const timeoutMs = this.resolveEmbeddingTimeout("query");
log.debug("memory embeddings: query start", { provider: this.provider.id, timeoutMs });
return await this.withTimeout(
this.provider.embedQuery(text),
timeoutMs,
`memory embeddings query timed out after ${Math.round(timeoutMs / 1000)}s`,
);
}
private async withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
message: string,
): Promise<T> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return await promise;
let timer: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
});
try {
return (await Promise.race([promise, timeoutPromise])) as T;
} finally {
if (timer) clearTimeout(timer);
}
}
private async runWithConcurrency<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<T[]> {
if (tasks.length === 0) return [];
const resolvedLimit = Math.max(1, Math.min(limit, tasks.length));
+1
View File
@@ -19,6 +19,7 @@ describe("plugin-sdk exports", () => {
"writeConfigFile",
"runCommandWithTimeout",
"enqueueSystemEvent",
"detectMime",
"fetchRemoteMedia",
"saveMediaBuffer",
"formatAgentEnvelope",
-5
View File
@@ -206,8 +206,6 @@ export type {
DiagnosticWebhookProcessedEvent,
DiagnosticWebhookReceivedEvent,
} from "../infra/diagnostic-events.js";
export { detectMime, extensionForMime, getFileExtension } from "../media/mime.js";
export { extractOriginalFilename } from "../media/store.js";
// Channel: Discord
export {
@@ -284,6 +282,3 @@ export { collectWhatsAppStatusIssues } from "../channels/plugins/status-issues/w
// Channel: BlueBubbles
export { collectBlueBubblesStatusIssues } from "../channels/plugins/status-issues/bluebubbles.js";
// Media utilities
export { loadWebMedia, type WebMediaResult } from "../web/media.js";
@@ -141,101 +141,83 @@ describe("createTelegramBot", () => {
// groupPolicy tests
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
const originalTz = process.env.TZ;
process.env.TZ = "UTC";
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
try {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({
identity: { name: "Bert" },
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
loadConfig.mockReturnValue({
identity: { name: "Bert" },
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
});
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "group", title: "Test Group" },
text: "bert: introduce yourself",
date: 1736380800,
message_id: 1,
from: { id: 9, first_name: "Ada" },
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
await handler({
message: {
chat: { id: 7, type: "group", title: "Test Group" },
text: "bert: introduce yourself",
date: 1736380800,
message_id: 1,
from: { id: 9, first_name: "Ada" },
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.WasMentioned).toBe(true);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
expect(payload.Body).toMatch(
/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
);
} finally {
process.env.TZ = originalTz;
}
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.WasMentioned).toBe(true);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
});
it("keeps group envelope headers stable (sender identity is separate)", async () => {
const originalTz = process.env.TZ;
process.env.TZ = "UTC";
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
try {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
});
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "hello",
date: 1736380800,
message_id: 2,
from: {
id: 99,
first_name: "Ada",
last_name: "Lovelace",
username: "ada",
},
await handler({
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "hello",
date: 1736380800,
message_id: 2,
from: {
id: 99,
first_name: "Ada",
last_name: "Lovelace",
username: "ada",
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
expect(payload.Body).toMatch(
/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
);
} finally {
process.env.TZ = originalTz;
}
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
});
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
onSpy.mockReset();
@@ -329,7 +329,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toMatch(
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09 01:00 [^\]]+\]/,
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
);
expect(payload.Body).toContain("hello world");
} finally {
+67 -85
View File
@@ -451,7 +451,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toMatch(
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09 01:00 [^\]]+\]/,
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
);
expect(payload.Body).toContain("hello world");
} finally {
@@ -551,104 +551,86 @@ describe("createTelegramBot", () => {
});
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
const originalTz = process.env.TZ;
process.env.TZ = "UTC";
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
try {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({
identity: { name: "Bert" },
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
loadConfig.mockReturnValue({
identity: { name: "Bert" },
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
});
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "group", title: "Test Group" },
text: "bert: introduce yourself",
date: 1736380800,
message_id: 1,
from: { id: 9, first_name: "Ada" },
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
await handler({
message: {
chat: { id: 7, type: "group", title: "Test Group" },
text: "bert: introduce yourself",
date: 1736380800,
message_id: 1,
from: { id: 9, first_name: "Ada" },
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.WasMentioned).toBe(true);
expect(payload.Body).toMatch(
/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
} finally {
process.env.TZ = originalTz;
}
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.WasMentioned).toBe(true);
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
});
it("includes sender identity in group envelope headers", async () => {
const originalTz = process.env.TZ;
process.env.TZ = "UTC";
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
try {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
});
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "hello",
date: 1736380800,
message_id: 2,
from: {
id: 99,
first_name: "Ada",
last_name: "Lovelace",
username: "ada",
},
await handler({
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "hello",
date: 1736380800,
message_id: 2,
from: {
id: 99,
first_name: "Ada",
last_name: "Lovelace",
username: "ada",
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.Body).toMatch(
/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
);
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
} finally {
process.env.TZ = originalTz;
}
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
});
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
@@ -329,11 +329,11 @@ describe("web auto-reply", () => {
const firstArgs = resolver.mock.calls[0][0];
const secondArgs = resolver.mock.calls[1][0];
expect(firstArgs.Body).toMatch(
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01 01:00 [^\]]+\] \[clawdbot\] first/,
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T00:00Z\] \[clawdbot\] first/,
);
expect(firstArgs.Body).not.toContain("second");
expect(secondArgs.Body).toMatch(
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01 02:00 [^\]]+\] \[clawdbot\] second/,
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T01:00Z\] \[clawdbot\] second/,
);
expect(secondArgs.Body).not.toContain("first");
+5 -10
View File
@@ -4,12 +4,11 @@ import { fileURLToPath } from "node:url";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js";
import { resolveUserPath } from "../utils.js";
import { fetchRemoteMedia } from "../media/fetch.js";
import { convertHeicToJpeg, resizeToJpeg } from "../media/image-ops.js";
import { detectMime, extensionForMime } from "../media/mime.js";
export type WebMediaResult = {
type WebMediaResult = {
buffer: Buffer;
contentType?: string;
kind: MediaKind;
@@ -90,9 +89,10 @@ async function loadWebMediaInternal(
kind: MediaKind;
fileName?: string;
}): Promise<WebMediaResult> => {
// If caller explicitly provides maxBytes, trust it (for channels that handle large files).
// Otherwise fall back to per-kind defaults.
const cap = maxBytes !== undefined ? maxBytes : maxBytesForKind(params.kind);
const cap =
maxBytes !== undefined
? Math.min(maxBytes, maxBytesForKind(params.kind))
: maxBytesForKind(params.kind);
if (params.kind === "image") {
const isGif = params.contentType === "image/gif";
if (isGif || !optimizeImages) {
@@ -141,11 +141,6 @@ async function loadWebMediaInternal(
return await clampAndFinalize({ buffer, contentType, kind, fileName });
}
// Expand tilde paths to absolute paths (e.g., ~/Downloads/photo.jpg)
if (mediaUrl.startsWith("~")) {
mediaUrl = resolveUserPath(mediaUrl);
}
// Local path
const data = await fs.readFile(mediaUrl);
const mime = await detectMime({ buffer: data, filePath: mediaUrl });
-6
View File
@@ -89,12 +89,6 @@
color: rgba(134, 142, 150, 1);
}
/* Image avatar support */
img.chat-avatar {
object-fit: cover;
object-position: center;
}
/* Minimal Bubble Design - dynamic width based on content */
.chat-bubble {
position: relative;
-6
View File
@@ -14,8 +14,6 @@ import {
startNodesPolling,
stopLogsPolling,
stopNodesPolling,
startDebugPolling,
stopDebugPolling,
} from "./app-polling";
type LifecycleHost = {
@@ -54,9 +52,6 @@ export function handleConnected(host: LifecycleHost) {
if (host.tab === "logs") {
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
}
if (host.tab === "debug") {
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
}
}
export function handleFirstUpdated(host: LifecycleHost) {
@@ -67,7 +62,6 @@ export function handleDisconnected(host: LifecycleHost) {
window.removeEventListener("popstate", host.popStateHandler);
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
detachThemeListener(
host as unknown as Parameters<typeof detachThemeListener>[0],
);
-16
View File
@@ -1,12 +1,10 @@
import { loadLogs } from "./controllers/logs";
import { loadNodes } from "./controllers/nodes";
import { loadDebug } from "./controllers/debug";
import type { ClawdbotApp } from "./app";
type PollingHost = {
nodesPollInterval: number | null;
logsPollInterval: number | null;
debugPollInterval: number | null;
tab: string;
};
@@ -37,17 +35,3 @@ export function stopLogsPolling(host: PollingHost) {
clearInterval(host.logsPollInterval);
host.logsPollInterval = null;
}
export function startDebugPolling(host: PollingHost) {
if (host.debugPollInterval != null) return;
host.debugPollInterval = window.setInterval(() => {
if (host.tab !== "debug") return;
void loadDebug(host as unknown as ClawdbotApp);
}, 3000);
}
export function stopDebugPolling(host: PollingHost) {
if (host.debugPollInterval == null) return;
clearInterval(host.debugPollInterval);
host.debugPollInterval = null;
}
-91
View File
@@ -1,91 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Tab } from "./navigation";
type SettingsHost = Parameters<typeof import("./app-settings").setTabFromRoute>[0];
const createHost = (tab: Tab): SettingsHost => ({
settings: {
gatewayUrl: "",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
},
theme: "system",
themeResolved: "dark",
applySessionKey: "main",
sessionKey: "main",
tab,
connected: false,
chatHasAutoScrolled: false,
logsAtBottom: false,
eventLog: [],
eventLogBuffer: [],
basePath: "",
themeMedia: null,
themeMediaHandler: null,
});
describe("setTabFromRoute", () => {
beforeEach(() => {
vi.resetModules();
});
it("starts and stops log polling based on the tab", async () => {
const startLogsPolling = vi.fn();
const stopLogsPolling = vi.fn();
const startDebugPolling = vi.fn();
const stopDebugPolling = vi.fn();
vi.doMock("./app-polling", () => ({
startLogsPolling,
stopLogsPolling,
startDebugPolling,
stopDebugPolling,
}));
const { setTabFromRoute } = await import("./app-settings");
const host = createHost("chat");
setTabFromRoute(host, "logs");
expect(startLogsPolling).toHaveBeenCalledTimes(1);
expect(stopLogsPolling).not.toHaveBeenCalled();
expect(startDebugPolling).not.toHaveBeenCalled();
expect(stopDebugPolling).toHaveBeenCalledTimes(1);
setTabFromRoute(host, "chat");
expect(stopLogsPolling).toHaveBeenCalledTimes(1);
});
it("starts and stops debug polling based on the tab", async () => {
const startLogsPolling = vi.fn();
const stopLogsPolling = vi.fn();
const startDebugPolling = vi.fn();
const stopDebugPolling = vi.fn();
vi.doMock("./app-polling", () => ({
startLogsPolling,
stopLogsPolling,
startDebugPolling,
stopDebugPolling,
}));
const { setTabFromRoute } = await import("./app-settings");
const host = createHost("chat");
setTabFromRoute(host, "debug");
expect(startDebugPolling).toHaveBeenCalledTimes(1);
expect(stopDebugPolling).not.toHaveBeenCalled();
expect(startLogsPolling).not.toHaveBeenCalled();
expect(stopLogsPolling).toHaveBeenCalledTimes(1);
setTabFromRoute(host, "chat");
expect(stopDebugPolling).toHaveBeenCalledTimes(1);
});
});
+1 -7
View File
@@ -14,7 +14,7 @@ import { saveSettings, type UiSettings } from "./storage";
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
import { startLogsPolling, stopLogsPolling } from "./app-polling";
import { refreshChat } from "./app-chat";
import type { ClawdbotApp } from "./app";
@@ -116,9 +116,6 @@ export function setTab(host: SettingsHost, next: Tab) {
if (next === "logs")
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
if (next === "debug")
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
else stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
void refreshActiveTab(host);
syncUrlWithTab(host, next, false);
}
@@ -264,9 +261,6 @@ export function setTabFromRoute(host: SettingsHost, next: Tab) {
if (next === "logs")
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
if (next === "debug")
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
else stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
if (host.connected) void refreshActiveTab(host);
}
-1
View File
@@ -226,7 +226,6 @@ export class ClawdbotApp extends LitElement {
private chatUserNearBottom = true;
private nodesPollInterval: number | null = null;
private logsPollInterval: number | null = null;
private debugPollInterval: number | null = null;
private logsScrollFrame: number | null = null;
private toolStreamById = new Map<string, ToolStreamEntry>();
private toolStreamOrder: string[] = [];
+1 -7
View File
@@ -105,7 +105,7 @@ export function renderMessageGroup(
`;
}
function renderAvatar(role: string, avatarUrl?: string) {
function renderAvatar(role: string) {
const normalized = normalizeRoleForGrouping(role);
const initial =
normalized === "user"
@@ -123,12 +123,6 @@ function renderAvatar(role: string, avatarUrl?: string) {
: normalized === "tool"
? "tool"
: "other";
// If avatar URL is provided for assistant, show image
if (avatarUrl && normalized === "assistant") {
return html`<img class="chat-avatar ${className}" src="${avatarUrl}" alt="Assistant" />`;
}
return html`<div class="chat-avatar ${className}">${initial}</div>`;
}
+1 -5
View File
@@ -1,7 +1,7 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./views/config-form";
import { analyzeConfigSchema, renderConfigForm } from "./views/config-form";
const rootSchema = {
type: "object",
@@ -40,10 +40,6 @@ const rootSchema = {
};
describe("config form renderer", () => {
it("exposes section metadata", () => {
expect(SECTION_META.env.label).toBe("Environment Variables");
});
it("renders inputs and patches values", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
+1 -1
View File
@@ -54,7 +54,7 @@ const sectionIcons = {
};
// Section metadata
export const SECTION_META: Record<string, { label: string; description: string }> = {
const SECTION_META: Record<string, { label: string; description: string }> = {
env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" },
update: { label: "Updates", description: "Auto-update settings and release channel" },
agents: { label: "Agents", description: "Agent configurations, models, and identities" },
+1 -1
View File
@@ -1,4 +1,4 @@
export { renderConfigForm, type ConfigFormProps, SECTION_META } from "./config-form.render";
export { renderConfigForm, type ConfigFormProps } from "./config-form.render";
export {
analyzeConfigSchema,
type ConfigSchemaAnalysis,
+1 -1
View File
@@ -1,6 +1,6 @@
import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form";
import { analyzeConfigSchema, renderConfigForm } from "./config-form";
import {
hintForPath,
humanize,