Compare commits

...
Author SHA1 Message Date
Peter Steinberger 3c163c71b5 fix: export SECTION_META for config form (#1418) (thanks @MaudeBot) 2026-01-22 04:33:37 +00:00
Maude Bot 277881b52f fix(ui): export SECTION_META from config-form module
Export the SECTION_META constant from config-form.render.ts and
re-export it through config-form.ts so it can be imported by config.ts.

This fixes a runtime error where SECTION_META was being referenced
but not properly exported from its source module.
2026-01-22 04:16:51 +00:00
Peter Steinberger 5424b4173c fix: localize system event timestamps 2026-01-22 04:15:39 +00:00
Peter Steinberger 30a8478e1a fix: default envelope timestamps to local 2026-01-22 04:10:06 +00:00
Peter Steinberger 2fc926ab1c Merge pull request #1329 from dlauer/feature/agent-avatar-support
feat: add avatar support for agent identity
2026-01-22 04:09:00 +00:00
Peter Steinberger 1ac1e72a47 Merge pull request #1204 from cpojer/reminders
Improve `cron` reminder tool description.
2026-01-22 04:06:50 +00:00
Peter Steinberger 9450873c1b fix: align exec approvals default agent 2026-01-22 04:05:54 +00:00
Peter Steinberger 5fb6a0fd32 fix: map OpenCode Zen models to correct APIs 2026-01-22 04:02:53 +00:00
Peter Steinberger 3b2aff0d6f Merge pull request #1417 from czekaj/fix/exec-allowlist-agentid-derivation
fix(exec): derive agentId from sessionKey for allowlist lookup
2026-01-22 04:01:01 +00:00
Peter Steinberger 2d583e877b fix: default exec approvals to main agent (#1417) (thanks @czekaj) 2026-01-22 03:58:53 +00:00
Lucas Czekaj 0c55b1e9ce fix(exec): derive agentId from sessionKey for allowlist lookup
When creating exec tools via chat/Discord, agentId was not passed,
causing allowlist lookup to use 'default' key instead of 'main'.
User's allowlist entries in agents.main were never matched.

Now derives agentId from sessionKey if not explicitly provided,
ensuring correct allowlist lookup for all exec paths.
2026-01-22 03:58:53 +00:00
Peter Steinberger 51cd9c7ff4 fix: make lobster tool tests windows-safe 2026-01-22 03:58:05 +00:00
Peter Steinberger 0c3d46cb72 Merge pull request #1103 from mkbehr/feat/cron-context-messages
feat(cron): Add parameter to control context messages
2026-01-22 03:52:34 +00:00
Peter Steinberger 654f9e5053 fix: cap cron context messages (#1103) (thanks @mkbehr) 2026-01-22 03:52:03 +00:00
Peter Steinberger 17fad54ca0 docs: update clawtributors 2026-01-22 03:37:29 +00:00
Peter SteinbergerandChristof 0f7f7bb95f fix: msteams attachments + plugin prompt hints
Co-authored-by: Christof <10854026+Evizero@users.noreply.github.com>
2026-01-22 03:37:29 +00:00
Michael Behr ffbf75d740 update description 2026-01-22 03:37:20 +00:00
Michael Behr 4642fae193 feat(cron): add contextMessages param to control reminder context 2026-01-22 03:37:20 +00:00
Peter SteinbergerandMariano 5fe8c4ab8c docs: add gog gmail messages search note (#1220) (thanks @mbelinky)
Co-authored-by: Mariano <mbelinky@users.noreply.github.com>
2026-01-22 03:36:28 +00:00
Mariano Belinky 7b8405cbfb docs(gog): sanitize gmail messages example 2026-01-22 03:31:00 +00:00
Mariano Belinky a96e7f59c0 docs(gog): add gmail messages search usage 2026-01-22 03:31:00 +00:00
Peter Steinberger 57f3d209de docs: expand lobster guides 2026-01-22 03:25:13 +00:00
Peter Steinberger 40757a8c18 fix: stabilize lobster tool subprocess 2026-01-22 03:20:23 +00:00
Peter Steinberger 472b8fe15d fix: prevent memory CLI hangs 2026-01-22 03:14:59 +00:00
Peter Steinberger 721737cc77 Merge pull request #1414 from czekaj/fix/discord-exec-resolvedpath-validation
fix(exec): pass undefined instead of null for optional approval params
2026-01-22 03:11:26 +00:00
Peter Steinberger 464de2978b docs: add special thanks 2026-01-22 02:48:17 +00:00
Peter Steinberger 9d22646120 fix: reduce invalid config log noise 2026-01-22 02:48:01 +00:00
Peter Steinberger f1aa260b0e test: avoid downgrade prompt in update fallback 2026-01-22 02:44:13 +00:00
Peter Steinberger b5c307d07f docs: highlight lobster in changelog 2026-01-22 02:37:26 +00:00
Peter Steinberger 2e1514095d fix: package Textual resources for mac app 2026-01-22 02:34:27 +00:00
Peter Steinberger f4b3f33c8e Merge pull request #1152 from vignesh07/feat/lobster-plugin
feat: Add optional lobster plugin tool (typed workflows, approvals/resume)
2026-01-22 02:34:05 +00:00
Peter Steinberger 2d1d793651 Merge pull request #1373 from yazinsai/main
Add auto-refresh polling for debug view
2026-01-22 02:25:24 +00:00
Peter Steinberger 2f47b3f6bd fix: sync debug polling with route changes (#1373) (thanks @yazinsai) 2026-01-22 02:24:19 +00:00
Peter Steinberger 302bb64457 test: fix await-thenable in signal typing test 2026-01-22 02:20:42 +00:00
Lucas Czekaj de898c423b fix(exec): pass undefined instead of null for optional approval params
TypeBox Type.Optional(Type.String()) accepts string|undefined but NOT null.
Discord exec was failing with 'resolvedPath must be string' because callers
passed null explicitly. Web UI worked because it skipped the approval request.

Fixes exec approval validation error in Discord-triggered sessions.
2026-01-21 18:14:51 -08:00
Yazin d7d98c3971 Add auto-refresh polling for debug view
The debug view now automatically refreshes every 3 seconds when active,
similar to the logs view. This removes the need to manually click the
refresh button to see updated debug messages and status information.
2026-01-22 02:03:40 +00:00
Dave Lauer 2f0dd9c4ee chore: fix swift formatting 2026-01-20 16:38:37 -05:00
Dave Lauer 2af497495f chore: regenerate protocol files 2026-01-20 16:21:15 -05:00
Dave Lauer 056b3e40d6 chore: fix formatting 2026-01-20 16:21:14 -05:00
Dave Lauer 6402a48482 feat: add avatar support for agent identity
- Add avatar field to IdentityConfig type
- Add avatar parsing in AgentIdentity from IDENTITY.md
- Add renderAvatar support for image avatars in webchat
- Add CSS styling for image avatars

Users can now configure a custom avatar for the assistant in the webchat
by setting 'identity.avatar' in the agent config or adding 'Avatar: path'
to IDENTITY.md. The avatar can be served from the assets folder.

Closes #TBD
2026-01-20 16:21:14 -05:00
cpojer ed909d6013 Improve cron reminder tool description. 2026-01-19 10:42:21 +09:00
Vignesh Natarajan 9497ffcc50 Add SKILL.md to teach Clawdbot when/how to use Lobster 2026-01-18 12:11:25 -08:00
Vignesh Natarajan 032c780a79 Add lobster.md documentation 2026-01-18 11:07:47 -08:00
Vignesh Natarajan e011c764a7 Gate lobster plugin tool in sandboxed contexts 2026-01-17 20:33:31 -08:00
Vignesh Natarajan b2650ba672 Move lobster integration to optional plugin tool 2026-01-17 20:18:54 -08:00
Vignesh Natarajan 147fccd967 Add lobster tool for running local Lobster pipelines 2026-01-17 20:13:00 -08:00
101 changed files with 4226 additions and 418 deletions

No files matched your search

+14
View File
@@ -5,12 +5,26 @@ Docs: https://docs.clawd.bot
## 2026.1.22
### Changes
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
### Breaking
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
### Fixes
- Config: avoid stack traces for invalid configs and log the config path.
- Doctor: warn when gateway.mode is unset with configure/config guidance.
- OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416)
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
- Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
- UI: export config form section metadata for shared usage. (#1418) Thanks @MaudeBot.
## 2026.1.21
+26 -22
View File
@@ -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>
@@ -149,6 +149,7 @@ struct ExecApprovalsResolvedDefaults {
enum ExecApprovalsStore {
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
private static let defaultAgentId = "main"
private static let defaultSecurity: ExecSecurity = .deny
private static let defaultAsk: ExecAsk = .onMiss
private static let defaultAskFallback: ExecSecurity = .deny
@@ -165,13 +166,22 @@ enum ExecApprovalsStore {
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
var agents = file.agents ?? [:]
if let legacyDefault = agents["default"] {
if let main = agents[self.defaultAgentId] {
agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault)
} else {
agents[self.defaultAgentId] = legacyDefault
}
agents.removeValue(forKey: "default")
}
return ExecApprovalsFile(
version: 1,
socket: ExecApprovalsSocketConfig(
path: socketPath.isEmpty ? nil : socketPath,
token: token.isEmpty ? nil : token),
defaults: file.defaults,
agents: file.agents)
agents: agents)
}
static func readSnapshot() -> ExecApprovalsSnapshot {
@@ -272,9 +282,7 @@ enum ExecApprovalsStore {
ask: defaults.ask ?? self.defaultAsk,
askFallback: defaults.askFallback ?? self.defaultAskFallback,
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
: "default"
let key = self.agentKey(agentId)
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
let resolvedAgent = ExecApprovalsResolvedDefaults(
@@ -457,7 +465,36 @@ enum ExecApprovalsStore {
private static func agentKey(_ agentId: String?) -> String {
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "default" : trimmed
return trimmed.isEmpty ? self.defaultAgentId : trimmed
}
private static func normalizedPattern(_ pattern: String?) -> String? {
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed.lowercased()
}
private static func mergeAgents(
current: ExecApprovalsAgent,
legacy: ExecApprovalsAgent
) -> ExecApprovalsAgent {
var seen = Set<String>()
var allowlist: [ExecAllowlistEntry] = []
func append(_ entry: ExecAllowlistEntry) {
guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else {
return
}
seen.insert(key)
allowlist.append(entry)
}
for entry in current.allowlist ?? [] { append(entry) }
for entry in legacy.allowlist ?? [] { append(entry) }
return ExecApprovalsAgent(
security: current.security ?? legacy.security,
ask: current.ask ?? legacy.ask,
askFallback: current.askFallback ?? legacy.askFallback,
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
allowlist: allowlist.isEmpty ? nil : allowlist)
}
}
+149 -3
View File
@@ -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.
+7 -6
View File
@@ -9,15 +9,15 @@ read_when:
Clawdbot standardizes timestamps so the model sees a **single reference time**.
## Message envelopes (UTC by default)
## Message envelopes (local by default)
Inbound messages are wrapped in an envelope like:
```
[Provider ... 2026-01-05T21:26Z] message text
[Provider ... 2026-01-05 16:26 PST] message text
```
The timestamp in the envelope is **UTC by default**, with minutes precision.
The timestamp in the envelope is **host-local by default**, with minutes precision.
You can override this with:
@@ -25,7 +25,7 @@ You can override this with:
{
agents: {
defaults: {
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone
envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
envelopeTimestamp: "on", // "on" | "off"
envelopeElapsed: "on" // "on" | "off"
}
@@ -33,6 +33,7 @@ You can override this with:
}
```
- `envelopeTimezone: "utc"` uses UTC.
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
@@ -40,10 +41,10 @@ You can override this with:
### Examples
**UTC (default):**
**Local (default):**
```
[Signal Alice +1555 2026-01-18T05:19Z] hello
[Signal Alice +1555 2026-01-18 00:19 PST] hello
```
**Fixed timezone:**
+13 -11
View File
@@ -7,18 +7,18 @@ read_when:
# Date & Time
Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**.
Clawdbot defaults to **host-local time for transport timestamps** and **user-local time only in the system prompt**.
Provider timestamps are preserved so tools keep their native semantics.
## Message envelopes (UTC by default)
## Message envelopes (local by default)
Inbound messages are wrapped with a UTC timestamp (minute precision):
Inbound messages are wrapped with a timestamp (minute precision):
```
[Provider ... 2026-01-05T21:26Z] message text
[Provider ... 2026-01-05 16:26 PST] message text
```
This envelope timestamp is **UTC by default**, regardless of the host timezone.
This envelope timestamp is **host-local by default**, regardless of the provider timezone.
You can override this behavior:
@@ -26,7 +26,7 @@ You can override this behavior:
{
agents: {
defaults: {
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone
envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
envelopeTimestamp: "on", // "on" | "off"
envelopeElapsed: "on" // "on" | "off"
}
@@ -34,6 +34,7 @@ You can override this behavior:
}
```
- `envelopeTimezone: "utc"` uses UTC.
- `envelopeTimezone: "local"` uses the host timezone.
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
@@ -42,10 +43,10 @@ You can override this behavior:
### Examples
**UTC (default):**
**Local (default):**
```
[WhatsApp +1555 2026-01-18T05:19Z] hello
[WhatsApp +1555 2026-01-18 00:19 PST] hello
```
**User timezone:**
@@ -73,12 +74,13 @@ Time format: 12-hour
If only the timezone is known, we still include the section and instruct the model
to assume UTC for unknown time references.
## System event lines (UTC)
## System event lines (local by default)
Queued system events inserted into agent context are prefixed with a UTC timestamp:
Queued system events inserted into agent context are prefixed with a timestamp using the
same timezone selection as message envelopes (default: host-local).
```
System: [2026-01-12T20:19:17Z] Model switched.
System: [2026-01-12 12:19:17 PST] Model switched.
```
### Configure user timezone + format
+1
View File
@@ -88,6 +88,7 @@ If a prompt is required but no UI is reachable, fallback decides:
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
editing in the macOS app. Patterns are **case-insensitive glob matches**.
Patterns should resolve to **binary paths** (basename-only entries are ignored).
Legacy `agents.default` entries are migrated to `agents.main` on load.
Examples:
- `~/Projects/**/bin/bird`
+4 -1
View File
@@ -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`
+171
View File
@@ -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)
+38
View File
@@ -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.
+90
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
{
"id": "lobster",
"name": "Lobster",
"description": "Typed workflow tool with resumable approvals.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+13
View File
@@ -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 },
);
}
+9
View File
@@ -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"]
}
}
+123
View File
@@ -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");
});
});
+188
View File
@@ -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,
};
},
};
}
+108 -27
View File
@@ -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", () => {
+5 -1
View File
@@ -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,
+21 -6
View File
@@ -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;
+100 -10
View File
@@ -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");
+64 -2
View File
@@ -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);
}
+122
View File
@@ -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}`);
}
}
+52
View File
@@ -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,
},
};
}
+445
View File
@@ -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();
});
});
});
+77
View File
@@ -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;
}
+7 -7
View File
@@ -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 },
});
+175 -28
View File
@@ -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");
+105
View File
@@ -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 });
+87
View File
@@ -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();
}
+1 -1
View File
@@ -76,7 +76,7 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
| undefined;
try {
const graphToken = await tokenProvider.getAccessToken(
"https://graph.microsoft.com/.default",
"https://graph.microsoft.com",
);
const accessToken = readAccessToken(graphToken);
const payload = accessToken ? decodeJwtPayload(accessToken) : null;
+17 -4
View File
@@ -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);
},
+43 -12
View File
@@ -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
View File
@@ -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).
*/
+19 -5
View File
@@ -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"
+2
View File
@@ -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.
+14 -8
View File
@@ -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,
+18
View File
@@ -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);
}
+2
View File
@@ -44,6 +44,8 @@ describe("resolveOpencodeZenModelApi", () => {
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
});
+5 -5
View File
@@ -87,19 +87,19 @@ export function resolveOpencodeZenAlias(modelIdOrAlias: string): string {
}
/**
* OpenCode Zen routes models to different APIs based on model family.
* OpenCode Zen routes models to specific API shapes by family.
*/
export function resolveOpencodeZenModelApi(modelId: string): ModelApi {
const lower = modelId.toLowerCase();
if (lower.startsWith("claude-") || lower.startsWith("minimax") || lower.startsWith("alpha-gd4")) {
if (lower.startsWith("gpt-")) {
return "openai-responses";
}
if (lower.startsWith("claude-") || lower.startsWith("minimax-")) {
return "anthropic-messages";
}
if (lower.startsWith("gemini-")) {
return "google-generative-ai";
}
if (lower.startsWith("gpt-")) {
return "openai-responses";
}
return "openai-completions";
}
+9 -1
View File
@@ -5,7 +5,7 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
import { listChannelSupportedActions } 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),
+12 -1
View File
@@ -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),
+6 -2
View File
@@ -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;
@@ -197,7 +200,7 @@ export function buildAgentSystemPrompt(params: {
browser: "Control web browser",
canvas: "Present/eval/snapshot the Canvas",
nodes: "List/describe/notify/camera/screen on paired nodes",
cron: "Manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)",
cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
message: "Send messages and channel actions",
gateway: "Restart, apply config, or run updates on the running Clawdbot process",
agents_list: "List agent ids allowed for sessions_spawn",
@@ -348,7 +351,7 @@ export function buildAgentSystemPrompt(params: {
"- browser: control clawd's dedicated browser",
"- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes",
"- cron: manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)",
"- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
"- sessions_list: list sessions",
"- sessions_history: fetch session history",
"- sessions_send: send to another session",
@@ -468,6 +471,7 @@ export function buildAgentSystemPrompt(params: {
messageChannelOptions,
inlineButtonsEnabled,
runtimeChannel,
messageToolHints: params.messageToolHints,
}),
];
+62 -1
View File
@@ -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:");
});
});
+18 -4
View File
@@ -16,7 +16,7 @@ const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "runs"
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
const REMINDER_CONTEXT_MESSAGES = 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);
+17 -3
View File
@@ -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,
});
}
+6 -5
View File
@@ -18,6 +18,7 @@ describe("formatAgentEnvelope", () => {
host: "mac-mini",
ip: "10.0.0.5",
timestamp: ts,
envelope: { timezone: "utc" },
body: "hello",
});
@@ -26,7 +27,7 @@ describe("formatAgentEnvelope", () => {
expect(body).toBe("[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello");
});
it("formats timestamps in UTC regardless of local timezone", () => {
it("formats timestamps in local timezone by default", () => {
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
@@ -39,10 +40,10 @@ describe("formatAgentEnvelope", () => {
process.env.TZ = originalTz;
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
});
it("formats timestamps in local timezone when configured", () => {
it("formats timestamps in UTC when configured", () => {
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
@@ -50,13 +51,13 @@ describe("formatAgentEnvelope", () => {
const body = formatAgentEnvelope({
channel: "WebChat",
timestamp: ts,
envelope: { timezone: "local" },
envelope: { timezone: "utc" },
body: "hello",
});
process.env.TZ = originalTz;
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
});
it("formats timestamps in user timezone when configured", () => {
+3 -3
View File
@@ -16,7 +16,7 @@ export type AgentEnvelopeParams = {
export type EnvelopeFormatOptions = {
/**
* "utc" (default), "local", "user", or an explicit IANA timezone string.
* "local" (default), "utc", "user", or an explicit IANA timezone string.
*/
timezone?: string;
/**
@@ -59,7 +59,7 @@ function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEn
const includeTimestamp = options?.includeTimestamp !== false;
const includeElapsed = options?.includeElapsed !== false;
return {
timezone: options?.timezone?.trim() || "utc",
timezone: options?.timezone?.trim() || "local",
includeTimestamp,
includeElapsed,
userTimezone: options?.userTimezone,
@@ -77,7 +77,7 @@ function resolveExplicitTimezone(value: string): string | undefined {
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
const trimmed = options.timezone?.trim();
if (!trimmed) return { mode: "utc" };
if (!trimmed) return { mode: "local" };
const lowered = trimmed.toLowerCase();
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" };
if (lowered === "local" || lowered === "host") return { mode: "local" };
+2 -2
View File
@@ -1,7 +1,7 @@
import type { NormalizedUsage } from "../../agents/usage.js";
import { getChannelDock } from "../../channels/dock.js";
import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js";
import { 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) {
+5 -4
View File
@@ -5,8 +5,10 @@ import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system
import { prependSystemEvents } from "./session-updates.js";
describe("prependSystemEvents", () => {
it("adds a UTC timestamp to queued system events", async () => {
it("adds a local timestamp to queued system events by default", async () => {
vi.useFakeTimers();
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
const timestamp = new Date("2026-01-12T20:19:17Z");
vi.setSystemTime(timestamp);
@@ -20,11 +22,10 @@ describe("prependSystemEvents", () => {
prefixedBodyBase: "User: hi",
});
const expectedTimestamp = "2026-01-12T20:19:17Z";
expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`);
expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
resetSystemEventsForTest();
process.env.TZ = originalTz;
vi.useRealTimers();
});
});
+64 -4
View File
@@ -1,5 +1,6 @@
import crypto from "node:crypto";
import { resolveUserTimezone } from "../../agents/date-time.js";
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
import type { ClawdbotConfig } from "../../config/config.js";
@@ -27,9 +28,32 @@ export async function prependSystemEvents(params: {
return trimmed;
};
const formatSystemEventTimestamp = (ts: number) => {
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return "unknown-time";
const resolveExplicitTimezone = (value: string): string | undefined => {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
return value;
} catch {
return undefined;
}
};
const resolveSystemEventTimezone = (cfg: ClawdbotConfig) => {
const raw = cfg.agents?.defaults?.envelopeTimezone?.trim();
if (!raw) return { mode: "local" as const };
const lowered = raw.toLowerCase();
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" as const };
if (lowered === "local" || lowered === "host") return { mode: "local" as const };
if (lowered === "user") {
return {
mode: "iana" as const,
timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
};
}
const explicit = resolveExplicitTimezone(raw);
return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const };
};
const formatUtcTimestamp = (date: Date): string => {
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
@@ -39,6 +63,42 @@ export async function prependSystemEvents(params: {
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
};
const formatZonedTimestamp = (date: Date, timeZone?: string): string | undefined => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
timeZoneName: "short",
}).formatToParts(date);
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
const yyyy = pick("year");
const mm = pick("month");
const dd = pick("day");
const hh = pick("hour");
const min = pick("minute");
const sec = pick("second");
const tz = [...parts]
.reverse()
.find((part) => part.type === "timeZoneName")
?.value?.trim();
if (!yyyy || !mm || !dd || !hh || !min || !sec) return undefined;
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
};
const formatSystemEventTimestamp = (ts: number, cfg: ClawdbotConfig) => {
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return "unknown-time";
const zone = resolveSystemEventTimezone(cfg);
if (zone.mode === "utc") return formatUtcTimestamp(date);
if (zone.mode === "local") return formatZonedTimestamp(date) ?? "unknown-time";
return formatZonedTimestamp(date, zone.timeZone) ?? "unknown-time";
};
const systemLines: string[] = [];
const queued = drainSystemEventEntries(params.sessionKey);
systemLines.push(
@@ -46,7 +106,7 @@ export async function prependSystemEvents(params: {
.map((event) => {
const compacted = compactSystemEvent(event.text);
if (!compacted) return null;
return `[${formatSystemEventTimestamp(event.ts)}] ${compacted}`;
return `[${formatSystemEventTimestamp(event.ts, params.cfg)}] ${compacted}`;
})
.filter((v): v is string => Boolean(v)),
);
+3
View File
@@ -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,
};
}
+7
View File
@@ -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> {
+5
View File
@@ -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>>;
};
+2
View File
@@ -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;
+1
View File
@@ -31,6 +31,7 @@ export type {
export type {
ChannelAccountSnapshot,
ChannelAccountState,
ChannelAgentPromptAdapter,
ChannelAgentTool,
ChannelAgentToolFactory,
ChannelCapabilities,
+5 -3
View File
@@ -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"
+2 -2
View File
@@ -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 =
+2
View File
@@ -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,
+1
View File
@@ -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),
+2 -2
View File
@@ -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",
);
+5 -1
View File
@@ -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) {
+10 -1
View File
@@ -34,6 +34,7 @@ export type AgentIdentity = {
creature?: string;
vibe?: string;
theme?: string;
avatar?: string;
};
export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
@@ -90,6 +91,7 @@ export function parseIdentityMarkdown(content: string): AgentIdentity {
if (label === "creature") identity.creature = value;
if (label === "vibe") identity.vibe = value;
if (label === "theme") identity.theme = value;
if (label === "avatar") identity.avatar = value;
}
return identity;
}
@@ -99,7 +101,14 @@ export function loadAgentIdentity(workspace: string): AgentIdentity | null {
try {
const content = fs.readFileSync(identityPath, "utf-8");
const parsed = parseIdentityMarkdown(content);
if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe) {
if (
!parsed.name &&
!parsed.emoji &&
!parsed.theme &&
!parsed.creature &&
!parsed.vibe &&
!parsed.avatar
) {
return null;
}
return parsed;
+5 -1
View File
@@ -247,7 +247,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
.join("\n");
if (!loggedInvalidConfigs.has(configPath)) {
loggedInvalidConfigs.add(configPath);
deps.logger.error(`Invalid config:\\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 {};
}
+2
View File
@@ -154,4 +154,6 @@ export type IdentityConfig = {
name?: string;
theme?: string;
emoji?: string;
/** Path to a custom avatar image (relative to workspace or absolute). */
avatar?: string;
};
+4
View File
@@ -78,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;
};
+4
View File
@@ -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) => {
+6
View File
@@ -191,6 +191,12 @@ export const ClawdbotSchema = z
bindings: BindingsSchema,
broadcast: BroadcastSchema,
audio: AudioSchema,
media: z
.object({
preserveFilenames: z.boolean().optional(),
})
.strict()
.optional(),
messages: MessagesSchema,
commands: CommandsSchema,
session: SessionSchema,
@@ -1,10 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import { ExecApprovalManager } from "../exec-approval-manager.js";
import { createExecApprovalHandlers } from "./exec-approval.js";
import { validateExecApprovalRequestParams } from "../protocol/index.js";
const noop = () => {};
describe("exec approval handlers", () => {
describe("ExecApprovalRequestParams validation", () => {
it("accepts request with resolvedPath omitted", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
};
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
it("accepts request with resolvedPath as string", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
resolvedPath: "/usr/bin/echo",
};
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
it("accepts request with resolvedPath as undefined", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
resolvedPath: undefined,
};
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
// This documents the TypeBox/AJV behavior that caused the Discord exec bug:
// Type.Optional(Type.String()) does NOT accept null, only string or undefined.
it("rejects request with resolvedPath as null", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
resolvedPath: null,
};
expect(validateExecApprovalRequestParams(params)).toBe(false);
});
});
it("broadcasts request + resolve", async () => {
const manager = new ExecApprovalManager();
const handlers = createExecApprovalHandlers(manager);
+33
View File
@@ -14,6 +14,7 @@ import {
normalizeSafeBins,
resolveCommandResolution,
resolveExecApprovals,
resolveExecApprovalsFromFile,
type ExecAllowlistEntry,
} from "./exec-approvals.js";
@@ -227,3 +228,35 @@ describe("exec approvals wildcard agent", () => {
}
});
});
describe("exec approvals default agent migration", () => {
it("migrates legacy default agent entries to main", () => {
const file = {
version: 1,
agents: {
default: { allowlist: [{ pattern: "/bin/legacy" }] },
},
};
const resolved = resolveExecApprovalsFromFile({ file });
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/legacy"]);
expect(resolved.file.agents?.default).toBeUndefined();
expect(resolved.file.agents?.main?.allowlist?.[0]?.pattern).toBe("/bin/legacy");
});
it("prefers main agent settings when both main and default exist", () => {
const file = {
version: 1,
agents: {
main: { ask: "always", allowlist: [{ pattern: "/bin/main" }] },
default: { ask: "off", allowlist: [{ pattern: "/bin/legacy" }] },
},
};
const resolved = resolveExecApprovalsFromFile({ file });
expect(resolved.agent.ask).toBe("always");
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual([
"/bin/main",
"/bin/legacy",
]);
expect(resolved.file.agents?.default).toBeUndefined();
});
});
+42 -4
View File
@@ -4,6 +4,8 @@ import net from "node:net";
import os from "node:os";
import path from "node:path";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
export type ExecHost = "sandbox" | "gateway" | "node";
export type ExecSecurity = "deny" | "allowlist" | "full";
export type ExecAsk = "off" | "on-miss" | "always";
@@ -84,6 +86,35 @@ export function resolveExecApprovalsSocketPath(): string {
return expandHome(DEFAULT_SOCKET);
}
function normalizeAllowlistPattern(value: string | undefined): string | null {
const trimmed = value?.trim() ?? "";
return trimmed ? trimmed.toLowerCase() : null;
}
function mergeLegacyAgent(
current: ExecApprovalsAgent,
legacy: ExecApprovalsAgent,
): ExecApprovalsAgent {
const allowlist: ExecAllowlistEntry[] = [];
const seen = new Set<string>();
const pushEntry = (entry: ExecAllowlistEntry) => {
const key = normalizeAllowlistPattern(entry.pattern);
if (!key || seen.has(key)) return;
seen.add(key);
allowlist.push(entry);
};
for (const entry of current.allowlist ?? []) pushEntry(entry);
for (const entry of legacy.allowlist ?? []) pushEntry(entry);
return {
security: current.security ?? legacy.security,
ask: current.ask ?? legacy.ask,
askFallback: current.askFallback ?? legacy.askFallback,
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
allowlist: allowlist.length > 0 ? allowlist : undefined,
};
}
function ensureDir(filePath: string) {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
@@ -92,6 +123,13 @@ function ensureDir(filePath: string) {
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
const socketPath = file.socket?.path?.trim();
const token = file.socket?.token?.trim();
const agents = { ...file.agents };
const legacyDefault = agents.default;
if (legacyDefault) {
const main = agents[DEFAULT_AGENT_ID];
agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault;
delete agents.default;
}
const normalized: ExecApprovalsFile = {
version: 1,
socket: {
@@ -104,7 +142,7 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi
askFallback: file.defaults?.askFallback,
autoAllowSkills: file.defaults?.autoAllowSkills,
},
agents: file.agents ?? {},
agents,
};
return normalized;
}
@@ -231,7 +269,7 @@ export function resolveExecApprovalsFromFile(params: {
}): ExecApprovalsResolved {
const file = normalizeExecApprovals(params.file);
const defaults = file.defaults ?? {};
const agentKey = params.agentId ?? "default";
const agentKey = params.agentId ?? DEFAULT_AGENT_ID;
const agent = file.agents?.[agentKey] ?? {};
const wildcard = file.agents?.["*"] ?? {};
const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY;
@@ -696,7 +734,7 @@ export function recordAllowlistUse(
command: string,
resolvedPath?: string,
) {
const target = agentId ?? "default";
const target = agentId ?? DEFAULT_AGENT_ID;
const agents = approvals.agents ?? {};
const existing = agents[target] ?? {};
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
@@ -720,7 +758,7 @@ export function addAllowlistEntry(
agentId: string | undefined,
pattern: string,
) {
const target = agentId ?? "default";
const target = agentId ?? DEFAULT_AGENT_ID;
const agents = approvals.agents ?? {};
const existing = agents[target] ?? {};
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
+25 -3
View File
@@ -410,6 +410,21 @@ function parseButtonsParam(params: Record<string, unknown>): void {
}
}
function parseCardParam(params: Record<string, unknown>): void {
const raw = params.card;
if (typeof raw !== "string") return;
const trimmed = raw.trim();
if (!trimmed) {
delete params.card;
return;
}
try {
params.card = JSON.parse(trimmed) as unknown;
} catch {
throw new Error("--card must be valid JSON");
}
}
async function resolveChannel(cfg: ClawdbotConfig, params: Record<string, unknown>) {
const channelHint = readStringParam(params, "channel");
const selection = await resolveMessageChannelSelection({
@@ -558,10 +573,15 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx;
const action: ChannelMessageActionName = "send";
const to = readStringParam(params, "to", { required: true });
const mediaHint = readStringParam(params, "media", { trim: false });
// Support media, path, and filePath parameters for attachments
const mediaHint =
readStringParam(params, "media", { trim: false }) ??
readStringParam(params, "path", { trim: false }) ??
readStringParam(params, "filePath", { trim: false });
const hasCard = params.card != null && typeof params.card === "object";
let message =
readStringParam(params, "message", {
required: !mediaHint,
required: !mediaHint && !hasCard,
allowEmpty: true,
}) ?? "";
@@ -570,7 +590,8 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
params.message = message;
if (!params.replyTo && parsed.replyToId) params.replyTo = parsed.replyToId;
if (!params.media) {
params.media = parsed.mediaUrls?.[0] || parsed.mediaUrl || undefined;
// Use path/filePath if media not set, then fall back to parsed directives
params.media = mediaHint || parsed.mediaUrls?.[0] || parsed.mediaUrl || undefined;
}
message = await maybeApplyCrossContextMarker({
@@ -729,6 +750,7 @@ export async function runMessageAction(
const cfg = input.cfg;
const params = { ...input.params };
parseButtonsParam(params);
parseCardParam(params);
const action = input.action;
if (action === "broadcast") {
+5 -3
View File
@@ -32,9 +32,11 @@ const EXT_BY_MIME: Record<string, string> = {
"text/markdown": ".md",
};
const MIME_BY_EXT: Record<string, string> = Object.fromEntries(
Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime]),
);
const MIME_BY_EXT: Record<string, string> = {
...Object.fromEntries(Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime])),
// Additional extension aliases
".jpeg": "image/jpeg",
};
const AUDIO_FILE_EXTENSIONS = new Set([
".aac",
+110
View File
@@ -161,4 +161,114 @@ describe("media store", () => {
expect(path.extname(saved.path)).toBe(".xlsx");
});
});
describe("extractOriginalFilename", () => {
it("extracts original filename from embedded pattern", async () => {
await withTempStore(async (store) => {
// Pattern: {original}---{uuid}.{ext}
const filename = "report---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf";
const result = store.extractOriginalFilename(`/path/to/${filename}`);
expect(result).toBe("report.pdf");
});
});
it("handles uppercase UUID pattern", async () => {
await withTempStore(async (store) => {
const filename = "Document---A1B2C3D4-E5F6-7890-ABCD-EF1234567890.docx";
const result = store.extractOriginalFilename(`/media/inbound/${filename}`);
expect(result).toBe("Document.docx");
});
});
it("falls back to basename for non-matching patterns", async () => {
await withTempStore(async (store) => {
// UUID-only filename (legacy format)
const uuidOnly = "a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf";
expect(store.extractOriginalFilename(`/path/${uuidOnly}`)).toBe(uuidOnly);
// Regular filename without embedded pattern
expect(store.extractOriginalFilename("/path/to/regular.txt")).toBe("regular.txt");
// Filename with --- but invalid UUID part
expect(store.extractOriginalFilename("/path/to/foo---bar.txt")).toBe("foo---bar.txt");
});
});
it("preserves original name with special characters", async () => {
await withTempStore(async (store) => {
const filename = "报告_2024---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf";
const result = store.extractOriginalFilename(`/media/${filename}`);
expect(result).toBe("报告_2024.pdf");
});
});
});
describe("saveMediaBuffer with originalFilename", () => {
it("embeds original filename in stored path when provided", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test content");
const saved = await store.saveMediaBuffer(
buf,
"text/plain",
"inbound",
5 * 1024 * 1024,
"report.txt",
);
// Should contain the original name and a UUID pattern
expect(saved.id).toMatch(/^report---[a-f0-9-]{36}\.txt$/);
expect(saved.path).toContain("report---");
// Should be able to extract original name
const extracted = store.extractOriginalFilename(saved.path);
expect(extracted).toBe("report.txt");
});
});
it("sanitizes unsafe characters in original filename", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test");
// Filename with unsafe chars: < > : " / \ | ? *
const saved = await store.saveMediaBuffer(
buf,
"text/plain",
"inbound",
5 * 1024 * 1024,
"my<file>:test.txt",
);
// Unsafe chars should be replaced with underscores
expect(saved.id).toMatch(/^my_file_test---[a-f0-9-]{36}\.txt$/);
});
});
it("truncates long original filenames", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test");
const longName = "a".repeat(100) + ".txt";
const saved = await store.saveMediaBuffer(
buf,
"text/plain",
"inbound",
5 * 1024 * 1024,
longName,
);
// Original name should be truncated to 60 chars
const baseName = path.parse(saved.id).name.split("---")[0];
expect(baseName.length).toBeLessThanOrEqual(60);
});
});
it("falls back to UUID-only when originalFilename not provided", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test");
const saved = await store.saveMediaBuffer(buf, "text/plain", "inbound");
// Should be UUID-only pattern (legacy behavior)
expect(saved.id).toMatch(/^[a-f0-9-]{36}\.txt$/);
expect(saved.id).not.toContain("---");
});
});
});
});
+52 -3
View File
@@ -11,6 +11,43 @@ const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
const MAX_BYTES = 5 * 1024 * 1024; // 5MB default
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
/**
* Sanitize a filename for cross-platform safety.
* Removes chars unsafe on Windows/SharePoint/all platforms.
* Keeps: alphanumeric, dots, hyphens, underscores, Unicode letters/numbers.
*/
function sanitizeFilename(name: string): string {
// Remove: < > : " / \ | ? * and control chars (U+0000-U+001F)
// oxlint-disable-next-line no-control-regex -- Intentionally matching control chars
const unsafe = /[<>:"/\\|?*\x00-\x1f]/g;
const sanitized = name.trim().replace(unsafe, "_").replace(/\s+/g, "_"); // Replace whitespace runs with underscore
// Collapse multiple underscores, trim leading/trailing, limit length
return sanitized.replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 60);
}
/**
* Extract original filename from path if it matches the embedded format.
* Pattern: {original}---{uuid}.{ext} → returns "{original}.{ext}"
* Falls back to basename if no pattern match, or "file.bin" if empty.
*/
export function extractOriginalFilename(filePath: string): string {
const basename = path.basename(filePath);
if (!basename) return "file.bin"; // Fallback for empty input
const ext = path.extname(basename);
const nameWithoutExt = path.basename(basename, ext);
// Check for ---{uuid} pattern (36 chars: 8-4-4-4-12 with hyphens)
const match = nameWithoutExt.match(
/^(.+)---[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i,
);
if (match?.[1]) {
return `${match[1]}${ext}`;
}
return basename; // Fallback: use as-is
}
export function getMediaDir() {
return resolveMediaDir();
}
@@ -152,17 +189,29 @@ export async function saveMediaBuffer(
contentType?: string,
subdir = "inbound",
maxBytes = MAX_BYTES,
originalFilename?: string,
): Promise<SavedMedia> {
if (buffer.byteLength > maxBytes) {
throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`);
}
const dir = path.join(resolveMediaDir(), subdir);
await fs.mkdir(dir, { recursive: true });
const baseId = crypto.randomUUID();
const uuid = crypto.randomUUID();
const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined);
const mime = await detectMime({ buffer, headerMime: contentType });
const ext = headerExt ?? extensionForMime(mime);
const id = ext ? `${baseId}${ext}` : baseId;
const ext = headerExt ?? extensionForMime(mime) ?? "";
let id: string;
if (originalFilename) {
// Embed original name: {sanitized}---{uuid}.ext
const base = path.parse(originalFilename).name;
const sanitized = sanitizeFilename(base);
id = sanitized ? `${sanitized}---${uuid}${ext}` : `${uuid}${ext}`;
} else {
// Legacy: just UUID
id = ext ? `${uuid}${ext}` : uuid;
}
const dest = path.join(dir, id);
await fs.writeFile(dest, buffer);
return { id, path: dest, size: buffer.byteLength, contentType: mime };
+75 -4
View File
@@ -102,6 +102,11 @@ const EMBEDDING_RETRY_BASE_DELAY_MS = 500;
const EMBEDDING_RETRY_MAX_DELAY_MS = 8000;
const BATCH_FAILURE_LIMIT = 2;
const SESSION_DELTA_READ_CHUNK_BYTES = 64 * 1024;
const VECTOR_LOAD_TIMEOUT_MS = 30_000;
const EMBEDDING_QUERY_TIMEOUT_REMOTE_MS = 60_000;
const EMBEDDING_QUERY_TIMEOUT_LOCAL_MS = 5 * 60_000;
const EMBEDDING_BATCH_TIMEOUT_REMOTE_MS = 2 * 60_000;
const EMBEDDING_BATCH_TIMEOUT_LOCAL_MS = 10 * 60_000;
const log = createSubsystemLogger("memory");
@@ -281,7 +286,7 @@ export class MemoryIndexManager {
? await this.searchKeyword(cleaned, candidates).catch(() => [])
: [];
const queryVec = await this.provider.embedQuery(cleaned);
const queryVec = await this.embedQueryWithTimeout(cleaned);
const hasVector = queryVec.some((v) => v !== 0);
const vectorResults = hasVector
? await this.searchVector(queryVec, candidates).catch(() => [])
@@ -580,9 +585,23 @@ export class MemoryIndexManager {
private async ensureVectorReady(dimensions?: number): Promise<boolean> {
if (!this.vector.enabled) return false;
if (!this.vectorReady) {
this.vectorReady = this.loadVectorExtension();
this.vectorReady = this.withTimeout(
this.loadVectorExtension(),
VECTOR_LOAD_TIMEOUT_MS,
`sqlite-vec load timed out after ${Math.round(VECTOR_LOAD_TIMEOUT_MS / 1000)}s`,
);
}
let ready = false;
try {
ready = await this.vectorReady;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.vector.available = false;
this.vector.loadError = message;
this.vectorReady = null;
log.warn(`sqlite-vec unavailable: ${message}`);
return false;
}
const ready = await this.vectorReady;
if (ready && typeof dimensions === "number" && dimensions > 0) {
this.ensureVectorTable(dimensions);
}
@@ -1153,6 +1172,13 @@ export class MemoryIndexManager {
progress?: (update: MemorySyncProgressUpdate) => void;
}) {
const progress = params?.progress ? this.createSyncProgress(params.progress) : undefined;
if (progress) {
progress.report({
completed: progress.completed,
total: progress.total,
label: "Loading vector extension…",
});
}
const vectorReady = await this.ensureVectorReady();
const meta = this.readMeta();
const needsFullReindex =
@@ -1844,7 +1870,17 @@ export class MemoryIndexManager {
let delayMs = EMBEDDING_RETRY_BASE_DELAY_MS;
while (true) {
try {
return await this.provider.embedBatch(texts);
const timeoutMs = this.resolveEmbeddingTimeout("batch");
log.debug("memory embeddings: batch start", {
provider: this.provider.id,
items: texts.length,
timeoutMs,
});
return await this.withTimeout(
this.provider.embedBatch(texts),
timeoutMs,
`memory embeddings batch timed out after ${Math.round(timeoutMs / 1000)}s`,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!this.isRetryableEmbeddingError(message) || attempt >= EMBEDDING_RETRY_MAX_ATTEMPTS) {
@@ -1868,6 +1904,41 @@ export class MemoryIndexManager {
);
}
private resolveEmbeddingTimeout(kind: "query" | "batch"): number {
const isLocal = this.provider.id === "local";
if (kind === "query") {
return isLocal ? EMBEDDING_QUERY_TIMEOUT_LOCAL_MS : EMBEDDING_QUERY_TIMEOUT_REMOTE_MS;
}
return isLocal ? EMBEDDING_BATCH_TIMEOUT_LOCAL_MS : EMBEDDING_BATCH_TIMEOUT_REMOTE_MS;
}
private async embedQueryWithTimeout(text: string): Promise<number[]> {
const timeoutMs = this.resolveEmbeddingTimeout("query");
log.debug("memory embeddings: query start", { provider: this.provider.id, timeoutMs });
return await this.withTimeout(
this.provider.embedQuery(text),
timeoutMs,
`memory embeddings query timed out after ${Math.round(timeoutMs / 1000)}s`,
);
}
private async withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
message: string,
): Promise<T> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return await promise;
let timer: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
});
try {
return (await Promise.race([promise, timeoutPromise])) as T;
} finally {
if (timer) clearTimeout(timer);
}
}
private async runWithConcurrency<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<T[]> {
if (tasks.length === 0) return [];
const resolvedLimit = Math.max(1, Math.min(limit, tasks.length));
-1
View File
@@ -19,7 +19,6 @@ describe("plugin-sdk exports", () => {
"writeConfigFile",
"runCommandWithTimeout",
"enqueueSystemEvent",
"detectMime",
"fetchRemoteMedia",
"saveMediaBuffer",
"formatAgentEnvelope",
+5
View File
@@ -206,6 +206,8 @@ export type {
DiagnosticWebhookProcessedEvent,
DiagnosticWebhookReceivedEvent,
} from "../infra/diagnostic-events.js";
export { detectMime, extensionForMime, getFileExtension } from "../media/mime.js";
export { extractOriginalFilename } from "../media/store.js";
// Channel: Discord
export {
@@ -282,3 +284,6 @@ export { collectWhatsAppStatusIssues } from "../channels/plugins/status-issues/w
// Channel: BlueBubbles
export { collectBlueBubblesStatusIssues } from "../channels/plugins/status-issues/bluebubbles.js";
// Media utilities
export { loadWebMedia, type WebMediaResult } from "../web/media.js";
@@ -12,7 +12,7 @@ vi.mock("./send.js", () => ({
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
dispatchReplyFromConfig: vi.fn(
async (params: { replyOptions?: { onReplyStart?: () => void } }) => {
await params.replyOptions?.onReplyStart?.();
await Promise.resolve(params.replyOptions?.onReplyStart?.());
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
},
),
@@ -141,83 +141,101 @@ describe("createTelegramBot", () => {
// groupPolicy tests
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
const originalTz = process.env.TZ;
process.env.TZ = "UTC";
loadConfig.mockReturnValue({
identity: { name: "Bert" },
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
try {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({
identity: { name: "Bert" },
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
},
});
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "group", title: "Test Group" },
text: "bert: introduce yourself",
date: 1736380800,
message_id: 1,
from: { id: 9, first_name: "Ada" },
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
await handler({
message: {
chat: { id: 7, type: "group", title: "Test Group" },
text: "bert: introduce yourself",
date: 1736380800,
message_id: 1,
from: { id: 9, first_name: "Ada" },
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.WasMentioned).toBe(true);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.WasMentioned).toBe(true);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
expect(payload.Body).toMatch(
/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
);
} finally {
process.env.TZ = originalTz;
}
});
it("keeps group envelope headers stable (sender identity is separate)", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
const originalTz = process.env.TZ;
process.env.TZ = "UTC";
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
try {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
},
});
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "hello",
date: 1736380800,
message_id: 2,
from: {
id: 99,
first_name: "Ada",
last_name: "Lovelace",
username: "ada",
await handler({
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "hello",
date: 1736380800,
message_id: 2,
from: {
id: 99,
first_name: "Ada",
last_name: "Lovelace",
username: "ada",
},
},
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
expect(payload.Body).toMatch(
/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
);
} finally {
process.env.TZ = originalTz;
}
});
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
onSpy.mockReset();
@@ -329,7 +329,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toMatch(
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09 01:00 [^\]]+\]/,
);
expect(payload.Body).toContain("hello world");
} finally {
+85 -67
View File
@@ -451,7 +451,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toMatch(
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09 01:00 [^\]]+\]/,
);
expect(payload.Body).toContain("hello world");
} finally {
@@ -551,86 +551,104 @@ describe("createTelegramBot", () => {
});
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
const originalTz = process.env.TZ;
process.env.TZ = "UTC";
loadConfig.mockReturnValue({
identity: { name: "Bert" },
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
try {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({
identity: { name: "Bert" },
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
},
});
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "group", title: "Test Group" },
text: "bert: introduce yourself",
date: 1736380800,
message_id: 1,
from: { id: 9, first_name: "Ada" },
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
await handler({
message: {
chat: { id: 7, type: "group", title: "Test Group" },
text: "bert: introduce yourself",
date: 1736380800,
message_id: 1,
from: { id: 9, first_name: "Ada" },
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.WasMentioned).toBe(true);
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.WasMentioned).toBe(true);
expect(payload.Body).toMatch(
/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
} finally {
process.env.TZ = originalTz;
}
});
it("includes sender identity in group envelope headers", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
const originalTz = process.env.TZ;
process.env.TZ = "UTC";
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
try {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
},
});
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "hello",
date: 1736380800,
message_id: 2,
from: {
id: 99,
first_name: "Ada",
last_name: "Lovelace",
username: "ada",
await handler({
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "hello",
date: 1736380800,
message_id: 2,
from: {
id: 99,
first_name: "Ada",
last_name: "Lovelace",
username: "ada",
},
},
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.Body).toMatch(
/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
);
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
} finally {
process.env.TZ = originalTz;
}
});
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
@@ -329,11 +329,11 @@ describe("web auto-reply", () => {
const firstArgs = resolver.mock.calls[0][0];
const secondArgs = resolver.mock.calls[1][0];
expect(firstArgs.Body).toMatch(
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T00:00Z\] \[clawdbot\] first/,
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01 01:00 [^\]]+\] \[clawdbot\] first/,
);
expect(firstArgs.Body).not.toContain("second");
expect(secondArgs.Body).toMatch(
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T01:00Z\] \[clawdbot\] second/,
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01 02:00 [^\]]+\] \[clawdbot\] second/,
);
expect(secondArgs.Body).not.toContain("first");
+10 -5
View File
@@ -4,11 +4,12 @@ import { fileURLToPath } from "node:url";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js";
import { resolveUserPath } from "../utils.js";
import { fetchRemoteMedia } from "../media/fetch.js";
import { convertHeicToJpeg, resizeToJpeg } from "../media/image-ops.js";
import { detectMime, extensionForMime } from "../media/mime.js";
type WebMediaResult = {
export type WebMediaResult = {
buffer: Buffer;
contentType?: string;
kind: MediaKind;
@@ -89,10 +90,9 @@ async function loadWebMediaInternal(
kind: MediaKind;
fileName?: string;
}): Promise<WebMediaResult> => {
const cap =
maxBytes !== undefined
? Math.min(maxBytes, maxBytesForKind(params.kind))
: maxBytesForKind(params.kind);
// If caller explicitly provides maxBytes, trust it (for channels that handle large files).
// Otherwise fall back to per-kind defaults.
const cap = maxBytes !== undefined ? maxBytes : maxBytesForKind(params.kind);
if (params.kind === "image") {
const isGif = params.contentType === "image/gif";
if (isGif || !optimizeImages) {
@@ -141,6 +141,11 @@ async function loadWebMediaInternal(
return await clampAndFinalize({ buffer, contentType, kind, fileName });
}
// Expand tilde paths to absolute paths (e.g., ~/Downloads/photo.jpg)
if (mediaUrl.startsWith("~")) {
mediaUrl = resolveUserPath(mediaUrl);
}
// Local path
const data = await fs.readFile(mediaUrl);
const mime = await detectMime({ buffer: data, filePath: mediaUrl });
+6
View File
@@ -89,6 +89,12 @@
color: rgba(134, 142, 150, 1);
}
/* Image avatar support */
img.chat-avatar {
object-fit: cover;
object-position: center;
}
/* Minimal Bubble Design - dynamic width based on content */
.chat-bubble {
position: relative;
+6
View File
@@ -14,6 +14,8 @@ import {
startNodesPolling,
stopLogsPolling,
stopNodesPolling,
startDebugPolling,
stopDebugPolling,
} from "./app-polling";
type LifecycleHost = {
@@ -52,6 +54,9 @@ export function handleConnected(host: LifecycleHost) {
if (host.tab === "logs") {
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
}
if (host.tab === "debug") {
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
}
}
export function handleFirstUpdated(host: LifecycleHost) {
@@ -62,6 +67,7 @@ export function handleDisconnected(host: LifecycleHost) {
window.removeEventListener("popstate", host.popStateHandler);
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
detachThemeListener(
host as unknown as Parameters<typeof detachThemeListener>[0],
);
+16
View File
@@ -1,10 +1,12 @@
import { loadLogs } from "./controllers/logs";
import { loadNodes } from "./controllers/nodes";
import { loadDebug } from "./controllers/debug";
import type { ClawdbotApp } from "./app";
type PollingHost = {
nodesPollInterval: number | null;
logsPollInterval: number | null;
debugPollInterval: number | null;
tab: string;
};
@@ -35,3 +37,17 @@ export function stopLogsPolling(host: PollingHost) {
clearInterval(host.logsPollInterval);
host.logsPollInterval = null;
}
export function startDebugPolling(host: PollingHost) {
if (host.debugPollInterval != null) return;
host.debugPollInterval = window.setInterval(() => {
if (host.tab !== "debug") return;
void loadDebug(host as unknown as ClawdbotApp);
}, 3000);
}
export function stopDebugPolling(host: PollingHost) {
if (host.debugPollInterval == null) return;
clearInterval(host.debugPollInterval);
host.debugPollInterval = null;
}
+91
View File
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Tab } from "./navigation";
type SettingsHost = Parameters<typeof import("./app-settings").setTabFromRoute>[0];
const createHost = (tab: Tab): SettingsHost => ({
settings: {
gatewayUrl: "",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
},
theme: "system",
themeResolved: "dark",
applySessionKey: "main",
sessionKey: "main",
tab,
connected: false,
chatHasAutoScrolled: false,
logsAtBottom: false,
eventLog: [],
eventLogBuffer: [],
basePath: "",
themeMedia: null,
themeMediaHandler: null,
});
describe("setTabFromRoute", () => {
beforeEach(() => {
vi.resetModules();
});
it("starts and stops log polling based on the tab", async () => {
const startLogsPolling = vi.fn();
const stopLogsPolling = vi.fn();
const startDebugPolling = vi.fn();
const stopDebugPolling = vi.fn();
vi.doMock("./app-polling", () => ({
startLogsPolling,
stopLogsPolling,
startDebugPolling,
stopDebugPolling,
}));
const { setTabFromRoute } = await import("./app-settings");
const host = createHost("chat");
setTabFromRoute(host, "logs");
expect(startLogsPolling).toHaveBeenCalledTimes(1);
expect(stopLogsPolling).not.toHaveBeenCalled();
expect(startDebugPolling).not.toHaveBeenCalled();
expect(stopDebugPolling).toHaveBeenCalledTimes(1);
setTabFromRoute(host, "chat");
expect(stopLogsPolling).toHaveBeenCalledTimes(1);
});
it("starts and stops debug polling based on the tab", async () => {
const startLogsPolling = vi.fn();
const stopLogsPolling = vi.fn();
const startDebugPolling = vi.fn();
const stopDebugPolling = vi.fn();
vi.doMock("./app-polling", () => ({
startLogsPolling,
stopLogsPolling,
startDebugPolling,
stopDebugPolling,
}));
const { setTabFromRoute } = await import("./app-settings");
const host = createHost("chat");
setTabFromRoute(host, "debug");
expect(startDebugPolling).toHaveBeenCalledTimes(1);
expect(stopDebugPolling).not.toHaveBeenCalled();
expect(startLogsPolling).not.toHaveBeenCalled();
expect(stopLogsPolling).toHaveBeenCalledTimes(1);
setTabFromRoute(host, "chat");
expect(stopDebugPolling).toHaveBeenCalledTimes(1);
});
});
+7 -1
View File
@@ -14,7 +14,7 @@ import { saveSettings, type UiSettings } from "./storage";
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
import { startLogsPolling, stopLogsPolling } from "./app-polling";
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
import { refreshChat } from "./app-chat";
import type { ClawdbotApp } from "./app";
@@ -116,6 +116,9 @@ export function setTab(host: SettingsHost, next: Tab) {
if (next === "logs")
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
if (next === "debug")
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
else stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
void refreshActiveTab(host);
syncUrlWithTab(host, next, false);
}
@@ -261,6 +264,9 @@ export function setTabFromRoute(host: SettingsHost, next: Tab) {
if (next === "logs")
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
if (next === "debug")
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
else stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
if (host.connected) void refreshActiveTab(host);
}
+1
View File
@@ -226,6 +226,7 @@ export class ClawdbotApp extends LitElement {
private chatUserNearBottom = true;
private nodesPollInterval: number | null = null;
private logsPollInterval: number | null = null;
private debugPollInterval: number | null = null;
private logsScrollFrame: number | null = null;
private toolStreamById = new Map<string, ToolStreamEntry>();
private toolStreamOrder: string[] = [];
+7 -1
View File
@@ -105,7 +105,7 @@ export function renderMessageGroup(
`;
}
function renderAvatar(role: string) {
function renderAvatar(role: string, avatarUrl?: string) {
const normalized = normalizeRoleForGrouping(role);
const initial =
normalized === "user"
@@ -123,6 +123,12 @@ function renderAvatar(role: string) {
: normalized === "tool"
? "tool"
: "other";
// If avatar URL is provided for assistant, show image
if (avatarUrl && normalized === "assistant") {
return html`<img class="chat-avatar ${className}" src="${avatarUrl}" alt="Assistant" />`;
}
return html`<div class="chat-avatar ${className}">${initial}</div>`;
}
+5 -1
View File
@@ -1,7 +1,7 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { analyzeConfigSchema, renderConfigForm } from "./views/config-form";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./views/config-form";
const rootSchema = {
type: "object",
@@ -40,6 +40,10 @@ const rootSchema = {
};
describe("config form renderer", () => {
it("exposes section metadata", () => {
expect(SECTION_META.env.label).toBe("Environment Variables");
});
it("renders inputs and patches values", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
+1 -1
View File
@@ -54,7 +54,7 @@ const sectionIcons = {
};
// Section metadata
const SECTION_META: Record<string, { label: string; description: string }> = {
export const SECTION_META: Record<string, { label: string; description: string }> = {
env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" },
update: { label: "Updates", description: "Auto-update settings and release channel" },
agents: { label: "Agents", description: "Agent configurations, models, and identities" },
+1 -1
View File
@@ -1,4 +1,4 @@
export { renderConfigForm, type ConfigFormProps } from "./config-form.render";
export { renderConfigForm, type ConfigFormProps, SECTION_META } from "./config-form.render";
export {
analyzeConfigSchema,
type ConfigSchemaAnalysis,
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more