gsm_core/messaging_card/
ir.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::messaging_card::tier::Tier;
7use crate::messaging_card::types::{Action, MessageCard};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct MessageCardIr {
11    pub tier: Tier,
12    pub head: Head,
13    #[serde(default, skip_serializing_if = "Vec::is_empty")]
14    pub elements: Vec<Element>,
15    #[serde(default, skip_serializing_if = "Vec::is_empty")]
16    pub actions: Vec<IrAction>,
17    #[serde(default)]
18    pub meta: Meta,
19}
20
21impl Default for MessageCardIr {
22    fn default() -> Self {
23        Self {
24            tier: Tier::Basic,
25            head: Head::default(),
26            elements: Vec::new(),
27            actions: Vec::new(),
28            meta: Meta::default(),
29        }
30    }
31}
32
33impl MessageCardIr {
34    pub fn from_plain(card: &MessageCard) -> Self {
35        let mut builder = MessageCardIrBuilder::default().tier(Tier::Basic);
36
37        if let Some(title) = &card.title {
38            builder = builder.title(title);
39        }
40        if let Some(text) = &card.text {
41            builder = builder.primary_text(text, card.allow_markdown);
42        }
43        if let Some(footer) = &card.footer {
44            builder = builder.footer(footer);
45        }
46        for image in &card.images {
47            builder = builder.image(image.url.clone(), image.alt.clone());
48        }
49        for action in &card.actions {
50            builder = match action {
51                Action::OpenUrl { title, url } => builder.open_url(title, url),
52                Action::PostBack { title, data } => builder.postback(title, data.clone()),
53            };
54        }
55
56        let mut built = builder.build();
57        built.auto_tier();
58        built
59    }
60
61    pub fn auto_tier(&mut self) {
62        self.tier = self.derive_tier();
63    }
64
65    fn derive_tier(&self) -> Tier {
66        let premium = self
67            .elements
68            .iter()
69            .any(|element| matches!(element, Element::Input { .. }))
70            || self
71                .meta
72                .capabilities
73                .iter()
74                .any(|cap| matches!(cap.as_str(), "inputs" | "execute" | "showcard"));
75        if premium {
76            return Tier::Premium;
77        }
78
79        let advanced = self
80            .elements
81            .iter()
82            .any(|element| matches!(element, Element::Image { .. } | Element::FactSet { .. }))
83            || self
84                .actions
85                .iter()
86                .any(|action| matches!(action, IrAction::Postback { .. }));
87
88        if advanced {
89            Tier::Advanced
90        } else {
91            Tier::Basic
92        }
93    }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
97pub struct Head {
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub title: Option<String>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub text: Option<String>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub footer: Option<String>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
107#[serde(tag = "type", rename_all = "snake_case")]
108pub enum Element {
109    Text {
110        text: String,
111        markdown: bool,
112    },
113    Image {
114        url: String,
115        #[serde(skip_serializing_if = "Option::is_none")]
116        alt: Option<String>,
117    },
118    FactSet {
119        facts: Vec<Fact>,
120    },
121    Input {
122        #[serde(skip_serializing_if = "Option::is_none")]
123        label: Option<String>,
124        kind: InputKind,
125        #[serde(skip_serializing_if = "Option::is_none")]
126        id: Option<String>,
127        required: bool,
128        #[serde(default, skip_serializing_if = "Vec::is_empty")]
129        choices: Vec<InputChoice>,
130    },
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
134pub struct Fact {
135    pub label: String,
136    pub value: String,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
140#[serde(rename_all = "snake_case")]
141pub enum InputKind {
142    Text,
143    Choice,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub struct InputChoice {
148    pub title: String,
149    pub value: String,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
153#[serde(tag = "type", rename_all = "snake_case")]
154pub enum IrAction {
155    OpenUrl { title: String, url: String },
156    Postback { title: String, data: Value },
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
160pub struct Meta {
161    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
162    pub capabilities: BTreeSet<String>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub source: Option<String>,
165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166    pub warnings: Vec<String>,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub adaptive_payload: Option<Value>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub app_link: Option<AppLink>,
171}
172
173impl Meta {
174    pub fn add_capability(&mut self, cap: impl Into<String>) {
175        self.capabilities.insert(cap.into());
176    }
177
178    pub fn warn(&mut self, message: impl Into<String>) {
179        self.warnings.push(message.into());
180    }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
184pub struct AppLink {
185    pub base_url: String,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub secret: Option<String>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub tenant: Option<String>,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub scope: Option<String>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub state: Option<Value>,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub jwt: Option<AppLinkJwt>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
199pub struct AppLinkJwt {
200    pub secret: String,
201    #[serde(default = "default_app_link_jwt_algorithm")]
202    pub algorithm: String,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub audience: Option<String>,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub issuer: Option<String>,
207    #[serde(default = "default_app_link_jwt_ttl")]
208    pub ttl_seconds: u64,
209}
210
211fn default_app_link_jwt_algorithm() -> String {
212    "HS256".into()
213}
214
215fn default_app_link_jwt_ttl() -> u64 {
216    900
217}
218
219#[derive(Debug, Default)]
220pub struct MessageCardIrBuilder {
221    inner: MessageCardIr,
222}
223
224impl MessageCardIrBuilder {
225    pub fn tier(mut self, tier: Tier) -> Self {
226        self.inner.tier = tier;
227        self
228    }
229
230    pub fn title(mut self, title: &str) -> Self {
231        self.inner.head.title = Some(title.to_string());
232        self
233    }
234
235    pub fn primary_text(mut self, text: &str, markdown: bool) -> Self {
236        self.inner.head.text = Some(text.to_string());
237        self.inner.elements.push(Element::Text {
238            text: text.into(),
239            markdown,
240        });
241        self
242    }
243
244    pub fn footer(mut self, footer: &str) -> Self {
245        self.inner.head.footer = Some(footer.to_string());
246        self
247    }
248
249    pub fn image(mut self, url: String, alt: Option<String>) -> Self {
250        self.inner.elements.push(Element::Image { url, alt });
251        self
252    }
253
254    pub fn fact(mut self, label: &str, value: &str) -> Self {
255        self.inner.elements.push(Element::FactSet {
256            facts: vec![Fact {
257                label: label.into(),
258                value: value.into(),
259            }],
260        });
261        self
262    }
263
264    pub fn input(
265        mut self,
266        label: Option<String>,
267        kind: InputKind,
268        id: Option<String>,
269        choices: Vec<InputChoice>,
270    ) -> Self {
271        self.inner.elements.push(Element::Input {
272            label,
273            kind,
274            id,
275            required: false,
276            choices,
277        });
278        self
279    }
280
281    pub fn open_url(mut self, title: &str, url: &str) -> Self {
282        self.inner.actions.push(IrAction::OpenUrl {
283            title: title.into(),
284            url: url.into(),
285        });
286        self
287    }
288
289    pub fn postback(mut self, title: &str, data: Value) -> Self {
290        self.inner.actions.push(IrAction::Postback {
291            title: title.into(),
292            data,
293        });
294        self
295    }
296
297    pub fn build(self) -> MessageCardIr {
298        self.inner
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::messaging_card::types::{Action, ImageRef, MessageCard};
306    use serde_json::json;
307
308    #[test]
309    fn builder_translates_plain_card() {
310        let card = MessageCard {
311            title: Some("IR".into()),
312            text: Some("hello".into()),
313            footer: Some("footer".into()),
314            images: vec![ImageRef {
315                url: "https://example.com/img.png".into(),
316                alt: Some("img".into()),
317            }],
318            actions: vec![
319                Action::OpenUrl {
320                    title: "view".into(),
321                    url: "https://example.com".into(),
322                },
323                Action::PostBack {
324                    title: "ack".into(),
325                    data: json!({"ok": true}),
326                },
327            ],
328            ..Default::default()
329        };
330
331        let ir = MessageCardIr::from_plain(&card);
332        assert_eq!(ir.head.title, Some("IR".into()));
333        assert_eq!(ir.elements.len(), 2);
334        assert_eq!(ir.actions.len(), 2);
335        assert_eq!(ir.tier, Tier::Advanced);
336    }
337}