From 7e3f5ff497ab42e638d3f0c45c26543e953df231 Mon Sep 17 00:00:00 2001 From: Vernon Stinebaker Date: Tue, 17 Feb 2026 21:28:53 +0800 Subject: [PATCH] feat(channels): add Mattermost integration for sovereign communication --- README.md | 5 +- docs/mattermost-setup.md | 48 ++++++ src/channels/mattermost.rs | 314 +++++++++++++++++++++++++++++++++++++ src/channels/mod.rs | 11 ++ src/config/schema.rs | 14 ++ src/cron/scheduler.rs | 18 ++- src/onboard/wizard.rs | 1 + 7 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 docs/mattermost-setup.md create mode 100644 src/channels/mattermost.rs diff --git a/README.md b/README.md index 9aaed96..c2327c8 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ Every subsystem is a **trait** — swap implementations with a config change, ze | Subsystem | Trait | Ships with | Extend | |-----------|-------|------------|--------| | **AI Models** | `Provider` | 23+ providers (OpenRouter, Anthropic, OpenAI, Ollama, Venice, Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Perplexity, Cohere, Bedrock, Astrai, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API | -| **Channels** | `Channel` | CLI, Telegram, Discord, Slack, iMessage, Matrix, WhatsApp, Webhook | Any messaging API | +| **Channels** | `Channel` | CLI, Telegram, Discord, Slack, Mattermost, iMessage, Matrix, WhatsApp, Webhook | Any messaging API | | **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Lucid bridge (CLI sync + SQLite fallback), Markdown | Any persistence backend | | **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget, browser_open (Brave + allowlist), browser (agent-browser / rust-native), composio (optional) | Any capability | | **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel | @@ -263,7 +263,7 @@ ZeroClaw enforces security at **every layer** — not just the sandbox. It passe > **Run your own nmap:** `nmap -p 1-65535 ` — ZeroClaw binds to localhost only, so nothing is exposed unless you explicitly configure a tunnel. -### Channel allowlists (Telegram / Discord / Slack) +### Channel allowlists (Telegram / Discord / Slack / Mattermost) Inbound sender policy is now consistent: @@ -278,6 +278,7 @@ Recommended low-friction setup (secure + fast): - **Telegram:** allowlist your own `@username` (without `@`) and/or your numeric Telegram user ID. - **Discord:** allowlist your own Discord user ID. - **Slack:** allowlist your own Slack member ID (usually starts with `U`). +- **Mattermost:** uses standard API v4. Allowlists use Mattermost user IDs. - Use `"*"` only for temporary open testing. Telegram operator-approval flow: diff --git a/docs/mattermost-setup.md b/docs/mattermost-setup.md new file mode 100644 index 0000000..6549880 --- /dev/null +++ b/docs/mattermost-setup.md @@ -0,0 +1,48 @@ +# Mattermost Integration Guide + +ZeroClaw supports native integration with Mattermost via its REST API v4. This integration is ideal for self-hosted, private, or air-gapped environments where sovereign communication is a requirement. + +## Prerequisites + +1. **Mattermost Server**: A running Mattermost instance (self-hosted or cloud). +2. **Bot Account**: + - Go to **Main Menu > Integrations > Bot Accounts**. + - Click **Add Bot Account**. + - Set a username (e.g., `zeroclaw-bot`). + - Enable **post:all** and **channel:read** permissions (or appropriate scopes). + - Save the **Access Token**. +3. **Channel ID**: + - Open the Mattermost channel you want the bot to monitor. + - Click the channel header and select **View Info**. + - Copy the **ID** (e.g., `7j8k9l...`). + +## Configuration + +Add the following to your `config.toml` under the `[channels]` section: + +```toml +[channels.mattermost] +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"] +``` + +### Configuration Fields + +| Field | Description | +|---|---| +| `url` | The base URL of your Mattermost server. | +| `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. | + +## 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. + +## Security Note + +Mattermost integration is designed for **sovereign communication**. By hosting your own Mattermost server, your agent's communication history remains entirely within your own infrastructure, avoiding third-party cloud logging. diff --git a/src/channels/mattermost.rs b/src/channels/mattermost.rs new file mode 100644 index 0000000..44e8819 --- /dev/null +++ b/src/channels/mattermost.rs @@ -0,0 +1,314 @@ +use super::traits::{Channel, ChannelMessage, SendMessage}; +use anyhow::{bail, Result}; +use async_trait::async_trait; + +/// 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, + allowed_users: Vec, + client: reqwest::Client, +} + +impl MattermostChannel { + pub fn new( + base_url: String, + bot_token: String, + channel_id: Option, + allowed_users: Vec, + ) -> 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, + client: reqwest::Client::new(), + } + } + + /// 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 so we can ignore our own messages. + async fn get_bot_user_id(&self) -> Option { + let resp: serde_json::Value = self + .client + .get(format!("{}/api/v4/users/me", self.base_url)) + .bearer_auth(&self.bot_token) + .send() + .await + .ok()? + .json() + .await + .ok()?; + + resp.get("id") + .and_then(|u| u.as_str()) + .map(String::from) + } +} + +#[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!("")); + bail!("Mattermost post failed ({status}): {body}"); + } + + Ok(()) + } + + async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> Result<()> { + let channel_id = self + .channel_id + .clone() + .ok_or_else(|| anyhow::anyhow!("Mattermost channel_id required for listening"))?; + + let bot_user_id = self.get_bot_user_id().await.unwrap_or_default(); + #[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, 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) + } +} + +impl MattermostChannel { + fn parse_mattermost_post( + &self, + post: &serde_json::Value, + bot_user_id: &str, + last_create_at: i64, + channel_id: &str, + ) -> Option { + 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; + } + + // If it's a thread, include root_id in reply_to so we reply in the same thread + let reply_target = if !root_id.is_empty() { + format!("{}:{}", channel_id, root_id) + } else { + // Or if it's a top-level message that WE want to start a thread on, + // the next reply will use THIS post's ID as root_id. + // But for now, we follow Mattermost's 'reply' convention where + // replying to a post uses its ID as root_id. + format!("{}:{}", channel_id, id) + }; + + Some(ChannelMessage { + id: format!("mattermost_{id}"), + sender: user_id.to_string(), + reply_target, + content: text.to_string(), + channel: "mattermost".to_string(), + #[allow(clippy::cast_sign_loss)] + timestamp: (create_at / 1000) as u64, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn mattermost_url_trimming() { + let ch = MattermostChannel::new( + "https://mm.example.com/".into(), + "token".into(), + None, + vec![], + ); + assert_eq!(ch.base_url, "https://mm.example.com"); + } + + #[test] + fn mattermost_allowlist_wildcard() { + let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()]); + assert!(ch.is_user_allowed("any-id")); + } + + #[test] + fn mattermost_parse_post_basic() { + let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()]); + 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.sender, "user456"); + assert_eq!(msg.content, "hello world"); + assert_eq!(msg.reply_target, "chan789:post123"); // Threads on the post + } + + #[test] + fn mattermost_parse_post_thread() { + let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()]); + 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", 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 = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()]); + 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", 1_500_000_000_000_i64, "chan789"); + assert!(msg.is_none()); + } + + #[test] + fn mattermost_parse_post_ignore_old() { + let ch = MattermostChannel::new("url".into(), "token".into(), None, vec!["*".into()]); + 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", 1_500_000_000_000_i64, "chan789"); + assert!(msg.is_none()); + } +} diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 9a8e75a..195bd16 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -6,6 +6,7 @@ pub mod imessage; pub mod irc; pub mod lark; pub mod matrix; +pub mod mattermost; pub mod qq; pub mod signal; pub mod slack; @@ -21,6 +22,7 @@ pub use imessage::IMessageChannel; pub use irc::IrcChannel; pub use lark::LarkChannel; pub use matrix::MatrixChannel; +pub use mattermost::MattermostChannel; pub use qq::QQChannel; pub use signal::SignalChannel; pub use slack::SlackChannel; @@ -1118,6 +1120,15 @@ pub async fn start_channels(config: Config) -> Result<()> { ))); } + if let Some(ref mm) = config.channels_config.mattermost { + channels.push(Arc::new(MattermostChannel::new( + mm.url.clone(), + mm.bot_token.clone(), + mm.channel_id.clone(), + mm.allowed_users.clone(), + ))); + } + if let Some(ref im) = config.channels_config.imessage { channels.push(Arc::new(IMessageChannel::new(im.allowed_contacts.clone()))); } diff --git a/src/config/schema.rs b/src/config/schema.rs index 9ec3b2f..30b6abe 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -1278,6 +1278,7 @@ pub struct ChannelsConfig { pub telegram: Option, pub discord: Option, pub slack: Option, + pub mattermost: Option, pub webhook: Option, pub imessage: Option, pub matrix: Option, @@ -1297,6 +1298,7 @@ impl Default for ChannelsConfig { telegram: None, discord: None, slack: None, + mattermost: None, webhook: None, imessage: None, matrix: None, @@ -1342,6 +1344,15 @@ pub struct SlackConfig { pub allowed_users: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MattermostConfig { + pub url: String, + pub bot_token: String, + pub channel_id: Option, + #[serde(default)] + pub allowed_users: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebhookConfig { pub port: u16, @@ -2196,6 +2207,7 @@ default_temperature = 0.7 }), discord: None, slack: None, + mattermost: None, webhook: None, imessage: None, matrix: None, @@ -2604,6 +2616,7 @@ tool_dispatcher = "xml" telegram: None, discord: None, slack: None, + mattermost: None, webhook: None, imessage: Some(IMessageConfig { allowed_contacts: vec!["+1".into()], @@ -2767,6 +2780,7 @@ channel_id = "C123" telegram: None, discord: None, slack: None, + mattermost: None, webhook: None, imessage: None, matrix: None, diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index dc53047..e50ef78 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -1,4 +1,6 @@ -use crate::channels::{Channel, DiscordChannel, SendMessage, SlackChannel, TelegramChannel}; +use crate::channels::{ + Channel, DiscordChannel, MattermostChannel, SendMessage, SlackChannel, TelegramChannel, +}; use crate::config::Config; use crate::cron::{ due_jobs, next_run_for_schedule, record_last_run, record_run, remove_job, reschedule_after_run, @@ -262,6 +264,20 @@ async fn deliver_if_configured(config: &Config, job: &CronJob, output: &str) -> ); channel.send(&SendMessage::new(output, target)).await?; } + "mattermost" => { + let mm = config + .channels_config + .mattermost + .as_ref() + .ok_or_else(|| anyhow::anyhow!("mattermost channel not configured"))?; + let channel = MattermostChannel::new( + mm.url.clone(), + mm.bot_token.clone(), + mm.channel_id.clone(), + mm.allowed_users.clone(), + ); + channel.send(&SendMessage::new(output, target)).await?; + } other => anyhow::bail!("unsupported delivery channel: {other}"), } diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 2152a4a..95391d6 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -2422,6 +2422,7 @@ fn setup_channels() -> Result { telegram: None, discord: None, slack: None, + mattermost: None, webhook: None, imessage: None, matrix: None,