use thiserror::Error;
use crate::event::{
Alphabet, Coordinate, CoordinateError, Event, EventBuilder, Kind, SingleLetterTag, Tag,
TagKind, Tags,
};
use crate::types::{RelayUrl, RelayUrlError};
pub const KIND_APP_RECOMMENDATION: Kind = Kind::APP_RECOMMENDATION;
pub const KIND_APP_HANDLER: Kind = Kind::APP_HANDLER;
pub const CLIENT_TAG: &str = "client";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandlerRecommendationEntry {
pub handler: Coordinate,
pub relay_hint: Option<RelayUrl>,
pub platform: Option<String>,
pub extra_columns: Vec<String>,
}
impl HandlerRecommendationEntry {
#[must_use]
pub const fn new(handler: Coordinate) -> Self {
Self {
handler,
relay_hint: None,
platform: None,
extra_columns: Vec::new(),
}
}
#[must_use]
pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
self.relay_hint = Some(relay);
self
}
#[must_use]
pub fn platform(mut self, platform: impl Into<String>) -> Self {
self.platform = Some(platform.into());
self
}
#[must_use]
pub fn to_tag(&self) -> Tag {
let mut values: Vec<String> = Vec::with_capacity(4);
values.push(self.handler.to_wire());
match (&self.relay_hint, &self.platform) {
(Some(relay), Some(platform)) => {
values.push(relay.as_str().to_owned());
values.push(platform.clone());
}
(Some(relay), None) => values.push(relay.as_str().to_owned()),
(None, Some(platform)) => {
values.push(String::new());
values.push(platform.clone());
}
(None, None) => {}
}
values.extend(self.extra_columns.iter().cloned());
let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::A));
Tag::with(&head, values)
}
pub fn from_tag(tag: &Tag) -> Result<Self, HandlerError> {
let coord_str = tag.get(1).ok_or(HandlerError::MalformedAddressTag)?;
let handler = Coordinate::parse(coord_str)?;
let relay_hint = match tag.get(2) {
Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
_ => None,
};
let platform = tag.get(3).filter(|s| !s.is_empty()).map(str::to_owned);
let extra_columns: Vec<String> =
tag.values().iter().skip(4).map(ToOwned::to_owned).collect();
Ok(Self {
handler,
relay_hint,
platform,
extra_columns,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandlerRecommendation {
pub recommended_kind: Kind,
pub entries: Vec<HandlerRecommendationEntry>,
pub extra_tags: Vec<Tag>,
}
impl HandlerRecommendation {
#[must_use]
pub const fn new(recommended_kind: Kind) -> Self {
Self {
recommended_kind,
entries: Vec::new(),
extra_tags: Vec::new(),
}
}
#[must_use]
pub fn entry(mut self, entry: HandlerRecommendationEntry) -> Self {
self.entries.push(entry);
self
}
#[must_use]
pub fn coordinate(&self, author: crate::PublicKey) -> Coordinate {
Coordinate::new(
KIND_APP_RECOMMENDATION,
author,
self.recommended_kind.as_u16().to_string(),
)
}
pub fn from_event(event: &Event) -> Result<Self, HandlerError> {
if event.kind != KIND_APP_RECOMMENDATION {
return Err(HandlerError::WrongKind(event.kind));
}
let d = d_value(&event.tags).ok_or(HandlerError::MissingIdentifier)?;
let recommended_kind: Kind = d
.parse::<u16>()
.map(Kind::from)
.map_err(|_| HandlerError::InvalidRecommendedKind(d.to_owned()))?;
let mut entries: Vec<HandlerRecommendationEntry> = Vec::new();
let mut extra_tags: Vec<Tag> = Vec::new();
for tag in &event.tags {
match tag.kind() {
TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::A => {
entries.push(HandlerRecommendationEntry::from_tag(tag)?);
}
_ => extra_tags.push(tag.clone()),
}
}
Ok(Self {
recommended_kind,
entries,
extra_tags,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandlerPlatformEntry {
pub platform: String,
pub url_template: String,
pub entity: Option<String>,
}
impl HandlerPlatformEntry {
#[must_use]
pub fn new(platform: impl Into<String>, url_template: impl Into<String>) -> Self {
Self {
platform: platform.into(),
url_template: url_template.into(),
entity: None,
}
}
#[must_use]
pub fn entity(mut self, entity: impl Into<String>) -> Self {
self.entity = Some(entity.into());
self
}
#[must_use]
pub fn to_tag(&self) -> Tag {
let head = TagKind::from_wire(&self.platform);
self.entity.as_ref().map_or_else(
|| Tag::with(&head, [self.url_template.clone()]),
|entity| Tag::with(&head, [self.url_template.clone(), entity.clone()]),
)
}
fn from_tag(tag: &Tag) -> Result<Self, HandlerError> {
let platform = tag.name().to_owned();
let url_template = tag
.get(1)
.ok_or(HandlerError::MalformedHandlerPlatform)?
.to_owned();
let entity = tag.get(2).filter(|s| !s.is_empty()).map(str::to_owned);
Ok(Self {
platform,
url_template,
entity,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandlerInformation {
pub identifier: String,
pub content: String,
pub supported_kinds: Vec<Kind>,
pub platforms: Vec<HandlerPlatformEntry>,
pub extra_tags: Vec<Tag>,
}
impl HandlerInformation {
#[must_use]
pub fn new(identifier: impl Into<String>) -> Self {
Self {
identifier: identifier.into(),
content: String::new(),
supported_kinds: Vec::new(),
platforms: Vec::new(),
extra_tags: Vec::new(),
}
}
#[must_use]
pub fn content(mut self, content: impl Into<String>) -> Self {
self.content = content.into();
self
}
#[must_use]
pub fn kind(mut self, kind: Kind) -> Self {
self.supported_kinds.push(kind);
self
}
#[must_use]
pub fn platform(mut self, entry: HandlerPlatformEntry) -> Self {
self.platforms.push(entry);
self
}
#[must_use]
pub fn coordinate(&self, author: crate::PublicKey) -> Coordinate {
Coordinate::new(KIND_APP_HANDLER, author, self.identifier.clone())
}
pub fn from_event(event: &Event) -> Result<Self, HandlerError> {
if event.kind != KIND_APP_HANDLER {
return Err(HandlerError::WrongKind(event.kind));
}
let identifier = d_value(&event.tags)
.ok_or(HandlerError::MissingIdentifier)?
.to_owned();
let mut supported_kinds: Vec<Kind> = Vec::new();
let mut platforms: Vec<HandlerPlatformEntry> = Vec::new();
let mut extra_tags: Vec<Tag> = Vec::new();
for tag in &event.tags {
match tag.kind() {
TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::K => {
let raw = tag.get(1).ok_or(HandlerError::MalformedKindTag)?;
let kind = raw
.parse::<u16>()
.map(Kind::from)
.map_err(|_| HandlerError::InvalidKind(raw.to_owned()))?;
supported_kinds.push(kind);
}
TagKind::Custom(_) => match HandlerPlatformEntry::from_tag(tag) {
Ok(entry) => platforms.push(entry),
Err(_) => extra_tags.push(tag.clone()),
},
_ => extra_tags.push(tag.clone()),
}
}
Ok(Self {
identifier,
content: event.content.clone(),
supported_kinds,
platforms,
extra_tags,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientTag {
pub name: String,
pub handler: Option<Coordinate>,
pub relay_hint: Option<RelayUrl>,
}
impl ClientTag {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
handler: None,
relay_hint: None,
}
}
#[must_use]
pub fn handler(mut self, handler: Coordinate) -> Self {
self.handler = Some(handler);
self
}
#[must_use]
pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
self.relay_hint = Some(relay);
self
}
#[must_use]
pub fn to_tag(&self) -> Tag {
let mut values: Vec<String> = Vec::with_capacity(4);
values.push(self.name.clone());
match (&self.handler, &self.relay_hint) {
(Some(coord), Some(relay)) => {
values.push(coord.to_wire());
values.push(relay.as_str().to_owned());
}
(Some(coord), None) => values.push(coord.to_wire()),
(None, Some(relay)) => {
values.push(String::new());
values.push(relay.as_str().to_owned());
}
(None, None) => {}
}
Tag::with(&TagKind::from_wire(CLIENT_TAG), values)
}
pub fn from_tag(tag: &Tag) -> Result<Self, HandlerError> {
if tag.name() != CLIENT_TAG {
return Err(HandlerError::WrongTag);
}
let name = tag
.get(1)
.ok_or(HandlerError::MalformedClientTag)?
.to_owned();
let handler = match tag.get(2) {
Some(s) if !s.is_empty() => Some(Coordinate::parse(s)?),
_ => None,
};
let relay_hint = match tag.get(3) {
Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
_ => None,
};
Ok(Self {
name,
handler,
relay_hint,
})
}
}
impl Tag {
#[must_use]
pub fn client(client: &ClientTag) -> Self {
client.to_tag()
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum HandlerError {
#[error("unexpected kind for NIP-89 event: {}", .0.as_u16())]
WrongKind(Kind),
#[error("unexpected tag for NIP-89")]
WrongTag,
#[error("NIP-89 event must carry a `d` tag")]
MissingIdentifier,
#[error("recommendation `d` tag must be a `u16` kind: `{0}`")]
InvalidRecommendedKind(String),
#[error("handler `k` tag must be a `u16` kind: `{0}`")]
InvalidKind(String),
#[error("`k` handler tag missing kind value")]
MalformedKindTag,
#[error("`a` recommendation tag missing handler coordinate")]
MalformedAddressTag,
#[error("`client` tag missing name column")]
MalformedClientTag,
#[error("handler platform tag missing URL template")]
MalformedHandlerPlatform,
#[error(transparent)]
InvalidCoordinate(#[from] CoordinateError),
#[error(transparent)]
InvalidRelayUrl(#[from] RelayUrlError),
}
fn d_value(tags: &Tags) -> Option<&str> {
let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
tags.find_first(&head).and_then(|tag| tag.get(1))
}
impl EventBuilder {
#[must_use]
pub fn handler_recommendation(rec: &HandlerRecommendation) -> Self {
let mut builder = Self::new(KIND_APP_RECOMMENDATION, "");
builder = builder.tag(Tag::d(rec.recommended_kind.as_u16().to_string()));
for entry in &rec.entries {
builder = builder.tag(entry.to_tag());
}
for tag in &rec.extra_tags {
builder = builder.tag(tag.clone());
}
builder
}
#[must_use]
pub fn handler_information(handler: &HandlerInformation) -> Self {
let mut builder = Self::new(KIND_APP_HANDLER, handler.content.clone());
builder = builder.tag(Tag::d(&handler.identifier));
for kind in &handler.supported_kinds {
builder = builder.tag(Tag::k(*kind));
}
for entry in &handler.platforms {
builder = builder.tag(entry.to_tag());
}
for tag in &handler.extra_tags {
builder = builder.tag(tag.clone());
}
builder
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
fn keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
}
fn relay() -> RelayUrl {
RelayUrl::parse("wss://relay.example/").unwrap()
}
fn coord(kind: u16, identifier: &str) -> Coordinate {
Coordinate::new(Kind::new(kind), *keys().public_key(), identifier.to_owned())
}
#[test]
fn recommendation_round_trip() {
let rec = HandlerRecommendation::new(Kind::new(31_337))
.entry(
HandlerRecommendationEntry::new(coord(31_990, "abcd"))
.relay_hint(relay())
.platform("web"),
)
.entry(HandlerRecommendationEntry::new(coord(31_990, "ios-bundle")).platform("ios"));
let event = EventBuilder::handler_recommendation(&rec)
.sign_with_keys(&keys())
.unwrap();
let parsed = HandlerRecommendation::from_event(&event).unwrap();
assert_eq!(parsed, rec);
}
#[test]
fn recommendation_rejects_wrong_kind() {
let event = EventBuilder::text_note("nope")
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
HandlerRecommendation::from_event(&event),
Err(HandlerError::WrongKind(_))
));
}
#[test]
fn recommendation_rejects_missing_identifier() {
let event = EventBuilder::new(KIND_APP_RECOMMENDATION, "")
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
HandlerRecommendation::from_event(&event),
Err(HandlerError::MissingIdentifier)
));
}
#[test]
fn handler_round_trip_with_platforms() {
let handler = HandlerInformation::new("handler-id-1")
.content(r#"{"name":"Demo"}"#)
.kind(Kind::new(1))
.kind(Kind::new(30_023))
.platform(
HandlerPlatformEntry::new("web", "https://demo.example/a/<bech32>")
.entity("nevent"),
)
.platform(HandlerPlatformEntry::new("ios", "demo://a/<bech32>"));
let event = EventBuilder::handler_information(&handler)
.sign_with_keys(&keys())
.unwrap();
let parsed = HandlerInformation::from_event(&event).unwrap();
assert_eq!(parsed, handler);
}
#[test]
fn handler_rejects_wrong_kind() {
let event = EventBuilder::text_note("nope")
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
HandlerInformation::from_event(&event),
Err(HandlerError::WrongKind(_))
));
}
#[test]
fn handler_rejects_invalid_kind_tag() {
let event = EventBuilder::new(KIND_APP_HANDLER, "")
.tag(Tag::d("h-1"))
.tag(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::K)),
["not-a-number"],
))
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
HandlerInformation::from_event(&event),
Err(HandlerError::InvalidKind(_))
));
}
#[test]
fn client_tag_round_trip() {
let client = ClientTag::new("My Client")
.handler(coord(31_990, "app-id"))
.relay_hint(relay());
let tag = client.to_tag();
assert_eq!(tag.name(), CLIENT_TAG);
let parsed = ClientTag::from_tag(&tag).unwrap();
assert_eq!(parsed, client);
}
#[test]
fn client_tag_name_only() {
let client = ClientTag::new("Bare Client");
let tag = client.to_tag();
let parsed = ClientTag::from_tag(&tag).unwrap();
assert_eq!(parsed, client);
}
#[test]
fn client_tag_rejects_wrong_head() {
let tag = Tag::title("not a client tag");
assert!(matches!(
ClientTag::from_tag(&tag),
Err(HandlerError::WrongTag)
));
}
}