1use thiserror::Error;
26
27use crate::event::{
28 Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind, Tag, TagKind,
29};
30use crate::key::{PublicKey, PublicKeyError};
31use crate::types::{RelayUrl, RelayUrlError};
32
33#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35#[non_exhaustive]
36pub enum CommentScope {
37 Event {
39 id: EventId,
41 relay_hint: Option<RelayUrl>,
43 },
44 Address {
46 coordinate: Coordinate,
48 relay_hint: Option<RelayUrl>,
50 },
51 External {
53 value: String,
55 context: Option<String>,
57 },
58}
59
60impl CommentScope {
61 #[must_use]
63 pub const fn event(id: EventId) -> Self {
64 Self::Event {
65 id,
66 relay_hint: None,
67 }
68 }
69
70 #[must_use]
72 pub const fn address(coordinate: Coordinate) -> Self {
73 Self::Address {
74 coordinate,
75 relay_hint: None,
76 }
77 }
78
79 #[must_use]
81 pub fn external(value: impl Into<String>) -> Self {
82 Self::External {
83 value: value.into(),
84 context: None,
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct Comment {
112 pub root: CommentScope,
114 pub root_kind: Option<Kind>,
116 pub root_author: Option<PublicKey>,
118 pub parent: CommentScope,
120 pub parent_kind: Option<Kind>,
122 pub parent_author: Option<PublicKey>,
124 pub content: String,
126}
127
128impl Comment {
129 #[must_use]
131 pub fn top_level(root: CommentScope, content: impl Into<String>) -> Self {
132 Self {
133 parent: root.clone(),
134 parent_kind: None,
135 parent_author: None,
136 root,
137 root_kind: None,
138 root_author: None,
139 content: content.into(),
140 }
141 }
142
143 #[must_use]
145 pub const fn with_root_kind(mut self, kind: Kind) -> Self {
146 self.root_kind = Some(kind);
147 self
148 }
149
150 #[must_use]
152 pub const fn with_root_author(mut self, author: PublicKey) -> Self {
153 self.root_author = Some(author);
154 self
155 }
156
157 #[must_use]
159 pub const fn with_parent_kind(mut self, kind: Kind) -> Self {
160 self.parent_kind = Some(kind);
161 self
162 }
163
164 #[must_use]
166 pub const fn with_parent_author(mut self, author: PublicKey) -> Self {
167 self.parent_author = Some(author);
168 self
169 }
170
171 #[must_use]
174 pub fn with_parent(mut self, parent: CommentScope) -> Self {
175 self.parent = parent;
176 self
177 }
178
179 #[must_use]
182 pub fn to_tags(&self) -> Vec<Tag> {
183 let mut tags = Vec::new();
184 push_scope_tags(&mut tags, &self.root, true);
185 if let Some(k) = self.root_kind {
186 tags.push(Tag::with(
187 &TagKind::from_wire("K"),
188 [k.as_u16().to_string()],
189 ));
190 }
191 if let Some(p) = self.root_author {
192 tags.push(Tag::with(&TagKind::from_wire("P"), [p.to_hex()]));
193 }
194 push_scope_tags(&mut tags, &self.parent, false);
195 if let Some(k) = self.parent_kind {
196 tags.push(Tag::with(
197 &TagKind::from_wire("k"),
198 [k.as_u16().to_string()],
199 ));
200 }
201 if let Some(p) = self.parent_author {
202 tags.push(Tag::with(&TagKind::from_wire("p"), [p.to_hex()]));
203 }
204 tags
205 }
206
207 pub fn from_event(event: &Event) -> Result<Self, CommentError> {
215 if event.kind != Kind::from(1111_u16) {
216 return Err(CommentError::UnexpectedKind(event.kind.as_u16()));
217 }
218
219 let mut root: Option<CommentScope> = None;
220 let mut parent: Option<CommentScope> = None;
221 let mut root_kind: Option<Kind> = None;
222 let mut parent_kind: Option<Kind> = None;
223 let mut root_author: Option<PublicKey> = None;
224 let mut parent_author: Option<PublicKey> = None;
225
226 for tag in &event.tags {
227 let head = tag.kind().as_str().to_owned();
228 match head.as_str() {
229 "E" => root = Some(parse_event_scope(tag)?),
230 "A" => root = Some(parse_address_scope(tag)?),
231 "I" => root = Some(parse_external_scope(tag)?),
232 "K" => root_kind = Some(parse_kind(tag, "K")?),
233 "P" => root_author = Some(parse_pubkey(tag, "P")?),
234 "e" => parent = Some(parse_event_scope(tag)?),
235 "a" => parent = Some(parse_address_scope(tag)?),
236 "i" => parent = Some(parse_external_scope(tag)?),
237 "k" => parent_kind = Some(parse_kind(tag, "k")?),
238 "p" => parent_author = Some(parse_pubkey(tag, "p")?),
239 _ => {} }
241 }
242
243 Ok(Self {
244 root: root.ok_or(CommentError::MissingRoot)?,
245 root_kind,
246 root_author,
247 parent: parent.ok_or(CommentError::MissingParent)?,
248 parent_kind,
249 parent_author,
250 content: event.content.clone(),
251 })
252 }
253}
254
255impl EventBuilder {
256 #[must_use]
258 pub fn comment(comment: &Comment) -> Self {
259 Self::new(Kind::from(1111_u16), comment.content.clone()).tags(comment.to_tags())
260 }
261}
262
263fn push_scope_tags(out: &mut Vec<Tag>, scope: &CommentScope, root: bool) {
264 let event_head = if root { "E" } else { "e" };
265 let addr_head = if root { "A" } else { "a" };
266 let ext_head = if root { "I" } else { "i" };
267
268 match scope {
269 CommentScope::Event { id, relay_hint } => {
270 let mut values = vec![id.to_hex()];
271 if let Some(r) = relay_hint {
272 values.push(r.as_str().to_owned());
273 }
274 out.push(Tag::with(&TagKind::from_wire(event_head), values));
275 }
276 CommentScope::Address {
277 coordinate,
278 relay_hint,
279 } => {
280 let mut values = vec![coordinate.to_wire()];
281 if let Some(r) = relay_hint {
282 values.push(r.as_str().to_owned());
283 }
284 out.push(Tag::with(&TagKind::from_wire(addr_head), values));
285 }
286 CommentScope::External { value, context } => {
287 let mut values = vec![value.clone()];
288 if let Some(c) = context {
289 values.push(c.clone());
290 }
291 out.push(Tag::with(&TagKind::from_wire(ext_head), values));
292 }
293 }
294}
295
296fn parse_event_scope(tag: &Tag) -> Result<CommentScope, CommentError> {
297 let mut args = tag.values().iter().skip(1);
298 let id = args
299 .next()
300 .ok_or(CommentError::MissingValue { tag: "E/e" })?
301 .parse::<EventId>()?;
302 let relay_hint = match args.next() {
303 Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
304 _ => None,
305 };
306 Ok(CommentScope::Event { id, relay_hint })
307}
308
309fn parse_address_scope(tag: &Tag) -> Result<CommentScope, CommentError> {
310 let mut args = tag.values().iter().skip(1);
311 let coordinate = args
312 .next()
313 .ok_or(CommentError::MissingValue { tag: "A/a" })?
314 .parse::<Coordinate>()?;
315 let relay_hint = match args.next() {
316 Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
317 _ => None,
318 };
319 Ok(CommentScope::Address {
320 coordinate,
321 relay_hint,
322 })
323}
324
325fn parse_external_scope(tag: &Tag) -> Result<CommentScope, CommentError> {
326 let mut args = tag.values().iter().skip(1);
327 let value = args
328 .next()
329 .ok_or(CommentError::MissingValue { tag: "I/i" })?
330 .clone();
331 let context = match args.next() {
332 Some(s) if !s.is_empty() => Some(s.clone()),
333 _ => None,
334 };
335 Ok(CommentScope::External { value, context })
336}
337
338fn parse_kind(tag: &Tag, name: &'static str) -> Result<Kind, CommentError> {
339 let value = tag
340 .values()
341 .get(1)
342 .ok_or(CommentError::MissingValue { tag: name })?;
343 let raw: u16 = value
344 .parse()
345 .map_err(|_| CommentError::InvalidKind(value.clone()))?;
346 Ok(Kind::from(raw))
347}
348
349fn parse_pubkey(tag: &Tag, name: &'static str) -> Result<PublicKey, CommentError> {
350 let value = tag
351 .values()
352 .get(1)
353 .ok_or(CommentError::MissingValue { tag: name })?;
354 Ok(value.parse::<PublicKey>()?)
355}
356
357#[derive(Debug, Clone, Error)]
359#[non_exhaustive]
360pub enum CommentError {
361 #[error("expected kind 1111, got {0}")]
363 UnexpectedKind(u16),
364 #[error("comment is missing the root scope tag (E/A/I)")]
366 MissingRoot,
367 #[error("comment is missing the parent scope tag (e/a/i)")]
369 MissingParent,
370 #[error("`{tag}` tag is missing its value")]
372 MissingValue {
373 tag: &'static str,
375 },
376 #[error("invalid kind value `{0}`")]
378 InvalidKind(String),
379 #[error(transparent)]
381 InvalidEventId(#[from] EventIdError),
382 #[error(transparent)]
384 InvalidCoordinate(#[from] CoordinateError),
385 #[error(transparent)]
387 InvalidRelay(#[from] RelayUrlError),
388 #[error(transparent)]
390 InvalidPubkey(#[from] PublicKeyError),
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use crate::Keys;
397 use crate::types::Timestamp;
398
399 fn keys() -> Keys {
400 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
401 }
402
403 fn pk(seed: u8) -> PublicKey {
404 let mut bytes = [0u8; 32];
405 bytes[31] = seed;
406 let sk = crate::SecretKey::from_byte_array(bytes).unwrap();
407 *Keys::from_secret_key(sk).public_key()
408 }
409
410 #[test]
411 fn top_level_event_round_trip() {
412 let id = EventId::from_byte_array([0xaa; 32]);
413 let comment = Comment::top_level(CommentScope::event(id), "hello!")
414 .with_root_kind(Kind::TEXT_NOTE)
415 .with_root_author(pk(1))
416 .with_parent_kind(Kind::TEXT_NOTE)
417 .with_parent_author(pk(1));
418 let event = EventBuilder::comment(&comment)
419 .created_at(Timestamp::from_secs(1))
420 .sign_with_keys(&keys())
421 .unwrap();
422 event.verify().unwrap();
423 assert_eq!(event.kind, Kind::from(1111_u16));
424 let parsed = Comment::from_event(&event).unwrap();
425 assert_eq!(parsed, comment);
426 }
427
428 #[test]
429 fn nested_reply_round_trip() {
430 let root_id = EventId::from_byte_array([0x10; 32]);
431 let parent_id = EventId::from_byte_array([0x20; 32]);
432 let comment = Comment::top_level(CommentScope::event(root_id), "ack")
433 .with_parent(CommentScope::event(parent_id))
434 .with_root_kind(Kind::TEXT_NOTE)
435 .with_root_author(pk(2))
436 .with_parent_kind(Kind::TEXT_NOTE)
437 .with_parent_author(pk(3));
438 let event = EventBuilder::comment(&comment)
439 .created_at(Timestamp::from_secs(2))
440 .sign_with_keys(&keys())
441 .unwrap();
442 let parsed = Comment::from_event(&event).unwrap();
443 assert_eq!(parsed, comment);
444 }
445
446 #[test]
447 fn address_scope_round_trip() {
448 let coord = Coordinate::new(Kind::from(30_023_u16), pk(4), "long-form-1");
449 let comment =
450 Comment::top_level(CommentScope::address(coord), "first comment on the article");
451 let event = EventBuilder::comment(&comment)
452 .created_at(Timestamp::from_secs(3))
453 .sign_with_keys(&keys())
454 .unwrap();
455 let parsed = Comment::from_event(&event).unwrap();
456 assert_eq!(parsed, comment);
457 }
458
459 #[test]
460 fn external_scope_round_trip() {
461 let comment = Comment::top_level(
462 CommentScope::external("https://example.com/article"),
463 "external pointer",
464 );
465 let event = EventBuilder::comment(&comment)
466 .created_at(Timestamp::from_secs(4))
467 .sign_with_keys(&keys())
468 .unwrap();
469 let parsed = Comment::from_event(&event).unwrap();
470 assert_eq!(parsed, comment);
471 }
472
473 #[test]
474 fn rejects_wrong_kind() {
475 let event = EventBuilder::text_note("not a comment")
476 .created_at(Timestamp::from_secs(5))
477 .sign_with_keys(&keys())
478 .unwrap();
479 let err = Comment::from_event(&event).unwrap_err();
480 assert!(matches!(err, CommentError::UnexpectedKind(1)));
481 }
482
483 #[test]
484 fn rejects_missing_root() {
485 let event = EventBuilder::new(Kind::from(1111_u16), "")
486 .created_at(Timestamp::from_secs(6))
487 .tag(Tag::new(["e", &EventId::from_byte_array([0u8; 32]).to_hex()]).unwrap())
488 .sign_with_keys(&keys())
489 .unwrap();
490 let err = Comment::from_event(&event).unwrap_err();
491 assert!(matches!(err, CommentError::MissingRoot));
492 }
493
494 #[test]
495 fn rejects_missing_parent() {
496 let event = EventBuilder::new(Kind::from(1111_u16), "")
497 .created_at(Timestamp::from_secs(7))
498 .tag(Tag::new(["E", &EventId::from_byte_array([0u8; 32]).to_hex()]).unwrap())
499 .sign_with_keys(&keys())
500 .unwrap();
501 let err = Comment::from_event(&event).unwrap_err();
502 assert!(matches!(err, CommentError::MissingParent));
503 }
504}