Skip to main content

zeroclaw_channels/
mattermost.rs

1use anyhow::{Context, Result, bail};
2use async_trait::async_trait;
3use parking_lot::Mutex;
4use std::collections::{HashMap, HashSet};
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7use tokio::sync::OnceCell;
8use zeroclaw_api::channel::{Channel, ChannelMessage, SendMessage};
9
10const MAX_MATTERMOST_AUDIO_BYTES: u64 = 25 * 1024 * 1024;
11/// Cadence at which auto-discovery re-runs to pick up newly-created DMs
12/// and team channel changes.
13const DISCOVERY_REFRESH: Duration = Duration::from_secs(60);
14/// Poll interval per discovery iteration. Matches the previous single-channel
15/// cadence so operators see no change in latency.
16const POLL_INTERVAL: Duration = Duration::from_secs(3);
17
18/// One channel the bot will poll. `is_direct` flags DM (`type=D`) and group DM
19/// (`type=G`) channels so the receive path can bypass `mention_only` for them.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub(crate) struct TargetChannel {
22    pub id: String,
23    pub is_direct: bool,
24}
25
26/// Mattermost channel `type` is a single-character code: `O` = open/public,
27/// `P` = private, `G` = group DM, `D` = direct DM. Group DMs are private
28/// multi-user conversations and share the no-ambient-noise semantic with 1:1
29/// DMs, so both are treated as "direct" for `mention_only` purposes.
30pub(crate) fn is_direct_channel(channel_type: &str) -> bool {
31    matches!(channel_type, "D" | "G")
32}
33
34/// Filter a raw `/api/v4/users/me/channels` response down to the channels the
35/// bot should poll. Public/private channels are gated by `team_ids` (empty =
36/// all teams); DM/group-DM channels are gated by `discover_dms`. DMs carry
37/// no `team_id`, so the team allowlist deliberately doesn't apply to them.
38pub(crate) fn filter_discovered_channels(
39    channels: &[serde_json::Value],
40    team_ids: &[String],
41    discover_dms: bool,
42) -> Vec<TargetChannel> {
43    channels
44        .iter()
45        .filter_map(|c| {
46            let id = c.get("id").and_then(|v| v.as_str())?;
47            let ty = c.get("type").and_then(|v| v.as_str()).unwrap_or("");
48            let team = c.get("team_id").and_then(|v| v.as_str()).unwrap_or("");
49            let direct = is_direct_channel(ty);
50            if direct {
51                if !discover_dms {
52                    return None;
53                }
54            } else if !team_ids.is_empty() && !team_ids.iter().any(|allowed| allowed == team) {
55                return None;
56            }
57            Some(TargetChannel {
58                id: id.to_string(),
59                is_direct: direct,
60            })
61        })
62        .collect()
63}
64
65/// Mattermost channel — polls channel posts via REST API v4.
66/// Mattermost is API-compatible with many Slack patterns but uses a dedicated v4 structure.
67pub struct MattermostChannel {
68    base_url: String, // e.g., https://mm.example.com
69    /// Static bot token from the config. Preferred over login when set.
70    bot_token: Option<String>,
71    /// Login ID for the password login flow. Used when `bot_token` is None.
72    login_id: Option<String>,
73    /// Password for the login flow. Used when `bot_token` is None.
74    password: Option<String>,
75    /// Resolved session token used by all API calls. Populated lazily on
76    /// first use, either by copying `bot_token` or by performing the login
77    /// flow with `login_id` and `password`.
78    session_token: OnceCell<String>,
79    /// (user_id, username) for the bot, fetched once from `/users/me`
80    /// inside `get_bot_identity`. Read by `self_handle` /
81    /// `self_addressed_mention` so the identity block reaches the prompt.
82    bot_identity: OnceCell<(String, String)>,
83    /// Channel IDs from config. Empty or `["*"]` triggers auto-discovery.
84    channel_ids: Vec<String>,
85    /// Team allowlist for auto-discovery. Empty = all teams.
86    team_ids: Vec<String>,
87    /// When true, auto-discovery includes DM (`type=D`) and group DM (`type=G`)
88    /// channels. Defaults to true at construction; `with_discover_dms` overrides.
89    discover_dms: bool,
90    /// The alias key under `[channels.mattermost.<alias>]` this handle is
91    /// bound to. Used to scope peer-group writes and resolver lookups.
92    alias: String,
93    /// Resolves inbound external peers from canonical state at message-time.
94    /// No cache (see AGENTS.md "ABSOLUTE RULE — SINGLE SOURCE OF TRUTH").
95    peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
96    /// When true (default), replies thread on the original post's root_id.
97    /// When false, replies go to the channel root.
98    thread_replies: bool,
99    /// When true, only respond to messages that @-mention the bot.
100    mention_only: bool,
101    /// Handle for the background typing-indicator loop (aborted on stop_typing).
102    typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
103    /// Per-channel proxy URL override.
104    proxy_url: Option<String>,
105    transcription: Option<zeroclaw_config::schema::TranscriptionConfig>,
106    transcription_manager: Option<Arc<super::transcription::TranscriptionManager>>,
107}
108
109impl MattermostChannel {
110    pub fn new(
111        base_url: String,
112        bot_token: Option<String>,
113        login_id: Option<String>,
114        password: Option<String>,
115        channel_ids: Vec<String>,
116        alias: impl Into<String>,
117        peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
118        thread_replies: bool,
119        mention_only: bool,
120    ) -> Self {
121        // Ensure base_url doesn't have a trailing slash for consistent path joining
122        let base_url = base_url.trim_end_matches('/').to_string();
123        Self {
124            base_url,
125            bot_token,
126            login_id,
127            password,
128            session_token: OnceCell::new(),
129            bot_identity: OnceCell::new(),
130            channel_ids,
131            team_ids: Vec::new(),
132            discover_dms: true,
133            alias: alias.into(),
134            peer_resolver,
135            thread_replies,
136            mention_only,
137            typing_handle: Mutex::new(None),
138            proxy_url: None,
139            transcription: None,
140            transcription_manager: None,
141        }
142    }
143
144    /// Restrict auto-discovery to the given team IDs. Empty = all teams the
145    /// bot belongs to. No effect when `channel_ids` lists explicit IDs.
146    pub fn with_team_ids(mut self, team_ids: Vec<String>) -> Self {
147        self.team_ids = team_ids;
148        self
149    }
150
151    /// Include (`true`, default) or omit (`false`) DM and group-DM channels
152    /// during auto-discovery. No effect when `channel_ids` lists explicit IDs.
153    pub fn with_discover_dms(mut self, discover_dms: bool) -> Self {
154        self.discover_dms = discover_dms;
155        self
156    }
157
158    /// Normalize a raw `channel_ids` entry: trim, drop blanks and the `*`
159    /// wildcard sentinel. Returns `None` when the entry should not contribute
160    /// to the explicit-scope list.
161    pub(crate) fn normalized_channel_id(input: Option<&str>) -> Option<String> {
162        input
163            .map(str::trim)
164            .filter(|v| !v.is_empty() && *v != "*")
165            .map(ToOwned::to_owned)
166    }
167
168    /// Resolve the explicit channel scope from `channel_ids`. Returns `None`
169    /// when the config asks for auto-discovery (empty list or wildcard-only).
170    pub(crate) fn scoped_channel_ids(&self) -> Option<Vec<String>> {
171        let mut seen = HashSet::new();
172        let ids: Vec<String> = self
173            .channel_ids
174            .iter()
175            .filter_map(|entry| Self::normalized_channel_id(Some(entry)))
176            .filter(|id| seen.insert(id.clone()))
177            .collect();
178        if ids.is_empty() { None } else { Some(ids) }
179    }
180
181    /// Resolve the set of channels this listener should poll, combining:
182    ///
183    /// - explicit `channel_ids` from config (looked up to learn each channel's
184    ///   type so the DM/non-DM distinction reaches the receive path), or
185    /// - auto-discovery via `/api/v4/users/me/channels` filtered by
186    ///   `team_ids` and `discover_dms`.
187    pub(crate) async fn list_target_channels(&self) -> Result<Vec<TargetChannel>> {
188        let token = self.token().await?.to_string();
189        if let Some(ids) = self.scoped_channel_ids() {
190            let mut out = Vec::with_capacity(ids.len());
191            for id in ids {
192                let resp = self
193                    .http_client()
194                    .get(format!("{}/api/v4/channels/{}", self.base_url, id))
195                    .bearer_auth(&token)
196                    .send()
197                    .await
198                    .with_context(|| format!("GET /channels/{id} failed"))?;
199                if !resp.status().is_success() {
200                    bail!(
201                        "GET /channels/{id} returned {}: explicit channel_id is not accessible to this bot",
202                        resp.status()
203                    );
204                }
205                let body: serde_json::Value = resp
206                    .json()
207                    .await
208                    .with_context(|| format!("decode /channels/{id} body"))?;
209                let ty = body.get("type").and_then(|v| v.as_str()).unwrap_or("");
210                out.push(TargetChannel {
211                    id,
212                    is_direct: is_direct_channel(ty),
213                });
214            }
215            return Ok(out);
216        }
217        let resp = self
218            .http_client()
219            .get(format!("{}/api/v4/users/me/channels", self.base_url))
220            .bearer_auth(&token)
221            .send()
222            .await
223            .context("GET /users/me/channels failed")?;
224        if !resp.status().is_success() {
225            bail!("GET /users/me/channels returned {}", resp.status());
226        }
227        let body: serde_json::Value = resp
228            .json()
229            .await
230            .context("decode /users/me/channels body")?;
231        let arr = body.as_array().cloned().unwrap_or_default();
232        Ok(filter_discovered_channels(
233            &arr,
234            &self.team_ids,
235            self.discover_dms,
236        ))
237    }
238
239    /// Return the alias under `[channels.mattermost.<alias>]` that this
240    /// channel handle is bound to.
241    pub fn alias(&self) -> &str {
242        &self.alias
243    }
244
245    /// Resolve the session token, performing the login flow on first call
246    /// if `bot_token` is not set.
247    async fn token(&self) -> Result<&str> {
248        self.session_token
249            .get_or_try_init(|| async {
250                if let Some(ref t) = self.bot_token {
251                    return Ok::<String, anyhow::Error>(t.clone());
252                }
253                let login_id = self.login_id.as_deref().ok_or_else(|| {
254                    ::zeroclaw_log::record!(
255                        ERROR,
256                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
257                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
258                            .with_attrs(::serde_json::json!({
259                                "missing": "login_id",
260                                "reason": "no_bot_token",
261                            })),
262                        "mattermost: bot_token unset and login_id missing"
263                    );
264                    anyhow::Error::msg(
265                        "bot_token is unset; configure either bot_token or both login_id and password",
266                    )
267                })?;
268                let password = self.password.as_deref().ok_or_else(|| {
269                    ::zeroclaw_log::record!(
270                        ERROR,
271                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
272                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
273                            .with_attrs(::serde_json::json!({
274                                "missing": "password",
275                                "reason": "no_bot_token",
276                            })),
277                        "mattermost: bot_token unset and password missing"
278                    );
279                    anyhow::Error::msg(
280                        "bot_token is unset and password is missing; both login_id and password must be set",
281                    )
282                })?;
283                self.login(login_id, password).await
284            })
285            .await
286            .map(String::as_str)
287    }
288
289    /// Perform the Mattermost password login flow and return the session
290    /// token. The session token is returned via the `Token` response header
291    /// per Mattermost API v4.
292    async fn login(&self, login_id: &str, password: &str) -> Result<String> {
293        let resp = self
294            .http_client()
295            .post(format!("{}/api/v4/users/login", self.base_url))
296            .json(&serde_json::json!({
297                "login_id": login_id,
298                "password": password,
299            }))
300            .send()
301            .await
302            .context("login request failed")?;
303        if !resp.status().is_success() {
304            let status = resp.status();
305            let body = resp.text().await.unwrap_or_default();
306            bail!("login failed ({status}): {body}");
307        }
308        let token = resp
309            .headers()
310            .get("Token")
311            .and_then(|v| v.to_str().ok())
312            .ok_or_else(|| {
313                ::zeroclaw_log::record!(
314                    ERROR,
315                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
316                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
317                    "login succeeded but the response had no Token header"
318                );
319                anyhow::Error::msg("login succeeded but the response had no Token header")
320            })?
321            .to_string();
322        ::zeroclaw_log::record!(
323            INFO,
324            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
325            "login succeeded; session token cached"
326        );
327        Ok(token)
328    }
329
330    /// Set a per-channel proxy URL that overrides the global proxy config.
331    pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
332        self.proxy_url = proxy_url;
333        self
334    }
335
336    pub fn with_transcription(
337        mut self,
338        config: zeroclaw_config::schema::TranscriptionConfig,
339    ) -> Self {
340        if !config.enabled {
341            return self;
342        }
343        match super::transcription::TranscriptionManager::new(&config) {
344            Ok(m) => {
345                // Bind the sole registered provider as the agent transcription
346                // provider for the channel-direct ingest path. Multi-provider
347                // setups still resolve via the orchestrator's per-agent
348                // routing (see orchestrator/mod.rs). See wati.rs for full
349                // rationale.
350                let names = m.available_providers();
351                let m = if names.len() == 1 {
352                    let only = names[0].to_string();
353                    m.with_agent_transcription_provider(only)
354                } else {
355                    m
356                };
357                self.transcription_manager = Some(Arc::new(m));
358                self.transcription = Some(config);
359            }
360            Err(e) => {
361                ::zeroclaw_log::record!(
362                    WARN,
363                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
364                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
365                        .with_attrs(::serde_json::json!({"e": e.to_string()})),
366                    "transcription manager init failed, voice transcription disabled"
367                );
368            }
369        }
370        self
371    }
372
373    fn http_client(&self) -> reqwest::Client {
374        zeroclaw_config::schema::build_channel_proxy_client_with_timeouts(
375            "channel.mattermost",
376            self.proxy_url.as_deref(),
377            30,
378            10,
379        )
380    }
381
382    /// Check if a user ID is in the allowlist.
383    /// Empty list means deny everyone. "*" means allow everyone.
384    fn is_user_allowed(&self, user_id: &str) -> bool {
385        let peers = (self.peer_resolver)();
386        crate::allowlist::is_user_allowed(&peers, user_id, crate::allowlist::Match::Sensitive)
387    }
388
389    /// Get the bot's own user ID and username so we can ignore our own messages
390    /// and detect @-mentions by username. Result cached on the channel
391    /// so `self_handle` / `self_addressed_mention` can read it sync.
392    async fn get_bot_identity(&self) -> (String, String) {
393        if let Some(cached) = self.bot_identity.get() {
394            return cached.clone();
395        }
396        let token = match self.token().await {
397            Ok(t) => t.to_string(),
398            Err(e) => {
399                ::zeroclaw_log::record!(
400                    WARN,
401                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
402                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
403                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
404                    "auth failed in get_bot_identity"
405                );
406                return (String::new(), String::new());
407            }
408        };
409        let resp: Option<serde_json::Value> = async {
410            self.http_client()
411                .get(format!("{}/api/v4/users/me", self.base_url))
412                .bearer_auth(&token)
413                .send()
414                .await
415                .ok()?
416                .json()
417                .await
418                .ok()
419        }
420        .await;
421
422        let id = resp
423            .as_ref()
424            .and_then(|v| v.get("id"))
425            .and_then(|u| u.as_str())
426            .unwrap_or("")
427            .to_string();
428        let username = resp
429            .as_ref()
430            .and_then(|v| v.get("username"))
431            .and_then(|u| u.as_str())
432            .unwrap_or("")
433            .to_string();
434        if !id.is_empty() || !username.is_empty() {
435            let _ = self.bot_identity.set((id.clone(), username.clone()));
436        }
437        (id, username)
438    }
439
440    async fn try_transcribe_audio_attachment(&self, post: &serde_json::Value) -> Option<String> {
441        let config = self.transcription.as_ref()?;
442        let manager = self.transcription_manager.as_deref()?;
443
444        let files = post
445            .get("metadata")
446            .and_then(|m| m.get("files"))
447            .and_then(|f| f.as_array())?;
448
449        let audio_file = files.iter().find(|f| is_audio_file(f))?;
450
451        if let Some(duration_ms) = audio_file.get("duration").and_then(|d| d.as_u64()) {
452            let duration_secs = duration_ms / 1000;
453            if duration_secs > config.max_duration_secs {
454                ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"duration_secs": duration_secs, "max": config.max_duration_secs})), "audio attachment exceeds max duration, skipping");
455                return None;
456            }
457        }
458
459        let file_id = audio_file.get("id").and_then(|i| i.as_str())?;
460        let file_name = audio_file
461            .get("name")
462            .and_then(|n| n.as_str())
463            .unwrap_or("audio");
464
465        let token = match self.token().await {
466            Ok(t) => t.to_string(),
467            Err(e) => {
468                ::zeroclaw_log::record!(
469                    WARN,
470                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
471                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
472                        .with_attrs(
473                            ::serde_json::json!({"error": format!("{}", e), "file_id": file_id})
474                        ),
475                    "audio download auth failed for"
476                );
477                return None;
478            }
479        };
480        let response = match self
481            .http_client()
482            .get(format!("{}/api/v4/files/{}", self.base_url, file_id))
483            .bearer_auth(&token)
484            .send()
485            .await
486        {
487            Ok(r) => r,
488            Err(e) => {
489                ::zeroclaw_log::record!(
490                    WARN,
491                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
492                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
493                        .with_attrs(
494                            ::serde_json::json!({"error": format!("{}", e), "file_id": file_id})
495                        ),
496                    "audio download failed for"
497                );
498                return None;
499            }
500        };
501
502        if !response.status().is_success() {
503            ::zeroclaw_log::record!(
504                WARN,
505                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
506                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
507                &format!("audio download returned {}: {file_id}", response.status())
508            );
509            return None;
510        }
511
512        if let Some(content_length) = response.content_length()
513            && content_length > MAX_MATTERMOST_AUDIO_BYTES
514        {
515            ::zeroclaw_log::record!(
516                WARN,
517                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
518                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
519                    .with_attrs(
520                        ::serde_json::json!({"content_length": content_length, "file_id": file_id})
521                    ),
522                "audio file too large ( bytes)"
523            );
524            return None;
525        }
526
527        let bytes = match response.bytes().await {
528            Ok(b) => b,
529            Err(e) => {
530                ::zeroclaw_log::record!(
531                    WARN,
532                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
533                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
534                        .with_attrs(
535                            ::serde_json::json!({"error": format!("{}", e), "file_id": file_id})
536                        ),
537                    "failed to read audio bytes for"
538                );
539                return None;
540            }
541        };
542
543        match manager.transcribe(&bytes, file_name).await {
544            Ok(text) => {
545                let trimmed = text.trim();
546                if trimmed.is_empty() {
547                    ::zeroclaw_log::record!(
548                        INFO,
549                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
550                        "transcription returned empty text, skipping"
551                    );
552                    None
553                } else {
554                    Some(format!("[Voice] {trimmed}"))
555                }
556            }
557            Err(e) => {
558                ::zeroclaw_log::record!(
559                    WARN,
560                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
561                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
562                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
563                    "audio transcription failed"
564                );
565                None
566            }
567        }
568    }
569}
570
571impl ::zeroclaw_api::attribution::Attributable for MattermostChannel {
572    fn role(&self) -> ::zeroclaw_api::attribution::Role {
573        ::zeroclaw_api::attribution::Role::Channel(
574            ::zeroclaw_api::attribution::ChannelKind::Mattermost,
575        )
576    }
577    fn alias(&self) -> &str {
578        &self.alias
579    }
580}
581
582#[async_trait]
583impl Channel for MattermostChannel {
584    fn name(&self) -> &str {
585        "mattermost"
586    }
587
588    fn self_handle(&self) -> Option<String> {
589        self.bot_identity
590            .get()
591            .map(|(id, _)| id.clone())
592            .filter(|id| !id.is_empty())
593    }
594
595    fn self_addressed_mention(&self) -> Option<String> {
596        self.bot_identity
597            .get()
598            .map(|(_, username)| username.clone())
599            .filter(|u| !u.is_empty())
600            .map(|u| format!("@{u}"))
601    }
602
603    async fn send(&self, message: &SendMessage) -> Result<()> {
604        // Mattermost supports threading via 'root_id'.
605        // We pack 'channel_id:root_id' into recipient if it's a thread.
606        let (channel_id, root_id) = if let Some((c, r)) = message.recipient.split_once(':') {
607            (c, Some(r))
608        } else {
609            (message.recipient.as_str(), None)
610        };
611
612        let mut body_map = serde_json::json!({
613            "channel_id": channel_id,
614            "message": message.content
615        });
616
617        if let Some(root) = root_id {
618            body_map.as_object_mut().unwrap().insert(
619                "root_id".to_string(),
620                serde_json::Value::String(root.to_string()),
621            );
622        }
623
624        let token = self.token().await?;
625        let resp = self
626            .http_client()
627            .post(format!("{}/api/v4/posts", self.base_url))
628            .bearer_auth(token)
629            .json(&body_map)
630            .send()
631            .await?;
632
633        let status = resp.status();
634        if !status.is_success() {
635            let body = resp
636                .text()
637                .await
638                .unwrap_or_else(|e| format!("<failed to read response: {e}>"));
639            bail!("post failed ({status}): {body}");
640        }
641
642        Ok(())
643    }
644
645    async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
646        // Resolve auth up front so misconfiguration fails fast at listen-time.
647        let initial_token = self.token().await?.to_string();
648        let (bot_user_id, bot_username) = self.get_bot_identity().await;
649
650        let auto_discover = self.scoped_channel_ids().is_none();
651        let mut target_channels = self.list_target_channels().await?;
652        let mut last_discovery = Instant::now();
653        let mut last_create_at_by_channel: HashMap<String, i64> = HashMap::new();
654
655        ::zeroclaw_log::record!(
656            INFO,
657            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
658                ::serde_json::json!({
659                    "alias": self.alias,
660                    "channel_count": target_channels.len(),
661                    "auto_discover": auto_discover,
662                    "team_ids": self.team_ids,
663                    "discover_dms": self.discover_dms,
664                })
665            ),
666            "Mattermost channel listening"
667        );
668
669        loop {
670            tokio::time::sleep(POLL_INTERVAL).await;
671
672            if auto_discover && last_discovery.elapsed() >= DISCOVERY_REFRESH {
673                match self.list_target_channels().await {
674                    Ok(refreshed) => {
675                        if refreshed != target_channels {
676                            ::zeroclaw_log::record!(
677                                INFO,
678                                ::zeroclaw_log::Event::new(
679                                    module_path!(),
680                                    ::zeroclaw_log::Action::Note,
681                                )
682                                .with_attrs(::serde_json::json!({
683                                    "alias": self.alias,
684                                    "before": target_channels.len(),
685                                    "after": refreshed.len(),
686                                })),
687                                "Mattermost auto-discovery refreshed channel list"
688                            );
689                            target_channels = refreshed;
690                        }
691                    }
692                    Err(e) => {
693                        ::zeroclaw_log::record!(
694                            WARN,
695                            ::zeroclaw_log::Event::new(
696                                module_path!(),
697                                ::zeroclaw_log::Action::Note,
698                            )
699                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
700                            .with_attrs(::serde_json::json!({
701                                "alias": self.alias,
702                                "error": format!("{}", e),
703                            })),
704                            "Mattermost auto-discovery refresh failed; keeping previous channel list"
705                        );
706                    }
707                }
708                last_discovery = Instant::now();
709            }
710
711            if target_channels.is_empty() {
712                continue;
713            }
714
715            #[allow(clippy::cast_possible_truncation)]
716            let bootstrap_ms = (std::time::SystemTime::now()
717                .duration_since(std::time::UNIX_EPOCH)
718                .unwrap_or_default()
719                .as_millis()) as i64;
720
721            for target in target_channels.clone() {
722                if self
723                    .poll_channel(
724                        &target,
725                        &initial_token,
726                        &bot_user_id,
727                        &bot_username,
728                        bootstrap_ms,
729                        &mut last_create_at_by_channel,
730                        &tx,
731                    )
732                    .await
733                {
734                    return Ok(());
735                }
736            }
737        }
738    }
739
740    async fn health_check(&self) -> bool {
741        let Ok(token) = self.token().await else {
742            return false;
743        };
744        self.http_client()
745            .get(format!("{}/api/v4/users/me", self.base_url))
746            .bearer_auth(token)
747            .send()
748            .await
749            .map(|r| r.status().is_success())
750            .unwrap_or(false)
751    }
752
753    async fn start_typing(&self, recipient: &str) -> Result<()> {
754        // Cancel any existing typing loop before starting a new one.
755        self.stop_typing(recipient).await?;
756
757        let client = self.http_client();
758        let token = self.token().await?.to_string();
759        let base_url = self.base_url.clone();
760
761        // recipient is "channel_id" or "channel_id:root_id"
762        let (channel_id, parent_id) = match recipient.split_once(':') {
763            Some((channel, parent)) => (channel.to_string(), Some(parent.to_string())),
764            None => (recipient.to_string(), None),
765        };
766
767        let handle = tokio::spawn(async move {
768            let url = format!("{base_url}/api/v4/users/me/typing");
769            loop {
770                let mut body = serde_json::json!({ "channel_id": channel_id });
771                if let Some(ref pid) = parent_id {
772                    body.as_object_mut()
773                        .unwrap()
774                        .insert("parent_id".to_string(), serde_json::json!(pid));
775                }
776
777                if let Ok(r) = client
778                    .post(&url)
779                    .bearer_auth(&token)
780                    .json(&body)
781                    .send()
782                    .await
783                    && !r.status().is_success()
784                {
785                    ::zeroclaw_log::record!(
786                        DEBUG,
787                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
788                            .with_attrs(::serde_json::json!({"status": r.status().to_string()})),
789                        "typing indicator failed"
790                    );
791                }
792
793                // Mattermost typing events expire after ~6s; re-fire every 4s.
794                tokio::time::sleep(std::time::Duration::from_secs(4)).await;
795            }
796        });
797
798        let mut guard = self.typing_handle.lock();
799        *guard = Some(handle);
800
801        Ok(())
802    }
803
804    async fn stop_typing(&self, _recipient: &str) -> Result<()> {
805        let mut guard = self.typing_handle.lock();
806        if let Some(handle) = guard.take() {
807            handle.abort();
808        }
809        Ok(())
810    }
811}
812
813impl MattermostChannel {
814    /// Poll one target channel for new posts since its cursor, dispatch each
815    /// post through `parse_mattermost_post`, and update the cursor in place.
816    /// Returns `true` when the outbound mpsc was closed (caller exits the
817    /// listen loop). Errors during the poll are logged and treated as a
818    /// no-op for this iteration; the next iteration retries.
819    #[allow(clippy::too_many_arguments)]
820    async fn poll_channel(
821        &self,
822        target: &TargetChannel,
823        token: &str,
824        bot_user_id: &str,
825        bot_username: &str,
826        bootstrap_ms: i64,
827        cursors: &mut HashMap<String, i64>,
828        tx: &tokio::sync::mpsc::Sender<ChannelMessage>,
829    ) -> bool {
830        let cursor = *cursors.entry(target.id.clone()).or_insert(bootstrap_ms);
831
832        let resp = match self
833            .http_client()
834            .get(format!(
835                "{}/api/v4/channels/{}/posts",
836                self.base_url, target.id
837            ))
838            .bearer_auth(token)
839            .query(&[("since", cursor.to_string())])
840            .send()
841            .await
842        {
843            Ok(r) => r,
844            Err(e) => {
845                ::zeroclaw_log::record!(
846                    WARN,
847                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
848                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
849                        .with_attrs(::serde_json::json!({
850                            "alias": self.alias,
851                            "channel_id": target.id,
852                            "error": format!("{}", e),
853                        })),
854                    "Mattermost poll error"
855                );
856                return false;
857            }
858        };
859
860        let data: serde_json::Value = match resp.json().await {
861            Ok(d) => d,
862            Err(e) => {
863                ::zeroclaw_log::record!(
864                    WARN,
865                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
866                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
867                        .with_attrs(::serde_json::json!({
868                            "alias": self.alias,
869                            "channel_id": target.id,
870                            "error": format!("{}", e),
871                        })),
872                    "Mattermost parse error"
873                );
874                return false;
875            }
876        };
877
878        let Some(posts) = data.get("posts").and_then(|p| p.as_object()) else {
879            return false;
880        };
881
882        let mut post_list: Vec<_> = posts.values().collect();
883        post_list.sort_by_key(|p| p.get("create_at").and_then(|c| c.as_i64()).unwrap_or(0));
884
885        let cursor_before_batch = cursor;
886        let mut new_cursor = cursor;
887        for post in post_list {
888            let create_at = post
889                .get("create_at")
890                .and_then(|c| c.as_i64())
891                .unwrap_or(new_cursor);
892            new_cursor = new_cursor.max(create_at);
893
894            let effective_text = if post
895                .get("message")
896                .and_then(|m| m.as_str())
897                .unwrap_or("")
898                .trim()
899                .is_empty()
900                && post_has_audio_attachment(post)
901            {
902                self.try_transcribe_audio_attachment(post).await
903            } else {
904                None
905            };
906
907            if let Some(channel_msg) = self.parse_mattermost_post(
908                post,
909                bot_user_id,
910                bot_username,
911                cursor_before_batch,
912                &target.id,
913                effective_text.as_deref(),
914                target.is_direct,
915            ) && tx.send(channel_msg).await.is_err()
916            {
917                return true;
918            }
919        }
920        cursors.insert(target.id.clone(), new_cursor);
921        false
922    }
923
924    fn parse_mattermost_post(
925        &self,
926        post: &serde_json::Value,
927        bot_user_id: &str,
928        bot_username: &str,
929        last_create_at: i64,
930        channel_id: &str,
931        injected_text: Option<&str>,
932        is_direct: bool,
933    ) -> Option<ChannelMessage> {
934        let id = post.get("id").and_then(|i| i.as_str()).unwrap_or("");
935        let user_id = post.get("user_id").and_then(|u| u.as_str()).unwrap_or("");
936        let text = post.get("message").and_then(|m| m.as_str()).unwrap_or("");
937        let create_at = post.get("create_at").and_then(|c| c.as_i64()).unwrap_or(0);
938        let root_id = post.get("root_id").and_then(|r| r.as_str()).unwrap_or("");
939
940        if user_id == bot_user_id || create_at <= last_create_at {
941            return None;
942        }
943
944        let effective_text = if text.is_empty() {
945            injected_text?
946        } else {
947            text
948        };
949
950        if !self.is_user_allowed(user_id) {
951            ::zeroclaw_log::record!(
952                WARN,
953                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
954                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
955                    .with_attrs(::serde_json::json!({"user_id": user_id})),
956                "ignoring message from unauthorized user"
957            );
958            return None;
959        }
960
961        // DM and group-DM channels have no ambient noise to filter against, so
962        // mention_only is bypassed for them. The flag still applies on public
963        // and private team channels.
964        let content = if self.mention_only && !is_direct {
965            let normalized =
966                normalize_mattermost_content(effective_text, bot_user_id, bot_username, post);
967            normalized?
968        } else {
969            effective_text.to_string()
970        };
971
972        // Reply routing depends on thread_replies config:
973        //   - Existing thread (root_id set): always stay in the thread.
974        //   - Top-level post + thread_replies=true: thread on the original post.
975        //   - Top-level post + thread_replies=false: reply at channel level.
976        let reply_target = if !root_id.is_empty() {
977            format!("{}:{}", channel_id, root_id)
978        } else if self.thread_replies {
979            format!("{}:{}", channel_id, id)
980        } else {
981            channel_id.to_string()
982        };
983
984        Some(ChannelMessage {
985            id: format!("mattermost_{id}"),
986            sender: user_id.to_string(),
987            reply_target,
988            content,
989            channel: "mattermost".to_string(),
990            channel_alias: Some(self.alias.clone()),
991            #[allow(clippy::cast_sign_loss)]
992            timestamp: (create_at / 1000) as u64,
993            thread_ts: None,
994            interruption_scope_id: None,
995            attachments: vec![],
996            subject: None,
997        })
998    }
999}
1000
1001fn post_has_audio_attachment(post: &serde_json::Value) -> bool {
1002    let files = post
1003        .get("metadata")
1004        .and_then(|m| m.get("files"))
1005        .and_then(|f| f.as_array());
1006    let Some(files) = files else { return false };
1007    files.iter().any(is_audio_file)
1008}
1009
1010fn is_audio_file(file: &serde_json::Value) -> bool {
1011    let mime = file.get("mime_type").and_then(|m| m.as_str()).unwrap_or("");
1012    if mime.starts_with("audio/") {
1013        return true;
1014    }
1015    let ext = file.get("extension").and_then(|e| e.as_str()).unwrap_or("");
1016    matches!(
1017        ext.to_ascii_lowercase().as_str(),
1018        "ogg" | "mp3" | "m4a" | "wav" | "opus" | "flac"
1019    )
1020}
1021
1022/// Check whether a Mattermost post contains an @-mention of the bot.
1023///
1024/// Checks two sources:
1025/// 1. Text-based: looks for `@bot_username` in the message body (case-insensitive).
1026/// 2. Metadata-based: checks the post's `metadata.mentions` array for the bot user ID.
1027#[cfg(test)]
1028fn contains_bot_mention_mm(
1029    text: &str,
1030    bot_user_id: &str,
1031    bot_username: &str,
1032    post: &serde_json::Value,
1033) -> bool {
1034    // 1. Text-based: @username (case-insensitive, word-boundary aware)
1035    if !find_bot_mention_spans(text, bot_username).is_empty() {
1036        return true;
1037    }
1038
1039    // 2. Metadata-based: Mattermost may include a "metadata.mentions" array of user IDs.
1040    if !bot_user_id.is_empty()
1041        && let Some(mentions) = post
1042            .get("metadata")
1043            .and_then(|m| m.get("mentions"))
1044            .and_then(|m| m.as_array())
1045        && mentions.iter().any(|m| m.as_str() == Some(bot_user_id))
1046    {
1047        return true;
1048    }
1049
1050    false
1051}
1052
1053fn is_mattermost_username_char(c: char) -> bool {
1054    c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.'
1055}
1056
1057fn find_bot_mention_spans(text: &str, bot_username: &str) -> Vec<(usize, usize)> {
1058    if bot_username.is_empty() {
1059        return Vec::new();
1060    }
1061
1062    let mention = format!("@{}", bot_username.to_ascii_lowercase());
1063    let mention_len = mention.len();
1064    if mention_len == 0 {
1065        return Vec::new();
1066    }
1067
1068    let mention_bytes = mention.as_bytes();
1069    let text_bytes = text.as_bytes();
1070    let mut spans = Vec::new();
1071    let mut index = 0;
1072
1073    while index + mention_len <= text_bytes.len() {
1074        let is_match = text_bytes[index] == b'@'
1075            && text_bytes[index..index + mention_len]
1076                .iter()
1077                .zip(mention_bytes.iter())
1078                .all(|(left, right)| left.eq_ignore_ascii_case(right));
1079
1080        if is_match {
1081            let end = index + mention_len;
1082            let at_boundary = text[end..]
1083                .chars()
1084                .next()
1085                .is_none_or(|next| !is_mattermost_username_char(next));
1086            if at_boundary {
1087                spans.push((index, end));
1088                index = end;
1089                continue;
1090            }
1091        }
1092
1093        let step = text[index..].chars().next().map_or(1, char::len_utf8);
1094        index += step;
1095    }
1096
1097    spans
1098}
1099
1100/// Gate incoming Mattermost content when `mention_only` is enabled.
1101///
1102/// Returns `None` if the message doesn't mention the bot, otherwise the
1103/// trimmed text with the mention preserved so downstream consumers can
1104/// see who was addressed.
1105fn normalize_mattermost_content(
1106    text: &str,
1107    bot_user_id: &str,
1108    bot_username: &str,
1109    post: &serde_json::Value,
1110) -> Option<String> {
1111    let mention_spans = find_bot_mention_spans(text, bot_username);
1112    let metadata_mentions_bot = !bot_user_id.is_empty()
1113        && post
1114            .get("metadata")
1115            .and_then(|m| m.get("mentions"))
1116            .and_then(|m| m.as_array())
1117            .is_some_and(|mentions| mentions.iter().any(|m| m.as_str() == Some(bot_user_id)));
1118
1119    if mention_spans.is_empty() && !metadata_mentions_bot {
1120        return None;
1121    }
1122
1123    let trimmed = text.trim();
1124    if trimmed.is_empty() {
1125        return None;
1126    }
1127    Some(trimmed.to_string())
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132    use super::*;
1133    use serde_json::json;
1134
1135    #[test]
1136    fn mattermost_url_trimming() {
1137        let thread_replies = false;
1138        let mention_only = false;
1139        let ch = MattermostChannel::new(
1140            "https://mm.example.com/".into(),
1141            Some("token".into()),
1142            None,
1143            None,
1144            Vec::new(),
1145            "mattermost_test_alias",
1146            Arc::new(Vec::new),
1147            thread_replies,
1148            mention_only,
1149        );
1150        assert_eq!(ch.base_url, "https://mm.example.com");
1151    }
1152
1153    #[test]
1154    fn mattermost_allowlist_wildcard() {
1155        let thread_replies = false;
1156        let mention_only = false;
1157        let ch = MattermostChannel::new(
1158            "url".into(),
1159            Some("token".into()),
1160            None,
1161            None,
1162            Vec::new(),
1163            "mattermost_test_alias",
1164            Arc::new(|| vec!["*".into()]),
1165            thread_replies,
1166            mention_only,
1167        );
1168        assert!(ch.is_user_allowed("any-id"));
1169    }
1170
1171    #[test]
1172    fn mattermost_parse_post_basic() {
1173        let thread_replies = true;
1174        let mention_only = false;
1175        let ch = MattermostChannel::new(
1176            "url".into(),
1177            Some("token".into()),
1178            None,
1179            None,
1180            Vec::new(),
1181            "mattermost_test_alias",
1182            Arc::new(|| vec!["*".into()]),
1183            thread_replies,
1184            mention_only,
1185        );
1186        let post = json!({
1187            "id": "post123",
1188            "user_id": "user456",
1189            "message": "hello world",
1190            "create_at": 1_600_000_000_000_i64,
1191            "root_id": ""
1192        });
1193
1194        let msg = ch
1195            .parse_mattermost_post(
1196                &post,
1197                "bot123",
1198                "botname",
1199                1_500_000_000_000_i64,
1200                "chan789",
1201                None,
1202                false,
1203            )
1204            .unwrap();
1205        assert_eq!(msg.sender, "user456");
1206        assert_eq!(msg.content, "hello world");
1207        assert_eq!(msg.reply_target, "chan789:post123"); // Default threaded reply
1208    }
1209
1210    #[test]
1211    fn mattermost_parse_post_thread_replies_enabled() {
1212        let thread_replies = true;
1213        let mention_only = false;
1214        let ch = MattermostChannel::new(
1215            "url".into(),
1216            Some("token".into()),
1217            None,
1218            None,
1219            Vec::new(),
1220            "mattermost_test_alias",
1221            Arc::new(|| vec!["*".into()]),
1222            thread_replies,
1223            mention_only,
1224        );
1225        let post = json!({
1226            "id": "post123",
1227            "user_id": "user456",
1228            "message": "hello world",
1229            "create_at": 1_600_000_000_000_i64,
1230            "root_id": ""
1231        });
1232
1233        let msg = ch
1234            .parse_mattermost_post(
1235                &post,
1236                "bot123",
1237                "botname",
1238                1_500_000_000_000_i64,
1239                "chan789",
1240                None,
1241                false,
1242            )
1243            .unwrap();
1244        assert_eq!(msg.reply_target, "chan789:post123"); // Threaded reply
1245    }
1246
1247    #[test]
1248    fn mattermost_parse_post_thread() {
1249        let thread_replies = false;
1250        let mention_only = false;
1251        let ch = MattermostChannel::new(
1252            "url".into(),
1253            Some("token".into()),
1254            None,
1255            None,
1256            Vec::new(),
1257            "mattermost_test_alias",
1258            Arc::new(|| vec!["*".into()]),
1259            thread_replies,
1260            mention_only,
1261        );
1262        let post = json!({
1263            "id": "post123",
1264            "user_id": "user456",
1265            "message": "reply",
1266            "create_at": 1_600_000_000_000_i64,
1267            "root_id": "root789"
1268        });
1269
1270        let msg = ch
1271            .parse_mattermost_post(
1272                &post,
1273                "bot123",
1274                "botname",
1275                1_500_000_000_000_i64,
1276                "chan789",
1277                None,
1278                false,
1279            )
1280            .unwrap();
1281        assert_eq!(msg.reply_target, "chan789:root789"); // Stays in the thread
1282    }
1283
1284    #[test]
1285    fn mattermost_parse_post_ignore_self() {
1286        let thread_replies = false;
1287        let mention_only = false;
1288        let ch = MattermostChannel::new(
1289            "url".into(),
1290            Some("token".into()),
1291            None,
1292            None,
1293            Vec::new(),
1294            "mattermost_test_alias",
1295            Arc::new(|| vec!["*".into()]),
1296            thread_replies,
1297            mention_only,
1298        );
1299        let post = json!({
1300            "id": "post123",
1301            "user_id": "bot123",
1302            "message": "my own message",
1303            "create_at": 1_600_000_000_000_i64
1304        });
1305
1306        let msg = ch.parse_mattermost_post(
1307            &post,
1308            "bot123",
1309            "botname",
1310            1_500_000_000_000_i64,
1311            "chan789",
1312            None,
1313            false,
1314        );
1315        assert!(msg.is_none());
1316    }
1317
1318    #[test]
1319    fn mattermost_parse_post_ignore_old() {
1320        let thread_replies = false;
1321        let mention_only = false;
1322        let ch = MattermostChannel::new(
1323            "url".into(),
1324            Some("token".into()),
1325            None,
1326            None,
1327            Vec::new(),
1328            "mattermost_test_alias",
1329            Arc::new(|| vec!["*".into()]),
1330            thread_replies,
1331            mention_only,
1332        );
1333        let post = json!({
1334            "id": "post123",
1335            "user_id": "user456",
1336            "message": "old message",
1337            "create_at": 1_400_000_000_000_i64
1338        });
1339
1340        let msg = ch.parse_mattermost_post(
1341            &post,
1342            "bot123",
1343            "botname",
1344            1_500_000_000_000_i64,
1345            "chan789",
1346            None,
1347            false,
1348        );
1349        assert!(msg.is_none());
1350    }
1351
1352    #[test]
1353    fn mattermost_parse_post_no_thread_when_disabled() {
1354        let thread_replies = false;
1355        let mention_only = false;
1356        let ch = MattermostChannel::new(
1357            "url".into(),
1358            Some("token".into()),
1359            None,
1360            None,
1361            Vec::new(),
1362            "mattermost_test_alias",
1363            Arc::new(|| vec!["*".into()]),
1364            thread_replies,
1365            mention_only,
1366        );
1367        let post = json!({
1368            "id": "post123",
1369            "user_id": "user456",
1370            "message": "hello world",
1371            "create_at": 1_600_000_000_000_i64,
1372            "root_id": ""
1373        });
1374
1375        let msg = ch
1376            .parse_mattermost_post(
1377                &post,
1378                "bot123",
1379                "botname",
1380                1_500_000_000_000_i64,
1381                "chan789",
1382                None,
1383                false,
1384            )
1385            .unwrap();
1386        assert_eq!(msg.reply_target, "chan789"); // No thread suffix
1387    }
1388
1389    #[test]
1390    fn mattermost_existing_thread_always_threads() {
1391        // Even with thread_replies=false, replies to existing threads stay in the thread
1392        let thread_replies = false;
1393        let mention_only = false;
1394        let ch = MattermostChannel::new(
1395            "url".into(),
1396            Some("token".into()),
1397            None,
1398            None,
1399            Vec::new(),
1400            "mattermost_test_alias",
1401            Arc::new(|| vec!["*".into()]),
1402            thread_replies,
1403            mention_only,
1404        );
1405        let post = json!({
1406            "id": "post123",
1407            "user_id": "user456",
1408            "message": "reply in thread",
1409            "create_at": 1_600_000_000_000_i64,
1410            "root_id": "root789"
1411        });
1412
1413        let msg = ch
1414            .parse_mattermost_post(
1415                &post,
1416                "bot123",
1417                "botname",
1418                1_500_000_000_000_i64,
1419                "chan789",
1420                None,
1421                false,
1422            )
1423            .unwrap();
1424        assert_eq!(msg.reply_target, "chan789:root789"); // Stays in existing thread
1425    }
1426
1427    // ── mention_only tests ────────────────────────────────────────
1428
1429    #[test]
1430    fn mention_only_skips_message_without_mention() {
1431        let thread_replies = true;
1432        let mention_only = true;
1433        let ch = MattermostChannel::new(
1434            "url".into(),
1435            Some("token".into()),
1436            None,
1437            None,
1438            Vec::new(),
1439            "mattermost_test_alias",
1440            Arc::new(|| vec!["*".into()]),
1441            thread_replies,
1442            mention_only,
1443        );
1444        let post = json!({
1445            "id": "post1",
1446            "user_id": "user1",
1447            "message": "hello everyone",
1448            "create_at": 1_600_000_000_000_i64,
1449            "root_id": ""
1450        });
1451
1452        let msg = ch.parse_mattermost_post(
1453            &post,
1454            "bot123",
1455            "mybot",
1456            1_500_000_000_000_i64,
1457            "chan1",
1458            None,
1459            false,
1460        );
1461        assert!(msg.is_none());
1462    }
1463
1464    #[test]
1465    fn mention_only_accepts_message_with_at_mention() {
1466        let thread_replies = true;
1467        let mention_only = true;
1468        let ch = MattermostChannel::new(
1469            "url".into(),
1470            Some("token".into()),
1471            None,
1472            None,
1473            Vec::new(),
1474            "mattermost_test_alias",
1475            Arc::new(|| vec!["*".into()]),
1476            thread_replies,
1477            mention_only,
1478        );
1479        let post = json!({
1480            "id": "post1",
1481            "user_id": "user1",
1482            "message": "@mybot what is the weather?",
1483            "create_at": 1_600_000_000_000_i64,
1484            "root_id": ""
1485        });
1486
1487        let msg = ch
1488            .parse_mattermost_post(
1489                &post,
1490                "bot123",
1491                "mybot",
1492                1_500_000_000_000_i64,
1493                "chan1",
1494                None,
1495                false,
1496            )
1497            .unwrap();
1498        assert_eq!(msg.content, "@mybot what is the weather?");
1499    }
1500
1501    #[test]
1502    fn mention_only_preserves_mention_in_body() {
1503        let thread_replies = true;
1504        let mention_only = true;
1505        let ch = MattermostChannel::new(
1506            "url".into(),
1507            Some("token".into()),
1508            None,
1509            None,
1510            Vec::new(),
1511            "mattermost_test_alias",
1512            Arc::new(|| vec!["*".into()]),
1513            thread_replies,
1514            mention_only,
1515        );
1516        let post = json!({
1517            "id": "post1",
1518            "user_id": "user1",
1519            "message": "  @mybot  run status  ",
1520            "create_at": 1_600_000_000_000_i64,
1521            "root_id": ""
1522        });
1523
1524        let msg = ch
1525            .parse_mattermost_post(
1526                &post,
1527                "bot123",
1528                "mybot",
1529                1_500_000_000_000_i64,
1530                "chan1",
1531                None,
1532                false,
1533            )
1534            .unwrap();
1535        assert_eq!(msg.content, "@mybot  run status");
1536    }
1537
1538    #[test]
1539    fn mention_only_admits_caption_that_is_only_the_mention() {
1540        let thread_replies = true;
1541        let mention_only = true;
1542        let ch = MattermostChannel::new(
1543            "url".into(),
1544            Some("token".into()),
1545            None,
1546            None,
1547            Vec::new(),
1548            "mattermost_test_alias",
1549            Arc::new(|| vec!["*".into()]),
1550            thread_replies,
1551            mention_only,
1552        );
1553        let post = json!({
1554            "id": "post1",
1555            "user_id": "user1",
1556            "message": "@mybot",
1557            "create_at": 1_600_000_000_000_i64,
1558            "root_id": ""
1559        });
1560
1561        let msg = ch
1562            .parse_mattermost_post(
1563                &post,
1564                "bot123",
1565                "mybot",
1566                1_500_000_000_000_i64,
1567                "chan1",
1568                None,
1569                false,
1570            )
1571            .unwrap();
1572        assert_eq!(msg.content, "@mybot");
1573    }
1574
1575    #[test]
1576    fn mention_only_case_insensitive() {
1577        let thread_replies = true;
1578        let mention_only = true;
1579        let ch = MattermostChannel::new(
1580            "url".into(),
1581            Some("token".into()),
1582            None,
1583            None,
1584            Vec::new(),
1585            "mattermost_test_alias",
1586            Arc::new(|| vec!["*".into()]),
1587            thread_replies,
1588            mention_only,
1589        );
1590        let post = json!({
1591            "id": "post1",
1592            "user_id": "user1",
1593            "message": "@MyBot hello",
1594            "create_at": 1_600_000_000_000_i64,
1595            "root_id": ""
1596        });
1597
1598        let msg = ch
1599            .parse_mattermost_post(
1600                &post,
1601                "bot123",
1602                "mybot",
1603                1_500_000_000_000_i64,
1604                "chan1",
1605                None,
1606                false,
1607            )
1608            .unwrap();
1609        assert_eq!(msg.content, "@MyBot hello");
1610    }
1611
1612    #[test]
1613    fn mention_only_detects_metadata_mentions() {
1614        // Even without @username in text, metadata.mentions should trigger.
1615        let thread_replies = true;
1616        let mention_only = true;
1617        let ch = MattermostChannel::new(
1618            "url".into(),
1619            Some("token".into()),
1620            None,
1621            None,
1622            Vec::new(),
1623            "mattermost_test_alias",
1624            Arc::new(|| vec!["*".into()]),
1625            thread_replies,
1626            mention_only,
1627        );
1628        let post = json!({
1629            "id": "post1",
1630            "user_id": "user1",
1631            "message": "hey check this out",
1632            "create_at": 1_600_000_000_000_i64,
1633            "root_id": "",
1634            "metadata": {
1635                "mentions": ["bot123"]
1636            }
1637        });
1638
1639        let msg = ch
1640            .parse_mattermost_post(
1641                &post,
1642                "bot123",
1643                "mybot",
1644                1_500_000_000_000_i64,
1645                "chan1",
1646                None,
1647                false,
1648            )
1649            .unwrap();
1650        // Content is preserved as-is since no @username was in the text to strip.
1651        assert_eq!(msg.content, "hey check this out");
1652    }
1653
1654    #[test]
1655    fn mention_only_word_boundary_prevents_partial_match() {
1656        let thread_replies = true;
1657        let mention_only = true;
1658        let ch = MattermostChannel::new(
1659            "url".into(),
1660            Some("token".into()),
1661            None,
1662            None,
1663            Vec::new(),
1664            "mattermost_test_alias",
1665            Arc::new(|| vec!["*".into()]),
1666            thread_replies,
1667            mention_only,
1668        );
1669        // "@mybotextended" should NOT match "@mybot" because it extends the username.
1670        let post = json!({
1671            "id": "post1",
1672            "user_id": "user1",
1673            "message": "@mybotextended hello",
1674            "create_at": 1_600_000_000_000_i64,
1675            "root_id": ""
1676        });
1677
1678        let msg = ch.parse_mattermost_post(
1679            &post,
1680            "bot123",
1681            "mybot",
1682            1_500_000_000_000_i64,
1683            "chan1",
1684            None,
1685            false,
1686        );
1687        assert!(msg.is_none());
1688    }
1689
1690    #[test]
1691    fn mention_only_mention_in_middle_of_text() {
1692        let thread_replies = true;
1693        let mention_only = true;
1694        let ch = MattermostChannel::new(
1695            "url".into(),
1696            Some("token".into()),
1697            None,
1698            None,
1699            Vec::new(),
1700            "mattermost_test_alias",
1701            Arc::new(|| vec!["*".into()]),
1702            thread_replies,
1703            mention_only,
1704        );
1705        let post = json!({
1706            "id": "post1",
1707            "user_id": "user1",
1708            "message": "hey @mybot how are you?",
1709            "create_at": 1_600_000_000_000_i64,
1710            "root_id": ""
1711        });
1712
1713        let msg = ch
1714            .parse_mattermost_post(
1715                &post,
1716                "bot123",
1717                "mybot",
1718                1_500_000_000_000_i64,
1719                "chan1",
1720                None,
1721                false,
1722            )
1723            .unwrap();
1724        assert_eq!(msg.content, "hey @mybot how are you?");
1725    }
1726
1727    #[test]
1728    fn mention_only_disabled_passes_all_messages() {
1729        // With mention_only=false (default), messages pass through unfiltered.
1730        let thread_replies = true;
1731        let mention_only = false;
1732        let ch = MattermostChannel::new(
1733            "url".into(),
1734            Some("token".into()),
1735            None,
1736            None,
1737            Vec::new(),
1738            "mattermost_test_alias",
1739            Arc::new(|| vec!["*".into()]),
1740            thread_replies,
1741            mention_only,
1742        );
1743        let post = json!({
1744            "id": "post1",
1745            "user_id": "user1",
1746            "message": "no mention here",
1747            "create_at": 1_600_000_000_000_i64,
1748            "root_id": ""
1749        });
1750
1751        let msg = ch
1752            .parse_mattermost_post(
1753                &post,
1754                "bot123",
1755                "mybot",
1756                1_500_000_000_000_i64,
1757                "chan1",
1758                None,
1759                false,
1760            )
1761            .unwrap();
1762        assert_eq!(msg.content, "no mention here");
1763    }
1764
1765    // ── contains_bot_mention_mm unit tests ────────────────────────
1766
1767    #[test]
1768    fn contains_mention_text_at_end() {
1769        let post = json!({});
1770        assert!(contains_bot_mention_mm(
1771            "hello @mybot",
1772            "bot123",
1773            "mybot",
1774            &post
1775        ));
1776    }
1777
1778    #[test]
1779    fn contains_mention_text_at_start() {
1780        let post = json!({});
1781        assert!(contains_bot_mention_mm(
1782            "@mybot hello",
1783            "bot123",
1784            "mybot",
1785            &post
1786        ));
1787    }
1788
1789    #[test]
1790    fn contains_mention_text_alone() {
1791        let post = json!({});
1792        assert!(contains_bot_mention_mm("@mybot", "bot123", "mybot", &post));
1793    }
1794
1795    #[test]
1796    fn no_mention_different_username() {
1797        let post = json!({});
1798        assert!(!contains_bot_mention_mm(
1799            "@otherbot hello",
1800            "bot123",
1801            "mybot",
1802            &post
1803        ));
1804    }
1805
1806    #[test]
1807    fn no_mention_partial_username() {
1808        let post = json!({});
1809        // "mybot" is a prefix of "mybotx" — should NOT match
1810        assert!(!contains_bot_mention_mm(
1811            "@mybotx hello",
1812            "bot123",
1813            "mybot",
1814            &post
1815        ));
1816    }
1817
1818    #[test]
1819    fn mention_detects_later_valid_mention_after_partial_prefix() {
1820        let post = json!({});
1821        assert!(contains_bot_mention_mm(
1822            "@mybotx ignore this, but @mybot handle this",
1823            "bot123",
1824            "mybot",
1825            &post
1826        ));
1827    }
1828
1829    #[test]
1830    fn mention_followed_by_punctuation() {
1831        let post = json!({});
1832        // "@mybot," — comma is not alphanumeric/underscore/dash/dot, so it's a boundary
1833        assert!(contains_bot_mention_mm(
1834            "@mybot, hello",
1835            "bot123",
1836            "mybot",
1837            &post
1838        ));
1839    }
1840
1841    #[test]
1842    fn mention_via_metadata_only() {
1843        let post = json!({
1844            "metadata": { "mentions": ["bot123"] }
1845        });
1846        assert!(contains_bot_mention_mm(
1847            "no at mention",
1848            "bot123",
1849            "mybot",
1850            &post
1851        ));
1852    }
1853
1854    #[test]
1855    fn no_mention_empty_username_no_metadata() {
1856        let post = json!({});
1857        assert!(!contains_bot_mention_mm("hello world", "bot123", "", &post));
1858    }
1859
1860    // ── normalize_mattermost_content unit tests ───────────────────
1861
1862    #[test]
1863    fn normalize_preserves_mention_and_trims() {
1864        let post = json!({});
1865        let result = normalize_mattermost_content("  @mybot  do stuff  ", "bot123", "mybot", &post);
1866        assert_eq!(result.as_deref(), Some("@mybot  do stuff"));
1867    }
1868
1869    #[test]
1870    fn normalize_returns_none_for_no_mention() {
1871        let post = json!({});
1872        let result = normalize_mattermost_content("hello world", "bot123", "mybot", &post);
1873        assert!(result.is_none());
1874    }
1875
1876    #[test]
1877    fn normalize_admits_mention_only_caption() {
1878        let post = json!({});
1879        let result = normalize_mattermost_content("@mybot", "bot123", "mybot", &post);
1880        assert_eq!(result.as_deref(), Some("@mybot"));
1881    }
1882
1883    #[test]
1884    fn normalize_preserves_text_for_metadata_mention() {
1885        let post = json!({
1886            "metadata": { "mentions": ["bot123"] }
1887        });
1888        let result = normalize_mattermost_content("check this out", "bot123", "mybot", &post);
1889        assert_eq!(result.as_deref(), Some("check this out"));
1890    }
1891
1892    #[test]
1893    fn normalize_preserves_multiple_mentions() {
1894        let post = json!({});
1895        let result =
1896            normalize_mattermost_content("@mybot hello @mybot world", "bot123", "mybot", &post);
1897        assert_eq!(result.as_deref(), Some("@mybot hello @mybot world"));
1898    }
1899
1900    #[test]
1901    fn normalize_keeps_partial_username_mentions() {
1902        let post = json!({});
1903        let result =
1904            normalize_mattermost_content("@mybot hello @mybotx world", "bot123", "mybot", &post);
1905        assert_eq!(result.as_deref(), Some("@mybot hello @mybotx world"));
1906    }
1907
1908    // ── Transcription tests ───────────────────────────────────────
1909
1910    #[test]
1911    fn mattermost_manager_none_when_transcription_not_configured() {
1912        let thread_replies = false;
1913        let mention_only = false;
1914        let ch = MattermostChannel::new(
1915            "url".into(),
1916            Some("token".into()),
1917            None,
1918            None,
1919            Vec::new(),
1920            "mattermost_test_alias",
1921            Arc::new(|| vec!["*".into()]),
1922            thread_replies,
1923            mention_only,
1924        );
1925        assert!(ch.transcription_manager.is_none());
1926    }
1927
1928    #[test]
1929    fn mattermost_manager_some_when_valid_config() {
1930        let thread_replies = false;
1931        let mention_only = false;
1932        let ch = MattermostChannel::new(
1933            "url".into(),
1934            Some("token".into()),
1935            None,
1936            None,
1937            Vec::new(),
1938            "mattermost_test_alias",
1939            Arc::new(|| vec!["*".into()]),
1940            thread_replies,
1941            mention_only,
1942        )
1943        .with_transcription(zeroclaw_config::schema::TranscriptionConfig {
1944            enabled: true,
1945            api_key: Some("test_key".to_string()),
1946            api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
1947            model: "whisper-large-v3".to_string(),
1948            language: None,
1949            initial_prompt: None,
1950            max_duration_secs: 600,
1951            openai: None,
1952            deepgram: None,
1953            assemblyai: None,
1954            google: None,
1955            local_whisper: None,
1956            transcribe_non_ptt_audio: false,
1957        });
1958        assert!(ch.transcription_manager.is_some());
1959    }
1960
1961    #[test]
1962    fn mattermost_manager_none_and_warn_on_init_failure() {
1963        let thread_replies = false;
1964        let mention_only = false;
1965        let ch = MattermostChannel::new(
1966            "url".into(),
1967            Some("token".into()),
1968            None,
1969            None,
1970            Vec::new(),
1971            "mattermost_test_alias",
1972            Arc::new(|| vec!["*".into()]),
1973            thread_replies,
1974            mention_only,
1975        )
1976        .with_transcription(zeroclaw_config::schema::TranscriptionConfig {
1977            enabled: true,
1978            api_key: Some(String::new()),
1979            api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
1980            model: "whisper-large-v3".to_string(),
1981            language: None,
1982            initial_prompt: None,
1983            max_duration_secs: 600,
1984            openai: None,
1985            deepgram: None,
1986            assemblyai: None,
1987            google: None,
1988            local_whisper: None,
1989            transcribe_non_ptt_audio: false,
1990        });
1991        assert!(ch.transcription_manager.is_none());
1992    }
1993
1994    #[test]
1995    fn mattermost_post_has_audio_attachment_true_for_audio_mime() {
1996        let post = json!({
1997            "metadata": {
1998                "files": [
1999                    {
2000                        "id": "file1",
2001                        "mime_type": "audio/ogg",
2002                        "name": "voice.ogg"
2003                    }
2004                ]
2005            }
2006        });
2007        assert!(post_has_audio_attachment(&post));
2008    }
2009
2010    #[test]
2011    fn mattermost_post_has_audio_attachment_true_for_audio_ext() {
2012        let post = json!({
2013            "metadata": {
2014                "files": [
2015                    {
2016                        "id": "file1",
2017                        "mime_type": "application/octet-stream",
2018                        "extension": "ogg"
2019                    }
2020                ]
2021            }
2022        });
2023        assert!(post_has_audio_attachment(&post));
2024    }
2025
2026    #[test]
2027    fn mattermost_post_has_audio_attachment_false_for_image() {
2028        let post = json!({
2029            "metadata": {
2030                "files": [
2031                    {
2032                        "id": "file1",
2033                        "mime_type": "image/png",
2034                        "name": "screenshot.png"
2035                    }
2036                ]
2037            }
2038        });
2039        assert!(!post_has_audio_attachment(&post));
2040    }
2041
2042    #[test]
2043    fn mattermost_post_has_audio_attachment_false_when_no_files() {
2044        let post = json!({
2045            "metadata": {}
2046        });
2047        assert!(!post_has_audio_attachment(&post));
2048    }
2049
2050    #[test]
2051    fn mattermost_parse_post_uses_injected_text() {
2052        let thread_replies = true;
2053        let mention_only = false;
2054        let ch = MattermostChannel::new(
2055            "url".into(),
2056            Some("token".into()),
2057            None,
2058            None,
2059            Vec::new(),
2060            "mattermost_test_alias",
2061            Arc::new(|| vec!["*".into()]),
2062            thread_replies,
2063            mention_only,
2064        );
2065        let post = json!({
2066            "id": "post123",
2067            "user_id": "user456",
2068            "message": "",
2069            "create_at": 1_600_000_000_000_i64,
2070            "root_id": ""
2071        });
2072
2073        let msg = ch
2074            .parse_mattermost_post(
2075                &post,
2076                "bot123",
2077                "botname",
2078                1_500_000_000_000_i64,
2079                "chan789",
2080                Some("transcript text"),
2081                false,
2082            )
2083            .unwrap();
2084        assert_eq!(msg.content, "transcript text");
2085    }
2086
2087    #[test]
2088    fn mattermost_parse_post_rejects_empty_message_without_injected() {
2089        let thread_replies = true;
2090        let mention_only = false;
2091        let ch = MattermostChannel::new(
2092            "url".into(),
2093            Some("token".into()),
2094            None,
2095            None,
2096            Vec::new(),
2097            "mattermost_test_alias",
2098            Arc::new(|| vec!["*".into()]),
2099            thread_replies,
2100            mention_only,
2101        );
2102        let post = json!({
2103            "id": "post123",
2104            "user_id": "user456",
2105            "message": "",
2106            "create_at": 1_600_000_000_000_i64,
2107            "root_id": ""
2108        });
2109
2110        let msg = ch.parse_mattermost_post(
2111            &post,
2112            "bot123",
2113            "botname",
2114            1_500_000_000_000_i64,
2115            "chan789",
2116            None,
2117            false,
2118        );
2119        assert!(msg.is_none());
2120    }
2121
2122    #[tokio::test]
2123    async fn mattermost_transcribe_skips_when_manager_none() {
2124        let thread_replies = false;
2125        let mention_only = false;
2126        let ch = MattermostChannel::new(
2127            "url".into(),
2128            Some("token".into()),
2129            None,
2130            None,
2131            Vec::new(),
2132            "mattermost_test_alias",
2133            Arc::new(|| vec!["*".into()]),
2134            thread_replies,
2135            mention_only,
2136        );
2137        let post = json!({
2138            "metadata": {
2139                "files": [
2140                    {
2141                        "id": "file1",
2142                        "mime_type": "audio/ogg",
2143                        "name": "voice.ogg"
2144                    }
2145                ]
2146            }
2147        });
2148        let result = ch.try_transcribe_audio_attachment(&post).await;
2149        assert!(result.is_none());
2150    }
2151
2152    #[tokio::test]
2153    async fn mattermost_transcribe_skips_over_duration_limit() {
2154        let thread_replies = false;
2155        let mention_only = false;
2156        let ch = MattermostChannel::new(
2157            "url".into(),
2158            Some("token".into()),
2159            None,
2160            None,
2161            Vec::new(),
2162            "mattermost_test_alias",
2163            Arc::new(|| vec!["*".into()]),
2164            thread_replies,
2165            mention_only,
2166        )
2167        .with_transcription(zeroclaw_config::schema::TranscriptionConfig {
2168            enabled: true,
2169            api_key: Some("test_key".to_string()),
2170            api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
2171            model: "whisper-large-v3".to_string(),
2172            language: None,
2173            initial_prompt: None,
2174            max_duration_secs: 3600,
2175            openai: None,
2176            deepgram: None,
2177            assemblyai: None,
2178            google: None,
2179            local_whisper: None,
2180            transcribe_non_ptt_audio: false,
2181        });
2182
2183        let post = json!({
2184            "metadata": {
2185                "files": [
2186                    {
2187                        "id": "file1",
2188                        "mime_type": "audio/ogg",
2189                        "name": "voice.ogg",
2190                        "duration": 7_200_000_u64
2191                    }
2192                ]
2193            }
2194        });
2195
2196        let result = ch.try_transcribe_audio_attachment(&post).await;
2197        assert!(result.is_none());
2198    }
2199
2200    #[cfg(test)]
2201    mod http_tests {
2202        use super::*;
2203        use wiremock::matchers::{method, path};
2204        use wiremock::{Mock, MockServer, ResponseTemplate};
2205
2206        #[tokio::test]
2207        async fn mattermost_audio_routes_through_local_whisper() {
2208            let mock_server = MockServer::start().await;
2209
2210            Mock::given(method("GET"))
2211                .and(path("/api/v4/files/file1"))
2212                .respond_with(ResponseTemplate::new(200).set_body_bytes(b"audio bytes"))
2213                .mount(&mock_server)
2214                .await;
2215
2216            Mock::given(method("POST"))
2217                .and(path("/v1/audio/transcriptions"))
2218                .respond_with(
2219                    ResponseTemplate::new(200).set_body_json(json!({"text": "test transcript"})),
2220                )
2221                .mount(&mock_server)
2222                .await;
2223
2224            let whisper_url = format!("{}/v1/audio/transcriptions", mock_server.uri());
2225            let thread_replies = false;
2226            let mention_only = false;
2227            let ch = MattermostChannel::new(
2228                mock_server.uri(),
2229                Some("test_token".to_string()),
2230                None,
2231                None,
2232                Vec::new(),
2233                "mattermost_test_alias",
2234                Arc::new(|| vec!["*".into()]),
2235                thread_replies,
2236                mention_only,
2237            )
2238            .with_transcription(zeroclaw_config::schema::TranscriptionConfig {
2239                enabled: true,
2240                api_key: None,
2241                api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
2242                model: "whisper-large-v3".to_string(),
2243                language: None,
2244                initial_prompt: None,
2245                max_duration_secs: 600,
2246                openai: None,
2247                deepgram: None,
2248                assemblyai: None,
2249                google: None,
2250                local_whisper: Some(zeroclaw_config::schema::LocalWhisperConfig {
2251                    url: whisper_url,
2252                    bearer_token: Some("test_token".to_string()),
2253                    max_audio_bytes: 25_000_000,
2254                    timeout_secs: 300,
2255                }),
2256                transcribe_non_ptt_audio: false,
2257            });
2258
2259            let post = json!({
2260                "metadata": {
2261                    "files": [
2262                        {
2263                            "id": "file1",
2264                            "mime_type": "audio/ogg",
2265                            "name": "voice.ogg"
2266                        }
2267                    ]
2268                }
2269            });
2270
2271            let result = ch.try_transcribe_audio_attachment(&post).await;
2272            assert_eq!(result.as_deref(), Some("[Voice] test transcript"));
2273        }
2274
2275        #[tokio::test]
2276        async fn mattermost_audio_skips_non_audio_attachment() {
2277            let mock_server = MockServer::start().await;
2278
2279            let thread_replies = false;
2280            let mention_only = false;
2281            let ch = MattermostChannel::new(
2282                mock_server.uri(),
2283                Some("test_token".to_string()),
2284                None,
2285                None,
2286                Vec::new(),
2287                "mattermost_test_alias",
2288                Arc::new(|| vec!["*".into()]),
2289                thread_replies,
2290                mention_only,
2291            )
2292            .with_transcription(zeroclaw_config::schema::TranscriptionConfig {
2293                enabled: true,
2294                api_key: None,
2295                api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
2296                model: "whisper-large-v3".to_string(),
2297                language: None,
2298                initial_prompt: None,
2299                max_duration_secs: 600,
2300                openai: None,
2301                deepgram: None,
2302                assemblyai: None,
2303                google: None,
2304                local_whisper: Some(zeroclaw_config::schema::LocalWhisperConfig {
2305                    url: mock_server.uri(),
2306                    bearer_token: Some("test_token".to_string()),
2307                    max_audio_bytes: 25_000_000,
2308                    timeout_secs: 300,
2309                }),
2310                transcribe_non_ptt_audio: false,
2311            });
2312
2313            let post = json!({
2314                "metadata": {
2315                    "files": [
2316                        {
2317                            "id": "file1",
2318                            "mime_type": "image/png",
2319                            "name": "screenshot.png"
2320                        }
2321                    ]
2322                }
2323            });
2324
2325            let result = ch.try_transcribe_audio_attachment(&post).await;
2326            assert!(result.is_none());
2327        }
2328    }
2329
2330    // ── Multi-channel + DM contract (red) ────────────────────────────
2331
2332    fn make_ch_for_scope(channel_ids: Vec<String>) -> MattermostChannel {
2333        MattermostChannel::new(
2334            "https://mm.example.com".into(),
2335            Some("token".into()),
2336            None,
2337            None,
2338            channel_ids,
2339            "mattermost_scope_alias",
2340            Arc::new(|| vec!["*".into()]),
2341            true,
2342            false,
2343        )
2344    }
2345
2346    #[test]
2347    fn normalized_channel_id_strips_wildcard_and_blank() {
2348        assert_eq!(MattermostChannel::normalized_channel_id(None), None);
2349        assert_eq!(MattermostChannel::normalized_channel_id(Some("")), None);
2350        assert_eq!(MattermostChannel::normalized_channel_id(Some("   ")), None);
2351        assert_eq!(MattermostChannel::normalized_channel_id(Some("*")), None);
2352        assert_eq!(
2353            MattermostChannel::normalized_channel_id(Some("  abc123 ")),
2354            Some("abc123".to_string())
2355        );
2356    }
2357
2358    #[test]
2359    fn scoped_channel_ids_empty_returns_none() {
2360        let ch = make_ch_for_scope(Vec::new());
2361        assert_eq!(ch.scoped_channel_ids(), None);
2362    }
2363
2364    #[test]
2365    fn scoped_channel_ids_wildcard_only_returns_none() {
2366        let ch = make_ch_for_scope(vec!["*".into()]);
2367        assert_eq!(ch.scoped_channel_ids(), None);
2368    }
2369
2370    #[test]
2371    fn scoped_channel_ids_explicit_returns_dedup() {
2372        let ch = make_ch_for_scope(vec![
2373            "abc".into(),
2374            "  def  ".into(),
2375            "abc".into(),
2376            "*".into(),
2377            "".into(),
2378        ]);
2379        assert_eq!(
2380            ch.scoped_channel_ids(),
2381            Some(vec!["abc".to_string(), "def".to_string()])
2382        );
2383    }
2384
2385    #[test]
2386    fn is_direct_channel_treats_dm_and_group_dm_as_direct() {
2387        assert!(is_direct_channel("D"));
2388        assert!(is_direct_channel("G"));
2389    }
2390
2391    #[test]
2392    fn is_direct_channel_rejects_public_and_private_team_channels() {
2393        assert!(!is_direct_channel("O"));
2394        assert!(!is_direct_channel("P"));
2395        assert!(!is_direct_channel(""));
2396        assert!(!is_direct_channel("X"));
2397    }
2398
2399    fn ch_obj(id: &str, ty: &str, team: &str) -> serde_json::Value {
2400        json!({"id": id, "type": ty, "team_id": team})
2401    }
2402
2403    #[test]
2404    fn filter_discovered_channels_includes_all_when_no_filters() {
2405        let raw = vec![
2406            ch_obj("pub1", "O", "teamA"),
2407            ch_obj("priv1", "P", "teamA"),
2408            ch_obj("dm1", "D", ""),
2409            ch_obj("gdm1", "G", ""),
2410        ];
2411        let kept = filter_discovered_channels(&raw, &[], true);
2412        let ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();
2413        assert_eq!(ids, vec!["pub1", "priv1", "dm1", "gdm1"]);
2414        assert!(!kept[0].is_direct);
2415        assert!(!kept[1].is_direct);
2416        assert!(kept[2].is_direct);
2417        assert!(kept[3].is_direct);
2418    }
2419
2420    #[test]
2421    fn filter_discovered_channels_respects_team_ids_allowlist() {
2422        let raw = vec![
2423            ch_obj("pub_a", "O", "teamA"),
2424            ch_obj("pub_b", "O", "teamB"),
2425            ch_obj("priv_a", "P", "teamA"),
2426        ];
2427        let kept = filter_discovered_channels(&raw, &["teamA".to_string()], true);
2428        let ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();
2429        assert_eq!(ids, vec!["pub_a", "priv_a"]);
2430    }
2431
2432    #[test]
2433    fn filter_discovered_channels_omits_dms_when_discover_dms_false() {
2434        let raw = vec![
2435            ch_obj("pub1", "O", "teamA"),
2436            ch_obj("dm1", "D", ""),
2437            ch_obj("gdm1", "G", ""),
2438        ];
2439        let kept = filter_discovered_channels(&raw, &[], false);
2440        let ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();
2441        assert_eq!(ids, vec!["pub1"]);
2442    }
2443
2444    #[test]
2445    fn filter_discovered_channels_keeps_dms_regardless_of_team_ids() {
2446        let raw = vec![
2447            ch_obj("pub_b", "O", "teamB"),
2448            ch_obj("dm1", "D", ""),
2449            ch_obj("gdm1", "G", ""),
2450        ];
2451        let kept = filter_discovered_channels(&raw, &["teamA".to_string()], true);
2452        let ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();
2453        assert_eq!(ids, vec!["dm1", "gdm1"]);
2454    }
2455
2456    #[test]
2457    fn mention_only_bypassed_for_direct_channels_in_parse() {
2458        let ch = MattermostChannel::new(
2459            "url".into(),
2460            Some("token".into()),
2461            None,
2462            None,
2463            Vec::new(),
2464            "mattermost_dm_alias",
2465            Arc::new(|| vec!["*".into()]),
2466            false,
2467            true,
2468        );
2469        let post = json!({
2470            "id": "post1",
2471            "user_id": "user1",
2472            "message": "no mention here, just talking",
2473            "create_at": 1_600_000_000_000_i64,
2474            "root_id": ""
2475        });
2476
2477        let msg = ch
2478            .parse_mattermost_post(
2479                &post,
2480                "bot123",
2481                "mybot",
2482                1_500_000_000_000_i64,
2483                "dm_channel",
2484                None,
2485                true,
2486            )
2487            .expect("DM message must bypass mention_only and produce a ChannelMessage");
2488        assert_eq!(msg.content, "no mention here, just talking");
2489    }
2490
2491    #[test]
2492    fn mention_only_applied_in_parse_when_is_direct_false() {
2493        let ch = MattermostChannel::new(
2494            "url".into(),
2495            Some("token".into()),
2496            None,
2497            None,
2498            Vec::new(),
2499            "mattermost_group_alias",
2500            Arc::new(|| vec!["*".into()]),
2501            false,
2502            true,
2503        );
2504        let post = json!({
2505            "id": "post1",
2506            "user_id": "user1",
2507            "message": "no mention here, just talking",
2508            "create_at": 1_600_000_000_000_i64,
2509            "root_id": ""
2510        });
2511
2512        let msg = ch.parse_mattermost_post(
2513            &post,
2514            "bot123",
2515            "mybot",
2516            1_500_000_000_000_i64,
2517            "pub_channel",
2518            None,
2519            false,
2520        );
2521        assert!(msg.is_none(), "public channel must enforce mention_only");
2522    }
2523
2524    #[cfg(test)]
2525    mod discovery_http_tests {
2526        use super::*;
2527        use wiremock::matchers::{method, path};
2528        use wiremock::{Mock, MockServer, ResponseTemplate};
2529
2530        #[tokio::test]
2531        async fn list_target_channels_discovers_via_users_me_channels() {
2532            let mock_server = MockServer::start().await;
2533
2534            Mock::given(method("GET"))
2535                .and(path("/api/v4/users/me"))
2536                .respond_with(
2537                    ResponseTemplate::new(200)
2538                        .set_body_json(json!({"id": "bot123", "username": "mybot"})),
2539                )
2540                .mount(&mock_server)
2541                .await;
2542
2543            Mock::given(method("GET"))
2544                .and(path("/api/v4/users/me/channels"))
2545                .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2546                    {"id": "pub_a", "type": "O", "team_id": "teamA"},
2547                    {"id": "pub_b", "type": "O", "team_id": "teamB"},
2548                    {"id": "dm_x",  "type": "D", "team_id": ""},
2549                    {"id": "gdm_y", "type": "G", "team_id": ""},
2550                ])))
2551                .mount(&mock_server)
2552                .await;
2553
2554            let ch = MattermostChannel::new(
2555                mock_server.uri(),
2556                Some("token".into()),
2557                None,
2558                None,
2559                Vec::new(),
2560                "mattermost_discover_alias",
2561                Arc::new(|| vec!["*".into()]),
2562                false,
2563                false,
2564            )
2565            .with_team_ids(vec!["teamA".to_string()])
2566            .with_discover_dms(true);
2567
2568            let targets = ch
2569                .list_target_channels()
2570                .await
2571                .expect("discovery must succeed");
2572            let ids: Vec<&str> = targets.iter().map(|t| t.id.as_str()).collect();
2573            assert_eq!(
2574                ids,
2575                vec!["pub_a", "dm_x", "gdm_y"],
2576                "discovery should keep teamA channels and all DMs"
2577            );
2578            assert!(!targets[0].is_direct);
2579            assert!(targets[1].is_direct);
2580            assert!(targets[2].is_direct);
2581        }
2582
2583        #[tokio::test]
2584        async fn list_target_channels_explicit_ids_skip_discovery_and_lookup_types() {
2585            let mock_server = MockServer::start().await;
2586
2587            Mock::given(method("GET"))
2588                .and(path("/api/v4/channels/explicit_dm"))
2589                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2590                    "id": "explicit_dm",
2591                    "type": "D",
2592                    "team_id": ""
2593                })))
2594                .mount(&mock_server)
2595                .await;
2596
2597            Mock::given(method("GET"))
2598                .and(path("/api/v4/channels/explicit_pub"))
2599                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2600                    "id": "explicit_pub",
2601                    "type": "O",
2602                    "team_id": "teamA"
2603                })))
2604                .mount(&mock_server)
2605                .await;
2606
2607            let ch = MattermostChannel::new(
2608                mock_server.uri(),
2609                Some("token".into()),
2610                None,
2611                None,
2612                vec!["explicit_dm".into(), "explicit_pub".into()],
2613                "mattermost_explicit_alias",
2614                Arc::new(|| vec!["*".into()]),
2615                false,
2616                false,
2617            );
2618
2619            let targets = ch
2620                .list_target_channels()
2621                .await
2622                .expect("explicit lookup must succeed");
2623            let by_id: std::collections::HashMap<_, _> = targets
2624                .iter()
2625                .map(|t| (t.id.as_str(), t.is_direct))
2626                .collect();
2627            assert_eq!(by_id.get("explicit_dm"), Some(&true));
2628            assert_eq!(by_id.get("explicit_pub"), Some(&false));
2629            assert_eq!(targets.len(), 2);
2630        }
2631    }
2632}