Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions charts/openab/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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")) }}
Expand Down
7 changes: 7 additions & 0 deletions charts/openab/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: <release>-<agentKey>)
# nameOverride: ""
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 45 additions & 8 deletions docs/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,26 +134,62 @@ 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 <prompt>` 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

When a user mentions another user (e.g. `@SomeUser`) in a message to the bot, the raw Discord mention `<@UID>` is preserved in the prompt sent to the LLM. This means:

- 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).

Expand Down Expand Up @@ -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.

---

Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Allow the bot to respond to Discord direct messages (DMs).
/// Default: false (opt-in). `allowed_users` still applies in DMs.
#[serde(default)]
Expand Down
48 changes: 38 additions & 10 deletions src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ pub struct Handler {
pub allow_bot_messages: AllowBots,
pub trusted_bot_ids: HashSet<u64>,
pub allow_user_messages: AllowUsers,
/// Role IDs that trigger the bot (same as direct @mention).
pub allowed_role_ids: HashSet<u64>,
/// Positive-only cache: thread channel_id → cached_at for threads where bot has participated.
pub participated_threads: tokio::sync::Mutex<HashMap<String, tokio::time::Instant>>,
/// Positive-only cache: thread channel_id → cached_at for threads where other bots have posted.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -1264,13 +1268,19 @@ fn is_thread_already_exists_error(err: &anyhow::Error) -> bool {
static ROLE_MENTION_RE: LazyLock<regex::Regex> =
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<u64>) -> 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()
}
Expand Down Expand Up @@ -1416,42 +1426,60 @@ 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");
}

/// Bot's own legacy <@!UID> mention is also stripped.
#[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");
}

/// Other users' <@UID> mentions are preserved so the LLM can mention them back.
#[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>");
}

/// Role mentions <@&UID> are replaced with @(role) placeholder.
#[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)");
}

/// Message containing only the bot mention results in empty string.
#[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<u64> = [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<u64> = [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).
Expand Down
3 changes: 3 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
Loading