From ce4a0cb7f94bb4cf271ca88c0386291b0f6143b2 Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 2 Apr 2026 15:47:57 +0000 Subject: [PATCH] storkit: merge 458_story_matrix_bot_ignores_messages_addressed_to_other_bots_in_ambient_mode --- .../src/chat/transport/matrix/bot/mentions.rs | 154 ++++++++++++++++++ .../src/chat/transport/matrix/bot/messages.rs | 15 +- 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/server/src/chat/transport/matrix/bot/mentions.rs b/server/src/chat/transport/matrix/bot/mentions.rs index 6e1a6d05..97e28402 100644 --- a/server/src/chat/transport/matrix/bot/mentions.rs +++ b/server/src/chat/transport/matrix/bot/mentions.rs @@ -73,6 +73,72 @@ pub(super) async fn is_reply_to_bot( candidate_ids.iter().any(|id| guard.contains(*id)) } +/// Returns `true` when the message body appears to be explicitly addressed to +/// someone **other** than this bot. +/// +/// Recognised address patterns at the start of the body: +/// - `"name: rest"` — display-name style (e.g. `"sally: do X"`) +/// - `"@name rest"` — @ mention style (e.g. `"@sally do X"`) +/// +/// A message is only considered addressed to another party when the name does +/// **not** match either the bot's `bot_name` (case-insensitive) or the +/// localpart of its `bot_user_id`. +/// +/// Used in ambient mode to suppress responses when a message is clearly +/// directed at a different participant (e.g. another bot in the same room). +pub fn is_addressed_to_other(body: &str, bot_user_id: &OwnedUserId, bot_name: &str) -> bool { + let trimmed = body.trim_start(); + let lower = trimmed.to_lowercase(); + let bot_name_lower = bot_name.to_lowercase(); + let bot_localpart = bot_user_id.localpart().to_lowercase(); + + // Pattern A: "@name …" at the start of the message. + // Handles both "@localpart" and "@localpart:homeserver" forms. + if let Some(rest) = lower.strip_prefix('@') { + // Extract everything up to the first whitespace character. + let word_end = rest + .find(|c: char| c.is_whitespace()) + .unwrap_or(rest.len()); + let mention = &rest[..word_end]; // e.g. "sally" or "sally:example.com" + + // Strip the homeserver part to get just the localpart. + let localpart = mention.split(':').next().unwrap_or(mention); + + if localpart.is_empty() { + return false; // bare "@" — not an address + } + if localpart == bot_localpart { + return false; // addressed to us + } + return true; // addressed to someone else + } + + // Pattern B: "name: rest" — display-name style. + // Only the text before the *first* colon is inspected. We require that + // the prefix contains no spaces so that ordinary sentences such as + // "Here is a question: …" are not misread as bot addresses. + if let Some(colon_pos) = lower.find(':') { + let prefix = &lower[..colon_pos]; + + // Single-word prefix (no spaces). + if !prefix.contains(' ') && !prefix.is_empty() { + if prefix == bot_name_lower || prefix == bot_localpart { + return false; // addressed to us + } + return true; // addressed to someone else + } + + // Multi-word prefix: only treat as an address if it is an exact + // case-insensitive match for our display name. + if prefix == bot_name_lower { + return false; // addressed to us + } + // Otherwise the colon is part of a regular sentence — not an address. + } + + false +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -195,4 +261,92 @@ mod tests { assert!(is_reply_to_bot(relates_to.as_ref(), &sent).await); } + + // -- is_addressed_to_other ---------------------------------------------- + + #[test] + fn addressed_to_other_display_name_colon() { + // "sally: do X" — addressed to sally, not our bot (stu) + let uid = make_user_id("@stu:homeserver.local"); + assert!(is_addressed_to_other("sally: do X", &uid, "stu")); + } + + #[test] + fn addressed_to_other_at_mention() { + // "@sally do X" — addressed to sally, not our bot (stu) + let uid = make_user_id("@stu:homeserver.local"); + assert!(is_addressed_to_other("@sally do X", &uid, "stu")); + } + + #[test] + fn addressed_to_other_at_mention_full_id() { + // "@sally:homeserver.local do X" — localpart is still "sally" + let uid = make_user_id("@stu:homeserver.local"); + assert!(is_addressed_to_other( + "@sally:homeserver.local do X", + &uid, + "stu" + )); + } + + #[test] + fn not_addressed_to_other_self_display_name() { + // "stu: do X" — addressed to us + let uid = make_user_id("@stu:homeserver.local"); + assert!(!is_addressed_to_other("stu: do X", &uid, "stu")); + } + + #[test] + fn not_addressed_to_other_self_at_mention() { + // "@stu do X" — addressed to us + let uid = make_user_id("@stu:homeserver.local"); + assert!(!is_addressed_to_other("@stu do X", &uid, "stu")); + } + + #[test] + fn not_addressed_to_other_self_at_mention_full_id() { + // "@stu:homeserver.local do X" — addressed to us + let uid = make_user_id("@stu:homeserver.local"); + assert!(!is_addressed_to_other( + "@stu:homeserver.local do X", + &uid, + "stu" + )); + } + + #[test] + fn not_addressed_to_other_no_addressee() { + // No explicit addressee — ambient message for everyone + let uid = make_user_id("@stu:homeserver.local"); + assert!(!is_addressed_to_other( + "what's the status of the pipeline?", + &uid, + "stu" + )); + } + + #[test] + fn not_addressed_to_other_sentence_with_colon() { + // Regular sentence with colon — not an address + let uid = make_user_id("@stu:homeserver.local"); + assert!(!is_addressed_to_other( + "here is the answer: it depends", + &uid, + "stu" + )); + } + + #[test] + fn not_addressed_to_other_display_name_case_insensitive() { + // "STU: do X" — case-insensitive match against our name "stu" + let uid = make_user_id("@stu:homeserver.local"); + assert!(!is_addressed_to_other("STU: do X", &uid, "stu")); + } + + #[test] + fn addressed_to_other_case_insensitive_other_name() { + // "SALLY: do X" — addressed to sally, not us + let uid = make_user_id("@stu:homeserver.local"); + assert!(is_addressed_to_other("SALLY: do X", &uid, "stu")); + } } diff --git a/server/src/chat/transport/matrix/bot/messages.rs b/server/src/chat/transport/matrix/bot/messages.rs index 5192f1f0..d908f4bc 100644 --- a/server/src/chat/transport/matrix/bot/messages.rs +++ b/server/src/chat/transport/matrix/bot/messages.rs @@ -19,7 +19,7 @@ use tokio::sync::watch; use super::context::BotContext; use super::format::markdown_to_html; use super::history::{ConversationEntry, ConversationRole, save_history}; -use super::mentions::{is_reply_to_bot, mentions_bot}; +use super::mentions::{is_addressed_to_other, is_reply_to_bot, mentions_bot}; use super::verification::check_sender_verified; /// Build the user-facing prompt for a single turn. In multi-user rooms the @@ -93,6 +93,19 @@ pub(super) async fn on_room_message( return; } + // In ambient mode, ignore messages that are explicitly addressed to a + // different entity (e.g. "sally: do X" or "@sally do X" when we are stu). + // We still let through messages addressed to us and the "ambient on" command. + if is_ambient && !is_addressed && !is_ambient_on + && is_addressed_to_other(&body, &ctx.bot_user_id, &ctx.bot_name) + { + slog!( + "[matrix-bot] Ignoring ambient message addressed to another bot (sender={})", + ev.sender + ); + return; + } + // Reject commands from unencrypted rooms — E2EE is mandatory. if !room.encryption_state().is_encrypted() { slog!(