fix(mattermost): preserve threaded default and docs

This commit is contained in:
Chummy 2026-02-18 17:41:43 +08:00
parent 58120b1c69
commit 1bfd50bce9
5 changed files with 26 additions and 23 deletions

View file

@ -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

View file

@ -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<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.
/// 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<Option<tokio::task::JoinHandle<()>>>,
typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
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,19 +228,17 @@ impl Channel for MattermostChannel {
}
});
if let Ok(mut guard) = self.typing_handle.lock() {
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() {
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]

View file

@ -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),
)));
}

View file

@ -1499,8 +1499,8 @@ pub struct MattermostConfig {
pub channel_id: Option<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
/// 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<bool>,
}

View file

@ -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?;
}