feat(mattermost): add thread_replies config and typing indicator

Add two Mattermost channel enhancements:

1. thread_replies config option (default: false)
   - When false, replies go to the channel root instead of threading.
   - When true, replies thread on the original post.
   - Existing thread replies always stay in-thread regardless of setting.

2. Typing indicator (start_typing/stop_typing)
   - Implements the Channel trait's typing methods for Mattermost.
   - Fires POST /api/v4/users/me/typing every 4s in a background task.
   - Supports parent_id for threaded typing indicators.
   - Aborts cleanly on stop_typing via JoinHandle.

Updated all MattermostChannel::new call sites (start_channels, scheduler)
and added 9 unit tests covering thread routing and edge cases.
This commit is contained in:
Vernon Stinebaker 2026-02-18 16:51:15 +08:00 committed by Chummy
parent 41c3e62dad
commit 58120b1c69
4 changed files with 143 additions and 13 deletions

View file

@ -9,7 +9,12 @@ pub struct MattermostChannel {
bot_token: String, bot_token: String,
channel_id: Option<String>, channel_id: Option<String>,
allowed_users: Vec<String>, allowed_users: Vec<String>,
/// When true, replies thread on the original post's root_id.
/// When false (default), replies go to the channel root.
thread_replies: bool,
client: reqwest::Client, client: reqwest::Client,
/// Handle for the background typing-indicator loop (aborted on stop_typing).
typing_handle: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
} }
impl MattermostChannel { impl MattermostChannel {
@ -18,6 +23,7 @@ impl MattermostChannel {
bot_token: String, bot_token: String,
channel_id: Option<String>, channel_id: Option<String>,
allowed_users: Vec<String>, allowed_users: Vec<String>,
thread_replies: bool,
) -> Self { ) -> Self {
// Ensure base_url doesn't have a trailing slash for consistent path joining // Ensure base_url doesn't have a trailing slash for consistent path joining
let base_url = base_url.trim_end_matches('/').to_string(); let base_url = base_url.trim_end_matches('/').to_string();
@ -26,7 +32,9 @@ impl MattermostChannel {
bot_token, bot_token,
channel_id, channel_id,
allowed_users, allowed_users,
thread_replies,
client: reqwest::Client::new(), client: reqwest::Client::new(),
typing_handle: std::sync::Mutex::new(None),
} }
} }
@ -177,6 +185,61 @@ impl Channel for MattermostChannel {
.map(|r| r.status().is_success()) .map(|r| r.status().is_success())
.unwrap_or(false) .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 = recipient.split(':').next().unwrap_or(recipient).to_string();
let parent_id = recipient.split(':').nth(1).map(String::from);
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;
}
});
if let Ok(mut guard) = self.typing_handle.lock() {
*guard = Some(handle);
}
Ok(())
}
async fn stop_typing(&self, _recipient: &str) -> Result<()> {
if let Ok(mut guard) = self.typing_handle.lock() {
if let Some(handle) = guard.take() {
handle.abort();
}
}
Ok(())
}
} }
impl MattermostChannel { impl MattermostChannel {
@ -202,15 +265,16 @@ impl MattermostChannel {
return None; return None;
} }
// If it's a thread, include root_id in reply_to so we reply in the same thread // Reply routing depends on thread_replies config:
let reply_target = if root_id.is_empty() { // - Existing thread (root_id set): always stay in the thread.
// Or if it's a top-level message that WE want to start a thread on, // - Top-level post + thread_replies=true: thread on the original post.
// the next reply will use THIS post's ID as root_id. // - Top-level post + thread_replies=false: reply at channel level.
// But for now, we follow Mattermost's 'reply' convention where let reply_target = if !root_id.is_empty() {
// replying to a post uses its ID as root_id. format!("{}:{}", channel_id, root_id)
} else if self.thread_replies {
format!("{}:{}", channel_id, id) format!("{}:{}", channel_id, id)
} else { } else {
format!("{}:{}", channel_id, root_id) channel_id.to_string()
}; };
Some(ChannelMessage { Some(ChannelMessage {
@ -237,19 +301,22 @@ mod tests {
"token".into(), "token".into(),
None, None,
vec![], vec![],
false,
); );
assert_eq!(ch.base_url, "https://mm.example.com"); assert_eq!(ch.base_url, "https://mm.example.com");
} }
#[test] #[test]
fn mattermost_allowlist_wildcard() { fn mattermost_allowlist_wildcard() {
let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()]); let ch =
MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false);
assert!(ch.is_user_allowed("any-id")); assert!(ch.is_user_allowed("any-id"));
} }
#[test] #[test]
fn mattermost_parse_post_basic() { fn mattermost_parse_post_basic() {
let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()]); let ch =
MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false);
let post = json!({ let post = json!({
"id": "post123", "id": "post123",
"user_id": "user456", "user_id": "user456",
@ -263,12 +330,30 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(msg.sender, "user456"); assert_eq!(msg.sender, "user456");
assert_eq!(msg.content, "hello world"); assert_eq!(msg.content, "hello world");
assert_eq!(msg.reply_target, "chan789:post123"); // Threads on the post assert_eq!(msg.reply_target, "chan789"); // Channel-level reply (thread_replies=false)
}
#[test]
fn mattermost_parse_post_thread_replies_enabled() {
let ch = MattermostChannel::new("url".into(), "token".into(), None, 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", 1_500_000_000_000_i64, "chan789")
.unwrap();
assert_eq!(msg.reply_target, "chan789:post123"); // Threaded reply
} }
#[test] #[test]
fn mattermost_parse_post_thread() { fn mattermost_parse_post_thread() {
let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()]); let ch =
MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false);
let post = json!({ let post = json!({
"id": "post123", "id": "post123",
"user_id": "user456", "user_id": "user456",
@ -285,7 +370,8 @@ mod tests {
#[test] #[test]
fn mattermost_parse_post_ignore_self() { fn mattermost_parse_post_ignore_self() {
let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()]); let ch =
MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false);
let post = json!({ let post = json!({
"id": "post123", "id": "post123",
"user_id": "bot123", "user_id": "bot123",
@ -299,7 +385,8 @@ mod tests {
#[test] #[test]
fn mattermost_parse_post_ignore_old() { fn mattermost_parse_post_ignore_old() {
let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()]); let ch =
MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false);
let post = json!({ let post = json!({
"id": "post123", "id": "post123",
"user_id": "user456", "user_id": "user456",
@ -310,4 +397,41 @@ mod tests {
let msg = ch.parse_mattermost_post(&post, "bot123", 1_500_000_000_000_i64, "chan789"); let msg = ch.parse_mattermost_post(&post, "bot123", 1_500_000_000_000_i64, "chan789");
assert!(msg.is_none()); 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 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", 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 =
MattermostChannel::new("url".into(), "token".into(), None, 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", 1_500_000_000_000_i64, "chan789")
.unwrap();
assert_eq!(msg.reply_target, "chan789:root789"); // Stays in existing thread
}
} }

View file

@ -1261,6 +1261,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
mm.bot_token.clone(), mm.bot_token.clone(),
mm.channel_id.clone(), mm.channel_id.clone(),
mm.allowed_users.clone(), mm.allowed_users.clone(),
mm.thread_replies.unwrap_or(false),
))); )));
} }

View file

@ -1499,6 +1499,10 @@ pub struct MattermostConfig {
pub channel_id: Option<String>, pub channel_id: Option<String>,
#[serde(default)] #[serde(default)]
pub allowed_users: Vec<String>, pub allowed_users: Vec<String>,
/// When true, replies thread on the original post. When false (default),
/// replies go to the channel root.
#[serde(default)]
pub thread_replies: Option<bool>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -300,6 +300,7 @@ async fn deliver_if_configured(config: &Config, job: &CronJob, output: &str) ->
mm.bot_token.clone(), mm.bot_token.clone(),
mm.channel_id.clone(), mm.channel_id.clone(),
mm.allowed_users.clone(), mm.allowed_users.clone(),
mm.thread_replies.unwrap_or(false),
); );
channel.send(&SendMessage::new(output, target)).await?; channel.send(&SendMessage::new(output, target)).await?;
} }