diff --git a/docs/mattermost-setup.md b/docs/mattermost-setup.md index 6549880..4b17b88 100644 --- a/docs/mattermost-setup.md +++ b/docs/mattermost-setup.md @@ -26,6 +26,7 @@ url = "https://mm.your-domain.com" bot_token = "your-bot-access-token" channel_id = "your-channel-id" allowed_users = ["user-id-1", "user-id-2"] +thread_replies = true ``` ### Configuration Fields @@ -36,12 +37,14 @@ allowed_users = ["user-id-1", "user-id-2"] | `bot_token` | The Personal Access Token for the bot account. | | `channel_id` | (Optional) The ID of the channel to listen to. Required for `listen` mode. | | `allowed_users` | (Optional) A list of Mattermost User IDs permitted to interact with the bot. Use `["*"]` to allow everyone. | +| `thread_replies` | (Optional) Whether top-level user messages should be answered in a thread. Default: `true`. Existing thread replies always remain in-thread. | ## Threaded Conversations -ZeroClaw automatically supports Mattermost threads. -- If a user sends a message in a thread, ZeroClaw will reply within that same thread. -- If a user sends a top-level message, ZeroClaw will start a thread by replying to that post. +ZeroClaw supports Mattermost threads in both modes: +- If a user sends a message in an existing thread, ZeroClaw always replies within that same thread. +- If `thread_replies = true` (default), top-level messages are answered by threading on that post. +- If `thread_replies = false`, top-level messages are answered at channel root level. ## Security Note diff --git a/src/channels/mattermost.rs b/src/channels/mattermost.rs index 6b5fdda..b03f746 100644 --- a/src/channels/mattermost.rs +++ b/src/channels/mattermost.rs @@ -1,6 +1,7 @@ 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. @@ -9,12 +10,12 @@ pub struct MattermostChannel { bot_token: String, channel_id: Option, allowed_users: Vec, - /// When true, replies thread on the original post's root_id. - /// When false (default), replies go to the channel root. + /// When true (default), replies thread on the original post's root_id. + /// When false, replies go to the channel root. thread_replies: bool, client: reqwest::Client, /// Handle for the background typing-indicator loop (aborted on stop_typing). - typing_handle: std::sync::Mutex>>, + typing_handle: Mutex>>, } impl MattermostChannel { @@ -34,7 +35,7 @@ impl MattermostChannel { allowed_users, thread_replies, client: reqwest::Client::new(), - typing_handle: std::sync::Mutex::new(None), + typing_handle: Mutex::new(None), } } @@ -195,8 +196,10 @@ impl Channel for MattermostChannel { 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 (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"); @@ -225,18 +228,16 @@ impl Channel for MattermostChannel { } }); - if let Ok(mut guard) = self.typing_handle.lock() { - *guard = Some(handle); - } + let 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(); - } + let mut guard = self.typing_handle.lock(); + if let Some(handle) = guard.take() { + handle.abort(); } Ok(()) } @@ -315,8 +316,7 @@ mod tests { #[test] fn mattermost_parse_post_basic() { - let ch = - MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], false); + let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()], true); let post = json!({ "id": "post123", "user_id": "user456", @@ -330,7 +330,7 @@ mod tests { .unwrap(); assert_eq!(msg.sender, "user456"); assert_eq!(msg.content, "hello world"); - assert_eq!(msg.reply_target, "chan789"); // Channel-level reply (thread_replies=false) + assert_eq!(msg.reply_target, "chan789:post123"); // Default threaded reply } #[test] diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 0c43fd1..b0fba77 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -1261,7 +1261,7 @@ pub async fn start_channels(config: Config) -> Result<()> { mm.bot_token.clone(), mm.channel_id.clone(), mm.allowed_users.clone(), - mm.thread_replies.unwrap_or(false), + mm.thread_replies.unwrap_or(true), ))); } diff --git a/src/config/schema.rs b/src/config/schema.rs index 33e2fe3..1551a0c 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -1499,8 +1499,8 @@ pub struct MattermostConfig { pub channel_id: Option, #[serde(default)] pub allowed_users: Vec, - /// When true, replies thread on the original post. When false (default), - /// replies go to the channel root. + /// When true (default), replies thread on the original post. + /// When false, replies go to the channel root. #[serde(default)] pub thread_replies: Option, } diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index fb8f61d..5373e61 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -300,7 +300,7 @@ async fn deliver_if_configured(config: &Config, job: &CronJob, output: &str) -> mm.bot_token.clone(), mm.channel_id.clone(), mm.allowed_users.clone(), - mm.thread_replies.unwrap_or(false), + mm.thread_replies.unwrap_or(true), ); channel.send(&SendMessage::new(output, target)).await?; }