use thiserror::Error;
use super::nip59;
use crate::event::{
Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind, Tags, UnsignedEvent,
};
use crate::key::{Keys, PublicKey};
use crate::types::{RelayUrl, Timestamp};
const SUBJECT_TAG: &str = "subject";
const REPLY_MARKER: &str = "reply";
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Nip17Error {
#[error("recipients list must not be empty")]
NoRecipients,
#[error("expected kind 10050, got {0}")]
UnexpectedKind(u16),
#[error("`relay` tag is missing the URL value")]
MissingRelayUrl,
#[error(transparent)]
InvalidRelayUrl(#[from] crate::types::RelayUrlError),
#[error(transparent)]
Wrap(#[from] nip59::Nip59Error),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Recipient {
pub public_key: PublicKey,
pub relay_hint: Option<RelayUrl>,
}
impl Recipient {
#[must_use]
pub const fn new(public_key: PublicKey) -> Self {
Self {
public_key,
relay_hint: None,
}
}
#[must_use]
pub fn with_relay_hint(mut self, relay: RelayUrl) -> Self {
self.relay_hint = Some(relay);
self
}
}
impl From<PublicKey> for Recipient {
fn from(public_key: PublicKey) -> Self {
Self::new(public_key)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ReplyTo {
pub event_id: crate::event::EventId,
pub relay_hint: Option<RelayUrl>,
}
#[must_use]
pub fn build_chat_message_rumor(
sender: &Keys,
recipients: &[Recipient],
message: impl Into<String>,
created_at: Timestamp,
subject: Option<&str>,
reply_to: Option<&ReplyTo>,
) -> UnsignedEvent {
let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
let subject_kind = TagKind::from_wire(SUBJECT_TAG);
let mut tags: Vec<Tag> = Vec::with_capacity(
recipients.len() + usize::from(subject.is_some()) + usize::from(reply_to.is_some()),
);
for recipient in recipients {
let values = recipient.relay_hint.as_ref().map_or_else(
|| vec![recipient.public_key.to_hex()],
|url| vec![recipient.public_key.to_hex(), url.as_str().to_owned()],
);
tags.push(Tag::with(&p_kind, values));
}
if let Some(reply) = reply_to {
let relay = reply
.relay_hint
.as_ref()
.map_or_else(String::new, |url| url.as_str().to_owned());
tags.push(Tag::with(
&e_kind,
[reply.event_id.to_hex(), relay, REPLY_MARKER.to_owned()],
));
}
if let Some(title) = subject {
tags.push(Tag::with(&subject_kind, [title.to_owned()]));
}
UnsignedEvent::new(
*sender.public_key(),
created_at,
Kind::PRIVATE_DIRECT_MESSAGE,
Tags::from_vec(tags),
message,
)
}
pub fn wrap_for_many(
sender: &Keys,
recipients: &[Recipient],
rumor: &UnsignedEvent,
timestamps: nip59::Timestamps,
) -> Result<Vec<Event>, Nip17Error> {
if recipients.is_empty() {
return Err(Nip17Error::NoRecipients);
}
let mut wraps = Vec::with_capacity(recipients.len() + 1);
let self_seal = nip59::create_seal(sender, sender.public_key(), rumor, timestamps.seal)?;
wraps.push(nip59::create_gift_wrap(
&self_seal,
sender.public_key(),
None,
timestamps.wrap,
)?);
for recipient in recipients {
let seal = nip59::create_seal(sender, &recipient.public_key, rumor, timestamps.seal)?;
wraps.push(nip59::create_gift_wrap(
&seal,
&recipient.public_key,
recipient.relay_hint.as_ref(),
timestamps.wrap,
)?);
}
Ok(wraps)
}
pub fn wrap_for(
sender: &Keys,
recipient: &Recipient,
rumor: &UnsignedEvent,
timestamps: nip59::Timestamps,
) -> Result<Event, Nip17Error> {
let seal = nip59::create_seal(sender, &recipient.public_key, rumor, timestamps.seal)?;
Ok(nip59::create_gift_wrap(
&seal,
&recipient.public_key,
recipient.relay_hint.as_ref(),
timestamps.wrap,
)?)
}
pub fn unwrap_chat_message(
recipient: &Keys,
gift_wrap: &Event,
) -> Result<UnsignedEvent, Nip17Error> {
let rumor = nip59::unwrap(recipient, gift_wrap).map_err(Nip17Error::Wrap)?;
if rumor.kind != Kind::PRIVATE_DIRECT_MESSAGE {
return Err(Nip17Error::UnexpectedKind(rumor.kind.as_u16()));
}
Ok(rumor)
}
pub fn unwrap_dm_payload(recipient: &Keys, gift_wrap: &Event) -> Result<UnsignedEvent, Nip17Error> {
nip59::unwrap(recipient, gift_wrap).map_err(Nip17Error::Wrap)
}
#[must_use]
pub fn build_dm_relays_event(relays: &[RelayUrl]) -> EventBuilder {
let kind = TagKind::from_wire("relay");
let tags: Vec<Tag> = relays
.iter()
.map(|url| Tag::with(&kind, [url.as_str().to_owned()]))
.collect();
EventBuilder::new(Kind::DM_RELAYS, "").tags(tags)
}
pub fn parse_dm_relays_event(event: &Event) -> Result<Vec<RelayUrl>, Nip17Error> {
if event.kind != Kind::DM_RELAYS {
return Err(Nip17Error::UnexpectedKind(event.kind.as_u16()));
}
let relay_kind = TagKind::from_wire("relay");
let mut out = Vec::new();
for tag in &event.tags {
if tag.kind() != relay_kind {
continue;
}
let value = tag.values().get(1).ok_or(Nip17Error::MissingRelayUrl)?;
out.push(RelayUrl::parse(value)?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
use crate::event::EventId;
fn keys_alice() -> Keys {
Keys::parse("000000000000000000000000000000000000000000000000000000000000a1ce").unwrap()
}
fn keys_bob() -> Keys {
Keys::parse("00000000000000000000000000000000000000000000000000000000000000b0").unwrap()
}
fn keys_carol() -> Keys {
Keys::parse("00000000000000000000000000000000000000000000000000000000000ca800").unwrap()
}
#[test]
fn rumor_carries_p_tag_per_recipient() {
let alice = keys_alice();
let bob = keys_bob();
let carol = keys_carol();
let now = Timestamp::from_secs(1_700_000_000);
let rumor = build_chat_message_rumor(
&alice,
&[
Recipient::new(*bob.public_key()),
Recipient::new(*carol.public_key()),
],
"hello",
now,
None,
None,
);
let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
let p_tags: Vec<&Tag> = rumor.tags.iter().filter(|t| t.kind() == p_kind).collect();
assert_eq!(p_tags.len(), 2);
assert_eq!(
p_tags[0].values().get(1).unwrap(),
&bob.public_key().to_hex()
);
assert_eq!(
p_tags[1].values().get(1).unwrap(),
&carol.public_key().to_hex()
);
assert_eq!(rumor.kind, Kind::PRIVATE_DIRECT_MESSAGE);
assert_eq!(rumor.content, "hello");
}
#[test]
fn rumor_carries_subject_and_reply_tags() {
let alice = keys_alice();
let bob = keys_bob();
let now = Timestamp::from_secs(1_700_000_000);
let parent = EventId::from_byte_array([0xab; 32]);
let relay = RelayUrl::parse("wss://relay.example/").unwrap();
let rumor = build_chat_message_rumor(
&alice,
&[Recipient::new(*bob.public_key()).with_relay_hint(relay.clone())],
"thread reply",
now,
Some("daily standup"),
Some(&ReplyTo {
event_id: parent,
relay_hint: Some(relay.clone()),
}),
);
let subject_tag = rumor
.tags
.find_first(&TagKind::from_wire(SUBJECT_TAG))
.unwrap();
assert_eq!(subject_tag.values().get(1).unwrap(), "daily standup");
let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
let e_tag = rumor.tags.find_first(&e_kind).unwrap();
let e_values = e_tag.values();
assert_eq!(e_values.get(1).unwrap(), &parent.to_hex());
assert_eq!(e_values.get(2).unwrap(), relay.as_str());
assert_eq!(e_values.get(3).unwrap(), REPLY_MARKER);
}
#[test]
fn wrap_for_many_produces_self_plus_recipient_copies() {
let alice = keys_alice();
let bob = keys_bob();
let carol = keys_carol();
let now = Timestamp::from_secs(1_700_000_000);
let rumor = build_chat_message_rumor(
&alice,
&[
Recipient::new(*bob.public_key()),
Recipient::new(*carol.public_key()),
],
"hi all",
now,
None,
None,
);
let wraps = wrap_for_many(
&alice,
&[
Recipient::new(*bob.public_key()),
Recipient::new(*carol.public_key()),
],
&rumor,
nip59::Timestamps::all_at(now),
)
.unwrap();
assert_eq!(wraps.len(), 3);
let recovered_self = unwrap_chat_message(&alice, &wraps[0]).unwrap();
assert_eq!(recovered_self.content, "hi all");
let recovered_bob = unwrap_chat_message(&bob, &wraps[1]).unwrap();
assert_eq!(recovered_bob.content, "hi all");
let recovered_carol = unwrap_chat_message(&carol, &wraps[2]).unwrap();
assert_eq!(recovered_carol.content, "hi all");
}
#[test]
fn wrap_for_many_rejects_empty_recipients() {
let alice = keys_alice();
let now = Timestamp::from_secs(1_700_000_000);
let rumor = build_chat_message_rumor(&alice, &[], "ghost", now, None, None);
let err = wrap_for_many(&alice, &[], &rumor, nip59::Timestamps::all_at(now)).unwrap_err();
assert!(matches!(err, Nip17Error::NoRecipients));
}
#[test]
fn unwrap_chat_message_rejects_wrong_inner_kind() {
let alice = keys_alice();
let bob = keys_bob();
let now = Timestamp::from_secs(1_700_000_000);
let rumor = UnsignedEvent::new(
*alice.public_key(),
now,
Kind::TEXT_NOTE,
Tags::new(),
"not a DM",
);
let seal = nip59::create_seal(&alice, bob.public_key(), &rumor, now).unwrap();
let wrap = nip59::create_gift_wrap(&seal, bob.public_key(), None, now).unwrap();
let err = unwrap_chat_message(&bob, &wrap).unwrap_err();
assert!(matches!(err, Nip17Error::UnexpectedKind(1)));
let recovered = unwrap_dm_payload(&bob, &wrap).unwrap();
assert_eq!(recovered.kind, Kind::TEXT_NOTE);
}
#[test]
fn dm_relays_round_trip() {
let alice = keys_alice();
let relays = vec![
RelayUrl::parse("wss://inbox.nostr.example/").unwrap(),
RelayUrl::parse("wss://dm.nostr.example/").unwrap(),
];
let event = build_dm_relays_event(&relays)
.created_at(Timestamp::from_secs(1))
.sign_with_keys(&alice)
.unwrap();
assert_eq!(event.kind, Kind::DM_RELAYS);
let parsed = parse_dm_relays_event(&event).unwrap();
assert_eq!(parsed, relays);
}
#[test]
fn dm_relays_rejects_wrong_kind() {
let alice = keys_alice();
let event = EventBuilder::text_note("not a dm relays event")
.created_at(Timestamp::from_secs(1))
.sign_with_keys(&alice)
.unwrap();
let err = parse_dm_relays_event(&event).unwrap_err();
assert!(matches!(err, Nip17Error::UnexpectedKind(1)));
}
#[test]
fn dm_relays_rejects_missing_url() {
let alice = keys_alice();
let event = EventBuilder::new(Kind::DM_RELAYS, "")
.tag(Tag::new(["relay"]).unwrap())
.created_at(Timestamp::from_secs(1))
.sign_with_keys(&alice)
.unwrap();
let err = parse_dm_relays_event(&event).unwrap_err();
assert!(matches!(err, Nip17Error::MissingRelayUrl));
}
}