From b6bc332b68c5bcd817e47d3132e3fd92b586f704 Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Fri, 20 Mar 2026 02:32:02 +0800
Subject: [PATCH] feat(slack): add thread_replies channel option (#3930)
Add a thread_replies option to Slack channel config (default true). When false, replies go to channel root instead of the originating thread.
Closes #3888
---
src/channels/mod.rs | 1 +
src/channels/slack.rs | 38 +++++++++++++++++++++++++++++++++++++-
src/config/schema.rs | 18 ++++++++++++++++++
src/onboard/wizard.rs | 1 +
4 files changed, 57 insertions(+), 1 deletion(-)
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,
});
}