zeroclaw/src/channels/mattermost.rs
Vernon Stinebaker d97866a640 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<bool>, 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
2026-02-18 21:25:28 +08:00

861 lines
27 KiB
Rust

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<String>,
allowed_users: Vec<String>,
/// 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<Option<tokio::task::JoinHandle<()>>>,
}
impl MattermostChannel {
pub fn new(
base_url: String,
bot_token: String,
channel_id: Option<String>,
allowed_users: Vec<String>,
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<serde_json::Value> = 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!("<failed to read response: {e}>"));
bail!("Mattermost post failed ({status}): {body}");
}
Ok(())
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> 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<ChannelMessage> {
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<String> {
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<String>, 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"));
}
}