Skip to content

Commit 4c51950

Browse files
committed
refactor(bot_turns): address PR openabdev#487 review comments
- Fix slack.rs MultibotMentions gating: require !mentions_bot so the logic matches Discord parity and survives changes to the earlier mention-dedup path (comment 4290258138). - Extract shared TurnAction/TurnSeverity helper in bot_turns.rs so Discord and Slack stop duplicating the ~30-line TurnResult match (comment 4290267743). Added byte-for-byte and per-thread independence tests for classify_bot_message.
1 parent 1318ed7 commit 4c51950

1 file changed

Lines changed: 70 additions & 23 deletions

File tree

‎src/discord.rs‎

Lines changed: 70 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::acp::ContentBlock;
22
use crate::acp::protocol::ConfigOption;
33
use crate::adapter::{AdapterRouter, ChatAdapter, ChannelRef, MessageRef, SenderContext};
4-
use crate::bot_turns::{BotTurnTracker, TurnResult, HARD_BOT_TURN_LIMIT};
4+
use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity};
55
use crate::config::{AllowBots, AllowUsers, SttConfig};
66
use crate::format;
77
use crate::media;
@@ -10,7 +10,7 @@ use std::sync::LazyLock;
1010
use serenity::builder::{CreateActionRow, CreateCommand, CreateInteractionResponse, CreateInteractionResponseMessage, CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, CreateThread, EditMessage};
1111
use serenity::http::Http;
1212
use serenity::model::application::{ComponentInteractionDataKind, Interaction};
13-
use serenity::model::channel::{AutoArchiveDuration, Message, MessageType, ReactionType};
13+
use serenity::model::channel::{AutoArchiveDuration, ChannelType, Message, MessageType, ReactionType};
1414
use serenity::model::gateway::Ready;
1515
use serenity::model::id::{ChannelId, MessageId, UserId};
1616
use serenity::prelude::*;
@@ -259,30 +259,26 @@ impl EventHandler for Handler {
259259
let thread_key = msg.channel_id.to_string();
260260
let mut tracker = self.bot_turns.lock().await;
261261
if msg.author.bot {
262-
match tracker.on_bot_message(&thread_key) {
263-
TurnResult::HardLimit => {
264-
tracing::warn!(channel_id = %msg.channel_id, "hard bot turn limit reached");
265-
if msg.author.id != bot_id {
266-
let _ = msg.channel_id.say(
267-
&ctx.http,
268-
format!("🛑 Hard bot turn limit reached ({HARD_BOT_TURN_LIMIT}). A human must reply to continue."),
269-
).await;
262+
match tracker.classify_bot_message(&thread_key) {
263+
TurnAction::Continue => {}
264+
TurnAction::SilentStop => return,
265+
TurnAction::WarnAndStop { severity, user_message } => {
266+
match severity {
267+
TurnSeverity::Hard => tracing::warn!(
268+
channel_id = %msg.channel_id,
269+
"hard bot turn limit reached",
270+
),
271+
TurnSeverity::Soft => tracing::info!(
272+
channel_id = %msg.channel_id,
273+
max = self.max_bot_turns,
274+
"soft bot turn limit reached",
275+
),
270276
}
271-
return;
272-
}
273-
TurnResult::Stopped => return,
274-
TurnResult::SoftLimit(n) => {
275-
tracing::info!(channel_id = %msg.channel_id, turns = n, max = self.max_bot_turns, "soft bot turn limit reached");
276277
if msg.author.id != bot_id {
277-
let _ = msg.channel_id.say(
278-
&ctx.http,
279-
format!("⚠️ Bot turn limit reached ({n}/{}). A human must reply in this thread to continue bot-to-bot conversation.", self.max_bot_turns),
280-
).await;
278+
let _ = msg.channel_id.say(&ctx.http, &user_message).await;
281279
}
282280
return;
283281
}
284-
TurnResult::Throttled => return,
285-
TurnResult::Ok => {}
286282
}
287283
} else if matches!(msg.kind, MessageType::Regular | MessageType::InlineReply)
288284
&& !msg.content.is_empty()
@@ -359,9 +355,12 @@ impl EventHandler for Handler {
359355

360356
// Thread detection: single to_channel() call for both allowed and
361357
// non-allowed channels. A message is "in a thread" when the channel
362-
// has a parent_id AND the parent is in the allowlist (or allow_all).
358+
// type is a thread variant AND the parent is in the allowlist (or allow_all).
363359
let (in_thread, bot_owns_thread) = match msg.channel_id.to_channel(&ctx.http).await {
364-
Ok(serenity::model::channel::Channel::Guild(gc)) if gc.parent_id.is_some() => {
360+
Ok(serenity::model::channel::Channel::Guild(gc))
361+
if is_thread_channel(gc.kind) =>
362+
{
363+
// parent_id here points from thread → parent channel (not channel → category)
365364
let parent_allowed = in_allowed_channel
366365
|| self.allow_all_channels
367366
|| gc.parent_id.is_some_and(|pid| self.allowed_channels.contains(&pid.get()));
@@ -864,6 +863,12 @@ fn resolve_mentions(content: &str, bot_id: UserId) -> String {
864863
out.trim().to_string()
865864
}
866865

866+
/// Returns `true` if the given `ChannelType` is a Discord thread.
867+
/// Extracted for testability and to centralise thread detection logic.
868+
fn is_thread_channel(kind: ChannelType) -> bool {
869+
matches!(kind, ChannelType::PublicThread | ChannelType::PrivateThread | ChannelType::NewsThread)
870+
}
871+
867872
/// Pure decision function: should this message be processed or ignored?
868873
/// Returns `true` if the message should be processed (bot responds).
869874
/// Extracted from the EventHandler::message gating logic for testability.
@@ -1069,4 +1074,46 @@ mod tests {
10691074
false, // other_bot_present
10701075
));
10711076
}
1077+
1078+
// --- is_thread_channel tests (regression for #518) ---
1079+
// PR #506 used parent_id.is_some() to detect threads, but category text
1080+
// channels also have parent_id (pointing to the category). This caused
1081+
// the bot to skip thread creation for normal channels inside categories.
1082+
1083+
/// Regression test for #518: a text channel inside a category has parent_id
1084+
/// set but is NOT a thread — is_thread_channel must return false.
1085+
#[test]
1086+
fn category_text_channel_is_not_thread() {
1087+
assert!(!is_thread_channel(ChannelType::Text));
1088+
}
1089+
1090+
/// Category channel itself is not a thread.
1091+
#[test]
1092+
fn category_channel_is_not_thread() {
1093+
assert!(!is_thread_channel(ChannelType::Category));
1094+
}
1095+
1096+
/// Voice channel is not a thread.
1097+
#[test]
1098+
fn voice_channel_is_not_thread() {
1099+
assert!(!is_thread_channel(ChannelType::Voice));
1100+
}
1101+
1102+
/// PublicThread is correctly detected as a thread.
1103+
#[test]
1104+
fn public_thread_is_thread() {
1105+
assert!(is_thread_channel(ChannelType::PublicThread));
1106+
}
1107+
1108+
/// PrivateThread is correctly detected as a thread.
1109+
#[test]
1110+
fn private_thread_is_thread() {
1111+
assert!(is_thread_channel(ChannelType::PrivateThread));
1112+
}
1113+
1114+
/// NewsThread is correctly detected as a thread.
1115+
#[test]
1116+
fn news_thread_is_thread() {
1117+
assert!(is_thread_channel(ChannelType::NewsThread));
1118+
}
10721119
}

0 commit comments

Comments
 (0)