use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct EventCallback {
#[serde(default)]
pub team_id: Option<String>,
#[serde(default)]
pub api_app_id: Option<String>,
pub event_id: String,
#[serde(default)]
pub event_time: Option<i64>,
pub event: Value,
#[serde(skip)]
pub retry_num: Option<u32>,
#[serde(skip)]
pub retry_reason: Option<String>,
}
impl EventCallback {
#[must_use]
pub fn event_type(&self) -> &str {
self.event.get("type").and_then(Value::as_str).unwrap_or("")
}
pub fn parse<T: serde::de::DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
T::deserialize(&self.event)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct MessageEvent {
#[serde(rename = "type")]
pub kind: String,
pub subtype: Option<String>,
pub channel: Option<String>,
pub channel_type: Option<String>,
pub user: Option<String>,
pub bot_id: Option<String>,
pub text: Option<String>,
pub ts: Option<String>,
pub thread_ts: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct SlashCommand {
pub command: String,
#[serde(default)]
pub text: String,
#[serde(default)]
pub user_id: String,
#[serde(default)]
pub user_name: String,
#[serde(default)]
pub channel_id: String,
#[serde(default)]
pub channel_name: String,
#[serde(default)]
pub team_id: String,
#[serde(default)]
pub team_domain: String,
#[serde(default)]
pub enterprise_id: Option<String>,
#[serde(default)]
pub api_app_id: Option<String>,
#[serde(default)]
pub response_url: String,
#[serde(default)]
pub trigger_id: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Message {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blocks: Option<Vec<Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_ts: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub replace_original: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delete_original: Option<bool>,
}
impl Message {
#[must_use]
pub fn text(text: impl Into<String>) -> Self {
Self {
text: Some(text.into()),
..Self::default()
}
}
#[must_use]
pub fn blocks(mut self, blocks: Vec<Value>) -> Self {
self.blocks = Some(blocks);
self
}
#[must_use]
pub fn in_thread(mut self, ts: impl Into<String>) -> Self {
self.thread_ts = Some(ts.into());
self
}
#[must_use]
pub fn ephemeral(mut self) -> Self {
self.response_type = Some("ephemeral".to_owned());
self
}
#[must_use]
pub fn in_channel(mut self) -> Self {
self.response_type = Some("in_channel".to_owned());
self
}
#[must_use]
pub const fn replace_original(mut self) -> Self {
self.replace_original = Some(true);
self
}
#[must_use]
pub const fn delete_original(mut self) -> Self {
self.delete_original = Some(true);
self
}
pub(crate) fn to_map(&self) -> Map<String, Value> {
match serde_json::to_value(self) {
Ok(Value::Object(m)) => m,
_ => Map::new(),
}
}
}
impl From<&str> for Message {
fn from(text: &str) -> Self {
Self::text(text)
}
}
impl From<String> for Message {
fn from(text: String) -> Self {
Self::text(text)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CommandResponse {
Ack,
Ephemeral(Message),
InChannel(Message),
}
impl CommandResponse {
#[must_use]
pub fn ephemeral(msg: impl Into<Message>) -> Self {
Self::Ephemeral(msg.into())
}
#[must_use]
pub fn in_channel(msg: impl Into<Message>) -> Self {
Self::InChannel(msg.into())
}
#[must_use]
pub fn into_message(self) -> Option<Message> {
match self {
Self::Ack => None,
Self::Ephemeral(m) => Some(m.ephemeral()),
Self::InChannel(m) => Some(m.in_channel()),
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct BlockAction {
pub action_id: String,
pub block_id: Option<String>,
pub value: Option<String>,
pub action: Value,
pub user_id: Option<String>,
pub team_id: Option<String>,
pub channel_id: Option<String>,
pub message_ts: Option<String>,
pub response_url: Option<String>,
pub trigger_id: Option<String>,
pub payload: Value,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ViewPayload {
pub callback_id: String,
pub view_id: Option<String>,
pub private_metadata: Option<String>,
pub view: Value,
pub user_id: Option<String>,
pub team_id: Option<String>,
pub trigger_id: Option<String>,
pub payload: Value,
}
impl ViewPayload {
#[must_use]
pub fn state_values(&self) -> Option<&Value> {
self.view.get("state")?.get("values")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ViewResponse {
Close,
Clear,
Update(Value),
Push(Value),
Errors(BTreeMap<String, String>),
}
impl ViewResponse {
#[must_use]
pub fn errors<K: Into<String>, V: Into<String>>(
errors: impl IntoIterator<Item = (K, V)>,
) -> Self {
Self::Errors(
errors
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
)
}
#[must_use]
pub fn to_body(&self) -> Option<Value> {
let body = match self {
Self::Close => return None,
Self::Clear => serde_json::json!({"response_action": "clear"}),
Self::Update(view) => serde_json::json!({"response_action": "update", "view": view}),
Self::Push(view) => serde_json::json!({"response_action": "push", "view": view}),
Self::Errors(errors) => {
serde_json::json!({"response_action": "errors", "errors": errors})
}
};
Some(body)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShortcutKind {
Global,
Message,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Shortcut {
pub kind: ShortcutKind,
pub callback_id: String,
pub trigger_id: String,
pub user_id: Option<String>,
pub team_id: Option<String>,
pub channel_id: Option<String>,
pub message: Option<Value>,
pub response_url: Option<String>,
pub payload: Value,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Interaction {
BlockActions(Vec<BlockAction>),
ViewSubmission(ViewPayload),
ViewClosed(ViewPayload),
Shortcut(Shortcut),
BlockSuggestion,
Other,
}
impl Interaction {
pub(crate) fn parse(payload: Value) -> Option<Self> {
let kind = payload.get("type")?.as_str()?.to_owned();
let user_id = str_at(&payload, &["user", "id"]);
let team_id =
str_at(&payload, &["team", "id"]).or_else(|| str_at(&payload, &["user", "team_id"]));
let trigger_id = str_at(&payload, &["trigger_id"]);
let response_url = str_at(&payload, &["response_url"]);
let channel_id = str_at(&payload, &["channel", "id"]);
match kind.as_str() {
"block_actions" => {
let actions = payload.get("actions")?.as_array()?;
let message_ts = str_at(&payload, &["container", "message_ts"])
.or_else(|| str_at(&payload, &["message", "ts"]));
let list = actions
.iter()
.map(|a| {
Some(BlockAction {
action_id: a.get("action_id")?.as_str()?.to_owned(),
block_id: str_at(a, &["block_id"]),
value: str_at(a, &["value"]),
action: a.clone(),
user_id: user_id.clone(),
team_id: team_id.clone(),
channel_id: channel_id.clone(),
message_ts: message_ts.clone(),
response_url: response_url.clone(),
trigger_id: trigger_id.clone(),
payload: payload.clone(),
})
})
.collect::<Option<Vec<_>>>()?;
Some(Self::BlockActions(list))
}
"view_submission" | "view_closed" => {
let view = payload.get("view")?.clone();
let v = ViewPayload {
callback_id: str_at(&view, &["callback_id"])?,
view_id: str_at(&view, &["id"]),
private_metadata: str_at(&view, &["private_metadata"]),
view,
user_id,
team_id,
trigger_id,
payload,
};
Some(if kind == "view_submission" {
Self::ViewSubmission(v)
} else {
Self::ViewClosed(v)
})
}
"shortcut" | "message_action" => Some(Self::Shortcut(Shortcut {
kind: if kind == "shortcut" {
ShortcutKind::Global
} else {
ShortcutKind::Message
},
callback_id: str_at(&payload, &["callback_id"])?,
trigger_id: trigger_id?,
user_id,
team_id,
channel_id,
message: payload.get("message").cloned(),
response_url,
payload,
})),
"block_suggestion" => Some(Self::BlockSuggestion),
_ => Some(Self::Other),
}
}
}
fn str_at(v: &Value, path: &[&str]) -> Option<String> {
let mut cur = v;
for key in path {
cur = cur.get(key)?;
}
cur.as_str().map(str::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn message_serializes_only_set_fields() {
let m = Message::text("hi").in_thread("1.0").ephemeral();
assert_eq!(
serde_json::to_value(&m).unwrap(),
json!({"text": "hi", "thread_ts": "1.0", "response_type": "ephemeral"})
);
let m = Message::default().delete_original();
assert_eq!(m.to_map().len(), 1);
assert_eq!(Message::from(String::from("a")), Message::from("a"));
}
#[test]
fn command_response_sets_type() {
assert_eq!(CommandResponse::Ack.into_message(), None);
let m = CommandResponse::in_channel("x").into_message().unwrap();
assert_eq!(m.response_type.as_deref(), Some("in_channel"));
let m = CommandResponse::ephemeral(Message::text("y"))
.into_message()
.unwrap();
assert_eq!(m.response_type.as_deref(), Some("ephemeral"));
}
#[test]
fn view_response_bodies() {
assert_eq!(ViewResponse::Close.to_body(), None);
assert_eq!(
ViewResponse::Clear.to_body().unwrap(),
json!({"response_action": "clear"})
);
assert_eq!(
ViewResponse::Update(json!({"type": "modal"}))
.to_body()
.unwrap(),
json!({"response_action": "update", "view": {"type": "modal"}})
);
assert_eq!(
ViewResponse::errors([("b", "bad")]).to_body().unwrap(),
json!({"response_action": "errors", "errors": {"b": "bad"}})
);
}
#[test]
fn event_callback_parse() {
let ev: EventCallback = serde_json::from_value(json!({
"event_id": "E1", "event": {"type": "message", "text": "t", "channel": "C"}
}))
.unwrap();
assert_eq!(ev.event_type(), "message");
let m: MessageEvent = ev.parse().unwrap();
assert_eq!(m.text.as_deref(), Some("t"));
let ev2: EventCallback =
serde_json::from_value(json!({"event_id": "E2", "event": 5})).unwrap();
assert_eq!(ev2.event_type(), "");
}
#[test]
fn interaction_parse_kinds() {
let p = json!({"type": "block_actions", "actions": [{"action_id": "a"}, {"action_id": "b"}], "user": {"id": "U"}});
let Some(Interaction::BlockActions(acts)) = Interaction::parse(p) else {
panic!("not block actions")
};
assert_eq!(acts.len(), 2);
assert_eq!(acts[1].action_id, "b");
assert_eq!(acts[0].user_id.as_deref(), Some("U"));
let p = json!({"type": "block_actions", "actions": [{"action_id": "a"}], "container": {"type": "view"}});
let Some(Interaction::BlockActions(acts)) = Interaction::parse(p) else {
panic!()
};
assert!(acts[0].response_url.is_none() && acts[0].message_ts.is_none());
let p = json!({"type": "view_submission", "view": {"id": "V", "callback_id": "c", "private_metadata": "m"}});
let Some(Interaction::ViewSubmission(v)) = Interaction::parse(p) else {
panic!()
};
assert_eq!(
(
v.callback_id.as_str(),
v.view_id.as_deref(),
v.private_metadata.as_deref()
),
("c", Some("V"), Some("m"))
);
assert!(matches!(
Interaction::parse(json!({"type": "view_closed", "view": {"callback_id": "c"}})),
Some(Interaction::ViewClosed(_))
));
assert!(matches!(
Interaction::parse(json!({"type": "block_suggestion"})),
Some(Interaction::BlockSuggestion)
));
assert!(matches!(
Interaction::parse(json!({"type": "new_kind"})),
Some(Interaction::Other)
));
let p = json!({"type": "view_closed", "team": null, "user": {"id": "U", "team_id": "T9"}, "view": {"callback_id": "c"}});
let Some(Interaction::ViewClosed(v)) = Interaction::parse(p) else {
panic!()
};
assert_eq!(v.team_id.as_deref(), Some("T9"));
assert_eq!(Interaction::parse(json!({"type": "view_submission"})), None);
assert_eq!(Interaction::parse(json!({"type": "block_actions"})), None);
assert_eq!(
Interaction::parse(json!({"type": "shortcut", "callback_id": "x"})),
None
);
assert_eq!(Interaction::parse(json!({"no": "type"})), None);
assert_eq!(Interaction::parse(json!([1])), None);
}
#[test]
fn slash_command_needs_command() {
let c: SlashCommand = serde_urlencoded::from_str("command=%2Fa&text=b").unwrap();
assert_eq!((c.command.as_str(), c.text.as_str()), ("/a", "b"));
assert!(serde_urlencoded::from_str::<SlashCommand>("text=b").is_err());
}
}