Skip to main content

slack_morphism/models/events/
interaction.rs

1use rsb_derive::Builder;
2use serde::{Deserialize, Serialize};
3use serde_with::{serde_as, skip_serializing_none};
4
5use crate::blocks::*;
6use crate::models::messages::*;
7use crate::*;
8use std::collections::HashMap;
9
10#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
11#[serde(tag = "type")]
12pub enum SlackInteractionEvent {
13    #[serde(rename = "block_actions")]
14    BlockActions(SlackInteractionBlockActionsEvent),
15    #[serde(rename = "block_suggestion")]
16    BlockSuggestion(SlackInteractionBlockSuggestionEvent),
17    #[serde(rename = "dialog_submission")]
18    DialogSubmission(SlackInteractionDialogueSubmissionEvent),
19    #[serde(rename = "message_action")]
20    MessageAction(SlackInteractionMessageActionEvent),
21    #[serde(rename = "shortcut")]
22    Shortcut(SlackInteractionShortcutEvent),
23    #[serde(rename = "view_submission")]
24    ViewSubmission(SlackInteractionViewSubmissionEvent),
25    #[serde(rename = "view_closed")]
26    ViewClosed(SlackInteractionViewClosedEvent),
27}
28
29#[skip_serializing_none]
30#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Builder)]
31pub struct SlackInteractionBlockActionsEvent {
32    pub team: SlackBasicTeamInfo,
33    pub user: Option<SlackBasicUserInfo>,
34    pub api_app_id: SlackAppId,
35    pub container: SlackInteractionActionContainer,
36    pub trigger_id: SlackTriggerId,
37    pub channel: Option<SlackBasicChannelInfo>,
38    pub message: Option<SlackHistoryMessage>,
39    pub view: Option<SlackView>,
40    pub response_url: Option<SlackResponseUrl>,
41    pub actions: Option<Vec<SlackInteractionActionInfo>>,
42    pub state: Option<SlackActionState>,
43}
44
45#[skip_serializing_none]
46#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Builder)]
47pub struct SlackInteractionBlockSuggestionEvent {
48    pub team: SlackBasicTeamInfo,
49    pub user: SlackBasicUserInfo,
50    pub api_app_id: SlackAppId,
51    pub block_id: SlackBlockId,
52    pub action_id: SlackActionId,
53    pub container: SlackInteractionActionContainer,
54    pub channel: Option<SlackBasicChannelInfo>,
55    pub view: Option<SlackView>,
56    pub value: String,
57    pub message: Option<SlackHistoryMessage>,
58}
59
60#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
61#[serde(tag = "type")]
62pub enum SlackInteractionActionContainer {
63    #[serde(rename = "message")]
64    Message(SlackInteractionActionMessageContainer),
65    #[serde(rename = "message_attachment")]
66    MessageAttachment(SlackInteractionActionMessageAttachmentContainer),
67    #[serde(rename = "view")]
68    View(SlackInteractionActionViewContainer),
69}
70
71#[skip_serializing_none]
72#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Builder)]
73pub struct SlackInteractionActionMessageContainer {
74    pub message_ts: SlackTs,
75    pub channel_id: Option<SlackChannelId>,
76    pub is_ephemeral: Option<bool>,
77    pub is_app_unfurl: Option<bool>,
78}
79
80#[skip_serializing_none]
81#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Builder)]
82pub struct SlackInteractionActionMessageAttachmentContainer {
83    pub message_ts: SlackTs,
84    pub attachment_id: SlackMessageAttachmentId,
85    pub channel_id: Option<SlackChannelId>,
86    pub is_ephemeral: Option<bool>,
87    pub is_app_unfurl: Option<bool>,
88}
89
90#[skip_serializing_none]
91#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Builder)]
92pub struct SlackInteractionActionViewContainer {
93    pub view_id: SlackViewId,
94}
95
96#[skip_serializing_none]
97#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Builder)]
98pub struct SlackInteractionActionInfo {
99    #[serde(rename = "type")]
100    pub action_type: SlackActionType,
101    pub action_id: SlackActionId,
102    pub block_id: Option<SlackBlockId>,
103    pub text: Option<SlackBlockText>,
104    pub value: Option<String>,
105    pub selected_option: Option<SlackBlockChoiceItem<SlackBlockText>>,
106    pub selected_options: Option<Vec<SlackBlockChoiceItem<SlackBlockText>>>,
107    pub action_ts: Option<SlackTs>,
108}
109
110#[serde_as]
111#[skip_serializing_none]
112#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Builder)]
113pub struct SlackInteractionDialogueSubmissionEvent {
114    pub team: SlackBasicTeamInfo,
115    pub user: SlackBasicUserInfo,
116    pub channel: Option<SlackBasicChannelInfo>,
117    #[serde(default)]
118    #[serde_as(as = "serde_with::NoneAsEmptyString")]
119    pub callback_id: Option<SlackCallbackId>,
120    pub state: Option<String>,
121    pub submission: HashMap<String, String>,
122}
123
124#[skip_serializing_none]
125#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Builder)]
126pub struct SlackInteractionMessageActionEvent {
127    pub team: SlackBasicTeamInfo,
128    pub user: SlackBasicUserInfo,
129    pub channel: Option<SlackBasicChannelInfo>,
130    pub message: Option<SlackHistoryMessage>,
131    pub callback_id: SlackCallbackId,
132    pub trigger_id: SlackTriggerId,
133    pub response_url: SlackResponseUrl,
134    pub actions: Option<Vec<SlackInteractionActionInfo>>,
135}
136
137#[skip_serializing_none]
138#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Builder)]
139pub struct SlackInteractionShortcutEvent {
140    pub team: SlackBasicTeamInfo,
141    pub user: SlackBasicUserInfo,
142    pub callback_id: SlackCallbackId,
143    pub trigger_id: SlackTriggerId,
144    pub actions: Option<Vec<SlackInteractionActionInfo>>,
145}
146
147#[skip_serializing_none]
148#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Builder)]
149pub struct SlackInteractionViewSubmissionEvent {
150    pub team: SlackBasicTeamInfo,
151    pub user: SlackBasicUserInfo,
152    pub view: SlackStatefulView,
153    pub trigger_id: Option<SlackTriggerId>,
154}
155
156#[skip_serializing_none]
157#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Builder)]
158pub struct SlackInteractionViewClosedEvent {
159    pub team: SlackBasicTeamInfo,
160    pub user: SlackBasicUserInfo,
161    pub view: SlackStatefulView,
162    pub trigger_id: Option<SlackTriggerId>,
163}
164
165#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
166#[serde(untagged)]
167pub enum SlackBlockSuggestionResponse {
168    Options(SlackBlockSuggestionOptions),
169    OptionGroups(SlackBlockSuggestionOptionGroups),
170}
171
172#[skip_serializing_none]
173#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Builder)]
174pub struct SlackBlockSuggestionOptions {
175    pub options: Vec<SlackBlockChoiceItem<SlackBlockPlainTextOnly>>,
176}
177
178#[skip_serializing_none]
179#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Builder)]
180pub struct SlackBlockSuggestionOptionGroups {
181    pub option_groups: Vec<SlackBlockOptionGroup<SlackBlockPlainTextOnly>>,
182}
183
184/// What an interaction handler hands back to Slack.
185///
186/// A single handler can answer every kind of [`SlackInteractionEvent`] with this:
187/// options for `block_suggestion`, a `response_action` for `view_submission`,
188/// and a plain acknowledgement for everything else.
189///
190/// [`SlackInteractionResponse::Empty`] has no wire representation: transports turn it
191/// into an empty HTTP 200 or a bare Socket Mode acknowledgement, and serialising it
192/// directly is an error rather than a silent `null`.
193#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
194#[serde(untagged)]
195pub enum SlackInteractionResponse {
196    #[serde(skip)]
197    Empty,
198    ViewSubmission(SlackViewSubmissionResponse),
199    BlockSuggestion(SlackBlockSuggestionResponse),
200}
201
202impl From<()> for SlackInteractionResponse {
203    fn from(_: ()) -> Self {
204        SlackInteractionResponse::Empty
205    }
206}
207
208impl From<SlackViewSubmissionResponse> for SlackInteractionResponse {
209    fn from(response: SlackViewSubmissionResponse) -> Self {
210        SlackInteractionResponse::ViewSubmission(response)
211    }
212}
213
214impl From<SlackBlockSuggestionResponse> for SlackInteractionResponse {
215    fn from(response: SlackBlockSuggestionResponse) -> Self {
216        SlackInteractionResponse::BlockSuggestion(response)
217    }
218}
219
220#[cfg(test)]
221mod test {
222    use super::*;
223
224    #[test]
225    fn test_block_suggestion_view_event_deserialization() {
226        let payload = include_str!("./fixtures/interaction_block_suggestion_view.json");
227        let event: SlackInteractionEvent = serde_json::from_str(payload).unwrap();
228
229        match event {
230            SlackInteractionEvent::BlockSuggestion(ev) => {
231                assert_eq!(ev.value, "sentien");
232                assert_eq!(ev.action_id, "my-external-select-action".into());
233                assert_eq!(ev.block_id, "my-external-select-block".into());
234                assert!(matches!(
235                    ev.container,
236                    SlackInteractionActionContainer::View(_)
237                ));
238                assert!(ev.view.is_some());
239                assert_eq!(ev.channel, None);
240                assert_eq!(ev.message, None);
241            }
242            other => panic!("Unexpected interaction event: {:?}", other),
243        }
244    }
245
246    #[test]
247    fn test_block_suggestion_message_event_deserialization() {
248        let payload = include_str!("./fixtures/interaction_block_suggestion_message.json");
249        let event: SlackInteractionEvent = serde_json::from_str(payload).unwrap();
250
251        match event {
252            SlackInteractionEvent::BlockSuggestion(ev) => {
253                assert_eq!(ev.value, "sentien");
254                assert_eq!(ev.action_id, "my-external-select-action".into());
255                assert_eq!(ev.block_id, "my-external-select-block".into());
256                assert!(matches!(
257                    ev.container,
258                    SlackInteractionActionContainer::Message(_)
259                ));
260                assert_eq!(
261                    ev.channel.map(|channel| channel.id),
262                    Some("CXXXXXXXXXX".into())
263                );
264                assert!(ev.message.is_some());
265                assert!(ev.view.is_none());
266            }
267            other => panic!("Unexpected interaction event: {:?}", other),
268        }
269    }
270
271    #[test]
272    fn test_block_suggestion_options_response_serialization() {
273        let response =
274            SlackBlockSuggestionResponse::Options(SlackBlockSuggestionOptions::new(vec![
275                SlackBlockChoiceItem::new(
276                    SlackBlockPlainTextOnly::from("Unexpected sentience"),
277                    "AI-2323".to_string(),
278                )
279                .with_description(SlackBlockPlainTextOnly::from("Reported by the night shift")),
280            ]));
281
282        assert_eq!(
283            serde_json::to_string(&response).unwrap(),
284            r#"{"options":[{"text":{"type":"plain_text","text":"Unexpected sentience"},"value":"AI-2323","description":{"type":"plain_text","text":"Reported by the night shift"}}]}"#
285        );
286    }
287
288    #[test]
289    fn test_block_suggestion_option_groups_response_serialization() {
290        let response = SlackBlockSuggestionResponse::OptionGroups(
291            SlackBlockSuggestionOptionGroups::new(vec![SlackBlockOptionGroup::new(
292                SlackBlockPlainTextOnly::from("Open"),
293                vec![SlackBlockChoiceItem::new(
294                    SlackBlockPlainTextOnly::from("Unexpected sentience"),
295                    "AI-2323".to_string(),
296                )],
297            )]),
298        );
299
300        assert_eq!(
301            serde_json::to_string(&response).unwrap(),
302            r#"{"option_groups":[{"label":{"type":"plain_text","text":"Open"},"options":[{"text":{"type":"plain_text","text":"Unexpected sentience"},"value":"AI-2323"}]}]}"#
303        );
304    }
305
306    #[test]
307    fn test_interaction_response_from_unit_is_empty() {
308        assert_eq!(
309            SlackInteractionResponse::from(()),
310            SlackInteractionResponse::Empty
311        );
312    }
313
314    #[test]
315    fn test_interaction_response_empty_is_not_serializable() {
316        assert!(serde_json::to_string(&SlackInteractionResponse::Empty).is_err());
317    }
318
319    #[test]
320    fn test_interaction_response_block_suggestion_serialization() {
321        let inner = SlackBlockSuggestionResponse::Options(SlackBlockSuggestionOptions::new(vec![
322            SlackBlockChoiceItem::new(
323                SlackBlockPlainTextOnly::from("Unexpected sentience"),
324                "AI-2323".to_string(),
325            ),
326        ]));
327
328        assert_eq!(
329            serde_json::to_string(&SlackInteractionResponse::from(inner.clone())).unwrap(),
330            serde_json::to_string(&inner).unwrap()
331        );
332    }
333
334    #[test]
335    fn test_interaction_response_view_submission_serialization() {
336        let inner = SlackViewSubmissionResponse::Clear(SlackViewSubmissionClearResponse::new());
337
338        assert_eq!(
339            serde_json::to_string(&SlackInteractionResponse::from(inner)).unwrap(),
340            r#"{"response_action":"clear"}"#
341        );
342    }
343}