Skip to main content

construct/channels/
mattermost.rs

1use super::traits::{Channel, ChannelMessage, SendMessage};
2use anyhow::{Result, bail};
3use async_trait::async_trait;
4use parking_lot::Mutex;
5use std::sync::Arc;
6
7const MAX_MATTERMOST_AUDIO_BYTES: u64 = 25 * 1024 * 1024;
8
9/// Mattermost channel — polls channel posts via REST API v4.
10/// Mattermost is API-compatible with many Slack patterns but uses a dedicated v4 structure.
11pub struct MattermostChannel {
12    base_url: String, // e.g., https://mm.example.com
13    bot_token: String,
14    channel_id: Option<String>,
15    allowed_users: Vec<String>,
16    /// When true (default), replies thread on the original post's root_id.
17    /// When false, replies go to the channel root.
18    thread_replies: bool,
19    /// When true, only respond to messages that @-mention the bot.
20    mention_only: bool,
21    /// Handle for the background typing-indicator loop (aborted on stop_typing).
22    typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
23    /// Per-channel proxy URL override.
24    proxy_url: Option<String>,
25    transcription: Option<crate::config::TranscriptionConfig>,
26    transcription_manager: Option<Arc<super::transcription::TranscriptionManager>>,
27}
28
29impl MattermostChannel {
30    pub fn new(
31        base_url: String,
32        bot_token: String,
33        channel_id: Option<String>,
34        allowed_users: Vec<String>,
35        thread_replies: bool,
36        mention_only: bool,
37    ) -> Self {
38        // Ensure base_url doesn't have a trailing slash for consistent path joining
39        let base_url = base_url.trim_end_matches('/').to_string();
40        Self {
41            base_url,
42            bot_token,
43            channel_id,
44            allowed_users,
45            thread_replies,
46            mention_only,
47            typing_handle: Mutex::new(None),
48            proxy_url: None,
49            transcription: None,
50            transcription_manager: None,
51        }
52    }
53
54    /// Set a per-channel proxy URL that overrides the global proxy config.
55    pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
56        self.proxy_url = proxy_url;
57        self
58    }
59
60    pub fn with_transcription(mut self, config: crate::config::TranscriptionConfig) -> Self {
61        if !config.enabled {
62            return self;
63        }
64        match super::transcription::TranscriptionManager::new(&config) {
65            Ok(m) => {
66                self.transcription_manager = Some(Arc::new(m));
67                self.transcription = Some(config);
68            }
69            Err(e) => {
70                tracing::warn!(
71                    "transcription manager init failed, voice transcription disabled: {e}"
72                );
73            }
74        }
75        self
76    }
77
78    fn http_client(&self) -> reqwest::Client {
79        crate::config::build_channel_proxy_client("channel.mattermost", self.proxy_url.as_deref())
80    }
81
82    /// Check if a user ID is in the allowlist.
83    /// Empty list means deny everyone. "*" means allow everyone.
84    fn is_user_allowed(&self, user_id: &str) -> bool {
85        self.allowed_users.iter().any(|u| u == "*" || u == user_id)
86    }
87
88    /// Get the bot's own user ID and username so we can ignore our own messages
89    /// and detect @-mentions by username.
90    async fn get_bot_identity(&self) -> (String, String) {
91        let resp: Option<serde_json::Value> = async {
92            self.http_client()
93                .get(format!("{}/api/v4/users/me", self.base_url))
94                .bearer_auth(&self.bot_token)
95                .send()
96                .await
97                .ok()?
98                .json()
99                .await
100                .ok()
101        }
102        .await;
103
104        let id = resp
105            .as_ref()
106            .and_then(|v| v.get("id"))
107            .and_then(|u| u.as_str())
108            .unwrap_or("")
109            .to_string();
110        let username = resp
111            .as_ref()
112            .and_then(|v| v.get("username"))
113            .and_then(|u| u.as_str())
114            .unwrap_or("")
115            .to_string();
116        (id, username)
117    }
118
119    async fn try_transcribe_audio_attachment(&self, post: &serde_json::Value) -> Option<String> {
120        let config = self.transcription.as_ref()?;
121        let manager = self.transcription_manager.as_deref()?;
122
123        let files = post
124            .get("metadata")
125            .and_then(|m| m.get("files"))
126            .and_then(|f| f.as_array())?;
127
128        let audio_file = files.iter().find(|f| is_audio_file(f))?;
129
130        if let Some(duration_ms) = audio_file.get("duration").and_then(|d| d.as_u64()) {
131            let duration_secs = duration_ms / 1000;
132            if duration_secs > config.max_duration_secs as u64 {
133                tracing::debug!(
134                    duration_secs,
135                    max = config.max_duration_secs,
136                    "Mattermost audio attachment exceeds max duration, skipping"
137                );
138                return None;
139            }
140        }
141
142        let file_id = audio_file.get("id").and_then(|i| i.as_str())?;
143        let file_name = audio_file
144            .get("name")
145            .and_then(|n| n.as_str())
146            .unwrap_or("audio");
147
148        let response = match self
149            .http_client()
150            .get(format!("{}/api/v4/files/{}", self.base_url, file_id))
151            .bearer_auth(&self.bot_token)
152            .send()
153            .await
154        {
155            Ok(r) => r,
156            Err(e) => {
157                tracing::warn!("Mattermost: audio download failed for {file_id}: {e}");
158                return None;
159            }
160        };
161
162        if !response.status().is_success() {
163            tracing::warn!(
164                "Mattermost: audio download returned {}: {file_id}",
165                response.status()
166            );
167            return None;
168        }
169
170        if let Some(content_length) = response.content_length() {
171            if content_length > MAX_MATTERMOST_AUDIO_BYTES {
172                tracing::warn!(
173                    "Mattermost: audio file too large ({content_length} bytes): {file_id}"
174                );
175                return None;
176            }
177        }
178
179        let bytes = match response.bytes().await {
180            Ok(b) => b,
181            Err(e) => {
182                tracing::warn!("Mattermost: failed to read audio bytes for {file_id}: {e}");
183                return None;
184            }
185        };
186
187        match manager.transcribe(&bytes, file_name).await {
188            Ok(text) => {
189                let trimmed = text.trim();
190                if trimmed.is_empty() {
191                    tracing::info!("Mattermost: transcription returned empty text, skipping");
192                    None
193                } else {
194                    Some(format!("[Voice] {trimmed}"))
195                }
196            }
197            Err(e) => {
198                tracing::warn!("Mattermost audio transcription failed: {e}");
199                None
200            }
201        }
202    }
203}
204
205#[async_trait]
206impl Channel for MattermostChannel {
207    fn name(&self) -> &str {
208        "mattermost"
209    }
210
211    async fn send(&self, message: &SendMessage) -> Result<()> {
212        // Mattermost supports threading via 'root_id'.
213        // We pack 'channel_id:root_id' into recipient if it's a thread.
214        let (channel_id, root_id) = if let Some((c, r)) = message.recipient.split_once(':') {
215            (c, Some(r))
216        } else {
217            (message.recipient.as_str(), None)
218        };
219
220        let mut body_map = serde_json::json!({
221            "channel_id": channel_id,
222            "message": message.content
223        });
224
225        if let Some(root) = root_id {
226            body_map.as_object_mut().unwrap().insert(
227                "root_id".to_string(),
228                serde_json::Value::String(root.to_string()),
229            );
230        }
231
232        let resp = self
233            .http_client()
234            .post(format!("{}/api/v4/posts", self.base_url))
235            .bearer_auth(&self.bot_token)
236            .json(&body_map)
237            .send()
238            .await?;
239
240        let status = resp.status();
241        if !status.is_success() {
242            let body = resp
243                .text()
244                .await
245                .unwrap_or_else(|e| format!("<failed to read response: {e}>"));
246            bail!("Mattermost post failed ({status}): {body}");
247        }
248
249        Ok(())
250    }
251
252    async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
253        let channel_id = self
254            .channel_id
255            .clone()
256            .ok_or_else(|| anyhow::anyhow!("Mattermost channel_id required for listening"))?;
257
258        let (bot_user_id, bot_username) = self.get_bot_identity().await;
259        #[allow(clippy::cast_possible_truncation)]
260        let mut last_create_at = (std::time::SystemTime::now()
261            .duration_since(std::time::UNIX_EPOCH)
262            .unwrap_or_default()
263            .as_millis()) as i64;
264
265        tracing::info!("Mattermost channel listening on {}...", channel_id);
266
267        loop {
268            tokio::time::sleep(std::time::Duration::from_secs(3)).await;
269
270            let resp = match self
271                .http_client()
272                .get(format!(
273                    "{}/api/v4/channels/{}/posts",
274                    self.base_url, channel_id
275                ))
276                .bearer_auth(&self.bot_token)
277                .query(&[("since", last_create_at.to_string())])
278                .send()
279                .await
280            {
281                Ok(r) => r,
282                Err(e) => {
283                    tracing::warn!("Mattermost poll error: {e}");
284                    continue;
285                }
286            };
287
288            let data: serde_json::Value = match resp.json().await {
289                Ok(d) => d,
290                Err(e) => {
291                    tracing::warn!("Mattermost parse error: {e}");
292                    continue;
293                }
294            };
295
296            if let Some(posts) = data.get("posts").and_then(|p| p.as_object()) {
297                // Process in chronological order
298                let mut post_list: Vec<_> = posts.values().collect();
299                post_list.sort_by_key(|p| p.get("create_at").and_then(|c| c.as_i64()).unwrap_or(0));
300
301                let last_create_at_before_this_batch = last_create_at;
302                for post in post_list {
303                    let create_at = post
304                        .get("create_at")
305                        .and_then(|c| c.as_i64())
306                        .unwrap_or(last_create_at);
307                    last_create_at = last_create_at.max(create_at);
308
309                    let effective_text = if post
310                        .get("message")
311                        .and_then(|m| m.as_str())
312                        .unwrap_or("")
313                        .trim()
314                        .is_empty()
315                        && post_has_audio_attachment(post)
316                    {
317                        self.try_transcribe_audio_attachment(post).await
318                    } else {
319                        None
320                    };
321
322                    if let Some(channel_msg) = self.parse_mattermost_post(
323                        post,
324                        &bot_user_id,
325                        &bot_username,
326                        last_create_at_before_this_batch,
327                        &channel_id,
328                        effective_text.as_deref(),
329                    ) {
330                        if tx.send(channel_msg).await.is_err() {
331                            return Ok(());
332                        }
333                    }
334                }
335            }
336        }
337    }
338
339    async fn health_check(&self) -> bool {
340        self.http_client()
341            .get(format!("{}/api/v4/users/me", self.base_url))
342            .bearer_auth(&self.bot_token)
343            .send()
344            .await
345            .map(|r| r.status().is_success())
346            .unwrap_or(false)
347    }
348
349    async fn start_typing(&self, recipient: &str) -> Result<()> {
350        // Cancel any existing typing loop before starting a new one.
351        self.stop_typing(recipient).await?;
352
353        let client = self.http_client();
354        let token = self.bot_token.clone();
355        let base_url = self.base_url.clone();
356
357        // recipient is "channel_id" or "channel_id:root_id"
358        let (channel_id, parent_id) = match recipient.split_once(':') {
359            Some((channel, parent)) => (channel.to_string(), Some(parent.to_string())),
360            None => (recipient.to_string(), None),
361        };
362
363        let handle = tokio::spawn(async move {
364            let url = format!("{base_url}/api/v4/users/me/typing");
365            loop {
366                let mut body = serde_json::json!({ "channel_id": channel_id });
367                if let Some(ref pid) = parent_id {
368                    body.as_object_mut()
369                        .unwrap()
370                        .insert("parent_id".to_string(), serde_json::json!(pid));
371                }
372
373                if let Ok(r) = client
374                    .post(&url)
375                    .bearer_auth(&token)
376                    .json(&body)
377                    .send()
378                    .await
379                {
380                    if !r.status().is_success() {
381                        tracing::debug!(status = %r.status(), "Mattermost typing indicator failed");
382                    }
383                }
384
385                // Mattermost typing events expire after ~6s; re-fire every 4s.
386                tokio::time::sleep(std::time::Duration::from_secs(4)).await;
387            }
388        });
389
390        let mut guard = self.typing_handle.lock();
391        *guard = Some(handle);
392
393        Ok(())
394    }
395
396    async fn stop_typing(&self, _recipient: &str) -> Result<()> {
397        let mut guard = self.typing_handle.lock();
398        if let Some(handle) = guard.take() {
399            handle.abort();
400        }
401        Ok(())
402    }
403}
404
405impl MattermostChannel {
406    fn parse_mattermost_post(
407        &self,
408        post: &serde_json::Value,
409        bot_user_id: &str,
410        bot_username: &str,
411        last_create_at: i64,
412        channel_id: &str,
413        injected_text: Option<&str>,
414    ) -> Option<ChannelMessage> {
415        let id = post.get("id").and_then(|i| i.as_str()).unwrap_or("");
416        let user_id = post.get("user_id").and_then(|u| u.as_str()).unwrap_or("");
417        let text = post.get("message").and_then(|m| m.as_str()).unwrap_or("");
418        let create_at = post.get("create_at").and_then(|c| c.as_i64()).unwrap_or(0);
419        let root_id = post.get("root_id").and_then(|r| r.as_str()).unwrap_or("");
420
421        if user_id == bot_user_id || create_at <= last_create_at {
422            return None;
423        }
424
425        let effective_text = if text.is_empty() {
426            injected_text?
427        } else {
428            text
429        };
430
431        if !self.is_user_allowed(user_id) {
432            tracing::warn!("Mattermost: ignoring message from unauthorized user: {user_id}");
433            return None;
434        }
435
436        // mention_only filtering: skip messages that don't @-mention the bot.
437        let content = if self.mention_only {
438            let normalized =
439                normalize_mattermost_content(effective_text, bot_user_id, bot_username, post);
440            normalized?
441        } else {
442            effective_text.to_string()
443        };
444
445        // Reply routing depends on thread_replies config:
446        //   - Existing thread (root_id set): always stay in the thread.
447        //   - Top-level post + thread_replies=true: thread on the original post.
448        //   - Top-level post + thread_replies=false: reply at channel level.
449        let reply_target = if !root_id.is_empty() {
450            format!("{}:{}", channel_id, root_id)
451        } else if self.thread_replies {
452            format!("{}:{}", channel_id, id)
453        } else {
454            channel_id.to_string()
455        };
456
457        Some(ChannelMessage {
458            id: format!("mattermost_{id}"),
459            sender: user_id.to_string(),
460            reply_target,
461            content,
462            channel: "mattermost".to_string(),
463            #[allow(clippy::cast_sign_loss)]
464            timestamp: (create_at / 1000) as u64,
465            thread_ts: None,
466            interruption_scope_id: None,
467            attachments: vec![],
468        })
469    }
470}
471
472fn post_has_audio_attachment(post: &serde_json::Value) -> bool {
473    let files = post
474        .get("metadata")
475        .and_then(|m| m.get("files"))
476        .and_then(|f| f.as_array());
477    let Some(files) = files else { return false };
478    files.iter().any(is_audio_file)
479}
480
481fn is_audio_file(file: &serde_json::Value) -> bool {
482    let mime = file.get("mime_type").and_then(|m| m.as_str()).unwrap_or("");
483    if mime.starts_with("audio/") {
484        return true;
485    }
486    let ext = file.get("extension").and_then(|e| e.as_str()).unwrap_or("");
487    matches!(
488        ext.to_ascii_lowercase().as_str(),
489        "ogg" | "mp3" | "m4a" | "wav" | "opus" | "flac"
490    )
491}
492
493/// Check whether a Mattermost post contains an @-mention of the bot.
494///
495/// Checks two sources:
496/// 1. Text-based: looks for `@bot_username` in the message body (case-insensitive).
497/// 2. Metadata-based: checks the post's `metadata.mentions` array for the bot user ID.
498fn contains_bot_mention_mm(
499    text: &str,
500    bot_user_id: &str,
501    bot_username: &str,
502    post: &serde_json::Value,
503) -> bool {
504    // 1. Text-based: @username (case-insensitive, word-boundary aware)
505    if !find_bot_mention_spans(text, bot_username).is_empty() {
506        return true;
507    }
508
509    // 2. Metadata-based: Mattermost may include a "metadata.mentions" array of user IDs.
510    if !bot_user_id.is_empty() {
511        if let Some(mentions) = post
512            .get("metadata")
513            .and_then(|m| m.get("mentions"))
514            .and_then(|m| m.as_array())
515        {
516            if mentions.iter().any(|m| m.as_str() == Some(bot_user_id)) {
517                return true;
518            }
519        }
520    }
521
522    false
523}
524
525fn is_mattermost_username_char(c: char) -> bool {
526    c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.'
527}
528
529fn find_bot_mention_spans(text: &str, bot_username: &str) -> Vec<(usize, usize)> {
530    if bot_username.is_empty() {
531        return Vec::new();
532    }
533
534    let mention = format!("@{}", bot_username.to_ascii_lowercase());
535    let mention_len = mention.len();
536    if mention_len == 0 {
537        return Vec::new();
538    }
539
540    let mention_bytes = mention.as_bytes();
541    let text_bytes = text.as_bytes();
542    let mut spans = Vec::new();
543    let mut index = 0;
544
545    while index + mention_len <= text_bytes.len() {
546        let is_match = text_bytes[index] == b'@'
547            && text_bytes[index..index + mention_len]
548                .iter()
549                .zip(mention_bytes.iter())
550                .all(|(left, right)| left.eq_ignore_ascii_case(right));
551
552        if is_match {
553            let end = index + mention_len;
554            let at_boundary = text[end..]
555                .chars()
556                .next()
557                .is_none_or(|next| !is_mattermost_username_char(next));
558            if at_boundary {
559                spans.push((index, end));
560                index = end;
561                continue;
562            }
563        }
564
565        let step = text[index..].chars().next().map_or(1, char::len_utf8);
566        index += step;
567    }
568
569    spans
570}
571
572/// Normalize incoming Mattermost content when `mention_only` is enabled.
573///
574/// Returns `None` if the message doesn't mention the bot.
575/// Returns `Some(cleaned)` with the @-mention stripped and text trimmed.
576fn normalize_mattermost_content(
577    text: &str,
578    bot_user_id: &str,
579    bot_username: &str,
580    post: &serde_json::Value,
581) -> Option<String> {
582    let mention_spans = find_bot_mention_spans(text, bot_username);
583    let metadata_mentions_bot = !bot_user_id.is_empty()
584        && post
585            .get("metadata")
586            .and_then(|m| m.get("mentions"))
587            .and_then(|m| m.as_array())
588            .is_some_and(|mentions| mentions.iter().any(|m| m.as_str() == Some(bot_user_id)));
589
590    if mention_spans.is_empty() && !metadata_mentions_bot {
591        return None;
592    }
593
594    let mut cleaned = text.to_string();
595    if !mention_spans.is_empty() {
596        let mut result = String::with_capacity(text.len());
597        let mut cursor = 0;
598        for (start, end) in mention_spans {
599            result.push_str(&text[cursor..start]);
600            result.push(' ');
601            cursor = end;
602        }
603        result.push_str(&text[cursor..]);
604        cleaned = result;
605    }
606
607    let cleaned = cleaned.trim().to_string();
608    if cleaned.is_empty() {
609        return None;
610    }
611
612    Some(cleaned)
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use serde_json::json;
619
620    // Helper: create a channel with mention_only=false (legacy behavior).
621    fn make_channel(allowed: Vec<String>, thread_replies: bool) -> MattermostChannel {
622        MattermostChannel::new(
623            "url".into(),
624            "token".into(),
625            None,
626            allowed,
627            thread_replies,
628            false,
629        )
630    }
631
632    // Helper: create a channel with mention_only=true.
633    fn make_mention_only_channel() -> MattermostChannel {
634        MattermostChannel::new(
635            "url".into(),
636            "token".into(),
637            None,
638            vec!["*".into()],
639            true,
640            true,
641        )
642    }
643
644    #[test]
645    fn mattermost_url_trimming() {
646        let ch = MattermostChannel::new(
647            "https://mm.example.com/".into(),
648            "token".into(),
649            None,
650            vec![],
651            false,
652            false,
653        );
654        assert_eq!(ch.base_url, "https://mm.example.com");
655    }
656
657    #[test]
658    fn mattermost_allowlist_wildcard() {
659        let ch = make_channel(vec!["*".into()], false);
660        assert!(ch.is_user_allowed("any-id"));
661    }
662
663    #[test]
664    fn mattermost_parse_post_basic() {
665        let ch = make_channel(vec!["*".into()], true);
666        let post = json!({
667            "id": "post123",
668            "user_id": "user456",
669            "message": "hello world",
670            "create_at": 1_600_000_000_000_i64,
671            "root_id": ""
672        });
673
674        let msg = ch
675            .parse_mattermost_post(
676                &post,
677                "bot123",
678                "botname",
679                1_500_000_000_000_i64,
680                "chan789",
681                None,
682            )
683            .unwrap();
684        assert_eq!(msg.sender, "user456");
685        assert_eq!(msg.content, "hello world");
686        assert_eq!(msg.reply_target, "chan789:post123"); // Default threaded reply
687    }
688
689    #[test]
690    fn mattermost_parse_post_thread_replies_enabled() {
691        let ch = make_channel(vec!["*".into()], true);
692        let post = json!({
693            "id": "post123",
694            "user_id": "user456",
695            "message": "hello world",
696            "create_at": 1_600_000_000_000_i64,
697            "root_id": ""
698        });
699
700        let msg = ch
701            .parse_mattermost_post(
702                &post,
703                "bot123",
704                "botname",
705                1_500_000_000_000_i64,
706                "chan789",
707                None,
708            )
709            .unwrap();
710        assert_eq!(msg.reply_target, "chan789:post123"); // Threaded reply
711    }
712
713    #[test]
714    fn mattermost_parse_post_thread() {
715        let ch = make_channel(vec!["*".into()], false);
716        let post = json!({
717            "id": "post123",
718            "user_id": "user456",
719            "message": "reply",
720            "create_at": 1_600_000_000_000_i64,
721            "root_id": "root789"
722        });
723
724        let msg = ch
725            .parse_mattermost_post(
726                &post,
727                "bot123",
728                "botname",
729                1_500_000_000_000_i64,
730                "chan789",
731                None,
732            )
733            .unwrap();
734        assert_eq!(msg.reply_target, "chan789:root789"); // Stays in the thread
735    }
736
737    #[test]
738    fn mattermost_parse_post_ignore_self() {
739        let ch = make_channel(vec!["*".into()], false);
740        let post = json!({
741            "id": "post123",
742            "user_id": "bot123",
743            "message": "my own message",
744            "create_at": 1_600_000_000_000_i64
745        });
746
747        let msg = ch.parse_mattermost_post(
748            &post,
749            "bot123",
750            "botname",
751            1_500_000_000_000_i64,
752            "chan789",
753            None,
754        );
755        assert!(msg.is_none());
756    }
757
758    #[test]
759    fn mattermost_parse_post_ignore_old() {
760        let ch = make_channel(vec!["*".into()], false);
761        let post = json!({
762            "id": "post123",
763            "user_id": "user456",
764            "message": "old message",
765            "create_at": 1_400_000_000_000_i64
766        });
767
768        let msg = ch.parse_mattermost_post(
769            &post,
770            "bot123",
771            "botname",
772            1_500_000_000_000_i64,
773            "chan789",
774            None,
775        );
776        assert!(msg.is_none());
777    }
778
779    #[test]
780    fn mattermost_parse_post_no_thread_when_disabled() {
781        let ch = make_channel(vec!["*".into()], false);
782        let post = json!({
783            "id": "post123",
784            "user_id": "user456",
785            "message": "hello world",
786            "create_at": 1_600_000_000_000_i64,
787            "root_id": ""
788        });
789
790        let msg = ch
791            .parse_mattermost_post(
792                &post,
793                "bot123",
794                "botname",
795                1_500_000_000_000_i64,
796                "chan789",
797                None,
798            )
799            .unwrap();
800        assert_eq!(msg.reply_target, "chan789"); // No thread suffix
801    }
802
803    #[test]
804    fn mattermost_existing_thread_always_threads() {
805        // Even with thread_replies=false, replies to existing threads stay in the thread
806        let ch = make_channel(vec!["*".into()], false);
807        let post = json!({
808            "id": "post123",
809            "user_id": "user456",
810            "message": "reply in thread",
811            "create_at": 1_600_000_000_000_i64,
812            "root_id": "root789"
813        });
814
815        let msg = ch
816            .parse_mattermost_post(
817                &post,
818                "bot123",
819                "botname",
820                1_500_000_000_000_i64,
821                "chan789",
822                None,
823            )
824            .unwrap();
825        assert_eq!(msg.reply_target, "chan789:root789"); // Stays in existing thread
826    }
827
828    // ── mention_only tests ────────────────────────────────────────
829
830    #[test]
831    fn mention_only_skips_message_without_mention() {
832        let ch = make_mention_only_channel();
833        let post = json!({
834            "id": "post1",
835            "user_id": "user1",
836            "message": "hello everyone",
837            "create_at": 1_600_000_000_000_i64,
838            "root_id": ""
839        });
840
841        let msg = ch.parse_mattermost_post(
842            &post,
843            "bot123",
844            "mybot",
845            1_500_000_000_000_i64,
846            "chan1",
847            None,
848        );
849        assert!(msg.is_none());
850    }
851
852    #[test]
853    fn mention_only_accepts_message_with_at_mention() {
854        let ch = make_mention_only_channel();
855        let post = json!({
856            "id": "post1",
857            "user_id": "user1",
858            "message": "@mybot what is the weather?",
859            "create_at": 1_600_000_000_000_i64,
860            "root_id": ""
861        });
862
863        let msg = ch
864            .parse_mattermost_post(
865                &post,
866                "bot123",
867                "mybot",
868                1_500_000_000_000_i64,
869                "chan1",
870                None,
871            )
872            .unwrap();
873        assert_eq!(msg.content, "what is the weather?");
874    }
875
876    #[test]
877    fn mention_only_strips_mention_and_trims() {
878        let ch = make_mention_only_channel();
879        let post = json!({
880            "id": "post1",
881            "user_id": "user1",
882            "message": "  @mybot  run status  ",
883            "create_at": 1_600_000_000_000_i64,
884            "root_id": ""
885        });
886
887        let msg = ch
888            .parse_mattermost_post(
889                &post,
890                "bot123",
891                "mybot",
892                1_500_000_000_000_i64,
893                "chan1",
894                None,
895            )
896            .unwrap();
897        assert_eq!(msg.content, "run status");
898    }
899
900    #[test]
901    fn mention_only_rejects_empty_after_stripping() {
902        let ch = make_mention_only_channel();
903        let post = json!({
904            "id": "post1",
905            "user_id": "user1",
906            "message": "@mybot",
907            "create_at": 1_600_000_000_000_i64,
908            "root_id": ""
909        });
910
911        let msg = ch.parse_mattermost_post(
912            &post,
913            "bot123",
914            "mybot",
915            1_500_000_000_000_i64,
916            "chan1",
917            None,
918        );
919        assert!(msg.is_none());
920    }
921
922    #[test]
923    fn mention_only_case_insensitive() {
924        let ch = make_mention_only_channel();
925        let post = json!({
926            "id": "post1",
927            "user_id": "user1",
928            "message": "@MyBot hello",
929            "create_at": 1_600_000_000_000_i64,
930            "root_id": ""
931        });
932
933        let msg = ch
934            .parse_mattermost_post(
935                &post,
936                "bot123",
937                "mybot",
938                1_500_000_000_000_i64,
939                "chan1",
940                None,
941            )
942            .unwrap();
943        assert_eq!(msg.content, "hello");
944    }
945
946    #[test]
947    fn mention_only_detects_metadata_mentions() {
948        // Even without @username in text, metadata.mentions should trigger.
949        let ch = make_mention_only_channel();
950        let post = json!({
951            "id": "post1",
952            "user_id": "user1",
953            "message": "hey check this out",
954            "create_at": 1_600_000_000_000_i64,
955            "root_id": "",
956            "metadata": {
957                "mentions": ["bot123"]
958            }
959        });
960
961        let msg = ch
962            .parse_mattermost_post(
963                &post,
964                "bot123",
965                "mybot",
966                1_500_000_000_000_i64,
967                "chan1",
968                None,
969            )
970            .unwrap();
971        // Content is preserved as-is since no @username was in the text to strip.
972        assert_eq!(msg.content, "hey check this out");
973    }
974
975    #[test]
976    fn mention_only_word_boundary_prevents_partial_match() {
977        let ch = make_mention_only_channel();
978        // "@mybotextended" should NOT match "@mybot" because it extends the username.
979        let post = json!({
980            "id": "post1",
981            "user_id": "user1",
982            "message": "@mybotextended hello",
983            "create_at": 1_600_000_000_000_i64,
984            "root_id": ""
985        });
986
987        let msg = ch.parse_mattermost_post(
988            &post,
989            "bot123",
990            "mybot",
991            1_500_000_000_000_i64,
992            "chan1",
993            None,
994        );
995        assert!(msg.is_none());
996    }
997
998    #[test]
999    fn mention_only_mention_in_middle_of_text() {
1000        let ch = make_mention_only_channel();
1001        let post = json!({
1002            "id": "post1",
1003            "user_id": "user1",
1004            "message": "hey @mybot how are you?",
1005            "create_at": 1_600_000_000_000_i64,
1006            "root_id": ""
1007        });
1008
1009        let msg = ch
1010            .parse_mattermost_post(
1011                &post,
1012                "bot123",
1013                "mybot",
1014                1_500_000_000_000_i64,
1015                "chan1",
1016                None,
1017            )
1018            .unwrap();
1019        assert_eq!(msg.content, "hey   how are you?");
1020    }
1021
1022    #[test]
1023    fn mention_only_disabled_passes_all_messages() {
1024        // With mention_only=false (default), messages pass through unfiltered.
1025        let ch = make_channel(vec!["*".into()], true);
1026        let post = json!({
1027            "id": "post1",
1028            "user_id": "user1",
1029            "message": "no mention here",
1030            "create_at": 1_600_000_000_000_i64,
1031            "root_id": ""
1032        });
1033
1034        let msg = ch
1035            .parse_mattermost_post(
1036                &post,
1037                "bot123",
1038                "mybot",
1039                1_500_000_000_000_i64,
1040                "chan1",
1041                None,
1042            )
1043            .unwrap();
1044        assert_eq!(msg.content, "no mention here");
1045    }
1046
1047    // ── contains_bot_mention_mm unit tests ────────────────────────
1048
1049    #[test]
1050    fn contains_mention_text_at_end() {
1051        let post = json!({});
1052        assert!(contains_bot_mention_mm(
1053            "hello @mybot",
1054            "bot123",
1055            "mybot",
1056            &post
1057        ));
1058    }
1059
1060    #[test]
1061    fn contains_mention_text_at_start() {
1062        let post = json!({});
1063        assert!(contains_bot_mention_mm(
1064            "@mybot hello",
1065            "bot123",
1066            "mybot",
1067            &post
1068        ));
1069    }
1070
1071    #[test]
1072    fn contains_mention_text_alone() {
1073        let post = json!({});
1074        assert!(contains_bot_mention_mm("@mybot", "bot123", "mybot", &post));
1075    }
1076
1077    #[test]
1078    fn no_mention_different_username() {
1079        let post = json!({});
1080        assert!(!contains_bot_mention_mm(
1081            "@otherbot hello",
1082            "bot123",
1083            "mybot",
1084            &post
1085        ));
1086    }
1087
1088    #[test]
1089    fn no_mention_partial_username() {
1090        let post = json!({});
1091        // "mybot" is a prefix of "mybotx" — should NOT match
1092        assert!(!contains_bot_mention_mm(
1093            "@mybotx hello",
1094            "bot123",
1095            "mybot",
1096            &post
1097        ));
1098    }
1099
1100    #[test]
1101    fn mention_detects_later_valid_mention_after_partial_prefix() {
1102        let post = json!({});
1103        assert!(contains_bot_mention_mm(
1104            "@mybotx ignore this, but @mybot handle this",
1105            "bot123",
1106            "mybot",
1107            &post
1108        ));
1109    }
1110
1111    #[test]
1112    fn mention_followed_by_punctuation() {
1113        let post = json!({});
1114        // "@mybot," — comma is not alphanumeric/underscore/dash/dot, so it's a boundary
1115        assert!(contains_bot_mention_mm(
1116            "@mybot, hello",
1117            "bot123",
1118            "mybot",
1119            &post
1120        ));
1121    }
1122
1123    #[test]
1124    fn mention_via_metadata_only() {
1125        let post = json!({
1126            "metadata": { "mentions": ["bot123"] }
1127        });
1128        assert!(contains_bot_mention_mm(
1129            "no at mention",
1130            "bot123",
1131            "mybot",
1132            &post
1133        ));
1134    }
1135
1136    #[test]
1137    fn no_mention_empty_username_no_metadata() {
1138        let post = json!({});
1139        assert!(!contains_bot_mention_mm("hello world", "bot123", "", &post));
1140    }
1141
1142    // ── normalize_mattermost_content unit tests ───────────────────
1143
1144    #[test]
1145    fn normalize_strips_and_trims() {
1146        let post = json!({});
1147        let result = normalize_mattermost_content("  @mybot  do stuff  ", "bot123", "mybot", &post);
1148        assert_eq!(result.as_deref(), Some("do stuff"));
1149    }
1150
1151    #[test]
1152    fn normalize_returns_none_for_no_mention() {
1153        let post = json!({});
1154        let result = normalize_mattermost_content("hello world", "bot123", "mybot", &post);
1155        assert!(result.is_none());
1156    }
1157
1158    #[test]
1159    fn normalize_returns_none_when_only_mention() {
1160        let post = json!({});
1161        let result = normalize_mattermost_content("@mybot", "bot123", "mybot", &post);
1162        assert!(result.is_none());
1163    }
1164
1165    #[test]
1166    fn normalize_preserves_text_for_metadata_mention() {
1167        let post = json!({
1168            "metadata": { "mentions": ["bot123"] }
1169        });
1170        let result = normalize_mattermost_content("check this out", "bot123", "mybot", &post);
1171        assert_eq!(result.as_deref(), Some("check this out"));
1172    }
1173
1174    #[test]
1175    fn normalize_strips_multiple_mentions() {
1176        let post = json!({});
1177        let result =
1178            normalize_mattermost_content("@mybot hello @mybot world", "bot123", "mybot", &post);
1179        assert_eq!(result.as_deref(), Some("hello   world"));
1180    }
1181
1182    #[test]
1183    fn normalize_keeps_partial_username_mentions() {
1184        let post = json!({});
1185        let result =
1186            normalize_mattermost_content("@mybot hello @mybotx world", "bot123", "mybot", &post);
1187        assert_eq!(result.as_deref(), Some("hello @mybotx world"));
1188    }
1189
1190    // ── Transcription tests ───────────────────────────────────────
1191
1192    #[test]
1193    fn mattermost_manager_none_when_transcription_not_configured() {
1194        let ch = make_channel(vec!["*".into()], false);
1195        assert!(ch.transcription_manager.is_none());
1196    }
1197
1198    #[test]
1199    fn mattermost_manager_some_when_valid_config() {
1200        let ch = make_channel(vec!["*".into()], false).with_transcription(
1201            crate::config::TranscriptionConfig {
1202                enabled: true,
1203                default_provider: "groq".to_string(),
1204                api_key: Some("test_key".to_string()),
1205                api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
1206                model: "whisper-large-v3".to_string(),
1207                language: None,
1208                initial_prompt: None,
1209                max_duration_secs: 600,
1210                openai: None,
1211                deepgram: None,
1212                assemblyai: None,
1213                google: None,
1214                local_whisper: None,
1215                transcribe_non_ptt_audio: false,
1216            },
1217        );
1218        assert!(ch.transcription_manager.is_some());
1219    }
1220
1221    #[test]
1222    fn mattermost_manager_none_and_warn_on_init_failure() {
1223        let ch = make_channel(vec!["*".into()], false).with_transcription(
1224            crate::config::TranscriptionConfig {
1225                enabled: true,
1226                default_provider: "groq".to_string(),
1227                api_key: Some(String::new()),
1228                api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
1229                model: "whisper-large-v3".to_string(),
1230                language: None,
1231                initial_prompt: None,
1232                max_duration_secs: 600,
1233                openai: None,
1234                deepgram: None,
1235                assemblyai: None,
1236                google: None,
1237                local_whisper: None,
1238                transcribe_non_ptt_audio: false,
1239            },
1240        );
1241        assert!(ch.transcription_manager.is_none());
1242    }
1243
1244    #[test]
1245    fn mattermost_post_has_audio_attachment_true_for_audio_mime() {
1246        let post = json!({
1247            "metadata": {
1248                "files": [
1249                    {
1250                        "id": "file1",
1251                        "mime_type": "audio/ogg",
1252                        "name": "voice.ogg"
1253                    }
1254                ]
1255            }
1256        });
1257        assert!(post_has_audio_attachment(&post));
1258    }
1259
1260    #[test]
1261    fn mattermost_post_has_audio_attachment_true_for_audio_ext() {
1262        let post = json!({
1263            "metadata": {
1264                "files": [
1265                    {
1266                        "id": "file1",
1267                        "mime_type": "application/octet-stream",
1268                        "extension": "ogg"
1269                    }
1270                ]
1271            }
1272        });
1273        assert!(post_has_audio_attachment(&post));
1274    }
1275
1276    #[test]
1277    fn mattermost_post_has_audio_attachment_false_for_image() {
1278        let post = json!({
1279            "metadata": {
1280                "files": [
1281                    {
1282                        "id": "file1",
1283                        "mime_type": "image/png",
1284                        "name": "screenshot.png"
1285                    }
1286                ]
1287            }
1288        });
1289        assert!(!post_has_audio_attachment(&post));
1290    }
1291
1292    #[test]
1293    fn mattermost_post_has_audio_attachment_false_when_no_files() {
1294        let post = json!({
1295            "metadata": {}
1296        });
1297        assert!(!post_has_audio_attachment(&post));
1298    }
1299
1300    #[test]
1301    fn mattermost_parse_post_uses_injected_text() {
1302        let ch = make_channel(vec!["*".into()], true);
1303        let post = json!({
1304            "id": "post123",
1305            "user_id": "user456",
1306            "message": "",
1307            "create_at": 1_600_000_000_000_i64,
1308            "root_id": ""
1309        });
1310
1311        let msg = ch
1312            .parse_mattermost_post(
1313                &post,
1314                "bot123",
1315                "botname",
1316                1_500_000_000_000_i64,
1317                "chan789",
1318                Some("transcript text"),
1319            )
1320            .unwrap();
1321        assert_eq!(msg.content, "transcript text");
1322    }
1323
1324    #[test]
1325    fn mattermost_parse_post_rejects_empty_message_without_injected() {
1326        let ch = make_channel(vec!["*".into()], true);
1327        let post = json!({
1328            "id": "post123",
1329            "user_id": "user456",
1330            "message": "",
1331            "create_at": 1_600_000_000_000_i64,
1332            "root_id": ""
1333        });
1334
1335        let msg = ch.parse_mattermost_post(
1336            &post,
1337            "bot123",
1338            "botname",
1339            1_500_000_000_000_i64,
1340            "chan789",
1341            None,
1342        );
1343        assert!(msg.is_none());
1344    }
1345
1346    #[tokio::test]
1347    async fn mattermost_transcribe_skips_when_manager_none() {
1348        let ch = make_channel(vec!["*".into()], false);
1349        let post = json!({
1350            "metadata": {
1351                "files": [
1352                    {
1353                        "id": "file1",
1354                        "mime_type": "audio/ogg",
1355                        "name": "voice.ogg"
1356                    }
1357                ]
1358            }
1359        });
1360        let result = ch.try_transcribe_audio_attachment(&post).await;
1361        assert!(result.is_none());
1362    }
1363
1364    #[tokio::test]
1365    async fn mattermost_transcribe_skips_over_duration_limit() {
1366        let ch = make_channel(vec!["*".into()], false).with_transcription(
1367            crate::config::TranscriptionConfig {
1368                enabled: true,
1369                default_provider: "groq".to_string(),
1370                api_key: Some("test_key".to_string()),
1371                api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
1372                model: "whisper-large-v3".to_string(),
1373                language: None,
1374                initial_prompt: None,
1375                max_duration_secs: 3600,
1376                openai: None,
1377                deepgram: None,
1378                assemblyai: None,
1379                google: None,
1380                local_whisper: None,
1381                transcribe_non_ptt_audio: false,
1382            },
1383        );
1384
1385        let post = json!({
1386            "metadata": {
1387                "files": [
1388                    {
1389                        "id": "file1",
1390                        "mime_type": "audio/ogg",
1391                        "name": "voice.ogg",
1392                        "duration": 7_200_000_u64
1393                    }
1394                ]
1395            }
1396        });
1397
1398        let result = ch.try_transcribe_audio_attachment(&post).await;
1399        assert!(result.is_none());
1400    }
1401
1402    #[cfg(test)]
1403    mod http_tests {
1404        use super::*;
1405        use wiremock::matchers::{method, path};
1406        use wiremock::{Mock, MockServer, ResponseTemplate};
1407
1408        #[tokio::test]
1409        async fn mattermost_audio_routes_through_local_whisper() {
1410            let mock_server = MockServer::start().await;
1411
1412            Mock::given(method("GET"))
1413                .and(path("/api/v4/files/file1"))
1414                .respond_with(ResponseTemplate::new(200).set_body_bytes(b"audio bytes"))
1415                .mount(&mock_server)
1416                .await;
1417
1418            Mock::given(method("POST"))
1419                .and(path("/v1/audio/transcriptions"))
1420                .respond_with(
1421                    ResponseTemplate::new(200).set_body_json(json!({"text": "test transcript"})),
1422                )
1423                .mount(&mock_server)
1424                .await;
1425
1426            let whisper_url = format!("{}/v1/audio/transcriptions", mock_server.uri());
1427            let ch = MattermostChannel::new(
1428                mock_server.uri(),
1429                "test_token".to_string(),
1430                None,
1431                vec!["*".into()],
1432                false,
1433                false,
1434            )
1435            .with_transcription(crate::config::TranscriptionConfig {
1436                enabled: true,
1437                default_provider: "local_whisper".to_string(),
1438                api_key: None,
1439                api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
1440                model: "whisper-large-v3".to_string(),
1441                language: None,
1442                initial_prompt: None,
1443                max_duration_secs: 600,
1444                openai: None,
1445                deepgram: None,
1446                assemblyai: None,
1447                google: None,
1448                local_whisper: Some(crate::config::LocalWhisperConfig {
1449                    url: whisper_url,
1450                    bearer_token: Some("test_token".to_string()),
1451                    max_audio_bytes: 25_000_000,
1452                    timeout_secs: 300,
1453                }),
1454                transcribe_non_ptt_audio: false,
1455            });
1456
1457            let post = json!({
1458                "metadata": {
1459                    "files": [
1460                        {
1461                            "id": "file1",
1462                            "mime_type": "audio/ogg",
1463                            "name": "voice.ogg"
1464                        }
1465                    ]
1466                }
1467            });
1468
1469            let result = ch.try_transcribe_audio_attachment(&post).await;
1470            assert_eq!(result.as_deref(), Some("[Voice] test transcript"));
1471        }
1472
1473        #[tokio::test]
1474        async fn mattermost_audio_skips_non_audio_attachment() {
1475            let mock_server = MockServer::start().await;
1476
1477            let ch = MattermostChannel::new(
1478                mock_server.uri(),
1479                "test_token".to_string(),
1480                None,
1481                vec!["*".into()],
1482                false,
1483                false,
1484            )
1485            .with_transcription(crate::config::TranscriptionConfig {
1486                enabled: true,
1487                default_provider: "local_whisper".to_string(),
1488                api_key: None,
1489                api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
1490                model: "whisper-large-v3".to_string(),
1491                language: None,
1492                initial_prompt: None,
1493                max_duration_secs: 600,
1494                openai: None,
1495                deepgram: None,
1496                assemblyai: None,
1497                google: None,
1498                local_whisper: Some(crate::config::LocalWhisperConfig {
1499                    url: mock_server.uri(),
1500                    bearer_token: Some("test_token".to_string()),
1501                    max_audio_bytes: 25_000_000,
1502                    timeout_secs: 300,
1503                }),
1504                transcribe_non_ptt_audio: false,
1505            });
1506
1507            let post = json!({
1508                "metadata": {
1509                    "files": [
1510                        {
1511                            "id": "file1",
1512                            "mime_type": "image/png",
1513                            "name": "screenshot.png"
1514                        }
1515                    ]
1516                }
1517            });
1518
1519            let result = ch.try_transcribe_audio_attachment(&post).await;
1520            assert!(result.is_none());
1521        }
1522    }
1523}