Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions crates/buzz-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ async fn reconcile_channels(
k
}
};
let relay_pubkey = relay_keys.public_key().to_bytes();

let tenant = resolve_admin_tenant(&db).await?;
let target_channel = channel_arg
Expand Down Expand Up @@ -609,17 +610,36 @@ async fn reconcile_channels(

// kind:39002 — members
{
let mut member_snapshot = db
.lock_member_snapshot(tenant.community(), channel.id, &relay_pubkey)
.await?;
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
for m in &members {
let mut canonical_members = member_snapshot.members.iter().collect::<Vec<_>>();
canonical_members.sort_by(|a, b| a.pubkey.cmp(&b.pubkey));
for m in canonical_members {
let pk = hex::encode(&m.pubkey);
tags.push(Tag::parse(["p", &pk, "", &m.role])?);
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let ts = member_snapshot
.latest_member_event_timestamp(tenant.community(), channel.id, &relay_pubkey)
.await?
.map(|timestamp| timestamp + 1)
.unwrap_or(now)
.max(now);
let event = EventBuilder::new(Kind::Custom(39002), "")
.tags(tags)
.custom_created_at(nostr::Timestamp::from(ts))
.allow_self_tagging()
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?;
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
member_snapshot
.replace_member_event(tenant.community(), channel.id, &event)
.await?;
member_snapshot.release().await?;
}

reconciled += 1;
Expand Down
121 changes: 120 additions & 1 deletion crates/buzz-relay/src/handlers/side_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1050,7 +1050,9 @@ async fn emit_addressable_discovery_event(
fn group_members_tags(group_id: &str, members: &[MemberRecord]) -> anyhow::Result<Vec<Tag>> {
let mut tags: Vec<Tag> = Vec::with_capacity(members.len() + 1);
tags.push(Tag::parse(["d", group_id])?);
for member in members {
let mut canonical_members = members.iter().collect::<Vec<_>>();
canonical_members.sort_by(|a, b| a.pubkey.cmp(&b.pubkey));
for member in canonical_members {
let pubkey_hex = hex::encode(&member.pubkey);
// NIP-29 convention: ["p", pubkey, relay_url, role]. Empty relay_url
// because the canonical relay is implicit (this event is signed by it).
Expand Down Expand Up @@ -3683,6 +3685,7 @@ pub async fn publish_nipia_unarchived(
#[cfg(test)]
mod tests {
use super::*;
use nostr::Keys;

#[test]
fn group_members_snapshot_keeps_members_past_one_thousand() {
Expand Down Expand Up @@ -3711,6 +3714,122 @@ mod tests {
}));
}

#[test]
fn group_members_snapshot_uses_canonical_pubkey_order() {
let channel_id = Uuid::new_v4();
let members = vec![
MemberRecord {
channel_id,
pubkey: vec![0xff; 32],
role: "member".to_string(),
joined_at: chrono::Utc::now(),
invited_by: None,
removed_at: None,
},
MemberRecord {
channel_id,
pubkey: vec![0x01; 32],
role: "owner".to_string(),
joined_at: chrono::Utc::now(),
invited_by: None,
removed_at: None,
},
MemberRecord {
channel_id,
pubkey: vec![0x80; 32],
role: "bot".to_string(),
joined_at: chrono::Utc::now(),
invited_by: None,
removed_at: None,
},
];

let tags = group_members_tags(&channel_id.to_string(), &members).expect("build tags");
let p_tags = tags
.iter()
.filter_map(|tag| {
let fields = tag.as_slice();
(fields.first().map(String::as_str) == Some("p")).then(|| {
(
fields.get(1).cloned(),
fields.get(2).cloned(),
fields.get(3).cloned(),
)
})
})
.collect::<Vec<_>>();

assert_eq!(
p_tags,
vec![
(
Some(hex::encode(vec![0x01; 32])),
Some(String::new()),
Some("owner".to_string())
),
(
Some(hex::encode(vec![0x80; 32])),
Some(String::new()),
Some("bot".to_string())
),
(
Some(hex::encode(vec![0xff; 32])),
Some(String::new()),
Some("member".to_string())
),
]
);
}

#[test]
fn group_members_snapshot_preserves_relay_self_tag() {
let relay_keys = Keys::generate();
let relay_pubkey = relay_keys.public_key().to_bytes();
let relay_pubkey_hex = hex::encode(relay_pubkey);
let channel_id = Uuid::new_v4();
let members = vec![
MemberRecord {
channel_id,
pubkey: relay_pubkey.to_vec(),
role: "owner".to_string(),
joined_at: chrono::Utc::now(),
invited_by: None,
removed_at: None,
},
MemberRecord {
channel_id,
pubkey: vec![0x80; 32],
role: "bot".to_string(),
joined_at: chrono::Utc::now(),
invited_by: None,
removed_at: None,
},
];

let tags = group_members_tags(&channel_id.to_string(), &members).expect("build tags");
let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_MEMBERS as u16), "")
.tags(tags)
.allow_self_tagging()
.sign_with_keys(&relay_keys)
.expect("sign member snapshot");
let p_tags = event
.tags
.iter()
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some("p"))
.collect::<Vec<_>>();

assert_eq!(p_tags.len(), 2);
assert!(p_tags.iter().any(|tag| {
tag.as_slice()
== [
"p".to_string(),
relay_pubkey_hex.clone(),
String::new(),
"owner".to_string(),
]
}));
}

#[test]
fn delete_tombstone_omits_absent_moderation_metadata() {
let content =
Expand Down