Skip to content

Commit 864e92b

Browse files
committed
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
1 parent 4446321 commit 864e92b

6 files changed

Lines changed: 66 additions & 10 deletions

File tree

charts/openab/templates/configmap.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ data:
4444
{{- if $cfg.discord.trustedBotIds }}
4545
trusted_bot_ids = {{ $cfg.discord.trustedBotIds | toJson }}
4646
{{- end }}
47+
{{- range $cfg.discord.allowedRoleIds }}
48+
{{- if regexMatch "e\\+|E\\+" (toString .) }}
49+
{{- fail (printf "discord.allowedRoleIds contains a mangled ID: %s — use --set-string instead of --set for role IDs" (toString .)) }}
50+
{{- end }}
51+
{{- if not (regexMatch "^[0-9]{17,20}$" (toString .)) }}
52+
{{- fail (printf "discord.allowedRoleIds contains an invalid role ID: %s — must be a 17-20 digit snowflake ID" (toString .)) }}
53+
{{- end }}
54+
{{- end }}
55+
{{- if $cfg.discord.allowedRoleIds }}
56+
allowed_role_ids = {{ $cfg.discord.allowedRoleIds | toJson }}
57+
{{- end }}
4758
{{- /* allowUserMessages: controls whether the bot requires @mention in threads (Discord) */ -}}
4859
{{- if $cfg.discord.allowUserMessages }}
4960
{{- if not (has $cfg.discord.allowUserMessages (list "involved" "mentions" "multibot-mentions")) }}

charts/openab/values.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ agents:
3737
# allowBotMessages: "off"
3838
# # trustedBotIds: [] # empty = any bot (mode permitting)
3939
# trustedBotIds: []
40+
# # allowedRoleIds: [] # role IDs that trigger the bot
41+
# allowedRoleIds: []
4042
# workingDir: /home/agent
4143
# # nameOverride: custom deployment name (default: <release>-<agentKey>)
4244
# nameOverride: ""
@@ -155,6 +157,11 @@ agents:
155157
allowBotMessages: "off"
156158
# trustedBotIds: [] # empty = any bot (mode permitting); set to restrict
157159
trustedBotIds: []
160+
# allowedRoleIds: Role IDs that trigger the bot (same as direct @mention).
161+
# Create a Discord role, assign it to the bot, then users can @role to trigger.
162+
# Empty (default) = role mentions do not trigger the bot.
163+
# allowedRoleIds: ["1234567890123456789"]
164+
allowedRoleIds: []
158165
# maxBotTurns: soft cap on consecutive bot turns per thread before
159166
# the bot stops auto-replying. A human message resets the counter.
160167
# Default 100 (Rust-side `default_max_bot_turns()`). Raise for long

config.toml.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto-
1111
# allow_bot_messages = "off" # "off" (default) | "mentions" | "all"
1212
# "mentions" is recommended for multi-agent collaboration
1313
# trusted_bot_ids = [] # empty = any bot (mode permitting); set to restrict
14+
# allowed_role_ids = [] # role IDs that trigger the bot (same as direct @mention)
15+
# note: if multiple bots share the same role, all will respond simultaneously
1416
# allow_user_messages = "involved" # "involved" (default) | "mentions"
1517
# "involved" = reply in threads bot owns or has participated in
1618
# "mentions" = always require @mention

src/config.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ pub struct DiscordConfig {
146146
/// Human message resets the counter. Default: 100.
147147
#[serde(default = "default_max_bot_turns")]
148148
pub max_bot_turns: u32,
149+
/// Role IDs that trigger the bot (same as direct @mention).
150+
/// When a message mentions a role in this list, it is treated as a bot trigger.
151+
/// Empty (default) = role mentions do not trigger the bot.
152+
#[serde(default)]
153+
pub allowed_role_ids: Vec<String>,
149154
/// Allow the bot to respond to Discord direct messages (DMs).
150155
/// Default: false (opt-in). `allowed_users` still applies in DMs.
151156
#[serde(default)]

src/discord.rs

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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).

src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,12 +331,14 @@ async fn main() -> anyhow::Result<()> {
331331
}
332332
let allowed_users = parse_id_set(&discord_cfg.allowed_users, "discord.allowed_users")?;
333333
let trusted_bot_ids = parse_id_set(&discord_cfg.trusted_bot_ids, "discord.trusted_bot_ids")?;
334+
let allowed_role_ids = parse_id_set(&discord_cfg.allowed_role_ids, "discord.allowed_role_ids")?;
334335
info!(
335336
allow_all_channels,
336337
allow_all_users,
337338
channels = allowed_channels.len(),
338339
users = allowed_users.len(),
339340
trusted_bots = trusted_bot_ids.len(),
341+
role_triggers = allowed_role_ids.len(),
340342
allow_bot_messages = ?discord_cfg.allow_bot_messages,
341343
allow_user_messages = ?discord_cfg.allow_user_messages,
342344
allow_dm = discord_cfg.allow_dm,
@@ -367,6 +369,7 @@ async fn main() -> anyhow::Result<()> {
367369
allow_bot_messages: discord_cfg.allow_bot_messages,
368370
trusted_bot_ids,
369371
allow_user_messages: discord_cfg.allow_user_messages,
372+
allowed_role_ids,
370373
participated_threads: tokio::sync::Mutex::new(std::collections::HashMap::new()),
371374
multibot_threads: tokio::sync::Mutex::new(std::collections::HashMap::new()),
372375
session_ttl: std::time::Duration::from_secs(ttl_secs),

0 commit comments

Comments
 (0)