11use crate :: acp:: ContentBlock ;
22use crate :: acp:: protocol:: ConfigOption ;
33use 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 } ;
55use crate :: config:: { AllowBots , AllowUsers , SttConfig } ;
66use crate :: format;
77use crate :: media;
@@ -10,7 +10,7 @@ use std::sync::LazyLock;
1010use serenity:: builder:: { CreateActionRow , CreateCommand , CreateInteractionResponse , CreateInteractionResponseMessage , CreateSelectMenu , CreateSelectMenuKind , CreateSelectMenuOption , CreateThread , EditMessage } ;
1111use serenity:: http:: Http ;
1212use serenity:: model:: application:: { ComponentInteractionDataKind , Interaction } ;
13- use serenity:: model:: channel:: { AutoArchiveDuration , Message , MessageType , ReactionType } ;
13+ use serenity:: model:: channel:: { AutoArchiveDuration , ChannelType , Message , MessageType , ReactionType } ;
1414use serenity:: model:: gateway:: Ready ;
1515use serenity:: model:: id:: { ChannelId , MessageId , UserId } ;
1616use 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