add chat plane
This commit is contained in:
@@ -0,0 +1,854 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::derive::channel_group_key;
|
||||
use crate::edition::canonical_decimal;
|
||||
use crate::stream::{
|
||||
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms,
|
||||
build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict,
|
||||
wrap_seal,
|
||||
};
|
||||
use crate::{ChannelId, Epoch, GroupKey, decode_hex_32};
|
||||
|
||||
pub const KIND_MESSAGE: u16 = 9;
|
||||
pub const KIND_COMMENT: u16 = 1111;
|
||||
pub const KIND_REACTION: u16 = 7;
|
||||
pub const KIND_DELETE: u16 = 5;
|
||||
pub const KIND_EDIT: u16 = 3302;
|
||||
pub const KIND_FILE: u16 = 15;
|
||||
pub const KIND_WEBXDC: u16 = 3310;
|
||||
pub const KIND_TYPING: u16 = 23311;
|
||||
|
||||
const TAG_QUOTE: &str = "q";
|
||||
const TAG_TARGET: &str = "e";
|
||||
const TAG_TARGET_KIND: &str = "k";
|
||||
const TAG_ROOT: &str = "E";
|
||||
const TAG_ROOT_KIND: &str = "K";
|
||||
const TAG_ROOT_AUTHOR: &str = "P";
|
||||
const TAG_TARGET_AUTHOR: &str = "p";
|
||||
const TAG_EXPIRATION: &str = "expiration";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ChatError {
|
||||
Stream(StreamError),
|
||||
NotEncryptedSealed,
|
||||
UnknownKind(u16),
|
||||
MissingTag(&'static str),
|
||||
DuplicateTag(&'static str),
|
||||
BadTag(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for ChatError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ChatError::Stream(error) => write!(f, "stream: {error}"),
|
||||
ChatError::NotEncryptedSealed => write!(f, "chat rumor must ride an encrypted seal"),
|
||||
ChatError::UnknownKind(kind) => write!(f, "not a chat rumor kind: {kind}"),
|
||||
ChatError::MissingTag(name) => write!(f, "missing chat tag: {name}"),
|
||||
ChatError::DuplicateTag(name) => write!(f, "duplicate chat tag: {name}"),
|
||||
ChatError::BadTag(name) => write!(f, "malformed chat tag: {name}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ChatError {}
|
||||
|
||||
impl From<StreamError> for ChatError {
|
||||
fn from(error: StreamError) -> Self {
|
||||
ChatError::Stream(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// A chat event another chat event refers to: a quote, a comment's parent, a
|
||||
/// reaction's target. The author slot is a SHOULD on the wire, so it is optional.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ReplyRef {
|
||||
pub id: EventId,
|
||||
pub author: Option<PublicKey>,
|
||||
}
|
||||
|
||||
/// A reference that also names the referenced event's kind, which a comment
|
||||
/// (`K`/`k`) and a reaction (`k`) must commit on the wire.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Target {
|
||||
pub reply: ReplyRef,
|
||||
pub kind: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ChatAction {
|
||||
Message {
|
||||
reply_to: Option<ReplyRef>,
|
||||
thread_root: Option<ReplyRef>,
|
||||
},
|
||||
Reaction {
|
||||
target: EventId,
|
||||
emoji: String,
|
||||
},
|
||||
Edit {
|
||||
target: EventId,
|
||||
content: String,
|
||||
},
|
||||
Delete {
|
||||
target: EventId,
|
||||
target_kind: Option<u16>,
|
||||
},
|
||||
Typing,
|
||||
Opaque,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatRumor {
|
||||
pub id: EventId,
|
||||
pub author: PublicKey,
|
||||
pub kind: Kind,
|
||||
pub channel: ChannelId,
|
||||
pub epoch: Epoch,
|
||||
pub at_ms: u64,
|
||||
pub content: String,
|
||||
pub expiration: Option<Timestamp>,
|
||||
pub action: ChatAction,
|
||||
}
|
||||
|
||||
/// A channel's timeline row, with every edit, delete and reaction folded in.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChatMessage {
|
||||
pub id: EventId,
|
||||
pub author: PublicKey,
|
||||
pub channel: ChannelId,
|
||||
pub epoch: Epoch,
|
||||
pub kind: Kind,
|
||||
pub content: String,
|
||||
pub reply_to: Option<EventId>,
|
||||
pub thread_root: Option<EventId>,
|
||||
pub at_ms: u64,
|
||||
pub expiration: Option<Timestamp>,
|
||||
pub edited_at: Option<u64>,
|
||||
pub deleted: bool,
|
||||
pub reactions: BTreeMap<PublicKey, String>,
|
||||
}
|
||||
|
||||
pub fn build_message(
|
||||
author: PublicKey,
|
||||
channel: &ChannelId,
|
||||
epoch: Epoch,
|
||||
content: &str,
|
||||
quote: Option<&ReplyRef>,
|
||||
at_ms: u64,
|
||||
) -> UnsignedEvent {
|
||||
let mut tags = channel_binding_tags(channel, epoch);
|
||||
|
||||
if let Some(quote) = quote {
|
||||
tags.push(reply_tag(TAG_QUOTE, quote));
|
||||
}
|
||||
|
||||
build_rumor_ms(KIND_MESSAGE, author, content, tags, at_ms)
|
||||
}
|
||||
|
||||
/// A NIP-22 comment. `parent` is the immediate parent and `root` the thread's
|
||||
/// immutable root; `None` means the parent is itself the root.
|
||||
pub fn build_comment(
|
||||
author: PublicKey,
|
||||
channel: &ChannelId,
|
||||
epoch: Epoch,
|
||||
content: &str,
|
||||
parent: &Target,
|
||||
root: Option<&Target>,
|
||||
at_ms: u64,
|
||||
) -> UnsignedEvent {
|
||||
let root = root.unwrap_or(parent);
|
||||
let mut tags = channel_binding_tags(channel, epoch);
|
||||
|
||||
tags.push(Tag::custom(TAG_ROOT_KIND, [root.kind.to_string()]));
|
||||
tags.push(reply_tag(TAG_ROOT, &root.reply));
|
||||
if let Some(root_author) = root.reply.author {
|
||||
tags.push(Tag::custom(TAG_ROOT_AUTHOR, [root_author.to_hex()]));
|
||||
}
|
||||
|
||||
tags.push(Tag::custom(TAG_TARGET_KIND, [parent.kind.to_string()]));
|
||||
tags.push(reply_tag(TAG_TARGET, &parent.reply));
|
||||
if let Some(parent_author) = parent.reply.author {
|
||||
tags.push(Tag::custom(TAG_TARGET_AUTHOR, [parent_author.to_hex()]));
|
||||
}
|
||||
|
||||
build_rumor_ms(KIND_COMMENT, author, content, tags, at_ms)
|
||||
}
|
||||
|
||||
pub fn build_reaction(
|
||||
author: PublicKey,
|
||||
channel: &ChannelId,
|
||||
epoch: Epoch,
|
||||
target: &Target,
|
||||
emoji: &str,
|
||||
at_ms: u64,
|
||||
) -> UnsignedEvent {
|
||||
let mut tags = channel_binding_tags(channel, epoch);
|
||||
|
||||
tags.push(Tag::custom(TAG_TARGET, [target.reply.id.to_hex()]));
|
||||
if let Some(target_author) = target.reply.author {
|
||||
tags.push(Tag::custom(TAG_TARGET_AUTHOR, [target_author.to_hex()]));
|
||||
}
|
||||
tags.push(Tag::custom(TAG_TARGET_KIND, [target.kind.to_string()]));
|
||||
|
||||
build_rumor_ms(KIND_REACTION, author, emoji, tags, at_ms)
|
||||
}
|
||||
|
||||
pub fn build_edit(
|
||||
author: PublicKey,
|
||||
channel: &ChannelId,
|
||||
epoch: Epoch,
|
||||
target: EventId,
|
||||
content: &str,
|
||||
at_ms: u64,
|
||||
) -> UnsignedEvent {
|
||||
let mut tags = channel_binding_tags(channel, epoch);
|
||||
tags.push(Tag::custom(TAG_TARGET, [target.to_hex()]));
|
||||
|
||||
build_rumor_ms(KIND_EDIT, author, content, tags, at_ms)
|
||||
}
|
||||
|
||||
pub fn build_delete(
|
||||
author: PublicKey,
|
||||
channel: &ChannelId,
|
||||
epoch: Epoch,
|
||||
target: EventId,
|
||||
target_kind: Option<u16>,
|
||||
at_ms: u64,
|
||||
) -> UnsignedEvent {
|
||||
let mut tags = channel_binding_tags(channel, epoch);
|
||||
tags.push(Tag::custom(TAG_TARGET, [target.to_hex()]));
|
||||
|
||||
if let Some(target_kind) = target_kind {
|
||||
tags.push(Tag::custom(TAG_TARGET_KIND, [target_kind.to_string()]));
|
||||
}
|
||||
|
||||
build_rumor_ms(KIND_DELETE, author, "", tags, at_ms)
|
||||
}
|
||||
|
||||
pub fn build_typing(
|
||||
author: PublicKey,
|
||||
channel: &ChannelId,
|
||||
epoch: Epoch,
|
||||
at_ms: u64,
|
||||
) -> UnsignedEvent {
|
||||
build_rumor_ms(
|
||||
KIND_TYPING,
|
||||
author,
|
||||
"",
|
||||
channel_binding_tags(channel, epoch),
|
||||
at_ms,
|
||||
)
|
||||
}
|
||||
|
||||
/// Seals a chat rumor and wraps it at the channel's address. `ephemeral` picks
|
||||
/// the 21059 wrap, which relays must not store.
|
||||
pub fn seal_rumor(
|
||||
rumor: &UnsignedEvent,
|
||||
group: &GroupKey,
|
||||
author: &Keys,
|
||||
ephemeral: bool,
|
||||
) -> Result<(Event, Keys), ChatError> {
|
||||
let kind = rumor.kind.as_u16();
|
||||
|
||||
if !is_chat_kind(kind) {
|
||||
return Err(ChatError::UnknownKind(kind));
|
||||
}
|
||||
|
||||
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
|
||||
let wrap_kind = if ephemeral {
|
||||
KIND_WRAP_EPHEMERAL
|
||||
} else {
|
||||
KIND_WRAP
|
||||
};
|
||||
|
||||
// CORD-08 §2: a NIP-40 expiration rides the wrap as well, so relays drop the
|
||||
// stored event on schedule; the inner copy is what drives a local purge.
|
||||
let expiration: Vec<Tag> = rumor
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some(TAG_EXPIRATION))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
Ok(wrap_seal(
|
||||
&seal,
|
||||
group,
|
||||
wrap_kind,
|
||||
rumor.created_at,
|
||||
&expiration,
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Opens a wrap against the plane whose key is tried. The channel and epoch the
|
||||
/// rumor claims must both be the ones that opened it, so a keyholder of two
|
||||
/// planes cannot re-seal a rumor elsewhere or replay it across an epoch.
|
||||
pub fn open(
|
||||
wrap: &Event,
|
||||
group: &GroupKey,
|
||||
channel: &ChannelId,
|
||||
epoch: Epoch,
|
||||
) -> Result<(OpenedStream, ChatRumor), ChatError> {
|
||||
let opened = open_wrap(wrap, group)?;
|
||||
|
||||
if opened.seal_form != SealForm::Encrypted {
|
||||
return Err(ChatError::NotEncryptedSealed);
|
||||
}
|
||||
|
||||
check_channel_binding(&opened.rumor, channel, epoch)?;
|
||||
|
||||
let chat = typed(&opened.rumor, channel, epoch)?;
|
||||
|
||||
Ok((opened, chat))
|
||||
}
|
||||
|
||||
/// Every epoch's group key for one channel. `secret` is whatever feeds the
|
||||
/// channel at that epoch: the `community_root` for a public one, its own key
|
||||
/// for a private one.
|
||||
pub fn plane_keys(
|
||||
held: &[(Epoch, [u8; 32])],
|
||||
channel: &ChannelId,
|
||||
) -> Result<Vec<(Epoch, GroupKey)>> {
|
||||
held.iter()
|
||||
.map(|(epoch, secret)| Ok((*epoch, channel_group_key(secret, channel, *epoch)?)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Folds the chat plane into timeline rows, newest first. A delete is honored
|
||||
/// only from the message's own author, and a deletion is terminal: an edit or a
|
||||
/// reaction arriving later never revives it.
|
||||
pub fn fold(rumors: &[ChatRumor]) -> Vec<ChatMessage> {
|
||||
let mut order: Vec<usize> = (0..rumors.len()).collect();
|
||||
order.sort_by_key(|&index| (rumors[index].at_ms, rumors[index].id));
|
||||
|
||||
let mut messages: Vec<ChatMessage> = Vec::new();
|
||||
let mut slot: BTreeMap<EventId, usize> = BTreeMap::new();
|
||||
|
||||
for index in order {
|
||||
let rumor = &rumors[index];
|
||||
|
||||
let ChatAction::Message {
|
||||
reply_to,
|
||||
thread_root,
|
||||
} = &rumor.action
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
slot.insert(rumor.id, messages.len());
|
||||
messages.push(ChatMessage {
|
||||
id: rumor.id,
|
||||
author: rumor.author,
|
||||
channel: rumor.channel,
|
||||
epoch: rumor.epoch,
|
||||
kind: rumor.kind,
|
||||
content: rumor.content.clone(),
|
||||
reply_to: reply_to.map(|reply| reply.id),
|
||||
thread_root: thread_root.map(|reply| reply.id),
|
||||
at_ms: rumor.at_ms,
|
||||
expiration: rumor.expiration,
|
||||
edited_at: None,
|
||||
deleted: false,
|
||||
reactions: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Mutations replay so the last one applied is the winner: the highest
|
||||
// `at_ms` and, between equal ones, the lower inner rumor id.
|
||||
let mut mutations: Vec<usize> = (0..rumors.len()).collect();
|
||||
mutations.sort_by_key(|&index| (rumors[index].at_ms, Reverse(rumors[index].id)));
|
||||
|
||||
for index in mutations {
|
||||
let rumor = &rumors[index];
|
||||
|
||||
match &rumor.action {
|
||||
ChatAction::Edit { target, content } => {
|
||||
let Some(&slot) = slot.get(target) else {
|
||||
continue;
|
||||
};
|
||||
let message = &mut messages[slot];
|
||||
|
||||
if message.deleted || message.author != rumor.author {
|
||||
continue;
|
||||
}
|
||||
|
||||
message.content = content.clone();
|
||||
message.edited_at = Some(rumor.at_ms);
|
||||
}
|
||||
ChatAction::Delete { target, .. } => {
|
||||
let Some(&slot) = slot.get(target) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if messages[slot].author == rumor.author {
|
||||
messages[slot].deleted = true;
|
||||
}
|
||||
}
|
||||
ChatAction::Reaction { target, emoji } => {
|
||||
let Some(&slot) = slot.get(target) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
messages[slot].reactions.insert(rumor.author, emoji.clone());
|
||||
}
|
||||
ChatAction::Message { .. } | ChatAction::Typing | ChatAction::Opaque => {}
|
||||
}
|
||||
}
|
||||
|
||||
messages.sort_by_key(|message| (Reverse(message.at_ms), message.id));
|
||||
|
||||
messages
|
||||
}
|
||||
|
||||
fn is_chat_kind(kind: u16) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
KIND_MESSAGE
|
||||
| KIND_COMMENT
|
||||
| KIND_REACTION
|
||||
| KIND_DELETE
|
||||
| KIND_EDIT
|
||||
| KIND_FILE
|
||||
| KIND_WEBXDC
|
||||
| KIND_TYPING
|
||||
)
|
||||
}
|
||||
|
||||
fn typed(rumor: &UnsignedEvent, channel: &ChannelId, epoch: Epoch) -> Result<ChatRumor, ChatError> {
|
||||
Ok(ChatRumor {
|
||||
id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
|
||||
author: rumor.pubkey,
|
||||
kind: rumor.kind,
|
||||
channel: *channel,
|
||||
epoch,
|
||||
at_ms: resolve_ms_strict(rumor)?,
|
||||
content: rumor.content.clone(),
|
||||
expiration: expiration_of(rumor)?,
|
||||
action: action_of(rumor)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn action_of(rumor: &UnsignedEvent) -> Result<ChatAction, ChatError> {
|
||||
let kind = rumor.kind.as_u16();
|
||||
|
||||
match kind {
|
||||
KIND_MESSAGE | KIND_FILE => Ok(ChatAction::Message {
|
||||
reply_to: optional_reply(rumor, TAG_QUOTE)?,
|
||||
thread_root: None,
|
||||
}),
|
||||
KIND_COMMENT => Ok(ChatAction::Message {
|
||||
reply_to: optional_reply(rumor, TAG_TARGET)?,
|
||||
thread_root: optional_reply(rumor, TAG_ROOT)?,
|
||||
}),
|
||||
KIND_REACTION => Ok(ChatAction::Reaction {
|
||||
target: required_id(rumor, TAG_TARGET)?,
|
||||
emoji: rumor.content.clone(),
|
||||
}),
|
||||
KIND_EDIT => Ok(ChatAction::Edit {
|
||||
target: required_id(rumor, TAG_TARGET)?,
|
||||
content: rumor.content.clone(),
|
||||
}),
|
||||
KIND_DELETE => Ok(ChatAction::Delete {
|
||||
target: required_id(rumor, TAG_TARGET)?,
|
||||
target_kind: optional_kind(rumor, TAG_TARGET_KIND)?,
|
||||
}),
|
||||
KIND_TYPING => Ok(ChatAction::Typing),
|
||||
KIND_WEBXDC => Ok(ChatAction::Opaque),
|
||||
other => Err(ChatError::UnknownKind(other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_reply(
|
||||
rumor: &UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<ReplyRef>, ChatError> {
|
||||
let Some(fields) = tag(rumor, name)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// NIP-C7 `q` and NIP-22 `E`/`e` put a relay hint at index 2 and the
|
||||
// referenced author at index 3, which is a SHOULD, so absent reads as unknown.
|
||||
let author = match fields.get(3).map(String::as_str) {
|
||||
Some(hex) if !hex.is_empty() => Some(pubkey(hex, name)?),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(Some(ReplyRef {
|
||||
id: hex_id(fields, name)?,
|
||||
author,
|
||||
}))
|
||||
}
|
||||
|
||||
fn required_id(rumor: &UnsignedEvent, name: &'static str) -> Result<EventId, ChatError> {
|
||||
let fields = tag(rumor, name)?.ok_or(ChatError::MissingTag(name))?;
|
||||
hex_id(fields, name)
|
||||
}
|
||||
|
||||
fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<u16>, ChatError> {
|
||||
let Some(fields) = tag(rumor, name)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let raw = value(fields, name)?;
|
||||
let kind = canonical_decimal(raw).ok_or(ChatError::BadTag(name))?;
|
||||
|
||||
u16::try_from(kind)
|
||||
.map(Some)
|
||||
.map_err(|_| ChatError::BadTag(name))
|
||||
}
|
||||
|
||||
fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
|
||||
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let seconds = canonical_decimal(value(fields, TAG_EXPIRATION)?)
|
||||
.ok_or(ChatError::BadTag(TAG_EXPIRATION))?;
|
||||
|
||||
Ok(Some(Timestamp::from_secs(seconds)))
|
||||
}
|
||||
|
||||
fn reply_tag(name: &str, reply: &ReplyRef) -> Tag {
|
||||
Tag::custom(
|
||||
name,
|
||||
[
|
||||
reply.id.to_hex(),
|
||||
String::new(),
|
||||
reply
|
||||
.author
|
||||
.map(|author| author.to_hex())
|
||||
.unwrap_or_default(),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn tag<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<&'a [String]>, ChatError> {
|
||||
let mut found: Option<&[String]> = None;
|
||||
|
||||
for candidate in rumor.tags.iter() {
|
||||
let fields = candidate.as_slice();
|
||||
|
||||
if fields.first().map(String::as_str) != Some(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if found.is_some() {
|
||||
return Err(ChatError::DuplicateTag(name));
|
||||
}
|
||||
|
||||
found = Some(fields);
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, ChatError> {
|
||||
fields
|
||||
.get(1)
|
||||
.map(String::as_str)
|
||||
.ok_or(ChatError::BadTag(name))
|
||||
}
|
||||
|
||||
fn hex_id(fields: &[String], name: &'static str) -> Result<EventId, ChatError> {
|
||||
let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?;
|
||||
|
||||
EventId::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
|
||||
}
|
||||
|
||||
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, ChatError> {
|
||||
let bytes = decode_hex_32(hex).map_err(|_| ChatError::BadTag(name))?;
|
||||
|
||||
PublicKey::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SECRET: [u8; 32] = [0x2du8; 32];
|
||||
const AT: u64 = 1_700_000_000_417;
|
||||
|
||||
fn channel() -> ChannelId {
|
||||
ChannelId::from_bytes([0x9cu8; 32])
|
||||
}
|
||||
|
||||
fn group() -> GroupKey {
|
||||
channel_group_key(&SECRET, &channel(), Epoch(0)).expect("derives")
|
||||
}
|
||||
|
||||
fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event {
|
||||
seal_rumor(rumor, group, author, false).expect("seals").0
|
||||
}
|
||||
|
||||
fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor {
|
||||
open(&sealed(rumor, group, author), group, &channel(), epoch)
|
||||
.expect("opens")
|
||||
.1
|
||||
}
|
||||
|
||||
fn target(id: EventId, author: &Keys) -> Target {
|
||||
Target {
|
||||
reply: ReplyRef {
|
||||
id,
|
||||
author: Some(author.public_key()),
|
||||
},
|
||||
kind: KIND_MESSAGE,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_holder_folds_edits_reactions_and_a_self_delete() {
|
||||
let alice = Keys::generate();
|
||||
let carol = Keys::generate();
|
||||
let group = group();
|
||||
|
||||
let message = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
|
||||
let id = message.compute_id();
|
||||
|
||||
let rumors = vec![
|
||||
read(&message, &group, &alice, Epoch(0)),
|
||||
read(
|
||||
&build_reaction(
|
||||
carol.public_key(),
|
||||
&channel(),
|
||||
Epoch(0),
|
||||
&target(id, &alice),
|
||||
"🔥",
|
||||
AT + 1_000,
|
||||
),
|
||||
&group,
|
||||
&carol,
|
||||
Epoch(0),
|
||||
),
|
||||
read(
|
||||
&build_edit(
|
||||
alice.public_key(),
|
||||
&channel(),
|
||||
Epoch(0),
|
||||
id,
|
||||
"hello (fixed)",
|
||||
AT + 2_000,
|
||||
),
|
||||
&group,
|
||||
&alice,
|
||||
Epoch(0),
|
||||
),
|
||||
read(
|
||||
&build_delete(
|
||||
alice.public_key(),
|
||||
&channel(),
|
||||
Epoch(0),
|
||||
id,
|
||||
Some(KIND_MESSAGE),
|
||||
AT + 3_000,
|
||||
),
|
||||
&group,
|
||||
&alice,
|
||||
Epoch(0),
|
||||
),
|
||||
];
|
||||
|
||||
let folded = fold(&rumors);
|
||||
|
||||
assert_eq!(folded.len(), 1);
|
||||
assert_eq!(folded[0].id, id);
|
||||
assert_eq!(folded[0].content, "hello (fixed)");
|
||||
assert_eq!(folded[0].edited_at, Some(AT + 2_000));
|
||||
assert_eq!(
|
||||
folded[0].reactions.get(&carol.public_key()),
|
||||
Some(&"🔥".to_owned())
|
||||
);
|
||||
assert!(folded[0].deleted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_or_delete_from_another_author_is_ignored() {
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
let group = group();
|
||||
|
||||
let message = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
|
||||
let id = message.compute_id();
|
||||
|
||||
let rumors = vec![
|
||||
read(&message, &group, &alice, Epoch(0)),
|
||||
read(
|
||||
&build_edit(
|
||||
bob.public_key(),
|
||||
&channel(),
|
||||
Epoch(0),
|
||||
id,
|
||||
"mine now",
|
||||
AT + 1_000,
|
||||
),
|
||||
&group,
|
||||
&bob,
|
||||
Epoch(0),
|
||||
),
|
||||
read(
|
||||
&build_delete(
|
||||
bob.public_key(),
|
||||
&channel(),
|
||||
Epoch(0),
|
||||
id,
|
||||
Some(KIND_MESSAGE),
|
||||
AT + 2_000,
|
||||
),
|
||||
&group,
|
||||
&bob,
|
||||
Epoch(0),
|
||||
),
|
||||
];
|
||||
|
||||
let folded = fold(&rumors);
|
||||
|
||||
assert_eq!(folded.len(), 1);
|
||||
assert_eq!(folded[0].content, "hello");
|
||||
assert_eq!(folded[0].edited_at, None);
|
||||
assert!(!folded[0].deleted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comment_carries_its_root_and_its_parent() {
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
let group = group();
|
||||
|
||||
let root = build_message(alice.public_key(), &channel(), Epoch(0), "root", None, AT);
|
||||
let root_id = root.compute_id();
|
||||
let parent = build_message(
|
||||
bob.public_key(),
|
||||
&channel(),
|
||||
Epoch(0),
|
||||
"parent",
|
||||
None,
|
||||
AT + 1_000,
|
||||
);
|
||||
let parent_id = parent.compute_id();
|
||||
|
||||
let comment = build_comment(
|
||||
alice.public_key(),
|
||||
&channel(),
|
||||
Epoch(0),
|
||||
"deep",
|
||||
&target(parent_id, &bob),
|
||||
Some(&target(root_id, &alice)),
|
||||
AT + 2_000,
|
||||
);
|
||||
|
||||
assert!(comment.tags.iter().any(|tag| tag.as_slice() == ["K", "9"]));
|
||||
assert!(
|
||||
comment
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| { tag.as_slice()[0] == "E" && tag.as_slice()[1] == root_id.to_hex() })
|
||||
);
|
||||
assert!(
|
||||
comment
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| { tag.as_slice()[0] == "e" && tag.as_slice()[1] == parent_id.to_hex() })
|
||||
);
|
||||
|
||||
let rumor = read(&comment, &group, &alice, Epoch(0));
|
||||
let ChatAction::Message {
|
||||
reply_to,
|
||||
thread_root,
|
||||
} = &rumor.action
|
||||
else {
|
||||
panic!("a comment is a message row")
|
||||
};
|
||||
|
||||
assert_eq!(reply_to.map(|reply| reply.id), Some(parent_id));
|
||||
assert_eq!(thread_root.map(|root| root.id), Some(root_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rumor_bound_to_another_channel_or_epoch_is_rejected() {
|
||||
let alice = Keys::generate();
|
||||
let group = group();
|
||||
|
||||
let plain = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
|
||||
assert!(
|
||||
open(
|
||||
&sealed(&plain, &group, &alice),
|
||||
&group,
|
||||
&channel(),
|
||||
Epoch(0)
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
// The keyholder re-addresses their own rumor: the binding is judged
|
||||
// against the plane whose key opened the wrap, never the rumor's claim.
|
||||
let elsewhere = ChannelId::from_bytes([0xeeu8; 32]);
|
||||
assert!(matches!(
|
||||
open(
|
||||
&sealed(&plain, &group, &alice),
|
||||
&group,
|
||||
&elsewhere,
|
||||
Epoch(0)
|
||||
),
|
||||
Err(ChatError::Stream(StreamError::ChannelMismatch))
|
||||
));
|
||||
|
||||
let stale = build_message(alice.public_key(), &channel(), Epoch(1), "stale", None, AT);
|
||||
assert!(matches!(
|
||||
open(
|
||||
&sealed(&stale, &group, &alice),
|
||||
&group,
|
||||
&channel(),
|
||||
Epoch(0)
|
||||
),
|
||||
Err(ChatError::Stream(StreamError::EpochMismatch))
|
||||
));
|
||||
|
||||
// Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a
|
||||
// chat rumor however well-formed it looks.
|
||||
let seal = build_seal(&plain, SealForm::Plaintext, &group, &alice).expect("seals");
|
||||
let (wrap, _) = wrap_seal(
|
||||
&seal,
|
||||
&group,
|
||||
KIND_WRAP,
|
||||
Timestamp::from_secs(AT / 1000),
|
||||
&[],
|
||||
)
|
||||
.expect("wraps");
|
||||
assert!(matches!(
|
||||
open(&wrap, &group, &channel(), Epoch(0)),
|
||||
Err(ChatError::NotEncryptedSealed)
|
||||
));
|
||||
|
||||
let ghost = build_rumor_ms(
|
||||
3300,
|
||||
alice.public_key(),
|
||||
"v1 ghost",
|
||||
channel_binding_tags(&channel(), Epoch(0)),
|
||||
AT,
|
||||
);
|
||||
assert!(matches!(
|
||||
seal_rumor(&ghost, &group, &alice, false),
|
||||
Err(ChatError::UnknownKind(3300))
|
||||
));
|
||||
|
||||
let mut tags = channel_binding_tags(&channel(), Epoch(0));
|
||||
tags.push(Tag::custom(TAG_TARGET, ["ab".repeat(32)]));
|
||||
tags.push(Tag::custom(TAG_TARGET, ["cd".repeat(32)]));
|
||||
let ambiguous = build_rumor_ms(KIND_DELETE, alice.public_key(), "", tags, AT);
|
||||
assert!(matches!(
|
||||
open(
|
||||
&sealed(&ambiguous, &group, &alice),
|
||||
&group,
|
||||
&channel(),
|
||||
Epoch(0)
|
||||
),
|
||||
Err(ChatError::DuplicateTag(TAG_TARGET))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -399,7 +399,7 @@ impl From<&ParsedEdition> for EntityHead {
|
||||
/// Every entity's committed head, keyed by coordinate.
|
||||
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
|
||||
|
||||
fn canonical_decimal(raw: &str) -> Option<u64> {
|
||||
pub(crate) fn canonical_decimal(raw: &str) -> Option<u64> {
|
||||
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod chat;
|
||||
pub mod control;
|
||||
pub mod derive;
|
||||
pub mod edition;
|
||||
|
||||
+167
-3
@@ -1,20 +1,23 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::chat::{self, ChatRumor, plane_keys};
|
||||
use crate::control::{
|
||||
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
|
||||
};
|
||||
use crate::derive::control_signer_group_key;
|
||||
use crate::edition::{EntityHead, Floors, ParsedEdition, vsk};
|
||||
use crate::stream::OpenedStream;
|
||||
use crate::{ChannelId, CommunityId, Epoch};
|
||||
use crate::stream::{KIND_WRAP_EPHEMERAL, OpenedStream};
|
||||
use crate::{ChannelId, CommunityId, Epoch, GroupKey};
|
||||
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
|
||||
const MAX_PAGES: usize = 8;
|
||||
const CHANNEL_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_C;
|
||||
const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
|
||||
const MARK_VALUE: &str = "concord";
|
||||
@@ -265,18 +268,179 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn backfill(
|
||||
client: &Client,
|
||||
database: &dyn NostrDatabase,
|
||||
channel: &ChannelId,
|
||||
held: &[(Epoch, [u8; 32])],
|
||||
until: Option<Timestamp>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ChatRumor>> {
|
||||
let planes = plane_keys(held, channel)?;
|
||||
let authors: Vec<PublicKey> = planes.iter().map(|(_, group)| group.pk()).collect();
|
||||
|
||||
let mut cursor = until;
|
||||
let mut seen: BTreeSet<EventId> = BTreeSet::new();
|
||||
let mut found: Vec<ChatRumor> = Vec::new();
|
||||
|
||||
for _ in 0..MAX_PAGES {
|
||||
let page = fetch_page(client, &authors, cursor, limit).await?;
|
||||
|
||||
if page.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen);
|
||||
|
||||
for (opened, rumor) in fresh {
|
||||
cache_rumor(database, channel, &opened).await?;
|
||||
found.push(rumor);
|
||||
}
|
||||
|
||||
match next {
|
||||
Some(next) => cursor = Some(next),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
|
||||
found.truncate(limit);
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn advance(
|
||||
page: &BTreeSet<Event>,
|
||||
planes: &[(Epoch, GroupKey)],
|
||||
channel: &ChannelId,
|
||||
cursor: Option<Timestamp>,
|
||||
limit: usize,
|
||||
seen: &mut BTreeSet<EventId>,
|
||||
) -> (Vec<(OpenedStream, ChatRumor)>, Option<Timestamp>) {
|
||||
let mut fresh = Vec::new();
|
||||
|
||||
for wrap in page {
|
||||
let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok((opened, rumor)) = chat::open(wrap, group, channel, *epoch) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if seen.insert(rumor.id) {
|
||||
fresh.push((opened, rumor));
|
||||
}
|
||||
}
|
||||
|
||||
if fresh.is_empty() || page.len() < limit {
|
||||
return (fresh, None);
|
||||
}
|
||||
|
||||
let oldest = page.iter().map(|event| event.created_at).min();
|
||||
|
||||
match oldest {
|
||||
Some(oldest) if cursor != Some(oldest) => (fresh, Some(oldest)),
|
||||
_ => (fresh, None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_page(
|
||||
client: &Client,
|
||||
authors: &[PublicKey],
|
||||
until: Option<Timestamp>,
|
||||
limit: usize,
|
||||
) -> Result<BTreeSet<Event>> {
|
||||
let mut filter = Filter::new()
|
||||
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
|
||||
.authors(authors.iter().copied())
|
||||
.limit(limit);
|
||||
|
||||
if let Some(until) = until {
|
||||
filter = filter.until(until);
|
||||
}
|
||||
|
||||
Ok(client.fetch_events(filter).await?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
use super::*;
|
||||
use crate::Epoch;
|
||||
use crate::chat::{build_message, seal_rumor};
|
||||
use crate::derive::channel_group_key;
|
||||
use crate::stream::{
|
||||
KIND_WRAP, SealForm, build_rumor_ms, build_seal, channel_binding_tags, open_wrap, wrap_seal,
|
||||
};
|
||||
|
||||
const SECRET: [u8; 32] = [0x07u8; 32];
|
||||
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
|
||||
|
||||
/// What a relay does with an inclusive `until` and a `limit`.
|
||||
fn serve_page(
|
||||
relay: &BTreeSet<Event>,
|
||||
cursor: Option<Timestamp>,
|
||||
limit: usize,
|
||||
) -> BTreeSet<Event> {
|
||||
let mut events: Vec<Event> = relay
|
||||
.iter()
|
||||
.filter(|event| cursor.is_none_or(|cursor| event.created_at <= cursor))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
events.sort_by_key(|event| Reverse(event.created_at));
|
||||
events.truncate(limit);
|
||||
events.into_iter().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_pages_back_across_a_rekey() {
|
||||
let channel = ChannelId::from_bytes([0x9cu8; 32]);
|
||||
let author = Keys::generate();
|
||||
let held = [(Epoch(0), SECRET), (Epoch(1), NEXT_SECRET)];
|
||||
let planes = plane_keys(&held, &channel).expect("derives");
|
||||
|
||||
// Three messages a second apart: a page boundary falls between each.
|
||||
let base = 1_700_000_000_000;
|
||||
let mut relay: BTreeSet<Event> = BTreeSet::new();
|
||||
|
||||
for (content, secret, epoch, at_ms) in [
|
||||
("before the rekey", &SECRET, Epoch(0), base),
|
||||
("still before", &SECRET, Epoch(0), base + 1_000),
|
||||
("after the rekey", &NEXT_SECRET, Epoch(1), base + 2_000),
|
||||
] {
|
||||
let group = channel_group_key(secret, &channel, epoch).expect("derives");
|
||||
let rumor = build_message(author.public_key(), &channel, epoch, content, None, at_ms);
|
||||
relay.insert(seal_rumor(&rumor, &group, &author, false).expect("seals").0);
|
||||
}
|
||||
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut found = Vec::new();
|
||||
let mut cursor = None;
|
||||
|
||||
for _ in 0..3 {
|
||||
let page = serve_page(&relay, cursor, 2);
|
||||
let (fresh, next) = advance(&page, &planes, &channel, cursor, 2, &mut seen);
|
||||
|
||||
found.extend(fresh.into_iter().map(|(_, rumor)| rumor));
|
||||
|
||||
match next {
|
||||
Some(next) => cursor = Some(next),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
|
||||
|
||||
let contents: Vec<&str> = found.iter().map(|rumor| rumor.content.as_str()).collect();
|
||||
assert_eq!(
|
||||
contents,
|
||||
["after the rekey", "still before", "before the rekey"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rumors_read_back_after_a_restart() {
|
||||
|
||||
Reference in New Issue
Block a user