Skip to content

Commit 4684c6a

Browse files
committed
fix: Slack bot-loop protection + silence already_reacted errors
- Add is_bot_loop() check using conversations.replies: if the last MAX_CONSECUTIVE_BOT_TURNS (10) messages in a thread are all from bots, stop responding to prevent runaway loops. Only applies to thread follow-ups (not @mentions). Fail-open on API error. - Silently ignore already_reacted in add_reaction and no_reaction in remove_reaction instead of logging noisy errors.
1 parent f641bec commit 4684c6a

1 file changed

Lines changed: 54 additions & 6 deletions

File tree

src/slack.rs

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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(
582597
static 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+
585633
fn strip_slack_mention(text: &str) -> String {
586634
SLACK_MENTION_RE.replace_all(text, "").trim().to_string()
587635
}

0 commit comments

Comments
 (0)