From abe1ab745ad5a6c842e6b2c8609104d11d41994a Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Wed, 6 May 2026 13:07:12 +0000 Subject: [PATCH 1/2] feat(discord): support role mention as trigger (allowed_role_ids) Add allowed_role_ids config field to DiscordConfig. When a message mentions a role in this list, it is treated as equivalent to a direct @mention for trigger purposes. - src/config.rs: add allowed_role_ids field (default empty) - src/discord.rs: extend is_mentioned to check msg.mention_roles against allowed_role_ids; update resolve_mentions to strip triggering role mentions from prompt - src/main.rs: parse allowed_role_ids via parse_id_set, pass to Handler - charts/openab: add allowedRoleIds with snowflake validation - config.toml.example: document new field Closes #758 Discord Discussion URL: https://discord.com/channels/1488041051187974246/1501546581105705012 --- charts/openab/templates/configmap.yaml | 11 ++++++ charts/openab/values.yaml | 7 ++++ config.toml.example | 2 ++ src/config.rs | 5 +++ src/discord.rs | 48 ++++++++++++++++++++------ src/main.rs | 3 ++ 6 files changed, 66 insertions(+), 10 deletions(-) diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index 32106c2b..ebbdb758 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -44,6 +44,17 @@ data: {{- if $cfg.discord.trustedBotIds }} trusted_bot_ids = {{ $cfg.discord.trustedBotIds | toJson }} {{- end }} + {{- range $cfg.discord.allowedRoleIds }} + {{- if regexMatch "e\\+|E\\+" (toString .) }} + {{- fail (printf "discord.allowedRoleIds contains a mangled ID: %s — use --set-string instead of --set for role IDs" (toString .)) }} + {{- end }} + {{- if not (regexMatch "^[0-9]{17,20}$" (toString .)) }} + {{- fail (printf "discord.allowedRoleIds contains an invalid role ID: %s — must be a 17-20 digit snowflake ID" (toString .)) }} + {{- end }} + {{- end }} + {{- if $cfg.discord.allowedRoleIds }} + allowed_role_ids = {{ $cfg.discord.allowedRoleIds | toJson }} + {{- end }} {{- /* allowUserMessages: controls whether the bot requires @mention in threads (Discord) */ -}} {{- if $cfg.discord.allowUserMessages }} {{- if not (has $cfg.discord.allowUserMessages (list "involved" "mentions" "multibot-mentions")) }} diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 2935637d..8a83e963 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -44,6 +44,8 @@ agents: # allowBotMessages: "off" # # trustedBotIds: [] # empty = any bot (mode permitting) # trustedBotIds: [] + # # allowedRoleIds: [] # role IDs that trigger the bot + # allowedRoleIds: [] # workingDir: /home/agent # # nameOverride: custom deployment name (default: -) # nameOverride: "" @@ -163,6 +165,11 @@ agents: allowBotMessages: "off" # trustedBotIds: [] # empty = any bot (mode permitting); set to restrict trustedBotIds: [] + # allowedRoleIds: Role IDs that trigger the bot (same as direct @mention). + # Create a Discord role, assign it to the bot, then users can @role to trigger. + # Empty (default) = role mentions do not trigger the bot. + # allowedRoleIds: ["1234567890123456789"] + allowedRoleIds: [] # maxBotTurns: soft cap on consecutive bot turns per thread before # the bot stops auto-replying. A human message resets the counter. # Default 100 (Rust-side `default_max_bot_turns()`). Raise for long diff --git a/config.toml.example b/config.toml.example index cf99f6bd..cc3282a9 100644 --- a/config.toml.example +++ b/config.toml.example @@ -11,6 +11,8 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # allow_bot_messages = "off" # "off" (default) | "mentions" | "all" # "mentions" is recommended for multi-agent collaboration # trusted_bot_ids = [] # empty = any bot (mode permitting); set to restrict +# allowed_role_ids = [] # role IDs that trigger the bot (same as direct @mention) + # note: if multiple bots share the same role, all will respond simultaneously # allow_user_messages = "involved" # "involved" (default) | "mentions" # "involved" = reply in threads bot owns or has participated in # "mentions" = always require @mention diff --git a/src/config.rs b/src/config.rs index dd56484d..f3c60f66 100644 --- a/src/config.rs +++ b/src/config.rs @@ -161,6 +161,11 @@ pub struct DiscordConfig { /// Human message resets the counter. Default: 100. #[serde(default = "default_max_bot_turns")] pub max_bot_turns: u32, + /// Role IDs that trigger the bot (same as direct @mention). + /// When a message mentions a role in this list, it is treated as a bot trigger. + /// Empty (default) = role mentions do not trigger the bot. + #[serde(default)] + pub allowed_role_ids: Vec, /// Allow the bot to respond to Discord direct messages (DMs). /// Default: false (opt-in). `allowed_users` still applies in DMs. #[serde(default)] diff --git a/src/discord.rs b/src/discord.rs index a8b27be2..e4946ea3 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -155,6 +155,8 @@ pub struct Handler { pub allow_bot_messages: AllowBots, pub trusted_bot_ids: HashSet, pub allow_user_messages: AllowUsers, + /// Role IDs that trigger the bot (same as direct @mention). + pub allowed_role_ids: HashSet, /// Positive-only cache: thread channel_id → cached_at for threads where bot has participated. pub participated_threads: tokio::sync::Mutex>, /// Positive-only cache: thread channel_id → cached_at for threads where other bots have posted. @@ -379,7 +381,9 @@ impl EventHandler for Handler { self.allow_all_channels || self.allowed_channels.contains(&channel_id); let is_mentioned = - msg.mentions_user_id(bot_id) || msg.content.contains(&format!("<@{}>", bot_id)); + msg.mentions_user_id(bot_id) || msg.content.contains(&format!("<@{}>", bot_id)) + || (!self.allowed_role_ids.is_empty() + && msg.mention_roles.iter().any(|r| self.allowed_role_ids.contains(&r.get()))); // Bot message gating (from upstream #321) if msg.author.bot { @@ -570,7 +574,7 @@ impl EventHandler for Handler { return; } - let prompt = resolve_mentions(&msg.content, bot_id); + let prompt = resolve_mentions(&msg.content, bot_id, &self.allowed_role_ids); // No text and no attachments → skip if prompt.is_empty() && msg.attachments.is_empty() { @@ -1264,13 +1268,19 @@ fn is_thread_already_exists_error(err: &anyhow::Error) -> bool { static ROLE_MENTION_RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"<@&\d+>").unwrap()); -fn resolve_mentions(content: &str, bot_id: UserId) -> String { +fn resolve_mentions(content: &str, bot_id: UserId, allowed_role_ids: &HashSet) -> String { // 1. Strip the bot's own trigger mention let out = content .replace(&format!("<@{}>", bot_id), "") .replace(&format!("<@!{}>", bot_id), ""); - // 2. Other user mentions: keep <@UID> as-is so the LLM can mention back - // 3. Fallback: replace role mentions only (user mentions are preserved) + // 2. Strip allowed role mentions (they triggered the bot, not useful in prompt) + let out = if allowed_role_ids.is_empty() { + out + } else { + allowed_role_ids.iter().fold(out, |s, id| s.replace(&format!("<@&{}>", id), "")) + }; + // 3. Other user mentions: keep <@UID> as-is so the LLM can mention back + // 4. Fallback: replace remaining role mentions only (user mentions are preserved) let out = ROLE_MENTION_RE.replace_all(&out, "@(role)").to_string(); out.trim().to_string() } @@ -1416,7 +1426,7 @@ mod tests { #[test] fn resolve_mentions_strips_bot_mention() { let bot_id = UserId::new(111); - let result = resolve_mentions("hello <@111> world", bot_id); + let result = resolve_mentions("hello <@111> world", bot_id, &HashSet::new()); assert_eq!(result, "hello world"); } @@ -1424,7 +1434,7 @@ mod tests { #[test] fn resolve_mentions_strips_bot_mention_legacy() { let bot_id = UserId::new(111); - let result = resolve_mentions("hello <@!111> world", bot_id); + let result = resolve_mentions("hello <@!111> world", bot_id, &HashSet::new()); assert_eq!(result, "hello world"); } @@ -1432,7 +1442,7 @@ mod tests { #[test] fn resolve_mentions_preserves_other_user_mentions() { let bot_id = UserId::new(111); - let result = resolve_mentions("<@111> say hi to <@222>", bot_id); + let result = resolve_mentions("<@111> say hi to <@222>", bot_id, &HashSet::new()); assert_eq!(result, "say hi to <@222>"); } @@ -1440,7 +1450,7 @@ mod tests { #[test] fn resolve_mentions_replaces_role_mentions() { let bot_id = UserId::new(111); - let result = resolve_mentions("hello <@&999>", bot_id); + let result = resolve_mentions("hello <@&999>", bot_id, &HashSet::new()); assert_eq!(result, "hello @(role)"); } @@ -1448,10 +1458,28 @@ mod tests { #[test] fn resolve_mentions_empty_after_strip() { let bot_id = UserId::new(111); - let result = resolve_mentions("<@111>", bot_id); + let result = resolve_mentions("<@111>", bot_id, &HashSet::new()); assert_eq!(result, ""); } + /// Allowed role mentions are stripped from prompt (not replaced with @(role)). + #[test] + fn resolve_mentions_strips_allowed_role() { + let bot_id = UserId::new(111); + let roles: HashSet = [999].into_iter().collect(); + let result = resolve_mentions("hello <@&999> world", bot_id, &roles); + assert_eq!(result, "hello world"); + } + + /// Non-allowed role mentions are still replaced with @(role). + #[test] + fn resolve_mentions_keeps_other_roles_as_placeholder() { + let bot_id = UserId::new(111); + let roles: HashSet = [999].into_iter().collect(); + let result = resolve_mentions("<@&999> check <@&888>", bot_id, &roles); + assert_eq!(result, "check @(role)"); + } + // --- thread-race error detection --- /// Detects the Discord error code for "thread already exists" (160004). diff --git a/src/main.rs b/src/main.rs index 3cfce2db..1a6eb5b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -374,12 +374,14 @@ async fn main() -> anyhow::Result<()> { let allowed_users = parse_id_set(&discord_cfg.allowed_users, "discord.allowed_users")?; let trusted_bot_ids = parse_id_set(&discord_cfg.trusted_bot_ids, "discord.trusted_bot_ids")?; + let allowed_role_ids = parse_id_set(&discord_cfg.allowed_role_ids, "discord.allowed_role_ids")?; info!( allow_all_channels, allow_all_users, channels = allowed_channels.len(), users = allowed_users.len(), trusted_bots = trusted_bot_ids.len(), + role_triggers = allowed_role_ids.len(), allow_bot_messages = ?discord_cfg.allow_bot_messages, allow_user_messages = ?discord_cfg.allow_user_messages, allow_dm = discord_cfg.allow_dm, @@ -410,6 +412,7 @@ async fn main() -> anyhow::Result<()> { allow_bot_messages: discord_cfg.allow_bot_messages, trusted_bot_ids, allow_user_messages: discord_cfg.allow_user_messages, + allowed_role_ids, participated_threads: tokio::sync::Mutex::new(std::collections::HashMap::new()), multibot_threads: tokio::sync::Mutex::new(std::collections::HashMap::new()), session_ttl: std::time::Duration::from_secs(ttl_secs), From e77bcb33aad6ebf4be8e329cad7d3ad21ca05c7b Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Wed, 6 May 2026 20:17:29 +0000 Subject: [PATCH 2/2] docs(discord): document allowed_role_ids feature Update docs/discord.md: - Add allowed_role_ids config reference section with setup steps - Update @Mention Behavior to include role mention trigger - Update Helm Values example with allowedRoleIds - Update troubleshooting to reflect role mention support --- docs/discord.md | 53 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/docs/discord.md b/docs/discord.md index 9a352796..ac4af4e9 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -134,18 +134,53 @@ trusted_bot_ids = ["123456789012345678"] # only this bot's messages pass throug Empty (default) = any bot can pass through (subject to the mode check). +### `allowed_role_ids` + +Role IDs that trigger the bot, same as a direct @mention. This enables users to invoke multiple bots simultaneously with a single role mention (e.g. `@AllBots review this`). + +```toml +allowed_role_ids = ["123456789012345678"] # @mention this role = trigger the bot +``` + +Empty (default) = role mentions do not trigger the bot. + +**Setup:** +1. Create a Discord role (e.g. `Bots` or `AllAgents`) +2. Assign the role to all bots you want to trigger together +3. Add the role's ID to each bot's `allowed_role_ids` +4. Users type `@RoleName ` to trigger all bots at once + +> **Note:** If multiple bots share the same role, all will respond simultaneously. Use `multibot-mentions` mode if you want bots to require explicit @mention when other bots are already in the thread. + +#### Interaction with `multibot-mentions` mode + +When `allow_user_messages = "multibot-mentions"` is set alongside `allowed_role_ids`: + +| Action | Result | +|--------|--------| +| `@Role review this` in a channel | All bots trigger (role mention = explicit mention) | +| Follow-up in the thread without @mention | Only the thread owner responds (multibot gate kicks in) | +| `@Role follow up` in the thread | All bots respond again | + +This gives the best of both worlds: one role mention to summon all bots, but subsequent messages in the thread don't cause all bots to pile on. + --- ## @Mention Behavior -**Always @mention the bot user, not the role.** Discord shows both in autocomplete — pick the one without the role icon. +The bot responds to: + +1. **Direct @mention** (`@BotUser`) — always works +2. **Role mention** (`@RoleName`) — only if the role ID is in `allowed_role_ids` +3. **Thread reply** — depends on `allow_user_messages` mode (no @mention needed in `involved` mode) ``` -✅ @AgentBroker hello ← user mention, bot responds -❌ @AgentBroker hello ← role mention (with role icon), bot ignores +✅ @AgentBroker hello ← user mention, bot responds +✅ @AllBots hello ← role mention, bot responds (if role in allowed_role_ids) +❌ @SomeOtherRole hello ← role not in allowed_role_ids, bot ignores ``` -Role mentions are ignored because they are shared across bots and cause false positives in multi-bot setups. This is intentional since v0.7.8-beta.3 (#420, #440). +The triggering role mention is stripped from the prompt sent to the agent (same as the bot's own user mention). ### User mention UIDs @@ -153,7 +188,8 @@ When a user mentions another user (e.g. `@SomeUser`) in a message to the bot, th - The LLM can copy `<@UID>` into its reply to produce a clickable Discord mention - The bot's own mention is stripped (so the bot doesn't see itself being triggered) -- Role mentions are replaced with `@(role)` placeholder +- Triggering role mentions (in `allowed_role_ids`) are stripped +- Other role mentions are replaced with `@(role)` placeholder To help the LLM know who each UID refers to, provide a UID→name mapping via system prompt or context entry (see [Multi-Bot Setup](#multi-bot-setup) below). @@ -274,10 +310,11 @@ helm install openab openab/openab \ --set agents.kiro.discord.botToken="$DISCORD_BOT_TOKEN" \ --set-string 'agents.kiro.discord.allowedChannels[0]=YOUR_CHANNEL_ID' \ --set agents.kiro.discord.allowBotMessages=off \ - --set agents.kiro.discord.allowUserMessages=involved + --set agents.kiro.discord.allowUserMessages=involved \ + --set-string 'agents.kiro.discord.allowedRoleIds[0]=YOUR_ROLE_ID' ``` -⚠️ Use `--set-string` for channel/user IDs to avoid float64 precision loss. +⚠️ Use `--set-string` for channel/user/role IDs to avoid float64 precision loss. --- @@ -288,7 +325,7 @@ helm install openab openab/openab \ 1. **Check channel ID** — make sure it's in `allowed_channels` 2. **Check permissions** — bot needs Send Messages, Create Public Threads, Read Message History in the channel 3. **Check intents** — Message Content Intent must be enabled in Developer Portal -4. **Check @mention type** — use user mention, not role mention +4. **Check @mention type** — use user mention or a role in `allowed_role_ids` 5. **Check if in a thread** — with `mentions` mode, @mention is required even in threads ### Bot stops receiving messages after restart