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
9pub struct MattermostChannel {
12 base_url: String, bot_token: String,
14 channel_id: Option<String>,
15 allowed_users: Vec<String>,
16 thread_replies: bool,
19 mention_only: bool,
21 typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
23 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 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 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 fn is_user_allowed(&self, user_id: &str) -> bool {
85 self.allowed_users.iter().any(|u| u == "*" || u == user_id)
86 }
87
88 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 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 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 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 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 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 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 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
493fn contains_bot_mention_mm(
499 text: &str,
500 bot_user_id: &str,
501 bot_username: &str,
502 post: &serde_json::Value,
503) -> bool {
504 if !find_bot_mention_spans(text, bot_username).is_empty() {
506 return true;
507 }
508
509 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
572fn 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 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 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"); }
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"); }
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"); }
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"); }
802
803 #[test]
804 fn mattermost_existing_thread_always_threads() {
805 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"); }
827
828 #[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 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 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 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 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 #[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 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 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 #[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 #[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}