feat(matrix): bot only responds when directly addressed

Story 191: Matrix bot should only respond when directly addressed.
The bot now checks if it was mentioned by name or replied to before
responding, preventing it from answering every message in a room.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-25 16:33:30 +00:00
parent 10d3c22e76
commit ae4fb3ae2c

View File

@@ -7,13 +7,14 @@ use matrix_sdk::{
event_handler::Ctx,
room::Room,
ruma::{
OwnedRoomId, OwnedUserId,
OwnedEventId, OwnedRoomId, OwnedUserId,
events::room::message::{
MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent,
MessageType, OriginalSyncRoomMessageEvent, Relation,
RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
},
},
};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -66,6 +67,10 @@ pub struct BotContext {
pub history: ConversationHistory,
/// Maximum number of entries to keep per room before trimming the oldest.
pub history_size: usize,
/// Event IDs of messages the bot has sent. Used to detect replies to the
/// bot so it can continue a conversation thread without requiring an
/// explicit `@mention` on every follow-up.
pub bot_sent_event_ids: Arc<TokioMutex<HashSet<OwnedEventId>>>,
}
// ---------------------------------------------------------------------------
@@ -154,6 +159,7 @@ pub async fn run_bot(config: BotConfig, project_root: PathBuf) -> Result<(), Str
allowed_users: config.allowed_users,
history: Arc::new(TokioMutex::new(HashMap::new())),
history_size: config.history_size,
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
};
// Register event handler and inject shared context
@@ -171,6 +177,64 @@ pub async fn run_bot(config: BotConfig, project_root: PathBuf) -> Result<(), Str
Ok(())
}
// ---------------------------------------------------------------------------
// Address-filtering helpers
// ---------------------------------------------------------------------------
/// Returns `true` if `body` contains a mention of the bot.
///
/// Two forms are recognised:
/// - The bot's full Matrix user ID (e.g. `@timmy:homeserver.local`)
/// - The bot's local part prefixed with `@` (e.g. `@timmy`)
///
/// A short mention (`@timmy`) is only counted when it is not immediately
/// followed by an alphanumeric character, hyphen, or underscore — this avoids
/// false-positives where a longer username (e.g. `@timmybot`) shares the same
/// prefix.
pub fn mentions_bot(body: &str, bot_user_id: &OwnedUserId) -> bool {
let full_id = bot_user_id.as_str();
if body.contains(full_id) {
return true;
}
let short = format!("@{}", bot_user_id.localpart());
let mut start = 0;
while let Some(rel) = body[start..].find(short.as_str()) {
let abs = start + rel;
let after = abs + short.len();
let next = body[after..].chars().next();
let is_word_end =
next.is_none_or(|c| !c.is_alphanumeric() && c != '-' && c != '_');
if is_word_end {
return true;
}
start = abs + 1;
}
false
}
/// Returns `true` if the message's `relates_to` field references an event that
/// the bot previously sent (i.e. the message is a reply or thread-reply to a
/// bot message).
async fn is_reply_to_bot(
relates_to: Option<&Relation<RoomMessageEventContentWithoutRelation>>,
bot_sent_event_ids: &TokioMutex<HashSet<OwnedEventId>>,
) -> bool {
let candidate_ids: Vec<&OwnedEventId> = match relates_to {
Some(Relation::Reply { in_reply_to }) => vec![&in_reply_to.event_id],
Some(Relation::Thread(thread)) => {
let mut ids = vec![&thread.event_id];
if let Some(irti) = &thread.in_reply_to {
ids.push(&irti.event_id);
}
ids
}
_ => return false,
};
let guard = bot_sent_event_ids.lock().await;
candidate_ids.iter().any(|id| guard.contains(*id))
}
// ---------------------------------------------------------------------------
// Event handler
// ---------------------------------------------------------------------------
@@ -215,12 +279,29 @@ async fn on_room_message(
}
// Only handle plain text messages.
let MessageType::Text(text_content) = ev.content.msgtype else {
return;
let body = match &ev.content.msgtype {
MessageType::Text(t) => t.body.clone(),
_ => return,
};
// Only respond when the bot is directly addressed (mentioned by name/ID)
// or when the message is a reply to one of the bot's own messages.
if !mentions_bot(&body, &ctx.bot_user_id)
&& !is_reply_to_bot(
ev.content.relates_to.as_ref(),
&ctx.bot_sent_event_ids,
)
.await
{
slog!(
"[matrix-bot] Ignoring unaddressed message from {}",
ev.sender
);
return;
}
let sender = ev.sender.to_string();
let user_message = text_content.body.clone();
let user_message = body;
slog!("[matrix-bot] Message from {sender}: {user_message}");
// Spawn a separate task so the Matrix sync loop is not blocked while we
@@ -292,12 +373,16 @@ async fn handle_message(
// Spawn a task to post messages to Matrix as they arrive so we don't
// block the LLM stream while waiting for Matrix send round-trips.
let post_room = room.clone();
let sent_ids = Arc::clone(&ctx.bot_sent_event_ids);
let post_task = tokio::spawn(async move {
while let Some(chunk) = msg_rx.recv().await {
let html = markdown_to_html(&chunk);
let _ = post_room
if let Ok(response) = post_room
.send(RoomMessageEventContent::text_html(chunk, html))
.await;
.await
{
sent_ids.lock().await.insert(response.event_id);
}
}
});
@@ -439,6 +524,112 @@ pub fn drain_complete_paragraphs(buffer: &mut String) -> Vec<String> {
mod tests {
use super::*;
// -- mentions_bot -------------------------------------------------------
fn make_user_id(s: &str) -> OwnedUserId {
s.parse().unwrap()
}
#[test]
fn mentions_bot_by_full_id() {
let uid = make_user_id("@timmy:homeserver.local");
assert!(mentions_bot("hello @timmy:homeserver.local can you help?", &uid));
}
#[test]
fn mentions_bot_by_localpart_at_start() {
let uid = make_user_id("@timmy:homeserver.local");
assert!(mentions_bot("@timmy please list open stories", &uid));
}
#[test]
fn mentions_bot_by_localpart_mid_sentence() {
let uid = make_user_id("@timmy:homeserver.local");
assert!(mentions_bot("hey @timmy what's the status?", &uid));
}
#[test]
fn mentions_bot_not_mentioned() {
let uid = make_user_id("@timmy:homeserver.local");
assert!(!mentions_bot("can someone help me with this PR?", &uid));
}
#[test]
fn mentions_bot_no_false_positive_longer_username() {
// "@timmybot" must NOT match "@timmy"
let uid = make_user_id("@timmy:homeserver.local");
assert!(!mentions_bot("hey @timmybot can you help?", &uid));
}
#[test]
fn mentions_bot_at_end_of_string() {
let uid = make_user_id("@timmy:homeserver.local");
assert!(mentions_bot("shoutout to @timmy", &uid));
}
#[test]
fn mentions_bot_followed_by_comma() {
let uid = make_user_id("@timmy:homeserver.local");
assert!(mentions_bot("@timmy, can you help?", &uid));
}
// -- is_reply_to_bot ----------------------------------------------------
#[tokio::test]
async fn is_reply_to_bot_direct_reply_match() {
let sent: Arc<TokioMutex<HashSet<OwnedEventId>>> =
Arc::new(TokioMutex::new(HashSet::new()));
let event_id: OwnedEventId = "$abc123:example.com".parse().unwrap();
sent.lock().await.insert(event_id.clone());
let in_reply_to = matrix_sdk::ruma::events::relation::InReplyTo::new(event_id);
let relates_to: Option<Relation<RoomMessageEventContentWithoutRelation>> =
Some(Relation::Reply { in_reply_to });
assert!(is_reply_to_bot(relates_to.as_ref(), &sent).await);
}
#[tokio::test]
async fn is_reply_to_bot_direct_reply_no_match() {
let sent: Arc<TokioMutex<HashSet<OwnedEventId>>> =
Arc::new(TokioMutex::new(HashSet::new()));
// sent is empty — this event was not sent by the bot
let in_reply_to = matrix_sdk::ruma::events::relation::InReplyTo::new(
"$other:example.com".parse::<OwnedEventId>().unwrap(),
);
let relates_to: Option<Relation<RoomMessageEventContentWithoutRelation>> =
Some(Relation::Reply { in_reply_to });
assert!(!is_reply_to_bot(relates_to.as_ref(), &sent).await);
}
#[tokio::test]
async fn is_reply_to_bot_no_relation() {
let sent: Arc<TokioMutex<HashSet<OwnedEventId>>> =
Arc::new(TokioMutex::new(HashSet::new()));
let relates_to: Option<Relation<RoomMessageEventContentWithoutRelation>> = None;
assert!(!is_reply_to_bot(relates_to.as_ref(), &sent).await);
}
#[tokio::test]
async fn is_reply_to_bot_thread_root_match() {
let sent: Arc<TokioMutex<HashSet<OwnedEventId>>> =
Arc::new(TokioMutex::new(HashSet::new()));
let root_id: OwnedEventId = "$root123:example.com".parse().unwrap();
sent.lock().await.insert(root_id.clone());
// Thread reply where the thread root is the bot's message
let thread = matrix_sdk::ruma::events::relation::Thread::plain(
root_id,
"$latest:example.com".parse::<OwnedEventId>().unwrap(),
);
let relates_to: Option<Relation<RoomMessageEventContentWithoutRelation>> =
Some(Relation::Thread(thread));
assert!(is_reply_to_bot(relates_to.as_ref(), &sent).await);
}
// -- markdown_to_html ---------------------------------------------------
#[test]