1use thiserror::Error;
31
32use crate::event::{
33 Alphabet, Coordinate, CoordinateError, Event, EventBuilder, Kind, SingleLetterTag, Tag,
34 TagKind, Tags,
35};
36use crate::types::{RelayUrl, RelayUrlError};
37
38pub const KIND_APP_RECOMMENDATION: Kind = Kind::APP_RECOMMENDATION;
40
41pub const KIND_APP_HANDLER: Kind = Kind::APP_HANDLER;
43
44pub const CLIENT_TAG: &str = "client";
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct HandlerRecommendationEntry {
50 pub handler: Coordinate,
52 pub relay_hint: Option<RelayUrl>,
54 pub platform: Option<String>,
56 pub extra_columns: Vec<String>,
59}
60
61impl HandlerRecommendationEntry {
62 #[must_use]
65 pub const fn new(handler: Coordinate) -> Self {
66 Self {
67 handler,
68 relay_hint: None,
69 platform: None,
70 extra_columns: Vec::new(),
71 }
72 }
73
74 #[must_use]
76 pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
77 self.relay_hint = Some(relay);
78 self
79 }
80
81 #[must_use]
83 pub fn platform(mut self, platform: impl Into<String>) -> Self {
84 self.platform = Some(platform.into());
85 self
86 }
87
88 #[must_use]
91 pub fn to_tag(&self) -> Tag {
92 let mut values: Vec<String> = Vec::with_capacity(4);
93 values.push(self.handler.to_wire());
94 match (&self.relay_hint, &self.platform) {
95 (Some(relay), Some(platform)) => {
96 values.push(relay.as_str().to_owned());
97 values.push(platform.clone());
98 }
99 (Some(relay), None) => values.push(relay.as_str().to_owned()),
100 (None, Some(platform)) => {
101 values.push(String::new());
104 values.push(platform.clone());
105 }
106 (None, None) => {}
107 }
108 values.extend(self.extra_columns.iter().cloned());
109 let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::A));
110 Tag::with(&head, values)
111 }
112
113 pub fn from_tag(tag: &Tag) -> Result<Self, HandlerError> {
123 let coord_str = tag.get(1).ok_or(HandlerError::MalformedAddressTag)?;
124 let handler = Coordinate::parse(coord_str)?;
125 let relay_hint = match tag.get(2) {
126 Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
127 _ => None,
128 };
129 let platform = tag.get(3).filter(|s| !s.is_empty()).map(str::to_owned);
130 let extra_columns: Vec<String> =
131 tag.values().iter().skip(4).map(ToOwned::to_owned).collect();
132 Ok(Self {
133 handler,
134 relay_hint,
135 platform,
136 extra_columns,
137 })
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct HandlerRecommendation {
144 pub recommended_kind: Kind,
146 pub entries: Vec<HandlerRecommendationEntry>,
148 pub extra_tags: Vec<Tag>,
150}
151
152impl HandlerRecommendation {
153 #[must_use]
155 pub const fn new(recommended_kind: Kind) -> Self {
156 Self {
157 recommended_kind,
158 entries: Vec::new(),
159 extra_tags: Vec::new(),
160 }
161 }
162
163 #[must_use]
165 pub fn entry(mut self, entry: HandlerRecommendationEntry) -> Self {
166 self.entries.push(entry);
167 self
168 }
169
170 #[must_use]
172 pub fn coordinate(&self, author: crate::PublicKey) -> Coordinate {
173 Coordinate::new(
174 KIND_APP_RECOMMENDATION,
175 author,
176 self.recommended_kind.as_u16().to_string(),
177 )
178 }
179
180 pub fn from_event(event: &Event) -> Result<Self, HandlerError> {
191 if event.kind != KIND_APP_RECOMMENDATION {
192 return Err(HandlerError::WrongKind(event.kind));
193 }
194 let d = d_value(&event.tags).ok_or(HandlerError::MissingIdentifier)?;
195 let recommended_kind: Kind = d
196 .parse::<u16>()
197 .map(Kind::from)
198 .map_err(|_| HandlerError::InvalidRecommendedKind(d.to_owned()))?;
199 let mut entries: Vec<HandlerRecommendationEntry> = Vec::new();
200 let mut extra_tags: Vec<Tag> = Vec::new();
201 for tag in &event.tags {
202 match tag.kind() {
203 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
204 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::A => {
205 entries.push(HandlerRecommendationEntry::from_tag(tag)?);
206 }
207 _ => extra_tags.push(tag.clone()),
208 }
209 }
210 Ok(Self {
211 recommended_kind,
212 entries,
213 extra_tags,
214 })
215 }
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct HandlerPlatformEntry {
221 pub platform: String,
223 pub url_template: String,
226 pub entity: Option<String>,
229}
230
231impl HandlerPlatformEntry {
232 #[must_use]
234 pub fn new(platform: impl Into<String>, url_template: impl Into<String>) -> Self {
235 Self {
236 platform: platform.into(),
237 url_template: url_template.into(),
238 entity: None,
239 }
240 }
241
242 #[must_use]
244 pub fn entity(mut self, entity: impl Into<String>) -> Self {
245 self.entity = Some(entity.into());
246 self
247 }
248
249 #[must_use]
251 pub fn to_tag(&self) -> Tag {
252 let head = TagKind::from_wire(&self.platform);
253 self.entity.as_ref().map_or_else(
254 || Tag::with(&head, [self.url_template.clone()]),
255 |entity| Tag::with(&head, [self.url_template.clone(), entity.clone()]),
256 )
257 }
258
259 fn from_tag(tag: &Tag) -> Result<Self, HandlerError> {
263 let platform = tag.name().to_owned();
264 let url_template = tag
265 .get(1)
266 .ok_or(HandlerError::MalformedHandlerPlatform)?
267 .to_owned();
268 let entity = tag.get(2).filter(|s| !s.is_empty()).map(str::to_owned);
269 Ok(Self {
270 platform,
271 url_template,
272 entity,
273 })
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct HandlerInformation {
280 pub identifier: String,
282 pub content: String,
284 pub supported_kinds: Vec<Kind>,
286 pub platforms: Vec<HandlerPlatformEntry>,
288 pub extra_tags: Vec<Tag>,
290}
291
292impl HandlerInformation {
293 #[must_use]
295 pub fn new(identifier: impl Into<String>) -> Self {
296 Self {
297 identifier: identifier.into(),
298 content: String::new(),
299 supported_kinds: Vec::new(),
300 platforms: Vec::new(),
301 extra_tags: Vec::new(),
302 }
303 }
304
305 #[must_use]
307 pub fn content(mut self, content: impl Into<String>) -> Self {
308 self.content = content.into();
309 self
310 }
311
312 #[must_use]
314 pub fn kind(mut self, kind: Kind) -> Self {
315 self.supported_kinds.push(kind);
316 self
317 }
318
319 #[must_use]
321 pub fn platform(mut self, entry: HandlerPlatformEntry) -> Self {
322 self.platforms.push(entry);
323 self
324 }
325
326 #[must_use]
328 pub fn coordinate(&self, author: crate::PublicKey) -> Coordinate {
329 Coordinate::new(KIND_APP_HANDLER, author, self.identifier.clone())
330 }
331
332 pub fn from_event(event: &Event) -> Result<Self, HandlerError> {
342 if event.kind != KIND_APP_HANDLER {
343 return Err(HandlerError::WrongKind(event.kind));
344 }
345 let identifier = d_value(&event.tags)
346 .ok_or(HandlerError::MissingIdentifier)?
347 .to_owned();
348 let mut supported_kinds: Vec<Kind> = Vec::new();
349 let mut platforms: Vec<HandlerPlatformEntry> = Vec::new();
350 let mut extra_tags: Vec<Tag> = Vec::new();
351 for tag in &event.tags {
352 match tag.kind() {
353 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
354 TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::K => {
355 let raw = tag.get(1).ok_or(HandlerError::MalformedKindTag)?;
356 let kind = raw
357 .parse::<u16>()
358 .map(Kind::from)
359 .map_err(|_| HandlerError::InvalidKind(raw.to_owned()))?;
360 supported_kinds.push(kind);
361 }
362 TagKind::Custom(_) => match HandlerPlatformEntry::from_tag(tag) {
363 Ok(entry) => platforms.push(entry),
364 Err(_) => extra_tags.push(tag.clone()),
365 },
366 _ => extra_tags.push(tag.clone()),
367 }
368 }
369 Ok(Self {
370 identifier,
371 content: event.content.clone(),
372 supported_kinds,
373 platforms,
374 extra_tags,
375 })
376 }
377}
378
379#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct ClientTag {
382 pub name: String,
384 pub handler: Option<Coordinate>,
386 pub relay_hint: Option<RelayUrl>,
388}
389
390impl ClientTag {
391 #[must_use]
393 pub fn new(name: impl Into<String>) -> Self {
394 Self {
395 name: name.into(),
396 handler: None,
397 relay_hint: None,
398 }
399 }
400
401 #[must_use]
403 pub fn handler(mut self, handler: Coordinate) -> Self {
404 self.handler = Some(handler);
405 self
406 }
407
408 #[must_use]
410 pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
411 self.relay_hint = Some(relay);
412 self
413 }
414
415 #[must_use]
417 pub fn to_tag(&self) -> Tag {
418 let mut values: Vec<String> = Vec::with_capacity(4);
419 values.push(self.name.clone());
420 match (&self.handler, &self.relay_hint) {
421 (Some(coord), Some(relay)) => {
422 values.push(coord.to_wire());
423 values.push(relay.as_str().to_owned());
424 }
425 (Some(coord), None) => values.push(coord.to_wire()),
426 (None, Some(relay)) => {
427 values.push(String::new());
428 values.push(relay.as_str().to_owned());
429 }
430 (None, None) => {}
431 }
432 Tag::with(&TagKind::from_wire(CLIENT_TAG), values)
433 }
434
435 pub fn from_tag(tag: &Tag) -> Result<Self, HandlerError> {
445 if tag.name() != CLIENT_TAG {
446 return Err(HandlerError::WrongTag);
447 }
448 let name = tag
449 .get(1)
450 .ok_or(HandlerError::MalformedClientTag)?
451 .to_owned();
452 let handler = match tag.get(2) {
453 Some(s) if !s.is_empty() => Some(Coordinate::parse(s)?),
454 _ => None,
455 };
456 let relay_hint = match tag.get(3) {
457 Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
458 _ => None,
459 };
460 Ok(Self {
461 name,
462 handler,
463 relay_hint,
464 })
465 }
466}
467
468impl Tag {
469 #[must_use]
471 pub fn client(client: &ClientTag) -> Self {
472 client.to_tag()
473 }
474}
475
476#[derive(Debug, Error)]
478#[non_exhaustive]
479pub enum HandlerError {
480 #[error("unexpected kind for NIP-89 event: {}", .0.as_u16())]
482 WrongKind(Kind),
483 #[error("unexpected tag for NIP-89")]
485 WrongTag,
486 #[error("NIP-89 event must carry a `d` tag")]
488 MissingIdentifier,
489 #[error("recommendation `d` tag must be a `u16` kind: `{0}`")]
491 InvalidRecommendedKind(String),
492 #[error("handler `k` tag must be a `u16` kind: `{0}`")]
494 InvalidKind(String),
495 #[error("`k` handler tag missing kind value")]
497 MalformedKindTag,
498 #[error("`a` recommendation tag missing handler coordinate")]
500 MalformedAddressTag,
501 #[error("`client` tag missing name column")]
503 MalformedClientTag,
504 #[error("handler platform tag missing URL template")]
506 MalformedHandlerPlatform,
507 #[error(transparent)]
509 InvalidCoordinate(#[from] CoordinateError),
510 #[error(transparent)]
512 InvalidRelayUrl(#[from] RelayUrlError),
513}
514
515fn d_value(tags: &Tags) -> Option<&str> {
516 let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
517 tags.find_first(&head).and_then(|tag| tag.get(1))
518}
519
520impl EventBuilder {
521 #[must_use]
523 pub fn handler_recommendation(rec: &HandlerRecommendation) -> Self {
524 let mut builder = Self::new(KIND_APP_RECOMMENDATION, "");
525 builder = builder.tag(Tag::d(rec.recommended_kind.as_u16().to_string()));
526 for entry in &rec.entries {
527 builder = builder.tag(entry.to_tag());
528 }
529 for tag in &rec.extra_tags {
530 builder = builder.tag(tag.clone());
531 }
532 builder
533 }
534
535 #[must_use]
537 pub fn handler_information(handler: &HandlerInformation) -> Self {
538 let mut builder = Self::new(KIND_APP_HANDLER, handler.content.clone());
539 builder = builder.tag(Tag::d(&handler.identifier));
540 for kind in &handler.supported_kinds {
541 builder = builder.tag(Tag::k(*kind));
542 }
543 for entry in &handler.platforms {
544 builder = builder.tag(entry.to_tag());
545 }
546 for tag in &handler.extra_tags {
547 builder = builder.tag(tag.clone());
548 }
549 builder
550 }
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use crate::Keys;
557
558 fn keys() -> Keys {
559 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
560 }
561
562 fn relay() -> RelayUrl {
563 RelayUrl::parse("wss://relay.example/").unwrap()
564 }
565
566 fn coord(kind: u16, identifier: &str) -> Coordinate {
567 Coordinate::new(Kind::new(kind), *keys().public_key(), identifier.to_owned())
568 }
569
570 #[test]
571 fn recommendation_round_trip() {
572 let rec = HandlerRecommendation::new(Kind::new(31_337))
573 .entry(
574 HandlerRecommendationEntry::new(coord(31_990, "abcd"))
575 .relay_hint(relay())
576 .platform("web"),
577 )
578 .entry(HandlerRecommendationEntry::new(coord(31_990, "ios-bundle")).platform("ios"));
579 let event = EventBuilder::handler_recommendation(&rec)
580 .sign_with_keys(&keys())
581 .unwrap();
582 let parsed = HandlerRecommendation::from_event(&event).unwrap();
583 assert_eq!(parsed, rec);
584 }
585
586 #[test]
587 fn recommendation_rejects_wrong_kind() {
588 let event = EventBuilder::text_note("nope")
589 .sign_with_keys(&keys())
590 .unwrap();
591 assert!(matches!(
592 HandlerRecommendation::from_event(&event),
593 Err(HandlerError::WrongKind(_))
594 ));
595 }
596
597 #[test]
598 fn recommendation_rejects_missing_identifier() {
599 let event = EventBuilder::new(KIND_APP_RECOMMENDATION, "")
600 .sign_with_keys(&keys())
601 .unwrap();
602 assert!(matches!(
603 HandlerRecommendation::from_event(&event),
604 Err(HandlerError::MissingIdentifier)
605 ));
606 }
607
608 #[test]
609 fn handler_round_trip_with_platforms() {
610 let handler = HandlerInformation::new("handler-id-1")
611 .content(r#"{"name":"Demo"}"#)
612 .kind(Kind::new(1))
613 .kind(Kind::new(30_023))
614 .platform(
615 HandlerPlatformEntry::new("web", "https://demo.example/a/<bech32>")
616 .entity("nevent"),
617 )
618 .platform(HandlerPlatformEntry::new("ios", "demo://a/<bech32>"));
619 let event = EventBuilder::handler_information(&handler)
620 .sign_with_keys(&keys())
621 .unwrap();
622 let parsed = HandlerInformation::from_event(&event).unwrap();
623 assert_eq!(parsed, handler);
624 }
625
626 #[test]
627 fn handler_rejects_wrong_kind() {
628 let event = EventBuilder::text_note("nope")
629 .sign_with_keys(&keys())
630 .unwrap();
631 assert!(matches!(
632 HandlerInformation::from_event(&event),
633 Err(HandlerError::WrongKind(_))
634 ));
635 }
636
637 #[test]
638 fn handler_rejects_invalid_kind_tag() {
639 let event = EventBuilder::new(KIND_APP_HANDLER, "")
640 .tag(Tag::d("h-1"))
641 .tag(Tag::with(
642 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::K)),
643 ["not-a-number"],
644 ))
645 .sign_with_keys(&keys())
646 .unwrap();
647 assert!(matches!(
648 HandlerInformation::from_event(&event),
649 Err(HandlerError::InvalidKind(_))
650 ));
651 }
652
653 #[test]
654 fn client_tag_round_trip() {
655 let client = ClientTag::new("My Client")
656 .handler(coord(31_990, "app-id"))
657 .relay_hint(relay());
658 let tag = client.to_tag();
659 assert_eq!(tag.name(), CLIENT_TAG);
660 let parsed = ClientTag::from_tag(&tag).unwrap();
661 assert_eq!(parsed, client);
662 }
663
664 #[test]
665 fn client_tag_name_only() {
666 let client = ClientTag::new("Bare Client");
667 let tag = client.to_tag();
668 let parsed = ClientTag::from_tag(&tag).unwrap();
669 assert_eq!(parsed, client);
670 }
671
672 #[test]
673 fn client_tag_rejects_wrong_head() {
674 let tag = Tag::title("not a client tag");
675 assert!(matches!(
676 ClientTag::from_tag(&tag),
677 Err(HandlerError::WrongTag)
678 ));
679 }
680}