storkit: merge 458_story_matrix_bot_ignores_messages_addressed_to_other_bots_in_ambient_mode
This commit is contained in:
@@ -73,6 +73,72 @@ pub(super) async fn is_reply_to_bot(
|
|||||||
candidate_ids.iter().any(|id| guard.contains(*id))
|
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
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -195,4 +261,92 @@ mod tests {
|
|||||||
|
|
||||||
assert!(is_reply_to_bot(relates_to.as_ref(), &sent).await);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use tokio::sync::watch;
|
|||||||
use super::context::BotContext;
|
use super::context::BotContext;
|
||||||
use super::format::markdown_to_html;
|
use super::format::markdown_to_html;
|
||||||
use super::history::{ConversationEntry, ConversationRole, save_history};
|
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;
|
use super::verification::check_sender_verified;
|
||||||
|
|
||||||
/// Build the user-facing prompt for a single turn. In multi-user rooms the
|
/// 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;
|
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.
|
// Reject commands from unencrypted rooms — E2EE is mandatory.
|
||||||
if !room.encryption_state().is_encrypted() {
|
if !room.encryption_state().is_encrypted() {
|
||||||
slog!(
|
slog!(
|
||||||
|
|||||||
Reference in New Issue
Block a user