Skip to main content

im_core/messages/
mention.rs

1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6const RANGE_UNIT_UNICODE_CODE_POINT: &str = "unicode_code_point";
7const FORBIDDEN_MENTION_FIELDS: &[&str] = &[
8    "sender",
9    "sender_did",
10    "from",
11    "actor_did",
12    "auth",
13    "origin_proof",
14    "proof",
15    "signature",
16];
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct MessageMentionPayload {
20    pub text: String,
21    pub mentions: Vec<MessageMention>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub annotations: Option<Value>,
24}
25
26impl MessageMentionPayload {
27    pub fn from_value(value: &Value) -> crate::ImResult<Self> {
28        validate_message_mention_payload_shape(value)?;
29        let payload: Self = serde_json::from_value(value.clone()).map_err(|err| {
30            crate::ImError::invalid_input(
31                Some("payload".to_owned()),
32                format!("invalid ANP P9 mention payload: {err}"),
33            )
34        })?;
35        payload.validate()?;
36        Ok(payload)
37    }
38
39    pub fn to_value(&self) -> crate::ImResult<Value> {
40        self.validate()?;
41        serde_json::to_value(self).map_err(|err| crate::ImError::Serialization {
42            detail: err.to_string(),
43        })
44    }
45
46    pub fn validate(&self) -> crate::ImResult<()> {
47        if self.text.is_empty() {
48            return invalid_mention_payload("text must not be empty");
49        }
50        if self.mentions.is_empty() {
51            return invalid_mention_payload("mentions must not be empty");
52        }
53        let code_points = self.text.chars().count();
54        let mut ids = HashSet::new();
55        for mention in &self.mentions {
56            mention.validate(code_points)?;
57            if !ids.insert(mention.id.as_str()) {
58                return invalid_mention_payload(format!("duplicate mention id: {}", mention.id));
59            }
60        }
61        Ok(())
62    }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct MessageMention {
67    pub id: String,
68    pub range: MessageMentionRange,
69    pub target: MessageMentionTarget,
70    #[serde(default = "default_mention_role")]
71    pub mention_role: MessageMentionRole,
72}
73
74impl MessageMention {
75    fn validate(&self, text_code_points: usize) -> crate::ImResult<()> {
76        if self.id.trim().is_empty() {
77            return invalid_mention_payload("mention id must not be empty");
78        }
79        self.range.validate(text_code_points)?;
80        self.target.validate()?;
81        Ok(())
82    }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct MessageMentionRange {
87    pub start: usize,
88    pub end: usize,
89    pub unit: MessageMentionRangeUnit,
90}
91
92impl MessageMentionRange {
93    fn validate(&self, text_code_points: usize) -> crate::ImResult<()> {
94        if self.start >= self.end {
95            return invalid_mention_payload("mention range start must be less than end");
96        }
97        if self.end > text_code_points {
98            return invalid_mention_payload("mention range end exceeds text length");
99        }
100        if self.unit != MessageMentionRangeUnit::UnicodeCodePoint {
101            return invalid_mention_payload("mention range unit must be unicode_code_point");
102        }
103        Ok(())
104    }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum MessageMentionRangeUnit {
110    UnicodeCodePoint,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(tag = "kind", rename_all = "snake_case")]
115pub enum MessageMentionTarget {
116    Human {
117        did: String,
118        #[serde(default, skip_serializing_if = "Option::is_none")]
119        display_name: Option<String>,
120    },
121    Agent {
122        did: String,
123        #[serde(default, skip_serializing_if = "Option::is_none")]
124        display_name: Option<String>,
125    },
126    GroupSelector {
127        selector: MessageMentionSelector,
128    },
129}
130
131impl MessageMentionTarget {
132    fn validate(&self) -> crate::ImResult<()> {
133        match self {
134            Self::Human { did, .. } | Self::Agent { did, .. } => {
135                if !looks_like_did(did) {
136                    return invalid_mention_payload("human/agent mention target did must be a DID");
137                }
138                Ok(())
139            }
140            Self::GroupSelector { .. } => Ok(()),
141        }
142    }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum MessageMentionSelector {
148    All,
149    Agents,
150    Humans,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum MessageMentionRole {
156    Addressee,
157    Cc,
158}
159
160fn default_mention_role() -> MessageMentionRole {
161    MessageMentionRole::Addressee
162}
163
164pub fn parse_message_mention_payload(value: &Value) -> crate::ImResult<MessageMentionPayload> {
165    MessageMentionPayload::from_value(value)
166}
167
168pub fn validate_message_mention_payload(value: &Value) -> crate::ImResult<()> {
169    MessageMentionPayload::from_value(value).map(|_| ())
170}
171
172pub fn is_message_mention_payload(value: &Value) -> bool {
173    value
174        .as_object()
175        .and_then(|object| object.get("mentions"))
176        .is_some_and(Value::is_array)
177}
178
179fn validate_message_mention_payload_shape(value: &Value) -> crate::ImResult<()> {
180    let object = value.as_object().ok_or_else(|| {
181        crate::ImError::invalid_input(
182            Some("payload".to_owned()),
183            "ANP P9 mention payload must be a JSON object".to_owned(),
184        )
185    })?;
186    match object.get("text") {
187        Some(Value::String(_)) => {}
188        _ => return invalid_mention_payload("mention payload text must be a string"),
189    }
190    let mentions = match object.get("mentions") {
191        Some(Value::Array(mentions)) => mentions,
192        _ => return invalid_mention_payload("mention payload mentions must be an array"),
193    };
194    if let Some(annotations) = object.get("annotations") {
195        if !annotations.is_object() {
196            return invalid_mention_payload("mention payload annotations must be an object");
197        }
198    }
199    for (index, mention) in mentions.iter().enumerate() {
200        validate_raw_mention(index, mention)?;
201    }
202    Ok(())
203}
204
205fn validate_raw_mention(index: usize, mention: &Value) -> crate::ImResult<()> {
206    let object = mention.as_object().ok_or_else(|| {
207        crate::ImError::invalid_input(
208            Some(format!("mentions[{index}]")),
209            "mention must be a JSON object".to_owned(),
210        )
211    })?;
212    for field in FORBIDDEN_MENTION_FIELDS {
213        if object.contains_key(*field) {
214            return invalid_mention_payload(format!(
215                "mention must not contain forbidden field `{field}`"
216            ));
217        }
218    }
219    let target = object
220        .get("target")
221        .and_then(Value::as_object)
222        .ok_or_else(|| {
223            crate::ImError::invalid_input(
224                Some(format!("mentions[{index}].target")),
225                "mention target must be an object".to_owned(),
226            )
227        })?;
228    match target.get("kind").and_then(Value::as_str) {
229        Some("human") | Some("agent") => {
230            if target.get("selector").is_some() {
231                return invalid_mention_payload(
232                    "human/agent mention target must not contain selector",
233                );
234            }
235        }
236        Some("group_selector") => {
237            if target.get("did").is_some() {
238                return invalid_mention_payload(
239                    "group selector mention target must not contain did",
240                );
241            }
242        }
243        Some(_) => return invalid_mention_payload("unsupported mention target kind"),
244        None => return invalid_mention_payload("mention target kind must be present"),
245    }
246    Ok(())
247}
248
249fn looks_like_did(value: &str) -> bool {
250    let trimmed = value.trim();
251    trimmed.starts_with("did:") && trimmed.len() > "did:".len()
252}
253
254fn invalid_mention_payload<T>(message: impl Into<String>) -> crate::ImResult<T> {
255    Err(crate::ImError::invalid_input(
256        Some("mentions".to_owned()),
257        message.into(),
258    ))
259}