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
861 lines
27 KiB
Rust
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"));
|
|
}
|
|
}
|