story-kit: merge 282_story_matrix_bot_ambient_mode_toggle_via_chat_command
This commit is contained in:
@@ -165,6 +165,10 @@ pub struct BotContext {
|
|||||||
/// The name the bot uses to refer to itself. Derived from `display_name`
|
/// The name the bot uses to refer to itself. Derived from `display_name`
|
||||||
/// in bot.toml; defaults to "Assistant" when unset.
|
/// in bot.toml; defaults to "Assistant" when unset.
|
||||||
pub bot_name: String,
|
pub bot_name: String,
|
||||||
|
/// Set of room IDs where ambient mode is active. In ambient mode the bot
|
||||||
|
/// responds to all messages rather than only addressed ones. This is
|
||||||
|
/// in-memory only — the state does not survive a bot restart.
|
||||||
|
pub ambient_rooms: Arc<TokioMutex<HashSet<OwnedRoomId>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -347,6 +351,7 @@ pub async fn run_bot(
|
|||||||
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
permission_timeout_secs: config.permission_timeout_secs,
|
permission_timeout_secs: config.permission_timeout_secs,
|
||||||
bot_name,
|
bot_name,
|
||||||
|
ambient_rooms: Arc::new(TokioMutex::new(HashSet::new())),
|
||||||
};
|
};
|
||||||
|
|
||||||
slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected");
|
slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected");
|
||||||
@@ -463,6 +468,50 @@ fn contains_word(haystack: &str, needle: &str) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse an ambient-mode toggle command from a message body.
|
||||||
|
///
|
||||||
|
/// Recognises the following (case-insensitive) forms, with or without a
|
||||||
|
/// leading bot mention:
|
||||||
|
///
|
||||||
|
/// - `@botname ambient on` / `@botname:server ambient on`
|
||||||
|
/// - `botname ambient on`
|
||||||
|
/// - `ambient on`
|
||||||
|
///
|
||||||
|
/// and the `off` variants.
|
||||||
|
///
|
||||||
|
/// Returns `Some(true)` for "ambient on", `Some(false)` for "ambient off",
|
||||||
|
/// and `None` when the body is not an ambient mode command.
|
||||||
|
pub fn parse_ambient_command(
|
||||||
|
body: &str,
|
||||||
|
bot_user_id: &OwnedUserId,
|
||||||
|
bot_name: &str,
|
||||||
|
) -> Option<bool> {
|
||||||
|
let lower = body.trim().to_ascii_lowercase();
|
||||||
|
let display_lower = bot_name.to_ascii_lowercase();
|
||||||
|
let localpart_lower = bot_user_id.localpart().to_ascii_lowercase();
|
||||||
|
|
||||||
|
// Strip a leading @mention (handles "@localpart" and "@localpart:server").
|
||||||
|
let rest = if let Some(after_at) = lower.strip_prefix('@') {
|
||||||
|
// Skip everything up to the first whitespace (the full mention token).
|
||||||
|
let word_end = after_at
|
||||||
|
.find(char::is_whitespace)
|
||||||
|
.unwrap_or(after_at.len());
|
||||||
|
after_at[word_end..].trim()
|
||||||
|
} else if let Some(after) = lower.strip_prefix(display_lower.as_str()) {
|
||||||
|
after.trim()
|
||||||
|
} else if let Some(after) = lower.strip_prefix(localpart_lower.as_str()) {
|
||||||
|
after.trim()
|
||||||
|
} else {
|
||||||
|
lower.as_str()
|
||||||
|
};
|
||||||
|
|
||||||
|
match rest {
|
||||||
|
"ambient on" => Some(true),
|
||||||
|
"ambient off" => Some(false),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `true` if the message's `relates_to` field references an event that
|
/// 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
|
/// the bot previously sent (i.e. the message is a reply or thread-reply to a
|
||||||
/// bot message).
|
/// bot message).
|
||||||
@@ -666,11 +715,14 @@ async fn on_room_message(
|
|||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only respond when the bot is directly addressed (mentioned by name/ID)
|
// 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.
|
// when the message is a reply to one of the bot's own messages, or when
|
||||||
if !mentions_bot(&body, formatted_body.as_deref(), &ctx.bot_user_id)
|
// ambient mode is enabled for this room.
|
||||||
&& !is_reply_to_bot(ev.content.relates_to.as_ref(), &ctx.bot_sent_event_ids).await
|
let is_addressed = mentions_bot(&body, formatted_body.as_deref(), &ctx.bot_user_id)
|
||||||
{
|
|| is_reply_to_bot(ev.content.relates_to.as_ref(), &ctx.bot_sent_event_ids).await;
|
||||||
|
let is_ambient = ctx.ambient_rooms.lock().await.contains(&incoming_room_id);
|
||||||
|
|
||||||
|
if !is_addressed && !is_ambient {
|
||||||
slog!(
|
slog!(
|
||||||
"[matrix-bot] Ignoring unaddressed message from {}",
|
"[matrix-bot] Ignoring unaddressed message from {}",
|
||||||
ev.sender
|
ev.sender
|
||||||
@@ -739,6 +791,42 @@ async fn on_room_message(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for ambient mode toggle commands. Commands are only recognised
|
||||||
|
// from addressed messages so they can't be accidentally triggered by
|
||||||
|
// ambient-mode traffic from other users.
|
||||||
|
let ambient_cmd = is_addressed
|
||||||
|
.then(|| parse_ambient_command(&body, &ctx.bot_user_id, &ctx.bot_name))
|
||||||
|
.flatten();
|
||||||
|
if let Some(enable) = ambient_cmd {
|
||||||
|
{
|
||||||
|
let mut ambient = ctx.ambient_rooms.lock().await;
|
||||||
|
if enable {
|
||||||
|
ambient.insert(incoming_room_id.clone());
|
||||||
|
} else {
|
||||||
|
ambient.remove(&incoming_room_id);
|
||||||
|
}
|
||||||
|
} // lock released before the async send below
|
||||||
|
|
||||||
|
let confirmation = if enable {
|
||||||
|
"Ambient mode on. I'll respond to all messages in this room."
|
||||||
|
} else {
|
||||||
|
"Ambient mode off. I'll only respond when mentioned."
|
||||||
|
};
|
||||||
|
let html = markdown_to_html(confirmation);
|
||||||
|
if let Ok(resp) = room
|
||||||
|
.send(RoomMessageEventContent::text_html(confirmation, html))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
ctx.bot_sent_event_ids.lock().await.insert(resp.event_id);
|
||||||
|
}
|
||||||
|
slog!(
|
||||||
|
"[matrix-bot] Ambient mode {} for room {}",
|
||||||
|
if enable { "enabled" } else { "disabled" },
|
||||||
|
incoming_room_id
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let sender = ev.sender.to_string();
|
let sender = ev.sender.to_string();
|
||||||
let user_message = body;
|
let user_message = body;
|
||||||
slog!("[matrix-bot] Message from {sender}: {user_message}");
|
slog!("[matrix-bot] Message from {sender}: {user_message}");
|
||||||
@@ -1251,6 +1339,7 @@ mod tests {
|
|||||||
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
permission_timeout_secs: 120,
|
permission_timeout_secs: 120,
|
||||||
bot_name: "Assistant".to_string(),
|
bot_name: "Assistant".to_string(),
|
||||||
|
ambient_rooms: Arc::new(TokioMutex::new(HashSet::new())),
|
||||||
};
|
};
|
||||||
// Clone must work (required by Matrix SDK event handler injection).
|
// Clone must work (required by Matrix SDK event handler injection).
|
||||||
let _cloned = ctx.clone();
|
let _cloned = ctx.clone();
|
||||||
@@ -1730,4 +1819,97 @@ mod tests {
|
|||||||
assert_eq!(resolve_bot_name(None), "Assistant");
|
assert_eq!(resolve_bot_name(None), "Assistant");
|
||||||
assert_eq!(resolve_bot_name(Some("Timmy".to_string())), "Timmy");
|
assert_eq!(resolve_bot_name(Some("Timmy".to_string())), "Timmy");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- parse_ambient_command ------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ambient_command_on_with_at_mention() {
|
||||||
|
let uid = make_user_id("@timmy:homeserver.local");
|
||||||
|
assert_eq!(parse_ambient_command("@timmy ambient on", &uid, "Timmy"), Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ambient_command_off_with_at_mention() {
|
||||||
|
let uid = make_user_id("@timmy:homeserver.local");
|
||||||
|
assert_eq!(parse_ambient_command("@timmy ambient off", &uid, "Timmy"), Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ambient_command_on_with_full_user_id() {
|
||||||
|
let uid = make_user_id("@timmy:homeserver.local");
|
||||||
|
assert_eq!(
|
||||||
|
parse_ambient_command("@timmy:homeserver.local ambient on", &uid, "Timmy"),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ambient_command_on_with_display_name() {
|
||||||
|
let uid = make_user_id("@timmy:homeserver.local");
|
||||||
|
assert_eq!(parse_ambient_command("timmy ambient on", &uid, "Timmy"), Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ambient_command_off_with_display_name() {
|
||||||
|
let uid = make_user_id("@timmy:homeserver.local");
|
||||||
|
assert_eq!(parse_ambient_command("timmy ambient off", &uid, "Timmy"), Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ambient_command_on_bare() {
|
||||||
|
// "ambient on" without any bot mention is also recognised.
|
||||||
|
let uid = make_user_id("@timmy:homeserver.local");
|
||||||
|
assert_eq!(parse_ambient_command("ambient on", &uid, "Timmy"), Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ambient_command_off_bare() {
|
||||||
|
let uid = make_user_id("@timmy:homeserver.local");
|
||||||
|
assert_eq!(parse_ambient_command("ambient off", &uid, "Timmy"), Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ambient_command_case_insensitive() {
|
||||||
|
let uid = make_user_id("@timmy:homeserver.local");
|
||||||
|
assert_eq!(parse_ambient_command("@Timmy AMBIENT ON", &uid, "Timmy"), Some(true));
|
||||||
|
assert_eq!(parse_ambient_command("TIMMY AMBIENT OFF", &uid, "Timmy"), Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ambient_command_unrelated_message_returns_none() {
|
||||||
|
let uid = make_user_id("@timmy:homeserver.local");
|
||||||
|
assert_eq!(parse_ambient_command("@timmy what is the status?", &uid, "Timmy"), None);
|
||||||
|
assert_eq!(parse_ambient_command("hello there", &uid, "Timmy"), None);
|
||||||
|
assert_eq!(parse_ambient_command("ambient", &uid, "Timmy"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- ambient mode state ---------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ambient_rooms_defaults_to_empty() {
|
||||||
|
let ambient_rooms: Arc<TokioMutex<HashSet<OwnedRoomId>>> =
|
||||||
|
Arc::new(TokioMutex::new(HashSet::new()));
|
||||||
|
let room_id: OwnedRoomId = "!room:example.com".parse().unwrap();
|
||||||
|
assert!(!ambient_rooms.lock().await.contains(&room_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ambient_mode_can_be_toggled_per_room() {
|
||||||
|
let ambient_rooms: Arc<TokioMutex<HashSet<OwnedRoomId>>> =
|
||||||
|
Arc::new(TokioMutex::new(HashSet::new()));
|
||||||
|
let room_a: OwnedRoomId = "!room_a:example.com".parse().unwrap();
|
||||||
|
let room_b: OwnedRoomId = "!room_b:example.com".parse().unwrap();
|
||||||
|
|
||||||
|
// Enable ambient mode for room_a only.
|
||||||
|
ambient_rooms.lock().await.insert(room_a.clone());
|
||||||
|
|
||||||
|
let guard = ambient_rooms.lock().await;
|
||||||
|
assert!(guard.contains(&room_a), "room_a should be in ambient mode");
|
||||||
|
assert!(!guard.contains(&room_b), "room_b should NOT be in ambient mode");
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
// Disable ambient mode for room_a.
|
||||||
|
ambient_rooms.lock().await.remove(&room_a);
|
||||||
|
assert!(!ambient_rooms.lock().await.contains(&room_a), "room_a ambient mode should be off");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user