diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 1d316357e..96112990f 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -3380,6 +3380,7 @@ fn collect_configured_channels( Vec::new(), sl.allowed_users.clone(), ) + .with_thread_replies(sl.thread_replies.unwrap_or(true)) .with_group_reply_policy(sl.mention_only, Vec::new()) .with_workspace_dir(config.workspace_dir.clone()), ), diff --git a/src/channels/slack.rs b/src/channels/slack.rs index ec84e3220..e029e607e 100644 --- a/src/channels/slack.rs +++ b/src/channels/slack.rs @@ -25,6 +25,7 @@ pub struct SlackChannel { channel_id: Option, channel_ids: Vec, allowed_users: Vec, + thread_replies: bool, mention_only: bool, group_reply_allowed_sender_ids: Vec, user_display_name_cache: Mutex>, @@ -75,6 +76,7 @@ impl SlackChannel { channel_id, channel_ids, allowed_users, + thread_replies: true, mention_only: false, group_reply_allowed_sender_ids: Vec::new(), user_display_name_cache: Mutex::new(HashMap::new()), @@ -94,6 +96,12 @@ impl SlackChannel { self } + /// Configure whether outbound replies stay in the originating Slack thread. + pub fn with_thread_replies(mut self, thread_replies: bool) -> Self { + self.thread_replies = thread_replies; + self + } + /// Configure workspace directory used for persisting inbound Slack attachments. pub fn with_workspace_dir(mut self, dir: PathBuf) -> Self { self.workspace_dir = Some(dir); @@ -122,6 +130,14 @@ impl SlackChannel { .any(|entry| entry == "*" || entry == user_id) } + fn outbound_thread_ts<'a>(&self, message: &'a SendMessage) -> Option<&'a str> { + if self.thread_replies { + message.thread_ts.as_deref() + } else { + None + } + } + /// Get the bot's own user ID so we can ignore our own messages async fn get_bot_user_id(&self) -> Option { let resp: serde_json::Value = self @@ -2149,7 +2165,7 @@ impl Channel for SlackChannel { "text": message.content }); - if let Some(ref ts) = message.thread_ts { + if let Some(ts) = self.outbound_thread_ts(message) { body["thread_ts"] = serde_json::json!(ts); } @@ -2484,10 +2500,30 @@ mod tests { #[test] fn slack_group_reply_policy_defaults_to_all_messages() { let ch = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec!["*".into()]); + assert!(ch.thread_replies); assert!(!ch.mention_only); assert!(ch.group_reply_allowed_sender_ids.is_empty()); } + #[test] + fn with_thread_replies_sets_flag() { + let ch = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![]) + .with_thread_replies(false); + assert!(!ch.thread_replies); + } + + #[test] + fn outbound_thread_ts_respects_thread_replies_setting() { + let msg = SendMessage::new("hello", "C123").in_thread(Some("1741234567.100001".into())); + + let threaded = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![]); + assert_eq!(threaded.outbound_thread_ts(&msg), Some("1741234567.100001")); + + let channel_root = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![]) + .with_thread_replies(false); + assert_eq!(channel_root.outbound_thread_ts(&msg), None); + } + #[test] fn with_workspace_dir_sets_field() { let ch = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![]) diff --git a/src/config/schema.rs b/src/config/schema.rs index 10d006a0a..19f273302 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -4640,6 +4640,10 @@ pub struct SlackConfig { /// cancels the in-flight request and starts a fresh response with preserved history. #[serde(default)] pub interrupt_on_new_message: bool, + /// When true (default), replies stay in the originating Slack thread. + /// When false, replies go to the channel root instead. + #[serde(default)] + pub thread_replies: Option, /// When true, only respond to messages that @-mention the bot in groups. /// Direct messages remain allowed. #[serde(default)] @@ -9383,6 +9387,7 @@ allowed_users = ["@ops:matrix.org"] let parsed: SlackConfig = serde_json::from_str(json).unwrap(); assert!(parsed.allowed_users.is_empty()); assert!(!parsed.interrupt_on_new_message); + assert_eq!(parsed.thread_replies, None); assert!(!parsed.mention_only); } @@ -9392,6 +9397,7 @@ allowed_users = ["@ops:matrix.org"] let parsed: SlackConfig = serde_json::from_str(json).unwrap(); assert_eq!(parsed.allowed_users, vec!["U111"]); assert!(!parsed.interrupt_on_new_message); + assert_eq!(parsed.thread_replies, None); assert!(!parsed.mention_only); } @@ -9401,6 +9407,7 @@ allowed_users = ["@ops:matrix.org"] let parsed: SlackConfig = serde_json::from_str(json).unwrap(); assert!(parsed.mention_only); assert!(!parsed.interrupt_on_new_message); + assert_eq!(parsed.thread_replies, None); } #[test] @@ -9408,6 +9415,16 @@ allowed_users = ["@ops:matrix.org"] let json = r#"{"bot_token":"xoxb-tok","interrupt_on_new_message":true}"#; let parsed: SlackConfig = serde_json::from_str(json).unwrap(); assert!(parsed.interrupt_on_new_message); + assert_eq!(parsed.thread_replies, None); + assert!(!parsed.mention_only); + } + + #[test] + async fn slack_config_deserializes_thread_replies() { + let json = r#"{"bot_token":"xoxb-tok","thread_replies":false}"#; + let parsed: SlackConfig = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.thread_replies, Some(false)); + assert!(!parsed.interrupt_on_new_message); assert!(!parsed.mention_only); } @@ -9431,6 +9448,7 @@ channel_id = "C123" let parsed: SlackConfig = toml::from_str(toml_str).unwrap(); assert!(parsed.allowed_users.is_empty()); assert!(!parsed.interrupt_on_new_message); + assert_eq!(parsed.thread_replies, None); assert!(!parsed.mention_only); assert_eq!(parsed.channel_id.as_deref(), Some("C123")); } diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 11c5ae60f..87aa3b376 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -3975,6 +3975,7 @@ fn setup_channels() -> Result { }, allowed_users, interrupt_on_new_message: false, + thread_replies: None, mention_only: false, }); }