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:
@@ -7,13 +7,14 @@ use matrix_sdk::{
|
|||||||
event_handler::Ctx,
|
event_handler::Ctx,
|
||||||
room::Room,
|
room::Room,
|
||||||
ruma::{
|
ruma::{
|
||||||
OwnedRoomId, OwnedUserId,
|
OwnedEventId, OwnedRoomId, OwnedUserId,
|
||||||
events::room::message::{
|
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::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -66,6 +67,10 @@ pub struct BotContext {
|
|||||||
pub history: ConversationHistory,
|
pub history: ConversationHistory,
|
||||||
/// Maximum number of entries to keep per room before trimming the oldest.
|
/// Maximum number of entries to keep per room before trimming the oldest.
|
||||||
pub history_size: usize,
|
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,
|
allowed_users: config.allowed_users,
|
||||||
history: Arc::new(TokioMutex::new(HashMap::new())),
|
history: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
history_size: config.history_size,
|
history_size: config.history_size,
|
||||||
|
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register event handler and inject shared context
|
// Register event handler and inject shared context
|
||||||
@@ -171,6 +177,64 @@ pub async fn run_bot(config: BotConfig, project_root: PathBuf) -> Result<(), Str
|
|||||||
Ok(())
|
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
|
// Event handler
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -215,12 +279,29 @@ async fn on_room_message(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only handle plain text messages.
|
// Only handle plain text messages.
|
||||||
let MessageType::Text(text_content) = ev.content.msgtype else {
|
let body = match &ev.content.msgtype {
|
||||||
return;
|
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 sender = ev.sender.to_string();
|
||||||
let user_message = text_content.body.clone();
|
let user_message = body;
|
||||||
slog!("[matrix-bot] Message from {sender}: {user_message}");
|
slog!("[matrix-bot] Message from {sender}: {user_message}");
|
||||||
|
|
||||||
// Spawn a separate task so the Matrix sync loop is not blocked while we
|
// 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
|
// 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.
|
// block the LLM stream while waiting for Matrix send round-trips.
|
||||||
let post_room = room.clone();
|
let post_room = room.clone();
|
||||||
|
let sent_ids = Arc::clone(&ctx.bot_sent_event_ids);
|
||||||
let post_task = tokio::spawn(async move {
|
let post_task = tokio::spawn(async move {
|
||||||
while let Some(chunk) = msg_rx.recv().await {
|
while let Some(chunk) = msg_rx.recv().await {
|
||||||
let html = markdown_to_html(&chunk);
|
let html = markdown_to_html(&chunk);
|
||||||
let _ = post_room
|
if let Ok(response) = post_room
|
||||||
.send(RoomMessageEventContent::text_html(chunk, html))
|
.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 {
|
mod tests {
|
||||||
use super::*;
|
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 ---------------------------------------------------
|
// -- markdown_to_html ---------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user