Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
86 changes: 60 additions & 26 deletions src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand All @@ -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");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

contains_key("discord_guild_id") treats null as present, so a DM could get misclassified and blocked when require_mention is on. Safer to mirror the old as_u64().is_some() behavior.

Suggested change
message.source == "discord" && !message.metadata.contains_key("discord_guild_id");
let is_dm = message.source == "discord"
&& message
.metadata
.get("discord_guild_id")
.and_then(|v| v.as_u64())
.is_none();

if is_dm {
return true;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

// Each adapter sets a `<platform>_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.
Expand Down Expand Up @@ -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<crate::AgentId> {
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))
}

// ---------------------------------------------------------------------------
Expand Down
7 changes: 5 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
&current_bindings,
&message,
&default_agent_id,
);
) else {
// Message suppressed by require_mention — drop it.
continue;
};
message.agent_id = Some(resolved.clone());
resolved
};
Expand Down
32 changes: 32 additions & 0 deletions src/messaging/telegram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small nit: this comment says mentions come via entities, but the implementation is a case-insensitive substring search on extracted text/caption. Either parse entities, or tweak the comment to match what’s happening.

Suggested change
// Check text-based @mention (Telegram sends mentions as entities)
// Check text-based @mention in message text/caption.

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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}

// 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(),
Expand All @@ -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)
}

Expand Down