feat: add slack multi-account routing

This commit is contained in:
Peter Steinberger
2026-01-08 08:49:16 +01:00
parent 00c1403f5c
commit 8930ec32cb
31 changed files with 878 additions and 93 deletions

View File

@@ -74,6 +74,7 @@
- CLI: add `clawdbot agents` (list/add/delete) with wizarded workspace/setup, provider login, and full prune on delete.
- CLI: add non-interactive flags for `agents add`, support `agents list --bindings`, and keep JSON output clean for scripting.
- Discord/Slack: fork thread sessions (agent-scoped) and inject thread starters for context. Thanks @thewilloftheshadow for PR #400.
- Slack: route tool actions per account and suppress duplicate follow-ups per provider/target/account. Thanks @adam91holt for PR #457.
- Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341.
- Agent: add opt-in session pruning for tool results to reduce context bloat. Thanks @maxsumrall for PR #381.
- Agent: protect bootstrap prefix from context pruning. Thanks @maxsumrall for PR #381.

View File

@@ -451,9 +451,9 @@ Thanks to all clawtributors:
<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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a>
<a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></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/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/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/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/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/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=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/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/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/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/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/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/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/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/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/search?q=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=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></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/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=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a>
<a href="https://github.com/search?q=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/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/search?q=Tobias%20Bischoff"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></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/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/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/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/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/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/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/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/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/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/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></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/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/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/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/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/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=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/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/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/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/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/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/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/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/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/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/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=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a>
<a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/search?q=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/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/search?q=Tobias%20Bischoff"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></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>

View File

