Skip to main content

conversation_api/
interaction.rs

1//! One-shot human interactions. Business cards and response validation belong to their owners.
2use serde::{Deserialize, Serialize};
3use serde_json::{Map, Value};
4
5#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
6#[serde(rename_all = "snake_case")]
7pub enum InteractionTone {
8    #[default]
9    Normal,
10    Danger,
11}
12
13/// A registered presentation type and its structured, business-owned data.
14/// `card_type` selects trusted client code; it is never a route, script or callback.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16#[serde(rename_all = "camelCase", deny_unknown_fields)]
17pub struct InteractionCard {
18    #[serde(rename = "type")]
19    pub card_type: String,
20    pub title: String,
21    pub message: String,
22    pub tone: InteractionTone,
23    pub continue_label: String,
24    pub cancel_label: String,
25    pub data: Map<String, Value>,
26}
27
28impl InteractionCard {
29    #[must_use]
30    pub fn new(
31        card_type: impl Into<String>,
32        title: impl Into<String>,
33        message: impl Into<String>,
34    ) -> Self {
35        Self {
36            card_type: card_type.into(),
37            title: title.into(),
38            message: message.into(),
39            tone: InteractionTone::Normal,
40            continue_label: "Continue".into(),
41            cancel_label: "Cancel".into(),
42            data: Map::new(),
43        }
44    }
45
46    #[must_use]
47    pub fn is_valid(&self) -> bool {
48        !self.card_type.is_empty()
49            && self
50                .card_type
51                .bytes()
52                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'.' || c == b'_')
53            && !self.title.trim().is_empty()
54            && !self.continue_label.trim().is_empty()
55            && !self.cancel_label.trim().is_empty()
56    }
57}
58
59#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
60#[serde(rename_all = "snake_case")]
61pub enum InteractionKind {
62    Approval,
63    UserAction,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
67#[serde(rename_all = "camelCase", deny_unknown_fields)]
68pub struct PendingInteractionItem {
69    pub action_id: String,
70    pub kind: InteractionKind,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub expires_at: Option<String>,
73    pub card: InteractionCard,
74}
75
76impl PendingInteractionItem {
77    #[must_use]
78    pub fn accepts(&self, response: &InteractionResponse) -> bool {
79        self.action_id == response.action_id
80            && matches!(
81                (self.kind, &response.response),
82                (
83                    InteractionKind::Approval,
84                    InteractionAnswer::Approve | InteractionAnswer::Reject
85                ) | (
86                    InteractionKind::UserAction,
87                    InteractionAnswer::Continue { .. } | InteractionAnswer::Cancel
88                )
89            )
90    }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
94#[serde(rename_all = "camelCase", deny_unknown_fields)]
95pub struct PendingInteraction {
96    pub request_id: String,
97    pub batch_id: String,
98    pub title: String,
99    pub message: String,
100    pub items: Vec<PendingInteractionItem>,
101}
102
103/// A response consumes one interaction. It never requests another waiting phase.
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
106pub enum InteractionAnswer {
107    #[serde(deserialize_with = "crate::execution::deserialize_empty_variant")]
108    Approve,
109    #[serde(deserialize_with = "crate::execution::deserialize_empty_variant")]
110    Reject,
111    Continue {
112        data: Value,
113    },
114    #[serde(deserialize_with = "crate::execution::deserialize_empty_variant")]
115    Cancel,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
119#[serde(rename_all = "camelCase", deny_unknown_fields)]
120pub struct InteractionResponse {
121    pub action_id: String,
122    pub response: InteractionAnswer,
123}