From badee64c1253f17e88662deb7b829f279faca4a2 Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 9 Mar 2026 03:45:21 -0700 Subject: [PATCH 1/2] fix: require_mention falls through to default agent instead of dropping message Two bugs made require_mention completely non-functional: 1. When require_mention caused a binding to not match, resolve_agent_for_message fell through to the default agent, which processed the message anyway. Split matches() into matches_route() + passes_require_mention() and return None from resolve_agent_for_message when a binding matched on routing but was blocked by require_mention. 2. The require_mention check was hardcoded to Discord only. Made it platform-agnostic by checking each adapter's *_mentions_or_replies_to_bot metadata key. Added the missing telegram_mentions_or_replies_to_bot key to the Telegram adapter. --- src/config/types.rs | 86 +++++++++++++++++++++++++++------------ src/main.rs | 7 +++- src/messaging/telegram.rs | 32 +++++++++++++++ 3 files changed, 97 insertions(+), 28 deletions(-) diff --git a/src/config/types.rs b/src/config/types.rs index 6c134457f..734f933fa 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1428,8 +1428,9 @@ impl Binding { self.adapter.is_none() } - /// Check if this binding matches an inbound message. - fn matches(&self, message: &crate::InboundMessage) -> bool { + /// Check if this binding matches on routing criteria (platform, guild, + /// channel IDs, adapter, etc.) — everything *except* `require_mention`. + fn matches_route(&self, message: &crate::InboundMessage) -> bool { if self.channel != message.source { return false; } @@ -1510,24 +1511,6 @@ impl Binding { } } - if self.channel == "discord" && self.require_mention { - let is_guild_message = message - .metadata - .get("discord_guild_id") - .and_then(|v| v.as_u64()) - .is_some(); - if is_guild_message { - let mentions_or_replies_to_bot = message - .metadata - .get("discord_mentions_or_replies_to_bot") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if !mentions_or_replies_to_bot { - return false; - } - } - } - if let Some(chat_id) = &self.chat_id { let message_chat = message.metadata.get("telegram_chat_id").and_then(|value| { value @@ -1542,6 +1525,44 @@ impl Binding { true } + + /// Check whether a message that already matched on routing criteria also + /// passes the `require_mention` filter. Returns `true` when + /// `require_mention` is disabled or the message includes a mention/reply. + /// + /// Works for all platforms by checking the platform-specific + /// `*_mentions_or_replies_to_bot` metadata key that every adapter sets. + /// DMs are always allowed through (they are inherently directed at the bot). + fn passes_require_mention(&self, message: &crate::InboundMessage) -> bool { + if !self.require_mention { + return true; + } + + // DMs are inherently directed at the bot — always pass. + let is_dm = + message.source == "discord" && !message.metadata.contains_key("discord_guild_id"); + if is_dm { + return true; + } + + // Each adapter sets a `_mentions_or_replies_to_bot` metadata + // key. Check the one that corresponds to the message source. + let mention_key = match message.source.as_str() { + "discord" => "discord_mentions_or_replies_to_bot", + "slack" => "slack_mentions_or_replies_to_bot", + "twitch" => "twitch_mentions_or_replies_to_bot", + "telegram" => "telegram_mentions_or_replies_to_bot", + // Unknown platforms: if require_mention is set, default to + // requiring a mention (safe default). + _ => return false, + }; + + message + .metadata + .get(mention_key) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } } /// Build a runtime adapter key from platform and optional named selector. @@ -1814,19 +1835,32 @@ fn validate_runtime_keys( /// Resolve which agent should handle an inbound message. /// -/// Checks bindings in order. First match wins. Falls back to the default -/// agent if no binding matches. +/// Checks bindings in order. First routing match wins. Falls back to the +/// default agent if no binding matches on routing criteria. +/// +/// Returns `None` when a binding matched on routing but the message was +/// suppressed by `require_mention` — the caller should drop the message. pub fn resolve_agent_for_message( bindings: &[Binding], message: &crate::InboundMessage, default_agent_id: &str, -) -> crate::AgentId { +) -> Option { for binding in bindings { - if binding.matches(message) { - return std::sync::Arc::from(binding.agent_id.as_str()); + if binding.matches_route(message) { + if binding.passes_require_mention(message) { + return Some(std::sync::Arc::from(binding.agent_id.as_str())); + } + // Binding owns this message but require_mention blocked it. + // Drop instead of falling through to the default agent. + tracing::debug!( + agent_id = %binding.agent_id, + source = %message.source, + "message suppressed by require_mention" + ); + return None; } } - std::sync::Arc::from(default_agent_id) + Some(std::sync::Arc::from(default_agent_id)) } // --------------------------------------------------------------------------- diff --git a/src/main.rs b/src/main.rs index 3f032db9c..8cf7c47eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1877,11 +1877,14 @@ async fn run( existing.clone() } else { let current_bindings = bindings.load(); - let resolved = spacebot::config::resolve_agent_for_message( + let Some(resolved) = spacebot::config::resolve_agent_for_message( ¤t_bindings, &message, &default_agent_id, - ); + ) else { + // Message suppressed by require_mention — drop it. + continue; + }; message.agent_id = Some(resolved.clone()); resolved }; diff --git a/src/messaging/telegram.rs b/src/messaging/telegram.rs index f806e7537..8dfc2bef1 100644 --- a/src/messaging/telegram.rs +++ b/src/messaging/telegram.rs @@ -858,7 +858,24 @@ fn build_metadata( metadata.insert("telegram_bot_username".into(), bot_username.clone().into()); } + // Compute combined mentions-or-replies-to-bot flag for require_mention. + // Matches the pattern used by Discord/Slack/Twitch adapters. + let mut mentions_or_replies_to_bot = false; + + // Check text-based @mention (Telegram sends mentions as entities) + if let Some(bot_username) = bot_username { + let bot_lower = bot_username.to_lowercase(); + if let Some(text) = extract_text(message) { + let text_lower = text.to_lowercase(); + let mention = format!("@{bot_lower}"); + if text_lower.contains(&mention) { + mentions_or_replies_to_bot = true; + } + } + } + // Reply-to context for threading + let mut reply_to_is_bot_match = false; if let Some(reply) = message.reply_to_message() { metadata.insert( "reply_to_message_id".into(), @@ -884,10 +901,25 @@ fn build_metadata( ); if let Some(username) = &from.username { metadata.insert("reply_to_username".into(), username.clone().into()); + // Check if reply is to our bot specifically + if from.is_bot + && let Some(bot_username) = bot_username + && username.to_lowercase() == bot_username.to_lowercase() + { + reply_to_is_bot_match = true; + } } } } + if !mentions_or_replies_to_bot && reply_to_is_bot_match { + mentions_or_replies_to_bot = true; + } + metadata.insert( + "telegram_mentions_or_replies_to_bot".into(), + serde_json::Value::Bool(mentions_or_replies_to_bot), + ); + (metadata, formatted_author) } From 3cd71b2b0e53ef3034cf8569fd04875ef6b3c15f Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 9 Mar 2026 09:25:43 -0700 Subject: [PATCH 2/2] address PR #376 review feedback - Fix discord_guild_id DM check: use .as_u64().is_none() instead of contains_key to handle null values correctly - Add Telegram private chat DM bypass so require_mention doesn't drop direct messages on Telegram - Fix Telegram mention prefix matching: add word-boundary check so @spacebot doesn't match @spacebot_extra --- src/config/types.rs | 17 +++++++++++++++-- src/messaging/telegram.rs | 16 +++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/config/types.rs b/src/config/types.rs index 734f933fa..88fd09497 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1539,8 +1539,21 @@ impl Binding { } // DMs are inherently directed at the bot — always pass. - let is_dm = - message.source == "discord" && !message.metadata.contains_key("discord_guild_id"); + let is_dm = match message.source.as_str() { + "discord" => message + .metadata + .get("discord_guild_id") + .and_then(|v| v.as_u64()) + .is_none(), + "telegram" => { + message + .metadata + .get("telegram_chat_type") + .and_then(|v| v.as_str()) + == Some("private") + } + _ => false, + }; if is_dm { return true; } diff --git a/src/messaging/telegram.rs b/src/messaging/telegram.rs index 8dfc2bef1..dc6f762d1 100644 --- a/src/messaging/telegram.rs +++ b/src/messaging/telegram.rs @@ -862,14 +862,24 @@ fn build_metadata( // Matches the pattern used by Discord/Slack/Twitch adapters. let mut mentions_or_replies_to_bot = false; - // Check text-based @mention (Telegram sends mentions as entities) + // Check text-based @mention in message text/caption. + // Uses a word-boundary check so "@spacebot" doesn't match "@spacebot_extra". if let Some(bot_username) = bot_username { let bot_lower = bot_username.to_lowercase(); if let Some(text) = extract_text(message) { let text_lower = text.to_lowercase(); let mention = format!("@{bot_lower}"); - if text_lower.contains(&mention) { - mentions_or_replies_to_bot = true; + // Telegram usernames can contain [a-z0-9_], so ensure the character + // after the mention (if any) is not a valid username character. + if let Some(start) = text_lower.find(&mention) { + let after = start + mention.len(); + let is_boundary = text_lower + .as_bytes() + .get(after) + .is_none_or(|&ch| !ch.is_ascii_alphanumeric() && ch != b'_'); + if is_boundary { + mentions_or_replies_to_bot = true; + } } } }