Skip to main content

conversation_api/
interaction.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4#[serde(tag = "type", rename_all = "snake_case")]
5#[serde(deny_unknown_fields)]
6pub enum PreviewValue {
7    Text { text: String },
8    TextList { items: Vec<String> },
9    Number { value: f64 },
10    Bool { value: bool },
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "camelCase")]
15#[serde(deny_unknown_fields)]
16pub struct PreviewDetail {
17    pub label: String,
18    pub value: PreviewValue,
19}
20
21#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(rename_all = "snake_case")]
23pub enum InteractionTone {
24    #[default]
25    Normal,
26    Danger,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[serde(
31    tag = "type",
32    rename_all = "snake_case",
33    rename_all_fields = "camelCase"
34)]
35#[serde(deny_unknown_fields)]
36pub enum ClientTask {
37    ConnectIntegration {
38        integration_id: String,
39        session_id: String,
40    },
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
44#[serde(rename_all = "camelCase")]
45#[serde(deny_unknown_fields)]
46pub struct InteractionPreview {
47    pub action_code: String,
48    pub title: String,
49    pub message: String,
50    #[serde(default)]
51    pub tone: InteractionTone,
52    #[serde(default)]
53    pub details: Vec<PreviewDetail>,
54    pub proceed_label: String,
55    pub reject_label: String,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub client_task: Option<ClientTask>,
58}
59
60#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(rename_all = "snake_case")]
62pub enum InteractionKind {
63    #[default]
64    Approval,
65    UserAction,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69#[serde(rename_all = "camelCase")]
70pub struct PendingInteractionItem {
71    pub action_id: String,
72    pub kind: InteractionKind,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub expires_at: Option<String>,
75    pub preview: InteractionPreview,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79#[serde(rename_all = "camelCase")]
80pub struct PendingInteraction {
81    pub request_id: String,
82    pub batch_id: String,
83    pub title: String,
84    pub message: String,
85    pub items: Vec<PendingInteractionItem>,
86}
87
88#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "snake_case")]
90pub enum InteractionDecisionValue {
91    Approve,
92    Reject,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(rename_all = "camelCase", deny_unknown_fields)]
97pub struct InteractionDecision {
98    pub action_id: String,
99    pub decision: InteractionDecisionValue,
100}
101
102impl Default for InteractionPreview {
103    fn default() -> Self {
104        Self::confirmation(
105            "interaction",
106            "Review action",
107            "Review this action before continuing.",
108        )
109    }
110}
111
112impl InteractionPreview {
113    #[must_use]
114    pub fn confirmation(
115        action_code: impl Into<String>,
116        title: impl Into<String>,
117        message: impl Into<String>,
118    ) -> Self {
119        Self {
120            action_code: action_code.into(),
121            title: title.into(),
122            message: message.into(),
123            tone: InteractionTone::Normal,
124            details: Vec::new(),
125            proceed_label: "Continue".to_owned(),
126            reject_label: "Cancel".to_owned(),
127            client_task: None,
128        }
129    }
130
131    /// Returns whether the facade supplied a complete display-only interaction contract.
132    #[must_use]
133    pub fn is_valid(&self) -> bool {
134        !self.action_code.trim().is_empty()
135            && !self.title.trim().is_empty()
136            && !self.proceed_label.trim().is_empty()
137            && !self.reject_label.trim().is_empty()
138            && self.details.iter().all(|detail| {
139                !detail.label.trim().is_empty()
140                    && match &detail.value {
141                        PreviewValue::Number { value } => value.is_finite(),
142                        PreviewValue::TextList { items } => {
143                            items.iter().all(|item| !item.trim().is_empty())
144                        }
145                        PreviewValue::Text { .. } | PreviewValue::Bool { .. } => true,
146                    }
147            })
148            && match &self.client_task {
149                Some(ClientTask::ConnectIntegration {
150                    integration_id,
151                    session_id,
152                }) => !integration_id.trim().is_empty() && !session_id.trim().is_empty(),
153                None => true,
154            }
155    }
156}