1use std::collections::HashMap;
9use std::sync::{Arc, Weak};
10use std::time::SystemTime;
11
12use forge_connectors::slack::{
13 AuthTest, MENTION_CURSOR, SlackApi, SlackError, SlackHistoryMessage, flatten_mrkdwn,
14 mention_ids, one_line_excerpt, read_thread, shared_messages, thread_parent, user_display_name,
15};
16use forge_primitives::slack::{
17 SlackBookmark, SlackConversation, SlackDraft, SlackPerson, SlackPin, SlackSearchMatch,
18 SlackSubscription, SlackSubscriptionTarget, SlackThreadName, SlackUser, SlackWatchMode,
19};
20use uuid::Uuid;
21
22use crate::SessionSlot;
23use crate::mcp::caller_context::caller_context;
24use crate::mcp::gotify::facade::resolve_identity;
25use crate::workspace::Workspace;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
33pub(crate) enum SlackWorkspaceError {
34 NotConfigured,
36 Unbound { org: String, project: String },
38 BoundButNotLive { label: String },
42}
43
44const BINDING_KEYS: &str = "[orgs.slack] workspace = \"<label>\" to the org, or \
45 [orgs.projects.slack] workspace = \"<label>\" to the project";
46
47pub(crate) fn format_workspace_error(err: &SlackWorkspaceError) -> String {
50 match err {
51 SlackWorkspaceError::NotConfigured => {
52 "no Slack workspace configured in forge.toml [[slack]]".to_owned()
53 }
54 SlackWorkspaceError::Unbound { org, project } => format!(
55 "no Slack workspace is bound to {org}/{project}; add {BINDING_KEYS} in forge.toml"
56 ),
57 SlackWorkspaceError::BoundButNotLive { label } => format!(
58 "the [[slack]] workspace '{label}' is bound but not live; a [[slack]] entry added \
59 or renamed since boot needs a forge restart"
60 ),
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub(crate) enum SlackSubscribeError {
67 Workspace(SlackWorkspaceError),
69 UnknownCallerProject,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
77pub(crate) enum SlackUnsubscribeError {
78 UnknownCallerProject,
80 NoSuchSubscription,
82 NotOwnedByCaller,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
89pub(crate) struct SlackPostRequest {
90 pub conversation: String,
91 pub thread_ts: Option<String>,
93 pub text: String,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub(crate) enum SlackPostError {
99 Rejected,
101 SentBack { note: String },
103 Partial { posted: usize, total: usize, source: String },
106 Workspace(SlackWorkspaceError),
108 Fetch(String),
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
118pub(crate) struct SlackPostOutcome {
119 pub workspace: String,
122 pub ts: Vec<String>,
123 pub conversation_name: String,
124 pub parts: usize,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub(crate) struct SlackEditRequest {
130 pub conversation: String,
131 pub ts: String,
132 pub text: Option<String>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub(crate) enum SlackEditError {
138 NotOwnMessage,
141 Rejected,
143 SentBack {
146 note: String,
147 },
148 Workspace(SlackWorkspaceError),
149 Fetch(String),
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
154pub(crate) struct SlackReactRequest {
155 pub conversation: String,
156 pub ts: String,
157 pub name: String,
159 pub add: bool,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub(crate) enum SlackReactError {
164 Rejected,
166 SentBack {
169 note: String,
170 },
171 Workspace(SlackWorkspaceError),
172 Fetch(String),
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
180pub(crate) enum SlackReadError {
181 Workspace(SlackWorkspaceError),
182 Fetch(String),
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
187pub(crate) struct SlackFetchRequest {
188 pub file_id: String,
189 pub dir: std::path::PathBuf,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub(crate) struct SlackUploadRequest {
197 pub conversation: String,
198 pub thread_ts: Option<String>,
200 pub path: std::path::PathBuf,
201 pub title: Option<String>,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub(crate) enum SlackAttachmentError {
206 Io(String),
208 Rejected,
210 SentBack {
212 note: String,
213 },
214 Workspace(SlackWorkspaceError),
215 Fetch(String),
217 NotADirectory(std::path::PathBuf),
219}
220
221fn safe_file_name(name: &str, fallback: &str) -> String {
227 let base = name.rsplit(['/', '\\']).next().unwrap_or("").trim();
228 if base.is_empty() || base == "." || base == ".." {
229 return fallback.to_owned();
230 }
231 base.to_owned()
232}
233
234const MAX_SEARCH_FILTER_PAGES: usize = 5;
239
240#[derive(Debug, Clone, PartialEq, Eq)]
242pub(crate) enum SlackSubscribeRequest {
243 DirectMessages,
245 Conversations(Vec<SlackChannelWatch>),
247 Mentions,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub(crate) struct SlackChannelWatch {
253 pub id: String,
254 pub mode: SlackWatchMode,
255}
256
257fn slack_ts_now() -> String {
260 let now = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
261 format!("{}.{:06}", now.as_secs(), now.subsec_micros())
262}
263
264fn user_matches(user: &SlackUser, needle: &str) -> bool {
268 let display_name = user.profile.as_ref().and_then(|profile| profile.display_name.as_deref());
269 [Some(user.name.as_str()), user.real_name.as_deref(), display_name]
270 .into_iter()
271 .flatten()
272 .any(|field| !field.is_empty() && field.to_lowercase().contains(needle))
273}
274
275fn resolve_in(
278 ws: &Workspace,
279 caller: &SessionSlot,
280) -> Result<(String, Arc<dyn SlackApi>), SlackWorkspaceError> {
281 if ws.slack.is_empty() {
282 return Err(SlackWorkspaceError::NotConfigured);
283 }
284 let unbound = || SlackWorkspaceError::Unbound {
285 org: caller.org().to_owned(),
286 project: caller.project().to_owned(),
287 };
288 let Some(label) = ws.slack_workspace_for_slot(caller) else {
289 return Err(unbound());
290 };
291 let Some(api) = ws.slack.client(&label) else {
292 return Err(SlackWorkspaceError::BoundButNotLive { label });
293 };
294 Ok((label, api))
295}
296
297#[async_trait::async_trait]
299pub(crate) trait SlackFacade: Send + Sync {
300 async fn conversations(
305 &self,
306 caller: &SessionSlot,
307 ) -> Result<(String, Vec<SlackConversation>), SlackReadError>;
308
309 fn subscribed_targets(&self, caller: &SessionSlot, label: &str) -> Vec<SlackSubscription>;
315
316 async fn post(
320 &self,
321 caller: &SessionSlot,
322 request: SlackPostRequest,
323 ) -> Result<SlackPostOutcome, SlackPostError>;
324
325 async fn edit(
335 &self,
336 caller: &SessionSlot,
337 request: SlackEditRequest,
338 ) -> Result<(), SlackEditError>;
339
340 async fn react(
343 &self,
344 caller: &SessionSlot,
345 request: SlackReactRequest,
346 ) -> Result<(), SlackReactError>;
347
348 async fn fetch_attachment(
352 &self,
353 caller: &SessionSlot,
354 request: SlackFetchRequest,
355 ) -> Result<std::path::PathBuf, SlackAttachmentError>;
356
357 async fn post_attachment(
361 &self,
362 caller: &SessionSlot,
363 request: SlackUploadRequest,
364 ) -> Result<(), SlackAttachmentError>;
365
366 async fn search(
370 &self,
371 caller: &SessionSlot,
372 query: &str,
373 count: u32,
374 conversation: Option<&str>,
375 ) -> Result<(String, Vec<SlackSearchMatch>), SlackReadError>;
376
377 async fn history(
381 &self,
382 caller: &SessionSlot,
383 conversation: &str,
384 limit: u32,
385 oldest: Option<&str>,
386 newest: Option<&str>,
387 ) -> Result<(String, Vec<SlackHistoryMessage>), SlackReadError>;
388
389 async fn replies(
391 &self,
392 caller: &SessionSlot,
393 conversation: &str,
394 ts: &str,
395 limit: u32,
396 ) -> Result<(String, Vec<SlackHistoryMessage>), SlackReadError>;
397
398 async fn user(
400 &self,
401 caller: &SessionSlot,
402 user: &str,
403 ) -> Result<(String, SlackUser), SlackReadError>;
404
405 async fn users(
410 &self,
411 caller: &SessionSlot,
412 query: &str,
413 ) -> Result<(String, Vec<SlackUser>), SlackReadError>;
414
415 async fn me(&self, caller: &SessionSlot) -> Result<(String, AuthTest), SlackReadError>;
419
420 async fn pins(
422 &self,
423 caller: &SessionSlot,
424 conversation: &str,
425 ) -> Result<(String, Vec<SlackPin>), SlackReadError>;
426
427 async fn bookmarks(
429 &self,
430 caller: &SessionSlot,
431 conversation: &str,
432 ) -> Result<(String, Vec<SlackBookmark>), SlackReadError>;
433
434 fn subscribe(
438 &self,
439 caller: &SessionSlot,
440 request: SlackSubscribeRequest,
441 ) -> Result<Vec<SlackSubscription>, SlackSubscribeError>;
442
443 fn unsubscribe(
449 &self,
450 caller: &SessionSlot,
451 id: Uuid,
452 ) -> Result<SlackSubscription, SlackUnsubscribeError>;
453}
454
455pub(crate) struct ProdSlackFacade {
458 workspace: Weak<Workspace>,
459}
460
461#[derive(Debug, Clone, PartialEq, Eq)]
464pub(crate) enum GateDecision {
465 Approved,
466 Rejected,
467 SentBack {
470 note: String,
471 },
472}
473
474struct ResolveOnDrop {
478 workspace: Arc<Workspace>,
479 id: Uuid,
480 caller: SessionSlot,
481}
482
483impl Drop for ResolveOnDrop {
484 fn drop(&mut self) {
485 self.workspace.resolve_slack_draft(
488 self.id,
489 &self.caller,
490 crate::slack::DraftResolution::Abandoned,
491 );
492 }
493}
494
495impl ProdSlackFacade {
496 pub(crate) fn from_arc(workspace: &Arc<Workspace>) -> Arc<dyn SlackFacade> {
497 Arc::new(Self { workspace: Arc::downgrade(workspace) })
498 }
499
500 fn resolve(
503 &self,
504 caller: &SessionSlot,
505 ) -> Result<(String, Arc<dyn SlackApi>), SlackWorkspaceError> {
506 let ws = self.workspace.upgrade().ok_or(SlackWorkspaceError::NotConfigured)?;
507 resolve_in(&ws, caller)
508 }
509
510 async fn resolve_conversation(
520 api: &Arc<dyn SlackApi>,
521 workspace: &str,
522 conversation: &str,
523 ) -> (String, String) {
524 let wanted = conversation.strip_prefix('#').unwrap_or(conversation);
528 let conversations = match api.list_conversations().await {
529 Ok(conversations) => conversations,
530 Err(error) => {
531 tracing::warn!(
532 target: "forge_workspace::slack",
533 event_name = "slack_conversation_list_failed",
534 workspace,
535 conversation,
536 %error,
537 "listing conversations failed; the approval prompt names the id instead",
538 );
539 return (wanted.to_owned(), conversation.to_owned());
540 }
541 };
542 match conversations.iter().find(|known| {
543 known.id == wanted
544 || known.name.as_deref() == Some(wanted)
545 || known.user.as_deref() == Some(wanted)
546 }) {
547 Some(known) => {
548 let label = match known.name.as_deref().filter(|name| !name.is_empty()) {
549 Some(name) if !known.is_im && !known.is_mpim => format!("#{name}"),
552 Some(name) => name.to_owned(),
553 None => known.user.clone().unwrap_or_else(|| known.id.clone()),
554 };
555 (known.id.clone(), label)
556 }
557 None => (wanted.to_owned(), conversation.to_owned()),
558 }
559 }
560
561 const THREAD_EXCERPT_CHARS: usize = 80;
563
564 async fn name_draft(
568 ws: &Arc<Workspace>,
569 api: &Arc<dyn SlackApi>,
570 label: &str,
571 conversation: &str,
572 draft: &mut SlackDraft,
573 ) {
574 let host = Arc::new(crate::slack::SlackSubsystemHost::new(ws));
575 let mut lookups = Vec::new();
580 let mut looked_up: Vec<String> = Vec::new();
581 for id in mention_ids(&draft.text) {
582 lookups.push((id.clone(), Self::look_up_user(&host, api, label, id.clone())));
583 looked_up.push(id);
584 }
585 let mut parent = None;
586 if let Some(ts) = draft.thread_ts.clone() {
587 parent = match thread_parent(api.as_ref(), conversation, &ts).await {
588 Ok(Some(parent)) if parent.ts == ts => Some(parent),
594 Ok(_) => None,
595 Err(error) => {
596 let first = ws.slack_thread_failures.lock().insert((
600 label.to_owned(),
601 conversation.to_owned(),
602 ts.clone(),
603 ));
604 if first {
605 tracing::warn!(
606 target: "forge_workspace::slack",
607 event_name = "slack_draft_thread_unreadable",
608 workspace = label,
609 conversation,
610 parent = %ts,
611 %error,
612 "reading a held draft's thread parent failed",
613 );
614 } else {
615 tracing::debug!(
616 target: "forge_workspace::slack",
617 event_name = "slack_draft_thread_unreadable",
618 workspace = label,
619 conversation,
620 parent = %ts,
621 %error,
622 "reading a held draft's thread parent failed again",
623 );
624 }
625 None
626 }
627 };
628 }
629 if let Some(parent) = &parent {
630 for id in mention_ids(&parent.text) {
631 if !looked_up.contains(&id) {
632 lookups.push((id.clone(), Self::look_up_user(&host, api, label, id.clone())));
633 looked_up.push(id);
634 }
635 }
636 let bot_named = parent.bot_name.as_deref().is_some_and(|name| !name.is_empty());
640 if !bot_named
641 && let Some(user) = parent.user.as_deref().filter(|user| !user.is_empty())
642 && !looked_up.iter().any(|seen| seen.as_str() == user)
643 {
644 lookups.push((
645 user.to_owned(),
646 Self::look_up_user(&host, api, label, user.to_owned()),
647 ));
648 looked_up.push(user.to_owned());
649 }
650 }
651 let mut names = HashMap::new();
652 for (id, lookup) in lookups {
653 if let Some(name) = lookup.await.unwrap_or(None) {
654 names.insert(id, name);
655 }
656 }
657 draft.tagged = mention_ids(&draft.text)
658 .into_iter()
659 .map(|id| SlackPerson { name: names.get(&id).cloned(), id })
660 .collect();
661 draft.thread = parent.as_ref().and_then(|parent| Self::thread_name(parent, &names));
662 }
663
664 fn look_up_user(
667 host: &Arc<crate::slack::SlackSubsystemHost>,
668 api: &Arc<dyn SlackApi>,
669 label: &str,
670 user: String,
671 ) -> tokio::task::JoinHandle<Option<String>> {
672 let host = Arc::clone(host);
673 let api = Arc::clone(api);
674 let workspace = label.to_owned();
675 tokio::spawn(async move {
676 user_display_name(host.as_ref(), api.as_ref(), &workspace, &user).await
677 })
678 }
679
680 fn thread_name(
683 parent: &SlackHistoryMessage,
684 names: &HashMap<String, String>,
685 ) -> Option<SlackThreadName> {
686 let author = if let Some(name) = parent.bot_name.as_deref().filter(|name| !name.is_empty())
687 {
688 name.to_owned()
690 } else {
691 let user = parent.user.as_deref().filter(|user| !user.is_empty())?;
692 names.get(user).cloned().unwrap_or_else(|| user.to_owned())
693 };
694 Some(SlackThreadName {
695 author,
696 excerpt: one_line_excerpt(
697 &flatten_mrkdwn(&parent.text, names, Self::THREAD_EXCERPT_CHARS),
698 Self::THREAD_EXCERPT_CHARS,
699 ),
700 })
701 }
702
703 async fn await_approval(
717 workspace: &Arc<Workspace>,
718 caller: &SessionSlot,
719 draft: SlackDraft,
720 ) -> GateDecision {
721 let (id, decision) = workspace.register_slack_draft(caller, draft);
722 let guard = ResolveOnDrop { workspace: Arc::clone(workspace), id, caller: caller.clone() };
723 let resolved = match decision.await {
724 Ok(forge_primitives::slack::SlackDraftAnswer::Approve) => GateDecision::Approved,
725 Ok(forge_primitives::slack::SlackDraftAnswer::SendBack { note }) => {
726 GateDecision::SentBack { note }
727 }
728 Ok(forge_primitives::slack::SlackDraftAnswer::Decline) | Err(_) => {
731 GateDecision::Rejected
732 }
733 };
734 drop(guard);
735 resolved
736 }
737}
738
739#[async_trait::async_trait]
740impl SlackFacade for ProdSlackFacade {
741 async fn conversations(
742 &self,
743 caller: &SessionSlot,
744 ) -> Result<(String, Vec<SlackConversation>), SlackReadError> {
745 let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
746 let conversations =
747 api.list_conversations().await.map_err(|err| SlackReadError::Fetch(err.to_string()))?;
748 Ok((label, conversations))
749 }
750
751 async fn post(
752 &self,
753 caller: &SessionSlot,
754 request: SlackPostRequest,
755 ) -> Result<SlackPostOutcome, SlackPostError> {
756 let ws = self
757 .workspace
758 .upgrade()
759 .ok_or(SlackPostError::Workspace(SlackWorkspaceError::NotConfigured))?;
760 let (label, api) = resolve_in(&ws, caller).map_err(SlackPostError::Workspace)?;
761 let (conversation, conversation_label) =
762 Self::resolve_conversation(&api, &label, &request.conversation).await;
763 let mut draft = SlackDraft {
764 id: Uuid::new_v4(),
765 workspace: label,
766 conversation: request.conversation,
767 conversation_label,
768 thread_ts: request.thread_ts,
769 text: request.text,
770 operation: forge_primitives::slack::SlackDraftOperation::Post,
771 thread: None,
772 tagged: Vec::new(),
773 };
774 let workspace = draft.workspace.clone();
775 Self::name_draft(&ws, &api, &workspace, &conversation, &mut draft).await;
776 match Self::await_approval(&ws, caller, draft.clone()).await {
777 GateDecision::Approved => {}
778 GateDecision::Rejected => return Err(SlackPostError::Rejected),
779 GateDecision::SentBack { note } => return Err(SlackPostError::SentBack { note }),
780 }
781 let conversation_name = draft
784 .conversation_label
785 .strip_prefix('#')
786 .unwrap_or(&draft.conversation_label)
787 .to_owned();
788 let parts = forge_connectors::slack::split_for_post(&draft.text);
789 let mut ts: Vec<String> = Vec::new();
790 for part in &parts {
791 let _in_flight = ws.begin_slack_post(&draft.workspace, &conversation);
796 match api.post_message(&draft.conversation, part, draft.thread_ts.as_deref()).await {
797 Ok(posted) => {
798 let landed = if let Some(channel) = posted.channel.as_deref() {
804 channel
805 } else {
806 tracing::debug!(
807 target: "forge_workspace::slack",
808 event_name = "slack_post_channel_omitted",
809 workspace = %draft.workspace,
810 conversation = %draft.conversation,
811 "chat.postMessage answered no channel; the ledger keys on the resolved conversation",
812 );
813 &conversation
814 };
815 ws.record_slack_own_post(&draft.workspace, landed, &posted.ts);
816 ts.push(posted.ts);
817 }
818 Err(error) if ts.is_empty() => {
819 return Err(SlackPostError::Fetch(error.to_string()));
821 }
822 Err(error) => {
823 return Err(SlackPostError::Partial {
827 posted: ts.len(),
828 total: parts.len(),
829 source: error.to_string(),
830 });
831 }
832 }
833 }
834 Ok(SlackPostOutcome {
835 workspace: draft.workspace.clone(),
836 ts,
837 conversation_name,
838 parts: parts.len(),
839 })
840 }
841
842 async fn edit(
843 &self,
844 caller: &SessionSlot,
845 request: SlackEditRequest,
846 ) -> Result<(), SlackEditError> {
847 let ws = self
848 .workspace
849 .upgrade()
850 .ok_or(SlackEditError::Workspace(SlackWorkspaceError::NotConfigured))?;
851 let (label, api) = resolve_in(&ws, caller).map_err(SlackEditError::Workspace)?;
852
853 let (conversation, conversation_label) =
858 Self::resolve_conversation(&api, &label, &request.conversation).await;
859 let mut draft = SlackDraft {
860 id: Uuid::new_v4(),
861 workspace: label.clone(),
862 conversation: request.conversation.clone(),
863 conversation_label,
864 thread_ts: Some(request.ts.clone()),
865 text: match &request.text {
866 Some(text) => text.clone(),
867 None => format!("[delete] message {}", request.ts),
868 },
869 operation: match &request.text {
870 Some(_) => forge_primitives::slack::SlackDraftOperation::Edit,
871 None => forge_primitives::slack::SlackDraftOperation::Delete,
872 },
873 thread: None,
874 tagged: Vec::new(),
875 };
876 let workspace = draft.workspace.clone();
877 Self::name_draft(&ws, &api, &workspace, &conversation, &mut draft).await;
878 match Self::await_approval(&ws, caller, draft).await {
879 GateDecision::Approved => {}
880 GateDecision::Rejected => return Err(SlackEditError::Rejected),
881 GateDecision::SentBack { note } => return Err(SlackEditError::SentBack { note }),
882 }
883
884 let result = match &request.text {
888 Some(text) => api.update_message(&request.conversation, &request.ts, text).await,
889 None => api.delete_message(&request.conversation, &request.ts).await,
890 };
891 match result {
892 Ok(()) => {
893 ws.record_slack_own_post(&label, &conversation, &request.ts);
898 Ok(())
899 }
900 Err(SlackError::Api { error, .. })
901 if error == "cant_update_message" || error == "cant_delete_message" =>
902 {
903 Err(SlackEditError::NotOwnMessage)
904 }
905 Err(err) => Err(SlackEditError::Fetch(err.to_string())),
906 }
907 }
908
909 async fn react(
910 &self,
911 caller: &SessionSlot,
912 request: SlackReactRequest,
913 ) -> Result<(), SlackReactError> {
914 let ws = self
915 .workspace
916 .upgrade()
917 .ok_or(SlackReactError::Workspace(SlackWorkspaceError::NotConfigured))?;
918 let (label, api) = resolve_in(&ws, caller).map_err(SlackReactError::Workspace)?;
919
920 let verb = if request.add { "react" } else { "unreact" };
923 let (conversation, conversation_label) =
924 Self::resolve_conversation(&api, &label, &request.conversation).await;
925 let mut draft = SlackDraft {
926 id: Uuid::new_v4(),
927 workspace: label,
928 conversation: request.conversation.clone(),
929 conversation_label,
930 thread_ts: Some(request.ts.clone()),
931 text: format!("[{verb}: {}]", request.name),
932 operation: if request.add {
933 forge_primitives::slack::SlackDraftOperation::React
934 } else {
935 forge_primitives::slack::SlackDraftOperation::Unreact
936 },
937 thread: None,
938 tagged: Vec::new(),
939 };
940 let workspace = draft.workspace.clone();
941 Self::name_draft(&ws, &api, &workspace, &conversation, &mut draft).await;
942 match Self::await_approval(&ws, caller, draft).await {
943 GateDecision::Approved => {}
944 GateDecision::Rejected => return Err(SlackReactError::Rejected),
945 GateDecision::SentBack { note } => return Err(SlackReactError::SentBack { note }),
946 }
947
948 api.set_reaction(&request.conversation, &request.ts, &request.name, request.add)
949 .await
950 .map_err(|err| SlackReactError::Fetch(err.to_string()))
951 }
952
953 async fn search(
954 &self,
955 caller: &SessionSlot,
956 query: &str,
957 count: u32,
958 conversation: Option<&str>,
959 ) -> Result<(String, Vec<SlackSearchMatch>), SlackReadError> {
960 let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
961 let Some(conversation) = conversation else {
962 let matches = api
963 .search_messages(query, count, None)
964 .await
965 .map(|page| page.matches)
966 .map_err(|err| SlackReadError::Fetch(err.to_string()))?;
967 return Ok((label, matches));
968 };
969
970 let name = api
976 .list_conversations()
977 .await
978 .map_err(|err| SlackReadError::Fetch(err.to_string()))?
979 .into_iter()
980 .find(|known| known.id == conversation)
981 .filter(|known| !known.is_im && !known.is_mpim)
985 .and_then(|known| known.name)
986 .filter(|name| !name.is_empty());
987 let narrowed = name.map_or_else(|| query.to_owned(), |name| format!("in:{name} {query}"));
988
989 let mut matches = Vec::new();
990 let mut cursor: Option<String> = None;
991 for _ in 0..MAX_SEARCH_FILTER_PAGES {
992 let page = api
993 .search_messages(&narrowed, count, cursor.as_deref())
994 .await
995 .map_err(|err| SlackReadError::Fetch(err.to_string()))?;
996 matches
997 .extend(page.matches.into_iter().filter(|hit| hit.conversation_id == conversation));
998 if matches.len() >= count as usize {
999 break;
1000 }
1001 match page.next_cursor {
1002 Some(next) => cursor = Some(next),
1003 None => break,
1004 }
1005 }
1006 matches.truncate(count as usize);
1007 Ok((label, matches))
1008 }
1009
1010 async fn history(
1011 &self,
1012 caller: &SessionSlot,
1013 conversation: &str,
1014 limit: u32,
1015 oldest: Option<&str>,
1016 newest: Option<&str>,
1017 ) -> Result<(String, Vec<SlackHistoryMessage>), SlackReadError> {
1018 let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
1019 let messages = api
1020 .history(conversation, oldest, newest, limit, None)
1021 .await
1022 .map(|page| page.messages)
1023 .map_err(|err| SlackReadError::Fetch(err.to_string()))?;
1024 Ok((label, messages))
1025 }
1026
1027 async fn replies(
1028 &self,
1029 caller: &SessionSlot,
1030 conversation: &str,
1031 ts: &str,
1032 limit: u32,
1033 ) -> Result<(String, Vec<SlackHistoryMessage>), SlackReadError> {
1034 let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
1035 let messages = read_thread(api.as_ref(), conversation, ts, limit)
1036 .await
1037 .map_err(|err| SlackReadError::Fetch(err.to_string()))?;
1038 Ok((label, messages))
1039 }
1040
1041 async fn user(
1042 &self,
1043 caller: &SessionSlot,
1044 user: &str,
1045 ) -> Result<(String, SlackUser), SlackReadError> {
1046 let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
1047 let user =
1048 api.user_info(user).await.map_err(|err| SlackReadError::Fetch(err.to_string()))?;
1049 Ok((label, user))
1050 }
1051
1052 async fn users(
1053 &self,
1054 caller: &SessionSlot,
1055 query: &str,
1056 ) -> Result<(String, Vec<SlackUser>), SlackReadError> {
1057 let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
1058 let needle = query.trim().to_lowercase();
1059 if needle.is_empty() {
1062 return Ok((label, Vec::new()));
1063 }
1064 let mut found: Vec<SlackUser> = api
1065 .list_users()
1066 .await
1067 .map_err(|err| SlackReadError::Fetch(err.to_string()))?
1068 .into_iter()
1069 .filter(|user| !user.deleted && user_matches(user, &needle))
1070 .collect();
1071 found.sort_by(|left, right| left.name.cmp(&right.name));
1072 Ok((label, found))
1073 }
1074
1075 async fn me(&self, caller: &SessionSlot) -> Result<(String, AuthTest), SlackReadError> {
1076 let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
1077 let auth = api.auth_test().await.map_err(|err| SlackReadError::Fetch(err.to_string()))?;
1078 Ok((label, auth))
1079 }
1080
1081 async fn pins(
1082 &self,
1083 caller: &SessionSlot,
1084 conversation: &str,
1085 ) -> Result<(String, Vec<SlackPin>), SlackReadError> {
1086 let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
1087 let pins =
1088 api.pins(conversation).await.map_err(|err| SlackReadError::Fetch(err.to_string()))?;
1089 Ok((label, pins))
1090 }
1091
1092 async fn bookmarks(
1093 &self,
1094 caller: &SessionSlot,
1095 conversation: &str,
1096 ) -> Result<(String, Vec<SlackBookmark>), SlackReadError> {
1097 let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
1098 let bookmarks = api
1099 .bookmarks(conversation)
1100 .await
1101 .map_err(|err| SlackReadError::Fetch(err.to_string()))?;
1102 Ok((label, bookmarks))
1103 }
1104
1105 async fn fetch_attachment(
1106 &self,
1107 caller: &SessionSlot,
1108 request: SlackFetchRequest,
1109 ) -> Result<std::path::PathBuf, SlackAttachmentError> {
1110 let (_, api) = self.resolve(caller).map_err(SlackAttachmentError::Workspace)?;
1111
1112 if let Ok(meta) = std::fs::metadata(&request.dir)
1115 && !meta.is_dir()
1116 {
1117 return Err(SlackAttachmentError::NotADirectory(request.dir.clone()));
1118 }
1119 std::fs::create_dir_all(&request.dir)
1120 .map_err(|err| SlackAttachmentError::Io(format!("{}: {err}", request.dir.display())))?;
1121
1122 let file = api
1123 .file_info(&request.file_id)
1124 .await
1125 .map_err(|err| SlackAttachmentError::Fetch(err.to_string()))?;
1126 let bytes = api
1127 .get_bytes(&file.url_private)
1128 .await
1129 .map_err(|err| SlackAttachmentError::Fetch(err.to_string()))?;
1130
1131 let name = safe_file_name(&file.name, &file.id);
1132 let path = request.dir.join(name);
1133 let mut handle = std::fs::OpenOptions::new()
1136 .write(true)
1137 .create_new(true)
1138 .open(&path)
1139 .map_err(|err| SlackAttachmentError::Io(err.to_string()))?;
1140 std::io::Write::write_all(&mut handle, &bytes)
1141 .map_err(|err| SlackAttachmentError::Io(err.to_string()))?;
1142 Ok(path)
1143 }
1144
1145 async fn post_attachment(
1146 &self,
1147 caller: &SessionSlot,
1148 request: SlackUploadRequest,
1149 ) -> Result<(), SlackAttachmentError> {
1150 let ws = self
1151 .workspace
1152 .upgrade()
1153 .ok_or(SlackAttachmentError::Workspace(SlackWorkspaceError::NotConfigured))?;
1154 let (label, api) = resolve_in(&ws, caller).map_err(SlackAttachmentError::Workspace)?;
1155
1156 let bytes = std::fs::read(&request.path)
1157 .map_err(|err| SlackAttachmentError::Io(err.to_string()))?;
1158 let name = request
1159 .path
1160 .file_name()
1161 .map_or_else(|| "file".to_owned(), |name| name.to_string_lossy().into_owned());
1162
1163 let (conversation, conversation_label) =
1168 Self::resolve_conversation(&api, &label, &request.conversation).await;
1169 let mut draft = SlackDraft {
1170 id: Uuid::new_v4(),
1171 workspace: label.clone(),
1172 conversation: request.conversation.clone(),
1173 conversation_label,
1174 thread_ts: request.thread_ts.clone(),
1175 text: format!("[file] {name}\nfrom {}", request.path.display()),
1176 operation: forge_primitives::slack::SlackDraftOperation::Upload,
1177 thread: None,
1178 tagged: Vec::new(),
1179 };
1180 let workspace = draft.workspace.clone();
1181 Self::name_draft(&ws, &api, &workspace, &conversation, &mut draft).await;
1182 match Self::await_approval(&ws, caller, draft).await {
1183 GateDecision::Approved => {}
1184 GateDecision::Rejected => return Err(SlackAttachmentError::Rejected),
1185 GateDecision::SentBack { note } => {
1186 return Err(SlackAttachmentError::SentBack { note });
1187 }
1188 }
1189
1190 let (url, file_id) = api
1191 .get_upload_url(&name, bytes.len())
1192 .await
1193 .map_err(|err| SlackAttachmentError::Fetch(err.to_string()))?;
1194 api.post_bytes(&url, bytes)
1195 .await
1196 .map_err(|err| SlackAttachmentError::Fetch(err.to_string()))?;
1197 let upload_call = ws.begin_slack_post(&label, &conversation);
1204 let uploaded = api
1205 .complete_upload(&file_id, &request.conversation, request.thread_ts.as_deref())
1206 .await
1207 .map_err(|err| SlackAttachmentError::Fetch(err.to_string()))?;
1208 let _shares = ws.begin_slack_post(&label, &conversation);
1209 drop(upload_call);
1210 let mut shared: Vec<(String, String)> = uploaded.iter().flat_map(shared_messages).collect();
1215 if shared.is_empty() {
1216 match api.file_info(&file_id).await {
1217 Ok(file) => shared = shared_messages(&file),
1218 Err(error) => tracing::warn!(
1219 target: "forge_workspace::slack",
1220 event_name = "slack_attachment_shares_unreadable",
1221 workspace = %label,
1222 conversation = %request.conversation,
1223 %error,
1224 "reading a posted file's shares failed; the attachment stays unledgered until this call succeeds",
1225 ),
1226 }
1227 }
1228 if shared.is_empty() {
1229 tracing::warn!(
1232 target: "forge_workspace::slack",
1233 event_name = "slack_attachment_ledger_missing",
1234 workspace = %label,
1235 conversation = %request.conversation,
1236 "the upload's shares name no message; the attachment stays unledgered and can come back once",
1237 );
1238 }
1239 for (conversation, ts) in shared {
1240 ws.record_slack_own_post(&label, &conversation, &ts);
1241 }
1242 Ok(())
1243 }
1244
1245 fn subscribed_targets(&self, caller: &SessionSlot, label: &str) -> Vec<SlackSubscription> {
1246 let Some(ws) = self.workspace.upgrade() else { return Vec::new() };
1247 let Some(cx) = caller_context(&ws, caller) else { return Vec::new() };
1248 ws.slack_subscriptions_for_project(&cx.project_name)
1249 .into_iter()
1250 .filter(|sub| sub.workspace == label && sub.team_role == cx.worker_label)
1251 .collect()
1252 }
1253
1254 fn subscribe(
1255 &self,
1256 caller: &SessionSlot,
1257 request: SlackSubscribeRequest,
1258 ) -> Result<Vec<SlackSubscription>, SlackSubscribeError> {
1259 let ws = self.workspace.upgrade().ok_or(SlackSubscribeError::UnknownCallerProject)?;
1260 let (label, _) = resolve_in(&ws, caller).map_err(SlackSubscribeError::Workspace)?;
1261 let (project, team_role, durable) =
1262 resolve_identity(&ws, caller).ok_or(SlackSubscribeError::UnknownCallerProject)?;
1263 let targets = match request {
1264 SlackSubscribeRequest::DirectMessages => {
1265 vec![SlackSubscriptionTarget::DirectMessages]
1266 }
1267 SlackSubscribeRequest::Conversations(watches) => watches
1270 .into_iter()
1271 .map(|watch| SlackSubscriptionTarget::Conversation {
1272 id: watch.id,
1273 name: None,
1274 mode: watch.mode,
1275 })
1276 .collect(),
1277 SlackSubscribeRequest::Mentions => vec![SlackSubscriptionTarget::Mentions],
1278 };
1279 let mut created = Vec::with_capacity(targets.len());
1280 let mut mentions_requested = false;
1281 for target in targets {
1282 if let SlackSubscriptionTarget::Conversation { id, mode, .. } = &target {
1288 if !ws.slack_has_cursor(&label, id) {
1294 ws.set_slack_watermark(&label, id, &slack_ts_now());
1295 }
1296 created.push(ws.watch_slack_conversation(
1297 &label,
1298 &project,
1299 team_role.as_deref(),
1300 id,
1301 *mode,
1302 durable,
1303 ));
1304 continue;
1305 }
1306 if matches!(target, SlackSubscriptionTarget::Mentions) {
1307 mentions_requested = true;
1308 }
1309 let sub = SlackSubscription {
1310 id: Uuid::new_v4(),
1311 workspace: label.clone(),
1312 project: project.clone(),
1313 team_role: team_role.clone(),
1314 target,
1315 created_at: SystemTime::now(),
1316 };
1317 if let SlackSubscriptionTarget::Conversation { id, .. } = &sub.target
1323 && !ws.slack_has_cursor(&label, id)
1324 {
1325 ws.set_slack_watermark(&label, id, &slack_ts_now());
1326 }
1327 ws.add_slack_subscription(sub.clone(), durable);
1328 created.push(sub);
1329 }
1330 if mentions_requested && !ws.slack_has_cursor(&label, MENTION_CURSOR) {
1331 ws.set_slack_watermark(&label, MENTION_CURSOR, &slack_ts_now());
1332 }
1333 ws.start_slack_subsystem();
1335 Ok(created)
1336 }
1337
1338 fn unsubscribe(
1339 &self,
1340 caller: &SessionSlot,
1341 id: Uuid,
1342 ) -> Result<SlackSubscription, SlackUnsubscribeError> {
1343 let ws = self.workspace.upgrade().ok_or(SlackUnsubscribeError::UnknownCallerProject)?;
1344 let cx = caller_context(&ws, caller).ok_or(SlackUnsubscribeError::UnknownCallerProject)?;
1345 let removed =
1346 ws.remove_slack_subscription_owned_by(&cx.project_name, id, cx.worker_label.as_deref());
1347 ws.stop_slack_subsystem_if_idle();
1348 if let Some(removed) = removed {
1349 return Ok(removed);
1350 }
1351 if ws.slack_subscriptions_for_project(&cx.project_name).iter().any(|s| s.id == id) {
1355 Err(SlackUnsubscribeError::NotOwnedByCaller)
1356 } else {
1357 Err(SlackUnsubscribeError::NoSuchSubscription)
1358 }
1359 }
1360}
1361
1362#[cfg(test)]
1364pub(crate) type SearchCall = (SessionSlot, String, u32, Option<String>);
1365
1366#[cfg(test)]
1368pub(crate) type HistoryCall = (SessionSlot, String, u32, Option<String>, Option<String>);
1369
1370#[cfg(test)]
1372pub(crate) type RepliesCall = (SessionSlot, String, String, u32);
1373
1374#[cfg(test)]
1377#[derive(Default)]
1378pub(crate) struct MockSlackFacade {
1379 pub conversations_calls: parking_lot::Mutex<Vec<SessionSlot>>,
1380 pub conversations_result:
1381 parking_lot::Mutex<Option<Result<Vec<SlackConversation>, SlackReadError>>>,
1382 pub subscribed_targets_calls: parking_lot::Mutex<Vec<(SessionSlot, String)>>,
1383 pub subscribed_targets: parking_lot::Mutex<Vec<SlackSubscription>>,
1384 pub search_calls: parking_lot::Mutex<Vec<SearchCall>>,
1385 pub search_result: parking_lot::Mutex<Option<Result<Vec<SlackSearchMatch>, SlackReadError>>>,
1386 pub history_calls: parking_lot::Mutex<Vec<HistoryCall>>,
1387 pub history_result:
1388 parking_lot::Mutex<Option<Result<Vec<SlackHistoryMessage>, SlackReadError>>>,
1389 pub replies_calls: parking_lot::Mutex<Vec<RepliesCall>>,
1390 pub replies_result:
1391 parking_lot::Mutex<Option<Result<Vec<SlackHistoryMessage>, SlackReadError>>>,
1392 pub user_calls: parking_lot::Mutex<Vec<(SessionSlot, String)>>,
1393 pub user_result: parking_lot::Mutex<Option<Result<SlackUser, SlackReadError>>>,
1394 pub users_calls: parking_lot::Mutex<Vec<(SessionSlot, String)>>,
1395 pub users_result: parking_lot::Mutex<Option<Result<Vec<SlackUser>, SlackReadError>>>,
1396 pub me_calls: parking_lot::Mutex<Vec<SessionSlot>>,
1397 pub me_result: parking_lot::Mutex<Option<Result<(String, AuthTest), SlackReadError>>>,
1398 pub pins_calls: parking_lot::Mutex<Vec<(SessionSlot, String)>>,
1399 pub pins_result: parking_lot::Mutex<Option<Result<Vec<SlackPin>, SlackReadError>>>,
1400 pub bookmarks_calls: parking_lot::Mutex<Vec<(SessionSlot, String)>>,
1401 pub bookmarks_result: parking_lot::Mutex<Option<Result<Vec<SlackBookmark>, SlackReadError>>>,
1402 pub fetch_calls: parking_lot::Mutex<Vec<SlackFetchRequest>>,
1403 pub fetch_result: parking_lot::Mutex<Option<Result<std::path::PathBuf, SlackAttachmentError>>>,
1404 pub upload_calls: parking_lot::Mutex<Vec<SlackUploadRequest>>,
1405 pub upload_result: parking_lot::Mutex<Option<Result<(), SlackAttachmentError>>>,
1406 pub post_calls: parking_lot::Mutex<Vec<SlackPostRequest>>,
1407 pub post_result: parking_lot::Mutex<Option<Result<SlackPostOutcome, SlackPostError>>>,
1408 pub edit_calls: parking_lot::Mutex<Vec<SlackEditRequest>>,
1409 pub edit_result: parking_lot::Mutex<Option<Result<(), SlackEditError>>>,
1410 pub react_calls: parking_lot::Mutex<Vec<SlackReactRequest>>,
1411 pub react_result: parking_lot::Mutex<Option<Result<(), SlackReactError>>>,
1412 pub subscribe_calls: parking_lot::Mutex<Vec<(SessionSlot, SlackSubscribeRequest)>>,
1413 pub subscribe_result:
1414 parking_lot::Mutex<Option<Result<Vec<SlackSubscription>, SlackSubscribeError>>>,
1415 pub unsubscribe_calls: parking_lot::Mutex<Vec<Uuid>>,
1416 pub unsubscribe_result:
1417 parking_lot::Mutex<Option<Result<SlackSubscription, SlackUnsubscribeError>>>,
1418}
1419
1420#[cfg(test)]
1421impl MockSlackFacade {
1422 pub(crate) fn new() -> Self {
1423 Self::default()
1424 }
1425 pub(crate) fn into_arc(self) -> Arc<dyn SlackFacade> {
1426 Arc::new(self)
1427 }
1428}
1429
1430#[cfg(test)]
1431#[async_trait::async_trait]
1432impl SlackFacade for MockSlackFacade {
1433 async fn conversations(
1434 &self,
1435 caller: &SessionSlot,
1436 ) -> Result<(String, Vec<SlackConversation>), SlackReadError> {
1437 self.conversations_calls.lock().push(caller.clone());
1438 self.conversations_result
1439 .lock()
1440 .clone()
1441 .unwrap_or_else(|| Ok(Vec::new()))
1442 .map(|rows| ("acme".to_owned(), rows))
1443 }
1444
1445 async fn search(
1446 &self,
1447 caller: &SessionSlot,
1448 query: &str,
1449 count: u32,
1450 conversation: Option<&str>,
1451 ) -> Result<(String, Vec<SlackSearchMatch>), SlackReadError> {
1452 self.search_calls.lock().push((
1453 caller.clone(),
1454 query.to_owned(),
1455 count,
1456 conversation.map(str::to_owned),
1457 ));
1458 self.search_result
1459 .lock()
1460 .clone()
1461 .unwrap_or_else(|| Ok(Vec::new()))
1462 .map(|rows| ("acme".to_owned(), rows))
1463 }
1464
1465 async fn history(
1466 &self,
1467 caller: &SessionSlot,
1468 conversation: &str,
1469 limit: u32,
1470 oldest: Option<&str>,
1471 newest: Option<&str>,
1472 ) -> Result<(String, Vec<SlackHistoryMessage>), SlackReadError> {
1473 self.history_calls.lock().push((
1474 caller.clone(),
1475 conversation.to_owned(),
1476 limit,
1477 oldest.map(str::to_owned),
1478 newest.map(str::to_owned),
1479 ));
1480 self.history_result
1481 .lock()
1482 .clone()
1483 .unwrap_or_else(|| Ok(Vec::new()))
1484 .map(|rows| ("acme".to_owned(), rows))
1485 }
1486
1487 async fn replies(
1488 &self,
1489 caller: &SessionSlot,
1490 conversation: &str,
1491 ts: &str,
1492 limit: u32,
1493 ) -> Result<(String, Vec<SlackHistoryMessage>), SlackReadError> {
1494 self.replies_calls.lock().push((
1495 caller.clone(),
1496 conversation.to_owned(),
1497 ts.to_owned(),
1498 limit,
1499 ));
1500 self.replies_result
1501 .lock()
1502 .clone()
1503 .unwrap_or_else(|| Ok(Vec::new()))
1504 .map(|rows| ("acme".to_owned(), rows))
1505 }
1506
1507 async fn user(
1508 &self,
1509 caller: &SessionSlot,
1510 user: &str,
1511 ) -> Result<(String, SlackUser), SlackReadError> {
1512 self.user_calls.lock().push((caller.clone(), user.to_owned()));
1513 self.user_result
1514 .lock()
1515 .clone()
1516 .unwrap_or_else(|| {
1517 Ok(SlackUser {
1518 id: user.to_owned(),
1519 name: "tester".to_owned(),
1520 real_name: None,
1521 tz: None,
1522 deleted: false,
1523 profile: None,
1524 })
1525 })
1526 .map(|user| ("acme".to_owned(), user))
1527 }
1528
1529 async fn users(
1530 &self,
1531 caller: &SessionSlot,
1532 query: &str,
1533 ) -> Result<(String, Vec<SlackUser>), SlackReadError> {
1534 self.users_calls.lock().push((caller.clone(), query.to_owned()));
1535 self.users_result
1536 .lock()
1537 .clone()
1538 .unwrap_or_else(|| Ok(Vec::new()))
1539 .map(|rows| ("acme".to_owned(), rows))
1540 }
1541
1542 async fn me(&self, caller: &SessionSlot) -> Result<(String, AuthTest), SlackReadError> {
1543 self.me_calls.lock().push(caller.clone());
1544 self.me_result.lock().clone().unwrap_or_else(|| {
1545 Ok((
1546 "acme".to_owned(),
1547 AuthTest {
1548 team: "Test".to_owned(),
1549 user: "tester".to_owned(),
1550 team_id: "T1".to_owned(),
1551 user_id: "U1".to_owned(),
1552 url: "https://test.slack.com/".to_owned(),
1553 },
1554 ))
1555 })
1556 }
1557
1558 async fn pins(
1559 &self,
1560 caller: &SessionSlot,
1561 conversation: &str,
1562 ) -> Result<(String, Vec<SlackPin>), SlackReadError> {
1563 self.pins_calls.lock().push((caller.clone(), conversation.to_owned()));
1564 self.pins_result
1565 .lock()
1566 .clone()
1567 .unwrap_or_else(|| Ok(Vec::new()))
1568 .map(|rows| ("acme".to_owned(), rows))
1569 }
1570
1571 async fn bookmarks(
1572 &self,
1573 caller: &SessionSlot,
1574 conversation: &str,
1575 ) -> Result<(String, Vec<SlackBookmark>), SlackReadError> {
1576 self.bookmarks_calls.lock().push((caller.clone(), conversation.to_owned()));
1577 self.bookmarks_result
1578 .lock()
1579 .clone()
1580 .unwrap_or_else(|| Ok(Vec::new()))
1581 .map(|rows| ("acme".to_owned(), rows))
1582 }
1583
1584 async fn fetch_attachment(
1585 &self,
1586 _caller: &SessionSlot,
1587 request: SlackFetchRequest,
1588 ) -> Result<std::path::PathBuf, SlackAttachmentError> {
1589 self.fetch_calls.lock().push(request);
1590 self.fetch_result.lock().clone().unwrap_or_else(|| Err(SlackAttachmentError::Rejected))
1591 }
1592
1593 async fn post_attachment(
1594 &self,
1595 _caller: &SessionSlot,
1596 request: SlackUploadRequest,
1597 ) -> Result<(), SlackAttachmentError> {
1598 self.upload_calls.lock().push(request);
1599 self.upload_result.lock().clone().unwrap_or(Ok(()))
1600 }
1601
1602 async fn post(
1603 &self,
1604 _caller: &SessionSlot,
1605 request: SlackPostRequest,
1606 ) -> Result<SlackPostOutcome, SlackPostError> {
1607 self.post_calls.lock().push(request);
1608 self.post_result.lock().clone().unwrap_or(Ok(SlackPostOutcome {
1609 workspace: "acme".to_owned(),
1610 ts: vec!["1.0".to_owned()],
1611 conversation_name: "general".to_owned(),
1612 parts: 1,
1613 }))
1614 }
1615
1616 async fn edit(
1617 &self,
1618 _caller: &SessionSlot,
1619 request: SlackEditRequest,
1620 ) -> Result<(), SlackEditError> {
1621 self.edit_calls.lock().push(request);
1622 self.edit_result.lock().clone().unwrap_or(Ok(()))
1623 }
1624
1625 async fn react(
1626 &self,
1627 _caller: &SessionSlot,
1628 request: SlackReactRequest,
1629 ) -> Result<(), SlackReactError> {
1630 self.react_calls.lock().push(request);
1631 self.react_result.lock().clone().unwrap_or(Ok(()))
1632 }
1633
1634 fn subscribed_targets(&self, caller: &SessionSlot, label: &str) -> Vec<SlackSubscription> {
1635 self.subscribed_targets_calls.lock().push((caller.clone(), label.to_owned()));
1636 self.subscribed_targets.lock().clone()
1637 }
1638
1639 fn subscribe(
1640 &self,
1641 caller: &SessionSlot,
1642 request: SlackSubscribeRequest,
1643 ) -> Result<Vec<SlackSubscription>, SlackSubscribeError> {
1644 self.subscribe_calls.lock().push((caller.clone(), request));
1645 self.subscribe_result.lock().clone().unwrap_or_else(|| Ok(Vec::new()))
1646 }
1647
1648 fn unsubscribe(
1649 &self,
1650 _caller: &SessionSlot,
1651 id: Uuid,
1652 ) -> Result<SlackSubscription, SlackUnsubscribeError> {
1653 self.unsubscribe_calls.lock().push(id);
1654 self.unsubscribe_result
1655 .lock()
1656 .clone()
1657 .unwrap_or(Err(SlackUnsubscribeError::NoSuchSubscription))
1658 }
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663 use super::*;
1664 use crate::config::LoadedConfig;
1665 use crate::mcp::slack::Unsubscribe;
1666 use crate::mcp::test_support::text_of;
1667 use crate::slack::DraftResolution;
1668 use forge_connectors::slack::{
1669 AuthTest, MessagePage, PostedMessage, SearchPage, SlackApi, SlackError,
1670 };
1671 use forge_primitives::slack::{
1672 SlackBookmark, SlackConfig, SlackDraftAnswer, SlackDraftEnding, SlackDraftOperation,
1673 SlackFile, SlackFileShare, SlackFileShares, SlackPin, SlackPinMessage, SlackUserProfile,
1674 };
1675 use forge_sdk::mcp::tool::{Tool as _, ToolInput};
1676 use std::collections::{BTreeMap, HashMap};
1677 use tempfile::tempdir;
1678
1679 fn caller() -> SessionSlot {
1680 SessionSlot::lead("TestOrg", "forge")
1681 }
1682
1683 type HistoryRead = (String, Option<String>, Option<String>, u32);
1685
1686 #[derive(Default)]
1689 struct RecordingApi {
1690 posts: parking_lot::Mutex<Vec<(String, String, Option<String>)>>,
1691 updates: parking_lot::Mutex<Vec<(String, String, String)>>,
1692 deletes: parking_lot::Mutex<Vec<(String, String)>>,
1693 reactions: parking_lot::Mutex<Vec<(String, String, String, bool)>>,
1694 foreign: parking_lot::Mutex<std::collections::HashSet<String>>,
1696 files: parking_lot::Mutex<HashMap<String, (String, Vec<u8>)>>,
1698 uploads: parking_lot::Mutex<Vec<(String, String, Option<String>)>>,
1699 upload_urls: parking_lot::Mutex<usize>,
1700 pins: parking_lot::Mutex<HashMap<String, Vec<SlackPin>>>,
1702 bookmark_rows: parking_lot::Mutex<HashMap<String, Vec<SlackBookmark>>>,
1703 pin_reads: parking_lot::Mutex<Vec<String>>,
1704 bookmark_reads: parking_lot::Mutex<Vec<String>>,
1705 history_rows: parking_lot::Mutex<HashMap<String, Vec<SlackHistoryMessage>>>,
1707 list_failure: parking_lot::Mutex<bool>,
1710 reply_rows: parking_lot::Mutex<HashMap<String, Vec<SlackHistoryMessage>>>,
1711 reply_failures: parking_lot::Mutex<std::collections::HashSet<String>>,
1713 user_failures: parking_lot::Mutex<std::collections::HashSet<String>>,
1715 history_reads: parking_lot::Mutex<Vec<HistoryRead>>,
1716 reply_reads: parking_lot::Mutex<Vec<(String, String)>>,
1717 search_pages: parking_lot::Mutex<Vec<Vec<SlackSearchMatch>>>,
1720 search_reads: parking_lot::Mutex<Vec<String>>,
1721 users_rows: parking_lot::Mutex<Vec<SlackUser>>,
1723 post_ts: parking_lot::Mutex<Option<String>>,
1725 post_channel: parking_lot::Mutex<Option<String>>,
1728 post_channel_absent: parking_lot::Mutex<bool>,
1730 post_park: parking_lot::Mutex<Option<Arc<tokio::sync::Notify>>>,
1733 upload_park: parking_lot::Mutex<Option<Arc<tokio::sync::Notify>>>,
1734 file_info_park: parking_lot::Mutex<Option<Arc<tokio::sync::Notify>>>,
1735 post_calls: parking_lot::Mutex<usize>,
1738 post_fail_at: parking_lot::Mutex<Option<usize>>,
1740 file_shares: parking_lot::Mutex<HashMap<String, SlackFileShares>>,
1742 upload_shares: parking_lot::Mutex<Option<SlackFileShares>>,
1745 file_info_calls: parking_lot::Mutex<usize>,
1747 conversations: parking_lot::Mutex<Vec<SlackConversation>>,
1749 }
1750
1751 impl RecordingApi {
1752 fn refuse_edits_of(&self, ts: &str) {
1753 self.foreign.lock().insert(ts.to_owned());
1754 }
1755 fn seed_file(&self, id: &str, name: &str, bytes: &[u8]) {
1756 self.files.lock().insert(id.to_owned(), (name.to_owned(), bytes.to_vec()));
1757 }
1758 fn seed_pins(&self, channel: &str, rows: Vec<SlackPin>) {
1759 self.pins.lock().insert(channel.to_owned(), rows);
1760 }
1761 fn seed_bookmarks(&self, channel: &str, rows: Vec<SlackBookmark>) {
1762 self.bookmark_rows.lock().insert(channel.to_owned(), rows);
1763 }
1764 fn seed_history(&self, channel: &str, rows: Vec<SlackHistoryMessage>) {
1765 self.history_rows.lock().insert(channel.to_owned(), rows);
1766 }
1767 fn fail_list(&self) {
1768 *self.list_failure.lock() = true;
1769 }
1770 fn seed_replies(&self, channel: &str, ts: &str, rows: Vec<SlackHistoryMessage>) {
1771 self.reply_rows.lock().insert(format!("{channel}/{ts}"), rows);
1772 }
1773 fn fail_replies(&self, channel: &str, ts: &str) {
1774 self.reply_failures.lock().insert(format!("{channel}/{ts}"));
1775 }
1776 fn fail_user_info(&self, user: &str) {
1777 self.user_failures.lock().insert(user.to_owned());
1778 }
1779 fn seed_search(&self, rows: Vec<SlackSearchMatch>) {
1780 self.search_pages.lock().push(rows);
1781 }
1782 fn seed_search_pages(&self, pages: Vec<Vec<SlackSearchMatch>>) {
1783 self.search_pages.lock().extend(pages);
1784 }
1785 fn seed_users(&self, rows: Vec<SlackUser>) {
1786 self.users_rows.lock().extend(rows);
1787 }
1788 fn history_reads(&self) -> Vec<HistoryRead> {
1789 self.history_reads.lock().clone()
1790 }
1791 fn reply_reads(&self) -> Vec<(String, String)> {
1792 self.reply_reads.lock().clone()
1793 }
1794 fn search_reads(&self) -> Vec<String> {
1795 self.search_reads.lock().clone()
1796 }
1797 fn posts(&self) -> Vec<(String, String, Option<String>)> {
1798 self.posts.lock().clone()
1799 }
1800 fn upload_url_count(&self) -> usize {
1801 *self.upload_urls.lock()
1802 }
1803 fn uploads(&self) -> Vec<(String, String, Option<String>)> {
1804 self.uploads.lock().clone()
1805 }
1806 fn seed_conversation(&self, id: &str, name: &str) {
1807 self.conversations.lock().push(SlackConversation {
1808 id: id.to_owned(),
1809 name: Some(name.to_owned()),
1810 is_channel: true,
1811 is_private: false,
1812 is_im: false,
1813 is_mpim: false,
1814 is_archived: false,
1815 user: None,
1816 purpose: None,
1817 topic: None,
1818 });
1819 }
1820 fn seed_dm(&self, id: &str, user: &str) {
1821 self.conversations.lock().push(SlackConversation {
1822 id: id.to_owned(),
1823 name: None,
1824 is_channel: false,
1825 is_private: false,
1826 is_im: true,
1827 is_mpim: false,
1828 is_archived: false,
1829 user: Some(user.to_owned()),
1830 purpose: None,
1831 topic: None,
1832 });
1833 }
1834 fn seed_mpim(&self, id: &str, name: &str) {
1835 self.conversations.lock().push(SlackConversation {
1836 id: id.to_owned(),
1837 name: Some(name.to_owned()),
1838 is_channel: false,
1839 is_private: false,
1840 is_im: false,
1841 is_mpim: true,
1842 is_archived: false,
1843 user: None,
1844 purpose: None,
1845 topic: None,
1846 });
1847 }
1848 fn answer_posts_with(&self, ts: &str) {
1849 *self.post_ts.lock() = Some(ts.to_owned());
1850 }
1851 fn answer_posts_in(&self, channel: &str) {
1853 *self.post_channel.lock() = Some(channel.to_owned());
1854 }
1855 fn answer_posts_without_channel(&self) {
1857 *self.post_channel_absent.lock() = true;
1858 }
1859 fn park_posts(&self) -> Arc<tokio::sync::Notify> {
1861 let gate = Arc::new(tokio::sync::Notify::new());
1862 *self.post_park.lock() = Some(Arc::clone(&gate));
1863 gate
1864 }
1865 fn park_uploads(&self) -> Arc<tokio::sync::Notify> {
1867 let gate = Arc::new(tokio::sync::Notify::new());
1868 *self.upload_park.lock() = Some(Arc::clone(&gate));
1869 gate
1870 }
1871 fn park_file_info(&self) -> Arc<tokio::sync::Notify> {
1873 let gate = Arc::new(tokio::sync::Notify::new());
1874 *self.file_info_park.lock() = Some(Arc::clone(&gate));
1875 gate
1876 }
1877 fn fail_post_at(&self, index: usize) {
1879 *self.post_fail_at.lock() = Some(index);
1880 }
1881 fn seed_file_shares(&self, id: &str, conversation: &str, ts: &str) {
1884 let mut shares = SlackFileShares::default();
1885 shares
1886 .public
1887 .insert(conversation.to_owned(), vec![SlackFileShare { ts: ts.to_owned() }]);
1888 self.file_shares.lock().insert(id.to_owned(), shares);
1889 }
1890 fn seed_upload_shares(&self, conversation: &str, ts: &str) {
1893 let mut shares = SlackFileShares::default();
1894 shares
1895 .public
1896 .insert(conversation.to_owned(), vec![SlackFileShare { ts: ts.to_owned() }]);
1897 *self.upload_shares.lock() = Some(shares);
1898 }
1899 fn file_info_calls(&self) -> usize {
1900 *self.file_info_calls.lock()
1901 }
1902 }
1903
1904 #[async_trait::async_trait]
1905 impl SlackApi for RecordingApi {
1906 async fn auth_test(&self) -> Result<AuthTest, SlackError> {
1907 Ok(AuthTest {
1908 team: "Test".to_owned(),
1909 user: "tester".to_owned(),
1910 team_id: "T1".to_owned(),
1911 user_id: "U1".to_owned(),
1912 url: "https://test.slack.com/".to_owned(),
1913 })
1914 }
1915
1916 async fn list_conversations(&self) -> Result<Vec<SlackConversation>, SlackError> {
1917 if *self.list_failure.lock() {
1918 return Err(SlackError::Transport {
1919 method: "conversations.list".to_owned(),
1920 detail: "connection reset".to_owned(),
1921 });
1922 }
1923 Ok(self.conversations.lock().clone())
1924 }
1925
1926 async fn history(
1927 &self,
1928 channel: &str,
1929 oldest: Option<&str>,
1930 newest: Option<&str>,
1931 limit: u32,
1932 _cursor: Option<&str>,
1933 ) -> Result<MessagePage, SlackError> {
1934 self.history_reads.lock().push((
1935 channel.to_owned(),
1936 oldest.map(str::to_owned),
1937 newest.map(str::to_owned),
1938 limit,
1939 ));
1940 Ok(MessagePage {
1941 messages: self.history_rows.lock().get(channel).cloned().unwrap_or_default(),
1942 next_cursor: None,
1943 })
1944 }
1945
1946 async fn replies(
1947 &self,
1948 channel: &str,
1949 ts: &str,
1950 _oldest: Option<&str>,
1951 _limit: u32,
1952 _cursor: Option<&str>,
1953 ) -> Result<MessagePage, SlackError> {
1954 self.reply_reads.lock().push((channel.to_owned(), ts.to_owned()));
1955 if self.reply_failures.lock().contains(&format!("{channel}/{ts}")) {
1956 return Err(SlackError::RateLimited {
1957 method: "conversations.replies".to_owned(),
1958 retry_after: std::time::Duration::from_secs(5),
1959 });
1960 }
1961 Ok(MessagePage {
1962 messages: self
1963 .reply_rows
1964 .lock()
1965 .get(&format!("{channel}/{ts}"))
1966 .cloned()
1967 .unwrap_or_default(),
1968 next_cursor: None,
1969 })
1970 }
1971
1972 async fn post_message(
1973 &self,
1974 channel: &str,
1975 text: &str,
1976 thread_ts: Option<&str>,
1977 ) -> Result<PostedMessage, SlackError> {
1978 self.posts.lock().push((
1979 channel.to_owned(),
1980 text.to_owned(),
1981 thread_ts.map(str::to_owned),
1982 ));
1983 let attempt = {
1984 let mut calls = self.post_calls.lock();
1985 let attempt = *calls;
1986 *calls += 1;
1987 attempt
1988 };
1989 let gate = self.post_park.lock().clone();
1990 if let Some(gate) = gate {
1991 gate.notified().await;
1992 }
1993 if *self.post_fail_at.lock() == Some(attempt) {
1994 return Err(SlackError::Api {
1995 method: "chat.postMessage".to_owned(),
1996 error: "rate_limited".to_owned(),
1997 needed: None,
1998 });
1999 }
2000 let ts = self.post_ts.lock().clone().unwrap_or_else(|| format!("{}.0", attempt + 1));
2001 let answered = if *self.post_channel_absent.lock() {
2002 None
2003 } else {
2004 Some(self.post_channel.lock().clone().unwrap_or_else(|| channel.to_owned()))
2005 };
2006 Ok(PostedMessage { channel: answered, ts })
2007 }
2008
2009 async fn update_message(
2010 &self,
2011 channel: &str,
2012 ts: &str,
2013 text: &str,
2014 ) -> Result<(), SlackError> {
2015 if self.foreign.lock().contains(ts) {
2016 return Err(SlackError::Api {
2017 method: "chat.update".to_owned(),
2018 error: "cant_update_message".to_owned(),
2019 needed: None,
2020 });
2021 }
2022 self.updates.lock().push((channel.to_owned(), ts.to_owned(), text.to_owned()));
2023 Ok(())
2024 }
2025
2026 async fn delete_message(&self, channel: &str, ts: &str) -> Result<(), SlackError> {
2027 if self.foreign.lock().contains(ts) {
2028 return Err(SlackError::Api {
2029 method: "chat.delete".to_owned(),
2030 error: "cant_delete_message".to_owned(),
2031 needed: None,
2032 });
2033 }
2034 self.deletes.lock().push((channel.to_owned(), ts.to_owned()));
2035 Ok(())
2036 }
2037
2038 async fn set_reaction(
2039 &self,
2040 channel: &str,
2041 ts: &str,
2042 name: &str,
2043 add: bool,
2044 ) -> Result<(), SlackError> {
2045 self.reactions.lock().push((channel.to_owned(), ts.to_owned(), name.to_owned(), add));
2046 Ok(())
2047 }
2048
2049 async fn file_info(&self, id: &str) -> Result<SlackFile, SlackError> {
2050 *self.file_info_calls.lock() += 1;
2051 let gate = self.file_info_park.lock().clone();
2052 if let Some(gate) = gate {
2053 gate.notified().await;
2054 }
2055 let files = self.files.lock();
2056 let Some((name, _)) = files.get(id) else {
2057 return Err(SlackError::Api {
2058 method: "files.info".to_owned(),
2059 error: "file_not_found".to_owned(),
2060 needed: None,
2061 });
2062 };
2063 Ok(SlackFile {
2064 id: id.to_owned(),
2065 name: name.clone(),
2066 url_private: format!("https://files.test/{id}"),
2067 shares: self.file_shares.lock().get(id).cloned().unwrap_or_default(),
2068 })
2069 }
2070
2071 async fn get_bytes(&self, url: &str) -> Result<Vec<u8>, SlackError> {
2072 let id = url.rsplit('/').next().unwrap_or_default();
2073 self.files.lock().get(id).map(|(_, bytes)| bytes.clone()).ok_or_else(|| {
2074 SlackError::Transport {
2075 method: "download".to_owned(),
2076 detail: format!("no seeded file at {url}"),
2077 }
2078 })
2079 }
2080
2081 async fn post_bytes(&self, _url: &str, _body: Vec<u8>) -> Result<(), SlackError> {
2082 Ok(())
2083 }
2084
2085 async fn get_upload_url(
2086 &self,
2087 name: &str,
2088 _length: usize,
2089 ) -> Result<(String, String), SlackError> {
2090 *self.upload_urls.lock() += 1;
2091 Ok((format!("https://upload.test/{name}"), "F-uploaded".to_owned()))
2092 }
2093
2094 async fn complete_upload(
2095 &self,
2096 file_id: &str,
2097 channel: &str,
2098 thread_ts: Option<&str>,
2099 ) -> Result<Vec<SlackFile>, SlackError> {
2100 self.uploads.lock().push((
2101 file_id.to_owned(),
2102 channel.to_owned(),
2103 thread_ts.map(str::to_owned),
2104 ));
2105 let gate = self.upload_park.lock().clone();
2106 if let Some(gate) = gate {
2107 gate.notified().await;
2108 }
2109 Ok(vec![SlackFile {
2110 id: file_id.to_owned(),
2111 name: String::new(),
2112 url_private: String::new(),
2113 shares: self.upload_shares.lock().clone().unwrap_or_default(),
2114 }])
2115 }
2116
2117 async fn search_messages(
2118 &self,
2119 query: &str,
2120 _count: u32,
2121 cursor: Option<&str>,
2122 ) -> Result<SearchPage, SlackError> {
2123 self.search_reads.lock().push(query.to_owned());
2124 let pages = self.search_pages.lock();
2125 let index = cursor.and_then(|cursor| cursor.parse::<usize>().ok()).unwrap_or(0);
2126 let matches = pages.get(index).cloned().unwrap_or_default();
2127 Ok(SearchPage {
2128 matches,
2129 next_cursor: (index + 1 < pages.len()).then(|| (index + 1).to_string()),
2130 })
2131 }
2132
2133 async fn user_info(&self, user: &str) -> Result<SlackUser, SlackError> {
2134 if self.user_failures.lock().contains(user) {
2135 return Err(SlackError::RateLimited {
2136 method: "users.info".to_owned(),
2137 retry_after: std::time::Duration::from_secs(5),
2138 });
2139 }
2140 Ok(SlackUser {
2141 id: user.to_owned(),
2142 name: "tester".to_owned(),
2143 real_name: None,
2144 tz: None,
2145 deleted: false,
2146 profile: None,
2147 })
2148 }
2149
2150 async fn list_users(&self) -> Result<Vec<SlackUser>, SlackError> {
2151 Ok(self.users_rows.lock().clone())
2152 }
2153
2154 async fn pins(&self, channel: &str) -> Result<Vec<SlackPin>, SlackError> {
2155 self.pin_reads.lock().push(channel.to_owned());
2156 Ok(self.pins.lock().get(channel).cloned().unwrap_or_default())
2157 }
2158
2159 async fn bookmarks(&self, channel: &str) -> Result<Vec<SlackBookmark>, SlackError> {
2160 self.bookmark_reads.lock().push(channel.to_owned());
2161 Ok(self.bookmark_rows.lock().get(channel).cloned().unwrap_or_default())
2162 }
2163 }
2164
2165 fn facade_with_recording_slack() -> (
2168 Arc<dyn SlackFacade>,
2169 Arc<Workspace>,
2170 Arc<RecordingApi>,
2171 tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
2172 ) {
2173 let (facade, ws, api, rx, _dir) = facade_with_recording_slack_inner(false);
2174 (facade, ws, api, rx)
2175 }
2176
2177 type RecordingSlack = (
2180 Arc<dyn SlackFacade>,
2181 Arc<Workspace>,
2182 Arc<RecordingApi>,
2183 tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
2184 tempfile::TempDir,
2185 );
2186
2187 fn facade_with_recording_slack_and_store() -> RecordingSlack {
2190 facade_with_recording_slack_inner(true)
2191 }
2192
2193 fn facade_with_recording_slack_inner(with_store: bool) -> RecordingSlack {
2194 let dir = tempdir().expect("tempdir");
2195 let mut config = LoadedConfig::empty_for_test();
2196 config.slack = vec![cfg("acme")];
2197 let api = Arc::new(RecordingApi::default());
2198 let mut apis: BTreeMap<String, Arc<dyn SlackApi>> = BTreeMap::new();
2199 apis.insert("acme".to_owned(), api.clone());
2200 let (ws, rx) = Workspace::testing_stub_with_slack(
2201 dir.path().to_path_buf(),
2202 config,
2203 Arc::new(crate::slack::SlackWorkspaces::from_apis(apis)),
2204 None,
2205 );
2206 if with_store {
2207 ws.install_db_for_test(
2208 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
2209 );
2210 }
2211 ws.seed_test_project_with_slack_workspace("forge", "/tmp/slack-facade-scope", "acme");
2212 ws.record_connected_session("/tmp/slack-facade-scope", "caller-uuid", None);
2213 let facade = ProdSlackFacade::from_arc(&ws);
2214 (facade, ws, api, rx, dir)
2215 }
2216
2217 #[tokio::test]
2221 async fn a_tool_resolves_each_projects_binding_from_the_config_file() {
2222 let dir = tempdir().expect("tempdir");
2226 let forge = dir.path().join("forge");
2227 std::fs::create_dir_all(&forge).expect("forge/");
2228 std::fs::write(
2229 forge.join("forge.toml"),
2230 r#"
2231[[orgs]]
2232name = "Busytools"
2233accounts = ["Acct"]
2234[[orgs.projects]]
2235name = "alpha"
2236path = "/tmp/alpha"
2237[orgs.projects.slack]
2238workspace = "acme"
2239[[orgs.projects]]
2240name = "beta"
2241path = "/tmp/beta"
2242[orgs.projects.slack]
2243workspace = "beta-ws"
2244[[orgs.projects]]
2245name = "gamma"
2246path = "/tmp/gamma"
2247[[accounts]]
2248display_name = "Acct"
2249token = "t"
2250models = ["claude-sonnet-5"]
2251provider = "anthropic"
2252[[slack]]
2253workspace = "acme"
2254token = "x"
2255[[slack]]
2256workspace = "beta-ws"
2257token = "y"
2258"#,
2259 )
2260 .expect("write forge.toml");
2261 let config = crate::config::load_from_dir(dir.path()).expect("the config loads");
2262 let mut apis: BTreeMap<String, Arc<dyn SlackApi>> = BTreeMap::new();
2263 apis.insert("acme".to_owned(), Arc::new(RecordingApi::default()));
2264 apis.insert("beta-ws".to_owned(), Arc::new(RecordingApi::default()));
2265 let (ws, _rx) = Workspace::testing_stub_with_slack(
2266 dir.path().to_path_buf(),
2267 config,
2268 Arc::new(crate::slack::SlackWorkspaces::from_apis(apis)),
2269 None,
2270 );
2271 let facade = ProdSlackFacade::from_arc(&ws);
2272
2273 for (project, expected) in [("alpha", "acme"), ("beta", "beta-ws")] {
2274 let tool = crate::mcp::slack::Me {
2275 facade: facade.clone(),
2276 slot: SessionSlot::lead("Busytools", project),
2277 };
2278 let out = forge_sdk::mcp::tool::Tool::call(
2279 &tool,
2280 forge_sdk::mcp::tool::ToolInput { value: serde_json::json!({}) },
2281 )
2282 .await;
2283 let text = crate::mcp::test_support::text_of(&out);
2284 assert!(!out.is_error, "{project} answers: {text}");
2285 assert_eq!(
2286 serde_json::from_str::<serde_json::Value>(text).expect("identity json")["workspace"],
2287 expected,
2288 "the slot's own project binding is the one resolved",
2289 );
2290 }
2291
2292 let unbound =
2293 crate::mcp::slack::List { facade, slot: SessionSlot::lead("Busytools", "gamma") };
2294 let out = forge_sdk::mcp::tool::Tool::call(
2295 &unbound,
2296 forge_sdk::mcp::tool::ToolInput { value: serde_json::json!({}) },
2297 )
2298 .await;
2299 let text = crate::mcp::test_support::text_of(&out);
2300 assert!(out.is_error, "a project with no binding refuses: {text}");
2301 assert!(text.contains("Busytools/gamma"), "names the scope: {text}");
2302 assert!(text.contains("[orgs.slack]"), "names the org key: {text}");
2303 assert!(text.contains("[orgs.projects.slack]"), "names the project key: {text}");
2304 }
2305
2306 fn answered(approved: bool) -> DraftResolution {
2310 DraftResolution::Answered(if approved {
2311 SlackDraftAnswer::Approve
2312 } else {
2313 SlackDraftAnswer::Decline
2314 })
2315 }
2316
2317 fn sent_back(note: &str) -> DraftResolution {
2318 DraftResolution::Answered(SlackDraftAnswer::SendBack { note: note.to_owned() })
2319 }
2320
2321 async fn wait_for_draft(ws: &Arc<Workspace>) -> Uuid {
2324 for _ in 0..200 {
2325 if let Some(id) = ws.slack_drafts.lock().keys().next().copied() {
2326 return id;
2327 }
2328 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2329 }
2330 panic!("no draft was registered");
2331 }
2332
2333 async fn until(check: impl Fn() -> bool) {
2335 for _ in 0..400 {
2336 if check() {
2337 return;
2338 }
2339 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2340 }
2341 panic!("the condition never held");
2342 }
2343
2344 fn post_request(conversation: &str, text: &str) -> SlackPostRequest {
2345 SlackPostRequest {
2346 conversation: conversation.to_owned(),
2347 thread_ts: None,
2348 text: text.to_owned(),
2349 }
2350 }
2351
2352 fn edit_request(conversation: &str, ts: &str, text: Option<&str>) -> SlackEditRequest {
2353 SlackEditRequest {
2354 conversation: conversation.to_owned(),
2355 ts: ts.to_owned(),
2356 text: text.map(str::to_owned),
2357 }
2358 }
2359
2360 fn react_request(conversation: &str, ts: &str, name: &str, add: bool) -> SlackReactRequest {
2361 SlackReactRequest {
2362 conversation: conversation.to_owned(),
2363 ts: ts.to_owned(),
2364 name: name.to_owned(),
2365 add,
2366 }
2367 }
2368
2369 fn fetch_request(file_id: &str, dir: &std::path::Path) -> SlackFetchRequest {
2370 SlackFetchRequest { file_id: file_id.to_owned(), dir: dir.to_path_buf() }
2371 }
2372
2373 fn upload_request(conversation: &str, path: &std::path::Path) -> SlackUploadRequest {
2374 SlackUploadRequest {
2375 conversation: conversation.to_owned(),
2376 thread_ts: None,
2377 path: path.to_path_buf(),
2378 title: None,
2379 }
2380 }
2381
2382 #[test]
2385 fn a_file_name_from_slack_cannot_escape_its_directory() {
2386 assert_eq!(safe_file_name("notes.txt", "F1"), "notes.txt");
2387 assert_eq!(safe_file_name("../../escape.txt", "F1"), "escape.txt");
2388 assert_eq!(safe_file_name("..\\..\\escape.txt", "F1"), "escape.txt");
2389 assert_eq!(safe_file_name("/etc/passwd", "F1"), "passwd");
2390 assert_eq!(safe_file_name("..", "F1"), "F1", "a bare parent falls back to the id");
2391 assert_eq!(safe_file_name(".", "F1"), "F1");
2392 assert_eq!(safe_file_name("", "F1"), "F1");
2393 assert_eq!(safe_file_name("dir/", "F1"), "F1", "a trailing separator leaves nothing");
2394 }
2395
2396 #[tokio::test]
2397 async fn an_attachment_lands_on_disk_with_the_expected_bytes() {
2398 let (facade, _ws, api, _rx) = facade_with_recording_slack();
2399 api.seed_file("F1", "notes.txt", b"attachment body");
2400 let dir = tempdir().expect("tempdir");
2401
2402 let path = facade
2403 .fetch_attachment(&caller(), fetch_request("F1", dir.path()))
2404 .await
2405 .expect("fetched");
2406 assert_eq!(std::fs::read(&path).expect("read back"), b"attachment body");
2407 assert_eq!(path.file_name().and_then(|name| name.to_str()), Some("notes.txt"));
2408 }
2409
2410 #[tokio::test]
2411 async fn a_download_refuses_to_escape_the_chosen_directory() {
2412 let (facade, _ws, api, _rx) = facade_with_recording_slack();
2415 api.seed_file("F1", "../../escape.txt", b"nope");
2416 let dir = tempdir().expect("tempdir");
2417
2418 let path = facade
2419 .fetch_attachment(&caller(), fetch_request("F1", dir.path()))
2420 .await
2421 .expect("fetched");
2422 assert_eq!(
2426 path.parent(),
2427 Some(dir.path()),
2428 "the file must land directly in the chosen directory, never above it: {path:?}",
2429 );
2430 }
2431
2432 #[tokio::test]
2435 async fn a_download_refuses_to_clobber_an_existing_file() {
2436 let (facade, _ws, api, _rx) = facade_with_recording_slack();
2437 api.seed_file("F1", "notes.txt", b"new bytes");
2438 let dir = tempdir().expect("tempdir");
2439 let existing = dir.path().join("notes.txt");
2440 std::fs::write(&existing, b"precious").expect("write the existing file");
2441
2442 assert!(
2443 facade.fetch_attachment(&caller(), fetch_request("F1", dir.path())).await.is_err(),
2444 "an existing file must not be overwritten",
2445 );
2446 assert_eq!(
2447 std::fs::read(&existing).expect("read back"),
2448 b"precious",
2449 "and the existing file is untouched",
2450 );
2451 }
2452
2453 #[tokio::test]
2456 async fn a_fetch_creates_a_missing_target_directory() {
2457 let (facade, _ws, api, _rx) = facade_with_recording_slack();
2458 api.seed_file("F1", "notes.txt", b"attachment body");
2459 let root = tempdir().expect("tempdir");
2460 let dir = root.path().join("fresh/deeper");
2461
2462 let path =
2463 facade.fetch_attachment(&caller(), fetch_request("F1", &dir)).await.expect("fetched");
2464 assert_eq!(
2465 path.parent(),
2466 Some(dir.as_path()),
2467 "the file lands in the directory that was created: {path:?}",
2468 );
2469 assert_eq!(std::fs::read(&path).expect("read back"), b"attachment body");
2470 }
2471
2472 #[tokio::test]
2476 async fn a_fetch_rejects_a_dir_that_is_a_file() {
2477 let (facade, _ws, _api, _rx) = facade_with_recording_slack();
2478 let root = tempdir().expect("tempdir");
2479 let not_a_dir = root.path().join("plain-file");
2480 std::fs::write(¬_a_dir, b"data").expect("write the file");
2481
2482 let err = facade
2483 .fetch_attachment(&caller(), fetch_request("F1", ¬_a_dir))
2484 .await
2485 .expect_err("a file is not a directory");
2486 assert_eq!(
2487 err,
2488 SlackAttachmentError::NotADirectory(not_a_dir.clone()),
2489 "the error carries the path the caller chose",
2490 );
2491 }
2492
2493 #[tokio::test]
2496 async fn a_fetch_failure_names_the_path() {
2497 let (facade, _ws, api, _rx) = facade_with_recording_slack();
2498 api.seed_file("F1", "notes.txt", b"body");
2499 let root = tempdir().expect("tempdir");
2500 let block = root.path().join("plain-file");
2501 std::fs::write(&block, b"data").expect("write the file");
2502 let dir = block.join("sub");
2503
2504 let err = facade
2505 .fetch_attachment(&caller(), fetch_request("F1", &dir))
2506 .await
2507 .expect_err("cannot land");
2508 let SlackAttachmentError::Io(message) = err else {
2509 panic!("expected the io error, got {err:?}");
2510 };
2511 assert!(
2512 message.contains(&dir.display().to_string()),
2513 "the error names the directory: {message}",
2514 );
2515 }
2516
2517 #[tokio::test]
2520 async fn a_held_draft_names_the_conversation_not_its_id() {
2521 let (facade, ws, api, mut rx) = facade_with_recording_slack();
2522 api.seed_conversation("C1", "ops");
2523
2524 let task = tokio::spawn({
2525 let facade = facade.clone();
2526 async move { facade.post(&caller(), post_request("C1", "hello")).await }
2527 });
2528
2529 let mut seen = None;
2530 for _ in 0..200 {
2531 while let Ok(frame) = rx.try_recv() {
2532 if let crate::protocol::SessionUpdate::SlackPostPending { draft, .. } =
2533 std::sync::Arc::unwrap_or_clone(frame.update)
2534 {
2535 seen = Some(draft.conversation_label);
2536 }
2537 }
2538 if seen.is_some() {
2539 break;
2540 }
2541 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2542 }
2543 assert_eq!(
2544 seen.expect("the draft reaches the prompt"),
2545 "#ops",
2546 "the prompt names the channel the message goes to, as a channel",
2547 );
2548
2549 let id = wait_for_draft(&ws).await;
2550 ws.resolve_slack_draft(id, &caller(), answered(false));
2551 let _ = task.await;
2552 }
2553
2554 async fn parked_draft(ws: &Arc<Workspace>) -> forge_primitives::slack::SlackDraft {
2557 for _ in 0..200 {
2558 let draft = ws.slack_drafts.lock().values().next().map(|(_, draft, _)| draft.clone());
2559 if let Some(draft) = draft {
2560 return draft;
2561 }
2562 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2563 }
2564 panic!("no draft was registered");
2565 }
2566
2567 fn parent_message(ts: &str, user: &str, text: &str) -> SlackHistoryMessage {
2569 SlackHistoryMessage {
2570 ts: ts.to_owned(),
2571 user: Some(user.to_owned()),
2572 text: text.to_owned(),
2573 thread_ts: Some(ts.to_owned()),
2574 reply_count: 0,
2575 bot_name: None,
2576 parent_user_id: None,
2577 latest_reply: None,
2578 edited: None,
2579 files: Vec::new(),
2580 }
2581 }
2582
2583 fn threaded_post(conversation: &str, ts: &str, text: &str) -> SlackPostRequest {
2584 SlackPostRequest {
2585 conversation: conversation.to_owned(),
2586 thread_ts: Some(ts.to_owned()),
2587 text: text.to_owned(),
2588 }
2589 }
2590
2591 #[tokio::test]
2594 async fn a_held_draft_names_its_thread_and_the_people_it_tags() {
2595 let (facade, ws, api, _rx) = facade_with_recording_slack();
2596 api.seed_conversation("C1", "ops");
2597 api.seed_replies(
2598 "C1",
2599 "100.0",
2600 vec![parent_message("100.0", "U2", "the deploy\nis green")],
2601 );
2602
2603 let task = tokio::spawn({
2604 let facade = facade.clone();
2605 async move { facade.post(&caller(), threaded_post("C1", "100.0", "confirmed <@U9>")).await }
2606 });
2607
2608 let draft = parked_draft(&ws).await;
2609 assert_eq!(
2610 draft.thread,
2611 Some(forge_primitives::slack::SlackThreadName {
2612 author: "tester".to_owned(),
2613 excerpt: "the deploy is green".to_owned(),
2614 }),
2615 "the thread is named by its author and one flattened line",
2616 );
2617 assert_eq!(
2618 draft.tagged,
2619 vec![forge_primitives::slack::SlackPerson {
2620 id: "U9".to_owned(),
2621 name: Some("tester".to_owned()),
2622 }],
2623 "the tagged person is named, not left as an id",
2624 );
2625
2626 let id = wait_for_draft(&ws).await;
2627 ws.resolve_slack_draft(id, &caller(), answered(false));
2628 let _ = task.await;
2629 }
2630
2631 #[tokio::test]
2634 async fn a_parents_mention_reads_as_its_name_in_the_excerpt() {
2635 let (facade, ws, api, _rx) = facade_with_recording_slack();
2636 api.seed_conversation("C1", "ops");
2637 api.seed_replies(
2638 "C1",
2639 "100.0",
2640 vec![parent_message("100.0", "U2", "<@U9> the deploy is green")],
2641 );
2642
2643 let task = tokio::spawn({
2644 let facade = facade.clone();
2645 async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
2646 });
2647
2648 assert_eq!(
2649 parked_draft(&ws).await.thread.map(|thread| thread.excerpt),
2650 Some("@tester the deploy is green".to_owned()),
2651 "the parent's mention draws its name, not the raw id",
2652 );
2653 let id = wait_for_draft(&ws).await;
2654 ws.resolve_slack_draft(id, &caller(), answered(false));
2655 let _ = task.await;
2656 }
2657
2658 #[tokio::test]
2661 async fn a_parents_mention_keeps_its_id_when_the_lookup_fails() {
2662 let (facade, ws, api, _rx) = facade_with_recording_slack();
2663 api.seed_conversation("C1", "ops");
2664 api.fail_user_info("U9");
2665 api.seed_replies(
2666 "C1",
2667 "100.0",
2668 vec![parent_message("100.0", "U2", "<@U9> the deploy is green")],
2669 );
2670
2671 let task = tokio::spawn({
2672 let facade = facade.clone();
2673 async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
2674 });
2675
2676 assert_eq!(
2677 parked_draft(&ws).await.thread.map(|thread| thread.excerpt),
2678 Some("U9 the deploy is green".to_owned()),
2679 "an unnamed mention keeps its id rather than a guessed name",
2680 );
2681 let id = wait_for_draft(&ws).await;
2682 ws.resolve_slack_draft(id, &caller(), answered(false));
2683 let _ = task.await;
2684 }
2685
2686 #[tokio::test]
2689 async fn only_the_drafts_own_mentions_are_tagged() {
2690 let (facade, ws, api, _rx) = facade_with_recording_slack();
2691 api.seed_conversation("C1", "ops");
2692 api.seed_replies(
2693 "C1",
2694 "100.0",
2695 vec![parent_message("100.0", "U2", "<@U8> the deploy is green")],
2696 );
2697
2698 let task = tokio::spawn({
2699 let facade = facade.clone();
2700 async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello <@U9>")).await }
2701 });
2702
2703 let draft = parked_draft(&ws).await;
2704 assert_eq!(
2705 draft.tagged,
2706 vec![SlackPerson { id: "U9".to_owned(), name: Some("tester".to_owned()) }],
2707 "a parent's mention is named in the excerpt, not tagged on the draft",
2708 );
2709 assert_eq!(
2710 draft.thread.map(|thread| thread.excerpt),
2711 Some("@tester the deploy is green".to_owned()),
2712 "the parent's mention still reads as its name",
2713 );
2714 let id = wait_for_draft(&ws).await;
2715 ws.resolve_slack_draft(id, &caller(), answered(false));
2716 let _ = task.await;
2717 }
2718
2719 #[tokio::test]
2722 async fn a_quoted_parent_is_bounded_after_its_markup_is_flattened() {
2723 let (facade, ws, api, _rx) = facade_with_recording_slack();
2724 api.seed_conversation("C1", "ops");
2725 api.seed_replies(
2726 "C1",
2727 "100.0",
2728 vec![parent_message(
2729 "100.0",
2730 "U2",
2731 "*<https://example.com|the staging deploy>* is <@U9> and every check is green \
2732 while the p99 is still high and the canary holds at one percent",
2733 )],
2734 );
2735
2736 let task = tokio::spawn({
2737 let facade = facade.clone();
2738 async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
2739 });
2740
2741 let excerpt = parked_draft(&ws).await.thread.expect("the thread is named").excerpt;
2742 assert!(excerpt.ends_with("..."), "the flattened line is cut: {excerpt:?}");
2743 assert!(
2744 excerpt.chars().count() <= ProdSlackFacade::THREAD_EXCERPT_CHARS + 3,
2745 "the bound holds after flattening: {excerpt:?}",
2746 );
2747 assert!(
2748 excerpt.chars().count() > ProdSlackFacade::THREAD_EXCERPT_CHARS - 5,
2749 "the budget counts prose, so markup did not consume the cut: {excerpt:?}",
2750 );
2751 assert!(
2752 !excerpt.contains('<') && !excerpt.contains('*'),
2753 "the bound is counted on prose, not raw markup: {excerpt:?}",
2754 );
2755 let kept = excerpt.strip_suffix("...").expect("the mark is there");
2756 assert!(!kept.ends_with(' '), "the cut lands at a word boundary: {excerpt:?}");
2757
2758 let id = wait_for_draft(&ws).await;
2759 ws.resolve_slack_draft(id, &caller(), answered(false));
2760 let _ = task.await;
2761 }
2762
2763 #[tokio::test]
2765 async fn a_root_post_names_no_thread_but_still_names_its_tags() {
2766 let (facade, ws, api, _rx) = facade_with_recording_slack();
2767 api.seed_conversation("C1", "ops");
2768
2769 let task = tokio::spawn({
2770 let facade = facade.clone();
2771 async move { facade.post(&caller(), post_request("C1", "ping <@U9>")).await }
2772 });
2773
2774 let draft = parked_draft(&ws).await;
2775 assert_eq!(draft.thread, None, "a root message has no thread segment");
2776 assert_eq!(
2777 draft.tagged,
2778 vec![forge_primitives::slack::SlackPerson {
2779 id: "U9".to_owned(),
2780 name: Some("tester".to_owned()),
2781 }],
2782 "the tags resolve whether or not the message replies into a thread",
2783 );
2784
2785 let id = wait_for_draft(&ws).await;
2786 ws.resolve_slack_draft(id, &caller(), answered(false));
2787 let _ = task.await;
2788 }
2789
2790 #[tokio::test]
2793 async fn a_parent_slack_no_longer_returns_leaves_the_thread_unnamed() {
2794 let (facade, ws, api, _rx) = facade_with_recording_slack();
2795 api.seed_conversation("C1", "ops");
2796
2797 let task = tokio::spawn({
2798 let facade = facade.clone();
2799 async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
2800 });
2801
2802 assert_eq!(parked_draft(&ws).await.thread, None, "no parent means no name to draw");
2803 assert_eq!(
2804 api.reply_reads(),
2805 vec![("C1".to_owned(), "100.0".to_owned())],
2806 "the read was attempted against the conversation, not skipped",
2807 );
2808 let id = wait_for_draft(&ws).await;
2809 ws.resolve_slack_draft(id, &caller(), answered(false));
2810 let _ = task.await;
2811 }
2812
2813 #[tokio::test]
2817 async fn a_channel_is_prefixed_and_a_dm_and_a_group_dm_are_not() {
2818 let recording = Arc::new(RecordingApi::default());
2819 recording.seed_conversation("C1", "ops");
2820 recording.seed_dm("D1", "U9");
2821 recording.seed_mpim("G1", "mpdm-ved-alice-1");
2822 let api: Arc<dyn SlackApi> = Arc::clone(&recording) as Arc<dyn SlackApi>;
2823
2824 assert_eq!(
2825 ProdSlackFacade::resolve_conversation(&api, "acme", "C1").await,
2826 ("C1".to_owned(), "#ops".to_owned()),
2827 "a channel's label is the card's spelling",
2828 );
2829 assert_eq!(
2830 ProdSlackFacade::resolve_conversation(&api, "acme", "D1").await,
2831 ("D1".to_owned(), "U9".to_owned()),
2832 "a DM is named after its partner, never with a `#`",
2833 );
2834 assert_eq!(
2835 ProdSlackFacade::resolve_conversation(&api, "acme", "G1").await,
2836 ("G1".to_owned(), "mpdm-ved-alice-1".to_owned()),
2837 "a group DM keeps Slack's own generated name",
2838 );
2839 assert_eq!(
2840 ProdSlackFacade::resolve_conversation(&api, "acme", "#ops").await,
2841 ("C1".to_owned(), "#ops".to_owned()),
2842 "the card's own label resolves when an agent echoes it back",
2843 );
2844 }
2845
2846 #[tokio::test]
2849 async fn a_list_failure_keeps_the_callers_spelling_and_a_bare_id() {
2850 let recording = Arc::new(RecordingApi::default());
2851 recording.fail_list();
2852 let api: Arc<dyn SlackApi> = Arc::clone(&recording) as Arc<dyn SlackApi>;
2853
2854 assert_eq!(
2855 ProdSlackFacade::resolve_conversation(&api, "acme", "#ops").await,
2856 ("ops".to_owned(), "#ops".to_owned()),
2857 "the id is addressable; the label keeps what the caller said",
2858 );
2859 assert_eq!(
2860 ProdSlackFacade::resolve_conversation(&api, "acme", "U9").await,
2861 ("U9".to_owned(), "U9".to_owned()),
2862 "a partner id carries no `#` either way",
2863 );
2864 }
2865
2866 #[tokio::test]
2869 async fn an_edit_addressed_by_a_dm_partner_reads_the_resolved_id() {
2870 let (facade, ws, api, _rx) = facade_with_recording_slack();
2871 api.seed_dm("D1", "U9");
2872 api.seed_replies("D1", "100.0", vec![parent_message("100.0", "U2", "the message")]);
2873
2874 let task = tokio::spawn({
2875 let facade = facade.clone();
2876 async move { facade.edit(&caller(), edit_request("U9", "100.0", Some("rewritten"))).await }
2877 });
2878
2879 let draft = parked_draft(&ws).await;
2880 assert_eq!(
2881 draft.thread.map(|thread| thread.excerpt),
2882 Some("the message".to_owned()),
2883 "the edit names the message it acts on",
2884 );
2885 assert_eq!(
2886 api.reply_reads(),
2887 vec![("D1".to_owned(), "100.0".to_owned())],
2888 "the read names the conversation Slack knows",
2889 );
2890 let id = wait_for_draft(&ws).await;
2891 ws.resolve_slack_draft(id, &caller(), answered(false));
2892 let _ = task.await;
2893 }
2894
2895 #[tokio::test]
2898 async fn an_upload_addressed_by_a_dm_partner_reads_the_resolved_id() {
2899 let (facade, ws, api, _rx) = facade_with_recording_slack();
2900 api.seed_dm("D1", "U9");
2901 api.seed_replies("D1", "100.0", vec![parent_message("100.0", "U2", "the parent")]);
2902 let dir = tempdir().expect("tempdir");
2903 let path = dir.path().join("notes.txt");
2904 std::fs::write(&path, b"body").expect("write the file");
2905 let mut request = upload_request("U9", &path);
2906 request.thread_ts = Some("100.0".to_owned());
2907
2908 let task = tokio::spawn({
2909 let facade = facade.clone();
2910 async move { facade.post_attachment(&caller(), request).await }
2911 });
2912
2913 assert!(parked_draft(&ws).await.thread.is_some(), "the upload names its thread");
2914 assert_eq!(
2915 api.reply_reads(),
2916 vec![("D1".to_owned(), "100.0".to_owned())],
2917 "the read names the conversation Slack knows",
2918 );
2919 let id = wait_for_draft(&ws).await;
2920 ws.resolve_slack_draft(id, &caller(), answered(false));
2921 let _ = task.await;
2922 }
2923
2924 #[tokio::test]
2927 async fn a_parent_with_no_author_leaves_the_thread_unnamed() {
2928 let (facade, ws, api, _rx) = facade_with_recording_slack();
2929 api.seed_conversation("C1", "ops");
2930 api.seed_replies(
2931 "C1",
2932 "100.0",
2933 vec![SlackHistoryMessage {
2934 user: None,
2935 bot_name: None,
2936 ..parent_message("100.0", "U2", "the parent")
2937 }],
2938 );
2939
2940 let task = tokio::spawn({
2941 let facade = facade.clone();
2942 async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
2943 });
2944
2945 assert_eq!(parked_draft(&ws).await.thread, None, "no author means no name to draw");
2946 let id = wait_for_draft(&ws).await;
2947 ws.resolve_slack_draft(id, &caller(), answered(false));
2948 let _ = task.await;
2949 }
2950
2951 #[tokio::test]
2954 async fn a_multibyte_parent_is_cut_by_characters_not_bytes() {
2955 let (facade, ws, api, _rx) = facade_with_recording_slack();
2956 api.seed_conversation("C1", "ops");
2957 let text = "é".repeat(100);
2958 api.seed_replies("C1", "100.0", vec![parent_message("100.0", "U2", &text)]);
2959
2960 let task = tokio::spawn({
2961 let facade = facade.clone();
2962 async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
2963 });
2964
2965 let excerpt = parked_draft(&ws).await.thread.expect("the thread is named").excerpt;
2966 assert_eq!(
2967 excerpt.chars().count(),
2968 ProdSlackFacade::THREAD_EXCERPT_CHARS + 3,
2969 "the bound is characters plus the mark, not bytes",
2970 );
2971 assert!(excerpt.starts_with('é'), "the glyph survives whole: {excerpt:?}");
2972 let id = wait_for_draft(&ws).await;
2973 ws.resolve_slack_draft(id, &caller(), answered(false));
2974 let _ = task.await;
2975 }
2976
2977 #[tokio::test]
2981 async fn a_message_inside_a_thread_is_not_named_as_the_thread_parent() {
2982 let (facade, ws, api, _rx) = facade_with_recording_slack();
2983 api.seed_conversation("C1", "ops");
2984 api.seed_replies("C1", "200.0", vec![parent_message("100.0", "U2", "the thread parent")]);
2985
2986 let task = tokio::spawn({
2987 let facade = facade.clone();
2988 async move { facade.edit(&caller(), edit_request("C1", "200.0", Some("rewritten"))).await }
2989 });
2990
2991 assert_eq!(
2992 parked_draft(&ws).await.thread,
2993 None,
2994 "the thread parent is not the message a reply's edit acts on",
2995 );
2996 let id = wait_for_draft(&ws).await;
2997 ws.resolve_slack_draft(id, &caller(), answered(false));
2998 let _ = task.await;
2999 }
3000
3001 #[tokio::test]
3005 async fn a_draft_addressed_by_a_dm_partner_names_the_thread_by_its_resolved_id() {
3006 let (facade, ws, api, _rx) = facade_with_recording_slack();
3007 api.seed_dm("D1", "U9");
3008 api.seed_replies("D1", "100.0", vec![parent_message("100.0", "U2", "the parent")]);
3009
3010 let task = tokio::spawn({
3011 let facade = facade.clone();
3012 async move { facade.post(&caller(), threaded_post("U9", "100.0", "hello")).await }
3013 });
3014
3015 let draft = parked_draft(&ws).await;
3016 assert_eq!(draft.conversation, "U9", "the wire keeps what the caller addressed");
3017 assert!(
3018 draft.thread.is_some(),
3019 "the thread is read from the resolved id, not the partner's"
3020 );
3021 assert_eq!(
3022 api.reply_reads(),
3023 vec![("D1".to_owned(), "100.0".to_owned())],
3024 "the read names the conversation Slack knows",
3025 );
3026
3027 let id = wait_for_draft(&ws).await;
3028 ws.resolve_slack_draft(id, &caller(), answered(false));
3029 let _ = task.await;
3030 }
3031
3032 #[tokio::test]
3036 async fn an_edit_or_a_delete_of_a_top_level_message_names_it() {
3037 for text in [Some("rewritten"), None] {
3038 let (facade, ws, api, _rx) = facade_with_recording_slack();
3039 api.seed_conversation("C1", "ops");
3040 api.seed_replies("C1", "100.0", vec![parent_message("100.0", "U2", "the message")]);
3041
3042 let task = tokio::spawn({
3043 let facade = facade.clone();
3044 async move { facade.edit(&caller(), edit_request("C1", "100.0", text)).await }
3045 });
3046
3047 let draft = parked_draft(&ws).await;
3048 assert_eq!(
3049 draft.thread.map(|thread| thread.excerpt),
3050 Some("the message".to_owned()),
3051 "the operation names the message it acts on",
3052 );
3053 let id = wait_for_draft(&ws).await;
3054 ws.resolve_slack_draft(id, &caller(), answered(false));
3055 let _ = task.await;
3056 }
3057 }
3058
3059 #[tokio::test]
3062 async fn a_failed_author_lookup_draws_the_raw_id_as_the_author() {
3063 let (facade, ws, api, _rx) = facade_with_recording_slack();
3064 api.seed_conversation("C1", "ops");
3065 api.seed_replies("C1", "100.0", vec![parent_message("100.0", "U2", "the parent")]);
3066 api.fail_user_info("U2");
3067
3068 let task = tokio::spawn({
3069 let facade = facade.clone();
3070 async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
3071 });
3072
3073 let draft = parked_draft(&ws).await;
3074 assert_eq!(
3075 draft.thread.map(|thread| thread.author),
3076 Some("U2".to_owned()),
3077 "the raw id is drawn rather than an invented or empty name",
3078 );
3079 let id = wait_for_draft(&ws).await;
3080 ws.resolve_slack_draft(id, &caller(), answered(false));
3081 let _ = task.await;
3082 }
3083
3084 #[tokio::test]
3087 async fn a_bot_parent_is_named_by_its_own_name() {
3088 let (facade, ws, api, _rx) = facade_with_recording_slack();
3089 api.seed_conversation("C1", "ops");
3090 api.seed_replies(
3091 "C1",
3092 "100.0",
3093 vec![SlackHistoryMessage {
3094 bot_name: Some("Deploy Bot".to_owned()),
3095 ..parent_message("100.0", "U3", "the deploy is green")
3096 }],
3097 );
3098 api.fail_user_info("U3");
3099
3100 let task = tokio::spawn({
3101 let facade = facade.clone();
3102 async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
3103 });
3104
3105 let draft = parked_draft(&ws).await;
3106 assert_eq!(
3107 draft.thread.map(|thread| thread.author),
3108 Some("Deploy Bot".to_owned()),
3109 "the bot's own name wins over a lookup",
3110 );
3111 let id = wait_for_draft(&ws).await;
3112 ws.resolve_slack_draft(id, &caller(), answered(false));
3113 let _ = task.await;
3114 }
3115
3116 #[tokio::test]
3119 async fn a_draft_still_parks_when_the_parent_and_a_tag_lookup_fail() {
3120 let (facade, ws, api, _rx) = facade_with_recording_slack();
3121 api.seed_conversation("C1", "ops");
3122 api.fail_replies("C1", "100.0");
3123 api.fail_user_info("U9");
3124
3125 let task = tokio::spawn({
3126 let facade = facade.clone();
3127 async move { facade.post(&caller(), threaded_post("C1", "100.0", "confirmed <@U9>")).await }
3128 });
3129
3130 let draft = parked_draft(&ws).await;
3131 assert_eq!(draft.thread, None, "a failed parent read is not invented over");
3132 assert_eq!(
3133 draft.tagged,
3134 vec![forge_primitives::slack::SlackPerson { id: "U9".to_owned(), name: None }],
3135 "an unresolved tag keeps its id, never dropped",
3136 );
3137
3138 let id = wait_for_draft(&ws).await;
3139 ws.resolve_slack_draft(id, &caller(), answered(false));
3140 let _ = task.await;
3141 }
3142
3143 #[tokio::test]
3146 async fn a_post_carries_the_ts_slack_answered_with() {
3147 let (facade, ws, api, _rx) = facade_with_recording_slack();
3148 api.answer_posts_with("1790186552.442169");
3149
3150 let task = tokio::spawn({
3151 let facade = facade.clone();
3152 async move { facade.post(&caller(), post_request("C1", "hello")).await }
3153 });
3154 let id = wait_for_draft(&ws).await;
3155 ws.resolve_slack_draft(id, &caller(), answered(true));
3156
3157 let outcome = task.await.expect("the post task does not panic").expect("the post landed");
3158 assert_eq!(
3159 outcome.ts,
3160 vec!["1790186552.442169".to_owned()],
3161 "the ts the wire sent reaches the caller",
3162 );
3163 assert_eq!(outcome.workspace, "acme", "and the label it posted into rides along");
3164 }
3165
3166 #[tokio::test]
3170 async fn a_multi_part_post_ledgers_every_landed_part() {
3171 let (facade, ws, _api, _rx, _dir) = facade_with_recording_slack_and_store();
3172 let text = "a".repeat(forge_connectors::slack::POST_LIMIT + 1);
3173
3174 let task = tokio::spawn({
3175 let facade = facade.clone();
3176 async move { facade.post(&caller(), post_request("C1", &text)).await }
3177 });
3178 let id = wait_for_draft(&ws).await;
3179 ws.resolve_slack_draft(id, &caller(), answered(true));
3180
3181 let outcome = task.await.expect("no panic").expect("the parts land");
3182 assert_eq!(outcome.ts, vec!["1.0".to_owned(), "2.0".to_owned()], "two parts were sent");
3183 for ts in &outcome.ts {
3184 assert!(ws.slack_own_post("acme", "C1", ts), "part {ts} must be refused to the sweep");
3185 }
3186 assert!(
3187 !ws.slack_own_post("acme", "C2", "1.0"),
3188 "and the ledger stays scoped to the conversation it was posted in",
3189 );
3190 }
3191
3192 #[tokio::test]
3197 async fn a_post_is_ledgered_under_the_channel_slack_posted_into() {
3198 let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3199 api.answer_posts_with("1700000000.000100");
3200 api.answer_posts_in("D456");
3201
3202 let task = tokio::spawn({
3203 let facade = facade.clone();
3204 async move { facade.post(&caller(), post_request("U123", "hello")).await }
3205 });
3206 let id = wait_for_draft(&ws).await;
3207 ws.resolve_slack_draft(id, &caller(), answered(true));
3208 let outcome = task.await.expect("no panic").expect("the post lands");
3209
3210 assert_eq!(outcome.ts, vec!["1700000000.000100".to_owned()]);
3211 assert!(
3212 ws.slack_own_post("acme", "D456", "1700000000.000100"),
3213 "the conversation the post landed in is refused to the sweep",
3214 );
3215 assert!(
3216 !ws.slack_own_post("acme", "U123", "1700000000.000100"),
3217 "the address it was sent to is not the conversation the sweep reads",
3218 );
3219 }
3220
3221 #[tokio::test]
3225 async fn a_partial_multi_part_post_ledgers_the_parts_slack_took() {
3226 let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3227 api.fail_post_at(1);
3228 let text = "a".repeat(forge_connectors::slack::POST_LIMIT + 1);
3229
3230 let task = tokio::spawn({
3231 let facade = facade.clone();
3232 async move { facade.post(&caller(), post_request("C1", &text)).await }
3233 });
3234 let id = wait_for_draft(&ws).await;
3235 ws.resolve_slack_draft(id, &caller(), answered(true));
3236 let outcome = task.await.expect("no panic");
3237
3238 assert!(
3239 matches!(outcome, Err(SlackPostError::Partial { posted: 1, .. })),
3240 "the failure names how much landed: {outcome:?}",
3241 );
3242 assert!(
3243 ws.slack_own_post("acme", "C1", "1.0"),
3244 "the part Slack took is refused to the sweep",
3245 );
3246 }
3247
3248 #[tokio::test]
3251 async fn a_post_marks_its_conversation_in_flight_until_the_ledger_write() {
3252 let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3253 let gate = api.park_posts();
3254
3255 let task = tokio::spawn({
3256 let facade = facade.clone();
3257 async move { facade.post(&caller(), post_request("C1", "hello")).await }
3258 });
3259 let id = wait_for_draft(&ws).await;
3260 ws.resolve_slack_draft(id, &caller(), answered(true));
3261
3262 let mut marked = false;
3263 for _ in 0..400 {
3264 if ws.slack_post_in_flight("acme", "C1").is_some() {
3265 marked = true;
3266 break;
3267 }
3268 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3269 }
3270 assert!(marked, "the facade marks the conversation before the post returns");
3271
3272 gate.notify_one();
3273 task.await.expect("no panic").expect("the post lands");
3274 assert!(
3275 ws.slack_post_in_flight("acme", "C1").is_none(),
3276 "and clears it once the ts is ledgered",
3277 );
3278 }
3279
3280 #[tokio::test]
3284 async fn each_post_part_takes_a_fresh_in_flight_mark() {
3285 let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3286 let gate = api.park_posts();
3287 let text = "a".repeat(forge_connectors::slack::POST_LIMIT + 1);
3288
3289 let task = tokio::spawn({
3290 let facade = facade.clone();
3291 async move { facade.post(&caller(), post_request("C1", &text)).await }
3292 });
3293 let id = wait_for_draft(&ws).await;
3294 ws.resolve_slack_draft(id, &caller(), answered(true));
3295
3296 until(|| ws.slack_post_in_flight("acme", "C1").is_some()).await;
3297 let stale = std::time::Instant::now()
3299 .checked_sub(forge_connectors::slack::REQUEST_TIMEOUT)
3300 .and_then(|instant| instant.checked_sub(std::time::Duration::from_secs(1)))
3301 .expect("the clock is past the timeout");
3302 ws.slack_inflight
3303 .lock()
3304 .get_mut(&("acme".to_owned(), "C1".to_owned()))
3305 .expect("the part's entry")
3306 .iter_mut()
3307 .for_each(|stamp| stamp.since = stale);
3308 gate.notify_one();
3309
3310 until(|| api.posts().len() == 2).await;
3313 assert!(
3314 ws.slack_post_in_flight("acme", "C1").is_some(),
3315 "the second part takes a fresh mark of its own",
3316 );
3317 gate.notify_one();
3318 task.await.expect("no panic").expect("both parts land");
3319 }
3320
3321 #[tokio::test]
3324 async fn a_failed_post_clears_its_in_flight_mark() {
3325 let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3326 api.fail_post_at(0);
3327
3328 let task = tokio::spawn({
3329 let facade = facade.clone();
3330 async move { facade.post(&caller(), post_request("C1", "hello")).await }
3331 });
3332 let id = wait_for_draft(&ws).await;
3333 ws.resolve_slack_draft(id, &caller(), answered(true));
3334
3335 assert!(task.await.expect("no panic").is_err(), "the post fails");
3336 assert!(
3337 ws.slack_post_in_flight("acme", "C1").is_none(),
3338 "a failed post leaves no mark behind",
3339 );
3340 }
3341
3342 #[tokio::test]
3345 async fn an_upload_marks_its_conversation_in_flight_until_the_ledger_write() {
3346 let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3347 let gate = api.park_uploads();
3348 let dir = tempdir().expect("tempdir");
3349 let source = dir.path().join("file.txt");
3350 std::fs::write(&source, b"payload").expect("write the source file");
3351
3352 let task = tokio::spawn({
3353 let facade = facade.clone();
3354 async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3355 });
3356 let id = wait_for_draft(&ws).await;
3357 ws.resolve_slack_draft(id, &caller(), answered(true));
3358
3359 let mut marked = false;
3360 for _ in 0..400 {
3361 if ws.slack_post_in_flight("acme", "C1").is_some() {
3362 marked = true;
3363 break;
3364 }
3365 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3366 }
3367 assert!(marked, "the facade marks the conversation before the upload completes");
3368
3369 gate.notify_one();
3370 task.await.expect("no panic").expect("an approved upload sends");
3371 assert!(
3372 ws.slack_post_in_flight("acme", "C1").is_none(),
3373 "and clears it once the upload's shares are ledgered",
3374 );
3375 }
3376
3377 #[tokio::test]
3381 async fn the_upload_marks_its_conversation_while_the_shares_are_read() {
3382 let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3383 api.seed_file("F-uploaded", "file.txt", b"payload");
3384 let gate = api.park_file_info();
3385 let dir = tempdir().expect("tempdir");
3386 let source = dir.path().join("file.txt");
3387 std::fs::write(&source, b"payload").expect("write the source file");
3388
3389 let task = tokio::spawn({
3390 let facade = facade.clone();
3391 async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3392 });
3393 let id = wait_for_draft(&ws).await;
3394 ws.resolve_slack_draft(id, &caller(), answered(true));
3395
3396 until(|| api.file_info_calls() >= 1).await;
3397 assert!(
3398 ws.slack_post_in_flight("acme", "C1").is_some(),
3399 "the shares read holds a fresh mark of its own",
3400 );
3401
3402 gate.notify_one();
3403 task.await.expect("no panic").expect("the upload sends");
3404 assert!(
3405 ws.slack_post_in_flight("acme", "C1").is_none(),
3406 "and clears it once the shares are ledgered",
3407 );
3408 }
3409
3410 #[tokio::test]
3414 async fn an_upload_whose_shares_cannot_be_read_still_succeeds() {
3415 let (facade, ws, _api, _rx, _dir) = facade_with_recording_slack_and_store();
3416 let dir = tempdir().expect("tempdir");
3417 let source = dir.path().join("file.txt");
3418 std::fs::write(&source, b"payload").expect("write the source file");
3419
3420 let task = tokio::spawn({
3421 let facade = facade.clone();
3422 async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3423 });
3424 let id = wait_for_draft(&ws).await;
3425 ws.resolve_slack_draft(id, &caller(), answered(true));
3426
3427 assert!(
3428 task.await.expect("no panic").is_ok(),
3429 "an unreadable share is a ledger gap, not a failed upload",
3430 );
3431 assert!(
3432 ws.slack_post_in_flight("acme", "C1").is_none(),
3433 "and the mark is cleared whatever the share read did",
3434 );
3435 }
3436
3437 #[tokio::test]
3441 async fn a_post_answered_without_a_channel_is_ledgered_under_the_resolved_conversation() {
3442 let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3443 api.answer_posts_with("1700000000.000100");
3444 api.answer_posts_without_channel();
3445
3446 let task = tokio::spawn({
3447 let facade = facade.clone();
3448 async move { facade.post(&caller(), post_request("C1", "hello")).await }
3449 });
3450 let id = wait_for_draft(&ws).await;
3451 ws.resolve_slack_draft(id, &caller(), answered(true));
3452 task.await.expect("no panic").expect("the post lands");
3453
3454 assert!(
3455 ws.slack_own_post("acme", "C1", "1700000000.000100"),
3456 "the resolved conversation is what the fallback keys on",
3457 );
3458 }
3459
3460 #[tokio::test]
3464 async fn an_upload_ledgers_the_message_under_the_shares_own_conversation() {
3465 let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3466 api.seed_file("F-uploaded", "file.txt", b"payload");
3467 api.seed_file_shares("F-uploaded", "D456", "1700000000.000100");
3468 let dir = tempdir().expect("tempdir");
3469 let source = dir.path().join("file.txt");
3470 std::fs::write(&source, b"payload").expect("write the source file");
3471
3472 let task = tokio::spawn({
3473 let facade = facade.clone();
3474 async move { facade.post_attachment(&caller(), upload_request("U123", &source)).await }
3475 });
3476 let id = wait_for_draft(&ws).await;
3477 ws.resolve_slack_draft(id, &caller(), answered(true));
3478 task.await.expect("no panic").expect("an approved upload sends");
3479
3480 assert!(
3481 ws.slack_own_post("acme", "D456", "1700000000.000100"),
3482 "the share's own conversation holds the message, so that is what the ledger keys",
3483 );
3484 assert!(
3485 !ws.slack_own_post("acme", "U123", "1700000000.000100"),
3486 "the request string is not a conversation",
3487 );
3488 }
3489
3490 #[tokio::test]
3493 async fn an_upload_answer_that_carries_its_shares_costs_no_files_info_call() {
3494 let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3495 api.seed_upload_shares("C1", "1700000000.000100");
3496 let dir = tempdir().expect("tempdir");
3497 let source = dir.path().join("file.txt");
3498 std::fs::write(&source, b"payload").expect("write the source file");
3499
3500 let task = tokio::spawn({
3501 let facade = facade.clone();
3502 async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3503 });
3504 let id = wait_for_draft(&ws).await;
3505 ws.resolve_slack_draft(id, &caller(), answered(true));
3506 task.await.expect("no panic").expect("an approved upload sends");
3507
3508 assert_eq!(api.file_info_calls(), 0, "the answer was enough; no follow-up read");
3509 assert!(
3510 ws.slack_own_post("acme", "C1", "1700000000.000100"),
3511 "and the message it named is refused to the sweep",
3512 );
3513 }
3514
3515 #[tokio::test]
3518 async fn an_edit_ledgers_the_message_ts_it_rewrote() {
3519 let (facade, ws, _api, _rx, _dir) = facade_with_recording_slack_and_store();
3520
3521 let task = tokio::spawn({
3522 let facade = facade.clone();
3523 async move { facade.edit(&caller(), edit_request("C1", "100.0", Some("new text"))).await }
3524 });
3525 let id = wait_for_draft(&ws).await;
3526 ws.resolve_slack_draft(id, &caller(), answered(true));
3527 task.await.expect("no panic").expect("an approved replacement is sent");
3528
3529 assert!(
3530 ws.slack_own_post("acme", "C1", "100.0"),
3531 "the edited message's own ts is refused to the sweep",
3532 );
3533 }
3534
3535 #[tokio::test]
3540 async fn an_upload_draft_names_the_local_path_it_will_send() {
3541 let (facade, ws, _api, mut rx) = facade_with_recording_slack();
3542 let dir = tempdir().expect("tempdir");
3543 let source = dir.path().join("file.txt");
3544 std::fs::write(&source, b"payload").expect("write the source file");
3545
3546 let task = tokio::spawn({
3547 let facade = facade.clone();
3548 let source = source.clone();
3549 async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3550 });
3551
3552 let mut seen = None;
3553 for _ in 0..200 {
3554 while let Ok(frame) = rx.try_recv() {
3555 if let crate::protocol::SessionUpdate::SlackPostPending { draft, .. } =
3556 std::sync::Arc::unwrap_or_clone(frame.update)
3557 {
3558 seen = Some(draft.text);
3559 }
3560 }
3561 if seen.is_some() {
3562 break;
3563 }
3564 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3565 }
3566 let text = seen.expect("the draft reaches the prompt");
3567 assert!(text.contains("file.txt"), "the file name is shown: {text}");
3568 assert!(
3569 text.contains(&source.display().to_string()),
3570 "and so is the local path it comes from: {text}",
3571 );
3572
3573 let id = wait_for_draft(&ws).await;
3574 ws.resolve_slack_draft(id, &caller(), answered(false));
3575 let _ = task.await;
3576 }
3577
3578 #[tokio::test]
3579 async fn an_upload_goes_through_the_gate() {
3580 let (facade, ws, api, _rx) = facade_with_recording_slack();
3581 let dir = tempdir().expect("tempdir");
3582 let source = dir.path().join("file.txt");
3583 std::fs::write(&source, b"payload").expect("write the source file");
3584
3585 let task = tokio::spawn({
3586 let facade = facade.clone();
3587 async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3588 });
3589 let id = wait_for_draft(&ws).await;
3590 ws.resolve_slack_draft(id, &caller(), answered(false));
3591
3592 assert!(task.await.expect("no panic").is_err(), "a rejected upload must not send");
3593 assert_eq!(api.upload_url_count(), 0, "nothing is requested from Slack either");
3594 }
3595
3596 #[tokio::test]
3597 async fn an_approved_upload_posts_the_bytes_then_completes() {
3598 let (facade, ws, api, _rx) = facade_with_recording_slack();
3599 let dir = tempdir().expect("tempdir");
3600 let source = dir.path().join("file.txt");
3601 std::fs::write(&source, b"payload").expect("write the source file");
3602
3603 let task = tokio::spawn({
3604 let facade = facade.clone();
3605 async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3606 });
3607 let id = wait_for_draft(&ws).await;
3608 ws.resolve_slack_draft(id, &caller(), answered(true));
3609 task.await.expect("no panic").expect("an approved upload sends");
3610
3611 assert_eq!(api.upload_url_count(), 1, "the upload URL is requested once");
3612 assert_eq!(api.uploads().len(), 1, "and completed once");
3613 assert_eq!(api.uploads()[0].1, "C1", "into the conversation that was named");
3614 }
3615
3616 #[tokio::test]
3619 async fn a_waiter_that_dies_is_reported_as_abandoned() {
3620 let (facade, ws, _api, mut rx) = facade_with_recording_slack();
3621 let task = tokio::spawn({
3622 let facade = facade.clone();
3623 async move { facade.post(&caller(), post_request("C1", "hello")).await }
3624 });
3625 let _id = wait_for_draft(&ws).await;
3626
3627 task.abort();
3628 let _ = task.await;
3629
3630 let mut resolved = Vec::new();
3631 while let Ok(frame) = rx.try_recv() {
3632 if let crate::protocol::SessionUpdate::SlackDraftResolved { ending, .. } =
3633 std::sync::Arc::unwrap_or_clone(frame.update)
3634 {
3635 resolved.push(ending);
3636 }
3637 }
3638 assert_eq!(
3639 resolved,
3640 vec![SlackDraftEnding::Abandoned],
3641 "a dropped waiter clears the registry and says which ending took the draft",
3642 );
3643 assert!(ws.slack_drafts.lock().is_empty(), "and leaves nothing registered");
3644 }
3645
3646 #[tokio::test]
3647 async fn a_rejected_draft_never_reaches_slack() {
3648 let (facade, ws, api, _rx) = facade_with_recording_slack();
3649 let task = tokio::spawn({
3650 let facade = facade.clone();
3651 async move { facade.post(&caller(), post_request("C1", "hello")).await }
3652 });
3653 let id = wait_for_draft(&ws).await;
3654 ws.resolve_slack_draft(id, &caller(), answered(false));
3655
3656 let outcome = task.await.expect("no panic");
3657 assert_eq!(outcome, Err(SlackPostError::Rejected));
3658 assert!(api.posts().is_empty(), "a rejected draft must not post");
3659 }
3660
3661 #[tokio::test]
3664 async fn a_sent_back_post_hands_the_note_back_and_sends_nothing() {
3665 let (facade, ws, api, _rx) = facade_with_recording_slack();
3666 let task = tokio::spawn({
3667 let facade = facade.clone();
3668 async move { facade.post(&caller(), post_request("C1", "hello")).await }
3669 });
3670 let id = wait_for_draft(&ws).await;
3671 ws.resolve_slack_draft(id, &caller(), sent_back("say staging, not prod"));
3672
3673 let outcome = task.await.expect("no panic");
3674 assert_eq!(
3675 outcome,
3676 Err(SlackPostError::SentBack { note: "say staging, not prod".to_owned() }),
3677 "the note reaches the blocked call whole",
3678 );
3679 assert!(api.posts().is_empty(), "a sent-back draft must not post");
3680 }
3681
3682 #[tokio::test]
3685 async fn a_sent_back_edit_hands_the_note_back_and_leaves_the_message() {
3686 let (facade, ws, api, _rx) = facade_with_recording_slack();
3687 let task = tokio::spawn({
3688 let facade = facade.clone();
3689 async move { facade.edit(&caller(), edit_request("C1", "100.0", Some("new"))).await }
3690 });
3691 let id = wait_for_draft(&ws).await;
3692 ws.resolve_slack_draft(id, &caller(), sent_back("keep the original wording"));
3693
3694 assert_eq!(
3695 task.await.expect("no panic"),
3696 Err(SlackEditError::SentBack { note: "keep the original wording".to_owned() }),
3697 );
3698 assert!(api.updates.lock().is_empty(), "a sent-back replacement is never sent");
3699 }
3700
3701 #[tokio::test]
3702 async fn a_sent_back_delete_hands_the_note_back_and_keeps_the_message() {
3703 let (facade, ws, api, _rx) = facade_with_recording_slack();
3704 let task = tokio::spawn({
3705 let facade = facade.clone();
3706 async move { facade.edit(&caller(), edit_request("C1", "100.0", None)).await }
3707 });
3708 let id = wait_for_draft(&ws).await;
3709 ws.resolve_slack_draft(id, &caller(), sent_back("that one needs to stay"));
3710
3711 assert_eq!(
3712 task.await.expect("no panic"),
3713 Err(SlackEditError::SentBack { note: "that one needs to stay".to_owned() }),
3714 );
3715 assert!(api.deletes.lock().is_empty(), "a sent-back deletion never removes anything");
3716 }
3717
3718 #[tokio::test]
3719 async fn a_sent_back_reaction_hands_the_note_back_and_applies_nothing() {
3720 let (facade, ws, api, _rx) = facade_with_recording_slack();
3721 let task = tokio::spawn({
3722 let facade = facade.clone();
3723 async move { facade.react(&caller(), react_request("C1", "100.0", "thumbsup", true)).await }
3724 });
3725 let id = wait_for_draft(&ws).await;
3726 ws.resolve_slack_draft(id, &caller(), sent_back("that reads as sarcasm here"));
3727
3728 assert_eq!(
3729 task.await.expect("no panic"),
3730 Err(SlackReactError::SentBack { note: "that reads as sarcasm here".to_owned() }),
3731 );
3732 assert!(api.reactions.lock().is_empty(), "a sent-back reaction is never applied");
3733 }
3734
3735 #[tokio::test]
3736 async fn a_sent_back_upload_hands_the_note_back_and_sends_no_bytes() {
3737 let (facade, ws, api, _rx) = facade_with_recording_slack();
3738 let dir = tempdir().expect("tempdir");
3739 let source = dir.path().join("file.txt");
3740 std::fs::write(&source, b"payload").expect("write the source file");
3741
3742 let task = tokio::spawn({
3743 let facade = facade.clone();
3744 async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3745 });
3746 let id = wait_for_draft(&ws).await;
3747 ws.resolve_slack_draft(id, &caller(), sent_back("wrong file, send the report instead"));
3748
3749 assert_eq!(
3750 task.await.expect("no panic"),
3751 Err(SlackAttachmentError::SentBack {
3752 note: "wrong file, send the report instead".to_owned(),
3753 }),
3754 );
3755 assert_eq!(api.upload_url_count(), 0, "a sent-back upload never reaches Slack");
3756 }
3757
3758 #[tokio::test]
3761 async fn a_revised_call_after_a_send_back_parks_its_own_draft() {
3762 let (facade, ws, api, _rx) = facade_with_recording_slack();
3763 let first = tokio::spawn({
3764 let facade = facade.clone();
3765 async move { facade.post(&caller(), post_request("C1", "prod is down")).await }
3766 });
3767 let first_id = wait_for_draft(&ws).await;
3768 ws.resolve_slack_draft(first_id, &caller(), sent_back("say staging, not prod"));
3769 assert!(first.await.expect("no panic").is_err(), "the first attempt was sent back");
3770
3771 let second = tokio::spawn({
3772 let facade = facade.clone();
3773 async move { facade.post(&caller(), post_request("C1", "staging is down")).await }
3774 });
3775 let second_id = wait_for_draft(&ws).await;
3776
3777 assert_ne!(second_id, first_id, "the revision parks its own card");
3778 assert!(api.posts().is_empty(), "and nothing approves the revision on its behalf");
3779
3780 ws.resolve_slack_draft(second_id, &caller(), answered(true));
3781 second.await.expect("no panic").expect("the reader's approval is what sends it");
3782 assert_eq!(api.posts().len(), 1, "one message goes out");
3783 assert_eq!(api.posts()[0].1, "staging is down", "the revised words are what went out");
3784 }
3785
3786 #[tokio::test]
3791 async fn a_draft_whose_answer_never_comes_posts_nothing() {
3792 let (facade, ws, api, _rx) = facade_with_recording_slack();
3793 let task = tokio::spawn({
3794 let facade = facade.clone();
3795 async move { facade.post(&caller(), post_request("C1", "hello")).await }
3796 });
3797 let id = wait_for_draft(&ws).await;
3798 drop(ws.slack_drafts.lock().remove(&id));
3799
3800 let outcome = task.await.expect("no panic");
3801 assert_eq!(outcome, Err(SlackPostError::Rejected));
3802 assert!(api.posts().is_empty(), "an unanswered draft must not post");
3803 }
3804
3805 #[tokio::test(start_paused = true)]
3809 async fn a_held_draft_waits_for_the_user_rather_than_a_clock() {
3810 let (facade, ws, api, _rx) = facade_with_recording_slack();
3811 let task = tokio::spawn({
3812 let facade = facade.clone();
3813 async move { facade.post(&caller(), post_request("C1", "hello")).await }
3814 });
3815 let id = wait_for_draft(&ws).await;
3816
3817 tokio::time::advance(std::time::Duration::from_secs(24 * 60 * 60)).await;
3818 assert!(!task.is_finished(), "a held draft waits for the user, not a clock");
3819
3820 ws.resolve_slack_draft(id, &caller(), answered(true));
3821 let outcome = task.await.expect("no panic");
3822 assert!(outcome.is_ok(), "the answer still posts after a day of waiting: {outcome:?}");
3823 assert_eq!(api.posts().len(), 1, "the held message goes out once");
3824 }
3825
3826 #[tokio::test]
3830 async fn an_approved_draft_posts_exactly_once() {
3831 let (facade, ws, api, _rx) = facade_with_recording_slack();
3832 api.seed_conversation("C1", "ops");
3833 let task = tokio::spawn({
3834 let facade = facade.clone();
3835 async move { facade.post(&caller(), post_request("C1", "hello")).await }
3836 });
3837 let id = wait_for_draft(&ws).await;
3838 ws.resolve_slack_draft(id, &caller(), answered(true));
3839
3840 let outcome = task.await.expect("no panic").expect("an approved draft posts");
3841 assert_eq!(api.posts().len(), 1, "exactly one message goes out");
3842 assert_eq!(outcome.ts, vec!["1.0".to_owned()], "the ts the wire minted");
3843 assert_eq!(
3844 outcome.conversation_name, "ops",
3845 "the agent-facing name is the bare one every other tool reports",
3846 );
3847 assert_eq!(outcome.parts, 1, "a short draft is one part");
3848 }
3849
3850 #[tokio::test]
3854 async fn a_split_draft_reports_its_part_count() {
3855 let (facade, ws, api, _rx) = facade_with_recording_slack();
3856 let long = "x".repeat(forge_connectors::slack::POST_LIMIT + 100);
3857 let task = tokio::spawn({
3858 let facade = facade.clone();
3859 async move { facade.post(&caller(), post_request("C1", &long)).await }
3860 });
3861 let id = wait_for_draft(&ws).await;
3862 ws.resolve_slack_draft(id, &caller(), answered(true));
3863
3864 let outcome = task.await.expect("no panic").expect("an approved draft posts");
3865 assert_eq!(outcome.parts, 2, "the split count travels with the ts");
3866 assert_eq!(outcome.ts.len(), 2, "and one ts per message really went out");
3867 assert_eq!(api.posts().len(), 2, "both parts reached Slack");
3868 }
3869
3870 #[tokio::test]
3871 async fn a_thread_reply_carries_the_parent_ts() {
3872 let (facade, ws, api, _rx) = facade_with_recording_slack();
3873 let task = tokio::spawn({
3874 let facade = facade.clone();
3875 async move {
3876 let mut request = post_request("C1", "hi");
3877 request.thread_ts = Some("100.0".to_owned());
3878 facade.post(&caller(), request).await
3879 }
3880 });
3881 let id = wait_for_draft(&ws).await;
3882 ws.resolve_slack_draft(id, &caller(), answered(true));
3883 task.await.expect("no panic").expect("posted");
3884
3885 assert_eq!(api.posts()[0].2.as_deref(), Some("100.0"));
3886 }
3887
3888 #[tokio::test]
3889 async fn a_foreign_message_is_refused_by_the_wire_and_surfaced_as_its_reason() {
3890 let (facade, ws, api, _rx) = facade_with_recording_slack();
3895 api.refuse_edits_of("100.0");
3896
3897 let task = tokio::spawn({
3898 let facade = facade.clone();
3899 async move { facade.edit(&caller(), edit_request("C1", "100.0", Some("new text"))).await }
3900 });
3901 let id = wait_for_draft(&ws).await;
3902 ws.resolve_slack_draft(id, &caller(), answered(true));
3903
3904 assert_eq!(
3905 task.await.expect("no panic"),
3906 Err(SlackEditError::NotOwnMessage),
3907 "the wire's cant_update_message reads as not-your-message",
3908 );
3909 assert!(api.updates.lock().is_empty(), "a refused update changes nothing");
3910 }
3911
3912 #[tokio::test]
3913 async fn editing_own_message_updates_it_once_approved() {
3914 let (facade, ws, api, _rx) = facade_with_recording_slack();
3915
3916 let task = tokio::spawn({
3917 let facade = facade.clone();
3918 async move { facade.edit(&caller(), edit_request("C1", "100.0", Some("new text"))).await }
3919 });
3920 let id = wait_for_draft(&ws).await;
3921 ws.resolve_slack_draft(id, &caller(), answered(true));
3922 task.await.expect("no panic").expect("an approved replacement is sent");
3923
3924 assert_eq!(api.updates.lock().len(), 1);
3925 }
3926
3927 #[tokio::test]
3928 async fn a_rejected_edit_leaves_the_message_untouched() {
3929 let (facade, ws, api, _rx) = facade_with_recording_slack();
3930
3931 let task = tokio::spawn({
3932 let facade = facade.clone();
3933 async move { facade.edit(&caller(), edit_request("C1", "100.0", Some("new text"))).await }
3934 });
3935 let id = wait_for_draft(&ws).await;
3936 ws.resolve_slack_draft(id, &caller(), answered(false));
3937
3938 assert_eq!(task.await.expect("no panic"), Err(SlackEditError::Rejected));
3939 assert!(api.updates.lock().is_empty(), "a rejected replacement is never sent");
3940 }
3941
3942 #[tokio::test]
3943 async fn deleting_own_message_removes_it_once_approved() {
3944 let (facade, ws, api, _rx) = facade_with_recording_slack();
3945
3946 let task = tokio::spawn({
3947 let facade = facade.clone();
3948 async move { facade.edit(&caller(), edit_request("C1", "100.0", None)).await }
3949 });
3950 let id = wait_for_draft(&ws).await;
3951 ws.resolve_slack_draft(id, &caller(), answered(true));
3952 task.await.expect("no panic").expect("an approved deletion is sent");
3953
3954 assert_eq!(api.deletes.lock().len(), 1);
3955 }
3956
3957 #[tokio::test]
3958 async fn a_rejected_delete_leaves_the_message_alone() {
3959 let (facade, ws, api, _rx) = facade_with_recording_slack();
3960
3961 let task = tokio::spawn({
3962 let facade = facade.clone();
3963 async move { facade.edit(&caller(), edit_request("C1", "100.0", None)).await }
3964 });
3965 let id = wait_for_draft(&ws).await;
3966 ws.resolve_slack_draft(id, &caller(), answered(false));
3967
3968 assert_eq!(task.await.expect("no panic"), Err(SlackEditError::Rejected));
3969 assert!(api.deletes.lock().is_empty(), "a rejected deletion is never sent");
3970 }
3971
3972 #[tokio::test]
3975 async fn reacting_adds_then_removes() {
3976 let (facade, ws, api, _rx) = facade_with_recording_slack();
3977
3978 let task = tokio::spawn({
3979 let facade = facade.clone();
3980 async move {
3981 facade
3982 .react(&caller(), react_request("C1", "100.0", "white_check_mark", true))
3983 .await?;
3984 facade
3985 .react(&caller(), react_request("C1", "100.0", "white_check_mark", false))
3986 .await
3987 }
3988 });
3989 let first = wait_for_draft(&ws).await;
3990 ws.resolve_slack_draft(first, &caller(), answered(true));
3991 let second = wait_for_draft(&ws).await;
3992 ws.resolve_slack_draft(second, &caller(), answered(true));
3993
3994 task.await.expect("no panic").expect("both reactions applied");
3995
3996 let reactions = api.reactions.lock().clone();
3997 assert_eq!(reactions.len(), 2);
3998 assert!(reactions[0].3, "the first call adds");
3999 assert!(!reactions[1].3, "the second removes");
4000 }
4001
4002 #[tokio::test]
4003 async fn a_rejected_reaction_is_never_sent() {
4004 let (facade, ws, api, _rx) = facade_with_recording_slack();
4005
4006 let task = tokio::spawn({
4007 let facade = facade.clone();
4008 async move {
4009 facade
4010 .react(&caller(), react_request("C1", "100.0", "white_check_mark", true))
4011 .await
4012 }
4013 });
4014 let id = wait_for_draft(&ws).await;
4015 ws.resolve_slack_draft(id, &caller(), answered(false));
4016
4017 assert_eq!(task.await.expect("no panic"), Err(SlackReactError::Rejected));
4018 assert!(api.reactions.lock().is_empty(), "a rejected reaction is never applied");
4019 }
4020
4021 #[tokio::test]
4025 async fn a_reaction_draft_names_whether_it_adds_or_removes() {
4026 let (facade, ws, _api, mut rx) = facade_with_recording_slack();
4027
4028 for (add, expected) in
4029 [(false, SlackDraftOperation::Unreact), (true, SlackDraftOperation::React)]
4030 {
4031 let task = tokio::spawn({
4032 let facade = facade.clone();
4033 async move {
4034 facade.react(&caller(), react_request("C1", "100.0", "thumbsup", add)).await
4035 }
4036 });
4037 let mut seen = None;
4038 for _ in 0..200 {
4039 while let Ok(frame) = rx.try_recv() {
4040 if let crate::protocol::SessionUpdate::SlackPostPending { draft, .. } =
4041 std::sync::Arc::unwrap_or_clone(frame.update)
4042 {
4043 seen = Some(draft.operation);
4044 }
4045 }
4046 if seen.is_some() {
4047 break;
4048 }
4049 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
4050 }
4051 assert_eq!(seen, Some(expected), "the draft names the act it would do");
4052 let id = wait_for_draft(&ws).await;
4053 ws.resolve_slack_draft(id, &caller(), answered(false));
4054 let _ = task.await;
4055 }
4056 }
4057
4058 fn cfg(label: &str) -> SlackConfig {
4059 SlackConfig {
4060 workspace: label.to_owned(),
4061 token: "xoxp-test".to_owned(),
4062 poll_seconds: 30,
4063 dm_poll_seconds: 150,
4064 thread_idle_days: 14,
4065 stale_after_hours: 24,
4066 }
4067 }
4068
4069 fn workspace_with_one_slack_workspace(label: &str) -> (Arc<Workspace>, Arc<dyn SlackFacade>) {
4072 let dir = tempdir().expect("tempdir");
4073 let mut config = LoadedConfig::empty_for_test();
4074 config.slack = vec![cfg(label)];
4075 let (ws, _rx) = Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
4076 .expect("the stub config's [[slack]] entries are well-formed");
4077 ws.seed_test_project_with_slack_workspace("forge", "/tmp/slack-facade-scope", label);
4078 ws.record_connected_session("/tmp/slack-facade-scope", "caller-uuid", None);
4079 let facade = ProdSlackFacade::from_arc(&ws);
4080 (ws, facade)
4081 }
4082
4083 fn workspace_with_one_slack_workspace_and_store(
4086 label: &str,
4087 ) -> (tempfile::TempDir, Arc<Workspace>, Arc<dyn SlackFacade>) {
4088 let dir = tempdir().expect("tempdir");
4089 let mut config = LoadedConfig::empty_for_test();
4090 config.slack = vec![cfg(label)];
4091 let (ws, _rx) = Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
4092 .expect("the stub config's [[slack]] entries are well-formed");
4093 ws.install_db_for_test(
4094 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4095 );
4096 ws.seed_test_project_with_slack_workspace("forge", "/tmp/slack-facade-scope", label);
4097 ws.record_connected_session("/tmp/slack-facade-scope", "caller-uuid", None);
4098 let facade = ProdSlackFacade::from_arc(&ws);
4099 (dir, ws, facade)
4100 }
4101
4102 #[tokio::test]
4107 async fn subscribing_twice_to_mentions_yields_two_records() {
4108 let (facade, ws, _api, _rx) = facade_with_recording_slack();
4109
4110 facade.subscribe(&caller(), SlackSubscribeRequest::Mentions).expect("first");
4111 facade.subscribe(&caller(), SlackSubscribeRequest::Mentions).expect("second");
4112
4113 assert_eq!(ws.slack_subscriptions_for_project("forge").len(), 2);
4114 }
4115
4116 #[tokio::test]
4121 async fn subscribing_twice_to_a_conversation_yields_one_record() {
4122 let (facade, ws, _api, _rx) = facade_with_recording_slack();
4123 let watching = |mode| {
4124 SlackSubscribeRequest::Conversations(vec![SlackChannelWatch {
4125 id: "C1".to_owned(),
4126 mode,
4127 }])
4128 };
4129
4130 let first = facade.subscribe(&caller(), watching(SlackWatchMode::All)).expect("first");
4131 assert_eq!(first.len(), 1, "the first subscribe creates the record");
4132
4133 let second =
4134 facade.subscribe(&caller(), watching(SlackWatchMode::MentionsOnly)).expect("second");
4135 assert_eq!(second.len(), 1, "the repeat adds no second record");
4136 assert_eq!(
4137 second[0].id, first[0].id,
4138 "the repeat hands back the id it already has, so the caller can drop it without a lookup",
4139 );
4140 assert_eq!(
4141 second[0].target,
4142 SlackSubscriptionTarget::Conversation {
4143 id: "C1".to_owned(),
4144 name: None,
4145 mode: SlackWatchMode::MentionsOnly,
4146 },
4147 "and the record as it NOW stands, not the state before the mode moved",
4148 );
4149 let stored = ws.slack_subscriptions_for_project("forge");
4150 assert_eq!(stored.len(), 1, "one record, so the Inspector renders one row");
4151 assert_eq!(
4152 stored[0].target,
4153 SlackSubscriptionTarget::Conversation {
4154 id: "C1".to_owned(),
4155 name: None,
4156 mode: SlackWatchMode::MentionsOnly,
4157 },
4158 "and the mode asked for is the mode stored",
4159 );
4160 }
4161
4162 #[tokio::test]
4167 async fn unsubscribe_returns_the_row_it_removed_and_leaves_the_other() {
4168 let (facade, ws, _api, _rx) = facade_with_recording_slack();
4169 let first = facade.subscribe(&caller(), SlackSubscribeRequest::Mentions).expect("first");
4170 let second =
4171 facade.subscribe(&caller(), SlackSubscribeRequest::DirectMessages).expect("second");
4172 assert_eq!(second.len(), 1, "one record per target");
4173
4174 let removed = facade
4175 .unsubscribe(&caller(), second[0].id)
4176 .expect("the second subscription is the caller's own");
4177
4178 assert_eq!(removed.id, second[0].id, "the echoed row is the one that went");
4179 assert_eq!(
4180 removed.target,
4181 SlackSubscriptionTarget::DirectMessages,
4182 "carrying the target it watched, not the other row's",
4183 );
4184 let left = ws.slack_subscriptions_for_project("forge");
4185 assert_eq!(left.len(), 1, "only the untouched subscription remains");
4186 assert_eq!(left[0].id, first[0].id, "and it is the one the call did not name");
4187 }
4188
4189 #[tokio::test]
4192 async fn unsubscribe_refuses_another_owners_row() {
4193 let (facade, ws, _api, _rx) = facade_with_recording_slack();
4194 let worker = live_worker(&ws, "forge", "tester");
4195 let owned = facade
4196 .subscribe(&caller(), SlackSubscribeRequest::Mentions)
4197 .expect("the lead subscribes");
4198
4199 assert_eq!(
4200 facade.unsubscribe(&worker, owned[0].id),
4201 Err(SlackUnsubscribeError::NotOwnedByCaller),
4202 "the lead's subscription is not the worker's to remove, and the refusal says which case it is",
4203 );
4204 assert_eq!(
4205 ws.slack_subscriptions_for_project("forge").len(),
4206 1,
4207 "a refused unsubscribe removes nothing",
4208 );
4209 }
4210
4211 #[test]
4214 fn unsubscribe_refuses_an_id_the_project_never_had() {
4215 let (facade, _ws, _api, _rx) = facade_with_recording_slack();
4216
4217 assert_eq!(
4218 facade.unsubscribe(&caller(), Uuid::from_u128(0xdead)),
4219 Err(SlackUnsubscribeError::NoSuchSubscription),
4220 "an id with no entry in the project is reported as absent",
4221 );
4222 }
4223
4224 #[test]
4228 fn unsubscribe_refuses_an_id_from_another_project_as_absent() {
4229 let (facade, ws, _api, _rx) = facade_with_recording_slack();
4230 let other = SlackSubscription {
4231 id: Uuid::new_v4(),
4232 workspace: "acme".to_owned(),
4233 project: "otherproj".to_owned(),
4234 team_role: None,
4235 target: SlackSubscriptionTarget::Mentions,
4236 created_at: SystemTime::UNIX_EPOCH,
4237 };
4238 let other_id = other.id;
4239 ws.add_slack_subscription(other, false);
4240
4241 assert_eq!(
4242 facade.unsubscribe(&caller(), other_id),
4243 Err(SlackUnsubscribeError::NoSuchSubscription),
4244 "an id that lives in another project is absent here, not a subscription of this project's",
4245 );
4246 }
4247
4248 fn live_worker(ws: &Workspace, project: &str, label: &str) -> SessionSlot {
4251 let slot = SessionSlot::worker("TestOrg", project, label);
4252 let project_key = ws
4253 .list_projects()
4254 .into_iter()
4255 .find(|view| view.name == project)
4256 .expect("the seeded project")
4257 .key;
4258 ws.insert_live_worker(
4259 &project_key,
4260 crate::mcp::workers::types::WorkerEntry {
4261 label: label.into(),
4262 charter: "c".into(),
4263 slot: slot.clone(),
4264 session_id: None,
4265 status: forge_primitives::WorkerLiveness::Running,
4266 spawned_at: std::time::SystemTime::UNIX_EPOCH,
4267 spawned_by: SessionSlot::lead("TestOrg", project),
4268 needs_tag: false,
4269 is_git_repo_at_spawn: false,
4270 cwd: None,
4271 diagnostic: None,
4272 kick: None,
4273 },
4274 );
4275 slot
4276 }
4277
4278 #[tokio::test]
4284 async fn unsubscribe_refusals_name_the_case_through_the_tool() {
4285 let (ws, facade) = workspace_with_one_slack_workspace("acme");
4286 let worker = live_worker(&ws, "forge", "tester");
4287 let lead_sub = facade
4288 .subscribe(&caller(), SlackSubscribeRequest::Mentions)
4289 .expect("the lead subscribes");
4290 let tool = Unsubscribe { facade, slot: worker };
4291
4292 let not_yours = tool
4293 .call(ToolInput { value: serde_json::json!({ "id": lead_sub[0].id.to_string() }) })
4294 .await;
4295 assert!(not_yours.is_error, "a refused unsubscribe is an error");
4296 assert_eq!(
4297 text_of(¬_yours),
4298 format!(
4299 "subscription {} exists in this project but was not subscribed by you",
4300 lead_sub[0].id
4301 ),
4302 "a live subscription owned by another session must say so, not report it missing",
4303 );
4304
4305 let ghost = Uuid::from_u128(0xdead);
4306 let not_there =
4307 tool.call(ToolInput { value: serde_json::json!({ "id": ghost.to_string() }) }).await;
4308 assert!(not_there.is_error, "an unknown id is an error");
4309 assert_eq!(
4310 text_of(¬_there),
4311 format!("no subscription with id {ghost} in this project"),
4312 "an id the project never had must not read as another session's subscription",
4313 );
4314 }
4315
4316 #[tokio::test]
4320 async fn a_caller_whose_project_binds_no_workspace_is_told_which_key_to_add() {
4321 let dir = tempdir().expect("tempdir");
4322 let mut config = LoadedConfig::empty_for_test();
4323 config.slack = vec![cfg("acme")];
4324 let (ws, _rx) = Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
4325 .expect("the stub config's [[slack]] entries are well-formed");
4326 ws.seed_test_project("forge", "/tmp/slack-facade-scope");
4327 ws.record_connected_session("/tmp/slack-facade-scope", "caller-uuid", None);
4328 let facade = ProdSlackFacade::from_arc(&ws);
4329
4330 let error = facade.pins(&caller(), "C1").await.expect_err("no binding to resolve");
4331 let SlackReadError::Workspace(error) = &error else {
4332 panic!("a missing binding is a workspace refusal: {error:?}");
4333 };
4334 assert_eq!(
4335 error,
4336 &SlackWorkspaceError::Unbound {
4337 org: "TestOrg".to_owned(),
4338 project: "forge".to_owned()
4339 },
4340 "the refusal names the scope that has no binding",
4341 );
4342 }
4343
4344 #[tokio::test]
4347 async fn a_binding_to_a_workspace_added_since_boot_names_the_restart() {
4348 let dir = tempdir().expect("tempdir");
4349 let mut config = LoadedConfig::empty_for_test();
4350 config.slack = vec![cfg("acme")];
4351 let (ws, _rx) = Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
4352 .expect("the stub config's [[slack]] entries are well-formed");
4353 ws.seed_test_project_with_slack_workspace("forge", "/tmp/slack-facade-scope", "beta");
4354 ws.record_connected_session("/tmp/slack-facade-scope", "caller-uuid", None);
4355 let facade = ProdSlackFacade::from_arc(&ws);
4356
4357 let error = facade.pins(&caller(), "C1").await.expect_err("no client to read with");
4358 let SlackReadError::Workspace(error) = &error else {
4359 panic!("a missing client is a workspace refusal: {error:?}");
4360 };
4361 assert_eq!(
4362 error,
4363 &SlackWorkspaceError::BoundButNotLive { label: "beta".to_owned() },
4364 "the refusal says the workspace is bound but not loaded",
4365 );
4366 assert!(
4367 format_workspace_error(error).contains("needs a forge restart"),
4368 "and tells the caller what makes it load",
4369 );
4370 }
4371
4372 #[tokio::test]
4373 async fn subscribing_to_dms_then_a_channel_yields_two_records() {
4374 let (ws, facade) = workspace_with_one_slack_workspace("acme");
4375 let first =
4376 facade.subscribe(&caller(), SlackSubscribeRequest::DirectMessages).expect("dms");
4377 let second = facade
4378 .subscribe(
4379 &caller(),
4380 SlackSubscribeRequest::Conversations(vec![SlackChannelWatch {
4381 id: "C1".to_owned(),
4382 mode: SlackWatchMode::All,
4383 }]),
4384 )
4385 .expect("channels");
4386 assert_eq!(first.len() + second.len(), 2);
4387 assert_eq!(ws.slack_subscriptions_for_project("forge").len(), 2);
4388 }
4389
4390 #[tokio::test]
4393 async fn re_subscribing_after_a_gap_baselines_fresh() {
4394 let (_dir, ws, facade) = workspace_with_one_slack_workspace_and_store("acme");
4395 facade
4396 .subscribe(
4397 &caller(),
4398 SlackSubscribeRequest::Conversations(vec![SlackChannelWatch {
4399 id: "C1".to_owned(),
4400 mode: SlackWatchMode::All,
4401 }]),
4402 )
4403 .expect("the first subscribe");
4404 ws.set_slack_watermark("acme", "C1", "100.0");
4405 let id = ws.slack_subscriptions_for_project("forge")[0].id;
4406 assert!(ws.remove_slack_subscription_owned_by("forge", id, None).is_some());
4407
4408 facade
4409 .subscribe(
4410 &caller(),
4411 SlackSubscribeRequest::Conversations(vec![SlackChannelWatch {
4412 id: "C1".to_owned(),
4413 mode: SlackWatchMode::All,
4414 }]),
4415 )
4416 .expect("re-subscribe");
4417
4418 let cursor = {
4419 let db = ws.db.lock();
4420 crate::store::slack::watermark(db.as_ref().expect("db installed"), "acme", "C1")
4421 .expect("read")
4422 };
4423 assert_ne!(
4424 cursor,
4425 Some("100.0".to_owned()),
4426 "a fresh subscription starts from now, never from the stale cursor",
4427 );
4428 assert!(cursor.is_some(), "and it is seeded, so the sweep does not replay");
4429 }
4430
4431 #[tokio::test]
4435 async fn subscribing_to_a_watched_conversation_does_not_reset_its_cursor() {
4436 let (_dir, ws, facade) = workspace_with_one_slack_workspace_and_store("acme");
4437 ws.set_slack_watermark("acme", "C1", "100.0");
4438
4439 facade
4440 .subscribe(
4441 &caller(),
4442 SlackSubscribeRequest::Conversations(vec![SlackChannelWatch {
4443 id: "C1".to_owned(),
4444 mode: SlackWatchMode::All,
4445 }]),
4446 )
4447 .expect("subscribe to the watched conversation");
4448
4449 let db = ws.db.lock();
4450 let cursor =
4451 crate::store::slack::watermark(db.as_ref().expect("db installed"), "acme", "C1")
4452 .expect("read");
4453 assert_eq!(cursor, Some("100.0".to_owned()), "a second subscriber never moves the cursor");
4454 }
4455
4456 #[tokio::test]
4461 async fn a_second_mention_subscription_seeds_nothing_but_the_first_seeds() {
4462 let (_dir, ws, facade) = workspace_with_one_slack_workspace_and_store("acme");
4463
4464 facade
4465 .subscribe(&caller(), SlackSubscribeRequest::Mentions)
4466 .expect("the first mention subscriber");
4467 let first = {
4468 let db = ws.db.lock();
4469 crate::store::slack::watermark(
4470 db.as_ref().expect("db installed"),
4471 "acme",
4472 MENTION_CURSOR,
4473 )
4474 .expect("read")
4475 };
4476 assert!(first.is_some(), "the first Mentions subscription seeds the stream");
4477
4478 ws.set_slack_watermark("acme", MENTION_CURSOR, "100.0");
4479 facade
4480 .subscribe(&caller(), SlackSubscribeRequest::Mentions)
4481 .expect("the second Mentions subscription");
4482 let second = {
4483 let db = ws.db.lock();
4484 crate::store::slack::watermark(
4485 db.as_ref().expect("db installed"),
4486 "acme",
4487 MENTION_CURSOR,
4488 )
4489 .expect("read")
4490 };
4491 assert_eq!(
4492 second,
4493 Some("100.0".to_owned()),
4494 "the second subscriber never resets the shared mention cursor",
4495 );
4496 }
4497
4498 fn directory_user(
4499 id: &str,
4500 name: &str,
4501 real_name: Option<&str>,
4502 display_name: Option<&str>,
4503 deleted: bool,
4504 ) -> SlackUser {
4505 SlackUser {
4506 id: id.to_owned(),
4507 name: name.to_owned(),
4508 real_name: real_name.map(str::to_owned),
4509 tz: None,
4510 deleted,
4511 profile: display_name.map(|display_name| SlackUserProfile {
4512 display_name: Some(display_name.to_owned()),
4513 }),
4514 }
4515 }
4516
4517 #[tokio::test]
4520 async fn a_user_resolver_matches_the_handle_the_real_name_and_the_display_name() {
4521 let (facade, _ws, api, _rx) = facade_with_recording_slack();
4522 api.seed_users(vec![
4523 directory_user("U1", "ved", Some("Vedhavyas S"), Some("ved"), false),
4524 directory_user("U2", "gina", Some("Gina Abrams"), None, false),
4525 directory_user("U3", "csmith", None, Some("Chris"), false),
4526 ]);
4527
4528 let (_, by_handle) =
4529 facade.users(&caller(), "VED").await.expect("handle, case-insensitive");
4530 assert_eq!(by_handle.iter().map(|user| user.id.as_str()).collect::<Vec<_>>(), ["U1"]);
4531
4532 let (_, by_real_name) = facade.users(&caller(), "abrams").await.expect("real name");
4533 assert_eq!(by_real_name.iter().map(|user| user.id.as_str()).collect::<Vec<_>>(), ["U2"]);
4534
4535 let (_, by_display_name) = facade.users(&caller(), "chris").await.expect("display name");
4536 assert_eq!(
4537 by_display_name.iter().map(|user| user.id.as_str()).collect::<Vec<_>>(),
4538 ["U3"],
4539 "the display name matches even when neither the handle nor the real name does",
4540 );
4541 }
4542
4543 #[tokio::test]
4546 async fn a_user_resolver_returns_nothing_for_an_empty_query() {
4547 let (facade, _ws, api, _rx) = facade_with_recording_slack();
4548 api.seed_users(vec![directory_user("U1", "ved", None, None, false)]);
4549
4550 let (_, found) = facade.users(&caller(), " ").await.expect("the guard is not a failure");
4551
4552 assert!(found.is_empty(), "an empty query matches nobody");
4553 }
4554
4555 #[tokio::test]
4559 async fn a_user_resolver_returns_every_live_match_and_no_deleted_ones() {
4560 let (facade, _ws, api, _rx) = facade_with_recording_slack();
4561 api.seed_users(vec![
4562 directory_user("U2", "samuel", None, None, false),
4563 directory_user("U1", "sam", None, None, false),
4564 directory_user("U3", "sam", None, None, true),
4565 directory_user("U4", "other", None, None, false),
4566 ]);
4567
4568 let (_, found) = facade.users(&caller(), "sam").await.expect("the directory is read");
4569
4570 assert_eq!(
4571 found.iter().map(|user| user.id.as_str()).collect::<Vec<_>>(),
4572 ["U1", "U2"],
4573 "every live match, sorted by handle, never one picked silently",
4574 );
4575 }
4576
4577 #[tokio::test]
4580 async fn a_filtered_search_pages_until_it_finds_the_conversations_hits() {
4581 let (facade, _ws, api, _rx) = facade_with_recording_slack();
4582 api.seed_conversation("C1", "ops");
4583 api.seed_search_pages(vec![
4584 vec![search_hit("C2", "100.1")],
4585 vec![search_hit("C1", "100.2")],
4586 ]);
4587
4588 let (_, found) =
4589 facade.search(&caller(), "deploy", 20, Some("C1")).await.expect("the search runs");
4590
4591 assert_eq!(found.len(), 1, "the match was on the second page: {found:?}");
4592 assert_eq!(found[0].conversation_id, "C1");
4593 assert_eq!(
4594 api.search_reads(),
4595 ["in:ops deploy", "in:ops deploy"],
4596 "the filter followed the cursor instead of stopping at the first page",
4597 );
4598 }
4599
4600 #[tokio::test]
4603 async fn an_identity_lookup_names_the_resolved_label_and_the_token_user() {
4604 let (facade, _ws, _api, _rx) = facade_with_recording_slack();
4605
4606 let (label, auth) = facade.me(&caller()).await.expect("the token is proved");
4607
4608 assert_eq!(label, "acme", "the label the caller can name again");
4609 assert_eq!(auth.team, "Test", "the team comes from auth.test");
4610 assert_eq!(auth.user_id, "U1", "and so does the token's own user id");
4611 }
4612
4613 #[tokio::test]
4614 async fn a_pins_read_reaches_the_workspace_client_with_its_conversation() {
4615 let (facade, _ws, api, _rx) = facade_with_recording_slack();
4616 api.seed_pins(
4617 "C1",
4618 vec![SlackPin {
4619 created: 1_700_000_000,
4620 created_by: Some("U1".to_owned()),
4621 message: Some(SlackPinMessage {
4622 ts: "1700000000.000100".to_owned(),
4623 user: Some("U2".to_owned()),
4624 text: "the pinned text".to_owned(),
4625 }),
4626 }],
4627 );
4628
4629 let (_, pins) = facade.pins(&caller(), "C1").await.expect("read");
4630 assert_eq!(pins.len(), 1, "the seeded row comes back");
4631 assert_eq!(
4632 pins[0].message.as_ref().expect("the message rides along").text,
4633 "the pinned text",
4634 );
4635 assert_eq!(api.pin_reads.lock().as_slice(), ["C1"], "the conversation is what was read");
4636 }
4637
4638 fn history_message(ts: &str, text: &str) -> SlackHistoryMessage {
4639 SlackHistoryMessage {
4640 ts: ts.to_owned(),
4641 user: Some("U1".to_owned()),
4642 text: text.to_owned(),
4643 thread_ts: None,
4644 reply_count: 0,
4645 bot_name: None,
4646 parent_user_id: None,
4647 latest_reply: None,
4648 edited: None,
4649 files: Vec::new(),
4650 }
4651 }
4652
4653 fn search_hit(conversation: &str, ts: &str) -> SlackSearchMatch {
4654 SlackSearchMatch {
4655 ts: ts.to_owned(),
4656 text: "a hit".to_owned(),
4657 conversation_id: conversation.to_owned(),
4658 conversation_name: None,
4659 username: None,
4660 user: None,
4661 thread_ts: None,
4662 files: Vec::new(),
4663 }
4664 }
4665
4666 #[tokio::test]
4667 async fn a_history_read_passes_its_bounds_and_its_page() {
4668 let (facade, _ws, api, _rx) = facade_with_recording_slack();
4669 api.seed_history("C1", vec![history_message("100.1", "hello")]);
4670
4671 let (_, messages) =
4672 facade.history(&caller(), "C1", 25, Some("100.0"), Some("200.0")).await.expect("read");
4673
4674 assert_eq!(messages.len(), 1, "the seeded page comes back");
4675 assert_eq!(messages[0].text, "hello");
4676 assert_eq!(
4677 api.history_reads().as_slice(),
4678 [("C1".to_owned(), Some("100.0".to_owned()), Some("200.0".to_owned()), 25)],
4679 "the conversation and both exclusive bounds reach the wire",
4680 );
4681 }
4682
4683 #[tokio::test]
4684 async fn a_thread_read_returns_the_parent_and_its_replies() {
4685 let (facade, _ws, api, _rx) = facade_with_recording_slack();
4686 api.seed_replies(
4687 "C1",
4688 "100.0",
4689 vec![history_message("100.0", "parent"), history_message("100.5", "reply")],
4690 );
4691
4692 let (_, messages) =
4693 facade.replies(&caller(), "C1", "100.0", 200).await.expect("the thread is read");
4694
4695 assert_eq!(messages.len(), 2, "the whole thread comes back");
4696 assert_eq!(messages[0].text, "parent", "the parent is first");
4697 assert_eq!(messages[1].text, "reply");
4698 assert_eq!(
4699 api.reply_reads().as_slice(),
4700 [("C1".to_owned(), "100.0".to_owned())],
4701 "the conversation and parent ts are what was read",
4702 );
4703 }
4704
4705 #[tokio::test]
4709 async fn a_search_filtered_by_a_named_conversation_narrows_and_drops_foreign_hits() {
4710 let (facade, _ws, api, _rx) = facade_with_recording_slack();
4711 api.seed_conversation("C1", "ops");
4712 api.seed_search(vec![search_hit("C1", "100.1"), search_hit("C2", "100.2")]);
4713
4714 let (_, found) =
4715 facade.search(&caller(), "deploy", 20, Some("C1")).await.expect("the search runs");
4716
4717 assert_eq!(found.len(), 1, "only the filtered conversation's hit survives: {found:?}");
4718 assert_eq!(found[0].conversation_id, "C1");
4719 assert_eq!(
4720 api.search_reads(),
4721 ["in:ops deploy"],
4722 "the name narrows the search server-side",
4723 );
4724 }
4725
4726 #[tokio::test]
4729 async fn a_search_filtered_by_a_dm_or_group_dm_keeps_the_query_whole() {
4730 let (facade, _ws, api, _rx) = facade_with_recording_slack();
4731 api.seed_dm("D1", "U9");
4732 api.seed_mpim("G1", "mpdm-ved--gina-1");
4733 api.seed_search(vec![
4734 search_hit("D1", "100.1"),
4735 search_hit("G1", "100.2"),
4736 search_hit("C2", "100.3"),
4737 ]);
4738
4739 let (_, direct) =
4740 facade.search(&caller(), "deploy", 20, Some("D1")).await.expect("the search runs");
4741 assert_eq!(direct.len(), 1, "only the DM's hit survives: {direct:?}");
4742 assert_eq!(direct[0].conversation_id, "D1");
4743
4744 let (_, group) =
4745 facade.search(&caller(), "deploy", 20, Some("G1")).await.expect("the search runs");
4746 assert_eq!(group.len(), 1, "only the group DM's hit survives: {group:?}");
4747 assert_eq!(group[0].conversation_id, "G1");
4748
4749 assert_eq!(
4750 api.search_reads(),
4751 ["deploy", "deploy"],
4752 "neither a DM nor a group DM adds an `in:`",
4753 );
4754 }
4755
4756 #[tokio::test]
4757 async fn a_bookmarks_read_reaches_the_workspace_client_with_its_conversation() {
4758 let (facade, _ws, api, _rx) = facade_with_recording_slack();
4759 api.seed_bookmarks(
4760 "C1",
4761 vec![SlackBookmark {
4762 id: "Bk1".to_owned(),
4763 title: Some("Runbook".to_owned()),
4764 link: Some("https://example.com".to_owned()),
4765 }],
4766 );
4767
4768 let (_, bookmarks) = facade.bookmarks(&caller(), "C1").await.expect("read");
4769 assert_eq!(bookmarks.len(), 1, "the seeded row comes back");
4770 assert_eq!(bookmarks[0].title.as_deref(), Some("Runbook"));
4771 assert_eq!(api.bookmark_reads.lock().as_slice(), ["C1"]);
4772 }
4773
4774 #[tokio::test]
4778 async fn subscribed_targets_scopes_to_the_callers_role() {
4779 let (ws, facade) = workspace_with_one_slack_workspace("acme");
4780 let lead = caller();
4781 facade
4782 .subscribe(
4783 &lead,
4784 SlackSubscribeRequest::Conversations(vec![SlackChannelWatch {
4785 id: "C1".to_owned(),
4786 mode: SlackWatchMode::All,
4787 }]),
4788 )
4789 .expect("the lead subscribes to C1");
4790
4791 let worker_key = SessionSlot::worker("TestOrg", "forge", "tester");
4792 let project_key = ws
4793 .list_projects()
4794 .into_iter()
4795 .find(|view| view.name == "forge")
4796 .expect("the seeded project")
4797 .key;
4798 ws.insert_live_worker(
4799 &project_key,
4800 crate::mcp::workers::types::WorkerEntry {
4801 label: "tester".into(),
4802 charter: "c".into(),
4803 slot: worker_key.clone(),
4804 session_id: None,
4805 status: forge_primitives::WorkerLiveness::Running,
4806 spawned_at: std::time::SystemTime::UNIX_EPOCH,
4807 spawned_by: SessionSlot::lead("TestOrg", "forge"),
4808 needs_tag: false,
4809 is_git_repo_at_spawn: false,
4810 cwd: None,
4811 diagnostic: None,
4812 kick: None,
4813 },
4814 );
4815 facade
4816 .subscribe(&worker_key, SlackSubscribeRequest::DirectMessages)
4817 .expect("the worker subscribes to the DM class");
4818
4819 let lead_targets: Vec<SlackSubscriptionTarget> =
4820 facade.subscribed_targets(&lead, "acme").into_iter().map(|s| s.target).collect();
4821 assert_eq!(
4822 lead_targets,
4823 vec![SlackSubscriptionTarget::Conversation {
4824 id: "C1".to_owned(),
4825 name: None,
4826 mode: SlackWatchMode::All,
4827 }],
4828 "the lead sees only its own record",
4829 );
4830 let worker_targets: Vec<SlackSubscriptionTarget> =
4831 facade.subscribed_targets(&worker_key, "acme").into_iter().map(|s| s.target).collect();
4832 assert_eq!(
4833 worker_targets,
4834 vec![SlackSubscriptionTarget::DirectMessages],
4835 "the worker sees only its own record, never the lead's",
4836 );
4837 }
4838}