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;
11const DISCOVERY_REFRESH: Duration = Duration::from_secs(60);
14const POLL_INTERVAL: Duration = Duration::from_secs(3);
17
18#[derive(Debug, Clone, PartialEq, Eq)]
21pub(crate) struct TargetChannel {
22 pub id: String,
23 pub is_direct: bool,
24}
25
26pub(crate) fn is_direct_channel(channel_type: &str) -> bool {
31 matches!(channel_type, "D" | "G")
32}
33
34pub(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
65pub struct MattermostChannel {
68 base_url: String, bot_token: Option<String>,
71 login_id: Option<String>,
73 password: Option<String>,
75 session_token: OnceCell<String>,
79 bot_identity: OnceCell<(String, String)>,
83 channel_ids: Vec<String>,
85 team_ids: Vec<String>,
87 discover_dms: bool,
90 alias: String,
93 peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
96 thread_replies: bool,
99 mention_only: bool,
101 typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
103 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 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 pub fn with_team_ids(mut self, team_ids: Vec<String>) -> Self {
147 self.team_ids = team_ids;
148 self
149 }
150
151 pub fn with_discover_dms(mut self, discover_dms: bool) -> Self {
154 self.discover_dms = discover_dms;
155 self
156 }
157
158 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 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 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 pub fn alias(&self) -> &str {
242 &self.alias
243 }
244
245 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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#[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 if !find_bot_mention_spans(text, bot_username).is_empty() {
1036 return true;
1037 }
1038
1039 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
1100fn 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"); }
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"); }
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"); }
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"); }
1388
1389 #[test]
1390 fn mattermost_existing_thread_always_threads() {
1391 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"); }
1426
1427 #[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 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 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 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 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 #[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 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 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 #[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 #[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 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}