@@ -733,15 +733,17 @@ Slack runs in Socket Mode and requires both a bot token and app token:
groupChannels: ["G123"]
},
channels: {
C123: { allow: true, requireMention: true },
C123: { allow: true, requireMention: true, allowBots: false },
"#general": {
allow: true,
requireMention: true,
allowBots: false,
users: ["U123"],
skills: ["docs"],
systemPrompt: "Short answers only."
}
},
allowBots: false,
reactionNotifications: "own", // off | own | all | allowlist
reactionAllowlist: ["U123"],
replyToMode: "off", // off | first | all
@@ -768,6 +770,8 @@ Multi-account support lives under `slack.accounts` (see the multi-account sectio
Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands.
Bot-authored messages are ignored by default. Enable with `slack.allowBots` or `slack.channels.<id>.allowBots`.
Reaction notification modes:
- `off`: no reaction events.
- `own`: reactions on the bot's own messages (default).

View File

@@ -227,6 +227,7 @@ Controlled by `slack.replyToMode`:
Channel options (`slack.channels.<id>` or `slack.channels.<name>`):
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
- `requireMention`: mention gating for the channel.
- `allowBots`: allow bot-authored messages in this channel (default: false).
- `users`: optional per-channel user allowlist.
- `skills`: skill filter (omit = all skills, empty = none).
- `systemPrompt`: extra system prompt for the channel (combined with topic/purpose).
@@ -251,5 +252,6 @@ Slack tool actions can be gated with `slack.actions.*`:
## Notes
- Mention gating is controlled via `slack.channels` (set `requireMention` to `true`); `routing.groupChat.mentionPatterns` also count as mentions.
- Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`).
- Bot-authored messages are ignored by default; enable via `slack.allowBots` or `slack.channels.<id>.allowBots`.
- For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions).
- Attachments are downloaded to the media store when permitted and under the size limit.

View File

@@ -20,6 +20,7 @@ export function createClawdbotTools(options?: {
browserControlUrl?: string;
agentSessionKey?: string;
agentProvider?: string;
agentAccountId?: string;
agentDir?: string;
sandboxed?: boolean;
config?: ClawdbotConfig;
@@ -34,7 +35,10 @@ export function createClawdbotTools(options?: {
createNodesTool(),
createCronTool(),
createDiscordTool(),
createSlackTool(),
createSlackTool({
agentAccountId: options?.agentAccountId,
config: options?.config,
}),
createTelegramTool(),
createWhatsAppTool(),
createGatewayTool({ agentSessionKey: options?.agentSessionKey }),

View File

@@ -276,6 +276,13 @@ type ApiKeyInfo = {
source: string;
};
export type MessagingToolSend = {
tool: string;
provider: string;
accountId?: string;
to?: string;
};
export type EmbeddedPiRunResult = {
payloads?: Array<{
text?: string;
@@ -290,6 +297,8 @@ export type EmbeddedPiRunResult = {
didSendViaMessagingTool?: boolean;
// Texts successfully sent via messaging tools during the run.
messagingToolSentTexts?: string[];
// Messaging tool targets that successfully sent a message during the run.
messagingToolSentTargets?: MessagingToolSend[];
};
export type EmbeddedPiCompactResult = {
@@ -737,6 +746,7 @@ export async function compactEmbeddedPiSession(params: {
sessionId: string;
sessionKey?: string;
messageProvider?: string;
agentAccountId?: string;
sessionFile: string;
workspaceDir: string;
agentDir?: string;
@@ -842,6 +852,7 @@ export async function compactEmbeddedPiSession(params: {
},
sandbox,
messageProvider: params.messageProvider,
agentAccountId: params.agentAccountId,
sessionKey: params.sessionKey ?? params.sessionId,
agentDir,
config: params.config,
@@ -962,6 +973,7 @@ export async function runEmbeddedPiAgent(params: {
sessionId: string;
sessionKey?: string;
messageProvider?: string;
agentAccountId?: string;
sessionFile: string;
workspaceDir: string;
agentDir?: string;
@@ -1153,6 +1165,7 @@ export async function runEmbeddedPiAgent(params: {
},
sandbox,
messageProvider: params.messageProvider,
agentAccountId: params.agentAccountId,
sessionKey: params.sessionKey ?? params.sessionId,
agentDir,
config: params.config,
@@ -1283,6 +1296,7 @@ export async function runEmbeddedPiAgent(params: {
unsubscribe,
waitForCompactionRetry,
getMessagingToolSentTexts,
getMessagingToolSentTargets,
didSendViaMessagingTool,
} = subscription;
@@ -1567,6 +1581,7 @@ export async function runEmbeddedPiAgent(params: {
},
didSendViaMessagingTool: didSendViaMessagingTool(),
messagingToolSentTexts: getMessagingToolSentTexts(),
messagingToolSentTargets: getMessagingToolSentTargets(),
};
} finally {
restoreSkillEnv?.();

View File

@@ -26,6 +26,13 @@ const log = createSubsystemLogger("agent/embedded");
export type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
type MessagingToolSend = {
tool: string;
provider: string;
accountId?: string;
to?: string;
};
function truncateToolText(text: string): string {
if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
@@ -86,6 +93,127 @@ function stripUnpairedThinkingTags(text: string): string {
return text;
}
function normalizeSlackTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
const mentionMatch = trimmed.match(/^<@([A-Z0-9]+)>$/i);
if (mentionMatch) return `user:${mentionMatch[1]}`;
if (trimmed.startsWith("user:")) {
const id = trimmed.slice(5).trim();
return id ? `user:${id}` : undefined;
}
if (trimmed.startsWith("channel:")) {
const id = trimmed.slice(8).trim();
return id ? `channel:${id}` : undefined;
}
if (trimmed.startsWith("slack:")) {
const id = trimmed.slice(6).trim();
return id ? `user:${id}` : undefined;
}
if (trimmed.startsWith("@")) {
const id = trimmed.slice(1).trim();
return id ? `user:${id}` : undefined;
}
if (trimmed.startsWith("#")) {
const id = trimmed.slice(1).trim();
return id ? `channel:${id}` : undefined;
}
return `channel:${trimmed}`;
}
function normalizeDiscordTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
const mentionMatch = trimmed.match(/^<@!?(\d+)>$/);
if (mentionMatch) return `user:${mentionMatch[1]}`;
if (trimmed.startsWith("user:")) {
const id = trimmed.slice(5).trim();
return id ? `user:${id}` : undefined;
}
if (trimmed.startsWith("channel:")) {
const id = trimmed.slice(8).trim();
return id ? `channel:${id}` : undefined;
}
if (trimmed.startsWith("discord:")) {
const id = trimmed.slice(8).trim();
return id ? `user:${id}` : undefined;
}
if (trimmed.startsWith("@")) {
const id = trimmed.slice(1).trim();
return id ? `user:${id}` : undefined;
}
return `channel:${trimmed}`;
}
function normalizeTelegramTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
let normalized = trimmed;
if (normalized.startsWith("telegram:")) {
normalized = normalized.slice("telegram:".length).trim();
} else if (normalized.startsWith("tg:")) {
normalized = normalized.slice("tg:".length).trim();
} else if (normalized.startsWith("group:")) {
normalized = normalized.slice("group:".length).trim();
}
if (!normalized) return undefined;
const tmeMatch =
/^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ??
/^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized);
if (tmeMatch?.[1]) normalized = `@${tmeMatch[1]}`;
if (!normalized) return undefined;
return `telegram:${normalized}`;
}
function extractMessagingToolSend(
toolName: string,
args: Record<string, unknown>,
): MessagingToolSend | undefined {
const action = typeof args.action === "string" ? args.action.trim() : "";
const accountIdRaw =
typeof args.accountId === "string" ? args.accountId.trim() : undefined;
const accountId = accountIdRaw ? accountIdRaw : undefined;
if (toolName === "slack") {
if (action !== "sendMessage") return undefined;
const toRaw = typeof args.to === "string" ? args.to : undefined;
if (!toRaw) return undefined;
const to = normalizeSlackTarget(toRaw);
return to
? { tool: toolName, provider: "slack", accountId, to }
: undefined;
}
if (toolName === "discord") {
if (action === "sendMessage") {
const toRaw = typeof args.to === "string" ? args.to : undefined;
if (!toRaw) return undefined;
const to = normalizeDiscordTarget(toRaw);
return to
? { tool: toolName, provider: "discord", accountId, to }
: undefined;
}
if (action === "threadReply") {
const channelId =
typeof args.channelId === "string" ? args.channelId.trim() : "";
if (!channelId) return undefined;
const to = normalizeDiscordTarget(`channel:${channelId}`);
return to
? { tool: toolName, provider: "discord", accountId, to }
: undefined;
}
return undefined;
}
if (toolName === "telegram") {
if (action !== "sendMessage") return undefined;
const toRaw = typeof args.to === "string" ? args.to : undefined;
if (!toRaw) return undefined;
const to = normalizeTelegramTarget(toRaw);
return to
? { tool: toolName, provider: "telegram", accountId, to }
: undefined;
}
return undefined;
}
export function subscribeEmbeddedPiSession(params: {
session: AgentSession;
runId: string;
@@ -151,7 +279,9 @@ export function subscribeEmbeddedPiSession(params: {
"sessions_send",
]);
const messagingToolSentTexts: string[] = [];
const messagingToolSentTargets: MessagingToolSend[] = [];
const pendingMessagingTexts = new Map<string, string>();
const pendingMessagingTargets = new Map<string, MessagingToolSend>();
const ensureCompactionPromise = () => {
if (!compactionRetryPromise) {
@@ -315,7 +445,9 @@ export function subscribeEmbeddedPiSession(params: {
toolMetaById.clear();
toolSummaryById.clear();
messagingToolSentTexts.length = 0;
messagingToolSentTargets.length = 0;
pendingMessagingTexts.clear();
pendingMessagingTargets.clear();
deltaBuffer = "";
blockBuffer = "";
blockChunker?.reset();
@@ -398,6 +530,10 @@ export function subscribeEmbeddedPiSession(params: {
action === "threadReply" ||
toolName === "sessions_send"
) {
const sendTarget = extractMessagingToolSend(toolName, argsRecord);
if (sendTarget) {
pendingMessagingTargets.set(toolCallId, sendTarget);
}
// Field names vary by tool: Discord/Slack use "content", sessions_send uses "message"
const text =
(argsRecord.content as string) ?? (argsRecord.message as string);
@@ -460,6 +596,7 @@ export function subscribeEmbeddedPiSession(params: {
// Commit messaging tool text on success, discard on error
const pendingText = pendingMessagingTexts.get(toolCallId);
const pendingTarget = pendingMessagingTargets.get(toolCallId);
if (pendingText) {
pendingMessagingTexts.delete(toolCallId);
if (!isError) {
@@ -469,6 +606,12 @@ export function subscribeEmbeddedPiSession(params: {
);
}
}
if (pendingTarget) {
pendingMessagingTargets.delete(toolCallId);
if (!isError) {
messagingToolSentTargets.push(pendingTarget);
}
}
emitAgentEvent({
runId: params.runId,
@@ -779,6 +922,7 @@ export function subscribeEmbeddedPiSession(params: {
unsubscribe,
isCompacting: () => compactionInFlight || pendingCompactionRetry > 0,
getMessagingToolSentTexts: () => messagingToolSentTexts.slice(),
getMessagingToolSentTargets: () => messagingToolSentTargets.slice(),
// Returns true if any messaging tool successfully sent a message.
// Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!")
// which is generated AFTER the tool sends the actual answer.

View File

@@ -625,6 +625,7 @@ function shouldIncludeWhatsAppTool(messageProvider?: string): boolean {
export function createClawdbotCodingTools(options?: {
bash?: BashToolDefaults & ProcessToolDefaults;
messageProvider?: string;
agentAccountId?: string;
sandbox?: SandboxContext | null;
sessionKey?: string;
agentDir?: string;
@@ -695,6 +696,7 @@ export function createClawdbotCodingTools(options?: {
browserControlUrl: sandbox?.browser?.controlUrl,
agentSessionKey: options?.sessionKey,
agentProvider: options?.messageProvider,
agentAccountId: options?.agentAccountId,
agentDir: options?.agentDir,
sandboxed: !!sandbox,
config: options?.config,

View File

@@ -1,6 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
import {
deleteSlackMessage,
editSlackMessage,
@@ -38,7 +39,11 @@ export async function handleSlackAction(
cfg: ClawdbotConfig,
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const isActionEnabled = createActionGate(cfg.slack?.actions);
const accountId = readStringParam(params, "accountId");
const accountOpts = accountId ? { accountId } : undefined;
const account = resolveSlackAccount({ cfg, accountId });
const actionConfig = account.actions ?? cfg.slack?.actions;
const isActionEnabled = createActionGate(actionConfig);
if (reactionsActions.has(action)) {
if (!isActionEnabled("reactions")) {
@@ -51,17 +56,29 @@ export async function handleSlackAction(
removeErrorMessage: "Emoji is required to remove a Slack reaction.",
});
if (remove) {
await removeSlackReaction(channelId, messageId, emoji);
if (accountOpts) {
await removeSlackReaction(channelId, messageId, emoji, accountOpts);
} else {
await removeSlackReaction(channelId, messageId, emoji);
}
return jsonResult({ ok: true, removed: emoji });
}
if (isEmpty) {
const removed = await removeOwnSlackReactions(channelId, messageId);
const removed = accountOpts
? await removeOwnSlackReactions(channelId, messageId, accountOpts)
: await removeOwnSlackReactions(channelId, messageId);
return jsonResult({ ok: true, removed });
}
await reactSlackMessage(channelId, messageId, emoji);
if (accountOpts) {
await reactSlackMessage(channelId, messageId, emoji, accountOpts);
} else {
await reactSlackMessage(channelId, messageId, emoji);
}
return jsonResult({ ok: true, added: emoji });
}
const reactions = await listSlackReactions(channelId, messageId);
const reactions = accountOpts
? await listSlackReactions(channelId, messageId, accountOpts)
: await listSlackReactions(channelId, messageId);
return jsonResult({ ok: true, reactions });
}
@@ -75,6 +92,7 @@ export async function handleSlackAction(
const content = readStringParam(params, "content", { required: true });
const mediaUrl = readStringParam(params, "mediaUrl");
const result = await sendSlackMessage(to, content, {
accountId: accountId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
});
return jsonResult({ ok: true, result });
@@ -89,7 +107,11 @@ export async function handleSlackAction(
const content = readStringParam(params, "content", {
required: true,
});
await editSlackMessage(channelId, messageId, content);
if (accountOpts) {
await editSlackMessage(channelId, messageId, content, accountOpts);
} else {
await editSlackMessage(channelId, messageId, content);
}
return jsonResult({ ok: true });
}
case "deleteMessage": {
@@ -99,7 +121,11 @@ export async function handleSlackAction(
const messageId = readStringParam(params, "messageId", {
required: true,
});
await deleteSlackMessage(channelId, messageId);
if (accountOpts) {
await deleteSlackMessage(channelId, messageId, accountOpts);
} else {
await deleteSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
case "readMessages": {
@@ -114,6 +140,7 @@ export async function handleSlackAction(
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const result = await readSlackMessages(channelId, {
accountId: accountId ?? undefined,
limit,
before: before ?? undefined,
after: after ?? undefined,
@@ -134,17 +161,27 @@ export async function handleSlackAction(
const messageId = readStringParam(params, "messageId", {
required: true,
});
await pinSlackMessage(channelId, messageId);
if (accountOpts) {
await pinSlackMessage(channelId, messageId, accountOpts);
} else {
await pinSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
if (action === "unpinMessage") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
await unpinSlackMessage(channelId, messageId);
if (accountOpts) {
await unpinSlackMessage(channelId, messageId, accountOpts);
} else {
await unpinSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
const pins = await listSlackPins(channelId);
const pins = accountOpts
? await listSlackPins(channelId, accountOpts)
: await listSlackPins(channelId);
return jsonResult({ ok: true, pins });
}
@@ -153,7 +190,9 @@ export async function handleSlackAction(
throw new Error("Slack member info is disabled.");
}
const userId = readStringParam(params, "userId", { required: true });
const info = await getSlackMemberInfo(userId);
const info = accountOpts
? await getSlackMemberInfo(userId, accountOpts)
: await getSlackMemberInfo(userId);
return jsonResult({ ok: true, info });
}
@@ -161,7 +200,9 @@ export async function handleSlackAction(
if (!isActionEnabled("emojiList")) {
throw new Error("Slack emoji list is disabled.");
}
const emojis = await listSlackEmojis();
const emojis = accountOpts
? await listSlackEmojis(accountOpts)
: await listSlackEmojis();
return jsonResult({ ok: true, emojis });
}

View File

@@ -9,28 +9,35 @@ export const SlackToolSchema = Type.Union([
messageId: Type.String(),
},
includeRemove: true,
extras: {
accountId: Type.Optional(Type.String()),
},
}),
Type.Object({
action: Type.Literal("reactions"),
channelId: Type.String(),
messageId: Type.String(),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("sendMessage"),
to: Type.String(),
content: Type.String(),
mediaUrl: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("editMessage"),
channelId: Type.String(),
messageId: Type.String(),
content: Type.String(),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("deleteMessage"),
channelId: Type.String(),
messageId: Type.String(),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("readMessages"),
@@ -38,26 +45,32 @@ export const SlackToolSchema = Type.Union([
limit: Type.Optional(Type.Number()),
before: Type.Optional(Type.String()),
after: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("pinMessage"),
channelId: Type.String(),
messageId: Type.String(),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("unpinMessage"),
channelId: Type.String(),
messageId: Type.String(),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("listPins"),
channelId: Type.String(),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("memberInfo"),
userId: Type.String(),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("emojiList"),
accountId: Type.Optional(Type.String()),
}),
]);

View File

@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const handleSlackActionMock = vi.fn();
vi.mock("./slack-actions.js", () => ({
handleSlackAction: (params: unknown, cfg: unknown) =>
handleSlackActionMock(params, cfg),
}));
import { createSlackTool } from "./slack-tool.js";
describe("slack tool", () => {
beforeEach(() => {
handleSlackActionMock.mockReset();
handleSlackActionMock.mockResolvedValue({
content: [],
details: { ok: true },
});
});
it("injects agentAccountId when accountId is missing", async () => {
const tool = createSlackTool({
agentAccountId: " Kev ",
config: { slack: { accounts: { kev: {} } } },
});
await tool.execute("call-1", {
action: "sendMessage",
to: "channel:C1",
content: "hello",
});
expect(handleSlackActionMock).toHaveBeenCalledTimes(1);
const [params] = handleSlackActionMock.mock.calls[0] ?? [];
expect(params).toMatchObject({ accountId: "kev" });
});
it("keeps explicit accountId when provided", async () => {
const tool = createSlackTool({
agentAccountId: "kev",
config: {},
});
await tool.execute("call-2", {
action: "sendMessage",
to: "channel:C1",
content: "hello",
accountId: "rex",
});
expect(handleSlackActionMock).toHaveBeenCalledTimes(1);
const [params] = handleSlackActionMock.mock.calls[0] ?? [];
expect(params).toMatchObject({ accountId: "rex" });
});
it("does not inject accountId when agentAccountId is missing", async () => {
const tool = createSlackTool({ config: {} });
await tool.execute("call-3", {
action: "sendMessage",
to: "channel:C1",
content: "hello",
});
expect(handleSlackActionMock).toHaveBeenCalledTimes(1);
const [params] = handleSlackActionMock.mock.calls[0] ?? [];
expect(params).not.toHaveProperty("accountId");
});
it("does not inject unknown agentAccountId when not configured", async () => {
const tool = createSlackTool({
agentAccountId: "unknown",
config: { slack: { accounts: { kev: {} } } },
});
await tool.execute("call-4", {
action: "sendMessage",
to: "channel:C1",
content: "hello",
});
const [params] = handleSlackActionMock.mock.calls[0] ?? [];
expect(params).not.toHaveProperty("accountId");
});
});

View File

@@ -1,9 +1,44 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js";
import { handleSlackAction } from "./slack-actions.js";
import { SlackToolSchema } from "./slack-schema.js";
export function createSlackTool(): AnyAgentTool {
type SlackToolOptions = {
agentAccountId?: string;
config?: ClawdbotConfig;
};
function resolveAgentAccountId(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return normalizeAccountId(trimmed);
}
function resolveConfiguredAccountId(
cfg: ClawdbotConfig,
accountId: string,
): string | undefined {
if (accountId === "default") return accountId;
const accounts = cfg.slack?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
if (accountId in accounts) return accountId;
const match = Object.keys(accounts).find(
(key) => key.toLowerCase() === accountId.toLowerCase(),
);
return match;
}
function hasAccountId(params: Record<string, unknown>): boolean {
const raw = params.accountId;
if (typeof raw !== "string") return false;
return raw.trim().length > 0;
}
export function createSlackTool(options?: SlackToolOptions): AnyAgentTool {
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
return {
label: "Slack",
name: "slack",
@@ -11,8 +46,24 @@ export function createSlackTool(): AnyAgentTool {
parameters: SlackToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const cfg = loadConfig();
return await handleSlackAction(params, cfg);
const cfg = options?.config ?? loadConfig();
const resolvedAccountId = agentAccountId
? resolveConfiguredAccountId(cfg, agentAccountId)
: undefined;
const resolvedParams =
resolvedAccountId && !hasAccountId(params)
? { ...params, accountId: resolvedAccountId }
: params;
if (hasAccountId(resolvedParams)) {
const action =
typeof params.action === "string" ? params.action : "unknown";
logVerbose(
`slack tool: action=${action} accountId=${String(
resolvedParams.accountId,
).trim()}`,
);
}
return await handleSlackAction(resolvedParams, cfg);
},
};
}

View File

@@ -791,6 +791,7 @@ export async function getReplyFromConfig(
sessionId: sessionIdFinal,
sessionKey,
messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined,
agentAccountId: sessionCtx.AccountId,
sessionFile,
workspaceDir,
config: cfg,

View File

@@ -0,0 +1,149 @@
import { describe, expect, it, vi } from "vitest";
import type { TemplateContext } from "../templating.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
const runEmbeddedPiAgentMock = vi.fn();
vi.mock("../../agents/model-fallback.js", () => ({
runWithModelFallback: async ({
provider,
model,
run,
}: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => ({
result: await run(provider, model),
provider,
model,
}),
}));
vi.mock("../../agents/pi-embedded.js", () => ({
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
}));
vi.mock("./queue.js", async () => {
const actual =
await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
scheduleFollowupDrain: vi.fn(),
};
});
import { runReplyAgent } from "./agent-runner.js";
function createRun(messageProvider = "slack") {
const typing = createMockTypingController();
const sessionCtx = {
Provider: messageProvider,
OriginatingTo: "channel:C1",
AccountId: "primary",
MessageSid: "msg",
} as unknown as TemplateContext;
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
const followupRun = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
run: {
sessionId: "session",
sessionKey: "main",
messageProvider,
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "anthropic",
model: "claude",
thinkLevel: "low",
verboseLevel: "off",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 1_000,
blockReplyBreak: "message_end",
},
} as unknown as FollowupRun;
return runReplyAgent({
commandBody: "hello",
followupRun,
queueKey: "main",
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing,
sessionCtx,
defaultModel: "anthropic/claude-opus-4-5",
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
});
}
describe("runReplyAgent messaging tool suppression", () => {
it("drops replies when a messaging tool sent via the same provider + target", async () => {
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [
{ tool: "slack", provider: "slack", to: "channel:C1" },
],
meta: {},
});
const result = await createRun("slack");
expect(result).toBeUndefined();
});
it("delivers replies when tool provider does not match", async () => {
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [
{ tool: "discord", provider: "discord", to: "channel:C1" },
],
meta: {},
});
const result = await createRun("slack");
expect(result).toMatchObject({ text: "hello world!" });
});
it("delivers replies when account ids do not match", async () => {
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [
{
tool: "slack",
provider: "slack",
to: "channel:C1",
accountId: "alt",
},
],
meta: {},
});
const result = await createRun("slack");
expect(result).toMatchObject({ text: "hello world!" });
});
});

View File

@@ -36,6 +36,7 @@ import {
applyReplyThreading,
filterMessagingToolDuplicates,
isRenderablePayload,
shouldSuppressMessagingToolReplies,
} from "./reply-payloads.js";
import {
createReplyToModeFilter,
@@ -240,6 +241,7 @@ export async function runReplyAgent(params: {
sessionKey,
messageProvider:
sessionCtx.Provider?.trim().toLowerCase() || undefined,
agentAccountId: sessionCtx.AccountId,
sessionFile: followupRun.run.sessionFile,
workspaceDir: followupRun.run.workspaceDir,
agentDir: followupRun.run.agentDir,
@@ -549,6 +551,13 @@ export async function runReplyAgent(params: {
const shouldDropFinalPayloads =
blockStreamingEnabled && didStreamBlockReply;
const messagingToolSentTexts = runResult.messagingToolSentTexts ?? [];
const messagingToolSentTargets = runResult.messagingToolSentTargets ?? [];
const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({
messageProvider: followupRun.run.messageProvider,
messagingToolSentTargets,
originatingTo: sessionCtx.OriginatingTo ?? sessionCtx.To,
accountId: sessionCtx.AccountId,
});
const dedupedPayloads = filterMessagingToolDuplicates({
payloads: replyTaggedPayloads,
sentTexts: messagingToolSentTexts,
@@ -560,10 +569,11 @@ export async function runReplyAgent(params: {
(payload) => !streamedPayloadKeys.has(buildPayloadKey(payload)),
)
: dedupedPayloads;
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;
if (filteredPayloads.length === 0) return finalizeWithFollowup(undefined);
if (replyPayloads.length === 0) return finalizeWithFollowup(undefined);
const shouldSignalTyping = filteredPayloads.some((payload) => {
const shouldSignalTyping = replyPayloads.some((payload) => {
const trimmed = payload.text?.trim();
if (trimmed && trimmed !== SILENT_REPLY_TOKEN) return true;
if (payload.mediaUrl) return true;
@@ -628,7 +638,7 @@ export async function runReplyAgent(params: {
}
// If verbose is enabled and this is a new session, prepend a session hint.
let finalPayloads = filteredPayloads;
let finalPayloads = replyPayloads;
if (autoCompactionCompleted) {
const count = await incrementCompactionCount({
sessionEntry,

View File

@@ -27,15 +27,17 @@ vi.mock("../../agents/pi-embedded.js", () => ({
import { createFollowupRunner } from "./followup-runner.js";
const baseQueuedRun = (): FollowupRun =>
const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun =>
({
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
originatingTo: "channel:C1",
run: {
sessionId: "session",
sessionKey: "main",
messageProvider: "whatsapp",
messageProvider,
agentAccountId: "primary",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
@@ -95,4 +97,27 @@ describe("createFollowupRunner messaging tool dedupe", () => {
expect(onBlockReply).toHaveBeenCalledTimes(1);
});
it("suppresses replies when a messaging tool sent via the same provider + target", async () => {
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [
{ tool: "slack", provider: "slack", to: "channel:C1" },
],
meta: {},
});
const runner = createFollowupRunner({
opts: { onBlockReply },
typing: createMockTypingController(),
typingMode: "instant",
defaultModel: "anthropic/claude-opus-4-5",
});
await runner(baseQueuedRun("slack"));
expect(onBlockReply).not.toHaveBeenCalled();
});
});

View File

@@ -17,6 +17,7 @@ import type { FollowupRun } from "./queue.js";
import {
applyReplyThreading,
filterMessagingToolDuplicates,
shouldSuppressMessagingToolReplies,
} from "./reply-payloads.js";
import {
createReplyToModeFilter,
@@ -136,6 +137,7 @@ export function createFollowupRunner(params: {
sessionId: queued.run.sessionId,
sessionKey: queued.run.sessionKey,
messageProvider: queued.run.messageProvider,
agentAccountId: queued.run.agentAccountId,
sessionFile: queued.run.sessionFile,
workspaceDir: queued.run.workspaceDir,
config: queued.run.config,
@@ -205,8 +207,15 @@ export function createFollowupRunner(params: {
payloads: replyTaggedPayloads,
sentTexts: runResult.messagingToolSentTexts ?? [],
});
const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({
messageProvider: queued.run.messageProvider,
messagingToolSentTargets: runResult.messagingToolSentTargets,
originatingTo: queued.originatingTo,
accountId: queued.run.agentAccountId,
});
const finalPayloads = suppressMessagingToolReplies ? [] : dedupedPayloads;
if (dedupedPayloads.length === 0) return;
if (finalPayloads.length === 0) return;
if (autoCompactionCompleted) {
const count = await incrementCompactionCount({
@@ -217,7 +226,7 @@ export function createFollowupRunner(params: {
});
if (queued.run.verboseLevel === "on") {
const suffix = typeof count === "number" ? ` (count ${count})` : "";
replyTaggedPayloads.unshift({
finalPayloads.unshift({
text: `🧹 Auto-compaction complete${suffix}.`,
});
}
@@ -271,7 +280,7 @@ export function createFollowupRunner(params: {
}
}
await sendFollowupPayloads(dedupedPayloads, queued);
await sendFollowupPayloads(finalPayloads, queued);
} finally {
typing.markRunComplete();
}

View File

@@ -4,6 +4,7 @@ import type {
GroupKeyResolution,
SessionEntry,
} from "../../config/sessions.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
import { normalizeGroupActivation } from "../group-activation.js";
import type { TemplateContext } from "../templating.js";
@@ -148,7 +149,8 @@ export function resolveGroupRequireMention(params: {
return true;
}
if (provider === "slack") {
const channels = cfg.slack?.channels ?? {};
const account = resolveSlackAccount({ cfg, accountId: ctx.AccountId });
const channels = account.channels ?? {};
const keys = Object.keys(channels);
if (keys.length === 0) return true;
const channelId = groupId?.trim();

View File

@@ -50,6 +50,7 @@ export type FollowupRun = {
sessionId: string;
sessionKey?: string;
messageProvider?: string;
agentAccountId?: string;
sessionFile: string;
workspaceDir: string;
config: ClawdbotConfig;

View File

@@ -1,4 +1,5 @@
import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js";
import type { ReplyPayload } from "../types.js";
import { extractReplyToTag } from "./reply-tags.js";
@@ -50,3 +51,126 @@ export function filterMessagingToolDuplicates(params: {
(payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts),
);
}
function normalizeSlackTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
const mentionMatch = trimmed.match(/^<@([A-Z0-9]+)>$/i);
if (mentionMatch) return `user:${mentionMatch[1]}`.toLowerCase();
if (trimmed.startsWith("user:")) {
const id = trimmed.slice(5).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("channel:")) {
const id = trimmed.slice(8).trim();
return id ? `channel:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("slack:")) {
const id = trimmed.slice(6).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("@")) {
const id = trimmed.slice(1).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("#")) {
const id = trimmed.slice(1).trim();
return id ? `channel:${id}`.toLowerCase() : undefined;
}
return `channel:${trimmed}`.toLowerCase();
}
function normalizeDiscordTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
const mentionMatch = trimmed.match(/^<@!?(\d+)>$/);
if (mentionMatch) return `user:${mentionMatch[1]}`.toLowerCase();
if (trimmed.startsWith("user:")) {
const id = trimmed.slice(5).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("channel:")) {
const id = trimmed.slice(8).trim();
return id ? `channel:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("discord:")) {
const id = trimmed.slice(8).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
if (trimmed.startsWith("@")) {
const id = trimmed.slice(1).trim();
return id ? `user:${id}`.toLowerCase() : undefined;
}
return `channel:${trimmed}`.toLowerCase();
}
function normalizeTelegramTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
let normalized = trimmed;
if (normalized.startsWith("telegram:")) {
normalized = normalized.slice("telegram:".length).trim();
} else if (normalized.startsWith("tg:")) {
normalized = normalized.slice("tg:".length).trim();
} else if (normalized.startsWith("group:")) {
normalized = normalized.slice("group:".length).trim();
}
if (!normalized) return undefined;
const tmeMatch =
/^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ??
/^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized);
if (tmeMatch?.[1]) normalized = `@${tmeMatch[1]}`;
if (!normalized) return undefined;
return `telegram:${normalized}`.toLowerCase();
}
function normalizeTargetForProvider(
provider: string,
raw?: string,
): string | undefined {
if (!raw) return undefined;
switch (provider) {
case "slack":
return normalizeSlackTarget(raw);
case "discord":
return normalizeDiscordTarget(raw);
case "telegram":
return normalizeTelegramTarget(raw);
default:
return raw.trim().toLowerCase() || undefined;
}
}
function normalizeAccountId(value?: string): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed.toLowerCase() : undefined;
}
export function shouldSuppressMessagingToolReplies(params: {
messageProvider?: string;
messagingToolSentTargets?: MessagingToolSend[];
originatingTo?: string;
accountId?: string;
}): boolean {
const provider = params.messageProvider?.trim().toLowerCase();
if (!provider) return false;
const originTarget = normalizeTargetForProvider(
provider,
params.originatingTo,
);
if (!originTarget) return false;
const originAccount = normalizeAccountId(params.accountId);
const sentTargets = params.messagingToolSentTargets ?? [];
if (sentTargets.length === 0) return false;
return sentTargets.some((target) => {
if (!target?.provider) return false;
if (target.provider.trim().toLowerCase() !== provider) return false;
const targetKey = normalizeTargetForProvider(provider, target.to);
if (!targetKey) return false;
const targetAccount = normalizeAccountId(target.accountId);
if (originAccount && targetAccount && originAccount !== targetAccount) {
return false;
}
return targetKey === originTarget;
});
}

View File

@@ -65,7 +65,7 @@ export function createCliProgress(options: ProgressOptions): ProgressReporter {
? createOscProgressController({
env: process.env,
isTty: stream.isTTY,
write: (chunk) => stream.write(chunk),
write: (chunk: string) => stream.write(chunk),
})
: null;

View File

@@ -123,6 +123,7 @@ const FIELD_LABELS: Record<string, string> = {
"discord.retry.jitter": "Discord Retry Jitter",
"discord.maxLinesPerMessage": "Discord Max Lines Per Message",
"slack.dm.policy": "Slack DM Policy",
"slack.allowBots": "Slack Allow Bot Messages",
"discord.token": "Discord Bot Token",
"slack.botToken": "Slack Bot Token",
"slack.appToken": "Slack App Token",
@@ -141,6 +142,8 @@ const FIELD_HELP: Record<string, string> = {
'Hot reload strategy for config changes ("hybrid" recommended).',
"gateway.reload.debounceMs":
"Debounce window (ms) before applying config changes.",
"slack.allowBots":
"Allow bot-authored messages to trigger Slack replies (default: false).",
"auth.profiles": "Named auth profiles (provider + mode + optional email).",
"auth.order":
"Ordered auth profile IDs per provider (used for automatic failover).",

View File

@@ -455,6 +455,8 @@ export type SlackChannelConfig = {
allow?: boolean;
/** Require mentioning the bot to trigger replies. */
requireMention?: boolean;
/** Allow bot-authored messages to trigger replies (default: false). */
allowBots?: boolean;
/** Allowlist of users that can invoke the bot in this channel. */
users?: Array<string | number>;
/** Optional skill filter for this channel. */
@@ -494,6 +496,8 @@ export type SlackAccountConfig = {
enabled?: boolean;
botToken?: string;
appToken?: string;
/** Allow bot-authored messages to trigger replies (default: false). */
allowBots?: boolean;
/**
* Controls how channel messages are handled:
* - "open" (default): channels bypass allowlists; mention-gating applies

View File

@@ -309,6 +309,7 @@ const SlackChannelSchema = z.object({
enabled: z.boolean().optional(),
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
allowBots: z.boolean().optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
skills: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
@@ -319,11 +320,13 @@ const SlackAccountSchema = z.object({
enabled: z.boolean().optional(),
botToken: z.string().optional(),
appToken: z.string().optional(),
allowBots: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
replyToMode: ReplyToModeSchema.optional(),
actions: z
.object({
reactions: z.boolean().optional(),
@@ -1188,7 +1191,6 @@ export const ClawdbotSchema = z.object({
});
})
.optional(),
telegram: TelegramConfigSchema.optional(),
discord: DiscordConfigSchema.optional(),
slack: SlackConfigSchema.optional(),

View File

@@ -17,6 +17,16 @@ export type ResolvedSlackAccount = {
botTokenSource: SlackTokenSource;
appTokenSource: SlackTokenSource;
config: SlackAccountConfig;
groupPolicy?: SlackAccountConfig["groupPolicy"];
textChunkLimit?: SlackAccountConfig["textChunkLimit"];
mediaMaxMb?: SlackAccountConfig["mediaMaxMb"];
reactionNotifications?: SlackAccountConfig["reactionNotifications"];
reactionAllowlist?: SlackAccountConfig["reactionAllowlist"];
replyToMode?: SlackAccountConfig["replyToMode"];
actions?: SlackAccountConfig["actions"];
slashCommand?: SlackAccountConfig["slashCommand"];
dm?: SlackAccountConfig["dm"];
channels?: SlackAccountConfig["channels"];
};
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
@@ -66,31 +76,26 @@ export function resolveSlackAccount(params: {
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const botToken = resolveSlackBotToken(
merged.botToken ??
(allowEnv ? process.env.SLACK_BOT_TOKEN : undefined) ??
(allowEnv ? params.cfg.slack?.botToken : undefined),
);
const appToken = resolveSlackAppToken(
merged.appToken ??
(allowEnv ? process.env.SLACK_APP_TOKEN : undefined) ??
(allowEnv ? params.cfg.slack?.appToken : undefined),
);
const botTokenSource: SlackTokenSource = merged.botToken
const envBot = allowEnv
? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN)
: undefined;
const envApp = allowEnv
? resolveSlackAppToken(process.env.SLACK_APP_TOKEN)
: undefined;
const configBot = resolveSlackBotToken(merged.botToken);
const configApp = resolveSlackAppToken(merged.appToken);
const botToken = configBot ?? envBot;
const appToken = configApp ?? envApp;
const botTokenSource: SlackTokenSource = configBot
? "config"
: allowEnv && process.env.SLACK_BOT_TOKEN
: envBot
? "env"
: allowEnv && params.cfg.slack?.botToken
? "config"
: "none";
const appTokenSource: SlackTokenSource = merged.appToken
: "none";
const appTokenSource: SlackTokenSource = configApp
? "config"
: allowEnv && process.env.SLACK_APP_TOKEN
: envApp
? "env"
: allowEnv && params.cfg.slack?.appToken
? "config"
: "none";
: "none";
return {
accountId,
@@ -101,6 +106,16 @@ export function resolveSlackAccount(params: {
botTokenSource,
appTokenSource,
config: merged,
groupPolicy: merged.groupPolicy,
textChunkLimit: merged.textChunkLimit,
mediaMaxMb: merged.mediaMaxMb,
reactionNotifications: merged.reactionNotifications,
reactionAllowlist: merged.reactionAllowlist,
replyToMode: merged.replyToMode,
actions: merged.actions,
slashCommand: merged.slashCommand,
dm: merged.dm,
channels: merged.channels,
};
}

View File

@@ -1,10 +1,13 @@
import { WebClient } from "@slack/web-api";
import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { resolveSlackAccount } from "./accounts.js";
import { sendMessageSlack } from "./send.js";
import { resolveSlackBotToken } from "./token.js";
export type SlackActionClientOpts = {
accountId?: string;
token?: string;
client?: WebClient;
};
@@ -28,12 +31,16 @@ export type SlackPin = {
file?: { id?: string; name?: string };
};
function resolveToken(explicit?: string) {
const cfgToken = loadConfig().slack?.botToken;
const token = resolveSlackBotToken(
explicit ?? process.env.SLACK_BOT_TOKEN ?? cfgToken ?? undefined,
);
function resolveToken(explicit?: string, accountId?: string) {
const cfg = loadConfig();
const account = resolveSlackAccount({ cfg, accountId });
const token = resolveSlackBotToken(explicit ?? account.botToken ?? undefined);
if (!token) {
logVerbose(
`slack actions: missing bot token for account=${account.accountId} explicit=${Boolean(
explicit,
)} source=${account.botTokenSource ?? "unknown"}`,
);
throw new Error(
"SLACK_BOT_TOKEN or slack.botToken is required for Slack actions",
);
@@ -50,7 +57,7 @@ function normalizeEmoji(raw: string) {
}
async function getClient(opts: SlackActionClientOpts = {}) {
const token = resolveToken(opts.token);
const token = resolveToken(opts.token, opts.accountId);
return opts.client ?? new WebClient(token);
}
@@ -141,6 +148,7 @@ export async function sendSlackMessage(
opts: SlackActionClientOpts & { mediaUrl?: string } = {},
) {
return await sendMessageSlack(to, content, {
accountId: opts.accountId,
token: opts.token,
mediaUrl: opts.mediaUrl,
client: opts.client,

View File

@@ -1,3 +1,9 @@
export {
listEnabledSlackAccounts,
listSlackAccountIds,
resolveDefaultSlackAccountId,
resolveSlackAccount,
} from "./accounts.js";
export {
deleteSlackMessage,
editSlackMessage,

View File

@@ -80,6 +80,7 @@ type SlackMessageEvent = {
user?: string;
bot_id?: string;
subtype?: string;
username?: string;
text?: string;
ts?: string;
thread_ts?: string;
@@ -93,6 +94,7 @@ type SlackAppMentionEvent = {
type: "app_mention";
user?: string;
bot_id?: string;
username?: string;
text?: string;
ts?: string;
thread_ts?: string;
@@ -170,6 +172,7 @@ type SlackThreadBroadcastEvent = {
type SlackChannelConfigResolved = {
allowed: boolean;
requireMention: boolean;
allowBots?: boolean;
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;
@@ -294,6 +297,7 @@ function resolveSlackChannelConfig(params: {
enabled?: boolean;
allow?: boolean;
requireMention?: boolean;
allowBots?: boolean;
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;
@@ -317,6 +321,7 @@ function resolveSlackChannelConfig(params: {
enabled?: boolean;
allow?: boolean;
requireMention?: boolean;
allowBots?: boolean;
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;
@@ -349,13 +354,14 @@ function resolveSlackChannelConfig(params: {
const requireMention =
firstDefined(resolved.requireMention, fallback?.requireMention, true) ??
true;
const allowBots = firstDefined(resolved.allowBots, fallback?.allowBots);
const users = firstDefined(resolved.users, fallback?.users);
const skills = firstDefined(resolved.skills, fallback?.skills);
const systemPrompt = firstDefined(
resolved.systemPrompt,
fallback?.systemPrompt,
);
return { allowed, requireMention, users, skills, systemPrompt };
return { allowed, requireMention, allowBots, users, skills, systemPrompt };
}
async function resolveSlackMedia(params: {
@@ -706,15 +712,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
opts: { source: "message" | "app_mention"; wasMentioned?: boolean },
) => {
if (opts.source === "message" && message.type !== "message") return;
if (message.bot_id) return;
if (
opts.source === "message" &&
message.subtype &&
message.subtype !== "file_share"
message.subtype !== "file_share" &&
message.subtype !== "bot_message"
) {
return;
}
if (!message.user) return;
if (markMessageSeen(message.channel, message.ts)) return;
let channelInfo: {
@@ -735,6 +740,40 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const isRoom =
resolvedChannelType === "channel" || resolvedChannelType === "group";
const channelConfig = isRoom
? resolveSlackChannelConfig({
channelId: message.channel,
channelName,
channels: channelsConfig,
})
: null;
const allowBots =
channelConfig?.allowBots ??
account.config?.allowBots ??
cfg.slack?.allowBots ??
false;
const isBotMessage = Boolean(message.bot_id);
if (isBotMessage) {
if (message.user && botUserId && message.user === botUserId) return;
if (!allowBots) {
logVerbose(
`slack: drop bot message ${message.bot_id ?? "unknown"} (allowBots=false)`,
);
return;
}
}
if (isDirectMessage && !message.user) {
logVerbose("slack: drop dm message (missing user id)");
return;
}
const senderId =
message.user ?? (isBotMessage ? message.bot_id : undefined);
if (!senderId) {
logVerbose("slack: drop message (missing sender id)");
return;
}
if (
!isChannelAllowed({
channelId: message.channel,
@@ -756,6 +795,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const effectiveAllowFromLower = normalizeAllowListLower(effectiveAllowFrom);
if (isDirectMessage) {
const directUserId = message.user;
if (!directUserId) {
logVerbose("slack: drop dm message (missing user id)");
return;
}
if (!dmEnabled || dmPolicy === "disabled") {
logVerbose("slack: drop dm (dms disabled)");
return;
@@ -763,20 +807,20 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
if (dmPolicy !== "open") {
const permitted = allowListMatches({
allowList: effectiveAllowFromLower,
id: message.user,
id: directUserId,
});
if (!permitted) {
if (dmPolicy === "pairing") {
const sender = await resolveUserName(message.user);
const sender = await resolveUserName(directUserId);
const senderName = sender?.name ?? undefined;
const { code, created } = await upsertProviderPairingRequest({
provider: "slack",
id: message.user,
id: directUserId,
meta: { name: senderName },
});
if (created) {
logVerbose(
`slack pairing request sender=${message.user} name=${senderName ?? "unknown"}`,
`slack pairing request sender=${directUserId} name=${senderName ?? "unknown"}`,
);
try {
await sendMessageSlack(
@@ -811,31 +855,28 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
}
}
const channelConfig = isRoom
? resolveSlackChannelConfig({
channelId: message.channel,
channelName,
channels: channelsConfig,
})
: null;
const wasMentioned =
opts.wasMentioned ??
(!isDirectMessage &&
(Boolean(botUserId && message.text?.includes(`<@${botUserId}>`)) ||
matchesMentionPatterns(message.text ?? "", mentionRegexes)));
const sender = await resolveUserName(message.user);
const senderName = sender?.name ?? message.user;
const sender = message.user ? await resolveUserName(message.user) : null;
const senderName =
sender?.name ??
message.username?.trim() ??
message.user ??
message.bot_id ??
"unknown";
const channelUserAuthorized = isRoom
? resolveSlackUserAllowed({
allowList: channelConfig?.users,
userId: message.user,
userId: senderId,
userName: senderName,
})
: true;
if (isRoom && !channelUserAuthorized) {
logVerbose(
`Blocked unauthorized slack sender ${message.user} (not in channel users)`,
`Blocked unauthorized slack sender ${senderId} (not in channel users)`,
);
return;
}
@@ -844,7 +885,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
(allowList.length === 0 ||
allowListMatches({
allowList,
id: message.user,
id: senderId,
name: senderName,
})) &&
channelUserAuthorized;
@@ -1010,7 +1051,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
GroupSubject: isRoomish ? roomLabel : undefined,
GroupSystemPrompt: isRoomish ? groupSystemPrompt : undefined,
SenderName: senderName,
SenderId: message.user,
SenderId: senderId,
Provider: "slack" as const,
Surface: "slack" as const,
MessageSid: message.ts,

View File

@@ -5,7 +5,9 @@ import {
resolveTextChunkLimit,
} from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { loadWebMedia } from "../web/media.js";
import type { SlackTokenSource } from "./accounts.js";
import { resolveSlackAccount } from "./accounts.js";
import { resolveSlackBotToken } from "./token.js";
@@ -38,11 +40,17 @@ function resolveToken(params: {
explicit?: string;
accountId: string;
fallbackToken?: string;
fallbackSource?: SlackTokenSource;
}) {
const explicit = resolveSlackBotToken(params.explicit);
if (explicit) return explicit;
const fallback = resolveSlackBotToken(params.fallbackToken);
if (!fallback) {
logVerbose(
`slack send: missing bot token for account=${params.accountId} explicit=${Boolean(
params.explicit,
)} source=${params.fallbackSource ?? "unknown"}`,
);
throw new Error(
`Slack bot token missing for account "${params.accountId}" (set slack.accounts.${params.accountId}.botToken or SLACK_BOT_TOKEN for default).`,
);
@@ -154,6 +162,7 @@ export async function sendMessageSlack(
explicit: opts.token,
accountId: account.accountId,
fallbackToken: account.botToken,
fallbackSource: account.botTokenSource,
});
const client = opts.client ?? new WebClient(token);
const recipient = parseRecipient(to);

View File

@@ -140,17 +140,12 @@ describe("createTelegramBot", () => {
globalThis.fetch = fetchSpy;
try {
createTelegramBot({ token: "tok" });
const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun);
if (isBun) {
expect(botCtorSpy).toHaveBeenCalledWith(
"tok",
expect.objectContaining({
client: expect.objectContaining({ fetch: fetchSpy }),
}),
);
} else {
expect(botCtorSpy).toHaveBeenCalledWith("tok", undefined);
}
expect(botCtorSpy).toHaveBeenCalledWith(
"tok",
expect.objectContaining({
client: expect.objectContaining({ fetch: fetchSpy }),
}),
);
} finally {
globalThis.fetch = originalFetch;
}

19
src/types/osc-progress.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
declare module "osc-progress" {
export type OscProgressController = {
setIndeterminate: (label: string) => void;
setPercent: (label: string, percent: number) => void;
clear: () => void;
done?: () => void;
};
export function createOscProgressController(params: {
env: NodeJS.ProcessEnv;
isTty: boolean;
write: (chunk: string) => void;
}): OscProgressController;
export function supportsOscProgress(
env: NodeJS.ProcessEnv,
isTty: boolean,
): boolean;
}