use super::traits::{Channel, ChannelMessage, SendMessage}; use anyhow::{bail, Result}; use async_trait::async_trait; use parking_lot::Mutex; /// Mattermost channel — polls channel posts via REST API v4. /// Mattermost is API-compatible with many Slack patterns but uses a dedicated v4 structure. pub struct MattermostChannel { base_url: String, // e.g., https://mm.example.com bot_token: String, channel_id: Option, allowed_users: Vec, /// 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>>, } impl MattermostChannel { pub fn new( base_url: String, bot_token: String, 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(); Self { base_url, bot_token, channel_id, allowed_users, thread_replies, mention_only, client: reqwest::Client::new(), typing_handle: Mutex::new(None), } } /// Check if a user ID is in the allowlist. /// Empty list means deny everyone. "*" means allow everyone. fn is_user_allowed(&self, user_id: &str) -> bool { self.allowed_users.iter().any(|u| u == "*" || u == user_id) } /// 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; 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) } } #[async_trait] impl Channel for MattermostChannel { fn name(&self) -> &str { "mattermost" } async fn send(&self, message: &SendMessage) -> Result<()> { // Mattermost supports threading via 'root_id'. // We pack 'channel_id:root_id' into recipient if it's a thread. let (channel_id, root_id) = if let Some((c, r)) = message.recipient.split_once(':') { (c, Some(r)) } else { (message.recipient.as_str(), None) }; let mut body_map = serde_json::json!({ "channel_id": channel_id, "message": message.content }); if let Some(root) = root_id { body_map.as_object_mut().unwrap().insert( "root_id".to_string(), serde_json::Value::String(root.to_string()), ); } let resp = self .client .post(format!("{}/api/v4/posts", self.base_url)) .bearer_auth(&self.bot_token) .json(&body_map) .send() .await?; let status = resp.status(); if !status.is_success() { let body = resp .text() .await .unwrap_or_else(|e| format!("")); bail!("Mattermost post failed ({status}): {body}"); } Ok(()) } async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> Result<()> { let channel_id = self .channel_id .clone() .ok_or_else(|| anyhow::anyhow!("Mattermost channel_id required for listening"))?; 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) .unwrap_or_default() .as_millis()) as i64; tracing::info!("Mattermost channel listening on {}...", channel_id); loop { tokio::time::sleep(std::time::Duration::from_secs(3)).await; let resp = match self .client .get(format!( "{}/api/v4/channels/{}/posts", self.base_url, channel_id )) .bearer_auth(&self.bot_token) .query(&[("since", last_create_at.to_string())]) .send() .await { Ok(r) => r, Err(e) => { tracing::warn!("Mattermost poll error: {e}"); continue; } }; let data: serde_json::Value = match resp.json().await { Ok(d) => d, Err(e) => { tracing::warn!("Mattermost parse error: {e}"); continue; } }; if let Some(posts) = data.get("posts").and_then(|p| p.as_object()) { // Process in chronological order let mut post_list: Vec<_> = posts.values().collect(); 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, &bot_username, last_create_at, &channel_id, ); let create_at = post .get("create_at") .and_then(|c| c.as_i64()) .unwrap_or(last_create_at); last_create_at = last_create_at.max(create_at); if let Some(channel_msg) = msg { if tx.send(channel_msg).await.is_err() { return Ok(()); } } } } } } async fn health_check(&self) -> bool { self.client .get(format!("{}/api/v4/users/me", self.base_url)) .bearer_auth(&self.bot_token) .send() .await .map(|r| r.status().is_success()) .unwrap_or(false) } async fn start_typing(&self, recipient: &str) -> Result<()> { // Cancel any existing typing loop before starting a new one. self.stop_typing(recipient).await?; let client = self.client.clone(); let token = self.bot_token.clone(); let base_url = self.base_url.clone(); // recipient is "channel_id" or "channel_id:root_id" let (channel_id, parent_id) = match recipient.split_once(':') { Some((channel, parent)) => (channel.to_string(), Some(parent.to_string())), None => (recipient.to_string(), None), }; let handle = tokio::spawn(async move { let url = format!("{base_url}/api/v4/users/me/typing"); loop { let mut body = serde_json::json!({ "channel_id": channel_id }); if let Some(ref pid) = parent_id { body.as_object_mut() .unwrap() .insert("parent_id".to_string(), serde_json::json!(pid)); } if let Ok(r) = client .post(&url) .bearer_auth(&token) .json(&body) .send() .await { if !r.status().is_success() { tracing::debug!(status = %r.status(), "Mattermost typing indicator failed"); } } // Mattermost typing events expire after ~6s; re-fire every 4s. tokio::time::sleep(std::time::Duration::from_secs(4)).await; } }); let mut guard = self.typing_handle.lock(); *guard = Some(handle); Ok(()) } async fn stop_typing(&self, _recipient: &str) -> Result<()> { let mut guard = self.typing_handle.lock(); if let Some(handle) = guard.take() { handle.abort(); } Ok(()) } } impl MattermostChannel { fn parse_mattermost_post( &self, post: &serde_json::Value, bot_user_id: &str, bot_username: &str, last_create_at: i64, channel_id: &str, ) -> Option { let id = post.get("id").and_then(|i| i.as_str()).unwrap_or(""); let user_id = post.get("user_id").and_then(|u| u.as_str()).unwrap_or(""); let text = post.get("message").and_then(|m| m.as_str()).unwrap_or(""); let create_at = post.get("create_at").and_then(|c| c.as_i64()).unwrap_or(0); let root_id = post.get("root_id").and_then(|r| r.as_str()).unwrap_or(""); if user_id == bot_user_id || create_at <= last_create_at || text.is_empty() { return None; } if !self.is_user_allowed(user_id) { tracing::warn!("Mattermost: ignoring message from unauthorized user: {user_id}"); 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. // - Top-level post + thread_replies=false: reply at channel level. let reply_target = if !root_id.is_empty() { format!("{}:{}", channel_id, root_id) } else if self.thread_replies { format!("{}:{}", channel_id, id) } else { channel_id.to_string() }; Some(ChannelMessage { id: format!("mattermost_{id}"), sender: user_id.to_string(), reply_target, content, channel: "mattermost".to_string(), #[allow(clippy::cast_sign_loss)] timestamp: (create_at / 1000) as u64, }) } } /// 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( "https://mm.example.com/".into(), "token".into(), None, vec![], false, false, ); assert_eq!(ch.base_url, "https://mm.example.com"); } #[test] fn mattermost_allowlist_wildcard() { let ch = make_channel(vec!["*".into()], false); assert!(ch.is_user_allowed("any-id")); } #[test] fn mattermost_parse_post_basic() { let ch = make_channel(vec!["*".into()], true); let post = json!({ "id": "post123", "user_id": "user456", "message": "hello world", "create_at": 1_600_000_000_000_i64, "root_id": "" }); let msg = ch .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"); assert_eq!(msg.reply_target, "chan789:post123"); // Default threaded reply } #[test] fn mattermost_parse_post_thread_replies_enabled() { let ch = make_channel(vec!["*".into()], true); let post = json!({ "id": "post123", "user_id": "user456", "message": "hello world", "create_at": 1_600_000_000_000_i64, "root_id": "" }); let msg = ch .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 = make_channel(vec!["*".into()], false); let post = json!({ "id": "post123", "user_id": "user456", "message": "reply", "create_at": 1_600_000_000_000_i64, "root_id": "root789" }); let msg = ch .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 = make_channel(vec!["*".into()], false); let post = json!({ "id": "post123", "user_id": "bot123", "message": "my own message", "create_at": 1_600_000_000_000_i64 }); 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 = make_channel(vec!["*".into()], false); let post = json!({ "id": "post123", "user_id": "user456", "message": "old message", "create_at": 1_400_000_000_000_i64 }); 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 = make_channel(vec!["*".into()], false); let post = json!({ "id": "post123", "user_id": "user456", "message": "hello world", "create_at": 1_600_000_000_000_i64, "root_id": "" }); let msg = ch .parse_mattermost_post(&post, "bot123", "botname", 1_500_000_000_000_i64, "chan789") .unwrap(); assert_eq!(msg.reply_target, "chan789"); // No thread suffix } #[test] fn mattermost_existing_thread_always_threads() { // Even with thread_replies=false, replies to existing threads stay in the thread let ch = make_channel(vec!["*".into()], false); let post = json!({ "id": "post123", "user_id": "user456", "message": "reply in thread", "create_at": 1_600_000_000_000_i64, "root_id": "root789" }); let msg = ch .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")); } }