@@ -147,6 +147,8 @@ pub struct Handler {
147147 pub allow_bot_messages : AllowBots ,
148148 pub trusted_bot_ids : HashSet < u64 > ,
149149 pub allow_user_messages : AllowUsers ,
150+ /// Role IDs that trigger the bot (same as direct @mention).
151+ pub allowed_role_ids : HashSet < u64 > ,
150152 /// Positive-only cache: thread channel_id → cached_at for threads where bot has participated.
151153 pub participated_threads : tokio:: sync:: Mutex < HashMap < String , tokio:: time:: Instant > > ,
152154 /// Positive-only cache: thread channel_id → cached_at for threads where other bots have posted.
@@ -359,7 +361,9 @@ impl EventHandler for Handler {
359361 self . allow_all_channels || self . allowed_channels . contains ( & channel_id) ;
360362
361363 let is_mentioned = msg. mentions_user_id ( bot_id)
362- || msg. content . contains ( & format ! ( "<@{}>" , bot_id) ) ;
364+ || msg. content . contains ( & format ! ( "<@{}>" , bot_id) )
365+ || ( !self . allowed_role_ids . is_empty ( )
366+ && msg. mention_roles . iter ( ) . any ( |r| self . allowed_role_ids . contains ( & r. get ( ) ) ) ) ;
363367
364368 // Bot message gating (from upstream #321)
365369 if msg. author . bot {
@@ -520,7 +524,7 @@ impl EventHandler for Handler {
520524 return ;
521525 }
522526
523- let prompt = resolve_mentions ( & msg. content , bot_id) ;
527+ let prompt = resolve_mentions ( & msg. content , bot_id, & self . allowed_role_ids ) ;
524528
525529 // No text and no attachments → skip
526530 if prompt. is_empty ( ) && msg. attachments . is_empty ( ) {
@@ -1151,13 +1155,19 @@ static ROLE_MENTION_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
11511155 regex:: Regex :: new ( r"<@&\d+>" ) . unwrap ( )
11521156} ) ;
11531157
1154- fn resolve_mentions ( content : & str , bot_id : UserId ) -> String {
1158+ fn resolve_mentions ( content : & str , bot_id : UserId , allowed_role_ids : & HashSet < u64 > ) -> String {
11551159 // 1. Strip the bot's own trigger mention
11561160 let out = content
11571161 . replace ( & format ! ( "<@{}>" , bot_id) , "" )
11581162 . replace ( & format ! ( "<@!{}>" , bot_id) , "" ) ;
1159- // 2. Other user mentions: keep <@UID> as-is so the LLM can mention back
1160- // 3. Fallback: replace role mentions only (user mentions are preserved)
1163+ // 2. Strip allowed role mentions (they triggered the bot, not useful in prompt)
1164+ let out = if allowed_role_ids. is_empty ( ) {
1165+ out
1166+ } else {
1167+ allowed_role_ids. iter ( ) . fold ( out, |s, id| s. replace ( & format ! ( "<@&{}>" , id) , "" ) )
1168+ } ;
1169+ // 3. Other user mentions: keep <@UID> as-is so the LLM can mention back
1170+ // 4. Fallback: replace remaining role mentions only (user mentions are preserved)
11611171 let out = ROLE_MENTION_RE . replace_all ( & out, "@(role)" ) . to_string ( ) ;
11621172 out. trim ( ) . to_string ( )
11631173}
@@ -1298,42 +1308,60 @@ mod tests {
12981308 #[ test]
12991309 fn resolve_mentions_strips_bot_mention ( ) {
13001310 let bot_id = UserId :: new ( 111 ) ;
1301- let result = resolve_mentions ( "hello <@111> world" , bot_id) ;
1311+ let result = resolve_mentions ( "hello <@111> world" , bot_id, & HashSet :: new ( ) ) ;
13021312 assert_eq ! ( result, "hello world" ) ;
13031313 }
13041314
13051315 /// Bot's own legacy <@!UID> mention is also stripped.
13061316 #[ test]
13071317 fn resolve_mentions_strips_bot_mention_legacy ( ) {
13081318 let bot_id = UserId :: new ( 111 ) ;
1309- let result = resolve_mentions ( "hello <@!111> world" , bot_id) ;
1319+ let result = resolve_mentions ( "hello <@!111> world" , bot_id, & HashSet :: new ( ) ) ;
13101320 assert_eq ! ( result, "hello world" ) ;
13111321 }
13121322
13131323 /// Other users' <@UID> mentions are preserved so the LLM can mention them back.
13141324 #[ test]
13151325 fn resolve_mentions_preserves_other_user_mentions ( ) {
13161326 let bot_id = UserId :: new ( 111 ) ;
1317- let result = resolve_mentions ( "<@111> say hi to <@222>" , bot_id) ;
1327+ let result = resolve_mentions ( "<@111> say hi to <@222>" , bot_id, & HashSet :: new ( ) ) ;
13181328 assert_eq ! ( result, "say hi to <@222>" ) ;
13191329 }
13201330
13211331 /// Role mentions <@&UID> are replaced with @(role) placeholder.
13221332 #[ test]
13231333 fn resolve_mentions_replaces_role_mentions ( ) {
13241334 let bot_id = UserId :: new ( 111 ) ;
1325- let result = resolve_mentions ( "hello <@&999>" , bot_id) ;
1335+ let result = resolve_mentions ( "hello <@&999>" , bot_id, & HashSet :: new ( ) ) ;
13261336 assert_eq ! ( result, "hello @(role)" ) ;
13271337 }
13281338
13291339 /// Message containing only the bot mention results in empty string.
13301340 #[ test]
13311341 fn resolve_mentions_empty_after_strip ( ) {
13321342 let bot_id = UserId :: new ( 111 ) ;
1333- let result = resolve_mentions ( "<@111>" , bot_id) ;
1343+ let result = resolve_mentions ( "<@111>" , bot_id, & HashSet :: new ( ) ) ;
13341344 assert_eq ! ( result, "" ) ;
13351345 }
13361346
1347+ /// Allowed role mentions are stripped from prompt (not replaced with @(role)).
1348+ #[ test]
1349+ fn resolve_mentions_strips_allowed_role ( ) {
1350+ let bot_id = UserId :: new ( 111 ) ;
1351+ let roles: HashSet < u64 > = [ 999 ] . into_iter ( ) . collect ( ) ;
1352+ let result = resolve_mentions ( "hello <@&999> world" , bot_id, & roles) ;
1353+ assert_eq ! ( result, "hello world" ) ;
1354+ }
1355+
1356+ /// Non-allowed role mentions are still replaced with @(role).
1357+ #[ test]
1358+ fn resolve_mentions_keeps_other_roles_as_placeholder ( ) {
1359+ let bot_id = UserId :: new ( 111 ) ;
1360+ let roles: HashSet < u64 > = [ 999 ] . into_iter ( ) . collect ( ) ;
1361+ let result = resolve_mentions ( "<@&999> check <@&888>" , bot_id, & roles) ;
1362+ assert_eq ! ( result, "check @(role)" ) ;
1363+ }
1364+
13371365 // --- thread-race error detection ---
13381366
13391367 /// Detects the Discord error code for "thread already exists" (160004).
0 commit comments