diff --git a/Cargo.lock b/Cargo.lock index 64364dd7..8e255ccc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4593,6 +4593,14 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nip29" +version = "1.0.2" +dependencies = [ + "anyhow", + "nostr-sdk", +] + [[package]] name = "no_std_io2" version = "0.9.4" diff --git a/crates/nip29/Cargo.toml b/crates/nip29/Cargo.toml new file mode 100644 index 00000000..8d299d56 --- /dev/null +++ b/crates/nip29/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "nip29" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +nostr-sdk.workspace = true +anyhow.workspace = true diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs new file mode 100644 index 00000000..87f025a7 --- /dev/null +++ b/crates/nip29/src/lib.rs @@ -0,0 +1,3 @@ +mod protocol; + +pub use protocol::*; diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs new file mode 100644 index 00000000..45bf54f5 --- /dev/null +++ b/crates/nip29/src/protocol.rs @@ -0,0 +1,551 @@ +use std::collections::hash_map::DefaultHasher; +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::str::FromStr; + +use anyhow::{Result, anyhow, bail}; +use nostr_sdk::prelude::*; + +pub const TIMELINE_WINDOW: usize = 50; + +const PREVIOUS_REFS: usize = 3; +const PREVIOUS_REF_LEN: usize = 8; + +pub const RENDER_KINDS: [Kind; 5] = [ + Kind::TextNote, + Kind::ChatMessage, + Kind::Thread, + Kind::Comment, + Kind::Reaction, +]; + +pub const STATE_KINDS: [Kind; 4] = [ + Kind::GroupMetadata, + Kind::GroupAdmins, + Kind::GroupMembers, + Kind::GroupRoles, +]; + +pub const MEMBERSHIP_KINDS: [Kind; 2] = [Kind::GroupPutUser, Kind::GroupRemoveUser]; + +fn has_tag(tags: &Tags, name: &str) -> bool { + tags.iter().any(|tag| tag.kind() == name) +} + +fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> { + tags.iter() + .find(|tag| tag.kind() == name) + .and_then(|tag| tag.content()) +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct GroupId(String); + +impl GroupId { + pub fn new(id: impl Into) -> Result { + let id = id.into(); + let id = id.trim(); + + if id.is_empty() { + bail!("group id is empty"); + } + + Ok(Self(id.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn h_tag(&self) -> Tag { + Tag::custom("h", [self.0.clone()]) + } + + pub fn timeline_filter(&self, limit: usize) -> Filter { + Filter::new() + .custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone()) + .limit(limit) + } + + pub fn state_filter(&self) -> Filter { + Filter::new().identifier(self.0.clone()).kinds(STATE_KINDS) + } + + pub fn membership_filter(&self, me: PublicKey) -> Filter { + Filter::new() + .custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone()) + .custom_tag(SingleLetterTag::LOWERCASE_P, me.to_hex()) + .kinds(MEMBERSHIP_KINDS) + } + + pub fn message( + &self, + content: &str, + replies: &[EventId], + previous: Option, + ) -> EventBuilder { + EventBuilder::new(Kind::ChatMessage, content) + .tag(self.h_tag()) + .tags(replies.iter().copied().map(Tag::event)) + .tag_maybe(previous) + } + + pub fn reaction(&self, target: EventId, emoji: &str, previous: Option) -> EventBuilder { + EventBuilder::new(Kind::Reaction, emoji) + .tag(self.h_tag()) + .tag(Tag::event(target)) + .tag_maybe(previous) + } + + pub fn join_request(&self, code: Option<&str>, reason: &str) -> EventBuilder { + EventBuilder::new(Kind::GroupJoinRequest, reason) + .tag(self.h_tag()) + .tag_maybe(code.map(|code| Tag::custom("code", [code]))) + } + + pub fn leave_request(&self, reason: &str) -> EventBuilder { + EventBuilder::new(Kind::GroupLeaveRequest, reason).tag(self.h_tag()) + } +} + +impl fmt::Display for GroupId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GroupKey { + relay: RelayUrl, + id: GroupId, +} + +impl GroupKey { + pub fn new(relay: RelayUrl, id: GroupId) -> Self { + Self { relay, id } + } + + pub fn relay(&self) -> &RelayUrl { + &self.relay + } + + pub fn id(&self) -> &GroupId { + &self.id + } + + pub fn cache_tag(&self) -> String { + format!("{}|{}", self.relay.as_str(), self.id) + } + + pub fn uniq_id(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.hash(&mut hasher); + hasher.finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupReference { + id: GroupId, + relay: RelayUrl, + invite: Option, +} + +impl GroupReference { + pub fn parse(input: &str) -> Result { + let input = input.trim(); + + let (address, query) = match input.split_once('?') { + Some((address, query)) => (address, Some(query)), + None => (input, None), + }; + + let coordinate = Nip19Coordinate::from_bech32(address)?; + + if coordinate.kind != Kind::GroupMetadata { + bail!("not a group reference: kind {}", coordinate.kind); + } + + let relay = coordinate + .relays + .first() + .cloned() + .ok_or_else(|| anyhow!("group reference has no relay hint"))?; + + let id = GroupId::new(coordinate.identifier.as_str())?; + + let invite = query + .and_then(|query| { + query + .split('&') + .find_map(|pair| pair.strip_prefix("invite=")) + }) + .map(str::to_owned); + + Ok(Self { id, relay, invite }) + } + + pub fn id(&self) -> &GroupId { + &self.id + } + + pub fn relay(&self) -> &RelayUrl { + &self.relay + } + + pub fn invite(&self) -> Option<&str> { + self.invite.as_deref() + } + + pub fn key(&self) -> GroupKey { + GroupKey::new(self.relay.clone(), self.id.clone()) + } +} + +impl FromStr for GroupReference { + type Err = anyhow::Error; + + fn from_str(input: &str) -> Result { + Self::parse(input) + } +} + +#[derive(Debug, Clone)] +pub struct GroupMetadata { + pub name: Option, + pub picture: Option, + pub banner: Option, + pub about: Option, + pub private: bool, + pub restricted: bool, + pub hidden: bool, + pub closed: bool, + pub livekit: bool, + pub parent: Option, + pub supported_kinds: Option>, + id: GroupId, + author: PublicKey, +} + +impl GroupMetadata { + pub fn parse(event: &Event) -> Result { + if event.kind != Kind::GroupMetadata { + bail!("not a group metadata event: kind {}", event.kind); + } + + let identifier = event + .tags + .identifier() + .ok_or_else(|| anyhow!("group metadata has no d tag"))?; + + let mut supported_kinds: Option> = None; + + for tag in event + .tags + .iter() + .filter(|tag| tag.kind() == "supported_kinds") + { + let kinds = supported_kinds.get_or_insert_with(Vec::new); + + for value in tag.as_slice().iter().skip(1) { + kinds.push(Kind::from_str(value)?); + } + } + + let parent = match tag_value(&event.tags, "parent") { + Some(parent) => Some(GroupId::new(parent)?), + None => None, + }; + + Ok(Self { + name: tag_value(&event.tags, "name").map(str::to_owned), + picture: tag_value(&event.tags, "picture").map(str::to_owned), + banner: tag_value(&event.tags, "banner").map(str::to_owned), + about: tag_value(&event.tags, "about").map(str::to_owned), + private: has_tag(&event.tags, "private"), + restricted: has_tag(&event.tags, "restricted"), + hidden: has_tag(&event.tags, "hidden"), + closed: has_tag(&event.tags, "closed"), + livekit: has_tag(&event.tags, "livekit"), + parent, + supported_kinds, + id: GroupId::new(identifier)?, + author: event.pubkey, + }) + } + + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + pub fn supports(&self, kind: Kind) -> bool { + match &self.supported_kinds { + None => true, + Some(kinds) => kinds.contains(&kind), + } + } + + pub fn naddr(&self, relay: &RelayUrl, invite: Option<&str>) -> Result { + let coordinate = + Coordinate::new(Kind::GroupMetadata, self.author).identifier(self.id.as_str()); + let address = Nip19Coordinate::new(coordinate, [relay.clone()]).to_bech32()?; + + Ok(match invite { + Some(invite) => format!("{address}?invite={invite}"), + None => address, + }) + } +} + +#[derive(Debug, Clone, Default)] +pub struct GroupAdmins(Vec<(PublicKey, Vec)>); + +impl GroupAdmins { + pub fn parse(event: &Event) -> Result { + if event.kind != Kind::GroupAdmins { + bail!("not a group admins event: kind {}", event.kind); + } + + let mut admins = Vec::new(); + + for tag in event.tags.iter().filter(|tag| tag.kind() == "p") { + let mut fields = tag.as_slice().iter().skip(1); + + let public_key = fields + .next() + .ok_or_else(|| anyhow!("admin tag has no public key"))?; + let public_key = PublicKey::from_hex(public_key)?; + + admins.push((public_key, fields.cloned().collect())); + } + + Ok(Self(admins)) + } + + pub fn entries(&self) -> &[(PublicKey, Vec)] { + &self.0 + } + + pub fn contains(&self, public_key: &PublicKey) -> bool { + self.0.iter().any(|(key, _)| key == public_key) + } +} + +#[derive(Debug, Clone, Default)] +pub struct GroupMembers(Vec); + +impl GroupMembers { + pub fn parse(event: &Event) -> Result { + if event.kind != Kind::GroupMembers { + bail!("not a group members event: kind {}", event.kind); + } + + Ok(Self(event.tags.public_keys().collect())) + } + + pub fn contains(&self, public_key: &PublicKey) -> bool { + self.0.contains(public_key) + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Role { + pub name: String, + pub description: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct GroupRoles(Vec); + +impl GroupRoles { + pub fn parse(event: &Event) -> Result { + if event.kind != Kind::GroupRoles { + bail!("not a group roles event: kind {}", event.kind); + } + + let mut roles = Vec::new(); + + for tag in event.tags.iter().filter(|tag| tag.kind() == "role") { + let mut fields = tag.as_slice().iter().skip(1); + let name = fields + .next() + .ok_or_else(|| anyhow!("role tag has no name"))?; + + roles.push(Role { + name: name.clone(), + description: fields.next().cloned(), + }); + } + + Ok(Self(roles)) + } + + pub fn roles(&self) -> &[Role] { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupListEntry { + pub id: GroupId, + pub relay: RelayUrl, + pub name: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct GroupList(Vec); + +impl GroupList { + pub fn parse(event: &Event) -> Result { + if event.kind != Kind::SimpleGroups { + bail!("not a group list event: kind {}", event.kind); + } + + let mut entries = Vec::new(); + + for tag in event.tags.iter().filter(|tag| tag.kind() == "group") { + let fields = tag.as_slice(); + + let (Some(id), Some(relay)) = (fields.get(1), fields.get(2)) else { + continue; + }; + + let Ok(id) = GroupId::new(id.as_str()) else { + continue; + }; + let Ok(relay) = RelayUrl::parse(relay.as_str()) else { + continue; + }; + + entries.push(GroupListEntry { + id, + relay, + name: fields.get(3).cloned(), + }); + } + + Ok(Self(entries)) + } + + pub fn entries(&self) -> &[GroupListEntry] { + &self.0 + } + + pub fn upsert(&mut self, entry: GroupListEntry) { + match self + .0 + .iter_mut() + .find(|existing| existing.id == entry.id && existing.relay == entry.relay) + { + Some(existing) => *existing = entry, + None => self.0.push(entry), + } + } + + pub fn remove(&mut self, key: &GroupKey) { + self.0 + .retain(|entry| entry.id != *key.id() || entry.relay != *key.relay()); + } + + pub fn to_builder(&self) -> EventBuilder { + let mut tags: Vec = Vec::with_capacity(self.0.len() * 2); + + for entry in &self.0 { + let mut values = vec![ + entry.id.as_str().to_owned(), + entry.relay.as_str().to_owned(), + ]; + + if let Some(name) = &entry.name { + values.push(name.clone()); + } + + tags.push(Tag::custom("group", values)); + tags.push(Tag::custom("r", [entry.relay.as_str()])); + } + + EventBuilder::new(Kind::SimpleGroups, "").tags(tags) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Membership { + Unknown, + Pending { since: Timestamp }, + Member, + Refused { reason: Option }, + Removed, +} + +impl Membership { + pub fn from_events(events: &[Event], me: PublicKey) -> Self { + let mut latest: Option<&Event> = None; + + for event in events { + if !MEMBERSHIP_KINDS.contains(&event.kind) { + continue; + } + + if !event.tags.public_keys().any(|key| key == me) { + continue; + } + + let newer = match latest { + None => true, + Some(current) => { + event.created_at > current.created_at + || (event.created_at == current.created_at + && current.kind == Kind::GroupPutUser + && event.kind == Kind::GroupRemoveUser) + } + }; + + if newer { + latest = Some(event); + } + } + + match latest { + Some(event) if event.kind == Kind::GroupRemoveUser => Membership::Removed, + Some(_) => Membership::Member, + None => Membership::Unknown, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct TimelineWindow(Vec); + +impl TimelineWindow { + pub fn new() -> Self { + Self(Vec::new()) + } + + pub fn push(&mut self, event: Event) { + self.0.retain(|existing| existing.id != event.id); + let position = self.0.partition_point(|e| e.created_at > event.created_at); + + self.0.insert(position, event); + self.0.truncate(TIMELINE_WINDOW); + } + + pub fn previous_tag(&self, me: PublicKey) -> Option { + let references: Vec = self + .0 + .iter() + .filter(|event| event.pubkey != me) + .take(PREVIOUS_REFS) + .map(|event| event.id.to_hex().chars().take(PREVIOUS_REF_LEN).collect()) + .collect(); + + if references.is_empty() { + None + } else { + Some(Tag::custom("previous", references)) + } + } +}