Skip to main content

autumn_plugin_slack/
payload.rs

1//! Typed Slack payloads and replies.
2//!
3//! `EventCallback` (in `event`), `BlockAction`, `ViewPayload`, and `Shortcut`
4//! (in `payload`) keep the raw JSON. A handler can read fields that the
5//! typed view does not have.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value};
11
12// ------------------------------------------------------------------ events
13
14/// An Events API `event_callback`.
15#[derive(Debug, Clone, PartialEq, Deserialize)]
16#[non_exhaustive]
17pub struct EventCallback {
18    /// Workspace ID.
19    #[serde(default)]
20    pub team_id: Option<String>,
21    /// App ID.
22    #[serde(default)]
23    pub api_app_id: Option<String>,
24    /// Unique event ID. Slack retries use the same ID.
25    pub event_id: String,
26    /// Event time, Unix seconds.
27    #[serde(default)]
28    pub event_time: Option<i64>,
29    /// The inner event.
30    pub event: Value,
31    /// `X-Slack-Retry-Num` of this delivery. `None` on the first delivery.
32    #[serde(skip)]
33    pub retry_num: Option<u32>,
34    /// `X-Slack-Retry-Reason`, for example `http_timeout`.
35    #[serde(skip)]
36    pub retry_reason: Option<String>,
37}
38
39impl EventCallback {
40    /// The inner event type, for example `app_mention`.
41    #[must_use]
42    pub fn event_type(&self) -> &str {
43        self.event.get("type").and_then(Value::as_str).unwrap_or("")
44    }
45
46    /// Decodes the inner event.
47    ///
48    /// # Errors
49    /// Returns the serde error when the event does not match `T`.
50    pub fn parse<T: serde::de::DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
51        T::deserialize(&self.event)
52    }
53}
54
55/// Common fields of `message` and `app_mention` events.
56#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
57#[serde(default)]
58#[non_exhaustive]
59pub struct MessageEvent {
60    /// Event type.
61    #[serde(rename = "type")]
62    pub kind: String,
63    /// Message subtype, for example `bot_message`.
64    pub subtype: Option<String>,
65    /// Channel ID.
66    pub channel: Option<String>,
67    /// Channel type: `channel`, `group`, `im`, or `mpim`.
68    pub channel_type: Option<String>,
69    /// Author user ID.
70    pub user: Option<String>,
71    /// Bot ID, for bot messages.
72    pub bot_id: Option<String>,
73    /// Message text.
74    pub text: Option<String>,
75    /// Message timestamp (its ID).
76    pub ts: Option<String>,
77    /// Parent message timestamp, for a thread reply.
78    pub thread_ts: Option<String>,
79}
80
81// --------------------------------------------------------------- commands
82
83/// A slash command request.
84#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
85#[non_exhaustive]
86pub struct SlashCommand {
87    /// Command name with the slash, for example `/deploy`.
88    pub command: String,
89    /// Text after the command.
90    #[serde(default)]
91    pub text: String,
92    /// User ID.
93    #[serde(default)]
94    pub user_id: String,
95    /// User name (legacy).
96    #[serde(default)]
97    pub user_name: String,
98    /// Channel ID.
99    #[serde(default)]
100    pub channel_id: String,
101    /// Channel name.
102    #[serde(default)]
103    pub channel_name: String,
104    /// Workspace ID.
105    #[serde(default)]
106    pub team_id: String,
107    /// Workspace domain.
108    #[serde(default)]
109    pub team_domain: String,
110    /// Enterprise Grid ID.
111    #[serde(default)]
112    pub enterprise_id: Option<String>,
113    /// App ID.
114    #[serde(default)]
115    pub api_app_id: Option<String>,
116    /// URL for replies after the ack. Valid for 30 minutes and 5 uses.
117    #[serde(default)]
118    pub response_url: String,
119    /// ID to open a modal. Valid for 3 s.
120    #[serde(default)]
121    pub trigger_id: String,
122}
123
124// ----------------------------------------------------------------- replies
125
126/// A message to post or to send as a reply.
127#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
128#[non_exhaustive]
129pub struct Message {
130    /// Text. Slack uses it for notifications when blocks are set.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub text: Option<String>,
133    /// Block Kit blocks.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub blocks: Option<Vec<Value>>,
136    /// Parent message timestamp. Posts in a thread.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub thread_ts: Option<String>,
139    /// `ephemeral` or `in_channel`. Only for replies.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub response_type: Option<String>,
142    /// Replace the source message. Only for `response_url` replies.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub replace_original: Option<bool>,
145    /// Delete the source message. Only for `response_url` replies.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub delete_original: Option<bool>,
148}
149
150impl Message {
151    /// A message with this text.
152    #[must_use]
153    pub fn text(text: impl Into<String>) -> Self {
154        Self {
155            text: Some(text.into()),
156            ..Self::default()
157        }
158    }
159
160    /// Sets the blocks.
161    #[must_use]
162    pub fn blocks(mut self, blocks: Vec<Value>) -> Self {
163        self.blocks = Some(blocks);
164        self
165    }
166
167    /// Posts in the thread of `ts`.
168    #[must_use]
169    pub fn in_thread(mut self, ts: impl Into<String>) -> Self {
170        self.thread_ts = Some(ts.into());
171        self
172    }
173
174    /// Only the user sees the reply.
175    #[must_use]
176    pub fn ephemeral(mut self) -> Self {
177        self.response_type = Some("ephemeral".to_owned());
178        self
179    }
180
181    /// All channel members see the reply.
182    #[must_use]
183    pub fn in_channel(mut self) -> Self {
184        self.response_type = Some("in_channel".to_owned());
185        self
186    }
187
188    /// The reply replaces the source message.
189    #[must_use]
190    pub const fn replace_original(mut self) -> Self {
191        self.replace_original = Some(true);
192        self
193    }
194
195    /// The reply deletes the source message.
196    #[must_use]
197    pub const fn delete_original(mut self) -> Self {
198        self.delete_original = Some(true);
199        self
200    }
201
202    /// The message as JSON object fields.
203    pub(crate) fn to_map(&self) -> Map<String, Value> {
204        match serde_json::to_value(self) {
205            Ok(Value::Object(m)) => m,
206            _ => Map::new(),
207        }
208    }
209}
210
211impl From<&str> for Message {
212    fn from(text: &str) -> Self {
213        Self::text(text)
214    }
215}
216
217impl From<String> for Message {
218    fn from(text: String) -> Self {
219        Self::text(text)
220    }
221}
222
223/// Reply to a slash command.
224#[derive(Debug, Clone, PartialEq, Eq)]
225#[non_exhaustive]
226pub enum CommandResponse {
227    /// Empty 200. Slack shows nothing.
228    Ack,
229    /// Only the user sees the reply.
230    Ephemeral(Message),
231    /// All channel members see the reply.
232    InChannel(Message),
233}
234
235impl CommandResponse {
236    /// An ephemeral reply.
237    #[must_use]
238    pub fn ephemeral(msg: impl Into<Message>) -> Self {
239        Self::Ephemeral(msg.into())
240    }
241
242    /// An in-channel reply.
243    #[must_use]
244    pub fn in_channel(msg: impl Into<Message>) -> Self {
245        Self::InChannel(msg.into())
246    }
247
248    /// The reply message with `response_type` set. `None` for [`Self::Ack`].
249    #[must_use]
250    pub fn into_message(self) -> Option<Message> {
251        match self {
252            Self::Ack => None,
253            Self::Ephemeral(m) => Some(m.ephemeral()),
254            Self::InChannel(m) => Some(m.in_channel()),
255        }
256    }
257}
258
259// ------------------------------------------------------------ interactions
260
261/// One action from a `block_actions` payload.
262#[derive(Debug, Clone, PartialEq)]
263#[non_exhaustive]
264pub struct BlockAction {
265    /// The action ID.
266    pub action_id: String,
267    /// The block ID.
268    pub block_id: Option<String>,
269    /// The `value` of a button.
270    pub value: Option<String>,
271    /// The action JSON.
272    pub action: Value,
273    /// User ID.
274    pub user_id: Option<String>,
275    /// Workspace ID.
276    pub team_id: Option<String>,
277    /// Channel ID, for a message action.
278    pub channel_id: Option<String>,
279    /// Timestamp of the message with the button.
280    pub message_ts: Option<String>,
281    /// URL for replies. Not set for actions in modals or the Home tab.
282    pub response_url: Option<String>,
283    /// ID to open a modal. Valid for 3 s.
284    pub trigger_id: Option<String>,
285    /// The full payload JSON.
286    pub payload: Value,
287}
288
289/// A `view_submission` or `view_closed` payload.
290#[derive(Debug, Clone, PartialEq)]
291#[non_exhaustive]
292pub struct ViewPayload {
293    /// The view `callback_id`.
294    pub callback_id: String,
295    /// The view ID.
296    pub view_id: Option<String>,
297    /// The view `private_metadata`.
298    pub private_metadata: Option<String>,
299    /// The view JSON.
300    pub view: Value,
301    /// User ID.
302    pub user_id: Option<String>,
303    /// Workspace ID.
304    pub team_id: Option<String>,
305    /// ID to open a modal. Valid for 3 s.
306    pub trigger_id: Option<String>,
307    /// The full payload JSON.
308    pub payload: Value,
309}
310
311impl ViewPayload {
312    /// The input values: `view.state.values`.
313    #[must_use]
314    pub fn state_values(&self) -> Option<&Value> {
315        self.view.get("state")?.get("values")
316    }
317}
318
319/// Reply to a `view_submission`.
320#[derive(Debug, Clone, PartialEq, Eq)]
321#[non_exhaustive]
322pub enum ViewResponse {
323    /// Close this view.
324    Close,
325    /// Close all views.
326    Clear,
327    /// Replace this view.
328    Update(Value),
329    /// Push a new view.
330    Push(Value),
331    /// Show errors by block ID. The view stays open.
332    Errors(BTreeMap<String, String>),
333}
334
335impl ViewResponse {
336    /// Errors by block ID.
337    #[must_use]
338    pub fn errors<K: Into<String>, V: Into<String>>(
339        errors: impl IntoIterator<Item = (K, V)>,
340    ) -> Self {
341        Self::Errors(
342            errors
343                .into_iter()
344                .map(|(k, v)| (k.into(), v.into()))
345                .collect(),
346        )
347    }
348
349    /// The reply body. `None` for [`Self::Close`].
350    #[must_use]
351    pub fn to_body(&self) -> Option<Value> {
352        let body = match self {
353            Self::Close => return None,
354            Self::Clear => serde_json::json!({"response_action": "clear"}),
355            Self::Update(view) => serde_json::json!({"response_action": "update", "view": view}),
356            Self::Push(view) => serde_json::json!({"response_action": "push", "view": view}),
357            Self::Errors(errors) => {
358                serde_json::json!({"response_action": "errors", "errors": errors})
359            }
360        };
361        Some(body)
362    }
363}
364
365/// Kind of shortcut.
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum ShortcutKind {
368    /// A global shortcut (`shortcut`).
369    Global,
370    /// A message shortcut (`message_action`).
371    Message,
372}
373
374/// A global or message shortcut.
375#[derive(Debug, Clone, PartialEq)]
376#[non_exhaustive]
377pub struct Shortcut {
378    /// Global or message.
379    pub kind: ShortcutKind,
380    /// The shortcut `callback_id`.
381    pub callback_id: String,
382    /// ID to open a modal. Valid for 3 s.
383    pub trigger_id: String,
384    /// User ID.
385    pub user_id: Option<String>,
386    /// Workspace ID.
387    pub team_id: Option<String>,
388    /// Channel ID, for a message shortcut.
389    pub channel_id: Option<String>,
390    /// The source message, for a message shortcut.
391    pub message: Option<Value>,
392    /// URL for replies, for a message shortcut.
393    pub response_url: Option<String>,
394    /// The full payload JSON.
395    pub payload: Value,
396}
397
398/// Interaction payload, split by type.
399#[derive(Debug, Clone, PartialEq)]
400pub(crate) enum Interaction {
401    BlockActions(Vec<BlockAction>),
402    ViewSubmission(ViewPayload),
403    ViewClosed(ViewPayload),
404    Shortcut(Shortcut),
405    /// `block_suggestion`: options load. Not in scope. The reply has no options.
406    BlockSuggestion,
407    /// A type with no support.
408    Other,
409}
410
411impl Interaction {
412    /// Splits an interaction payload. `None` when a needed field is missing.
413    pub(crate) fn parse(payload: Value) -> Option<Self> {
414        let kind = payload.get("type")?.as_str()?.to_owned();
415        let user_id = str_at(&payload, &["user", "id"]);
416        // In an org-wide install, `team` can be null. Then `user.team_id` has the ID.
417        let team_id =
418            str_at(&payload, &["team", "id"]).or_else(|| str_at(&payload, &["user", "team_id"]));
419        let trigger_id = str_at(&payload, &["trigger_id"]);
420        let response_url = str_at(&payload, &["response_url"]);
421        let channel_id = str_at(&payload, &["channel", "id"]);
422        match kind.as_str() {
423            "block_actions" => {
424                let actions = payload.get("actions")?.as_array()?;
425                let message_ts = str_at(&payload, &["container", "message_ts"])
426                    .or_else(|| str_at(&payload, &["message", "ts"]));
427                let list = actions
428                    .iter()
429                    .map(|a| {
430                        Some(BlockAction {
431                            action_id: a.get("action_id")?.as_str()?.to_owned(),
432                            block_id: str_at(a, &["block_id"]),
433                            value: str_at(a, &["value"]),
434                            action: a.clone(),
435                            user_id: user_id.clone(),
436                            team_id: team_id.clone(),
437                            channel_id: channel_id.clone(),
438                            message_ts: message_ts.clone(),
439                            response_url: response_url.clone(),
440                            trigger_id: trigger_id.clone(),
441                            payload: payload.clone(),
442                        })
443                    })
444                    .collect::<Option<Vec<_>>>()?;
445                Some(Self::BlockActions(list))
446            }
447            "view_submission" | "view_closed" => {
448                let view = payload.get("view")?.clone();
449                let v = ViewPayload {
450                    callback_id: str_at(&view, &["callback_id"])?,
451                    view_id: str_at(&view, &["id"]),
452                    private_metadata: str_at(&view, &["private_metadata"]),
453                    view,
454                    user_id,
455                    team_id,
456                    trigger_id,
457                    payload,
458                };
459                Some(if kind == "view_submission" {
460                    Self::ViewSubmission(v)
461                } else {
462                    Self::ViewClosed(v)
463                })
464            }
465            "shortcut" | "message_action" => Some(Self::Shortcut(Shortcut {
466                kind: if kind == "shortcut" {
467                    ShortcutKind::Global
468                } else {
469                    ShortcutKind::Message
470                },
471                callback_id: str_at(&payload, &["callback_id"])?,
472                trigger_id: trigger_id?,
473                user_id,
474                team_id,
475                channel_id,
476                message: payload.get("message").cloned(),
477                response_url,
478                payload,
479            })),
480            "block_suggestion" => Some(Self::BlockSuggestion),
481            _ => Some(Self::Other),
482        }
483    }
484}
485
486/// The string at `path`, if any.
487fn str_at(v: &Value, path: &[&str]) -> Option<String> {
488    let mut cur = v;
489    for key in path {
490        cur = cur.get(key)?;
491    }
492    cur.as_str().map(str::to_owned)
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use serde_json::json;
499
500    #[test]
501    fn message_serializes_only_set_fields() {
502        let m = Message::text("hi").in_thread("1.0").ephemeral();
503        assert_eq!(
504            serde_json::to_value(&m).unwrap(),
505            json!({"text": "hi", "thread_ts": "1.0", "response_type": "ephemeral"})
506        );
507        let m = Message::default().delete_original();
508        assert_eq!(m.to_map().len(), 1);
509        assert_eq!(Message::from(String::from("a")), Message::from("a"));
510    }
511
512    #[test]
513    fn command_response_sets_type() {
514        assert_eq!(CommandResponse::Ack.into_message(), None);
515        let m = CommandResponse::in_channel("x").into_message().unwrap();
516        assert_eq!(m.response_type.as_deref(), Some("in_channel"));
517        let m = CommandResponse::ephemeral(Message::text("y"))
518            .into_message()
519            .unwrap();
520        assert_eq!(m.response_type.as_deref(), Some("ephemeral"));
521    }
522
523    #[test]
524    fn view_response_bodies() {
525        assert_eq!(ViewResponse::Close.to_body(), None);
526        assert_eq!(
527            ViewResponse::Clear.to_body().unwrap(),
528            json!({"response_action": "clear"})
529        );
530        assert_eq!(
531            ViewResponse::Update(json!({"type": "modal"}))
532                .to_body()
533                .unwrap(),
534            json!({"response_action": "update", "view": {"type": "modal"}})
535        );
536        assert_eq!(
537            ViewResponse::errors([("b", "bad")]).to_body().unwrap(),
538            json!({"response_action": "errors", "errors": {"b": "bad"}})
539        );
540    }
541
542    #[test]
543    fn event_callback_parse() {
544        let ev: EventCallback = serde_json::from_value(json!({
545            "event_id": "E1", "event": {"type": "message", "text": "t", "channel": "C"}
546        }))
547        .unwrap();
548        assert_eq!(ev.event_type(), "message");
549        let m: MessageEvent = ev.parse().unwrap();
550        assert_eq!(m.text.as_deref(), Some("t"));
551        let ev2: EventCallback =
552            serde_json::from_value(json!({"event_id": "E2", "event": 5})).unwrap();
553        assert_eq!(ev2.event_type(), "");
554    }
555
556    #[test]
557    fn interaction_parse_kinds() {
558        let p = json!({"type": "block_actions", "actions": [{"action_id": "a"}, {"action_id": "b"}], "user": {"id": "U"}});
559        let Some(Interaction::BlockActions(acts)) = Interaction::parse(p) else {
560            panic!("not block actions")
561        };
562        assert_eq!(acts.len(), 2);
563        assert_eq!(acts[1].action_id, "b");
564        assert_eq!(acts[0].user_id.as_deref(), Some("U"));
565        // Action in a view: no channel, no response_url, container has view.
566        let p = json!({"type": "block_actions", "actions": [{"action_id": "a"}], "container": {"type": "view"}});
567        let Some(Interaction::BlockActions(acts)) = Interaction::parse(p) else {
568            panic!()
569        };
570        assert!(acts[0].response_url.is_none() && acts[0].message_ts.is_none());
571        let p = json!({"type": "view_submission", "view": {"id": "V", "callback_id": "c", "private_metadata": "m"}});
572        let Some(Interaction::ViewSubmission(v)) = Interaction::parse(p) else {
573            panic!()
574        };
575        assert_eq!(
576            (
577                v.callback_id.as_str(),
578                v.view_id.as_deref(),
579                v.private_metadata.as_deref()
580            ),
581            ("c", Some("V"), Some("m"))
582        );
583        assert!(matches!(
584            Interaction::parse(json!({"type": "view_closed", "view": {"callback_id": "c"}})),
585            Some(Interaction::ViewClosed(_))
586        ));
587        assert!(matches!(
588            Interaction::parse(json!({"type": "block_suggestion"})),
589            Some(Interaction::BlockSuggestion)
590        ));
591        assert!(matches!(
592            Interaction::parse(json!({"type": "new_kind"})),
593            Some(Interaction::Other)
594        ));
595        // Org-wide install: `team` is null.
596        let p = json!({"type": "view_closed", "team": null, "user": {"id": "U", "team_id": "T9"}, "view": {"callback_id": "c"}});
597        let Some(Interaction::ViewClosed(v)) = Interaction::parse(p) else {
598            panic!()
599        };
600        assert_eq!(v.team_id.as_deref(), Some("T9"));
601        // Missing fields.
602        assert_eq!(Interaction::parse(json!({"type": "view_submission"})), None);
603        assert_eq!(Interaction::parse(json!({"type": "block_actions"})), None);
604        assert_eq!(
605            Interaction::parse(json!({"type": "shortcut", "callback_id": "x"})),
606            None
607        );
608        assert_eq!(Interaction::parse(json!({"no": "type"})), None);
609        assert_eq!(Interaction::parse(json!([1])), None);
610    }
611
612    #[test]
613    fn slash_command_needs_command() {
614        let c: SlashCommand = serde_urlencoded::from_str("command=%2Fa&text=b").unwrap();
615        assert_eq!((c.command.as_str(), c.text.as_str()), ("/a", "b"));
616        assert!(serde_urlencoded::from_str::<SlashCommand>("text=b").is_err());
617    }
618}