Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d583e877b | ||
|
|
0c55b1e9ce | ||
|
|
51cd9c7ff4 | ||
|
|
0c3d46cb72 | ||
|
|
654f9e5053 | ||
|
|
17fad54ca0 | ||
|
|
0f7f7bb95f | ||
|
|
ffbf75d740 | ||
|
|
4642fae193 | ||
|
|
5fe8c4ab8c | ||
|
|
7b8405cbfb | ||
|
|
a96e7f59c0 | ||
|
|
57f3d209de | ||
|
|
40757a8c18 | ||
|
|
472b8fe15d | ||
|
|
721737cc77 | ||
|
|
464de2978b | ||
|
|
9d22646120 | ||
|
|
f1aa260b0e | ||
|
|
b5c307d07f | ||
|
|
2e1514095d | ||
|
|
f4b3f33c8e | ||
|
|
2d1d793651 | ||
|
|
2f47b3f6bd | ||
|
|
302bb64457 | ||
|
|
de898c423b | ||
|
|
47ebe29195 | ||
|
|
cc74e0d188 | ||
|
|
d7d98c3971 | ||
|
|
5bf7a9d0db | ||
|
|
3ad0d2fe23 | ||
|
|
da98528651 | ||
|
|
75dd1781b7 | ||
|
|
1b947dcdf9 | ||
|
|
9497ffcc50 | ||
|
|
032c780a79 | ||
|
|
e011c764a7 | ||
|
|
b2650ba672 | ||
|
|
147fccd967 |
No files matched your search
@@ -5,11 +5,21 @@ 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.
|
||||
|
||||
### Fixes
|
||||
- Config: avoid stack traces for invalid configs and log the config path.
|
||||
- Doctor: warn when gateway.mode is unset with configure/config guidance.
|
||||
- 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.
|
||||
|
||||
## 2026.1.21
|
||||
|
||||
|
||||
@@ -471,30 +471,34 @@ 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/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>
|
||||
<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>
|
||||
</p>
|
||||
+5
-5
@@ -3,13 +3,13 @@
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.20</title>
|
||||
<title>2026.1.21</title>
|
||||
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>7116</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.20</sparkle:shortVersionString>
|
||||
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.20</h2>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui</li>
|
||||
@@ -190,7 +190,7 @@
|
||||
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.20/Clawdbot-2026.1.20.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.16-2</title>
|
||||
@@ -290,4 +290,4 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202601200
|
||||
versionName = "2026.1.20"
|
||||
versionCode = 202601210
|
||||
versionName = "2026.1.21"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.20</string>
|
||||
<string>2026.1.21</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260120</string>
|
||||
<string>20260121</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.20</string>
|
||||
<string>2026.1.21</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260120</string>
|
||||
<string>20260121</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -81,8 +81,8 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: Clawdbot
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.1.20"
|
||||
CFBundleVersion: "20260120"
|
||||
CFBundleShortVersionString: "2026.1.21"
|
||||
CFBundleVersion: "20260121"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -130,5 +130,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: ClawdbotTests
|
||||
CFBundleShortVersionString: "2026.1.20"
|
||||
CFBundleVersion: "20260120"
|
||||
CFBundleShortVersionString: "2026.1.21"
|
||||
CFBundleVersion: "20260121"
|
||||
@@ -6,15 +6,20 @@ final class ConnectionModeCoordinator {
|
||||
static let shared = ConnectionModeCoordinator()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "connection")
|
||||
private var lastMode: AppState.ConnectionMode?
|
||||
|
||||
/// Apply the requested connection mode by starting/stopping local gateway,
|
||||
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
|
||||
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
|
||||
if let lastMode = self.lastMode, lastMode != mode {
|
||||
GatewayProcessManager.shared.clearLastFailure()
|
||||
NodesStore.shared.lastError = nil
|
||||
}
|
||||
self.lastMode = mode
|
||||
switch mode {
|
||||
case .unconfigured:
|
||||
if let error = await NodeServiceManager.stop() {
|
||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||
}
|
||||
_ = await NodeServiceManager.stop()
|
||||
NodesStore.shared.lastError = nil
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
GatewayProcessManager.shared.stop()
|
||||
@@ -23,9 +28,8 @@ final class ConnectionModeCoordinator {
|
||||
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
|
||||
|
||||
case .local:
|
||||
if let error = await NodeServiceManager.stop() {
|
||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||
}
|
||||
_ = await NodeServiceManager.stop()
|
||||
NodesStore.shared.lastError = nil
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
||||
@@ -56,6 +60,7 @@ final class ConnectionModeCoordinator {
|
||||
WebChatManager.shared.resetTunnels()
|
||||
|
||||
do {
|
||||
NodesStore.shared.lastError = nil
|
||||
if let error = await NodeServiceManager.start() {
|
||||
NodesStore.shared.lastError = "Node service start failed: \(error)"
|
||||
}
|
||||
|
||||
@@ -42,10 +42,20 @@ final class GatewayProcessManager {
|
||||
private var environmentRefreshTask: Task<Void, Never>?
|
||||
private var lastEnvironmentRefresh: Date?
|
||||
private var logRefreshTask: Task<Void, Never>?
|
||||
#if DEBUG
|
||||
private var testingConnection: GatewayConnection?
|
||||
#endif
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.process")
|
||||
|
||||
private let logLimit = 20000 // characters to keep in-memory
|
||||
private let environmentRefreshMinInterval: TimeInterval = 30
|
||||
private var connection: GatewayConnection {
|
||||
#if DEBUG
|
||||
return self.testingConnection ?? .shared
|
||||
#else
|
||||
return .shared
|
||||
#endif
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
// Remote mode should never spawn a local gateway; treat as stopped.
|
||||
@@ -126,6 +136,10 @@ final class GatewayProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
func clearLastFailure() {
|
||||
self.lastFailureReason = nil
|
||||
}
|
||||
|
||||
func refreshEnvironmentStatus(force: Bool = false) {
|
||||
let now = Date()
|
||||
if !force {
|
||||
@@ -178,7 +192,7 @@ final class GatewayProcessManager {
|
||||
let hasListener = instance != nil
|
||||
|
||||
let attemptAttach = {
|
||||
try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000)
|
||||
try await self.connection.requestRaw(method: .health, timeoutMs: 2000)
|
||||
}
|
||||
|
||||
for attempt in 0..<(hasListener ? 3 : 1) {
|
||||
@@ -187,6 +201,7 @@ final class GatewayProcessManager {
|
||||
let snap = decodeHealthSnapshot(from: data)
|
||||
let details = self.describe(details: instanceText, port: port, snap: snap)
|
||||
self.existingGatewayDetails = details
|
||||
self.clearLastFailure()
|
||||
self.status = .attachedExisting(details: details)
|
||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||
self.logger.info("gateway using existing instance details=\(details)")
|
||||
@@ -310,9 +325,10 @@ final class GatewayProcessManager {
|
||||
while Date() < deadline {
|
||||
if !self.desiredActive { return }
|
||||
do {
|
||||
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
|
||||
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
||||
let instance = await PortGuardian.shared.describe(port: port)
|
||||
let details = instance.map { "pid \($0.pid)" }
|
||||
self.clearLastFailure()
|
||||
self.status = .running(details: details)
|
||||
self.logger.info("gateway started details=\(details ?? "ok")")
|
||||
self.refreshControlChannelIfNeeded(reason: "gateway started")
|
||||
@@ -352,7 +368,8 @@ final class GatewayProcessManager {
|
||||
while Date() < deadline {
|
||||
if !self.desiredActive { return false }
|
||||
do {
|
||||
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
|
||||
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
||||
self.clearLastFailure()
|
||||
return true
|
||||
} catch {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
@@ -385,3 +402,19 @@ final class GatewayProcessManager {
|
||||
return String(text.suffix(limit))
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension GatewayProcessManager {
|
||||
func setTestingConnection(_ connection: GatewayConnection?) {
|
||||
self.testingConnection = connection
|
||||
}
|
||||
|
||||
func setTestingDesiredActive(_ active: Bool) {
|
||||
self.desiredActive = active
|
||||
}
|
||||
|
||||
func setTestingLastFailureReason(_ reason: String?) {
|
||||
self.lastFailureReason = reason
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.20</string>
|
||||
<string>2026.1.21</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601200</string>
|
||||
<string>202601210</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Clawdbot</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
import os
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct GatewayProcessManagerTests {
|
||||
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
|
||||
private let pendingReceiveHandler =
|
||||
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
|
||||
-> Void)?>(initialState: nil)
|
||||
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
|
||||
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
|
||||
|
||||
var state: URLSessionTask.State = .suspended
|
||||
|
||||
func resume() {
|
||||
self.state = .running
|
||||
}
|
||||
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
_ = (closeCode, reason)
|
||||
self.state = .canceling
|
||||
self.cancelCount.withLock { $0 += 1 }
|
||||
let handler = self.pendingReceiveHandler.withLock { handler in
|
||||
defer { handler = nil }
|
||||
return handler
|
||||
}
|
||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||
let currentSendCount = self.sendCount.withLock { count in
|
||||
defer { count += 1 }
|
||||
return count
|
||||
}
|
||||
|
||||
if currentSendCount == 0 {
|
||||
guard case let .data(data) = message else { return }
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
(obj["type"] as? String) == "req",
|
||||
(obj["method"] as? String) == "connect",
|
||||
let id = obj["id"] as? String
|
||||
{
|
||||
self.connectRequestID.withLock { $0 = id }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard case let .data(data) = message else { return }
|
||||
guard
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
(obj["type"] as? String) == "req",
|
||||
let id = obj["id"] as? String
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let response = Self.responseData(id: id)
|
||||
let handler = self.pendingReceiveHandler.withLock { $0 }
|
||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
|
||||
}
|
||||
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
||||
return .data(Self.connectOkData(id: id))
|
||||
}
|
||||
|
||||
func receive(
|
||||
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
||||
{
|
||||
self.pendingReceiveHandler.withLock { $0 = completionHandler }
|
||||
}
|
||||
|
||||
private static func connectOkData(id: String) -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"type": "hello-ok",
|
||||
"protocol": 2,
|
||||
"server": { "version": "test", "connId": "test" },
|
||||
"features": { "methods": [], "events": [] },
|
||||
"snapshot": {
|
||||
"presence": [ { "ts": 1 } ],
|
||||
"health": {},
|
||||
"stateVersion": { "presence": 0, "health": 0 },
|
||||
"uptimeMs": 0
|
||||
},
|
||||
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
private static func responseData(id: String) -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": { "ok": true }
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||
private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]())
|
||||
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
_ = url
|
||||
let task = FakeWebSocketTask()
|
||||
self.tasks.withLock { $0.append(task) }
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func clearsLastFailureWhenHealthSucceeds() async {
|
||||
let session = FakeWebSocketSession()
|
||||
let url = URL(string: "ws://example.invalid")!
|
||||
let connection = GatewayConnection(
|
||||
configProvider: { (url: url, token: nil, password: nil) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
|
||||
let manager = GatewayProcessManager.shared
|
||||
manager.setTestingConnection(connection)
|
||||
manager.setTestingDesiredActive(true)
|
||||
manager.setTestingLastFailureReason("health failed")
|
||||
defer {
|
||||
manager.setTestingConnection(nil)
|
||||
manager.setTestingDesiredActive(false)
|
||||
manager.setTestingLastFailureReason(nil)
|
||||
}
|
||||
|
||||
let ready = await manager.waitForGatewayReady(timeout: 0.5)
|
||||
#expect(ready)
|
||||
#expect(manager.lastFailureReason == nil)
|
||||
}
|
||||
}
|
||||
+149
-3
@@ -8,9 +8,9 @@ read_when:
|
||||
> "Abandon all hope, ye who enter here."
|
||||
|
||||
|
||||
Updated: 2026-01-16
|
||||
Updated: 2026-01-21
|
||||
|
||||
Status: text + DM attachments are supported; channel/group attachments require Microsoft Graph permissions. Polls are sent via Adaptive Cards.
|
||||
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.
|
||||
|
||||
## 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 used for polls; other card types are not yet supported
|
||||
- Adaptive Cards are supported for polls and arbitrary card sends (see below)
|
||||
|
||||
## Configuration
|
||||
Key settings (see `/gateway/configuration` for shared channel patterns):
|
||||
@@ -422,6 +422,7 @@ 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)):
|
||||
@@ -471,6 +472,75 @@ 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).
|
||||
|
||||
@@ -479,6 +549,82 @@ 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.
|
||||
|
||||
@@ -100,6 +100,11 @@ Groups:
|
||||
- Use `channels.signal.ignoreAttachments` to skip downloading media.
|
||||
- Group history context uses `channels.signal.historyLimit` (or `channels.signal.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||
|
||||
## Typing + read receipts
|
||||
- **Typing indicators**: Clawdbot sends typing signals via `signal-cli sendTyping` and refreshes them while a reply is running.
|
||||
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
|
||||
- Signal-cli does not expose read receipts for groups.
|
||||
|
||||
## Delivery targets (CLI/cron)
|
||||
- DMs: `signal:+15551234567` (or plain E.164).
|
||||
- Groups: `signal:group:<groupId>`.
|
||||
|
||||
@@ -30,17 +30,17 @@ Notes:
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
BUNDLE_ID=com.clawdbot.mac \
|
||||
APP_VERSION=2026.1.20 \
|
||||
APP_VERSION=2026.1.21 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-app.sh
|
||||
|
||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.20.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.21.zip
|
||||
|
||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.20.dmg
|
||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
@@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.20.dmg
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
||||
BUNDLE_ID=com.clawdbot.mac \
|
||||
APP_VERSION=2026.1.20 \
|
||||
APP_VERSION=2026.1.21 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Optional: ship dSYM alongside the release
|
||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.20.dSYM.zip
|
||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.21.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.20.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.21.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||
```
|
||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
||||
|
||||
## Publish & verify
|
||||
- Upload `Clawdbot-2026.1.20.zip` (and `Clawdbot-2026.1.20.dSYM.zip`) to the GitHub release for tag `v2026.1.20`.
|
||||
- Upload `Clawdbot-2026.1.21.zip` (and `Clawdbot-2026.1.21.dSYM.zip`) to the GitHub release for tag `v2026.1.21`.
|
||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
||||
- Sanity checks:
|
||||
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
||||
|
||||
+4
-1
@@ -154,6 +154,9 @@ 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`
|
||||
@@ -319,7 +322,7 @@ Notes:
|
||||
Send messages and channel actions across Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams.
|
||||
|
||||
Core actions:
|
||||
- `send` (text + optional media)
|
||||
- `send` (text + optional media; MS Teams also supports `card` for Adaptive Cards)
|
||||
- `poll` (WhatsApp/Discord/MS Teams polls)
|
||||
- `react` / `reactions` / `read` / `edit` / `delete`
|
||||
- `pin` / `unpin` / `list-pins`
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
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
|
||||
|
||||
Lobster is a workflow shell that lets Clawdbot run multi-step tool sequences as a single, deterministic operation with explicit approval checkpoints.
|
||||
|
||||
## Why
|
||||
|
||||
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.
|
||||
- **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
|
||||
→ LLM summarizes
|
||||
→ User: "draft replies to #2 and #5"
|
||||
→ LLM drafts
|
||||
→ User: "send #2"
|
||||
→ clawd calls gmail.send
|
||||
(repeat daily, no memory of what was triaged)
|
||||
```
|
||||
|
||||
With Lobster:
|
||||
```json
|
||||
{
|
||||
"action": "run",
|
||||
"pipeline": "email.triage --limit 20",
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
```
|
||||
|
||||
Returns a JSON envelope (truncated):
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"status": "needs_approval",
|
||||
"output": [{ "summary": "5 need replies, 2 need action" }],
|
||||
"requiresApproval": {
|
||||
"type": "approval_request",
|
||||
"prompt": "Send 2 draft replies?",
|
||||
"items": [],
|
||||
"resumeToken": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
User approves → resume:
|
||||
```json
|
||||
{
|
||||
"action": "resume",
|
||||
"token": "<resumeToken>",
|
||||
"approve": true
|
||||
}
|
||||
```
|
||||
|
||||
One workflow. Deterministic. Safe.
|
||||
|
||||
## Tool parameters
|
||||
|
||||
### `run`
|
||||
|
||||
Run a 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
|
||||
}
|
||||
```
|
||||
|
||||
### `resume`
|
||||
|
||||
Continue a halted workflow after approval.
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "resume",
|
||||
"token": "<resumeToken>",
|
||||
"approve": true
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
- **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.
|
||||
- **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)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/bluebubbles",
|
||||
"version": "2026.1.21-1",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot BlueBubbles channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/copilot-proxy",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Copilot Proxy provider plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/diagnostics-otel",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
||||
"clawdbot": {
|
||||
@@ -10,15 +10,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.210.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.210.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.210.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.210.0",
|
||||
"@opentelemetry/resources": "^2.4.0",
|
||||
"@opentelemetry/sdk-logs": "^0.210.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.4.0",
|
||||
"@opentelemetry/sdk-node": "^0.210.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.4.0",
|
||||
"@opentelemetry/api-logs": "^0.211.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.211.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.211.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.211.0",
|
||||
"@opentelemetry/resources": "^2.5.0",
|
||||
"@opentelemetry/sdk-logs": "^0.211.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.5.0",
|
||||
"@opentelemetry/sdk-node": "^0.211.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.5.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.39.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/discord",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Discord channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/google-antigravity-auth",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/google-gemini-cli-auth",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/imessage",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot iMessage channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Lobster (plugin)
|
||||
|
||||
Adds the `lobster` agent tool as an **optional** plugin tool.
|
||||
|
||||
## What this is
|
||||
|
||||
- Lobster is a standalone workflow shell (typed JSON-first pipelines + approvals/resume).
|
||||
- This plugin integrates Lobster with Clawdbot *without core changes*.
|
||||
|
||||
## Enable
|
||||
|
||||
Because this tool can trigger side effects (via workflows), it is registered with `optional: true`.
|
||||
|
||||
Enable it in an agent allowlist:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"tools": {
|
||||
"allow": [
|
||||
"lobster" // plugin id (enables all tools from this plugin)
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- Runs the `lobster` executable as a local subprocess.
|
||||
- Does not manage OAuth/tokens.
|
||||
- Uses timeouts, stdout caps, and strict JSON envelope parsing.
|
||||
- Prefer an absolute `lobsterPath` in production to avoid PATH hijack.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Lobster
|
||||
|
||||
Lobster executes multi-step workflows with approval checkpoints. Use it when:
|
||||
|
||||
- User wants a repeatable automation (triage, monitor, sync)
|
||||
- Actions need human approval before executing (send, post, delete)
|
||||
- Multiple tool calls should run as one deterministic operation
|
||||
|
||||
## When to use Lobster
|
||||
|
||||
| User intent | Use Lobster? |
|
||||
|-------------|--------------|
|
||||
| "Triage my email" | Yes — multi-step, may send replies |
|
||||
| "Send a message" | No — single action, use message tool directly |
|
||||
| "Check my email every morning and ask before replying" | Yes — scheduled workflow with approval |
|
||||
| "What's the weather?" | No — simple query |
|
||||
| "Monitor this PR and notify me of changes" | Yes — stateful, recurring |
|
||||
|
||||
## Basic usage
|
||||
|
||||
### Run a pipeline
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "run",
|
||||
"pipeline": "gog.gmail.search --query 'newer_than:1d' --max 20 | email.triage"
|
||||
}
|
||||
```
|
||||
|
||||
Returns structured result:
|
||||
```json
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"ok": true,
|
||||
"status": "ok",
|
||||
"output": [{ "summary": {...}, "items": [...] }],
|
||||
"requiresApproval": null
|
||||
}
|
||||
```
|
||||
|
||||
### Handle approval
|
||||
|
||||
If the workflow needs approval:
|
||||
```json
|
||||
{
|
||||
"status": "needs_approval",
|
||||
"output": [],
|
||||
"requiresApproval": {
|
||||
"prompt": "Send 3 draft replies?",
|
||||
"items": [...],
|
||||
"resumeToken": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Present the prompt to the user. If they approve:
|
||||
```json
|
||||
{
|
||||
"action": "resume",
|
||||
"token": "<resumeToken>",
|
||||
"approve": true
|
||||
}
|
||||
```
|
||||
|
||||
## Example workflows
|
||||
|
||||
### Email triage
|
||||
```
|
||||
gog.gmail.search --query 'newer_than:1d' --max 20 | email.triage
|
||||
```
|
||||
Fetches recent emails, classifies into buckets (needs_reply, needs_action, fyi).
|
||||
|
||||
### Email triage with approval gate
|
||||
```
|
||||
gog.gmail.search --query 'newer_than:1d' | email.triage | approve --prompt 'Process these?'
|
||||
```
|
||||
Same as above, but halts for approval before returning.
|
||||
|
||||
## Key behaviors
|
||||
|
||||
- **Deterministic**: Same input → same output (no LLM variance in pipeline execution)
|
||||
- **Approval gates**: `approve` command halts execution, returns token
|
||||
- **Resumable**: Use `resume` action with token to continue
|
||||
- **Structured output**: Always returns JSON envelope with `protocolVersion`
|
||||
|
||||
## Don't use Lobster for
|
||||
|
||||
- Simple single-action requests (just use the tool directly)
|
||||
- Queries that need LLM interpretation mid-flow
|
||||
- One-off tasks that won't be repeated
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "lobster",
|
||||
"name": "Lobster",
|
||||
"description": "Typed workflow tool with resumable approvals.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
|
||||
|
||||
import { createLobsterTool } from "./src/lobster-tool.js";
|
||||
|
||||
export default function register(api: ClawdbotPluginApi) {
|
||||
api.registerTool(
|
||||
(ctx) => {
|
||||
if (ctx.sandboxed) return null;
|
||||
return createLobsterTool(api);
|
||||
},
|
||||
{ optional: true },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@clawdbot/lobster",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
const binPath = path.join(dir, "lobster");
|
||||
const file = `#!/usr/bin/env node\n${scriptBody}\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",
|
||||
name: "lobster",
|
||||
source: "test",
|
||||
config: {} as any,
|
||||
runtime: { version: "test" } as any,
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
||||
registerTool() {},
|
||||
registerHttpHandler() {},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
registerService() {},
|
||||
registerProvider() {},
|
||||
resolvePath: (p) => p,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeCtx(overrides: Partial<ClawdbotPluginToolContext> = {}): ClawdbotPluginToolContext {
|
||||
return {
|
||||
config: {} as any,
|
||||
workspaceDir: "/tmp",
|
||||
agentDir: "/tmp",
|
||||
agentId: "main",
|
||||
sessionKey: "main",
|
||||
messageChannel: undefined,
|
||||
agentAccountId: undefined,
|
||||
sandboxed: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("lobster plugin tool", () => {
|
||||
it("runs lobster and returns parsed envelope in details", async () => {
|
||||
const fake = await writeFakeLobster({
|
||||
payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
|
||||
});
|
||||
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
const res = await tool.execute("call1", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
lobsterPath: fake.binPath,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
||||
});
|
||||
|
||||
it("requires absolute lobsterPath when provided", async () => {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call2", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
lobsterPath: "./lobster",
|
||||
}),
|
||||
).rejects.toThrow(/absolute path/);
|
||||
});
|
||||
|
||||
it("rejects invalid JSON from lobster", async () => {
|
||||
const { binPath } = await writeFakeLobsterScript(
|
||||
`process.stdout.write("nope");\n`,
|
||||
"clawdbot-lobster-plugin-bad-",
|
||||
);
|
||||
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call3", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
lobsterPath: binPath,
|
||||
}),
|
||||
).rejects.toThrow(/invalid JSON/);
|
||||
});
|
||||
|
||||
it("can be gated off in sandboxed contexts", async () => {
|
||||
const api = fakeApi();
|
||||
const factoryTool = (ctx: ClawdbotPluginToolContext) => {
|
||||
if (ctx.sandboxed) return null;
|
||||
return createLobsterTool(api);
|
||||
};
|
||||
|
||||
expect(factoryTool(fakeCtx({ sandboxed: true }))).toBeNull();
|
||||
expect(factoryTool(fakeCtx({ sandboxed: false }))?.name).toBe("lobster");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ClawdbotPluginApi } from "../../../src/plugins/types.js";
|
||||
|
||||
type LobsterEnvelope =
|
||||
| {
|
||||
ok: true;
|
||||
status: "ok" | "needs_approval" | "cancelled";
|
||||
output: unknown[];
|
||||
requiresApproval: null | {
|
||||
type: "approval_request";
|
||||
prompt: string;
|
||||
items: unknown[];
|
||||
resumeToken?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: { type?: string; message: string };
|
||||
};
|
||||
|
||||
function resolveExecutablePath(lobsterPathRaw: string | undefined) {
|
||||
const lobsterPath = lobsterPathRaw?.trim() || "lobster";
|
||||
if (lobsterPath !== "lobster" && !path.isAbsolute(lobsterPath)) {
|
||||
throw new Error("lobsterPath must be an absolute path (or omit to use PATH)");
|
||||
}
|
||||
return lobsterPath;
|
||||
}
|
||||
|
||||
async function runLobsterSubprocess(params: {
|
||||
execPath: string;
|
||||
argv: string[];
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
maxStdoutBytes: number;
|
||||
}) {
|
||||
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) => {
|
||||
const child = spawn(execPath, argv, {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stdoutBytes = 0;
|
||||
let stderr = "";
|
||||
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
const str = String(chunk);
|
||||
stdoutBytes += Buffer.byteLength(str, "utf8");
|
||||
if (stdoutBytes > maxStdoutBytes) {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} finally {
|
||||
reject(new Error("lobster output exceeded maxStdoutBytes"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
stdout += str;
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} finally {
|
||||
reject(new Error("lobster subprocess timed out"));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
child.once("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
child.once("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`));
|
||||
return;
|
||||
}
|
||||
resolve({ stdout });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseEnvelope(stdout: string): LobsterEnvelope {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(stdout);
|
||||
} catch {
|
||||
throw new Error("lobster returned invalid JSON");
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("lobster returned invalid JSON envelope");
|
||||
}
|
||||
|
||||
const ok = (parsed as { ok?: unknown }).ok;
|
||||
if (ok === true || ok === false) {
|
||||
return parsed as LobsterEnvelope;
|
||||
}
|
||||
|
||||
throw new Error("lobster returned invalid JSON envelope");
|
||||
}
|
||||
|
||||
export function createLobsterTool(api: ClawdbotPluginApi) {
|
||||
return {
|
||||
name: "lobster",
|
||||
description:
|
||||
"Run Lobster pipelines as a local-first workflow runtime (typed JSON envelope + resumable approvals).",
|
||||
parameters: Type.Object({
|
||||
// NOTE: Prefer string enums in tool schemas; some providers reject unions/anyOf.
|
||||
action: Type.Unsafe<"run" | "resume">({ type: "string", enum: ["run", "resume"] }),
|
||||
pipeline: Type.Optional(Type.String()),
|
||||
token: Type.Optional(Type.String()),
|
||||
approve: Type.Optional(Type.Boolean()),
|
||||
lobsterPath: Type.Optional(Type.String()),
|
||||
cwd: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
maxStdoutBytes: Type.Optional(Type.Number()),
|
||||
}),
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const action = String(params.action || "").trim();
|
||||
if (!action) throw new Error("action required");
|
||||
|
||||
const execPath = resolveExecutablePath(
|
||||
typeof params.lobsterPath === "string" ? params.lobsterPath : undefined,
|
||||
);
|
||||
const cwd = typeof params.cwd === "string" && params.cwd.trim() ? params.cwd.trim() : process.cwd();
|
||||
const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 20_000;
|
||||
const maxStdoutBytes = typeof params.maxStdoutBytes === "number" ? params.maxStdoutBytes : 512_000;
|
||||
|
||||
const argv = (() => {
|
||||
if (action === "run") {
|
||||
const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
|
||||
if (!pipeline.trim()) throw new Error("pipeline required");
|
||||
return ["run", "--mode", "tool", pipeline];
|
||||
}
|
||||
if (action === "resume") {
|
||||
const token = typeof params.token === "string" ? params.token : "";
|
||||
if (!token.trim()) throw new Error("token required");
|
||||
const approve = params.approve;
|
||||
if (typeof approve !== "boolean") throw new Error("approve required");
|
||||
return ["resume", "--token", token, "--approve", approve ? "yes" : "no"];
|
||||
}
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
})();
|
||||
|
||||
if (api.runtime?.version && api.logger?.debug) {
|
||||
api.logger.debug(`lobster plugin runtime=${api.runtime.version}`);
|
||||
}
|
||||
|
||||
const { stdout } = await runLobsterSubprocess({
|
||||
execPath,
|
||||
argv,
|
||||
cwd,
|
||||
timeoutMs,
|
||||
maxStdoutBytes,
|
||||
});
|
||||
|
||||
const envelope = parseEnvelope(stdout);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }],
|
||||
details: envelope,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.20-2
|
||||
## 2026.1.21
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/matrix",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Matrix channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/memory-core",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot core memory search plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/memory-lancedb",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.20-2
|
||||
## 2026.1.21
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/msteams",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Microsoft Teams channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -101,9 +101,9 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadMSTeamsImageAttachments", () => {
|
||||
describe("downloadMSTeamsAttachments", () => {
|
||||
it("downloads and stores image contentUrl attachments", async () => {
|
||||
const { downloadMSTeamsImageAttachments } = await load();
|
||||
const { downloadMSTeamsAttachments } = 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 downloadMSTeamsImageAttachments({
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
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 { downloadMSTeamsImageAttachments } = await load();
|
||||
const { downloadMSTeamsAttachments } = 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 downloadMSTeamsImageAttachments({
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [
|
||||
{
|
||||
contentType: "application/vnd.microsoft.teams.file.download.info",
|
||||
@@ -149,8 +149,35 @@ 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 { downloadMSTeamsImageAttachments } = await load();
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(Buffer.from("png"), {
|
||||
status: 200,
|
||||
@@ -158,7 +185,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsImageAttachments({
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
@@ -175,9 +202,9 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("stores inline data:image base64 payloads", async () => {
|
||||
const { downloadMSTeamsImageAttachments } = await load();
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const base64 = Buffer.from("png").toString("base64");
|
||||
const media = await downloadMSTeamsImageAttachments({
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
@@ -193,7 +220,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("retries with auth when the first request is unauthorized", async () => {
|
||||
const { downloadMSTeamsImageAttachments } = await load();
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
|
||||
const hasAuth = Boolean(
|
||||
opts &&
|
||||
@@ -210,7 +237,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsImageAttachments({
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
|
||||
maxBytes: 1024 * 1024,
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
@@ -224,9 +251,9 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("skips urls outside the allowlist", async () => {
|
||||
const { downloadMSTeamsImageAttachments } = await load();
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn();
|
||||
const media = await downloadMSTeamsImageAttachments({
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }],
|
||||
maxBytes: 1024 * 1024,
|
||||
allowHosts: ["graph.microsoft.com"],
|
||||
@@ -236,20 +263,6 @@ 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", () => {
|
||||
@@ -324,6 +337,74 @@ 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,4 +1,8 @@
|
||||
export { downloadMSTeamsImageAttachments } from "./attachments/download.js";
|
||||
export {
|
||||
downloadMSTeamsAttachments,
|
||||
/** @deprecated Use `downloadMSTeamsAttachments` instead. */
|
||||
downloadMSTeamsImageAttachments,
|
||||
} from "./attachments/download.js";
|
||||
export { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
|
||||
export {
|
||||
buildMSTeamsAttachmentPlaceholder,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import {
|
||||
extractInlineImageCandidates,
|
||||
inferPlaceholder,
|
||||
isLikelyImageAttachment,
|
||||
isDownloadableAttachment,
|
||||
isRecord,
|
||||
isUrlAllowed,
|
||||
normalizeContentType,
|
||||
@@ -102,23 +102,31 @@ async function fetchWithAuthFallback(params: {
|
||||
return firstAttempt;
|
||||
}
|
||||
|
||||
export async function downloadMSTeamsImageAttachments(params: {
|
||||
/**
|
||||
* Download all file attachments from a Teams message (images, documents, etc.).
|
||||
* Renamed from downloadMSTeamsImageAttachments to support all file types.
|
||||
*/
|
||||
export async function downloadMSTeamsAttachments(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);
|
||||
|
||||
const candidates: DownloadCandidate[] = list
|
||||
.filter(isLikelyImageAttachment)
|
||||
// Download ANY downloadable attachment (not just images)
|
||||
const downloadable = list.filter(isDownloadableAttachment);
|
||||
const candidates: DownloadCandidate[] = downloadable
|
||||
.map(resolveDownloadCandidate)
|
||||
.filter(Boolean) as DownloadCandidate[];
|
||||
|
||||
const inlineCandidates = extractInlineImageCandidates(list);
|
||||
|
||||
const seenUrls = new Set<string>();
|
||||
for (const inline of inlineCandidates) {
|
||||
if (inline.kind === "url") {
|
||||
@@ -133,7 +141,6 @@ export async function downloadMSTeamsImageAttachments(params: {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0 && inlineCandidates.length === 0) return [];
|
||||
|
||||
const out: MSTeamsInboundMedia[] = [];
|
||||
@@ -141,6 +148,7 @@ export async function downloadMSTeamsImageAttachments(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,
|
||||
@@ -172,11 +180,13 @@ export async function downloadMSTeamsImageAttachments(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,
|
||||
@@ -184,8 +194,13 @@ export async function downloadMSTeamsImageAttachments(params: {
|
||||
placeholder: candidate.placeholder,
|
||||
});
|
||||
} catch {
|
||||
// Ignore download failures and continue.
|
||||
// Ignore download failures and continue with next candidate.
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `downloadMSTeamsAttachments` instead (supports all file types).
|
||||
*/
|
||||
export const downloadMSTeamsImageAttachments = downloadMSTeamsAttachments;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { downloadMSTeamsImageAttachments } from "./download.js";
|
||||
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
||||
import { downloadMSTeamsAttachments } from "./download.js";
|
||||
import { GRAPH_ROOT, inferPlaceholder, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
||||
import type {
|
||||
MSTeamsAccessTokenProvider,
|
||||
MSTeamsAttachmentLike,
|
||||
@@ -128,11 +128,16 @@ function normalizeGraphAttachment(att: GraphAttachment): MSTeamsAttachmentLike {
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadGraphHostedImages(params: {
|
||||
/**
|
||||
* Download all hosted content from a Teams message (images, documents, etc.).
|
||||
* Renamed from downloadGraphHostedImages to support all file types.
|
||||
*/
|
||||
async function downloadGraphHostedContent(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`,
|
||||
@@ -158,7 +163,7 @@ async function downloadGraphHostedImages(params: {
|
||||
buffer,
|
||||
headerMime: item.contentType ?? undefined,
|
||||
});
|
||||
if (mime && !mime.startsWith("image/")) continue;
|
||||
// Download any file type, not just images
|
||||
try {
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
buffer,
|
||||
@@ -169,7 +174,7 @@ async function downloadGraphHostedImages(params: {
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:image>",
|
||||
placeholder: inferPlaceholder({ contentType: saved.contentType }),
|
||||
});
|
||||
} catch {
|
||||
// Ignore save failures.
|
||||
@@ -185,6 +190,8 @@ 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);
|
||||
@@ -196,11 +203,83 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
return { media: [], messageUrl, tokenError: true };
|
||||
}
|
||||
|
||||
const hosted = await downloadGraphHostedImages({
|
||||
// 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({
|
||||
accessToken,
|
||||
messageUrl,
|
||||
maxBytes: params.maxBytes,
|
||||
fetchFn: params.fetchFn,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
});
|
||||
|
||||
const attachments = await fetchGraphCollection<GraphAttachment>({
|
||||
@@ -210,18 +289,29 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
});
|
||||
|
||||
const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);
|
||||
const attachmentMedia = await downloadMSTeamsImageAttachments({
|
||||
attachments: normalizedAttachments,
|
||||
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,
|
||||
maxBytes: params.maxBytes,
|
||||
tokenProvider: params.tokenProvider,
|
||||
allowHosts,
|
||||
fetchFn: params.fetchFn,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
});
|
||||
|
||||
return {
|
||||
media: [...hosted.media, ...attachmentMedia],
|
||||
media: [...sharePointMedia, ...hosted.media, ...attachmentMedia],
|
||||
hostedCount: hosted.count,
|
||||
attachmentCount: attachments.items.length,
|
||||
attachmentCount: filteredAttachments.length + sharePointMedia.length,
|
||||
hostedStatus: hosted.status,
|
||||
attachmentStatus: attachments.status,
|
||||
messageUrl,
|
||||
|
||||
@@ -37,6 +37,15 @@ 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";
|
||||
@@ -85,6 +94,30 @@ 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");
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
resolveMSTeamsChannelAllowlist,
|
||||
resolveMSTeamsUserAllowlist,
|
||||
} from "./resolve-allowlist.js";
|
||||
import { sendMessageMSTeams } from "./send.js";
|
||||
import { sendAdaptiveCardMSTeams, sendMessageMSTeams } from "./send.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
import {
|
||||
listMSTeamsDirectoryGroupsLive,
|
||||
@@ -64,6 +64,19 @@ 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: {
|
||||
@@ -137,7 +150,12 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(conversation:|user:)/i.test(trimmed)) return true;
|
||||
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);
|
||||
}
|
||||
return trimmed.includes("@thread");
|
||||
},
|
||||
hint: "<conversationId|user:ID|conversation:ID>",
|
||||
@@ -320,6 +338,50 @@ 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: {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -51,7 +51,7 @@ describe("msteams messenger", () => {
|
||||
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
||||
{ textChunkLimit: 4000 },
|
||||
);
|
||||
expect(messages).toEqual(["hi", "https://example.com/a.png"]);
|
||||
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "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(["hi\n\nhttps://example.com/a.png"]);
|
||||
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
|
||||
});
|
||||
|
||||
it("chunks long text when enabled", () => {
|
||||
@@ -101,7 +101,7 @@ describe("msteams messenger", () => {
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
context: ctx,
|
||||
messages: ["one", "two"],
|
||||
messages: [{ text: "one" }, { text: "two" }],
|
||||
});
|
||||
|
||||
expect(sent).toEqual(["one", "two"]);
|
||||
@@ -129,7 +129,7 @@ describe("msteams messenger", () => {
|
||||
adapter,
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
messages: ["hello"],
|
||||
messages: [{ text: "hello" }],
|
||||
});
|
||||
|
||||
expect(seen.texts).toEqual(["hello"]);
|
||||
@@ -168,7 +168,7 @@ describe("msteams messenger", () => {
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
context: ctx,
|
||||
messages: ["one"],
|
||||
messages: [{ text: "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: ["one"],
|
||||
messages: [{ text: "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: ["hello"],
|
||||
messages: [{ text: "hello" }],
|
||||
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
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>;
|
||||
};
|
||||
@@ -41,6 +63,15 @@ 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;
|
||||
@@ -90,16 +121,8 @@ 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: string[],
|
||||
out: MSTeamsRenderedMessage[],
|
||||
text: string,
|
||||
opts: {
|
||||
chunkText: boolean;
|
||||
@@ -111,16 +134,17 @@ 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(trimmed);
|
||||
out.push({ text: trimmed });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) return;
|
||||
out.push(trimmed);
|
||||
out.push({ text: trimmed });
|
||||
}
|
||||
|
||||
|
||||
function clampMs(value: number, maxMs: number): number {
|
||||
if (!Number.isFinite(value) || value < 0) return 0;
|
||||
return Math.min(value, maxMs);
|
||||
@@ -167,8 +191,8 @@ function shouldRetry(classification: ReturnType<typeof classifyMSTeamsSendError>
|
||||
export function renderReplyPayloadsToMessages(
|
||||
replies: ReplyPayload[],
|
||||
options: MSTeamsReplyRenderOptions,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
): MSTeamsRenderedMessage[] {
|
||||
const out: MSTeamsRenderedMessage[] = [];
|
||||
const chunkLimit = Math.min(options.textChunkLimit, 4000);
|
||||
const chunkText = options.chunkText !== false;
|
||||
const mediaMode = options.mediaMode ?? "split";
|
||||
@@ -185,8 +209,17 @@ export function renderReplyPayloadsToMessages(
|
||||
}
|
||||
|
||||
if (mediaMode === "inline") {
|
||||
const combined = text ? `${text}\n\n${mediaList.join("\n")}` : mediaList.join("\n");
|
||||
pushTextMessages(out, combined, { chunkText, chunkLimit });
|
||||
// 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 });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -194,26 +227,142 @@ 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: string[];
|
||||
messages: MSTeamsRenderedMessage[];
|
||||
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
|
||||
.map((m) => (typeof m === "string" ? m : String(m)))
|
||||
.filter((m) => m.trim().length > 0);
|
||||
const messages = params.messages.filter(
|
||||
(m) => (m.text && m.text.trim().length > 0) || m.mediaUrl,
|
||||
);
|
||||
if (messages.length === 0) return [];
|
||||
|
||||
const retryOptions = resolveRetryOptions(params.retry);
|
||||
@@ -259,10 +408,9 @@ export async function sendMSTeamsMessages(params: {
|
||||
for (const [idx, message] of messages.entries()) {
|
||||
const response = await sendWithRetry(
|
||||
async () =>
|
||||
await ctx.sendActivity({
|
||||
type: "message",
|
||||
text: message,
|
||||
}),
|
||||
await ctx.sendActivity(
|
||||
await buildActivity(message, params.conversationRef, params.tokenProvider, params.sharePointSiteId, params.mediaMaxBytes),
|
||||
),
|
||||
{ messageIndex: idx, messageCount: messages.length },
|
||||
);
|
||||
messageIds.push(extractMessageId(response) ?? "unknown");
|
||||
@@ -281,10 +429,9 @@ export async function sendMSTeamsMessages(params: {
|
||||
for (const [idx, message] of messages.entries()) {
|
||||
const response = await sendWithRetry(
|
||||
async () =>
|
||||
await ctx.sendActivity({
|
||||
type: "message",
|
||||
text: message,
|
||||
}),
|
||||
await ctx.sendActivity(
|
||||
await buildActivity(message, params.conversationRef, params.tokenProvider, params.sharePointSiteId, params.mediaMaxBytes),
|
||||
),
|
||||
{ messageIndex: idx, messageCount: messages.length },
|
||||
);
|
||||
messageIds.push(extractMessageId(response) ?? "unknown");
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
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";
|
||||
|
||||
@@ -17,6 +23,7 @@ export type MSTeamsActivityHandler = {
|
||||
onMembersAdded: (
|
||||
handler: (context: unknown, next: () => Promise<void>) => Promise<void>,
|
||||
) => MSTeamsActivityHandler;
|
||||
run?: (context: unknown) => Promise<void>;
|
||||
};
|
||||
|
||||
export type MSTeamsMessageHandlerDeps = {
|
||||
@@ -32,11 +39,109 @@ 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,6 +24,8 @@ 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,
|
||||
@@ -36,13 +38,15 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
conversationMessageId,
|
||||
activity,
|
||||
log,
|
||||
preserveFilenames,
|
||||
} = params;
|
||||
|
||||
let mediaList = await downloadMSTeamsImageAttachments({
|
||||
let mediaList = await downloadMSTeamsAttachments({
|
||||
attachments,
|
||||
maxBytes,
|
||||
tokenProvider,
|
||||
allowHosts,
|
||||
preserveFilenames,
|
||||
});
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
@@ -81,6 +85,7 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
tokenProvider,
|
||||
maxBytes,
|
||||
allowHosts,
|
||||
preserveFilenames,
|
||||
});
|
||||
attempts.push({
|
||||
url: messageUrl,
|
||||
@@ -104,7 +109,7 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
}
|
||||
|
||||
if (mediaList.length > 0) {
|
||||
log.debug("downloaded image attachments", { count: mediaList.length });
|
||||
log.debug("downloaded attachments", { count: mediaList.length });
|
||||
} else if (htmlSummary?.imgTags) {
|
||||
log.debug("inline images detected but none downloaded", {
|
||||
imgTags: htmlSummary.imgTags,
|
||||
|
||||
@@ -402,7 +402,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
channelData: activity.channelData,
|
||||
},
|
||||
log,
|
||||
});
|
||||
preserveFilenames: cfg.media?.preserveFilenames,
|
||||
});
|
||||
|
||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||
@@ -476,6 +477,7 @@ 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,
|
||||
@@ -492,6 +494,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
recordMSTeamsSentMessage(conversationId, id);
|
||||
}
|
||||
},
|
||||
tokenProvider,
|
||||
sharePointSiteId,
|
||||
});
|
||||
|
||||
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
|
||||
| undefined;
|
||||
try {
|
||||
const graphToken = await tokenProvider.getAccessToken(
|
||||
"https://graph.microsoft.com/.default",
|
||||
"https://graph.microsoft.com",
|
||||
);
|
||||
const accessToken = readAccessToken(graphToken);
|
||||
const payload = accessToken ? decodeJwtPayload(accessToken) : null;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {
|
||||
ClawdbotConfig,
|
||||
MSTeamsReplyStyle,
|
||||
RuntimeEnv,
|
||||
import {
|
||||
resolveChannelMediaMaxBytes,
|
||||
type ClawdbotConfig,
|
||||
type MSTeamsReplyStyle,
|
||||
type RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import {
|
||||
classifyMSTeamsSendError,
|
||||
@@ -30,6 +32,10 @@ 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 () => {
|
||||
@@ -52,6 +58,10 @@ 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,
|
||||
@@ -67,6 +77,9 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
...event,
|
||||
});
|
||||
},
|
||||
tokenProvider: params.tokenProvider,
|
||||
sharePointSiteId: params.sharePointSiteId,
|
||||
mediaMaxBytes,
|
||||
});
|
||||
if (ids.length > 0) params.onSentMessageIds?.(ids);
|
||||
},
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import { resolveChannelMediaMaxBytes, type ClawdbotConfig, type PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
||||
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";
|
||||
|
||||
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 MSTeamsConversationType = "personal" | "groupChat" | "channel";
|
||||
|
||||
export type MSTeamsProactiveContext = {
|
||||
appId: string;
|
||||
conversationId: string;
|
||||
ref: StoredConversationReference;
|
||||
adapter: MSTeamsAdapter;
|
||||
log: Awaited<ReturnType<typeof getLog>>;
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -110,16 +112,45 @@ export async function resolveMSTeamsSendContext(params: {
|
||||
}
|
||||
|
||||
const { conversationId, ref } = found;
|
||||
const log = await getLog();
|
||||
const core = getMSTeamsRuntime();
|
||||
const log = core.logging.getChildLogger({ name: "msteams:send" });
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
+314
-46
@@ -1,18 +1,22 @@
|
||||
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 {
|
||||
buildConversationReference,
|
||||
type MSTeamsAdapter,
|
||||
sendMSTeamsMessages,
|
||||
} from "./messenger.js";
|
||||
getDriveItemProperties,
|
||||
uploadAndShareOneDrive,
|
||||
uploadAndShareSharePoint,
|
||||
} from "./graph-upload.js";
|
||||
import { extractFilename, extractMessageId } from "./media-helpers.js";
|
||||
import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js";
|
||||
import { buildMSTeamsPollCard } from "./polls.js";
|
||||
import { resolveMSTeamsSendContext } from "./send-context.js";
|
||||
import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
|
||||
|
||||
export type SendMSTeamsMessageParams = {
|
||||
/** Full config (for credentials) */
|
||||
@@ -28,8 +32,19 @@ 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;
|
||||
@@ -49,32 +64,19 @@ export type SendMSTeamsPollResult = {
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
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 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>;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
export type SendMSTeamsCardResult = {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a message to a Teams conversation or user.
|
||||
@@ -82,23 +84,225 @@ async function sendMSTeamsActivity(params: {
|
||||
* 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 { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
|
||||
cfg,
|
||||
to,
|
||||
});
|
||||
const ctx = await resolveMSTeamsSendContext({ cfg, to });
|
||||
const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx;
|
||||
|
||||
log.debug("sending proactive message", {
|
||||
conversationId,
|
||||
conversationType,
|
||||
textLength: text.length,
|
||||
hasMedia: Boolean(mediaUrl),
|
||||
});
|
||||
|
||||
const message = mediaUrl ? (text ? `${text}\n\n${mediaUrl}` : mediaUrl) : text;
|
||||
// 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;
|
||||
|
||||
let messageIds: string[];
|
||||
try {
|
||||
messageIds = await sendMSTeamsMessages({
|
||||
@@ -106,12 +310,14 @@ export async function sendMessageMSTeams(
|
||||
adapter,
|
||||
appId,
|
||||
conversationRef: ref,
|
||||
messages: [message],
|
||||
// Enable default retry/backoff for throttling/transient failures.
|
||||
messages: [{ text: text || undefined, mediaUrl }],
|
||||
retry: {},
|
||||
onRetry: (event) => {
|
||||
log.debug("retrying send", { conversationId, ...event });
|
||||
},
|
||||
tokenProvider,
|
||||
sharePointSiteId,
|
||||
mediaMaxBytes,
|
||||
});
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
@@ -121,8 +327,8 @@ export async function sendMessageMSTeams(
|
||||
`msteams send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
|
||||
);
|
||||
}
|
||||
const messageId = messageIds[0] ?? "unknown";
|
||||
|
||||
const messageId = messageIds[0] ?? "unknown";
|
||||
log.info("sent proactive message", { conversationId, messageId });
|
||||
|
||||
return {
|
||||
@@ -157,7 +363,6 @@ export async function sendPollMSTeams(
|
||||
|
||||
const activity = {
|
||||
type: "message",
|
||||
text: pollCard.fallbackText,
|
||||
attachments: [
|
||||
{
|
||||
contentType: "application/vnd.microsoft.card.adaptive",
|
||||
@@ -166,13 +371,18 @@ export async function sendPollMSTeams(
|
||||
],
|
||||
};
|
||||
|
||||
let messageId: string;
|
||||
// Send poll via proactive conversation (Adaptive Cards require direct activity send)
|
||||
const baseRef = buildConversationReference(ref);
|
||||
const proactiveRef = {
|
||||
...baseRef,
|
||||
activityId: undefined,
|
||||
};
|
||||
|
||||
let messageId = "unknown";
|
||||
try {
|
||||
messageId = await sendMSTeamsActivity({
|
||||
adapter,
|
||||
appId,
|
||||
conversationRef: ref,
|
||||
activity,
|
||||
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
|
||||
const response = await ctx.sendActivity(activity);
|
||||
messageId = extractMessageId(response) ?? "unknown";
|
||||
});
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
@@ -192,6 +402,64 @@ 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).
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/nextcloud-talk",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Nextcloud Talk channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.20-2
|
||||
## 2026.1.21
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/nostr",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
|
||||
"clawdbot": {
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"clawdbot": "workspace:*",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"nostr-tools": "^2.19.4",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/signal",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Signal channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/slack",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Slack channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/telegram",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Telegram channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.20-2
|
||||
## 2026.1.21
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/voice-call",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot voice-call plugin",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/whatsapp",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot WhatsApp channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.20-2
|
||||
## 2026.1.21
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/zalo",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Zalo channel plugin",
|
||||
"clawdbot": {
|
||||
@@ -28,6 +28,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"clawdbot": "workspace:*",
|
||||
"undici": "7.18.2"
|
||||
"undici": "7.19.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.20-2
|
||||
## 2026.1.21
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/zalouser",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
||||
"dependencies": {
|
||||
|
||||
+8
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawdbot",
|
||||
"version": "2026.1.20-2",
|
||||
"version": "2026.1.21",
|
||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -153,10 +153,10 @@
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
"@homebridge/ciao": "^1.3.4",
|
||||
"@lydell/node-pty": "1.2.0-beta.3",
|
||||
"@mariozechner/pi-agent-core": "0.49.2",
|
||||
"@mariozechner/pi-ai": "0.49.2",
|
||||
"@mariozechner/pi-coding-agent": "0.49.2",
|
||||
"@mariozechner/pi-tui": "0.49.2",
|
||||
"@mariozechner/pi-agent-core": "0.49.3",
|
||||
"@mariozechner/pi-ai": "0.49.3",
|
||||
"@mariozechner/pi-coding-agent": "0.49.3",
|
||||
"@mariozechner/pi-tui": "0.49.3",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"@slack/bolt": "^4.6.0",
|
||||
@@ -166,7 +166,7 @@
|
||||
"body-parser": "^2.2.2",
|
||||
"chalk": "^5.6.2",
|
||||
"chokidar": "^5.0.0",
|
||||
"chromium-bidi": "13.0.0",
|
||||
"chromium-bidi": "13.0.1",
|
||||
"cli-highlight": "^2.1.11",
|
||||
"commander": "^14.0.2",
|
||||
"croner": "^9.1.0",
|
||||
@@ -192,7 +192,7 @@
|
||||
"sqlite-vec": "0.1.7-alpha.2",
|
||||
"tar": "7.5.4",
|
||||
"tslog": "^4.10.2",
|
||||
"undici": "^7.18.2",
|
||||
"undici": "^7.19.0",
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.5"
|
||||
@@ -209,7 +209,7 @@
|
||||
"@types/body-parser": "^1.19.6",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^25.0.9",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
|
||||
Generated
+653
-653
File diff suppressed because it is too large.
Load diff
@@ -227,15 +227,29 @@ fi
|
||||
|
||||
echo "📦 Copying Textual resources"
|
||||
TEXTUAL_BUNDLE_DIR="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG"
|
||||
TEXTUAL_BUNDLE="$TEXTUAL_BUNDLE_DIR/textual_Textual.bundle"
|
||||
if [ ! -d "$TEXTUAL_BUNDLE" ]; then
|
||||
TEXTUAL_BUNDLE="$TEXTUAL_BUNDLE_DIR/Textual_Textual.bundle"
|
||||
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)"
|
||||
fi
|
||||
if [ -d "$TEXTUAL_BUNDLE" ]; then
|
||||
if [ -n "$TEXTUAL_BUNDLE" ] && [ -d "$TEXTUAL_BUNDLE" ]; then
|
||||
rm -rf "$APP_ROOT/Contents/Resources/$(basename "$TEXTUAL_BUNDLE")"
|
||||
cp -R "$TEXTUAL_BUNDLE" "$APP_ROOT/Contents/Resources/"
|
||||
else
|
||||
echo "WARN: Textual resource bundle not found in $TEXTUAL_BUNDLE_DIR (continuing)" >&2
|
||||
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
|
||||
fi
|
||||
|
||||
echo "⏹ Stopping any running Clawdbot"
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 -`
|
||||
@@ -88,3 +89,4 @@ 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.
|
||||
@@ -54,6 +54,7 @@ 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"),
|
||||
@@ -659,6 +660,11 @@ 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",
|
||||
@@ -799,7 +805,7 @@ export function createExecTool(
|
||||
|
||||
if (host === "node") {
|
||||
const approvals = resolveExecApprovals(
|
||||
defaults?.agentId,
|
||||
agentId,
|
||||
host === "node" ? { security: "allowlist" } : undefined,
|
||||
);
|
||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||
@@ -865,7 +871,7 @@ export function createExecTool(
|
||||
cwd: workdir,
|
||||
env: nodeEnv,
|
||||
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
|
||||
agentId: defaults?.agentId,
|
||||
agentId,
|
||||
sessionKey: defaults?.sessionKey,
|
||||
approved: approvedByAsk,
|
||||
approvalDecision: approvalDecision ?? undefined,
|
||||
@@ -895,7 +901,7 @@ export function createExecTool(
|
||||
host: "node",
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
agentId: defaults?.agentId,
|
||||
agentId,
|
||||
resolvedPath: null,
|
||||
sessionKey: defaults?.sessionKey ?? null,
|
||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
@@ -1026,7 +1032,7 @@ export function createExecTool(
|
||||
}
|
||||
|
||||
if (host === "gateway") {
|
||||
const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" });
|
||||
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
|
||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
||||
const askFallback = approvals.agent.askFallback;
|
||||
@@ -1080,7 +1086,7 @@ export function createExecTool(
|
||||
host: "gateway",
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
agentId: defaults?.agentId,
|
||||
agentId,
|
||||
resolvedPath,
|
||||
sessionKey: defaults?.sessionKey ?? null,
|
||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
@@ -1123,7 +1129,7 @@ export function createExecTool(
|
||||
for (const segment of analysis.segments) {
|
||||
const pattern = segment.resolution?.resolvedPath ?? "";
|
||||
if (pattern) {
|
||||
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
|
||||
addAllowlistEntry(approvals.file, agentId, pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1152,7 +1158,7 @@ export function createExecTool(
|
||||
seen.add(match.pattern);
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
defaults?.agentId,
|
||||
agentId,
|
||||
match,
|
||||
commandText,
|
||||
resolvedPath ?? undefined,
|
||||
@@ -1242,7 +1248,7 @@ export function createExecTool(
|
||||
seen.add(match.pattern);
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
defaults?.agentId,
|
||||
agentId,
|
||||
match,
|
||||
params.command,
|
||||
analysis.segments[0]?.resolution?.resolvedPath,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -46,3 +48,19 @@ 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);
|
||||
}
|
||||
@@ -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 } from "../channel-tools.js";
|
||||
import { listChannelSupportedActions, resolveChannelMessageToolHints } 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,6 +245,13 @@ 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,
|
||||
@@ -287,6 +294,7 @@ export async function compactEmbeddedPiSession(params: {
|
||||
docsPath: docsPath ?? undefined,
|
||||
promptMode,
|
||||
runtimeInfo,
|
||||
messageToolHints,
|
||||
sandboxInfo,
|
||||
tools,
|
||||
modelAliasLines: buildModelAliasLines(params.config),
|
||||
|
||||
@@ -7,7 +7,10 @@ import { streamSimple } from "@mariozechner/pi-ai";
|
||||
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
|
||||
import { listChannelSupportedActions } from "../../channel-tools.js";
|
||||
import {
|
||||
listChannelSupportedActions,
|
||||
resolveChannelMessageToolHints,
|
||||
} 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";
|
||||
@@ -260,6 +263,13 @@ 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 ?? {},
|
||||
@@ -305,6 +315,7 @@ export async function runEmbeddedAttempt(
|
||||
reactionGuidance,
|
||||
promptMode,
|
||||
runtimeInfo,
|
||||
messageToolHints,
|
||||
sandboxInfo,
|
||||
tools,
|
||||
modelAliasLines: buildModelAliasLines(params.config),
|
||||
|
||||
@@ -35,6 +35,7 @@ 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[];
|
||||
@@ -56,6 +57,7 @@ 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),
|
||||
|
||||
@@ -85,6 +85,7 @@ function buildMessagingSection(params: {
|
||||
messageChannelOptions: string;
|
||||
inlineButtonsEnabled: boolean;
|
||||
runtimeChannel?: string;
|
||||
messageToolHints?: string[];
|
||||
}) {
|
||||
if (params.isMinimal) return [];
|
||||
return [
|
||||
@@ -105,6 +106,7 @@ 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")
|
||||
@@ -159,6 +161,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
channel?: string;
|
||||
capabilities?: string[];
|
||||
};
|
||||
messageToolHints?: string[];
|
||||
sandboxInfo?: {
|
||||
enabled: boolean;
|
||||
workspaceDir?: string;
|
||||
@@ -468,6 +471,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
messageChannelOptions,
|
||||
inlineButtonsEnabled,
|
||||
runtimeChannel,
|
||||
messageToolHints: params.messageToolHints,
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("cron tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("adds recent context for systemEvent reminders when session key is available", async () => {
|
||||
it("adds recent context for systemEvent reminders when contextMessages > 0", async () => {
|
||||
callGatewayMock
|
||||
.mockResolvedValueOnce({
|
||||
messages: [
|
||||
@@ -102,6 +102,7 @@ describe("cron tool", () => {
|
||||
const tool = createCronTool({ agentSessionKey: "main" });
|
||||
await tool.execute("call3", {
|
||||
action: "add",
|
||||
contextMessages: 3,
|
||||
job: {
|
||||
name: "reminder",
|
||||
schedule: { atMs: 123 },
|
||||
@@ -127,4 +128,64 @@ 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:");
|
||||
});
|
||||
});
|
||||
@@ -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 = 3;
|
||||
const REMINDER_CONTEXT_MESSAGES_MAX = 10;
|
||||
const REMINDER_CONTEXT_PER_MESSAGE_MAX = 220;
|
||||
const REMINDER_CONTEXT_TOTAL_MAX = 700;
|
||||
const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n";
|
||||
@@ -34,6 +34,9 @@ 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 = {
|
||||
@@ -86,7 +89,13 @@ 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();
|
||||
@@ -95,13 +104,13 @@ async function buildReminderContextLines(params: {
|
||||
try {
|
||||
const res = (await callGatewayTool("chat.history", params.gatewayOpts, {
|
||||
sessionKey: resolvedKey,
|
||||
limit: 12,
|
||||
limit: maxMessages,
|
||||
})) 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(-REMINDER_CONTEXT_MESSAGES);
|
||||
const recent = parsed.slice(-maxMessages);
|
||||
if (recent.length === 0) return [];
|
||||
const lines: string[] = [];
|
||||
let total = 0;
|
||||
@@ -124,7 +133,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.",
|
||||
"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.",
|
||||
parameters: CronToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
@@ -149,6 +158,10 @@ 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" &&
|
||||
@@ -160,6 +173,7 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool {
|
||||
const contextLines = await buildReminderContextLines({
|
||||
agentSessionKey: opts?.agentSessionKey,
|
||||
gatewayOpts,
|
||||
contextMessages,
|
||||
});
|
||||
if (contextLines.length > 0) {
|
||||
const baseText = stripExistingContext(payload.text);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
listChannelMessageActions,
|
||||
supportsChannelMessageButtons,
|
||||
supportsChannelMessageCards,
|
||||
} from "../../channels/plugins/message-actions.js";
|
||||
import {
|
||||
CHANNEL_MESSAGE_ACTION_NAMES,
|
||||
@@ -36,7 +37,7 @@ function buildRoutingSchema() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildSendSchema(options: { includeButtons: boolean }) {
|
||||
function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) {
|
||||
const props: Record<string, unknown> = {
|
||||
message: Type.Optional(Type.String()),
|
||||
effectId: Type.Optional(
|
||||
@@ -77,8 +78,18 @@ function buildSendSchema(options: { includeButtons: boolean }) {
|
||||
},
|
||||
),
|
||||
),
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -192,7 +203,7 @@ function buildChannelManagementSchema() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMessageToolSchemaProps(options: { includeButtons: boolean }) {
|
||||
function buildMessageToolSchemaProps(options: { includeButtons: boolean; includeCards: boolean }) {
|
||||
return {
|
||||
...buildRoutingSchema(),
|
||||
...buildSendSchema(options),
|
||||
@@ -211,7 +222,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean }) {
|
||||
|
||||
function buildMessageToolSchemaFromActions(
|
||||
actions: readonly string[],
|
||||
options: { includeButtons: boolean },
|
||||
options: { includeButtons: boolean; includeCards: boolean },
|
||||
) {
|
||||
const props = buildMessageToolSchemaProps(options);
|
||||
return Type.Object({
|
||||
@@ -222,6 +233,7 @@ function buildMessageToolSchemaFromActions(
|
||||
|
||||
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
|
||||
includeButtons: true,
|
||||
includeCards: true,
|
||||
});
|
||||
|
||||
type MessageToolOptions = {
|
||||
@@ -238,8 +250,10 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { normalizeChannelId } from "../../channels/registry.js";
|
||||
import { normalizeAnyChannelId, 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);
|
||||
const provider = normalizeChannelId(rawProvider) ?? normalizeAnyChannelId(rawProvider);
|
||||
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
|
||||
const dock = provider ? getChannelDock(provider) : undefined;
|
||||
if (!dock?.threading?.buildToolContext) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
ChannelElevatedAdapter,
|
||||
ChannelGroupAdapter,
|
||||
ChannelId,
|
||||
ChannelAgentPromptAdapter,
|
||||
ChannelMentionAdapter,
|
||||
ChannelPlugin,
|
||||
ChannelThreadingAdapter,
|
||||
@@ -51,6 +52,7 @@ export type ChannelDock = {
|
||||
groups?: ChannelGroupAdapter;
|
||||
mentions?: ChannelMentionAdapter;
|
||||
threading?: ChannelThreadingAdapter;
|
||||
agentPrompt?: ChannelAgentPromptAdapter;
|
||||
};
|
||||
|
||||
type ChannelDockStreaming = {
|
||||
@@ -319,6 +321,7 @@ function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {
|
||||
groups: plugin.groups,
|
||||
mentions: plugin.mentions,
|
||||
threading: plugin.threading,
|
||||
agentPrompt: plugin.agentPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@ 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> {
|
||||
|
||||
@@ -240,6 +240,10 @@ export type ChannelMessagingAdapter = {
|
||||
}) => string;
|
||||
};
|
||||
|
||||
export type ChannelAgentPromptAdapter = {
|
||||
messageToolHints?: (params: { cfg: ClawdbotConfig; accountId?: string | null }) => string[];
|
||||
};
|
||||
|
||||
export type ChannelDirectoryEntryKind = "user" | "group" | "channel";
|
||||
|
||||
export type ChannelDirectoryEntry = {
|
||||
@@ -281,6 +285,7 @@ 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>>;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
ChannelAgentToolFactory,
|
||||
ChannelCapabilities,
|
||||
ChannelId,
|
||||
ChannelAgentPromptAdapter,
|
||||
ChannelMentionAdapter,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessagingAdapter,
|
||||
@@ -73,6 +74,7 @@ export type ChannelPlugin<ResolvedAccount = any> = {
|
||||
streaming?: ChannelStreamingAdapter;
|
||||
threading?: ChannelThreadingAdapter;
|
||||
messaging?: ChannelMessagingAdapter;
|
||||
agentPrompt?: ChannelAgentPromptAdapter;
|
||||
directory?: ChannelDirectoryAdapter;
|
||||
resolver?: ChannelResolverAdapter;
|
||||
actions?: ChannelMessageActionAdapter;
|
||||
|
||||
@@ -31,6 +31,7 @@ export type {
|
||||
export type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelAccountState,
|
||||
ChannelAgentPromptAdapter,
|
||||
ChannelAgentTool,
|
||||
ChannelAgentToolFactory,
|
||||
ChannelCapabilities,
|
||||
|
||||
@@ -323,9 +323,11 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
|
||||
}
|
||||
if (status.vector) {
|
||||
const vectorState = status.vector.enabled
|
||||
? status.vector.available
|
||||
? "ready"
|
||||
: "unavailable"
|
||||
? status.vector.available === undefined
|
||||
? "unknown"
|
||||
: status.vector.available
|
||||
? "ready"
|
||||
: "unavailable"
|
||||
: "disabled";
|
||||
const vectorColor =
|
||||
vectorState === "ready"
|
||||
|
||||
@@ -267,8 +267,8 @@ export function registerNodesInvokeCommands(nodes: Command) {
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
agentId,
|
||||
resolvedPath: null,
|
||||
sessionKey: null,
|
||||
resolvedPath: undefined,
|
||||
sessionKey: undefined,
|
||||
timeoutMs: 120_000,
|
||||
})) as { decision?: string } | null;
|
||||
const decision =
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -32,6 +33,7 @@ export function createMessageCliHelpers(
|
||||
|
||||
const runMessageAction = async (action: string, opts: Record<string, unknown>) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
ensurePluginRegistryLoaded();
|
||||
const deps = createDefaultDeps();
|
||||
await runCommandWithRuntime(
|
||||
defaultRuntime,
|
||||
|
||||
@@ -19,6 +19,7 @@ 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),
|
||||
|
||||
@@ -236,7 +236,7 @@ describe("update-cli", () => {
|
||||
durationMs: 100,
|
||||
});
|
||||
|
||||
await updateCommand({});
|
||||
await updateCommand({ yes: true });
|
||||
|
||||
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
|
||||
expect(call?.channel).toBe("stable");
|
||||
@@ -273,7 +273,7 @@ describe("update-cli", () => {
|
||||
try {
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify({ name: "clawdbot", version: "2026.1.18-1" }),
|
||||
JSON.stringify({ name: "clawdbot", version: "1.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
@@ -302,7 +302,7 @@ describe("update-cli", () => {
|
||||
});
|
||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||
tag: "latest",
|
||||
version: "2026.1.20-1",
|
||||
version: "1.2.3-1",
|
||||
});
|
||||
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
||||
status: "ok",
|
||||
|
||||
@@ -589,16 +589,20 @@ 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 =
|
||||
currentVersion != null && (targetVersion == null || (cmp != null && cmp > 0));
|
||||
!fallbackToLatest &&
|
||||
currentVersion != null &&
|
||||
(targetVersion == null || (cmp != null && cmp > 0));
|
||||
|
||||
if (needsConfirm && !opts.yes) {
|
||||
if (!process.stdin.isTTY || opts.json) {
|
||||
|
||||
+5
-1
@@ -247,7 +247,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
.join("\n");
|
||||
if (!loggedInvalidConfigs.has(configPath)) {
|
||||
loggedInvalidConfigs.add(configPath);
|
||||
deps.logger.error(`Invalid config:\\n${details}`);
|
||||
deps.logger.error(`Invalid config at ${configPath}:\\n${details}`);
|
||||
}
|
||||
const error = new Error("Invalid config");
|
||||
(error as { code?: string; details?: string }).code = "INVALID_CONFIG";
|
||||
@@ -299,6 +299,10 @@ 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 {};
|
||||
}
|
||||
|
||||
@@ -78,4 +78,8 @@ 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;
|
||||
};
|
||||
@@ -599,6 +599,10 @@ 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) => {
|
||||
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user