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 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 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 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 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 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 #[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 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 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 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 #[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}