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}