Skip to content

Commit 12bd9f3

Browse files
committed
fix(discord): move bot turn tracker before self-check and gating (#483)
Bot turn limits never triggered in multi-bot ping-pong because the tracker ran after both the self-check and bot message gating. Each bot's process never counted the other bot's messages (filtered by gating) or its own replies (filtered by self-check). Move the tracker before BOTH self-check and bot gating so ALL bot messages count — including the bot's own replies. This means soft_limit=20 = 20 total bot messages in the thread (~10 per bot in a two-bot ping-pong). Warning messages only sent when the triggering message is from another bot (not self) to avoid the bot warning about its own reply.
1 parent db462ff commit 12bd9f3

1 file changed

Lines changed: 80 additions & 42 deletions

File tree

src/discord.rs

Lines changed: 80 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,52 @@ impl EventHandler for Handler {
228228
async fn message(&self, ctx: Context, msg: Message) {
229229
let bot_id = ctx.cache.current_user().id;
230230

231-
// Always ignore own messages
231+
// Early multibot detection: cache that another bot is present.
232+
// Runs before self-check and bot gating so we always detect other bots. (#481)
233+
if msg.author.bot && msg.author.id != bot_id {
234+
let key = msg.channel_id.to_string();
235+
let mut cache = self.multibot_threads.lock().await;
236+
cache.entry(key).or_insert_with(tokio::time::Instant::now);
237+
}
238+
239+
// Bot turn counting: runs before self-check so ALL bot messages
240+
// (including own) count toward the per-thread limit. This means
241+
// soft_limit=20 = 20 total bot messages in the thread (~10 per bot
242+
// in a two-bot ping-pong). (#483)
243+
{
244+
let thread_key = msg.channel_id.to_string();
245+
let mut tracker = self.bot_turns.lock().await;
246+
if msg.author.bot {
247+
match tracker.on_bot_message(&thread_key) {
248+
TurnResult::HardLimit => {
249+
tracing::warn!(channel_id = %msg.channel_id, "hard bot turn limit reached");
250+
// Only send the warning if we're not the one who caused it
251+
if msg.author.id != bot_id {
252+
let _ = msg.channel_id.say(
253+
&ctx.http,
254+
format!("🛑 Hard limit reached ({HARD_BOT_TURN_LIMIT}). Bot-to-bot conversation in this thread has been permanently stopped."),
255+
).await;
256+
}
257+
return;
258+
}
259+
TurnResult::SoftLimit(n) => {
260+
tracing::info!(channel_id = %msg.channel_id, turns = n, max = self.max_bot_turns, "soft bot turn limit reached");
261+
if msg.author.id != bot_id {
262+
let _ = msg.channel_id.say(
263+
&ctx.http,
264+
format!("⚠️ Bot turn limit reached ({n}/{}). A human must reply in this thread to continue bot-to-bot conversation.", self.max_bot_turns),
265+
).await;
266+
}
267+
return;
268+
}
269+
TurnResult::Ok => {}
270+
}
271+
} else {
272+
tracker.on_human_message(&thread_key);
273+
}
274+
}
275+
276+
// Ignore own messages (after counting toward bot turns above)
232277
if msg.author.id == bot_id {
233278
return;
234279
}
@@ -244,15 +289,6 @@ impl EventHandler for Handler {
244289
let is_mentioned = msg.mentions_user_id(bot_id)
245290
|| msg.content.contains(&format!("<@{}>", bot_id));
246291

247-
// Early multibot detection: cache that another bot is present in this
248-
// channel/thread. Runs BEFORE bot message gating so we detect other
249-
// bots even when their messages are filtered out. (#481)
250-
if msg.author.bot && msg.author.id != bot_id {
251-
let key = msg.channel_id.to_string();
252-
let mut cache = self.multibot_threads.lock().await;
253-
cache.entry(key).or_insert_with(tokio::time::Instant::now);
254-
}
255-
256292
// Bot message gating (from upstream #321)
257293
if msg.author.bot {
258294
match self.allow_bot_messages {
@@ -398,38 +434,6 @@ impl EventHandler for Handler {
398434

399435
let prompt = resolve_mentions(&msg.content, bot_id);
400436

401-
// Bot turn limiting: track consecutive bot turns per thread.
402-
// Placed after all gating so only messages that will actually be
403-
// processed count toward the limit.
404-
// Human message resets both soft and hard counters.
405-
{
406-
let thread_key = msg.channel_id.to_string();
407-
let mut tracker = self.bot_turns.lock().await;
408-
if msg.author.bot {
409-
match tracker.on_bot_message(&thread_key) {
410-
TurnResult::HardLimit => {
411-
tracing::warn!(channel_id = %msg.channel_id, "hard bot turn limit reached");
412-
let _ = msg.channel_id.say(
413-
&ctx.http,
414-
format!("🛑 Hard limit reached ({HARD_BOT_TURN_LIMIT}). Bot-to-bot conversation in this thread has been permanently stopped."),
415-
).await;
416-
return;
417-
}
418-
TurnResult::SoftLimit(n) => {
419-
tracing::info!(channel_id = %msg.channel_id, turns = n, max = self.max_bot_turns, "soft bot turn limit reached");
420-
let _ = msg.channel_id.say(
421-
&ctx.http,
422-
format!("⚠️ Bot turn limit reached ({n}/{}). A human must reply in this thread to continue bot-to-bot conversation.", self.max_bot_turns),
423-
).await;
424-
return;
425-
}
426-
TurnResult::Ok => {}
427-
}
428-
} else {
429-
tracker.on_human_message(&thread_key);
430-
}
431-
}
432-
433437
// No text and no attachments → skip
434438
if prompt.is_empty() && msg.attachments.is_empty() {
435439
return;
@@ -932,6 +936,40 @@ mod tests {
932936
t.on_human_message("unknown"); // should not panic
933937
}
934938

939+
/// Two-bot ping-pong: both bots' messages count toward the same per-thread
940+
/// limit. With soft_limit=20, the limit triggers after 20 total bot messages
941+
/// (~10 per bot). This simulates what each bot's process sees when the
942+
/// tracker runs before self-check — own messages are counted too. (#483)
943+
#[test]
944+
fn two_bot_pingpong_hits_soft_limit() {
945+
let mut t = BotTurnTracker::new(20);
946+
// Simulate 20 bot messages (alternating bot A and bot B,
947+
// but the tracker doesn't distinguish — it just counts)
948+
for i in 1..20 {
949+
assert_eq!(t.on_bot_message("t1"), TurnResult::Ok, "turn {i}");
950+
}
951+
assert_eq!(t.on_bot_message("t1"), TurnResult::SoftLimit(20));
952+
}
953+
954+
/// Human message in the middle of a ping-pong resets the counter,
955+
/// allowing bots to continue.
956+
#[test]
957+
fn two_bot_pingpong_human_resets() {
958+
let mut t = BotTurnTracker::new(20);
959+
for _ in 0..15 {
960+
assert_eq!(t.on_bot_message("t1"), TurnResult::Ok);
961+
}
962+
t.on_human_message("t1"); // human intervenes at 15
963+
for _ in 0..15 {
964+
assert_eq!(t.on_bot_message("t1"), TurnResult::Ok); // can do 15 more
965+
}
966+
// now at 15 again, 5 more to hit limit
967+
for _ in 0..4 {
968+
assert_eq!(t.on_bot_message("t1"), TurnResult::Ok);
969+
}
970+
assert_eq!(t.on_bot_message("t1"), TurnResult::SoftLimit(20));
971+
}
972+
935973
// --- resolve_mentions tests ---
936974

937975
/// Bot's own <@UID> mention is stripped from the prompt.

0 commit comments

Comments
 (0)