@@ -205,30 +205,38 @@ impl ChatAdapter for SlackAdapter {
205205
206206 async fn add_reaction ( & self , msg : & MessageRef , emoji : & str ) -> Result < ( ) > {
207207 let name = unicode_to_slack_emoji ( emoji) ;
208- self . api_post (
208+ match self . api_post (
209209 "reactions.add" ,
210210 serde_json:: json!( {
211211 "channel" : msg. channel. channel_id,
212212 "timestamp" : msg. message_id,
213213 "name" : name,
214214 } ) ,
215215 )
216- . await ?;
217- Ok ( ( ) )
216+ . await
217+ {
218+ Ok ( _) => Ok ( ( ) ) ,
219+ Err ( e) if e. to_string ( ) . contains ( "already_reacted" ) => Ok ( ( ) ) ,
220+ Err ( e) => Err ( e) ,
221+ }
218222 }
219223
220224 async fn remove_reaction ( & self , msg : & MessageRef , emoji : & str ) -> Result < ( ) > {
221225 let name = unicode_to_slack_emoji ( emoji) ;
222- self . api_post (
226+ match self . api_post (
223227 "reactions.remove" ,
224228 serde_json:: json!( {
225229 "channel" : msg. channel. channel_id,
226230 "timestamp" : msg. message_id,
227231 "name" : name,
228232 } ) ,
229233 )
230- . await ?;
231- Ok ( ( ) )
234+ . await
235+ {
236+ Ok ( _) => Ok ( ( ) ) ,
237+ Err ( e) if e. to_string ( ) . contains ( "no_reaction" ) => Ok ( ( ) ) ,
238+ Err ( e) => Err ( e) ,
239+ }
232240 }
233241}
234242
@@ -478,6 +486,13 @@ async fn handle_message(
478486 let prompt = if is_mention {
479487 strip_slack_mention ( & text)
480488 } else {
489+ // Thread follow-up: check for bot loop before processing
490+ if let Some ( ref tts) = thread_ts {
491+ if is_bot_loop ( adapter, & channel_id, tts) . await {
492+ tracing:: warn!( channel_id, thread_ts = tts, "bot loop detected, ignoring" ) ;
493+ return ;
494+ }
495+ }
481496 text. trim ( ) . to_string ( )
482497 } ;
483498
@@ -582,6 +597,39 @@ async fn handle_message(
582597static SLACK_MENTION_RE : LazyLock < regex:: Regex > =
583598 LazyLock :: new ( || regex:: Regex :: new ( r"<@[A-Z0-9]+>" ) . unwrap ( ) ) ;
584599
600+ /// Hard cap on consecutive bot messages in a thread.
601+ /// Mirrors Discord's MAX_CONSECUTIVE_BOT_TURNS to prevent runaway loops.
602+ const MAX_CONSECUTIVE_BOT_TURNS : usize = 10 ;
603+
604+ /// Check if the last N messages in a Slack thread are all from bots.
605+ async fn is_bot_loop ( adapter : & SlackAdapter , channel : & str , thread_ts : & str ) -> bool {
606+ let resp = adapter
607+ . api_post (
608+ "conversations.replies" ,
609+ serde_json:: json!( {
610+ "channel" : channel,
611+ "ts" : thread_ts,
612+ "limit" : MAX_CONSECUTIVE_BOT_TURNS + 1 ,
613+ "inclusive" : true ,
614+ } ) ,
615+ )
616+ . await ;
617+
618+ let Ok ( json) = resp else { return false } ; // fail-open on API error
619+ let Some ( messages) = json[ "messages" ] . as_array ( ) else { return false } ;
620+
621+ // Skip the first message (thread parent), count consecutive bot messages from the end
622+ let recent: Vec < _ > = messages. iter ( ) . skip ( 1 ) . rev ( ) . collect ( ) ;
623+ if recent. len ( ) < MAX_CONSECUTIVE_BOT_TURNS {
624+ return false ;
625+ }
626+
627+ recent
628+ . iter ( )
629+ . take ( MAX_CONSECUTIVE_BOT_TURNS )
630+ . all ( |m| m[ "bot_id" ] . is_string ( ) || m[ "subtype" ] . as_str ( ) == Some ( "bot_message" ) )
631+ }
632+
585633fn strip_slack_mention ( text : & str ) -> String {
586634 SLACK_MENTION_RE . replace_all ( text, "" ) . trim ( ) . to_string ( )
587635}
0 commit comments