use nostr_sdk::prelude::PublicKey;
use super::super::{ChannelId, CommunityId, Epoch};
use super::control::{CommunityIdentity, Genesis, ImageRef};
use super::invite::CommunityInvite;
#[derive(Debug, Clone)]
pub struct ChannelV2 {
pub id: ChannelId,
pub name: String,
pub private: bool,
pub key: Option<[u8; 32]>,
pub epoch: Epoch,
pub voice: Option<bool>,
pub meta_custom: Option<serde_json::Map<String, serde_json::Value>>,
pub meta_extra: serde_json::Map<String, serde_json::Value>,
}
impl ChannelV2 {
pub fn metadata(&self) -> super::control::ChannelMetadata {
super::control::ChannelMetadata {
name: self.name.clone(),
private: self.private,
voice: self.voice,
deleted: None,
custom: self.meta_custom.clone(),
extra: self.meta_extra.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct CommunityV2 {
pub identity: CommunityIdentity,
pub community_root: [u8; 32],
pub root_epoch: Epoch,
pub name: String,
pub description: Option<String>,
pub icon: Option<ImageRef>,
pub banner: Option<ImageRef>,
pub meta_custom: Option<serde_json::Map<String, serde_json::Value>>,
pub meta_extra: serde_json::Map<String, serde_json::Value>,
pub relays: Vec<String>,
pub channels: Vec<ChannelV2>,
pub dissolved: bool,
pub created_at_ms: u64,
}
impl CommunityV2 {
pub fn from_genesis(g: &Genesis, name: &str, description: Option<String>, relays: Vec<String>, created_at_ms: u64) -> CommunityV2 {
CommunityV2 {
identity: g.identity.clone(),
community_root: g.community_root,
root_epoch: Epoch(0),
name: name.to_string(),
description,
icon: None,
banner: None,
meta_custom: None,
meta_extra: Default::default(),
relays,
channels: vec![ChannelV2 {
id: g.general_channel_id,
name: "general".to_string(),
private: false,
key: None,
epoch: Epoch(0),
voice: None,
meta_custom: None,
meta_extra: Default::default(),
}],
dissolved: false,
created_at_ms,
}
}
pub fn from_bundle(bundle: &CommunityInvite, created_at_ms: u64) -> Result<CommunityV2, String> {
bundle.validate().map_err(|e| e.to_string())?;
let community_id = CommunityId(parse_hex32(&bundle.community_id, "community_id")?);
let owner_xonly = parse_hex32(&bundle.owner, "owner")?;
let owner_salt = parse_hex32(&bundle.owner_salt, "owner_salt")?;
let identity = CommunityIdentity { community_id, owner_xonly, owner_salt };
let community_root = parse_hex32(&bundle.community_root, "community_root")?;
let mut channels = Vec::with_capacity(bundle.channels.len());
for g in &bundle.channels {
let id = ChannelId(parse_hex32(&g.id, "channel id")?);
let key = parse_hex32(&g.key, "channel key")?;
let private = key != community_root;
channels.push(ChannelV2 {
id,
name: g.name.clone(),
private,
key: private.then_some(key),
epoch: Epoch(g.epoch),
voice: None,
meta_custom: None,
meta_extra: Default::default(),
});
}
Ok(CommunityV2 {
identity,
community_root,
root_epoch: Epoch(bundle.root_epoch),
name: bundle.name.clone(),
description: None,
icon: bundle.icon.clone(),
banner: None,
meta_custom: None,
meta_extra: Default::default(),
relays: bundle.relays.clone(),
channels,
dissolved: false,
created_at_ms,
})
}
pub fn id(&self) -> &CommunityId {
&self.identity.community_id
}
pub fn metadata(&self) -> super::control::CommunityMetadata {
super::control::CommunityMetadata {
name: self.name.clone(),
description: self.description.clone(),
relays: self.relays.clone(),
icon: self.icon.clone(),
banner: self.banner.clone(),
custom: self.meta_custom.clone(),
extra: self.meta_extra.clone(),
}
}
pub fn owner(&self) -> Result<PublicKey, String> {
self.identity.owner()
}
pub fn channel(&self, id: &ChannelId) -> Option<&ChannelV2> {
self.channels.iter().find(|c| c.id.0 == id.0)
}
pub fn primary_channel(&self) -> Option<&ChannelV2> {
let readable = |c: &&ChannelV2| !(c.private && c.key.is_none());
self.channels
.iter()
.filter(readable)
.find(|c| c.name.eq_ignore_ascii_case("general"))
.or_else(|| self.channels.iter().find(readable))
.or_else(|| self.channels.first())
}
pub fn channel_secret(&self, ch: &ChannelV2) -> ([u8; 32], Epoch) {
match ch.key {
Some(k) if ch.private => (k, ch.epoch),
_ => (self.community_root, self.root_epoch),
}
}
pub fn channel_read_coords(&self, ch: &ChannelV2) -> Vec<([u8; 32], Epoch)> {
if ch.private && ch.key.is_none() {
return Vec::new();
}
vec![self.channel_secret(ch)]
}
pub fn to_summary_json(&self) -> serde_json::Value {
serde_json::json!({
"id": crate::simd::hex::bytes_to_hex_32(&self.identity.community_id.0),
"version": 2,
"name": self.name,
"description": self.description,
"relays": self.relays,
"owner": crate::simd::hex::bytes_to_hex_32(&self.identity.owner_xonly),
"dissolved": self.dissolved,
"channels": self.channels.iter().map(|c| serde_json::json!({
"id": crate::simd::hex::bytes_to_hex_32(&c.id.0),
"name": c.name,
"private": c.private,
})).collect::<Vec<_>>(),
})
}
}
fn parse_hex32(hex: &str, field: &str) -> Result<[u8; 32], String> {
if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!("{field} is not 32-byte hex"));
}
Ok(crate::simd::hex::hex_to_bytes_32(hex))
}
#[cfg(test)]
mod tests {
use super::super::invite::ChannelGrant;
use super::*;
use nostr_sdk::prelude::Keys;
#[test]
fn genesis_yields_a_public_general_channel() {
let owner = Keys::generate();
let meta = super::super::control::CommunityMetadata { name: "Test".into(), ..Default::default() };
let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
let c = CommunityV2::from_genesis(&g, "Test", None, vec!["wss://r".into()], 42);
assert!(c.identity.verify());
assert_eq!(c.owner().unwrap(), owner.public_key());
assert_eq!(c.channels.len(), 1);
let ch = &c.channels[0];
assert!(!ch.private);
assert_eq!(ch.key, None, "a public channel stores no key");
assert_eq!(c.channel_secret(ch), (c.community_root, Epoch(0)));
}
#[test]
fn primary_channel_prefers_a_readable_general() {
let owner = Keys::generate();
let meta = super::super::control::CommunityMetadata { name: "T".into(), ..Default::default() };
let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
let mut c = CommunityV2::from_genesis(&g, "T", None, vec!["wss://r".into()], 0);
assert_eq!(c.primary_channel().unwrap().name, "general");
c.channels[0].name = "lobby".into();
c.channels.insert(0, ChannelV2 { id: ChannelId([9u8; 32]), name: "sekrit".into(), private: true, key: None, epoch: Epoch(0), voice: None, meta_custom: None, meta_extra: Default::default() });
assert_eq!(c.primary_channel().unwrap().name, "lobby");
c.channels.push(ChannelV2 { id: ChannelId([8u8; 32]), name: "General".into(), private: false, key: None, epoch: Epoch(0), voice: None, meta_custom: None, meta_extra: Default::default() });
assert_eq!(c.primary_channel().unwrap().name, "General");
}
#[test]
fn metadata_document_rebuilds_the_full_entity() {
let owner = Keys::generate();
let meta = super::super::control::CommunityMetadata { name: "Test".into(), ..Default::default() };
let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
let mut c = CommunityV2::from_genesis(&g, "Test", Some("desc".into()), vec!["wss://r".into()], 42);
let mut extra = serde_json::Map::new();
extra.insert("ext".into(), serde_json::Value::String("png".into()));
c.icon = Some(ImageRef {
url: "https://blossom.example/i".into(),
key: "k".into(),
nonce: "n".into(),
hash: "h".into(),
extra,
});
let mut custom = serde_json::Map::new();
custom.insert("theme".into(), serde_json::Value::String("solarpunk".into()));
c.meta_custom = Some(custom.clone());
c.meta_extra.insert("future_field".into(), serde_json::Value::Bool(true));
let doc = c.metadata();
assert_eq!(doc.name, "Test");
assert_eq!(doc.description.as_deref(), Some("desc"));
assert_eq!(doc.icon, c.icon);
assert_eq!(doc.banner, None);
assert_eq!(doc.relays, c.relays);
assert_eq!(doc.custom, Some(custom), "client-extensible custom rides the edit base");
assert_eq!(doc.extra.get("future_field"), Some(&serde_json::Value::Bool(true)), "unknown fields ride too");
}
#[test]
fn channel_metadata_document_preserves_undriven_fields() {
let owner = Keys::generate();
let meta = super::super::control::CommunityMetadata { name: "T".into(), ..Default::default() };
let g = super::super::control::genesis(&owner, meta, 1_000).unwrap();
let mut c = CommunityV2::from_genesis(&g, "T", None, vec!["wss://r".into()], 0);
c.channels[0].voice = Some(true);
let mut custom = serde_json::Map::new();
custom.insert("bitrate".into(), serde_json::Value::from(64000));
c.channels[0].meta_custom = Some(custom.clone());
c.channels[0].meta_extra.insert("vnd_field".into(), serde_json::Value::from("x"));
let mut doc = c.channels[0].metadata();
doc.name = "lounge".into();
assert_eq!(doc.voice, Some(true), "a rename must not wipe the voice flag");
assert_eq!(doc.custom, Some(custom));
assert_eq!(doc.extra.get("vnd_field"), Some(&serde_json::Value::from("x")));
assert_eq!(doc.deleted, None);
}
#[test]
fn from_bundle_verifies_owner_and_classifies_channels() {
let owner = Keys::generate();
let identity = CommunityIdentity::mint(&owner.public_key());
let root = [0x11u8; 32];
let hex = crate::simd::hex::bytes_to_hex_32;
let priv_key = [0x22u8; 32];
let bundle = CommunityInvite {
community_id: hex(&identity.community_id.0),
owner: hex(&identity.owner_xonly),
owner_salt: hex(&identity.owner_salt),
community_root: hex(&root),
root_epoch: 0,
channels: vec![
ChannelGrant { id: hex(&[0xa1; 32]), key: hex(&root), epoch: 0, name: "general".into() },
ChannelGrant { id: hex(&[0xa2; 32]), key: hex(&priv_key), epoch: 1, name: "mods".into() },
],
relays: vec!["wss://r".into()],
name: "Test".into(),
icon: None,
expires_at: None,
creator_npub: None,
label: None,
extra: Default::default(),
};
let c = CommunityV2::from_bundle(&bundle, 99).unwrap();
assert_eq!(c.owner().unwrap(), owner.public_key());
assert!(!c.channels[0].private);
assert!(c.channels[1].private);
assert_eq!(c.channels[1].key, Some(priv_key));
assert_eq!(c.channel_secret(&c.channels[0]), (root, Epoch(0)));
assert_eq!(c.channel_secret(&c.channels[1]), (priv_key, Epoch(1)));
}
#[test]
fn from_bundle_rejects_an_out_of_range_epoch() {
let owner = Keys::generate();
let identity = CommunityIdentity::mint(&owner.public_key());
let hex = crate::simd::hex::bytes_to_hex_32;
let root = [0x11u8; 32];
let bundle = CommunityInvite {
community_id: hex(&identity.community_id.0),
owner: hex(&identity.owner_xonly),
owner_salt: hex(&identity.owner_salt),
community_root: hex(&root),
root_epoch: u64::MAX,
channels: vec![ChannelGrant { id: hex(&[0xa1; 32]), key: hex(&root), epoch: 0, name: "general".into() }],
relays: vec!["wss://r".into()],
name: "Overflow".into(),
icon: None,
expires_at: None,
creator_npub: None,
label: None,
extra: Default::default(),
};
assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an out-of-range epoch is refused");
}
#[test]
fn from_bundle_rejects_a_forged_owner_commitment() {
let owner = Keys::generate();
let attacker = Keys::generate();
let identity = CommunityIdentity::mint(&owner.public_key());
let hex = crate::simd::hex::bytes_to_hex_32;
let bundle = CommunityInvite {
community_id: hex(&identity.community_id.0),
owner: hex(&attacker.public_key().to_bytes()),
owner_salt: hex(&identity.owner_salt),
community_root: hex(&[0x11; 32]),
root_epoch: 0,
channels: vec![],
relays: vec![],
name: "X".into(),
icon: None,
expires_at: None,
creator_npub: None,
label: None,
extra: Default::default(),
};
assert!(CommunityV2::from_bundle(&bundle, 0).is_err());
}
}