Skip to main content

xmtp_content_types/
actions.rs

1use std::collections::HashSet;
2
3use crate::{CodecError, ContentCodec};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use xmtp_proto::xmtp::mls::message_contents::{ContentTypeId, EncodedContent};
7
8const EXPIRY_RANGE_ERROR: &str = "Actions expiry is outside the signed 64-bit nanosecond range.";
9
10const NANOS_PER_MILLISECOND: i64 = 1_000_000;
11
12const UTC_MILLIS_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.3fZ";
13
14mod datetime_utc_millis_option {
15    use super::*;
16
17    pub fn serialize<S>(date: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
18    where
19        S: Serializer,
20    {
21        match date {
22            Some(dt) => serializer.serialize_some(&dt.format(UTC_MILLIS_FORMAT).to_string()),
23            None => serializer.serialize_none(),
24        }
25    }
26
27    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
28    where
29        D: Deserializer<'de>,
30    {
31        let opt: Option<String> = Option::deserialize(deserializer)?;
32        match opt {
33            Some(s) => DateTime::parse_from_rfc3339(&s)
34                .map(|dt| Some(dt.with_timezone(&Utc)))
35                .map_err(serde::de::Error::custom),
36            None => Ok(None),
37        }
38    }
39}
40
41pub struct ActionsCodec;
42impl ActionsCodec {
43    const AUTHORITY_ID: &str = "coinbase.com";
44    pub const TYPE_ID: &str = "actions";
45    pub const MAJOR_VERSION: u32 = 1;
46    pub const MINOR_VERSION: u32 = 0;
47}
48
49impl ActionsCodec {
50    fn fallback(content: &Actions) -> Option<String> {
51        let action_list = content
52            .actions
53            .iter()
54            .enumerate()
55            .map(|(i, a)| format!("[{}] {}", i + 1, a.label))
56            .collect::<Vec<_>>()
57            .join("\n");
58
59        Some(format!(
60            "{}\n\n{}\n\nReply with the number to select",
61            content.description, action_list
62        ))
63    }
64}
65
66impl ContentCodec<Actions> for ActionsCodec {
67    fn content_type() -> ContentTypeId {
68        ContentTypeId {
69            authority_id: Self::AUTHORITY_ID.to_string(),
70            type_id: Self::TYPE_ID.to_string(),
71            version_major: Self::MAJOR_VERSION,
72            version_minor: Self::MINOR_VERSION,
73        }
74    }
75
76    // implements: CTYPE-019, CTYPE-030
77    fn encode(actions: Actions) -> Result<EncodedContent, CodecError> {
78        if !actions.expiry_is_representable()
79            || actions.expiries().any(|expiry| {
80                expiry
81                    .timestamp_millis()
82                    .checked_mul(NANOS_PER_MILLISECOND)
83                    .is_none()
84            })
85        {
86            return Err(CodecError::Encode(EXPIRY_RANGE_ERROR.into()));
87        }
88        if actions.actions.is_empty() {
89            return Err(CodecError::Encode(
90                "Actions must contain at least one action.".to_string(),
91            ));
92        }
93        if actions.actions.len() > 10 {
94            return Err(CodecError::Encode(
95                "Actions cannot exceed 10 actions for UX reasons.".to_string(),
96            ));
97        }
98
99        if actions
100            .actions
101            .iter()
102            .map(|a| &a.id)
103            .collect::<HashSet<_>>()
104            .len()
105            != actions.actions.len()
106        {
107            return Err(CodecError::Encode("Action ids must be unique.".to_string()));
108        }
109
110        Ok(EncodedContent {
111            r#type: Some(Self::content_type()),
112            content: serde_json::to_vec(&actions)
113                .map_err(|e| CodecError::Encode(format!("Unable to serialize actions. {e:?}")))?,
114            fallback: Self::fallback(&actions),
115            ..Default::default()
116        })
117    }
118
119    // implements: CTYPE-030
120    fn decode(actions: EncodedContent) -> Result<Actions, CodecError> {
121        let actions: Actions = serde_json::from_slice(&actions.content)
122            .map_err(|e| CodecError::Decode(format!("Unable to deserialize actions. {e:?}")))?;
123
124        if !actions.expiry_is_representable() {
125            return Err(CodecError::Decode(EXPIRY_RANGE_ERROR.into()));
126        }
127        Ok(actions)
128    }
129
130    fn should_push() -> bool {
131        true
132    }
133}
134
135#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
136pub struct Actions {
137    pub id: String,
138    pub description: String,
139    pub actions: Vec<Action>,
140    #[serde(
141        default,
142        alias = "expires_at",
143        rename = "expiresAt",
144        skip_serializing_if = "Option::is_none",
145        with = "datetime_utc_millis_option"
146    )]
147    pub expires_at: Option<DateTime<Utc>>,
148}
149
150impl Actions {
151    fn expiry_is_representable(&self) -> bool {
152        self.expiries()
153            .all(|expiry| expiry.timestamp_nanos_opt().is_some())
154    }
155
156    fn expiries(&self) -> impl Iterator<Item = &DateTime<Utc>> {
157        self.expires_at.iter().chain(
158            self.actions
159                .iter()
160                .filter_map(|action| action.expires_at.as_ref()),
161        )
162    }
163}
164
165#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
166pub struct Action {
167    pub id: String,
168    pub label: String,
169    #[serde(
170        alias = "image_url",
171        rename = "imageUrl",
172        skip_serializing_if = "Option::is_none"
173    )]
174    pub image_url: Option<String>,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub style: Option<ActionStyle>,
177    #[serde(
178        default,
179        alias = "expires_at",
180        rename = "expiresAt",
181        skip_serializing_if = "Option::is_none",
182        with = "datetime_utc_millis_option"
183    )]
184    pub expires_at: Option<DateTime<Utc>>,
185}
186
187#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
188#[serde(rename_all = "lowercase")]
189pub enum ActionStyle {
190    Primary,
191    Secondary,
192    Danger,
193}
194
195#[cfg(test)]
196mod tests {
197    use super::{Action, ActionStyle, Actions, ActionsCodec};
198    use crate::{CodecError, ContentCodec};
199    use chrono::{DateTime, TimeZone, Utc};
200    use xmtp_proto::xmtp::mls::message_contents::EncodedContent;
201
202    #[xmtp_common::test(unwrap_try = true)]
203    // verifies: CTYPE-007, CTYPE-019
204    fn encode_decode_actions() {
205        let mut actions = Actions {
206            id: "thanksgiving_selection".to_string(),
207            description: "Grandma is asking for your input on Thanksgiving".to_string(),
208            actions: vec![
209                Action {
210                    id: "the_turkey_of_course".to_string(),
211                    label: "The Turkey (of course)".to_string(),
212                    image_url: Some("http://turkey-images.biz/the-one.jpg".to_string()),
213                    style: Some(ActionStyle::Primary),
214                    expires_at: None,
215                },
216                Action {
217                    id: "pork_loin".to_string(),
218                    label: "Pork Loin".to_string(),
219                    image_url: None,
220                    style: None,
221                    expires_at: Some(Utc.with_ymd_and_hms(2025, 1, 15, 12, 30, 45).unwrap()),
222                },
223            ],
224            expires_at: Some(Utc.with_ymd_and_hms(2025, 12, 31, 23, 59, 59).unwrap()),
225        };
226
227        let encoded = ActionsCodec::encode(actions.clone())?;
228        assert!(
229            encoded
230                .fallback()
231                .contains("[1] The Turkey (of course)\n[2] Pork Loin"),
232        );
233        let decoded = ActionsCodec::decode(encoded)?;
234
235        assert_eq!(decoded, actions);
236
237        actions.actions.push(Action {
238            id: "pork_loin".to_string(),
239            label: "More Pork Loin".to_string(),
240            image_url: None,
241            style: None,
242            expires_at: None,
243        });
244
245        let encoded_result = ActionsCodec::encode(actions);
246        let Err(CodecError::Encode(reason)) = encoded_result else {
247            panic!("Expected an uniqueness encoding error.");
248        };
249        assert!(reason.contains("unique"));
250    }
251
252    #[xmtp_common::test(unwrap_try = true)]
253    // verifies: CTYPE-019
254    fn expires_at_serializes_as_utc_with_millis() {
255        let actions = Actions {
256            id: "test".to_string(),
257            description: "Test".to_string(),
258            actions: vec![Action {
259                id: "action1".to_string(),
260                label: "Action 1".to_string(),
261                image_url: None,
262                style: None,
263                expires_at: Some(Utc.with_ymd_and_hms(2025, 6, 15, 10, 30, 45).unwrap()),
264            }],
265            expires_at: Some(Utc.with_ymd_and_hms(2025, 12, 25, 23, 59, 59).unwrap()),
266        };
267
268        let encoded = ActionsCodec::encode(actions)?;
269        let json = String::from_utf8(encoded.content.clone())?;
270
271        // Verify format includes milliseconds (.000) and UTC suffix (Z)
272        assert!(
273            json.contains("2025-06-15T10:30:45.000Z"),
274            "Action expires_at should be formatted with milliseconds and UTC: {json}"
275        );
276        assert!(
277            json.contains("2025-12-25T23:59:59.000Z"),
278            "Actions expires_at should be formatted with milliseconds and UTC: {json}"
279        );
280    }
281
282    // verifies: CTYPE-030
283    #[xmtp_common::test(unwrap_try = true)]
284    fn actions_expiry_checks_both_fields_and_range_limits() {
285        let sample = || Actions {
286            id: "choice".into(),
287            description: "Choose".into(),
288            actions: vec![Action {
289                id: "one".into(),
290                label: "One".into(),
291                image_url: None,
292                style: None,
293                expires_at: None,
294            }],
295            expires_at: None,
296        };
297        // Encode uses milliseconds. These are the nearest valid whole milliseconds.
298        for timestamp in ["1677-09-21T00:12:43.146Z", "2262-04-11T23:47:16.854Z"] {
299            for nested in [false, true] {
300                let mut actions = sample();
301                let expiry = DateTime::parse_from_rfc3339(timestamp)?.with_timezone(&Utc);
302                if nested {
303                    actions.actions[0].expires_at = Some(expiry);
304                } else {
305                    actions.expires_at = Some(expiry);
306                }
307                assert_eq!(
308                    ActionsCodec::decode(ActionsCodec::encode(actions.clone())?)?,
309                    actions
310                );
311            }
312        }
313        for (timestamp, valid) in [
314            ("1677-09-21T00:12:43.145224192Z", true),
315            ("2262-04-11T23:47:16.854775807Z", true),
316            ("1677-09-21T00:12:43.145224191Z", false),
317            ("2262-04-11T23:47:16.854775808Z", false),
318        ] {
319            for nested in [false, true] {
320                let mut actions = sample();
321                let expiry = DateTime::parse_from_rfc3339(timestamp)?.with_timezone(&Utc);
322                if nested {
323                    actions.actions[0].expires_at = Some(expiry);
324                } else {
325                    actions.expires_at = Some(expiry);
326                }
327                let wire = EncodedContent {
328                    r#type: Some(ActionsCodec::content_type()),
329                    content: serde_json::to_vec(&actions)?,
330                    ..Default::default()
331                };
332                // Keep nanosecond precision in received bytes, independent of the encoder.
333                let mut json: serde_json::Value = serde_json::from_slice(&wire.content)?;
334                if nested {
335                    json["actions"][0]["expiresAt"] = timestamp.into();
336                } else {
337                    json["expiresAt"] = timestamp.into();
338                }
339                let result = ActionsCodec::decode(EncodedContent {
340                    content: serde_json::to_vec(&json)?,
341                    ..wire
342                });
343                if valid {
344                    assert_eq!(result?, actions.clone());
345                    if timestamp.starts_with("1677") {
346                        // Millisecond truncation moves this expiry below the wire range.
347                        assert!(matches!(
348                            ActionsCodec::encode(actions),
349                            Err(CodecError::Encode(_))
350                        ));
351                    } else {
352                        assert!(ActionsCodec::decode(ActionsCodec::encode(actions)?).is_ok());
353                    }
354                } else {
355                    assert!(matches!(result, Err(CodecError::Decode(_))), "{timestamp}");
356                    assert!(
357                        matches!(ActionsCodec::encode(actions), Err(CodecError::Encode(_))),
358                        "{timestamp}"
359                    );
360                }
361            }
362        }
363    }
364
365    // verifies: CTYPE-030
366    #[xmtp_common::test(unwrap_try = true)]
367    fn last_action_expiry_is_checked() {
368        for timestamp in [
369            "1677-09-21T00:12:43.145224191Z",
370            "2262-04-11T23:47:16.854775808Z",
371        ] {
372            let expiry = DateTime::parse_from_rfc3339(timestamp)?.with_timezone(&Utc);
373            let actions = Actions {
374                id: "choices".into(),
375                description: "Choose".into(),
376                actions: vec![
377                    Action {
378                        id: "one".into(),
379                        label: "One".into(),
380                        image_url: None,
381                        style: None,
382                        expires_at: None,
383                    },
384                    Action {
385                        id: "two".into(),
386                        label: "Two".into(),
387                        image_url: None,
388                        style: None,
389                        expires_at: Some(expiry),
390                    },
391                ],
392                expires_at: None,
393            };
394            assert!(matches!(
395                ActionsCodec::encode(actions.clone()),
396                Err(CodecError::Encode(_))
397            ));
398            let mut json = serde_json::to_value(actions)?;
399            json["actions"][1]["expiresAt"] = timestamp.into();
400            assert!(matches!(
401                ActionsCodec::decode(EncodedContent {
402                    r#type: Some(ActionsCodec::content_type()),
403                    content: serde_json::to_vec(&json)?,
404                    ..Default::default()
405                }),
406                Err(CodecError::Decode(_))
407            ));
408        }
409    }
410}