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 `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. - 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. - 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: 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: 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. - 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/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/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/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/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=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=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/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/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/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/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=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/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> </p>

View File

@@ -733,15 +733,17 @@ Slack runs in Socket Mode and requires both a bot token and app token:
groupChannels: ["G123"] groupChannels: ["G123"]
}, },
channels: { channels: {
C123: { allow: true, requireMention: true }, C123: { allow: true, requireMention: true, allowBots: false },
"#general": { "#general": {
allow: true, allow: true,
requireMention: true, requireMention: true,
allowBots: false,
users: ["U123"], users: ["U123"],
skills: ["docs"], skills: ["docs"],
systemPrompt: "Short answers only." systemPrompt: "Short answers only."
} }
}, },
allowBots: false,
reactionNotifications: "own", // off | own | all | allowlist reactionNotifications: "own", // off | own | all | allowlist
reactionAllowlist: ["U123"], reactionAllowlist: ["U123"],
replyToMode: "off", // off | first | all 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. 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: Reaction notification modes:
- `off`: no reaction events. - `off`: no reaction events.
- `own`: reactions on the bot's own messages (default). - `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>`): Channel options (`slack.channels.<id>` or `slack.channels.<name>`):
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`. - `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
- `requireMention`: mention gating for the channel. - `requireMention`: mention gating for the channel.
- `allowBots`: allow bot-authored messages in this channel (default: false).
- `users`: optional per-channel user allowlist. - `users`: optional per-channel user allowlist.
- `skills`: skill filter (omit = all skills, empty = none). - `skills`: skill filter (omit = all skills, empty = none).
- `systemPrompt`: extra system prompt for the channel (combined with topic/purpose). - `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 ## Notes
- Mention gating is controlled via `slack.channels` (set `requireMention` to `true`); `routing.groupChat.mentionPatterns` also count as mentions. - 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`). - 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). - 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. - 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; browserControlUrl?: string;
agentSessionKey?: string; agentSessionKey?: string;
agentProvider?: string; agentProvider?: string;
agentAccountId?: string;
agentDir?: string; agentDir?: string;
sandboxed?: boolean; sandboxed?: boolean;
config?: ClawdbotConfig; config?: ClawdbotConfig;
@@ -34,7 +35,10 @@ export function createClawdbotTools(options?: {
createNodesTool(), createNodesTool(),
createCronTool(), createCronTool(),
createDiscordTool(), createDiscordTool(),
createSlackTool(), createSlackTool({
agentAccountId: options?.agentAccountId,
config: options?.config,
}),
createTelegramTool(), createTelegramTool(),
createWhatsAppTool(), createWhatsAppTool(),
createGatewayTool({ agentSessionKey: options?.agentSessionKey }), createGatewayTool({ agentSessionKey: options?.agentSessionKey }),

View File

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

View File

@@ -26,6 +26,13 @@ const log = createSubsystemLogger("agent/embedded");
export type { BlockReplyChunking } from "./pi-embedded-block-chunker.js"; export type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
type MessagingToolSend = {
tool: string;
provider: string;
accountId?: string;
to?: string;
};
function truncateToolText(text: string): string { function truncateToolText(text: string): string {
if (text.length <= TOOL_RESULT_MAX_CHARS) return text; if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`; return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
@@ -86,6 +93,127 @@ function stripUnpairedThinkingTags(text: string): string {
return text; 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: { export function subscribeEmbeddedPiSession(params: {
session: AgentSession; session: AgentSession;
runId: string; runId: string;
@@ -151,7 +279,9 @@ export function subscribeEmbeddedPiSession(params: {
"sessions_send", "sessions_send",
]); ]);
const messagingToolSentTexts: string[] = []; const messagingToolSentTexts: string[] = [];
const messagingToolSentTargets: MessagingToolSend[] = [];
const pendingMessagingTexts = new Map<string, string>(); const pendingMessagingTexts = new Map<string, string>();
const pendingMessagingTargets = new Map<string, MessagingToolSend>();
const ensureCompactionPromise = () => { const ensureCompactionPromise = () => {
if (!compactionRetryPromise) { if (!compactionRetryPromise) {
@@ -315,7 +445,9 @@ export function subscribeEmbeddedPiSession(params: {
toolMetaById.clear(); toolMetaById.clear();
toolSummaryById.clear(); toolSummaryById.clear();
messagingToolSentTexts.length = 0; messagingToolSentTexts.length = 0;
messagingToolSentTargets.length = 0;
pendingMessagingTexts.clear(); pendingMessagingTexts.clear();
pendingMessagingTargets.clear();
deltaBuffer = ""; deltaBuffer = "";
blockBuffer = ""; blockBuffer = "";
blockChunker?.reset(); blockChunker?.reset();
@@ -398,6 +530,10 @@ export function subscribeEmbeddedPiSession(params: {
action === "threadReply" || action === "threadReply" ||
toolName === "sessions_send" 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" // Field names vary by tool: Discord/Slack use "content", sessions_send uses "message"
const text = const text =
(argsRecord.content as string) ?? (argsRecord.message as string); (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 // Commit messaging tool text on success, discard on error
const pendingText = pendingMessagingTexts.get(toolCallId); const pendingText = pendingMessagingTexts.get(toolCallId);
const pendingTarget = pendingMessagingTargets.get(toolCallId);
if (pendingText) { if (pendingText) {
pendingMessagingTexts.delete(toolCallId); pendingMessagingTexts.delete(toolCallId);
if (!isError) { if (!isError) {
@@ -469,6 +606,12 @@ export function subscribeEmbeddedPiSession(params: {
); );
} }
} }
if (pendingTarget) {
pendingMessagingTargets.delete(toolCallId);
if (!isError) {
messagingToolSentTargets.push(pendingTarget);
}
}
emitAgentEvent({ emitAgentEvent({
runId: params.runId, runId: params.runId,
@@ -779,6 +922,7 @@ export function subscribeEmbeddedPiSession(params: {
unsubscribe, unsubscribe,
isCompacting: () => compactionInFlight || pendingCompactionRetry > 0, isCompacting: () => compactionInFlight || pendingCompactionRetry > 0,
getMessagingToolSentTexts: () => messagingToolSentTexts.slice(), getMessagingToolSentTexts: () => messagingToolSentTexts.slice(),
getMessagingToolSentTargets: () => messagingToolSentTargets.slice(),
// Returns true if any messaging tool successfully sent a message. // Returns true if any messaging tool successfully sent a message.
// Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!") // Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!")
// which is generated AFTER the tool sends the actual answer. // 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?: { export function createClawdbotCodingTools(options?: {
bash?: BashToolDefaults & ProcessToolDefaults; bash?: BashToolDefaults & ProcessToolDefaults;
messageProvider?: string; messageProvider?: string;
agentAccountId?: string;
sandbox?: SandboxContext | null; sandbox?: SandboxContext | null;
sessionKey?: string; sessionKey?: string;
agentDir?: string; agentDir?: string;
@@ -695,6 +696,7 @@ export function createClawdbotCodingTools(options?: {
browserControlUrl: sandbox?.browser?.controlUrl, browserControlUrl: sandbox?.browser?.controlUrl,
agentSessionKey: options?.sessionKey, agentSessionKey: options?.sessionKey,
agentProvider: options?.messageProvider, agentProvider: options?.messageProvider,
agentAccountId: options?.agentAccountId,
agentDir: options?.agentDir, agentDir: options?.agentDir,
sandboxed: !!sandbox, sandboxed: !!sandbox,
config: options?.config, config: options?.config,

View File

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

View File

@@ -9,28 +9,35 @@ export const SlackToolSchema = Type.Union([
messageId: Type.String(), messageId: Type.String(),
}, },
includeRemove: true, includeRemove: true,
extras: {
accountId: Type.Optional(Type.String()),
},
}), }),
Type.Object({ Type.Object({
action: Type.Literal("reactions"), action: Type.Literal("reactions"),
channelId: Type.String(), channelId: Type.String(),
messageId: Type.String(), messageId: Type.String(),
accountId: Type.Optional(Type.String()),
}), }),
Type.Object({ Type.Object({
action: Type.Literal("sendMessage"), action: Type.Literal("sendMessage"),
to: Type.String(), to: Type.String(),
content: Type.String(), content: Type.String(),
mediaUrl: Type.Optional(Type.String()), mediaUrl: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
}), }),
Type.Object({ Type.Object({
action: Type.Literal("editMessage"), action: Type.Literal("editMessage"),
channelId: Type.String(), channelId: Type.String(),
messageId: Type.String(), messageId: Type.String(),
content: Type.String(), content: Type.String(),
accountId: Type.Optional(Type.String()),
}), }),
Type.Object({ Type.Object({
action: Type.Literal("deleteMessage"), action: Type.Literal("deleteMessage"),
channelId: Type.String(), channelId: Type.String(),
messageId: Type.String(), messageId: Type.String(),
accountId: Type.Optional(Type.String()),
}), }),
Type.Object({ Type.Object({
action: Type.Literal("readMessages"), action: Type.Literal("readMessages"),
@@ -38,26 +45,32 @@ export const SlackToolSchema = Type.Union([
limit: Type.Optional(Type.Number()), limit: Type.Optional(Type.Number()),
before: Type.Optional(Type.String()), before: Type.Optional(Type.String()),
after: Type.Optional(Type.String()), after: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
}), }),
Type.Object({ Type.Object({
action: Type.Literal("pinMessage"), action: Type.Literal("pinMessage"),
channelId: Type.String(), channelId: Type.String(),
messageId: Type.String(), messageId: Type.String(),
accountId: Type.Optional(Type.String()),
}), }),
Type.Object({ Type.Object({
action: Type.Literal("unpinMessage"), action: Type.Literal("unpinMessage"),
channelId: Type.String(), channelId: Type.String(),
messageId: Type.String(), messageId: Type.String(),
accountId: Type.Optional(Type.String()),
}), }),
Type.Object({ Type.Object({
action: Type.Literal("listPins"), action: Type.Literal("listPins"),
channelId: Type.String(), channelId: Type.String(),
accountId: Type.Optional(Type.String()),
}), }),
Type.Object({ Type.Object({
action: Type.Literal("memberInfo"), action: Type.Literal("memberInfo"),
userId: Type.String(), userId: Type.String(),
accountId: Type.Optional(Type.String()),
}), }),
Type.Object({ Type.Object({
action: Type.Literal("emojiList"), 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 { loadConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
import { handleSlackAction } from "./slack-actions.js"; import { handleSlackAction } from "./slack-actions.js";
import { SlackToolSchema } from "./slack-schema.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 { return {
label: "Slack", label: "Slack",
name: "slack", name: "slack",
@@ -11,8 +46,24 @@ export function createSlackTool(): AnyAgentTool {
parameters: SlackToolSchema, parameters: SlackToolSchema,
execute: async (_toolCallId, args) => { execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
const cfg = loadConfig(); const cfg = options?.config ?? loadConfig();
return await handleSlackAction(params, cfg); 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, sessionId: sessionIdFinal,
sessionKey, sessionKey,
messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined, messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined,
agentAccountId: sessionCtx.AccountId,
sessionFile, sessionFile,
workspaceDir, workspaceDir,
config: cfg, 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, applyReplyThreading,
filterMessagingToolDuplicates, filterMessagingToolDuplicates,
isRenderablePayload, isRenderablePayload,
shouldSuppressMessagingToolReplies,
} from "./reply-payloads.js"; } from "./reply-payloads.js";
import { import {
createReplyToModeFilter, createReplyToModeFilter,
@@ -240,6 +241,7 @@ export async function runReplyAgent(params: {
sessionKey, sessionKey,
messageProvider: messageProvider:
sessionCtx.Provider?.trim().toLowerCase() || undefined, sessionCtx.Provider?.trim().toLowerCase() || undefined,
agentAccountId: sessionCtx.AccountId,
sessionFile: followupRun.run.sessionFile, sessionFile: followupRun.run.sessionFile,
workspaceDir: followupRun.run.workspaceDir, workspaceDir: followupRun.run.workspaceDir,
agentDir: followupRun.run.agentDir, agentDir: followupRun.run.agentDir,
@@ -549,6 +551,13 @@ export async function runReplyAgent(params: {
const shouldDropFinalPayloads = const shouldDropFinalPayloads =
blockStreamingEnabled && didStreamBlockReply; blockStreamingEnabled && didStreamBlockReply;
const messagingToolSentTexts = runResult.messagingToolSentTexts ?? []; 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({ const dedupedPayloads = filterMessagingToolDuplicates({
payloads: replyTaggedPayloads, payloads: replyTaggedPayloads,
sentTexts: messagingToolSentTexts, sentTexts: messagingToolSentTexts,
@@ -560,10 +569,11 @@ export async function runReplyAgent(params: {
(payload) => !streamedPayloadKeys.has(buildPayloadKey(payload)), (payload) => !streamedPayloadKeys.has(buildPayloadKey(payload)),
) )
: dedupedPayloads; : 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(); const trimmed = payload.text?.trim();
if (trimmed && trimmed !== SILENT_REPLY_TOKEN) return true; if (trimmed && trimmed !== SILENT_REPLY_TOKEN) return true;
if (payload.mediaUrl) 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. // If verbose is enabled and this is a new session, prepend a session hint.
let finalPayloads = filteredPayloads; let finalPayloads = replyPayloads;
if (autoCompactionCompleted) { if (autoCompactionCompleted) {
const count = await incrementCompactionCount({ const count = await incrementCompactionCount({
sessionEntry, sessionEntry,

View File

@@ -27,15 +27,17 @@ vi.mock("../../agents/pi-embedded.js", () => ({
import { createFollowupRunner } from "./followup-runner.js"; import { createFollowupRunner } from "./followup-runner.js";
const baseQueuedRun = (): FollowupRun => const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun =>
({ ({
prompt: "hello", prompt: "hello",
summaryLine: "hello", summaryLine: "hello",
enqueuedAt: Date.now(), enqueuedAt: Date.now(),
originatingTo: "channel:C1",
run: { run: {
sessionId: "session", sessionId: "session",
sessionKey: "main", sessionKey: "main",
messageProvider: "whatsapp", messageProvider,
agentAccountId: "primary",
sessionFile: "/tmp/session.jsonl", sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp", workspaceDir: "/tmp",
config: {}, config: {},
@@ -95,4 +97,27 @@ describe("createFollowupRunner messaging tool dedupe", () => {
expect(onBlockReply).toHaveBeenCalledTimes(1); 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 { import {
applyReplyThreading, applyReplyThreading,
filterMessagingToolDuplicates, filterMessagingToolDuplicates,
shouldSuppressMessagingToolReplies,
} from "./reply-payloads.js"; } from "./reply-payloads.js";
import { import {
createReplyToModeFilter, createReplyToModeFilter,
@@ -136,6 +137,7 @@ export function createFollowupRunner(params: {
sessionId: queued.run.sessionId, sessionId: queued.run.sessionId,
sessionKey: queued.run.sessionKey, sessionKey: queued.run.sessionKey,
messageProvider: queued.run.messageProvider, messageProvider: queued.run.messageProvider,
agentAccountId: queued.run.agentAccountId,
sessionFile: queued.run.sessionFile, sessionFile: queued.run.sessionFile,
workspaceDir: queued.run.workspaceDir, workspaceDir: queued.run.workspaceDir,
config: queued.run.config, config: queued.run.config,
@@ -205,8 +207,15 @@ export function createFollowupRunner(params: {
payloads: replyTaggedPayloads, payloads: replyTaggedPayloads,
sentTexts: runResult.messagingToolSentTexts ?? [], 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) { if (autoCompactionCompleted) {
const count = await incrementCompactionCount({ const count = await incrementCompactionCount({
@@ -217,7 +226,7 @@ export function createFollowupRunner(params: {
}); });
if (queued.run.verboseLevel === "on") { if (queued.run.verboseLevel === "on") {
const suffix = typeof count === "number" ? ` (count ${count})` : ""; const suffix = typeof count === "number" ? ` (count ${count})` : "";
replyTaggedPayloads.unshift({ finalPayloads.unshift({
text: `🧹 Auto-compaction complete${suffix}.`, text: `🧹 Auto-compaction complete${suffix}.`,
}); });
} }
@@ -271,7 +280,7 @@ export function createFollowupRunner(params: {
} }
} }
await sendFollowupPayloads(dedupedPayloads, queued); await sendFollowupPayloads(finalPayloads, queued);
} finally { } finally {
typing.markRunComplete(); typing.markRunComplete();
} }

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js"; import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js";
import type { ReplyPayload } from "../types.js"; import type { ReplyPayload } from "../types.js";
import { extractReplyToTag } from "./reply-tags.js"; import { extractReplyToTag } from "./reply-tags.js";
@@ -50,3 +51,126 @@ export function filterMessagingToolDuplicates(params: {
(payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts), (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({ ? createOscProgressController({
env: process.env, env: process.env,
isTty: stream.isTTY, isTty: stream.isTTY,
write: (chunk) => stream.write(chunk), write: (chunk: string) => stream.write(chunk),
}) })
: null; : null;

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,16 @@ export type ResolvedSlackAccount = {
botTokenSource: SlackTokenSource; botTokenSource: SlackTokenSource;
appTokenSource: SlackTokenSource; appTokenSource: SlackTokenSource;
config: SlackAccountConfig; 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[] { function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
@@ -66,31 +76,26 @@ export function resolveSlackAccount(params: {
const accountEnabled = merged.enabled !== false; const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled; const enabled = baseEnabled && accountEnabled;
const allowEnv = accountId === DEFAULT_ACCOUNT_ID; const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const envBot = allowEnv
const botToken = resolveSlackBotToken( ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN)
merged.botToken ?? : undefined;
(allowEnv ? process.env.SLACK_BOT_TOKEN : undefined) ?? const envApp = allowEnv
(allowEnv ? params.cfg.slack?.botToken : undefined), ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN)
); : undefined;
const appToken = resolveSlackAppToken( const configBot = resolveSlackBotToken(merged.botToken);
merged.appToken ?? const configApp = resolveSlackAppToken(merged.appToken);
(allowEnv ? process.env.SLACK_APP_TOKEN : undefined) ?? const botToken = configBot ?? envBot;
(allowEnv ? params.cfg.slack?.appToken : undefined), const appToken = configApp ?? envApp;
); const botTokenSource: SlackTokenSource = configBot
const botTokenSource: SlackTokenSource = merged.botToken
? "config" ? "config"
: allowEnv && process.env.SLACK_BOT_TOKEN : envBot
? "env" ? "env"
: allowEnv && params.cfg.slack?.botToken : "none";
? "config" const appTokenSource: SlackTokenSource = configApp
: "none";
const appTokenSource: SlackTokenSource = merged.appToken
? "config" ? "config"
: allowEnv && process.env.SLACK_APP_TOKEN : envApp
? "env" ? "env"
: allowEnv && params.cfg.slack?.appToken : "none";
? "config"
: "none";
return { return {
accountId, accountId,
@@ -101,6 +106,16 @@ export function resolveSlackAccount(params: {
botTokenSource, botTokenSource,
appTokenSource, appTokenSource,
config: merged, 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 { WebClient } from "@slack/web-api";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { resolveSlackAccount } from "./accounts.js";
import { sendMessageSlack } from "./send.js"; import { sendMessageSlack } from "./send.js";
import { resolveSlackBotToken } from "./token.js"; import { resolveSlackBotToken } from "./token.js";
export type SlackActionClientOpts = { export type SlackActionClientOpts = {
accountId?: string;
token?: string; token?: string;
client?: WebClient; client?: WebClient;
}; };
@@ -28,12 +31,16 @@ export type SlackPin = {
file?: { id?: string; name?: string }; file?: { id?: string; name?: string };
}; };
function resolveToken(explicit?: string) { function resolveToken(explicit?: string, accountId?: string) {
const cfgToken = loadConfig().slack?.botToken; const cfg = loadConfig();
const token = resolveSlackBotToken( const account = resolveSlackAccount({ cfg, accountId });
explicit ?? process.env.SLACK_BOT_TOKEN ?? cfgToken ?? undefined, const token = resolveSlackBotToken(explicit ?? account.botToken ?? undefined);
);
if (!token) { if (!token) {
logVerbose(
`slack actions: missing bot token for account=${account.accountId} explicit=${Boolean(
explicit,
)} source=${account.botTokenSource ?? "unknown"}`,
);
throw new Error( throw new Error(
"SLACK_BOT_TOKEN or slack.botToken is required for Slack actions", "SLACK_BOT_TOKEN or slack.botToken is required for Slack actions",
); );
@@ -50,7 +57,7 @@ function normalizeEmoji(raw: string) {
} }
async function getClient(opts: SlackActionClientOpts = {}) { async function getClient(opts: SlackActionClientOpts = {}) {
const token = resolveToken(opts.token); const token = resolveToken(opts.token, opts.accountId);
return opts.client ?? new WebClient(token); return opts.client ?? new WebClient(token);
} }
@@ -141,6 +148,7 @@ export async function sendSlackMessage(
opts: SlackActionClientOpts & { mediaUrl?: string } = {}, opts: SlackActionClientOpts & { mediaUrl?: string } = {},
) { ) {
return await sendMessageSlack(to, content, { return await sendMessageSlack(to, content, {
accountId: opts.accountId,
token: opts.token, token: opts.token,
mediaUrl: opts.mediaUrl, mediaUrl: opts.mediaUrl,
client: opts.client, client: opts.client,

View File

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

View File

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

View File

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

View File

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