From 84aabf05e5c33df57b482a1d7141a08d2925374c Mon Sep 17 00:00:00 2001 From: Robert Pankowecki Date: Thu, 19 Mar 2026 12:46:46 +0100 Subject: [PATCH] feat: support conversation filter object on conversations select elements (#346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack's conversation_filter composition object lets callers restrict which conversation types appear in a conversations_select picker — e.g. only public/private channels, excluding DMs and bot users. This is needed downstream to prevent users from selecting incorrect conversations. A separate SlackConversationFilterInclude enum is introduced because the filter API uses "public"/"private" while the existing SlackConversationType serializes to "public_channel"/"private_channel". --- ...lack_conversations_select_with_filter.json | 20 ++++ src/models/blocks/kit.rs | 107 ++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/models/blocks/fixtures/slack_conversations_select_with_filter.json diff --git a/src/models/blocks/fixtures/slack_conversations_select_with_filter.json b/src/models/blocks/fixtures/slack_conversations_select_with_filter.json new file mode 100644 index 0000000..222d083 --- /dev/null +++ b/src/models/blocks/fixtures/slack_conversations_select_with_filter.json @@ -0,0 +1,20 @@ +{ + "type": "section", + "text": { + "type": "plain_text", + "text": "Pick a channel" + }, + "accessory": { + "type": "conversations_select", + "action_id": "channel_select", + "placeholder": { + "type": "plain_text", + "text": "Select a channel" + }, + "filter": { + "include": ["public", "private"], + "exclude_external_shared_channels": true, + "exclude_bot_users": true + } + } +} diff --git a/src/models/blocks/kit.rs b/src/models/blocks/kit.rs index bfe14c2..3cd7fe2 100644 --- a/src/models/blocks/kit.rs +++ b/src/models/blocks/kit.rs @@ -520,6 +520,26 @@ impl From for SlackInputBlockElement { } } +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +pub enum SlackConversationFilterInclude { + #[serde(rename = "im")] + Im, + #[serde(rename = "mpim")] + Mpim, + #[serde(rename = "public")] + Public, + #[serde(rename = "private")] + Private, +} + +#[skip_serializing_none] +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Builder)] +pub struct SlackBlockConversationFilter { + pub include: Option>, + pub exclude_external_shared_channels: Option, + pub exclude_bot_users: Option, +} + #[skip_serializing_none] #[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Builder)] pub struct SlackBlockConversationsSelectElement { @@ -530,6 +550,7 @@ pub struct SlackBlockConversationsSelectElement { pub confirm: Option, pub response_url_enabled: Option, pub focus_on_load: Option, + pub filter: Option, } impl From for SlackSectionBlockElement { @@ -560,6 +581,7 @@ pub struct SlackBlockMultiConversationsSelectElement { pub confirm: Option, pub max_selected_items: Option, pub focus_on_load: Option, + pub filter: Option, } impl From for SlackSectionBlockElement { @@ -1091,6 +1113,91 @@ mod test { use super::*; use crate::blocks::SlackHomeView; + #[test] + fn test_conversation_filter_deserialize() -> Result<(), Box> { + let payload = include_str!("./fixtures/slack_conversations_select_with_filter.json"); + let block: SlackBlock = serde_json::from_str(payload)?; + match block { + SlackBlock::Section(section) => match section.accessory { + Some(SlackSectionBlockElement::ConversationsSelect(elem)) => { + let filter = elem.filter.expect("filter should be present"); + let include = filter.include.expect("include should be present"); + assert_eq!(include.len(), 2); + assert_eq!(include[0], SlackConversationFilterInclude::Public); + assert_eq!(include[1], SlackConversationFilterInclude::Private); + assert_eq!(filter.exclude_external_shared_channels, Some(true)); + assert_eq!(filter.exclude_bot_users, Some(true)); + } + _ => panic!("Expected ConversationsSelect accessory"), + }, + _ => panic!("Expected Section block"), + } + Ok(()) + } + + #[test] + fn test_conversation_filter_serialize() -> Result<(), Box> { + let filter = SlackBlockConversationFilter::new() + .with_include(vec![ + SlackConversationFilterInclude::Im, + SlackConversationFilterInclude::Mpim, + ]) + .with_exclude_bot_users(true); + + let json = serde_json::to_value(&filter)?; + assert_eq!( + json, + serde_json::json!({ + "include": ["im", "mpim"], + "exclude_bot_users": true + }) + ); + Ok(()) + } + + #[test] + fn test_conversation_filter_roundtrip() -> Result<(), Box> { + let elem = SlackBlockConversationsSelectElement::new(SlackActionId("test_action".into())) + .with_filter( + SlackBlockConversationFilter::new() + .with_include(vec![SlackConversationFilterInclude::Public]) + .with_exclude_external_shared_channels(true), + ); + + let json = serde_json::to_string(&elem)?; + let parsed: SlackBlockConversationsSelectElement = serde_json::from_str(&json)?; + assert_eq!(elem, parsed); + Ok(()) + } + + #[test] + fn test_multi_conversations_select_filter() -> Result<(), Box> { + let elem = + SlackBlockMultiConversationsSelectElement::new(SlackActionId("multi_action".into())) + .with_filter( + SlackBlockConversationFilter::new() + .with_include(vec![ + SlackConversationFilterInclude::Public, + SlackConversationFilterInclude::Private, + ]) + .with_exclude_bot_users(true), + ); + + let json = serde_json::to_string(&elem)?; + let parsed: SlackBlockMultiConversationsSelectElement = serde_json::from_str(&json)?; + assert_eq!(elem, parsed); + Ok(()) + } + + #[test] + fn test_conversation_filter_none_omitted() -> Result<(), Box> { + let elem = SlackBlockConversationsSelectElement::new(SlackActionId("no_filter".into())); + + let json = serde_json::to_value(&elem)?; + assert!(json.get("filter").is_none()); + Ok(()) + } + #[test] fn test_slack_image_block_deserialize() -> Result<(), Box> { let payload = include_str!("./fixtures/slack_image_blocks.json");