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
99 changes: 73 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,57 @@ 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 = 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;
Comment thread
coderabbitai[bot] marked this conversation as 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 +1848,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 @@ -1885,11 +1885,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
42 changes: 42 additions & 0 deletions src/messaging/telegram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -858,7 +858,34 @@ 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 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}");
// 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;
}
}
}
}

// 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 +911,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