From d97866a640cf114e4dbcd877b0f0bd14366c6932 Mon Sep 17 00:00:00 2001 From: Vernon Stinebaker Date: Wed, 18 Feb 2026 19:51:45 +0800 Subject: [PATCH] feat(mattermost): add mention_only config for @-mention filtering Add mention_only support for the Mattermost channel, matching the existing Discord implementation. When enabled, the bot only processes messages that contain an @-mention of the bot username, reducing noise in busy channels. - Add mention_only field to MattermostConfig schema (Option, default false) - Rename get_bot_user_id() to get_bot_identity() returning (user_id, username) - Add contains_bot_mention_mm() with case-insensitive word-boundary matching and metadata.mentions array support - Add normalize_mattermost_content() to strip @-mentions from processed text - Wire mention_only through channel and cron factory constructors - Add 23 new tests covering mention detection, stripping, case-insensitivity, word boundaries, metadata mentions, empty-after-strip, and disabled passthrough --- src/channels/mattermost.rs | 500 ++++++++++++++++++++++++++++++++++--- src/channels/mod.rs | 1 + src/config/schema.rs | 4 + src/cron/scheduler.rs | 1 + 4 files changed, 468 insertions(+), 38 deletions(-) diff --git a/src/channels/mattermost.rs b/src/channels/mattermost.rs index b03f746..b7f84dd 100644 --- a/src/channels/mattermost.rs +++ b/src/channels/mattermost.rs @@ -13,6 +13,8 @@ pub struct MattermostChannel { /// When true (default), replies thread on the original post's root_id. /// When false, replies go to the channel root. thread_replies: bool, + /// When true, only respond to messages that @-mention the bot. + mention_only: bool, client: reqwest::Client, /// Handle for the background typing-indicator loop (aborted on stop_typing). typing_handle: Mutex>>, @@ -25,6 +27,7 @@ impl MattermostChannel { channel_id: Option, allowed_users: Vec, thread_replies: bool, + mention_only: bool, ) -> Self { // Ensure base_url doesn't have a trailing slash for consistent path joining let base_url = base_url.trim_end_matches('/').to_string(); @@ -34,6 +37,7 @@ impl MattermostChannel { channel_id, allowed_users, thread_replies, + mention_only, client: reqwest::Client::new(), typing_handle: Mutex::new(None), } @@ -45,20 +49,35 @@ impl MattermostChannel { self.allowed_users.iter().any(|u| u == "*" || u == user_id) } - /// Get the bot's own user ID so we can ignore our own messages. - async fn get_bot_user_id(&self) -> Option { - let resp: serde_json::Value = self - .client - .get(format!("{}/api/v4/users/me", self.base_url)) - .bearer_auth(&self.bot_token) - .send() - .await - .ok()? - .json() - .await - .ok()?; + /// Get the bot's own user ID and username so we can ignore our own messages + /// and detect @-mentions by username. + async fn get_bot_identity(&self) -> (String, String) { + let resp: Option = async { + self.client + .get(format!("{}/api/v4/users/me", self.base_url)) + .bearer_auth(&self.bot_token) + .send() + .await + .ok()? + .json() + .await + .ok() + } + .await; - resp.get("id").and_then(|u| u.as_str()).map(String::from) + let id = resp + .as_ref() + .and_then(|v| v.get("id")) + .and_then(|u| u.as_str()) + .unwrap_or("") + .to_string(); + let username = resp + .as_ref() + .and_then(|v| v.get("username")) + .and_then(|u| u.as_str()) + .unwrap_or("") + .to_string(); + (id, username) } } @@ -115,7 +134,7 @@ impl Channel for MattermostChannel { .clone() .ok_or_else(|| anyhow::anyhow!("Mattermost channel_id required for listening"))?; - let bot_user_id = self.get_bot_user_id().await.unwrap_or_default(); + let (bot_user_id, bot_username) = self.get_bot_identity().await; #[allow(clippy::cast_possible_truncation)] let mut last_create_at = (std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -159,8 +178,13 @@ impl Channel for MattermostChannel { post_list.sort_by_key(|p| p.get("create_at").and_then(|c| c.as_i64()).unwrap_or(0)); for post in post_list { - let msg = - self.parse_mattermost_post(post, &bot_user_id, last_create_at, &channel_id); + let msg = self.parse_mattermost_post( + post, + &bot_user_id, + &bot_username, + last_create_at, + &channel_id, + ); let create_at = post .get("create_at") .and_then(|c| c.as_i64()) @@ -248,6 +272,7 @@ impl MattermostChannel { &self, post: &serde_json::Value, bot_user_id: &str, + bot_username: &str, last_create_at: i64, channel_id: &str, ) -> Option { @@ -266,6 +291,14 @@ impl MattermostChannel { return None; } + // mention_only filtering: skip messages that don't @-mention the bot. + let content = if self.mention_only { + let normalized = normalize_mattermost_content(text, bot_user_id, bot_username, post); + normalized? + } else { + text.to_string() + }; + // Reply routing depends on thread_replies config: // - Existing thread (root_id set): always stay in the thread. // - Top-level post + thread_replies=true: thread on the original post. @@ -282,7 +315,7 @@ impl MattermostChannel { id: format!("mattermost_{id}"), sender: user_id.to_string(), reply_target, - content: text.to_string(), + content, channel: "mattermost".to_string(), #[allow(clippy::cast_sign_loss)] timestamp: (create_at / 1000) as u64, @@ -290,11 +323,122 @@ impl MattermostChannel { } } +/// Check whether a Mattermost post contains an @-mention of the bot. +/// +/// Checks two sources: +/// 1. Text-based: looks for `@bot_username` in the message body (case-insensitive). +/// 2. Metadata-based: checks the post's `metadata.mentions` array for the bot user ID. +fn contains_bot_mention_mm( + text: &str, + bot_user_id: &str, + bot_username: &str, + post: &serde_json::Value, +) -> bool { + // 1. Text-based: @username (case-insensitive, word-boundary aware) + if !bot_username.is_empty() { + let at_mention = format!("@{}", bot_username); + let text_lower = text.to_lowercase(); + let mention_lower = at_mention.to_lowercase(); + if let Some(pos) = text_lower.find(&mention_lower) { + // Verify it's a word boundary: the char after the mention (if any) must not be + // alphanumeric or underscore (Mattermost usernames are [a-z0-9._-]). + let end = pos + mention_lower.len(); + let at_boundary = end >= text_lower.len() + || !text_lower[end..].chars().next().map_or(false, |c| { + c.is_alphanumeric() || c == '_' || c == '-' || c == '.' + }); + if at_boundary { + return true; + } + } + } + + // 2. Metadata-based: Mattermost may include a "metadata.mentions" array of user IDs. + if !bot_user_id.is_empty() { + if let Some(mentions) = post + .get("metadata") + .and_then(|m| m.get("mentions")) + .and_then(|m| m.as_array()) + { + if mentions.iter().any(|m| m.as_str() == Some(bot_user_id)) { + return true; + } + } + } + + false +} + +/// Normalize incoming Mattermost content when `mention_only` is enabled. +/// +/// Returns `None` if the message doesn't mention the bot. +/// Returns `Some(cleaned)` with the @-mention stripped and text trimmed. +fn normalize_mattermost_content( + text: &str, + bot_user_id: &str, + bot_username: &str, + post: &serde_json::Value, +) -> Option { + if !contains_bot_mention_mm(text, bot_user_id, bot_username, post) { + return None; + } + + // Strip @bot_username from the text (case-insensitive). + let mut cleaned = text.to_string(); + if !bot_username.is_empty() { + let at_mention = format!("@{}", bot_username); + // Case-insensitive replacement: find each occurrence and replace with space. + let lower = cleaned.to_lowercase(); + let mention_lower = at_mention.to_lowercase(); + let mut result = String::with_capacity(cleaned.len()); + let mut search_start = 0; + while let Some(pos) = lower[search_start..].find(&mention_lower) { + let abs_pos = search_start + pos; + result.push_str(&cleaned[search_start..abs_pos]); + result.push(' '); + search_start = abs_pos + at_mention.len(); + } + result.push_str(&cleaned[search_start..]); + cleaned = result; + } + + let cleaned = cleaned.trim().to_string(); + if cleaned.is_empty() { + return None; + } + + Some(cleaned) +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + // Helper: create a channel with mention_only=false (legacy behavior). + fn make_channel(allowed: Vec, thread_replies: bool) -> MattermostChannel { + MattermostChannel::new( + "url".into(), + "token".into(), + None, + allowed, + thread_replies, + false, + ) + } + + // Helper: create a channel with mention_only=true. + fn make_mention_only_channel() -> MattermostChannel { + MattermostChannel::new( + "url".into(), + "token".into(), + None, + vec!["*".into()], + true, + true, + ) + } + #[test] fn mattermost_url_trimming() { let ch = MattermostChannel::new( @@ -303,20 +447,20 @@ mod tests { None, vec![], false, + false, ); assert_eq!(ch.base_url, "https://mm.example.com"); } #[test] fn mattermost_allowlist_wildcard() { - let ch = - MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false); + let ch = make_channel(vec!["*".into()], false); assert!(ch.is_user_allowed("any-id")); } #[test] fn mattermost_parse_post_basic() { - let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], true); + let ch = make_channel(vec!["*".into()], true); let post = json!({ "id": "post123", "user_id": "user456", @@ -326,7 +470,7 @@ mod tests { }); let msg = ch - .parse_mattermost_post(&post, "bot123", 1_500_000_000_000_i64, "chan789") + .parse_mattermost_post(&post, "bot123", "botname", 1_500_000_000_000_i64, "chan789") .unwrap(); assert_eq!(msg.sender, "user456"); assert_eq!(msg.content, "hello world"); @@ -335,7 +479,7 @@ mod tests { #[test] fn mattermost_parse_post_thread_replies_enabled() { - let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], true); + let ch = make_channel(vec!["*".into()], true); let post = json!({ "id": "post123", "user_id": "user456", @@ -345,15 +489,14 @@ mod tests { }); let msg = ch - .parse_mattermost_post(&post, "bot123", 1_500_000_000_000_i64, "chan789") + .parse_mattermost_post(&post, "bot123", "botname", 1_500_000_000_000_i64, "chan789") .unwrap(); assert_eq!(msg.reply_target, "chan789:post123"); // Threaded reply } #[test] fn mattermost_parse_post_thread() { - let ch = - MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false); + let ch = make_channel(vec!["*".into()], false); let post = json!({ "id": "post123", "user_id": "user456", @@ -363,15 +506,14 @@ mod tests { }); let msg = ch - .parse_mattermost_post(&post, "bot123", 1_500_000_000_000_i64, "chan789") + .parse_mattermost_post(&post, "bot123", "botname", 1_500_000_000_000_i64, "chan789") .unwrap(); assert_eq!(msg.reply_target, "chan789:root789"); // Stays in the thread } #[test] fn mattermost_parse_post_ignore_self() { - let ch = - MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false); + let ch = make_channel(vec!["*".into()], false); let post = json!({ "id": "post123", "user_id": "bot123", @@ -379,14 +521,14 @@ mod tests { "create_at": 1_600_000_000_000_i64 }); - let msg = ch.parse_mattermost_post(&post, "bot123", 1_500_000_000_000_i64, "chan789"); + let msg = + ch.parse_mattermost_post(&post, "bot123", "botname", 1_500_000_000_000_i64, "chan789"); assert!(msg.is_none()); } #[test] fn mattermost_parse_post_ignore_old() { - let ch = - MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false); + let ch = make_channel(vec!["*".into()], false); let post = json!({ "id": "post123", "user_id": "user456", @@ -394,14 +536,14 @@ mod tests { "create_at": 1_400_000_000_000_i64 }); - let msg = ch.parse_mattermost_post(&post, "bot123", 1_500_000_000_000_i64, "chan789"); + let msg = + ch.parse_mattermost_post(&post, "bot123", "botname", 1_500_000_000_000_i64, "chan789"); assert!(msg.is_none()); } #[test] fn mattermost_parse_post_no_thread_when_disabled() { - let ch = - MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false); + let ch = make_channel(vec!["*".into()], false); let post = json!({ "id": "post123", "user_id": "user456", @@ -411,7 +553,7 @@ mod tests { }); let msg = ch - .parse_mattermost_post(&post, "bot123", 1_500_000_000_000_i64, "chan789") + .parse_mattermost_post(&post, "bot123", "botname", 1_500_000_000_000_i64, "chan789") .unwrap(); assert_eq!(msg.reply_target, "chan789"); // No thread suffix } @@ -419,8 +561,7 @@ mod tests { #[test] fn mattermost_existing_thread_always_threads() { // Even with thread_replies=false, replies to existing threads stay in the thread - let ch = - MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false); + let ch = make_channel(vec!["*".into()], false); let post = json!({ "id": "post123", "user_id": "user456", @@ -430,8 +571,291 @@ mod tests { }); let msg = ch - .parse_mattermost_post(&post, "bot123", 1_500_000_000_000_i64, "chan789") + .parse_mattermost_post(&post, "bot123", "botname", 1_500_000_000_000_i64, "chan789") .unwrap(); assert_eq!(msg.reply_target, "chan789:root789"); // Stays in existing thread } + + // ── mention_only tests ──────────────────────────────────────── + + #[test] + fn mention_only_skips_message_without_mention() { + let ch = make_mention_only_channel(); + let post = json!({ + "id": "post1", + "user_id": "user1", + "message": "hello everyone", + "create_at": 1_600_000_000_000_i64, + "root_id": "" + }); + + let msg = + ch.parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1"); + assert!(msg.is_none()); + } + + #[test] + fn mention_only_accepts_message_with_at_mention() { + let ch = make_mention_only_channel(); + let post = json!({ + "id": "post1", + "user_id": "user1", + "message": "@mybot what is the weather?", + "create_at": 1_600_000_000_000_i64, + "root_id": "" + }); + + let msg = ch + .parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1") + .unwrap(); + assert_eq!(msg.content, "what is the weather?"); + } + + #[test] + fn mention_only_strips_mention_and_trims() { + let ch = make_mention_only_channel(); + let post = json!({ + "id": "post1", + "user_id": "user1", + "message": " @mybot run status ", + "create_at": 1_600_000_000_000_i64, + "root_id": "" + }); + + let msg = ch + .parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1") + .unwrap(); + assert_eq!(msg.content, "run status"); + } + + #[test] + fn mention_only_rejects_empty_after_stripping() { + let ch = make_mention_only_channel(); + let post = json!({ + "id": "post1", + "user_id": "user1", + "message": "@mybot", + "create_at": 1_600_000_000_000_i64, + "root_id": "" + }); + + let msg = + ch.parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1"); + assert!(msg.is_none()); + } + + #[test] + fn mention_only_case_insensitive() { + let ch = make_mention_only_channel(); + let post = json!({ + "id": "post1", + "user_id": "user1", + "message": "@MyBot hello", + "create_at": 1_600_000_000_000_i64, + "root_id": "" + }); + + let msg = ch + .parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1") + .unwrap(); + assert_eq!(msg.content, "hello"); + } + + #[test] + fn mention_only_detects_metadata_mentions() { + // Even without @username in text, metadata.mentions should trigger. + let ch = make_mention_only_channel(); + let post = json!({ + "id": "post1", + "user_id": "user1", + "message": "hey check this out", + "create_at": 1_600_000_000_000_i64, + "root_id": "", + "metadata": { + "mentions": ["bot123"] + } + }); + + let msg = ch + .parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1") + .unwrap(); + // Content is preserved as-is since no @username was in the text to strip. + assert_eq!(msg.content, "hey check this out"); + } + + #[test] + fn mention_only_word_boundary_prevents_partial_match() { + let ch = make_mention_only_channel(); + // "@mybotextended" should NOT match "@mybot" because it extends the username. + let post = json!({ + "id": "post1", + "user_id": "user1", + "message": "@mybotextended hello", + "create_at": 1_600_000_000_000_i64, + "root_id": "" + }); + + let msg = + ch.parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1"); + assert!(msg.is_none()); + } + + #[test] + fn mention_only_mention_in_middle_of_text() { + let ch = make_mention_only_channel(); + let post = json!({ + "id": "post1", + "user_id": "user1", + "message": "hey @mybot how are you?", + "create_at": 1_600_000_000_000_i64, + "root_id": "" + }); + + let msg = ch + .parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1") + .unwrap(); + assert_eq!(msg.content, "hey how are you?"); + } + + #[test] + fn mention_only_disabled_passes_all_messages() { + // With mention_only=false (default), messages pass through unfiltered. + let ch = make_channel(vec!["*".into()], true); + let post = json!({ + "id": "post1", + "user_id": "user1", + "message": "no mention here", + "create_at": 1_600_000_000_000_i64, + "root_id": "" + }); + + let msg = ch + .parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1") + .unwrap(); + assert_eq!(msg.content, "no mention here"); + } + + // ── contains_bot_mention_mm unit tests ──────────────────────── + + #[test] + fn contains_mention_text_at_end() { + let post = json!({}); + assert!(contains_bot_mention_mm( + "hello @mybot", + "bot123", + "mybot", + &post + )); + } + + #[test] + fn contains_mention_text_at_start() { + let post = json!({}); + assert!(contains_bot_mention_mm( + "@mybot hello", + "bot123", + "mybot", + &post + )); + } + + #[test] + fn contains_mention_text_alone() { + let post = json!({}); + assert!(contains_bot_mention_mm("@mybot", "bot123", "mybot", &post)); + } + + #[test] + fn no_mention_different_username() { + let post = json!({}); + assert!(!contains_bot_mention_mm( + "@otherbot hello", + "bot123", + "mybot", + &post + )); + } + + #[test] + fn no_mention_partial_username() { + let post = json!({}); + // "mybot" is a prefix of "mybotx" — should NOT match + assert!(!contains_bot_mention_mm( + "@mybotx hello", + "bot123", + "mybot", + &post + )); + } + + #[test] + fn mention_followed_by_punctuation() { + let post = json!({}); + // "@mybot," — comma is not alphanumeric/underscore/dash/dot, so it's a boundary + assert!(contains_bot_mention_mm( + "@mybot, hello", + "bot123", + "mybot", + &post + )); + } + + #[test] + fn mention_via_metadata_only() { + let post = json!({ + "metadata": { "mentions": ["bot123"] } + }); + assert!(contains_bot_mention_mm( + "no at mention", + "bot123", + "mybot", + &post + )); + } + + #[test] + fn no_mention_empty_username_no_metadata() { + let post = json!({}); + assert!(!contains_bot_mention_mm("hello world", "bot123", "", &post)); + } + + // ── normalize_mattermost_content unit tests ─────────────────── + + #[test] + fn normalize_strips_and_trims() { + let post = json!({}); + let result = normalize_mattermost_content(" @mybot do stuff ", "bot123", "mybot", &post); + assert_eq!(result.as_deref(), Some("do stuff")); + } + + #[test] + fn normalize_returns_none_for_no_mention() { + let post = json!({}); + let result = normalize_mattermost_content("hello world", "bot123", "mybot", &post); + assert!(result.is_none()); + } + + #[test] + fn normalize_returns_none_when_only_mention() { + let post = json!({}); + let result = normalize_mattermost_content("@mybot", "bot123", "mybot", &post); + assert!(result.is_none()); + } + + #[test] + fn normalize_preserves_text_for_metadata_mention() { + let post = json!({ + "metadata": { "mentions": ["bot123"] } + }); + let result = normalize_mattermost_content("check this out", "bot123", "mybot", &post); + assert_eq!(result.as_deref(), Some("check this out")); + } + + #[test] + fn normalize_strips_multiple_mentions() { + let post = json!({}); + let result = + normalize_mattermost_content("@mybot hello @mybot world", "bot123", "mybot", &post); + assert_eq!(result.as_deref(), Some("hello world")); + } } diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 91b59ca..749d624 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -1301,6 +1301,7 @@ pub async fn start_channels(config: Config) -> Result<()> { mm.channel_id.clone(), mm.allowed_users.clone(), mm.thread_replies.unwrap_or(true), + mm.mention_only.unwrap_or(false), ))); } diff --git a/src/config/schema.rs b/src/config/schema.rs index 778aa0b..fae92a6 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -1573,6 +1573,10 @@ pub struct MattermostConfig { /// When false, replies go to the channel root. #[serde(default)] pub thread_replies: Option, + /// When true, only respond to messages that @-mention the bot. + /// Other messages in the channel are silently ignored. + #[serde(default)] + pub mention_only: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index 783bdf7..ce9c6c3 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -306,6 +306,7 @@ async fn deliver_if_configured(config: &Config, job: &CronJob, output: &str) -> mm.channel_id.clone(), mm.allowed_users.clone(), mm.thread_replies.unwrap_or(true), + mm.mention_only.unwrap_or(false), ); channel.send(&SendMessage::new(output, target)).await?; }