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            && response
81                .client_report
82                .as_ref()
83                .is_none_or(|report| self.kind == InteractionKind::UserAction && report.is_valid())
84            && matches!(
85                (self.kind, &response.response),
86                (
87                    InteractionKind::Approval,
88                    InteractionAnswer::Approve | InteractionAnswer::Reject
89                ) | (
90                    InteractionKind::UserAction,
91                    InteractionAnswer::Continue { .. } | InteractionAnswer::Cancel
92                )
93            )
94    }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
98#[serde(rename_all = "camelCase", deny_unknown_fields)]
99pub struct PendingInteraction {
100    pub request_id: String,
101    pub batch_id: String,
102    pub title: String,
103    pub message: String,
104    pub items: Vec<PendingInteractionItem>,
105    /// Persisted answers. Clients display the first item not present here.
106    /// Answer bodies remain private until the tool produces its final result.
107    pub answered_action_ids: Vec<String>,
108}
109
110/// A response consumes one interaction. It never requests another waiting phase.
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
113pub enum InteractionAnswer {
114    #[serde(deserialize_with = "crate::execution::deserialize_empty_variant")]
115    Approve,
116    #[serde(deserialize_with = "crate::execution::deserialize_empty_variant")]
117    Reject,
118    Continue {
119        data: Value,
120    },
121    #[serde(deserialize_with = "crate::execution::deserialize_empty_variant")]
122    Cancel,
123}
124
125/// Client observations from one Operation, never evidence that its business work succeeded.
126/// Only safe public warning text is accepted; raw Facade details and credentials are excluded.
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128#[serde(deny_unknown_fields)]
129pub struct ClientOperationWarning {
130    pub code: String,
131    pub message: String,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135#[serde(deny_unknown_fields)]
136pub struct ClientOperationReport {
137    pub warnings: Vec<ClientOperationWarning>,
138}
139
140impl ClientOperationReport {
141    pub fn is_valid(&self) -> bool {
142        !self.warnings.is_empty()
143            && self.warnings.iter().all(|warning| {
144                !warning.code.trim().is_empty() && !warning.message.trim().is_empty()
145            })
146    }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
150#[serde(rename_all = "camelCase", deny_unknown_fields)]
151pub struct InteractionResponse {
152    pub action_id: String,
153    pub response: InteractionAnswer,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub client_report: Option<ClientOperationReport>,
156}