mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
3825 lines
133 KiB
Rust
3825 lines
133 KiB
Rust
//! Typed event builder functions (38 builders).
|
||
//!
|
||
//! All functions return `Result<nostr::EventBuilder, SdkError>`.
|
||
//! The caller signs: `builder.sign_with_keys(&keys)?`.
|
||
|
||
use buzz_core::{
|
||
kind::{
|
||
KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_DELETION,
|
||
KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, KIND_GIT_PATCH,
|
||
KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT,
|
||
KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED,
|
||
KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST,
|
||
KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT,
|
||
KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF,
|
||
KIND_WORKFLOW_TRIGGER,
|
||
},
|
||
observer::{
|
||
content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG,
|
||
OBSERVER_FRAME_TELEMETRY,
|
||
},
|
||
};
|
||
use nostr::{EventBuilder, Kind, Tag};
|
||
use uuid::Uuid;
|
||
|
||
use crate::{
|
||
ChannelKind, CustomEmoji, DiffMeta, MemberRole, SdkError, ThreadRef, Visibility, VoteDirection,
|
||
};
|
||
|
||
/// Parse a tag slice, mapping errors to `SdkError::InvalidTag`.
|
||
fn tag(parts: &[&str]) -> Result<Tag, SdkError> {
|
||
Tag::parse(parts.iter().copied()).map_err(|e| SdkError::InvalidTag(e.to_string()))
|
||
}
|
||
|
||
/// Validate content byte length.
|
||
fn check_content(content: &str, max: usize) -> Result<(), SdkError> {
|
||
let got = content.len();
|
||
if got > max {
|
||
return Err(SdkError::ContentTooLarge { max, got });
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Validate hex string has at least `min_len` hex characters.
|
||
fn check_hex_len(s: &str, min_len: usize, field: &str) -> Result<(), SdkError> {
|
||
if s.len() < min_len || !s.chars().all(|c| c.is_ascii_hexdigit()) {
|
||
return Err(SdkError::InvalidDiffMeta(format!(
|
||
"{field} must be at least {min_len} hex characters (got {:?})",
|
||
s
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Validate a git commit-like hex id (commit, parent-commit, euc,
|
||
/// merge-commit, applied-as-commit). Git object ids are full SHA-1 (40 hex
|
||
/// chars) or SHA-256 (64 hex chars) — anything shorter is an abbreviated
|
||
/// ref that NIP-34 canonical tags shouldn't carry, since consumers resolve
|
||
/// these against the actual repo.
|
||
fn check_commit_hex(s: &str, field: &str) -> Result<(), SdkError> {
|
||
if (s.len() != 40 && s.len() != 64) || !s.chars().all(|c| c.is_ascii_hexdigit()) {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"{field} must be a full 40-character (SHA-1) or 64-character (SHA-256) hex commit id (got {:?})",
|
||
s
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn check_pubkey_hex(s: &str, field: &str) -> Result<String, SdkError> {
|
||
if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"{field} must be a 64-character hex pubkey"
|
||
)));
|
||
}
|
||
Ok(s.to_ascii_lowercase())
|
||
}
|
||
|
||
/// Validate an exact-length hex string (event ids), returning it lowercased.
|
||
fn check_hex_exact(s: &str, len: usize, field: &str) -> Result<String, SdkError> {
|
||
if s.len() != len || !s.chars().all(|c| c.is_ascii_hexdigit()) {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"{field} must be a {len}-character hex string"
|
||
)));
|
||
}
|
||
Ok(s.to_ascii_lowercase())
|
||
}
|
||
|
||
/// Validate a git repo identifier: `[a-zA-Z0-9._-]{1,64}`, no leading dot,
|
||
/// no `..`. Shared by `build_repo_announcement` and `GitRepoCoord` so a
|
||
/// repo coordinate built directly through the SDK (bypassing CLI-side
|
||
/// `validate_repo_id`) can't slip an invalid `d`-tag into an `a`-tag value.
|
||
fn check_repo_id(repo_id: &str) -> Result<(), SdkError> {
|
||
if repo_id.is_empty() {
|
||
return Err(SdkError::InvalidInput("repo_id must not be empty".into()));
|
||
}
|
||
if repo_id.len() > 64 {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"repo_id exceeds 64 characters (got {})",
|
||
repo_id.len()
|
||
)));
|
||
}
|
||
if !repo_id
|
||
.chars()
|
||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
|
||
{
|
||
return Err(SdkError::InvalidInput(
|
||
"repo_id may only contain [a-zA-Z0-9._-]".into(),
|
||
));
|
||
}
|
||
if repo_id.starts_with('.') {
|
||
return Err(SdkError::InvalidInput(
|
||
"repo_id must not start with a dot".into(),
|
||
));
|
||
}
|
||
if repo_id.contains("..") {
|
||
return Err(SdkError::InvalidInput(
|
||
"repo_id must not contain '..'".into(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Validate and normalize a NIP-30 custom emoji shortcode.
|
||
///
|
||
/// Shortcodes are case-insensitive in Buzz's relay-global set; lowercase
|
||
/// normalization prevents `party_parrot` and `Party_Parrot` from colliding.
|
||
pub fn normalize_custom_emoji_shortcode(shortcode: &str) -> Result<String, SdkError> {
|
||
let trimmed = shortcode.trim().trim_matches(':');
|
||
if trimmed.is_empty() {
|
||
return Err(SdkError::InvalidInput(
|
||
"emoji shortcode must not be empty".into(),
|
||
));
|
||
}
|
||
if trimmed.len() > 64 {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"emoji shortcode exceeds 64 bytes (got {})",
|
||
trimmed.len()
|
||
)));
|
||
}
|
||
if !trimmed
|
||
.chars()
|
||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||
{
|
||
return Err(SdkError::InvalidInput(
|
||
"emoji shortcode may only contain ASCII letters, digits, hyphens, and underscores"
|
||
.into(),
|
||
));
|
||
}
|
||
Ok(trimmed.to_ascii_lowercase())
|
||
}
|
||
|
||
fn check_custom_emoji_url(url: &str) -> Result<(), SdkError> {
|
||
if url.is_empty() {
|
||
return Err(SdkError::InvalidInput(
|
||
"emoji image URL must not be empty".into(),
|
||
));
|
||
}
|
||
if url.len() > 2048 {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"emoji image URL exceeds 2048 bytes (got {})",
|
||
url.len()
|
||
)));
|
||
}
|
||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||
return Err(SdkError::InvalidInput(
|
||
"emoji image URL must start with http:// or https://".into(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Emit NIP-10 e-tags for a `ThreadRef`.
|
||
fn thread_tags(thread_ref: &ThreadRef, tags: &mut Vec<Tag>) -> Result<(), SdkError> {
|
||
let root = thread_ref.root_event_id.to_hex();
|
||
let parent = thread_ref.parent_event_id.to_hex();
|
||
if root == parent {
|
||
// Direct reply
|
||
tags.push(tag(&["e", &root, "", "reply"])?);
|
||
} else {
|
||
// Nested reply
|
||
tags.push(tag(&["e", &root, "", "root"])?);
|
||
tags.push(tag(&["e", &parent, "", "reply"])?);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Deduplicate and cap mentions, emitting p-tags.
|
||
fn mention_tags(mentions: &[&str], tags: &mut Vec<Tag>) -> Result<(), SdkError> {
|
||
if mentions.len() > crate::mentions::MENTION_CAP {
|
||
return Err(SdkError::TooManyMentions);
|
||
}
|
||
let mut seen = std::collections::HashSet::new();
|
||
for &hex in mentions {
|
||
let lower = hex.to_ascii_lowercase();
|
||
if seen.insert(lower.clone()) {
|
||
tags.push(tag(&["p", &lower])?);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Emit imeta tags from raw tag vectors.
|
||
fn imeta_tags(media_tags: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(), SdkError> {
|
||
for mt in media_tags {
|
||
let parts: Vec<&str> = mt.iter().map(String::as_str).collect();
|
||
tags.push(Tag::parse(parts).map_err(|e| SdkError::InvalidTag(e.to_string()))?);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Build a stream message (kind 9).
|
||
///
|
||
/// - `channel_id`: target channel UUID
|
||
/// - `content`: message text (max 64 KiB)
|
||
/// - `thread_ref`: optional NIP-10 reply context
|
||
/// - `mentions`: pubkey hex strings to p-tag (deduped, max 50)
|
||
/// - `broadcast`: if true, adds `["broadcast", "1"]` tag
|
||
/// - `media_tags`: raw imeta tag vectors
|
||
pub fn build_message(
|
||
channel_id: Uuid,
|
||
content: &str,
|
||
thread_ref: Option<&ThreadRef>,
|
||
mentions: &[&str],
|
||
broadcast: bool,
|
||
media_tags: &[Vec<String>],
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_content(content, 64 * 1024)?;
|
||
let mut tags = vec![tag(&["h", &channel_id.to_string()])?];
|
||
if let Some(tr) = thread_ref {
|
||
thread_tags(tr, &mut tags)?;
|
||
}
|
||
mention_tags(mentions, &mut tags)?;
|
||
if broadcast {
|
||
tags.push(tag(&["broadcast", "1"])?);
|
||
}
|
||
imeta_tags(media_tags, &mut tags)?;
|
||
Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags))
|
||
}
|
||
|
||
/// Build an encrypted agent observer frame (kind 24200).
|
||
///
|
||
/// `recipient_pubkey` is the cleartext `p` tag used by the relay for owner-only
|
||
/// routing. `agent_pubkey` identifies the managed agent whose observer stream
|
||
/// this frame belongs to. `encrypted_content` must be NIP-44 v2 ciphertext.
|
||
pub fn build_agent_observer_frame(
|
||
recipient_pubkey: &str,
|
||
agent_pubkey: &str,
|
||
frame: &str,
|
||
encrypted_content: &str,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
if frame != OBSERVER_FRAME_TELEMETRY && frame != OBSERVER_FRAME_CONTROL {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"observer frame must be {OBSERVER_FRAME_TELEMETRY:?} or {OBSERVER_FRAME_CONTROL:?}"
|
||
)));
|
||
}
|
||
if !content_looks_like_nip44(encrypted_content) {
|
||
return Err(SdkError::InvalidInput(
|
||
"observer frame content must be NIP-44 v2 ciphertext".into(),
|
||
));
|
||
}
|
||
|
||
let recipient_pubkey = check_pubkey_hex(recipient_pubkey, "recipient_pubkey")?;
|
||
let agent_pubkey = check_pubkey_hex(agent_pubkey, "agent_pubkey")?;
|
||
let tags = vec![
|
||
tag(&["p", &recipient_pubkey])?,
|
||
tag(&[OBSERVER_AGENT_TAG, &agent_pubkey])?,
|
||
tag(&[OBSERVER_FRAME_TAG, frame])?,
|
||
];
|
||
|
||
Ok(EventBuilder::new(
|
||
Kind::Custom(KIND_AGENT_OBSERVER_FRAME as u16),
|
||
encrypted_content,
|
||
)
|
||
.tags(tags))
|
||
}
|
||
|
||
/// Build a forum post thread root (kind 45001).
|
||
pub fn build_forum_post(
|
||
channel_id: Uuid,
|
||
content: &str,
|
||
mentions: &[&str],
|
||
media_tags: &[Vec<String>],
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_content(content, 64 * 1024)?;
|
||
let mut tags = vec![tag(&["h", &channel_id.to_string()])?];
|
||
mention_tags(mentions, &mut tags)?;
|
||
imeta_tags(media_tags, &mut tags)?;
|
||
Ok(EventBuilder::new(Kind::Custom(45001), content).tags(tags))
|
||
}
|
||
|
||
/// Build a forum comment reply (kind 45003).
|
||
pub fn build_forum_comment(
|
||
channel_id: Uuid,
|
||
content: &str,
|
||
thread_ref: &ThreadRef,
|
||
mentions: &[&str],
|
||
media_tags: &[Vec<String>],
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_content(content, 64 * 1024)?;
|
||
let mut tags = vec![tag(&["h", &channel_id.to_string()])?];
|
||
thread_tags(thread_ref, &mut tags)?;
|
||
mention_tags(mentions, &mut tags)?;
|
||
imeta_tags(media_tags, &mut tags)?;
|
||
Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags))
|
||
}
|
||
|
||
/// Build a diff/patch message (kind 40008).
|
||
pub fn build_diff_message(
|
||
channel_id: Uuid,
|
||
content: &str,
|
||
diff_meta: &DiffMeta,
|
||
thread_ref: Option<&ThreadRef>,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_content(content, 60 * 1024)?;
|
||
|
||
// Validate DiffMeta
|
||
if !diff_meta.repo_url.starts_with("http://") && !diff_meta.repo_url.starts_with("https://") {
|
||
return Err(SdkError::InvalidDiffMeta(
|
||
"repo_url must start with http:// or https://".into(),
|
||
));
|
||
}
|
||
check_hex_len(&diff_meta.commit_sha, 7, "commit_sha")?;
|
||
if let Some(ref pc) = diff_meta.parent_commit {
|
||
check_hex_len(pc, 7, "parent_commit")?;
|
||
}
|
||
match &diff_meta.branch {
|
||
Some((src, tgt)) if src.is_empty() || tgt.is_empty() => {
|
||
return Err(SdkError::InvalidDiffMeta(
|
||
"branch requires both source and target to be non-empty".into(),
|
||
));
|
||
}
|
||
_ => {}
|
||
}
|
||
if let Some(pr) = diff_meta.pr_number {
|
||
if pr == 0 {
|
||
return Err(SdkError::InvalidDiffMeta(
|
||
"pr_number must be positive".into(),
|
||
));
|
||
}
|
||
}
|
||
|
||
let mut tags = vec![
|
||
tag(&["h", &channel_id.to_string()])?,
|
||
tag(&["repo", &diff_meta.repo_url])?,
|
||
tag(&["commit", &diff_meta.commit_sha])?,
|
||
];
|
||
if let Some(ref fp) = diff_meta.file_path {
|
||
tags.push(tag(&["file", fp])?);
|
||
}
|
||
if let Some(ref pc) = diff_meta.parent_commit {
|
||
tags.push(tag(&["parent-commit", pc])?);
|
||
}
|
||
if let Some((ref src, ref tgt)) = diff_meta.branch {
|
||
tags.push(tag(&["branch", src, tgt])?);
|
||
}
|
||
if let Some(pr) = diff_meta.pr_number {
|
||
tags.push(tag(&["pr", &pr.to_string()])?);
|
||
}
|
||
if let Some(ref lang) = diff_meta.language {
|
||
tags.push(tag(&["l", lang])?);
|
||
}
|
||
if let Some(ref desc) = diff_meta.description {
|
||
tags.push(tag(&["description", desc])?);
|
||
}
|
||
if diff_meta.truncated {
|
||
tags.push(tag(&["truncated", "true"])?);
|
||
}
|
||
if let Some(ref alt) = diff_meta.alt_text {
|
||
tags.push(tag(&["alt", alt])?);
|
||
}
|
||
if let Some(tr) = thread_ref {
|
||
thread_tags(tr, &mut tags)?;
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(40008), content).tags(tags))
|
||
}
|
||
|
||
/// Build an edit event targeting an existing message (kind 40003).
|
||
pub fn build_edit(
|
||
channel_id: Uuid,
|
||
target_event_id: nostr::EventId,
|
||
new_content: &str,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_content(new_content, 64 * 1024)?;
|
||
let tags = vec![
|
||
tag(&["h", &channel_id.to_string()])?,
|
||
tag(&["e", &target_event_id.to_hex()])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(40003), new_content).tags(tags))
|
||
}
|
||
|
||
/// Optional metadata for moderator delete tombstones (kind 9005).
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct DeleteMessageOptions<'a> {
|
||
/// Audit action UUID to link from the public tombstone.
|
||
pub action_id: Option<Uuid>,
|
||
/// Machine-readable, public-safe reason code.
|
||
pub reason_code: Option<&'a str>,
|
||
/// Human-readable reason safe for the room-facing tombstone.
|
||
pub public_reason: Option<&'a str>,
|
||
}
|
||
|
||
/// Build a Buzz-native delete event (kind 9005).
|
||
pub fn build_delete_message(
|
||
channel_id: Uuid,
|
||
target_event_id: nostr::EventId,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
build_delete_message_with_options(channel_id, target_event_id, DeleteMessageOptions::default())
|
||
}
|
||
|
||
/// Build a Buzz-native delete event (kind 9005) with optional moderation metadata.
|
||
pub fn build_delete_message_with_options(
|
||
channel_id: Uuid,
|
||
target_event_id: nostr::EventId,
|
||
options: DeleteMessageOptions<'_>,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
let mut tags = vec![
|
||
tag(&["h", &channel_id.to_string()])?,
|
||
tag(&["e", &target_event_id.to_hex()])?,
|
||
];
|
||
if let Some(action_id) = options.action_id {
|
||
tags.push(tag(&["action_id", &action_id.to_string()])?);
|
||
}
|
||
if let Some(reason_code) = options.reason_code {
|
||
tags.push(tag(&["reason_code", reason_code])?);
|
||
}
|
||
if let Some(public_reason) = options.public_reason {
|
||
tags.push(tag(&["public_reason", public_reason])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(9005), "").tags(tags))
|
||
}
|
||
|
||
/// Build a NIP-09 deletion event (kind 5). The `h` tag is non-standard for
|
||
/// NIP-09 but is required so channel-scoped subscriptions observe the delete.
|
||
pub fn build_delete_compat(
|
||
channel_id: Uuid,
|
||
target_event_id: nostr::EventId,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
let tags = vec![
|
||
tag(&["h", &channel_id.to_string()])?,
|
||
tag(&["e", &target_event_id.to_hex()])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags))
|
||
}
|
||
|
||
/// Build a forum vote event (kind 45002). Content is `"+"` or `"-"`.
|
||
pub fn build_vote(
|
||
channel_id: Uuid,
|
||
target_event_id: nostr::EventId,
|
||
direction: VoteDirection,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
let content = match direction {
|
||
VoteDirection::Up => "+",
|
||
VoteDirection::Down => "-",
|
||
};
|
||
let tags = vec![
|
||
tag(&["h", &channel_id.to_string()])?,
|
||
tag(&["e", &target_event_id.to_hex()])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(45002), content).tags(tags))
|
||
}
|
||
|
||
/// Build a NIP-25 reaction event (kind 7). Emoji max 64 chars.
|
||
pub fn build_reaction(
|
||
target_event_id: nostr::EventId,
|
||
emoji: &str,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
if emoji.chars().count() > 64 {
|
||
return Err(SdkError::EmojiTooLong);
|
||
}
|
||
let tags = vec![tag(&["e", &target_event_id.to_hex()])?];
|
||
Ok(EventBuilder::new(Kind::Custom(7), emoji).tags(tags))
|
||
}
|
||
|
||
/// Build a NIP-25 reaction event using a NIP-30 custom emoji.
|
||
///
|
||
/// The reaction content is `:shortcode:` and the event carries exactly one
|
||
/// `["emoji", shortcode, url]` tag, matching NIP-25's custom emoji reaction
|
||
/// guidance.
|
||
pub fn build_custom_emoji_reaction(
|
||
target_event_id: nostr::EventId,
|
||
shortcode: &str,
|
||
url: &str,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
let shortcode = normalize_custom_emoji_shortcode(shortcode)?;
|
||
check_custom_emoji_url(url)?;
|
||
let content = format!(":{shortcode}:");
|
||
let tags = vec![
|
||
tag(&["e", &target_event_id.to_hex()])?,
|
||
tag(&["emoji", &shortcode, url])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(7), content).tags(tags))
|
||
}
|
||
|
||
/// Build a deletion event targeting a reaction (kind 5).
|
||
pub fn build_remove_reaction(reaction_event_id: nostr::EventId) -> Result<EventBuilder, SdkError> {
|
||
let tags = vec![tag(&["e", &reaction_event_id.to_hex()])?];
|
||
Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags))
|
||
}
|
||
|
||
/// d-tag for a member's own custom emoji set. Each member publishes one
|
||
/// user-signed kind:30030 under this d-tag; the workspace palette is the
|
||
/// client-side union of every member's set.
|
||
pub const CUSTOM_EMOJI_SET_D_TAG: &str = "buzz:custom-emoji";
|
||
|
||
/// Build a member's own custom emoji set event (kind:30030, NIP-30/NIP-51).
|
||
///
|
||
/// User-signed and parameterized-replaceable, keyed by `(pubkey, 30030,
|
||
/// "buzz:custom-emoji")`. Replaces the caller's prior set. The workspace
|
||
/// palette shown in clients is the union of every member's set, deduped by
|
||
/// `(shortcode, url)` on read. Add/remove is read-own-set → mutate → rebuild.
|
||
pub fn build_custom_emoji_set(emojis: &[CustomEmoji]) -> Result<EventBuilder, SdkError> {
|
||
let mut seen = std::collections::HashSet::with_capacity(emojis.len());
|
||
let mut tags = Vec::with_capacity(emojis.len() + 1);
|
||
tags.push(tag(&["d", CUSTOM_EMOJI_SET_D_TAG])?);
|
||
for emoji in emojis {
|
||
let shortcode = normalize_custom_emoji_shortcode(&emoji.shortcode)?;
|
||
check_custom_emoji_url(&emoji.url)?;
|
||
if !seen.insert(shortcode.clone()) {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"duplicate emoji shortcode: {shortcode}"
|
||
)));
|
||
}
|
||
tags.push(tag(&["emoji", &shortcode, &emoji.url])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_EMOJI_SET as u16), "").tags(tags))
|
||
}
|
||
|
||
/// Build a canvas update event (kind 40100).
|
||
pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result<EventBuilder, SdkError> {
|
||
let tags = vec![tag(&["h", &channel_id.to_string()])?];
|
||
Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags))
|
||
}
|
||
|
||
/// Build a NIP-01 profile metadata event (kind 0).
|
||
///
|
||
/// Only present (Some) fields are included in the JSON object.
|
||
pub fn build_profile(
|
||
display_name: Option<&str>,
|
||
name: Option<&str>,
|
||
picture: Option<&str>,
|
||
about: Option<&str>,
|
||
nip05: Option<&str>,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
let mut map = serde_json::Map::new();
|
||
if let Some(v) = display_name {
|
||
map.insert("display_name".into(), serde_json::Value::String(v.into()));
|
||
}
|
||
if let Some(v) = name {
|
||
map.insert("name".into(), serde_json::Value::String(v.into()));
|
||
}
|
||
if let Some(v) = picture {
|
||
map.insert("picture".into(), serde_json::Value::String(v.into()));
|
||
}
|
||
if let Some(v) = about {
|
||
map.insert("about".into(), serde_json::Value::String(v.into()));
|
||
}
|
||
if let Some(v) = nip05 {
|
||
map.insert("nip05".into(), serde_json::Value::String(v.into()));
|
||
}
|
||
let content = serde_json::Value::Object(map).to_string();
|
||
Ok(EventBuilder::new(Kind::Custom(0), content).tags([]))
|
||
}
|
||
|
||
/// Build a NIP-29 add-member event (kind 9000).
|
||
pub fn build_add_member(
|
||
channel_id: Uuid,
|
||
target_pubkey: &str,
|
||
role: Option<MemberRole>,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_hex_len(target_pubkey, 64, "target_pubkey")?;
|
||
let mut tags = vec![
|
||
tag(&["h", &channel_id.to_string()])?,
|
||
tag(&["p", &target_pubkey.to_ascii_lowercase()])?,
|
||
];
|
||
if let Some(r) = role {
|
||
tags.push(tag(&["role", r.as_str()])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(9000), "").tags(tags))
|
||
}
|
||
|
||
/// Build a NIP-29 remove-member event (kind 9001).
|
||
pub fn build_remove_member(
|
||
channel_id: Uuid,
|
||
target_pubkey: &str,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_hex_len(target_pubkey, 64, "target_pubkey")?;
|
||
let tags = vec![
|
||
tag(&["h", &channel_id.to_string()])?,
|
||
tag(&["p", &target_pubkey.to_ascii_lowercase()])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(9001), "").tags(tags))
|
||
}
|
||
|
||
/// Build a NIP-29 leave-request event (kind 9022).
|
||
pub fn build_leave(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
|
||
let tags = vec![tag(&["h", &channel_id.to_string()])?];
|
||
Ok(EventBuilder::new(Kind::Custom(9022), "").tags(tags))
|
||
}
|
||
|
||
/// Build a NIP-29 edit-metadata event for name/about/visibility/ttl (kind 9002).
|
||
///
|
||
/// `ttl`: outer `None` leaves it unchanged; `Some(Some(secs))` sets the
|
||
/// ephemeral timeout; `Some(None)` clears it (emits `["ttl", ""]`).
|
||
pub fn build_update_channel(
|
||
channel_id: Uuid,
|
||
name: Option<&str>,
|
||
about: Option<&str>,
|
||
visibility: Option<&str>,
|
||
ttl: Option<Option<i32>>,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
if name.is_none() && about.is_none() && visibility.is_none() && ttl.is_none() {
|
||
return Err(SdkError::InvalidTag(
|
||
"at least one of name, about, visibility, or ttl must be provided".into(),
|
||
));
|
||
}
|
||
if let Some(v) = visibility {
|
||
if v != "open" && v != "private" {
|
||
return Err(SdkError::InvalidTag(
|
||
"visibility must be \"open\" or \"private\"".into(),
|
||
));
|
||
}
|
||
}
|
||
if name
|
||
.map(buzz_core::channel::canonical_channel_name)
|
||
.is_some_and(|name| name.trim().is_empty())
|
||
{
|
||
return Err(SdkError::InvalidTag("channel name is required".into()));
|
||
}
|
||
let mut tags = vec![tag(&["h", &channel_id.to_string()])?];
|
||
if let Some(n) = name {
|
||
tags.push(tag(&[
|
||
"name",
|
||
buzz_core::channel::canonical_channel_name(n),
|
||
])?);
|
||
}
|
||
if let Some(a) = about {
|
||
tags.push(tag(&["about", a])?);
|
||
}
|
||
if let Some(v) = visibility {
|
||
tags.push(tag(&["visibility", v])?);
|
||
}
|
||
if let Some(ttl) = ttl {
|
||
match ttl {
|
||
Some(secs) => tags.push(tag(&["ttl", &secs.to_string()])?),
|
||
None => tags.push(tag(&["ttl", ""])?),
|
||
}
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
|
||
}
|
||
|
||
/// Build a NIP-29 edit-metadata event for topic (kind 9002).
|
||
pub fn build_set_topic(channel_id: Uuid, topic: &str) -> Result<EventBuilder, SdkError> {
|
||
let tags = vec![
|
||
tag(&["h", &channel_id.to_string()])?,
|
||
tag(&["topic", topic])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
|
||
}
|
||
|
||
/// Build a NIP-29 edit-metadata event for purpose (kind 9002).
|
||
pub fn build_set_purpose(channel_id: Uuid, purpose: &str) -> Result<EventBuilder, SdkError> {
|
||
let tags = vec![
|
||
tag(&["h", &channel_id.to_string()])?,
|
||
tag(&["purpose", purpose])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
|
||
}
|
||
|
||
/// Build a NIP-29 create-group event (kind 9007).
|
||
///
|
||
/// `ttl`: `Some(secs)` makes the channel ephemeral with that lifetime in
|
||
/// seconds (the relay archives it once the deadline passes without activity);
|
||
/// `None` leaves it permanent.
|
||
pub fn build_create_channel(
|
||
channel_id: Uuid,
|
||
name: &str,
|
||
visibility: Option<Visibility>,
|
||
channel_type: Option<ChannelKind>,
|
||
about: Option<&str>,
|
||
ttl: Option<i32>,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
let name = buzz_core::channel::canonical_channel_name(name);
|
||
if name.trim().is_empty() {
|
||
return Err(SdkError::InvalidTag("channel name is required".into()));
|
||
}
|
||
let mut tags = vec![tag(&["h", &channel_id.to_string()])?, tag(&["name", name])?];
|
||
if let Some(v) = visibility {
|
||
tags.push(tag(&["visibility", v.as_str()])?);
|
||
}
|
||
if let Some(ct) = channel_type {
|
||
tags.push(tag(&["channel_type", ct.as_str()])?);
|
||
}
|
||
if let Some(a) = about {
|
||
tags.push(tag(&["about", a])?);
|
||
}
|
||
if let Some(secs) = ttl {
|
||
tags.push(tag(&["ttl", &secs.to_string()])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(9007), "").tags(tags))
|
||
}
|
||
|
||
/// Build a NIP-29 join-request event (kind 9021).
|
||
pub fn build_join(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
|
||
let tags = vec![tag(&["h", &channel_id.to_string()])?];
|
||
Ok(EventBuilder::new(Kind::Custom(9021), "").tags(tags))
|
||
}
|
||
|
||
/// Build a NIP-29 archive event (kind 9002, `["archived", "true"]`).
|
||
pub fn build_archive(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
|
||
let tags = vec![
|
||
tag(&["h", &channel_id.to_string()])?,
|
||
tag(&["archived", "true"])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
|
||
}
|
||
|
||
/// Build a NIP-29 unarchive event (kind 9002, `["archived", "false"]`).
|
||
pub fn build_unarchive(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
|
||
let tags = vec![
|
||
tag(&["h", &channel_id.to_string()])?,
|
||
tag(&["archived", "false"])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
|
||
}
|
||
|
||
/// Build a NIP-29 delete-group event (kind 9008).
|
||
pub fn build_delete_channel(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
|
||
let tags = vec![tag(&["h", &channel_id.to_string()])?];
|
||
Ok(EventBuilder::new(Kind::Custom(9008), "").tags(tags))
|
||
}
|
||
|
||
/// Build a global text note (kind:1, NIP-01).
|
||
///
|
||
/// `reply_to_event_id`: adds a single `["e", <id>, "", "reply"]` tag.
|
||
/// This is intentionally simpler than the full `ThreadRef` mechanism used
|
||
/// for channel messages — social notes use a flat reply model for now.
|
||
/// Full NIP-10 threading (root + reply + p-tags) is deferred.
|
||
pub fn build_note(
|
||
content: &str,
|
||
reply_to_event_id: Option<nostr::EventId>,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_content(content, 64 * 1024)?;
|
||
let mut tags = vec![];
|
||
if let Some(reply_id) = reply_to_event_id {
|
||
tags.push(tag(&["e", &reply_id.to_hex(), "", "reply"])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(1), content).tags(tags))
|
||
}
|
||
|
||
/// Maximum number of contacts allowed in a single contact list event.
|
||
const MAX_CONTACTS: usize = 10_000;
|
||
|
||
/// Build a contact list replacement event (kind:3, NIP-02).
|
||
///
|
||
/// Each contact is `(pubkey_hex, relay_url, petname)`.
|
||
/// `pubkey_hex` must be exactly 64 hex characters (any case accepted, normalized
|
||
/// to lowercase before storage). Non-hex or wrong-length pubkeys are rejected
|
||
/// with `SdkError::InvalidInput`.
|
||
/// `relay_url` and `petname` may be `None` (stored as empty string per NIP-02).
|
||
///
|
||
/// Duplicate pubkeys are silently deduplicated — the first occurrence is kept.
|
||
///
|
||
/// Replaces the entire contact list — callers must read-before-write for deltas.
|
||
pub fn build_contact_list(
|
||
contacts: &[(&str, Option<&str>, Option<&str>)],
|
||
) -> Result<EventBuilder, SdkError> {
|
||
if contacts.len() > MAX_CONTACTS {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"contact list exceeds maximum of {} contacts (got {})",
|
||
MAX_CONTACTS,
|
||
contacts.len()
|
||
)));
|
||
}
|
||
let mut seen = std::collections::HashSet::with_capacity(contacts.len());
|
||
let mut tags = Vec::with_capacity(contacts.len());
|
||
for &(pubkey_hex, relay_url, petname) in contacts {
|
||
if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"contact pubkey must be exactly 64 hex chars, got len={}",
|
||
pubkey_hex.len()
|
||
)));
|
||
}
|
||
if let Some(url) = relay_url {
|
||
if url.len() > 2048 {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"relay_url exceeds 2048 bytes (got {})",
|
||
url.len()
|
||
)));
|
||
}
|
||
}
|
||
if let Some(name) = petname {
|
||
if name.len() > 256 {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"petname exceeds 256 bytes (got {})",
|
||
name.len()
|
||
)));
|
||
}
|
||
}
|
||
let lower = pubkey_hex.to_ascii_lowercase();
|
||
if !seen.insert(lower.clone()) {
|
||
continue;
|
||
}
|
||
tags.push(tag(&[
|
||
"p",
|
||
&lower,
|
||
relay_url.unwrap_or(""),
|
||
petname.unwrap_or(""),
|
||
])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(3), "").tags(tags))
|
||
}
|
||
|
||
/// Extract the channel UUID from an event's `h` tag.
|
||
///
|
||
/// Returns `None` if no `h` tag is present or the value is not a valid UUID.
|
||
pub fn extract_channel_id(event: &nostr::Event) -> Option<Uuid> {
|
||
event.tags.iter().find_map(|t| {
|
||
let vec = t.as_slice();
|
||
if vec.first().map(|s| s.as_str()) == Some("h") {
|
||
vec.get(1).and_then(|v| Uuid::parse_str(v.as_str()).ok())
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
}
|
||
|
||
/// Build a git repository announcement event (kind:30617, NIP-34).
|
||
///
|
||
/// Creates or updates a repository. The `repo_id` is the unique identifier
|
||
/// (d-tag) — must be `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`.
|
||
///
|
||
/// This is a parameterized replaceable event: publishing again with the same
|
||
/// `repo_id` updates the announcement (relay overwrites the previous one).
|
||
pub fn build_repo_announcement(
|
||
repo_id: &str,
|
||
name: Option<&str>,
|
||
description: Option<&str>,
|
||
clone_urls: &[&str],
|
||
web_url: Option<&str>,
|
||
relays: &[&str],
|
||
) -> Result<EventBuilder, SdkError> {
|
||
// Validate repo_id
|
||
check_repo_id(repo_id)?;
|
||
|
||
// Validate optional name
|
||
if let Some(n) = name {
|
||
if n.len() > 128 {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"name exceeds 128 characters (got {})",
|
||
n.len()
|
||
)));
|
||
}
|
||
}
|
||
|
||
// Validate optional description
|
||
if let Some(d) = description {
|
||
if d.len() > 1024 {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"description exceeds 1024 characters (got {})",
|
||
d.len()
|
||
)));
|
||
}
|
||
}
|
||
|
||
// Validate clone_urls
|
||
if clone_urls.len() > 5 {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"too many clone_urls (max 5, got {})",
|
||
clone_urls.len()
|
||
)));
|
||
}
|
||
for url in clone_urls {
|
||
if url.is_empty() {
|
||
return Err(SdkError::InvalidInput("clone_url must not be empty".into()));
|
||
}
|
||
if url.len() > 512 {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"clone_url exceeds 512 characters (got {})",
|
||
url.len()
|
||
)));
|
||
}
|
||
}
|
||
|
||
// Validate web_url
|
||
if let Some(url) = web_url {
|
||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"web_url must start with http:// or https:// (got {:?})",
|
||
url
|
||
)));
|
||
}
|
||
if url.len() > 512 {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"web_url exceeds 512 characters (got {})",
|
||
url.len()
|
||
)));
|
||
}
|
||
}
|
||
|
||
// Validate relays
|
||
if relays.len() > 10 {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"too many relays (max 10, got {})",
|
||
relays.len()
|
||
)));
|
||
}
|
||
for relay in relays {
|
||
if !relay.starts_with("ws://") && !relay.starts_with("wss://") {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"relay must start with ws:// or wss:// (got {:?})",
|
||
relay
|
||
)));
|
||
}
|
||
if relay.len() > 256 {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"relay exceeds 256 characters (got {})",
|
||
relay.len()
|
||
)));
|
||
}
|
||
}
|
||
|
||
// Build tags
|
||
let mut tags = vec![tag(&["d", repo_id])?];
|
||
if let Some(n) = name {
|
||
tags.push(tag(&["name", n])?);
|
||
}
|
||
if let Some(d) = description {
|
||
tags.push(tag(&["description", d])?);
|
||
}
|
||
if !clone_urls.is_empty() {
|
||
let mut clone_tag = vec!["clone"];
|
||
clone_tag.extend_from_slice(clone_urls);
|
||
tags.push(tag(&clone_tag)?);
|
||
}
|
||
if let Some(url) = web_url {
|
||
tags.push(tag(&["web", url])?);
|
||
}
|
||
if !relays.is_empty() {
|
||
let mut relay_tag = vec!["relays"];
|
||
relay_tag.extend_from_slice(relays);
|
||
tags.push(tag(&relay_tag)?);
|
||
}
|
||
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_REPO_ANNOUNCEMENT as u16), "").tags(tags))
|
||
}
|
||
|
||
/// Build a repository announcement while preserving caller-supplied metadata.
|
||
///
|
||
/// This is intended for read-modify-write updates to kind:30617 announcements.
|
||
/// Every supplied tag is retained except `d`; all existing `d` tags are replaced
|
||
/// with exactly one validated canonical repository identifier.
|
||
pub fn build_repo_announcement_with_tags(
|
||
repo_id: &str,
|
||
content: &str,
|
||
mut tags: Vec<Tag>,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_repo_id(repo_id)?;
|
||
tags.retain(|tag| tag.as_slice().first().map(String::as_str) != Some("d"));
|
||
tags.insert(0, tag(&["d", repo_id])?);
|
||
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_REPO_ANNOUNCEMENT as u16), content).tags(tags))
|
||
}
|
||
|
||
/// Repository coordinate — owner pubkey + `d`-tag identifier.
|
||
///
|
||
/// Renders as the `a`-tag value clients use to address a kind:30617
|
||
/// announcement: `30617:<owner>:<id>`.
|
||
pub struct GitRepoCoord {
|
||
/// 64-char hex pubkey of the repo's announcing owner.
|
||
pub owner: String,
|
||
/// The repo's `d`-tag identifier.
|
||
pub id: String,
|
||
}
|
||
|
||
impl GitRepoCoord {
|
||
fn to_a_tag_value(&self) -> Result<String, SdkError> {
|
||
let owner = check_pubkey_hex(&self.owner, "repo owner")?;
|
||
check_repo_id(&self.id)?;
|
||
Ok(format!("30617:{owner}:{}", self.id))
|
||
}
|
||
}
|
||
|
||
/// Metadata for a git patch event (kind:1617, NIP-34).
|
||
#[derive(Default)]
|
||
pub struct GitPatchMeta {
|
||
/// Earliest-unique-commit of the repo (`r` tag, `euc` marker).
|
||
pub euc: Option<String>,
|
||
/// Additional pubkeys to `p`-tag besides the repo owner.
|
||
pub recipients: Vec<String>,
|
||
/// Previous patch in a series, or the original root patch when this is
|
||
/// the first patch of a revision — emits `["e", id, "", "reply"]`.
|
||
pub reply_to: Option<String>,
|
||
/// First patch in a new series — emits `["t", "root"]`.
|
||
pub root: bool,
|
||
/// First patch in a revision of an existing series — emits `["t", "root-revision"]`.
|
||
pub root_revision: bool,
|
||
/// Commit ID this patch produces when applied (`commit` tag + `r` tag).
|
||
pub commit: Option<String>,
|
||
/// Parent commit ID (`parent-commit` tag).
|
||
pub parent_commit: Option<String>,
|
||
/// PGP signature of the commit, or `Some("")` for an explicitly unsigned commit.
|
||
pub commit_pgp_sig: Option<String>,
|
||
/// Committer identity: `(name, email, unix-timestamp, tz-offset-minutes)`.
|
||
pub committer: Option<(String, String, String, String)>,
|
||
}
|
||
|
||
/// Build a git patch event (kind:1617, NIP-34).
|
||
///
|
||
/// `content` is the verbatim output of `git format-patch` — not truncated.
|
||
/// NIP-34 says patches SHOULD be used when under 60KB (PRs otherwise); this
|
||
/// builder enforces that bound rather than silently truncating a patch that
|
||
/// must remain applyable.
|
||
pub fn build_git_patch(
|
||
repo: &GitRepoCoord,
|
||
content: &str,
|
||
meta: &GitPatchMeta,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
if content.trim().is_empty() {
|
||
return Err(SdkError::InvalidInput(
|
||
"patch content must not be empty — refusing to publish an unappliable patch".into(),
|
||
));
|
||
}
|
||
check_content(content, 60 * 1024)?;
|
||
let a_value = repo.to_a_tag_value()?;
|
||
let owner = check_pubkey_hex(&repo.owner, "repo owner")?;
|
||
|
||
let mut tags = vec![tag(&["a", &a_value])?];
|
||
if let Some(ref euc) = meta.euc {
|
||
check_commit_hex(euc, "euc")?;
|
||
tags.push(tag(&["r", euc, "euc"])?);
|
||
}
|
||
tags.push(tag(&["p", &owner])?);
|
||
for recipient in &meta.recipients {
|
||
let pk = check_pubkey_hex(recipient, "recipient")?;
|
||
tags.push(tag(&["p", &pk])?);
|
||
}
|
||
if let Some(ref prev) = meta.reply_to {
|
||
let event_id = check_hex_exact(prev, 64, "reply_to")?;
|
||
tags.push(tag(&["e", &event_id, "", "reply"])?);
|
||
}
|
||
if meta.root && meta.root_revision {
|
||
return Err(SdkError::InvalidInput(
|
||
"patch cannot be both --root and --root-revision".into(),
|
||
));
|
||
}
|
||
if meta.root {
|
||
tags.push(tag(&["t", "root"])?);
|
||
}
|
||
if meta.root_revision {
|
||
tags.push(tag(&["t", "root-revision"])?);
|
||
}
|
||
if let Some(ref commit) = meta.commit {
|
||
check_commit_hex(commit, "commit")?;
|
||
tags.push(tag(&["commit", commit])?);
|
||
tags.push(tag(&["r", commit])?);
|
||
}
|
||
if let Some(ref parent) = meta.parent_commit {
|
||
check_commit_hex(parent, "parent_commit")?;
|
||
tags.push(tag(&["parent-commit", parent])?);
|
||
}
|
||
if let Some(ref sig) = meta.commit_pgp_sig {
|
||
tags.push(tag(&["commit-pgp-sig", sig])?);
|
||
}
|
||
if let Some((ref name, ref email, ref ts, ref tz)) = meta.committer {
|
||
tags.push(tag(&["committer", name, email, ts, tz])?);
|
||
}
|
||
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_PATCH as u16), content).tags(tags))
|
||
}
|
||
|
||
/// Metadata for a git issue event (kind:1621, NIP-34).
|
||
#[derive(Default)]
|
||
pub struct GitIssueMeta {
|
||
/// Labels (`t` tags).
|
||
pub labels: Vec<String>,
|
||
/// Additional pubkeys to `p`-tag besides the repo owner.
|
||
pub recipients: Vec<String>,
|
||
}
|
||
|
||
/// Build a git issue event (kind:1621, NIP-34). `content` is the markdown body.
|
||
pub fn build_git_issue(
|
||
repo: &GitRepoCoord,
|
||
subject: &str,
|
||
content: &str,
|
||
meta: &GitIssueMeta,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_content(content, 64 * 1024)?;
|
||
if subject.is_empty() {
|
||
return Err(SdkError::InvalidInput("subject must not be empty".into()));
|
||
}
|
||
if subject.len() > 256 {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"subject exceeds 256 characters (got {})",
|
||
subject.len()
|
||
)));
|
||
}
|
||
let a_value = repo.to_a_tag_value()?;
|
||
let owner = check_pubkey_hex(&repo.owner, "repo owner")?;
|
||
|
||
let mut tags = vec![tag(&["a", &a_value])?, tag(&["p", &owner])?];
|
||
for recipient in &meta.recipients {
|
||
let pk = check_pubkey_hex(recipient, "recipient")?;
|
||
tags.push(tag(&["p", &pk])?);
|
||
}
|
||
tags.push(tag(&["subject", subject])?);
|
||
for label in &meta.labels {
|
||
tags.push(tag(&["t", label])?);
|
||
}
|
||
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_ISSUE as u16), content).tags(tags))
|
||
}
|
||
|
||
/// Status to apply to a patch or issue root (kind:1630/1631/1632/1633, NIP-34).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum GitStatus {
|
||
/// 1630 — Open (the default state).
|
||
Open,
|
||
/// 1631 — Applied/Merged for patches; Resolved for issues.
|
||
AppliedOrResolved,
|
||
/// 1632 — Closed.
|
||
Closed,
|
||
/// 1633 — Draft.
|
||
Draft,
|
||
}
|
||
|
||
/// A reference to an applied/merged patch event for a status `q` tag,
|
||
/// optionally carrying a relay-url and/or pubkey hint per NIP-34:
|
||
/// `['q', <id>, <relay-url>, <pubkey>]`.
|
||
///
|
||
/// Parsed from the CLI's `--q <id>[:<relay-url>[:<pubkey>]]` syntax via
|
||
/// [`GitAppliedPatchRef::parse`].
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct GitAppliedPatchRef {
|
||
/// The applied/merged patch event id (64-char hex).
|
||
pub id: String,
|
||
/// Optional relay-url hint where the patch event can be found.
|
||
pub relay: Option<String>,
|
||
/// Optional pubkey hint of the patch's author. Only meaningful when
|
||
/// `relay` is also set, per NIP-34's `['q', id, relay, pubkey]` shape.
|
||
pub pubkey: Option<String>,
|
||
}
|
||
|
||
impl GitAppliedPatchRef {
|
||
/// Parse `<id>`, `<id>:<relay-url>`, or `<id>:<relay-url>:<pubkey>`.
|
||
///
|
||
/// The relay-url segment may itself contain `:` (e.g. `wss://host:port`),
|
||
/// so splitting is bounded to at most 3 parts rather than splitting on
|
||
/// every colon.
|
||
pub fn parse(spec: &str) -> Result<Self, SdkError> {
|
||
let mut parts = spec.splitn(3, ':');
|
||
let id = parts.next().unwrap_or_default().to_string();
|
||
let rest = parts.next();
|
||
match rest {
|
||
None => Ok(GitAppliedPatchRef {
|
||
id,
|
||
relay: None,
|
||
pubkey: None,
|
||
}),
|
||
Some(_) => {
|
||
// Re-split with the relay url glued back together, since a
|
||
// relay URL itself contains colons (`wss://host:port`); the
|
||
// pubkey, if present, is always the last `:`-delimited
|
||
// segment.
|
||
let rest_str = &spec[id.len() + 1..];
|
||
if let Some(idx) = rest_str.rfind(':') {
|
||
let candidate_pubkey = &rest_str[idx + 1..];
|
||
if candidate_pubkey.len() == 64
|
||
&& candidate_pubkey.chars().all(|c| c.is_ascii_hexdigit())
|
||
{
|
||
return Ok(GitAppliedPatchRef {
|
||
id,
|
||
relay: Some(rest_str[..idx].to_string()),
|
||
pubkey: Some(candidate_pubkey.to_ascii_lowercase()),
|
||
});
|
||
}
|
||
}
|
||
Ok(GitAppliedPatchRef {
|
||
id,
|
||
relay: Some(rest_str.to_string()),
|
||
pubkey: None,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl GitStatus {
|
||
fn kind(self) -> u16 {
|
||
match self {
|
||
GitStatus::Open => KIND_GIT_STATUS_OPEN as u16,
|
||
GitStatus::AppliedOrResolved => KIND_GIT_STATUS_MERGED as u16,
|
||
GitStatus::Closed => KIND_GIT_STATUS_CLOSED as u16,
|
||
GitStatus::Draft => KIND_GIT_STATUS_DRAFT as u16,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Metadata for a git status event (kind:1630-1633, NIP-34). Applies to a
|
||
/// patch root, a patch revision root, an issue, or a PR.
|
||
#[derive(Default)]
|
||
pub struct GitStatusMeta {
|
||
/// The issue/PR/original-root-patch event being given a status — required.
|
||
pub root_event: String,
|
||
/// When a revision was the one applied/merged, its root id.
|
||
pub accepted_revision_root: Option<String>,
|
||
/// Repo coordinate, included as an `a` tag for subscription efficiency.
|
||
pub repo: Option<GitRepoCoord>,
|
||
/// Earliest-unique-commit of the repo (`r` tag, no marker).
|
||
pub euc: Option<String>,
|
||
/// Additional `p` tags (root author, revision author, etc.) besides the repo owner.
|
||
pub recipients: Vec<String>,
|
||
/// Applied/merged patch event references (`q` tags) — kind:1631 only.
|
||
pub applied_patches: Vec<GitAppliedPatchRef>,
|
||
/// Merge commit id — kind:1631 only.
|
||
pub merge_commit: Option<String>,
|
||
/// Commit ids applied to the target branch — kind:1631 only.
|
||
pub applied_as_commits: Vec<String>,
|
||
}
|
||
|
||
/// Build a git status event (kind:1630/1631/1632/1633, NIP-34).
|
||
/// `content` is optional markdown context for the status change.
|
||
pub fn build_git_status(
|
||
status: GitStatus,
|
||
content: &str,
|
||
meta: &GitStatusMeta,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_content(content, 64 * 1024)?;
|
||
let root = check_hex_exact(&meta.root_event, 64, "root_event")?;
|
||
|
||
let mut tags = vec![tag(&["e", &root, "", "root"])?];
|
||
if let Some(ref revision) = meta.accepted_revision_root {
|
||
let revision = check_hex_exact(revision, 64, "accepted_revision_root")?;
|
||
tags.push(tag(&["e", &revision, "", "reply"])?);
|
||
}
|
||
for recipient in &meta.recipients {
|
||
let pk = check_pubkey_hex(recipient, "recipient")?;
|
||
tags.push(tag(&["p", &pk])?);
|
||
}
|
||
if let Some(ref repo) = meta.repo {
|
||
let a_value = repo.to_a_tag_value()?;
|
||
tags.push(tag(&["a", &a_value])?);
|
||
}
|
||
if let Some(ref euc) = meta.euc {
|
||
check_commit_hex(euc, "euc")?;
|
||
tags.push(tag(&["r", euc])?);
|
||
}
|
||
|
||
if status != GitStatus::AppliedOrResolved
|
||
&& (!meta.applied_patches.is_empty()
|
||
|| meta.merge_commit.is_some()
|
||
|| !meta.applied_as_commits.is_empty())
|
||
{
|
||
return Err(SdkError::InvalidInput(
|
||
"applied_patches/merge_commit/applied_as_commits only apply to the merged/resolved status".into(),
|
||
));
|
||
}
|
||
for patch_ref in &meta.applied_patches {
|
||
let patch_id = check_hex_exact(&patch_ref.id, 64, "applied_patch")?;
|
||
match (&patch_ref.relay, &patch_ref.pubkey) {
|
||
(None, None) => tags.push(tag(&["q", &patch_id])?),
|
||
(Some(relay), None) => tags.push(tag(&["q", &patch_id, relay])?),
|
||
(Some(relay), Some(pubkey)) => {
|
||
let pubkey = check_pubkey_hex(pubkey, "applied_patch pubkey")?;
|
||
tags.push(tag(&["q", &patch_id, relay, &pubkey])?)
|
||
}
|
||
(None, Some(_)) => {
|
||
return Err(SdkError::InvalidInput(
|
||
"applied_patch pubkey hint requires a relay-url hint".into(),
|
||
))
|
||
}
|
||
}
|
||
}
|
||
if let Some(ref merge_commit) = meta.merge_commit {
|
||
check_commit_hex(merge_commit, "merge_commit")?;
|
||
tags.push(tag(&["merge-commit", merge_commit])?);
|
||
tags.push(tag(&["r", merge_commit])?);
|
||
}
|
||
if !meta.applied_as_commits.is_empty() {
|
||
let mut commits_tag = vec!["applied-as-commits"];
|
||
for commit in &meta.applied_as_commits {
|
||
check_commit_hex(commit, "applied_as_commit")?;
|
||
}
|
||
commits_tag.extend(meta.applied_as_commits.iter().map(String::as_str));
|
||
tags.push(tag(&commits_tag)?);
|
||
for commit in &meta.applied_as_commits {
|
||
tags.push(tag(&["r", commit])?);
|
||
}
|
||
}
|
||
|
||
Ok(EventBuilder::new(Kind::Custom(status.kind()), content).tags(tags))
|
||
}
|
||
|
||
/// Metadata for a git pull-request event (kind:1618, NIP-34).
|
||
///
|
||
/// A PR points reviewers at a branch tip they can fetch — `commit` (the tip)
|
||
/// plus at least one `clone_urls` entry where that commit is reachable. Unlike
|
||
/// a [`build_git_patch`], the change is *not* inlined; the diff is whatever the
|
||
/// tip introduces over its merge base. Per NIP-34 the tip SHOULD already be
|
||
/// pushed to `refs/nostr/<pr-event-id>` (or otherwise reachable) in the clone
|
||
/// repos before the event is signed — this builder does no network work and
|
||
/// does not verify reachability, mirroring [`build_git_patch`]'s philosophy.
|
||
#[derive(Default)]
|
||
pub struct GitPullRequestMeta {
|
||
/// Earliest-unique-commit of the repo (`r` tag) — lets clients subscribe
|
||
/// to all PRs against a local repo.
|
||
pub euc: Option<String>,
|
||
/// Additional pubkeys to `p`-tag besides the repo owner.
|
||
pub recipients: Vec<String>,
|
||
/// NIP-29 channel where the pull request originated (`h` tag).
|
||
pub channel_id: Option<String>,
|
||
/// PR subject line (`subject` tag) — required, used as the header.
|
||
pub subject: String,
|
||
/// Labels (`t` tags).
|
||
pub labels: Vec<String>,
|
||
/// Tip commit id of the PR branch (`c` tag) — required.
|
||
pub commit: String,
|
||
/// Clone URL(s) where the tip can be fetched (`clone` tag) — at least one.
|
||
pub clone_urls: Vec<String>,
|
||
/// Recommended branch name (`branch-name` tag).
|
||
pub branch_name: Option<String>,
|
||
/// Most recent common ancestor with the target branch (`merge-base` tag).
|
||
pub merge_base: Option<String>,
|
||
/// Root patch event this PR revises, which should then be closed
|
||
/// (`e` tag) — optional.
|
||
pub revision_of: Option<String>,
|
||
}
|
||
|
||
/// Build a git pull-request event (kind:1618, NIP-34). `content` is the
|
||
/// markdown PR description.
|
||
pub fn build_git_pull_request(
|
||
repo: &GitRepoCoord,
|
||
content: &str,
|
||
meta: &GitPullRequestMeta,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_content(content, 64 * 1024)?;
|
||
if meta.subject.is_empty() {
|
||
return Err(SdkError::InvalidInput("subject must not be empty".into()));
|
||
}
|
||
if meta.subject.len() > 256 {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"subject exceeds 256 characters (got {})",
|
||
meta.subject.len()
|
||
)));
|
||
}
|
||
check_commit_hex(&meta.commit, "commit")?;
|
||
if meta.clone_urls.is_empty() {
|
||
return Err(SdkError::InvalidInput(
|
||
"a pull request needs at least one --clone url where the tip commit can be fetched"
|
||
.into(),
|
||
));
|
||
}
|
||
let a_value = repo.to_a_tag_value()?;
|
||
let owner = check_pubkey_hex(&repo.owner, "repo owner")?;
|
||
|
||
let mut tags = vec![tag(&["a", &a_value])?];
|
||
if let Some(ref euc) = meta.euc {
|
||
check_commit_hex(euc, "euc")?;
|
||
tags.push(tag(&["r", euc])?);
|
||
}
|
||
tags.push(tag(&["p", &owner])?);
|
||
for recipient in &meta.recipients {
|
||
let pk = check_pubkey_hex(recipient, "recipient")?;
|
||
tags.push(tag(&["p", &pk])?);
|
||
}
|
||
tags.push(tag(&["subject", &meta.subject])?);
|
||
for label in &meta.labels {
|
||
tags.push(tag(&["t", label])?);
|
||
}
|
||
tags.push(tag(&["c", &meta.commit])?);
|
||
if let Some(ref channel_id) = meta.channel_id {
|
||
let channel_id = Uuid::parse_str(channel_id)
|
||
.map_err(|e| SdkError::InvalidInput(format!("channel_id must be a valid UUID: {e}")))?;
|
||
tags.push(tag(&["h", &channel_id.to_string()])?);
|
||
}
|
||
let mut clone_tag = vec!["clone"];
|
||
clone_tag.extend(meta.clone_urls.iter().map(String::as_str));
|
||
tags.push(tag(&clone_tag)?);
|
||
if let Some(ref branch) = meta.branch_name {
|
||
tags.push(tag(&["branch-name", branch])?);
|
||
}
|
||
if let Some(ref base) = meta.merge_base {
|
||
check_commit_hex(base, "merge_base")?;
|
||
tags.push(tag(&["merge-base", base])?);
|
||
}
|
||
if let Some(ref patch) = meta.revision_of {
|
||
let patch_id = check_hex_exact(patch, 64, "revision_of")?;
|
||
tags.push(tag(&["e", &patch_id])?);
|
||
}
|
||
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_PULL_REQUEST as u16), content).tags(tags))
|
||
}
|
||
|
||
/// Metadata for a git pull-request update event (kind:1619, NIP-34). A PR
|
||
/// update changes the tip commit of an existing PR; it references the PR via
|
||
/// NIP-22 uppercase root tags (`E`/`P`).
|
||
#[derive(Default)]
|
||
pub struct GitPrUpdateMeta {
|
||
/// Earliest-unique-commit of the repo (`r` tag).
|
||
pub euc: Option<String>,
|
||
/// Additional pubkeys to `p`-tag besides the repo owner.
|
||
pub recipients: Vec<String>,
|
||
/// The pull-request event being updated (`E` tag, NIP-22 root) — required.
|
||
pub pr_event: String,
|
||
/// The pull-request author (`P` tag, NIP-22 root) — required.
|
||
pub pr_author: String,
|
||
/// Updated tip commit id (`c` tag) — required.
|
||
pub commit: String,
|
||
/// Clone URL(s) where the new tip can be fetched (`clone` tag) — at least one.
|
||
pub clone_urls: Vec<String>,
|
||
/// Most recent common ancestor with the target branch (`merge-base` tag).
|
||
pub merge_base: Option<String>,
|
||
}
|
||
|
||
/// Build a git pull-request update event (kind:1619, NIP-34). `content` is
|
||
/// optional markdown context for the update.
|
||
pub fn build_git_pr_update(
|
||
repo: &GitRepoCoord,
|
||
content: &str,
|
||
meta: &GitPrUpdateMeta,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_content(content, 64 * 1024)?;
|
||
let pr_event = check_hex_exact(&meta.pr_event, 64, "pr_event")?;
|
||
let pr_author = check_pubkey_hex(&meta.pr_author, "pr_author")?;
|
||
check_commit_hex(&meta.commit, "commit")?;
|
||
if meta.clone_urls.is_empty() {
|
||
return Err(SdkError::InvalidInput(
|
||
"a pull request update needs at least one --clone url where the tip commit can be fetched"
|
||
.into(),
|
||
));
|
||
}
|
||
let a_value = repo.to_a_tag_value()?;
|
||
let owner = check_pubkey_hex(&repo.owner, "repo owner")?;
|
||
|
||
let mut tags = vec![tag(&["a", &a_value])?];
|
||
if let Some(ref euc) = meta.euc {
|
||
check_commit_hex(euc, "euc")?;
|
||
tags.push(tag(&["r", euc])?);
|
||
}
|
||
tags.push(tag(&["p", &owner])?);
|
||
for recipient in &meta.recipients {
|
||
let pk = check_pubkey_hex(recipient, "recipient")?;
|
||
tags.push(tag(&["p", &pk])?);
|
||
}
|
||
tags.push(tag(&["E", &pr_event])?);
|
||
tags.push(tag(&["P", &pr_author])?);
|
||
tags.push(tag(&["c", &meta.commit])?);
|
||
let mut clone_tag = vec!["clone"];
|
||
clone_tag.extend(meta.clone_urls.iter().map(String::as_str));
|
||
tags.push(tag(&clone_tag)?);
|
||
if let Some(ref base) = meta.merge_base {
|
||
check_commit_hex(base, "merge_base")?;
|
||
tags.push(tag(&["merge-base", base])?);
|
||
}
|
||
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_PR_UPDATE as u16), content).tags(tags))
|
||
}
|
||
|
||
/// Build a workflow definition event (kind 30620).
|
||
///
|
||
/// - `channel_id`: the channel this workflow belongs to (h-tag)
|
||
/// - `workflow_id`: unique workflow UUID (d-tag)
|
||
/// - `yaml`: workflow YAML definition as content
|
||
pub fn build_workflow_def(
|
||
channel_id: Uuid,
|
||
workflow_id: Uuid,
|
||
yaml: &str,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_content(yaml, 64 * 1024)?;
|
||
let tags = vec![
|
||
tag(&["d", &workflow_id.to_string()])?,
|
||
tag(&["h", &channel_id.to_string()])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_WORKFLOW_DEF as u16), yaml).tags(tags))
|
||
}
|
||
|
||
/// Build a workflow update event (kind 30620) for an existing workflow.
|
||
///
|
||
/// Updates an existing workflow definition in-place via the parameterized
|
||
/// replaceable event mechanism — same d-tag overwrites the previous version.
|
||
/// The h-tag (channel scope) is required by the relay for authorization.
|
||
pub fn build_workflow_update(
|
||
channel_id: Uuid,
|
||
workflow_id: Uuid,
|
||
yaml: &str,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_content(yaml, 64 * 1024)?;
|
||
let tags = vec![
|
||
tag(&["d", &workflow_id.to_string()])?,
|
||
tag(&["h", &channel_id.to_string()])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_WORKFLOW_DEF as u16), yaml).tags(tags))
|
||
}
|
||
|
||
/// Build a NIP-09 deletion event targeting a workflow definition (kind 5).
|
||
///
|
||
/// The `a`-tag addresses the parameterized replaceable event
|
||
/// `<KIND_WORKFLOW_DEF>:<pubkey>:<workflow_id>`.
|
||
pub fn build_workflow_delete(
|
||
author_pubkey: &str,
|
||
workflow_id: Uuid,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
let pk = check_pubkey_hex(author_pubkey, "author_pubkey")?;
|
||
let tags = vec![tag(&[
|
||
"a",
|
||
&format!("{}:{pk}:{workflow_id}", KIND_WORKFLOW_DEF),
|
||
])?];
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_DELETION as u16), "").tags(tags))
|
||
}
|
||
|
||
/// Build a workflow trigger event (kind 46020).
|
||
pub fn build_workflow_trigger(workflow_id: Uuid) -> Result<EventBuilder, SdkError> {
|
||
let tags = vec![tag(&["d", &workflow_id.to_string()])?];
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_WORKFLOW_TRIGGER as u16), "").tags(tags))
|
||
}
|
||
|
||
/// Build a workflow approval event — kind 46030 (grant) or 46031 (deny).
|
||
///
|
||
/// - `token_hash`: hex-encoded SHA-256 of the approval token UUID (d-tag).
|
||
/// Must be exactly 64 hex characters.
|
||
/// - `approved`: `true` emits kind 46030 (grant), `false` emits kind 46031 (deny)
|
||
/// - `note`: optional human-readable note as event content
|
||
pub fn build_workflow_approval(
|
||
token_hash: &str,
|
||
approved: bool,
|
||
note: &str,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
if token_hash.len() != 64 || !token_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||
return Err(SdkError::InvalidInput(
|
||
"token_hash must be a 64-character hex SHA-256 digest".into(),
|
||
));
|
||
}
|
||
let kind = if approved {
|
||
KIND_APPROVAL_GRANT
|
||
} else {
|
||
KIND_APPROVAL_DENY
|
||
};
|
||
let tags = vec![tag(&["d", token_hash])?];
|
||
Ok(EventBuilder::new(Kind::Custom(kind as u16), note).tags(tags))
|
||
}
|
||
|
||
/// Build a DM open event (kind 41010).
|
||
///
|
||
/// `pubkeys` must be 1–8 hex-encoded pubkeys to include in the DM conversation.
|
||
pub fn build_dm_open(pubkeys: &[&str]) -> Result<EventBuilder, SdkError> {
|
||
if pubkeys.is_empty() || pubkeys.len() > 8 {
|
||
return Err(SdkError::InvalidInput(
|
||
"dm open requires 1-8 pubkeys".into(),
|
||
));
|
||
}
|
||
let mut tags = Vec::with_capacity(pubkeys.len());
|
||
for pk in pubkeys {
|
||
let validated = check_pubkey_hex(pk, "pubkey")?;
|
||
tags.push(tag(&["p", &validated])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_DM_OPEN as u16), "").tags(tags))
|
||
}
|
||
|
||
/// Build a DM add-member event (kind 41011).
|
||
pub fn build_dm_add_member(channel_id: Uuid, pubkey: &str) -> Result<EventBuilder, SdkError> {
|
||
let pk = check_pubkey_hex(pubkey, "pubkey")?;
|
||
let tags = vec![tag(&["h", &channel_id.to_string()])?, tag(&["p", &pk])?];
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_DM_ADD_MEMBER as u16), "").tags(tags))
|
||
}
|
||
|
||
/// Build a presence update event (kind 20001).
|
||
///
|
||
/// `status` must be one of: `"online"`, `"away"`, `"offline"`.
|
||
/// The status is placed in `event.content` (relay reads it there) and also
|
||
/// in a `["status", ...]` tag for structured access.
|
||
pub fn build_presence_update(status: &str) -> Result<EventBuilder, SdkError> {
|
||
match status {
|
||
"online" | "away" | "offline" => {}
|
||
_ => {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"status must be online, away, or offline (got: {status})"
|
||
)))
|
||
}
|
||
}
|
||
let tags = vec![tag(&["status", status])?];
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), status).tags(tags))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Community moderation commands (kinds 9040–9044).
|
||
//
|
||
// These mirror the NIP-43 relay-admin 9030-series: mod-signed command events
|
||
// that the relay validates + executes directly and never stores. The tenant
|
||
// (community) is bound by the connection host, so no `h` tag is carried — a
|
||
// stray `h` would be rejected as channel-scoping a global-only command. The
|
||
// tag vocabulary below is pinned by `moderation_commands.rs` (relay and CLI
|
||
// must agree).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Build a community ban command (kind 9040).
|
||
///
|
||
/// `expires_at`: `None` ⇒ permanent; `Some(unix_secs)` ⇒ ban lifts at that time.
|
||
pub fn build_moderation_ban(
|
||
target_pubkey: &str,
|
||
expires_at: Option<u64>,
|
||
reason: Option<&str>,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?;
|
||
let mut tags = vec![tag(&["p", &target_pubkey])?];
|
||
if let Some(exp) = expires_at {
|
||
tags.push(tag(&["expiration", &exp.to_string()])?);
|
||
}
|
||
if let Some(r) = reason {
|
||
tags.push(tag(&["reason", r])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_BAN as u16), "").tags(tags))
|
||
}
|
||
|
||
/// Build a community unban command (kind 9041).
|
||
pub fn build_moderation_unban(target_pubkey: &str) -> Result<EventBuilder, SdkError> {
|
||
let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?;
|
||
let tags = vec![tag(&["p", &target_pubkey])?];
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_UNBAN as u16), "").tags(tags))
|
||
}
|
||
|
||
/// Build a community timeout (write-block) command (kind 9042).
|
||
///
|
||
/// `expires_at` (required) is the unix-seconds timestamp the timeout lifts at.
|
||
pub fn build_moderation_timeout(
|
||
target_pubkey: &str,
|
||
expires_at: u64,
|
||
reason: Option<&str>,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?;
|
||
let mut tags = vec![
|
||
tag(&["p", &target_pubkey])?,
|
||
tag(&["expiration", &expires_at.to_string()])?,
|
||
];
|
||
if let Some(r) = reason {
|
||
tags.push(tag(&["reason", r])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_TIMEOUT as u16), "").tags(tags))
|
||
}
|
||
|
||
/// Build a community untimeout command (kind 9043).
|
||
pub fn build_moderation_untimeout(target_pubkey: &str) -> Result<EventBuilder, SdkError> {
|
||
let target_pubkey = check_pubkey_hex(target_pubkey, "target_pubkey")?;
|
||
let tags = vec![tag(&["p", &target_pubkey])?];
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_UNTIMEOUT as u16), "").tags(tags))
|
||
}
|
||
|
||
/// Build a resolve-report command (kind 9044).
|
||
///
|
||
/// `report_event_id`: hex event id of the kind:1984 report being resolved.
|
||
/// `status`: `resolved` | `dismissed`. `action`: `delete` | `kick` | `ban` |
|
||
/// `timeout` | `dismiss` | `escalate` (`dismiss` pairs with `dismissed`,
|
||
/// everything else with `resolved` — the relay enforces the pairing).
|
||
/// `reason`: optional; audited into `public_reason` and relayed in the
|
||
/// reporter notice DM, so it must be safe for the reporter to read.
|
||
pub fn build_moderation_resolve_report(
|
||
report_event_id: &str,
|
||
status: &str,
|
||
action: &str,
|
||
reason: Option<&str>,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
let report_event_id = check_hex_exact(report_event_id, 64, "report_event_id")?;
|
||
match status {
|
||
"resolved" | "dismissed" => {}
|
||
_ => {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"status must be resolved or dismissed (got: {status})"
|
||
)))
|
||
}
|
||
}
|
||
match action {
|
||
"delete" | "kick" | "ban" | "timeout" | "dismiss" | "escalate" => {}
|
||
_ => {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"action must be delete, kick, ban, timeout, dismiss, or escalate (got: {action})"
|
||
)))
|
||
}
|
||
}
|
||
let mut tags = vec![
|
||
tag(&["report", &report_event_id])?,
|
||
tag(&["status", status])?,
|
||
tag(&["action", action])?,
|
||
];
|
||
if let Some(r) = reason {
|
||
tags.push(tag(&["reason", r])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_RESOLVE_REPORT as u16), "").tags(tags))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// NIP-IA identity archival (kinds 9035/9036).
|
||
//
|
||
// kind:9035 archive request, kind:9036 unarchive request. Both protected by
|
||
// NIP-70 (`["-"]`), p-tag the target, and may carry an optional machine-
|
||
// readable `reason` code, a `replaced-by` rotation pointer (9035 only), and a
|
||
// NIP-OA `auth` tag for owner-of-agent requests. The relay verifies; this
|
||
// builder's job is to produce a well-formed, signed request — the relay
|
||
// selects the consent path (self / admin / owner). Mirrors the desktop's
|
||
// `identity_archive_tags`/`build_archive_identity_request` (events.rs:624-743)
|
||
// so both clients emit the same wire form.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Maximum `reason` length in UTF-8 bytes (not chars — see
|
||
/// `desktop/src-tauri/src/events.rs:635-647`, whose `.len()` check is
|
||
/// already byte-based despite its error text saying "chars").
|
||
const MAX_REASON_BYTES: usize = 64;
|
||
|
||
fn check_reason(reason: &str) -> Result<(), SdkError> {
|
||
if reason.len() > MAX_REASON_BYTES {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"reason code exceeds maximum length of {MAX_REASON_BYTES} UTF-8 bytes (got {})",
|
||
reason.len()
|
||
)));
|
||
}
|
||
if reason.chars().any(|c| c.is_control()) {
|
||
return Err(SdkError::InvalidInput(
|
||
"reason code must not contain control characters".into(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Structural check only — the relay performs full NIP-OA verification.
|
||
/// Requires the `auth` label, a 64-hex owner pubkey, and a 128-hex signature.
|
||
fn check_auth_tag_shape(auth: &[String; 4]) -> Result<(), SdkError> {
|
||
if auth[0] != "auth" {
|
||
return Err(SdkError::InvalidInput(format!(
|
||
"auth tag label must be \"auth\" (got \"{}\")",
|
||
auth[0]
|
||
)));
|
||
}
|
||
check_pubkey_hex(&auth[1], "auth tag owner pubkey")?;
|
||
if auth[3].len() != 128 || !auth[3].chars().all(|c| c.is_ascii_hexdigit()) {
|
||
return Err(SdkError::InvalidInput(
|
||
"auth tag signature must be 128-character hex".into(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn identity_archive_tags(
|
||
target_pubkey: &str,
|
||
reason: Option<&str>,
|
||
replaced_by: Option<&str>,
|
||
auth: Option<&[String; 4]>,
|
||
) -> Result<Vec<Tag>, SdkError> {
|
||
let target_lower = check_pubkey_hex(target_pubkey, "target_pubkey")?;
|
||
|
||
// NIP-70: mark as protected administrative state.
|
||
let mut tags = vec![tag(&["-"])?, tag(&["p", &target_lower])?];
|
||
|
||
if let Some(r) = reason {
|
||
check_reason(r)?;
|
||
tags.push(tag(&["reason", r])?);
|
||
}
|
||
|
||
if let Some(rb) = replaced_by {
|
||
let rb_lower = check_pubkey_hex(rb, "replaced_by")?;
|
||
if rb_lower == target_lower {
|
||
return Err(SdkError::InvalidInput(
|
||
"replaced-by must differ from the target".into(),
|
||
));
|
||
}
|
||
tags.push(tag(&["replaced-by", &rb_lower])?);
|
||
}
|
||
|
||
if let Some(auth_tag) = auth {
|
||
check_auth_tag_shape(auth_tag)?;
|
||
tags.push(tag(&["auth", &auth_tag[1], &auth_tag[2], &auth_tag[3]])?);
|
||
}
|
||
|
||
Ok(tags)
|
||
}
|
||
|
||
/// Build a NIP-IA archive request (kind 9035).
|
||
///
|
||
/// `content` is an optional human-readable reason (clients MUST NOT parse
|
||
/// authorization semantics from it; capped at 64 KiB like other content
|
||
/// fields). `reason` is a machine-readable code (`rotated`, `retired`,
|
||
/// `bot-rebuilt`, `left-organization`, `spam`, ... — unknown codes are
|
||
/// allowed per spec), capped at 64 UTF-8 bytes. `replaced_by` is the
|
||
/// rotation pointer and must differ from `target_pubkey`. `auth` is a
|
||
/// NIP-OA owner-attestation tag required only for the owner-of-agent
|
||
/// consent path; full verification happens relay-side.
|
||
///
|
||
/// `.allow_self_tagging()` is required: NIP-IA's self path has
|
||
/// `actor == target`, so the request's `["p", target]` matches the signer.
|
||
/// nostr 0.44 strips matching `p` tags by default — this keeps the wire
|
||
/// form intact.
|
||
pub fn build_archive_identity_request(
|
||
target_pubkey: &str,
|
||
content: &str,
|
||
reason: Option<&str>,
|
||
replaced_by: Option<&str>,
|
||
auth: Option<&[String; 4]>,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_content(content, 64 * 1024)?;
|
||
let tags = identity_archive_tags(target_pubkey, reason, replaced_by, auth)?;
|
||
Ok(
|
||
EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16), content)
|
||
.tags(tags)
|
||
.allow_self_tagging(),
|
||
)
|
||
}
|
||
|
||
/// Build a NIP-IA unarchive request (kind 9036).
|
||
///
|
||
/// Same shape as [`build_archive_identity_request`] minus `replaced-by`
|
||
/// (which has no defined meaning on unarchive per spec). `auth` is used for
|
||
/// owner-of-agent unarchive paths. See that function for the rationale on
|
||
/// `.allow_self_tagging()`.
|
||
pub fn build_unarchive_identity_request(
|
||
target_pubkey: &str,
|
||
content: &str,
|
||
reason: Option<&str>,
|
||
auth: Option<&[String; 4]>,
|
||
) -> Result<EventBuilder, SdkError> {
|
||
check_content(content, 64 * 1024)?;
|
||
let tags = identity_archive_tags(target_pubkey, reason, None, auth)?;
|
||
Ok(
|
||
EventBuilder::new(Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16), content)
|
||
.tags(tags)
|
||
.allow_self_tagging(),
|
||
)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use nostr::{EventId, Keys};
|
||
|
||
fn keys() -> Keys {
|
||
Keys::generate()
|
||
}
|
||
|
||
fn sign(b: EventBuilder) -> nostr::Event {
|
||
b.sign_with_keys(&keys()).expect("sign")
|
||
}
|
||
|
||
fn event_id() -> EventId {
|
||
let k = keys();
|
||
EventBuilder::new(Kind::Custom(1), "x")
|
||
.tags([])
|
||
.sign_with_keys(&k)
|
||
.expect("sign")
|
||
.id
|
||
}
|
||
|
||
fn uuid() -> Uuid {
|
||
Uuid::new_v4()
|
||
}
|
||
|
||
fn tag_values(event: &nostr::Event, key: &str) -> Vec<String> {
|
||
event
|
||
.tags
|
||
.iter()
|
||
.filter_map(|t| {
|
||
let s = t.as_slice();
|
||
if s.first().map(|v| v.as_str()) == Some(key) {
|
||
s.get(1).map(|v| v.to_string())
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn has_tag(event: &nostr::Event, key: &str, val: &str) -> bool {
|
||
event.tags.iter().any(|t| {
|
||
let s = t.as_slice();
|
||
s.first().map(|v| v.as_str()) == Some(key) && s.get(1).map(|v| v.as_str()) == Some(val)
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn message_happy_path() {
|
||
let cid = uuid();
|
||
let ev = sign(build_message(cid, "hello", None, &[], false, &[]).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 9);
|
||
assert_eq!(ev.content, "hello");
|
||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn agent_observer_frame_happy_path() {
|
||
let sender = keys();
|
||
let recipient = keys();
|
||
let agent = keys();
|
||
let encrypted = buzz_core::observer::encrypt_observer_payload(
|
||
&sender,
|
||
&recipient.public_key(),
|
||
&serde_json::json!({"type": "acp_read"}),
|
||
)
|
||
.unwrap();
|
||
let ev = sign(
|
||
build_agent_observer_frame(
|
||
&recipient.public_key().to_hex(),
|
||
&agent.public_key().to_hex(),
|
||
OBSERVER_FRAME_TELEMETRY,
|
||
&encrypted,
|
||
)
|
||
.unwrap(),
|
||
);
|
||
|
||
assert_eq!(ev.kind.as_u16(), KIND_AGENT_OBSERVER_FRAME as u16);
|
||
assert_eq!(ev.content, encrypted);
|
||
assert!(has_tag(&ev, "p", &recipient.public_key().to_hex()));
|
||
assert!(has_tag(
|
||
&ev,
|
||
OBSERVER_AGENT_TAG,
|
||
&agent.public_key().to_hex()
|
||
));
|
||
assert!(has_tag(&ev, OBSERVER_FRAME_TAG, OBSERVER_FRAME_TELEMETRY));
|
||
}
|
||
|
||
#[test]
|
||
fn agent_observer_frame_rejects_plaintext_content() {
|
||
let err = build_agent_observer_frame(
|
||
&"a".repeat(64),
|
||
&"b".repeat(64),
|
||
OBSERVER_FRAME_TELEMETRY,
|
||
"not encrypted",
|
||
)
|
||
.unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn message_direct_reply() {
|
||
let cid = uuid();
|
||
let eid = event_id();
|
||
let tr = ThreadRef {
|
||
root_event_id: eid,
|
||
parent_event_id: eid,
|
||
};
|
||
let ev = sign(build_message(cid, "reply", Some(&tr), &[], false, &[]).unwrap());
|
||
// Direct reply: only one e-tag with "reply" marker
|
||
let e_tags: Vec<_> = ev
|
||
.tags
|
||
.iter()
|
||
.filter(|t| t.as_slice().first().map(|v| v.as_str()) == Some("e"))
|
||
.collect();
|
||
assert_eq!(e_tags.len(), 1);
|
||
assert_eq!(
|
||
e_tags[0].as_slice().get(3).map(|v| v.as_str()),
|
||
Some("reply")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn message_nested_reply() {
|
||
let cid = uuid();
|
||
let root = event_id();
|
||
let parent = event_id();
|
||
let tr = ThreadRef {
|
||
root_event_id: root,
|
||
parent_event_id: parent,
|
||
};
|
||
let ev = sign(build_message(cid, "nested", Some(&tr), &[], false, &[]).unwrap());
|
||
let e_tags: Vec<_> = ev
|
||
.tags
|
||
.iter()
|
||
.filter(|t| t.as_slice().first().map(|v| v.as_str()) == Some("e"))
|
||
.collect();
|
||
assert_eq!(e_tags.len(), 2);
|
||
let markers: Vec<_> = e_tags
|
||
.iter()
|
||
.filter_map(|t| t.as_slice().get(3).map(|v| v.as_str()))
|
||
.collect();
|
||
assert!(markers.contains(&"root"));
|
||
assert!(markers.contains(&"reply"));
|
||
}
|
||
|
||
#[test]
|
||
fn message_broadcast_flag() {
|
||
let cid = uuid();
|
||
let ev = sign(build_message(cid, "hi", None, &[], true, &[]).unwrap());
|
||
assert!(has_tag(&ev, "broadcast", "1"));
|
||
}
|
||
|
||
#[test]
|
||
fn message_mentions_deduped() {
|
||
let cid = uuid();
|
||
let hex = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
|
||
let ev = sign(build_message(cid, "hi", None, &[hex, hex], false, &[]).unwrap());
|
||
let p_tags = tag_values(&ev, "p");
|
||
assert_eq!(p_tags.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn message_too_many_mentions() {
|
||
let cid = uuid();
|
||
let hex = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
|
||
let _mentions: Vec<&str> = (0..51).map(|_| hex).collect();
|
||
// All same hex so dedup would reduce to 1, but the check is on raw len
|
||
// Let's use 51 distinct-ish values by varying the first char
|
||
let hexes: Vec<String> = (0..51u8)
|
||
.map(|i| {
|
||
format!(
|
||
"{:02x}cd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd12",
|
||
i
|
||
)
|
||
})
|
||
.collect();
|
||
let refs: Vec<&str> = hexes.iter().map(|s| s.as_str()).collect();
|
||
let result = build_message(cid, "hi", None, &refs, false, &[]);
|
||
assert!(matches!(result, Err(SdkError::TooManyMentions)));
|
||
}
|
||
|
||
#[test]
|
||
fn message_content_too_large() {
|
||
let cid = uuid();
|
||
let big = "x".repeat(64 * 1024 + 1);
|
||
let result = build_message(cid, &big, None, &[], false, &[]);
|
||
assert!(matches!(result, Err(SdkError::ContentTooLarge { .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn message_max_content_ok() {
|
||
let cid = uuid();
|
||
let max = "x".repeat(64 * 1024);
|
||
assert!(build_message(cid, &max, None, &[], false, &[]).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn forum_post_happy_path() {
|
||
let cid = uuid();
|
||
let ev = sign(build_forum_post(cid, "post body", &[], &[]).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 45001);
|
||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn forum_post_content_too_large() {
|
||
let cid = uuid();
|
||
let big = "x".repeat(64 * 1024 + 1);
|
||
assert!(matches!(
|
||
build_forum_post(cid, &big, &[], &[]),
|
||
Err(SdkError::ContentTooLarge { .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn forum_comment_happy_path() {
|
||
let cid = uuid();
|
||
let eid = event_id();
|
||
let tr = ThreadRef {
|
||
root_event_id: eid,
|
||
parent_event_id: eid,
|
||
};
|
||
let ev = sign(build_forum_comment(cid, "comment", &tr, &[], &[]).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 45003);
|
||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||
}
|
||
|
||
fn good_diff_meta() -> DiffMeta {
|
||
DiffMeta {
|
||
repo_url: "https://github.com/example/repo".into(),
|
||
commit_sha: "abc1234".into(),
|
||
file_path: Some("src/main.rs".into()),
|
||
parent_commit: None,
|
||
branch: None,
|
||
pr_number: None,
|
||
language: Some("rust".into()),
|
||
description: None,
|
||
truncated: false,
|
||
alt_text: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn diff_message_happy_path() {
|
||
let cid = uuid();
|
||
let ev = sign(build_diff_message(cid, "diff content", &good_diff_meta(), None).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 40008);
|
||
assert!(has_tag(&ev, "repo", "https://github.com/example/repo"));
|
||
assert!(has_tag(&ev, "commit", "abc1234"));
|
||
assert!(has_tag(&ev, "l", "rust"));
|
||
}
|
||
|
||
#[test]
|
||
fn diff_message_bad_repo_url() {
|
||
let cid = uuid();
|
||
let mut meta = good_diff_meta();
|
||
meta.repo_url = "ftp://bad.url".into();
|
||
assert!(matches!(
|
||
build_diff_message(cid, "x", &meta, None),
|
||
Err(SdkError::InvalidDiffMeta(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn diff_message_short_commit_sha() {
|
||
let cid = uuid();
|
||
let mut meta = good_diff_meta();
|
||
meta.commit_sha = "abc12".into(); // only 5 chars
|
||
assert!(matches!(
|
||
build_diff_message(cid, "x", &meta, None),
|
||
Err(SdkError::InvalidDiffMeta(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn diff_message_invalid_commit_sha_chars() {
|
||
let cid = uuid();
|
||
let mut meta = good_diff_meta();
|
||
meta.commit_sha = "xyz1234".into(); // 'x', 'y', 'z' not hex
|
||
assert!(matches!(
|
||
build_diff_message(cid, "x", &meta, None),
|
||
Err(SdkError::InvalidDiffMeta(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn diff_message_branch_only_source() {
|
||
let cid = uuid();
|
||
let mut meta = good_diff_meta();
|
||
meta.branch = Some(("main".into(), "".into())); // target empty
|
||
assert!(matches!(
|
||
build_diff_message(cid, "x", &meta, None),
|
||
Err(SdkError::InvalidDiffMeta(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn diff_message_pr_zero() {
|
||
let cid = uuid();
|
||
let mut meta = good_diff_meta();
|
||
meta.pr_number = Some(0);
|
||
assert!(matches!(
|
||
build_diff_message(cid, "x", &meta, None),
|
||
Err(SdkError::InvalidDiffMeta(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn diff_message_content_too_large() {
|
||
let cid = uuid();
|
||
let big = "x".repeat(60 * 1024 + 1);
|
||
assert!(matches!(
|
||
build_diff_message(cid, &big, &good_diff_meta(), None),
|
||
Err(SdkError::ContentTooLarge { .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn diff_message_all_optional_fields() {
|
||
let cid = uuid();
|
||
let meta = DiffMeta {
|
||
repo_url: "https://github.com/example/repo".into(),
|
||
commit_sha: "abc1234def".into(),
|
||
file_path: Some("src/lib.rs".into()),
|
||
parent_commit: Some("1234567".into()),
|
||
branch: Some(("feature".into(), "main".into())),
|
||
pr_number: Some(42),
|
||
language: Some("rust".into()),
|
||
description: Some("fix bug".into()),
|
||
truncated: true,
|
||
alt_text: Some("patch for bug fix".into()),
|
||
};
|
||
let ev = sign(build_diff_message(cid, "diff", &meta, None).unwrap());
|
||
assert!(has_tag(&ev, "file", "src/lib.rs"));
|
||
assert!(has_tag(&ev, "parent-commit", "1234567"));
|
||
assert!(has_tag(&ev, "pr", "42"));
|
||
assert!(has_tag(&ev, "truncated", "true"));
|
||
assert!(has_tag(&ev, "alt", "patch for bug fix"));
|
||
}
|
||
|
||
#[test]
|
||
fn edit_happy_path() {
|
||
let cid = uuid();
|
||
let eid = event_id();
|
||
let ev = sign(build_edit(cid, eid, "new content").unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 40003);
|
||
assert!(has_tag(&ev, "e", &eid.to_hex()));
|
||
}
|
||
|
||
#[test]
|
||
fn edit_content_too_large() {
|
||
let cid = uuid();
|
||
let eid = event_id();
|
||
let big = "x".repeat(64 * 1024 + 1);
|
||
assert!(matches!(
|
||
build_edit(cid, eid, &big),
|
||
Err(SdkError::ContentTooLarge { .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn delete_message_happy_path() {
|
||
let cid = uuid();
|
||
let eid = event_id();
|
||
let ev = sign(build_delete_message(cid, eid).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 9005);
|
||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||
assert!(has_tag(&ev, "e", &eid.to_hex()));
|
||
assert_eq!(ev.content, "");
|
||
}
|
||
|
||
#[test]
|
||
fn delete_message_with_moderation_metadata() {
|
||
let cid = uuid();
|
||
let eid = event_id();
|
||
let action_id = Uuid::new_v4();
|
||
let ev = sign(
|
||
build_delete_message_with_options(
|
||
cid,
|
||
eid,
|
||
DeleteMessageOptions {
|
||
action_id: Some(action_id),
|
||
reason_code: Some("spam"),
|
||
public_reason: Some("Removed for spam."),
|
||
},
|
||
)
|
||
.unwrap(),
|
||
);
|
||
assert_eq!(ev.kind.as_u16(), 9005);
|
||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||
assert!(has_tag(&ev, "e", &eid.to_hex()));
|
||
assert!(has_tag(&ev, "action_id", &action_id.to_string()));
|
||
assert!(has_tag(&ev, "reason_code", "spam"));
|
||
assert!(has_tag(&ev, "public_reason", "Removed for spam."));
|
||
}
|
||
|
||
#[test]
|
||
fn delete_compat_happy_path() {
|
||
let cid = uuid();
|
||
let eid = event_id();
|
||
let ev = sign(build_delete_compat(cid, eid).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 5);
|
||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||
assert!(has_tag(&ev, "e", &eid.to_hex()));
|
||
assert_eq!(ev.content, "");
|
||
}
|
||
|
||
#[test]
|
||
fn vote_up() {
|
||
let cid = uuid();
|
||
let eid = event_id();
|
||
let ev = sign(build_vote(cid, eid, VoteDirection::Up).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 45002);
|
||
assert_eq!(ev.content, "+");
|
||
}
|
||
|
||
#[test]
|
||
fn vote_down() {
|
||
let cid = uuid();
|
||
let eid = event_id();
|
||
let ev = sign(build_vote(cid, eid, VoteDirection::Down).unwrap());
|
||
assert_eq!(ev.content, "-");
|
||
}
|
||
|
||
#[test]
|
||
fn reaction_happy_path() {
|
||
let eid = event_id();
|
||
let ev = sign(build_reaction(eid, "👍").unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 7);
|
||
assert_eq!(ev.content, "👍");
|
||
}
|
||
|
||
#[test]
|
||
fn reaction_emoji_too_long() {
|
||
let eid = event_id();
|
||
let long_emoji = "a".repeat(65);
|
||
assert!(matches!(
|
||
build_reaction(eid, &long_emoji),
|
||
Err(SdkError::EmojiTooLong)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn reaction_emoji_max_len_ok() {
|
||
let eid = event_id();
|
||
let max_emoji = "a".repeat(64);
|
||
assert!(build_reaction(eid, &max_emoji).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn custom_emoji_reaction_happy_path() {
|
||
let eid = event_id();
|
||
let ev = sign(
|
||
build_custom_emoji_reaction(eid, ":Party_Parrot:", "https://example.com/parrot.png")
|
||
.unwrap(),
|
||
);
|
||
assert_eq!(ev.kind.as_u16(), 7);
|
||
assert_eq!(ev.content, ":party_parrot:");
|
||
assert!(has_tag(&ev, "emoji", "party_parrot"));
|
||
}
|
||
|
||
#[test]
|
||
fn custom_emoji_set_happy_path() {
|
||
let ev = sign(
|
||
build_custom_emoji_set(&[CustomEmoji {
|
||
shortcode: "party".to_string(),
|
||
url: "https://example.com/party.png".to_string(),
|
||
}])
|
||
.unwrap(),
|
||
);
|
||
assert_eq!(ev.kind.as_u16(), 30030);
|
||
assert!(has_tag(&ev, "d", CUSTOM_EMOJI_SET_D_TAG));
|
||
assert!(has_tag(&ev, "emoji", "party"));
|
||
}
|
||
|
||
#[test]
|
||
fn remove_reaction_happy_path() {
|
||
let eid = event_id();
|
||
let ev = sign(build_remove_reaction(eid).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 5);
|
||
assert!(has_tag(&ev, "e", &eid.to_hex()));
|
||
}
|
||
|
||
#[test]
|
||
fn set_canvas_happy_path() {
|
||
let cid = uuid();
|
||
let ev = sign(build_set_canvas(cid, "# Canvas\nHello").unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 40100);
|
||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||
assert_eq!(ev.content, "# Canvas\nHello");
|
||
}
|
||
|
||
#[test]
|
||
fn profile_all_fields() {
|
||
let ev = sign(
|
||
build_profile(
|
||
Some("Alice"),
|
||
Some("alice"),
|
||
Some("https://example.com/pic.jpg"),
|
||
Some("Hello world"),
|
||
Some("alice@example.com"),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
assert_eq!(ev.kind.as_u16(), 0);
|
||
let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap();
|
||
assert_eq!(v["display_name"], "Alice");
|
||
assert_eq!(v["name"], "alice");
|
||
assert_eq!(v["nip05"], "alice@example.com");
|
||
}
|
||
|
||
#[test]
|
||
fn profile_some_fields() {
|
||
let ev = sign(build_profile(Some("Bob"), None, None, None, None).unwrap());
|
||
let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap();
|
||
assert_eq!(v["display_name"], "Bob");
|
||
assert!(
|
||
v.get("name").is_none()
|
||
|| !v["name"].is_null() && v.get("name") == Some(&serde_json::Value::Null)
|
||
|| !v.as_object().unwrap().contains_key("name")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn profile_no_fields() {
|
||
let ev = sign(build_profile(None, None, None, None, None).unwrap());
|
||
let v: serde_json::Value = serde_json::from_str(&ev.content).unwrap();
|
||
assert!(v.as_object().unwrap().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn add_member_with_role() {
|
||
let cid = uuid();
|
||
let pubkey = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
|
||
let ev = sign(build_add_member(cid, pubkey, Some(MemberRole::Admin)).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 9000);
|
||
assert!(has_tag(&ev, "p", pubkey));
|
||
assert!(has_tag(&ev, "role", "admin"));
|
||
}
|
||
|
||
#[test]
|
||
fn add_member_without_role() {
|
||
let cid = uuid();
|
||
let pubkey = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
|
||
let ev = sign(build_add_member(cid, pubkey, None::<MemberRole>).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 9000);
|
||
assert!(tag_values(&ev, "role").is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn remove_member_happy_path() {
|
||
let cid = uuid();
|
||
let pubkey = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
|
||
let ev = sign(build_remove_member(cid, pubkey).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 9001);
|
||
assert!(has_tag(&ev, "p", pubkey));
|
||
}
|
||
|
||
#[test]
|
||
fn leave_happy_path() {
|
||
let cid = uuid();
|
||
let ev = sign(build_leave(cid).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 9022);
|
||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn update_channel_name_and_about() {
|
||
let cid = uuid();
|
||
let ev = sign(
|
||
build_update_channel(cid, Some("new-name"), Some("new about"), None, None).unwrap(),
|
||
);
|
||
assert_eq!(ev.kind.as_u16(), 9002);
|
||
assert!(has_tag(&ev, "name", "new-name"));
|
||
assert!(has_tag(&ev, "about", "new about"));
|
||
}
|
||
|
||
#[test]
|
||
fn update_channel_strips_all_leading_hashes_from_name() {
|
||
let ev =
|
||
sign(build_update_channel(uuid(), Some(" ###new-name "), None, None, None).unwrap());
|
||
assert!(has_tag(&ev, "name", "new-name"));
|
||
}
|
||
|
||
#[test]
|
||
fn update_channel_rejects_hash_only_name() {
|
||
assert!(matches!(
|
||
build_update_channel(uuid(), Some(" ### "), None, None, None),
|
||
Err(SdkError::InvalidTag(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn update_channel_visibility_and_ttl() {
|
||
let cid = uuid();
|
||
let ev =
|
||
sign(build_update_channel(cid, None, None, Some("private"), Some(Some(3600))).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 9002);
|
||
assert!(has_tag(&ev, "visibility", "private"));
|
||
assert!(has_tag(&ev, "ttl", "3600"));
|
||
}
|
||
|
||
#[test]
|
||
fn update_channel_clears_ttl() {
|
||
let cid = uuid();
|
||
let ev = sign(build_update_channel(cid, None, None, None, Some(None)).unwrap());
|
||
assert!(has_tag(&ev, "ttl", ""));
|
||
}
|
||
|
||
#[test]
|
||
fn update_channel_invalid_visibility_rejected() {
|
||
let cid = uuid();
|
||
assert!(matches!(
|
||
build_update_channel(cid, None, None, Some("secret"), None),
|
||
Err(SdkError::InvalidTag(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn update_channel_no_fields_rejected() {
|
||
let cid = uuid();
|
||
assert!(matches!(
|
||
build_update_channel(cid, None, None, None, None),
|
||
Err(SdkError::InvalidTag(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn set_topic_happy_path() {
|
||
let cid = uuid();
|
||
let ev = sign(build_set_topic(cid, "Rust async patterns").unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 9002);
|
||
assert!(has_tag(&ev, "topic", "Rust async patterns"));
|
||
}
|
||
|
||
#[test]
|
||
fn set_purpose_happy_path() {
|
||
let cid = uuid();
|
||
let ev = sign(build_set_purpose(cid, "Team coordination").unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 9002);
|
||
assert!(has_tag(&ev, "purpose", "Team coordination"));
|
||
}
|
||
|
||
#[test]
|
||
fn create_channel_all_fields() {
|
||
let cid = uuid();
|
||
let ev = sign(
|
||
build_create_channel(
|
||
cid,
|
||
"general",
|
||
Some(Visibility::Open),
|
||
Some(ChannelKind::Stream),
|
||
Some("General chat"),
|
||
None,
|
||
)
|
||
.unwrap(),
|
||
);
|
||
assert_eq!(ev.kind.as_u16(), 9007);
|
||
assert!(has_tag(&ev, "name", "general"));
|
||
assert!(has_tag(&ev, "visibility", "open"));
|
||
assert!(has_tag(&ev, "channel_type", "stream"));
|
||
assert!(has_tag(&ev, "about", "General chat"));
|
||
}
|
||
|
||
#[test]
|
||
fn create_channel_minimal() {
|
||
let cid = uuid();
|
||
let ev = sign(
|
||
build_create_channel(
|
||
cid,
|
||
"dev",
|
||
None::<Visibility>,
|
||
None::<ChannelKind>,
|
||
None,
|
||
None,
|
||
)
|
||
.unwrap(),
|
||
);
|
||
assert_eq!(ev.kind.as_u16(), 9007);
|
||
assert!(has_tag(&ev, "name", "dev"));
|
||
}
|
||
|
||
#[test]
|
||
fn create_channel_strips_all_leading_hashes_from_name() {
|
||
let ev = sign(
|
||
build_create_channel(
|
||
uuid(),
|
||
" ###dev ",
|
||
None::<Visibility>,
|
||
None::<ChannelKind>,
|
||
None,
|
||
None,
|
||
)
|
||
.unwrap(),
|
||
);
|
||
assert!(has_tag(&ev, "name", "dev"));
|
||
}
|
||
|
||
#[test]
|
||
fn create_channel_rejects_hash_only_name() {
|
||
assert!(matches!(
|
||
build_create_channel(
|
||
uuid(),
|
||
" ### ",
|
||
None::<Visibility>,
|
||
None::<ChannelKind>,
|
||
None,
|
||
None,
|
||
),
|
||
Err(SdkError::InvalidTag(_))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn create_channel_ephemeral_emits_ttl() {
|
||
let cid = uuid();
|
||
let ev = sign(
|
||
build_create_channel(
|
||
cid,
|
||
"standup",
|
||
Some(Visibility::Open),
|
||
Some(ChannelKind::Stream),
|
||
None,
|
||
Some(3600),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
assert_eq!(ev.kind.as_u16(), 9007);
|
||
assert!(has_tag(&ev, "ttl", "3600"));
|
||
}
|
||
|
||
#[test]
|
||
fn join_happy_path() {
|
||
let cid = uuid();
|
||
let ev = sign(build_join(cid).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 9021);
|
||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn archive_happy_path() {
|
||
let cid = uuid();
|
||
let ev = sign(build_archive(cid).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 9002);
|
||
assert!(has_tag(&ev, "archived", "true"));
|
||
}
|
||
|
||
#[test]
|
||
fn unarchive_happy_path() {
|
||
let cid = uuid();
|
||
let ev = sign(build_unarchive(cid).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 9002);
|
||
assert!(has_tag(&ev, "archived", "false"));
|
||
}
|
||
|
||
#[test]
|
||
fn delete_channel_happy_path() {
|
||
let cid = uuid();
|
||
let ev = sign(build_delete_channel(cid).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 9008);
|
||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_channel_id_present() {
|
||
let cid = uuid();
|
||
let ev = sign(build_join(cid).unwrap());
|
||
assert_eq!(extract_channel_id(&ev), Some(cid));
|
||
}
|
||
|
||
#[test]
|
||
fn extract_channel_id_absent() {
|
||
// build_note (kind 1) is a global text note — no h tag.
|
||
let ev = sign(build_note("hello", None).unwrap());
|
||
assert_eq!(extract_channel_id(&ev), None);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_channel_id_invalid_uuid() {
|
||
// Build an event with a malformed h-tag value
|
||
let tags = vec![Tag::parse(["h", "not-a-uuid"]).unwrap()];
|
||
let ev = EventBuilder::new(Kind::Custom(9), "x")
|
||
.tags(tags)
|
||
.sign_with_keys(&keys())
|
||
.unwrap();
|
||
assert_eq!(extract_channel_id(&ev), None);
|
||
}
|
||
|
||
#[test]
|
||
fn build_note_happy_path() {
|
||
let builder = build_note("hello world", None).unwrap();
|
||
let keys = nostr::Keys::generate();
|
||
let event = builder.sign_with_keys(&keys).unwrap();
|
||
assert_eq!(event.kind, Kind::Custom(1));
|
||
assert_eq!(event.content, "hello world");
|
||
assert!(event.tags.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn build_note_with_reply() {
|
||
let keys = nostr::Keys::generate();
|
||
// Create a dummy event to get a valid EventId
|
||
let dummy = EventBuilder::new(Kind::Custom(1), "dummy")
|
||
.tags(vec![])
|
||
.sign_with_keys(&keys)
|
||
.unwrap();
|
||
let builder = build_note("reply text", Some(dummy.id)).unwrap();
|
||
let event = builder.sign_with_keys(&keys).unwrap();
|
||
assert_eq!(event.kind, Kind::Custom(1));
|
||
assert_eq!(event.content, "reply text");
|
||
assert_eq!(event.tags.len(), 1);
|
||
let tag = event.tags.iter().next().unwrap();
|
||
assert_eq!(tag.as_slice()[0], "e");
|
||
assert_eq!(tag.as_slice()[1], dummy.id.to_hex());
|
||
assert_eq!(tag.as_slice()[3], "reply");
|
||
}
|
||
|
||
#[test]
|
||
fn build_note_content_too_large() {
|
||
let big = "x".repeat(64 * 1024 + 1);
|
||
let err = build_note(&big, None).unwrap_err();
|
||
assert!(matches!(err, SdkError::ContentTooLarge { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn build_note_empty_content() {
|
||
// Empty content is valid per NIP-01.
|
||
let builder = build_note("", None).unwrap();
|
||
let keys = nostr::Keys::generate();
|
||
let event = builder.sign_with_keys(&keys).unwrap();
|
||
assert_eq!(event.kind, Kind::Custom(1));
|
||
assert_eq!(event.content, "");
|
||
assert!(event.tags.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn build_contact_list_happy_path() {
|
||
let pubkey = "a".repeat(64);
|
||
let contacts = vec![(pubkey.as_str(), None, None)];
|
||
let builder = build_contact_list(&contacts).unwrap();
|
||
let keys = nostr::Keys::generate();
|
||
let event = builder.sign_with_keys(&keys).unwrap();
|
||
assert_eq!(event.kind, Kind::Custom(3));
|
||
assert_eq!(event.content, "");
|
||
assert_eq!(event.tags.len(), 1);
|
||
let tag = event.tags.iter().next().unwrap();
|
||
assert_eq!(tag.as_slice()[0], "p");
|
||
assert_eq!(tag.as_slice()[1], pubkey);
|
||
}
|
||
|
||
#[test]
|
||
fn build_contact_list_normalizes_uppercase() {
|
||
let upper = "A".repeat(64);
|
||
let contacts = vec![(upper.as_str(), None, None)];
|
||
let builder = build_contact_list(&contacts).unwrap();
|
||
let keys = nostr::Keys::generate();
|
||
let event = builder.sign_with_keys(&keys).unwrap();
|
||
let tag = event.tags.iter().next().unwrap();
|
||
assert_eq!(tag.as_slice()[1], "a".repeat(64));
|
||
}
|
||
|
||
#[test]
|
||
fn build_contact_list_with_relay_and_petname() {
|
||
let pubkey = "b".repeat(64);
|
||
let contacts = vec![(
|
||
pubkey.as_str(),
|
||
Some("wss://relay.example.com"),
|
||
Some("alice"),
|
||
)];
|
||
let builder = build_contact_list(&contacts).unwrap();
|
||
let keys = nostr::Keys::generate();
|
||
let event = builder.sign_with_keys(&keys).unwrap();
|
||
let tag = event.tags.iter().next().unwrap();
|
||
assert_eq!(tag.as_slice()[0], "p");
|
||
assert_eq!(tag.as_slice()[2], "wss://relay.example.com");
|
||
assert_eq!(tag.as_slice()[3], "alice");
|
||
}
|
||
|
||
#[test]
|
||
fn build_contact_list_empty() {
|
||
let builder = build_contact_list(&[]).unwrap();
|
||
let keys = nostr::Keys::generate();
|
||
let event = builder.sign_with_keys(&keys).unwrap();
|
||
assert_eq!(event.kind, Kind::Custom(3));
|
||
assert!(event.tags.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn build_contact_list_rejects_short_pubkey() {
|
||
let short = "a".repeat(63);
|
||
let contacts = vec![(short.as_str(), None, None)];
|
||
let err = build_contact_list(&contacts).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn build_contact_list_rejects_long_pubkey() {
|
||
let long = "a".repeat(65);
|
||
let contacts = vec![(long.as_str(), None, None)];
|
||
let err = build_contact_list(&contacts).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn build_contact_list_rejects_non_hex() {
|
||
let non_hex = "g".repeat(64);
|
||
let contacts = vec![(non_hex.as_str(), None, None)];
|
||
let err = build_contact_list(&contacts).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn build_contact_list_rejects_long_relay_url() {
|
||
let pubkey = "a".repeat(64);
|
||
let long_url = "x".repeat(2049);
|
||
let contacts = vec![(pubkey.as_str(), Some(long_url.as_str()), None)];
|
||
let err = build_contact_list(&contacts).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn build_contact_list_rejects_long_petname() {
|
||
let pubkey = "a".repeat(64);
|
||
let long_name = "x".repeat(257);
|
||
let contacts = vec![(pubkey.as_str(), None, Some(long_name.as_str()))];
|
||
let err = build_contact_list(&contacts).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn build_contact_list_duplicate_pubkeys() {
|
||
let pubkey = "c".repeat(64);
|
||
// Same pubkey twice — only one p-tag should be emitted.
|
||
let contacts = vec![
|
||
(pubkey.as_str(), None, None),
|
||
(
|
||
pubkey.as_str(),
|
||
Some("wss://relay.example.com"),
|
||
Some("bob"),
|
||
),
|
||
];
|
||
let builder = build_contact_list(&contacts).unwrap();
|
||
let keys = nostr::Keys::generate();
|
||
let event = builder.sign_with_keys(&keys).unwrap();
|
||
assert_eq!(event.tags.len(), 1);
|
||
let tag = event.tags.iter().next().unwrap();
|
||
assert_eq!(tag.as_slice()[0], "p");
|
||
assert_eq!(tag.as_slice()[1], pubkey);
|
||
}
|
||
|
||
#[test]
|
||
fn build_contact_list_too_many() {
|
||
let pubkey = "d".repeat(64);
|
||
// MAX_CONTACTS + 1 entries (all same pubkey — uniqueness doesn't matter,
|
||
// the cap is checked before deduplication).
|
||
let entry = (pubkey.as_str(), None, None);
|
||
let contacts = vec![entry; MAX_CONTACTS + 1];
|
||
let err = build_contact_list(&contacts).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn repo_announcement_happy_path_all_fields() {
|
||
let ev = sign(
|
||
build_repo_announcement(
|
||
"my-repo",
|
||
Some("My Repo"),
|
||
Some("A test repository"),
|
||
&["https://github.com/example/my-repo.git"],
|
||
Some("https://github.com/example/my-repo"),
|
||
&["wss://relay.example.com"],
|
||
)
|
||
.unwrap(),
|
||
);
|
||
assert_eq!(ev.kind.as_u16(), 30617);
|
||
assert_eq!(ev.content, "");
|
||
assert!(has_tag(&ev, "d", "my-repo"));
|
||
assert!(has_tag(&ev, "name", "My Repo"));
|
||
assert!(has_tag(&ev, "description", "A test repository"));
|
||
assert!(has_tag(
|
||
&ev,
|
||
"clone",
|
||
"https://github.com/example/my-repo.git"
|
||
));
|
||
assert!(has_tag(&ev, "web", "https://github.com/example/my-repo"));
|
||
// relays is a multi-value tag — check the tag key exists
|
||
assert!(ev.tags.iter().any(|t| {
|
||
let s = t.as_slice();
|
||
s.first().map(|v| v.as_str()) == Some("relays")
|
||
&& s.get(1).map(|v| v.as_str()) == Some("wss://relay.example.com")
|
||
}));
|
||
}
|
||
|
||
#[test]
|
||
fn repo_announcement_happy_path_minimal() {
|
||
let ev = sign(build_repo_announcement("bare-repo", None, None, &[], None, &[]).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 30617);
|
||
assert_eq!(ev.content, "");
|
||
assert!(has_tag(&ev, "d", "bare-repo"));
|
||
// No optional tags present
|
||
assert!(!ev
|
||
.tags
|
||
.iter()
|
||
.any(|t| t.as_slice().first().map(|v| v.as_str()) == Some("name")));
|
||
assert!(!ev
|
||
.tags
|
||
.iter()
|
||
.any(|t| t.as_slice().first().map(|v| v.as_str()) == Some("clone")));
|
||
}
|
||
|
||
#[test]
|
||
fn repo_announcement_with_tags_preserves_metadata_and_canonicalizes_d() {
|
||
let tags = vec![
|
||
Tag::parse(["d", "wrong-repo"]).unwrap(),
|
||
Tag::parse(["name", "Protected Repo"]).unwrap(),
|
||
Tag::parse(["buzz-channel", "channel-id"]).unwrap(),
|
||
Tag::parse(["future-metadata", "preserve-me"]).unwrap(),
|
||
];
|
||
|
||
let ev = sign(
|
||
build_repo_announcement_with_tags("protected-repo", "repository content", tags)
|
||
.unwrap(),
|
||
);
|
||
|
||
assert_eq!(ev.kind.as_u16(), 30617);
|
||
assert_eq!(ev.content, "repository content");
|
||
assert_eq!(
|
||
ev.tags
|
||
.iter()
|
||
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some("d"))
|
||
.count(),
|
||
1
|
||
);
|
||
assert!(has_tag(&ev, "d", "protected-repo"));
|
||
assert!(has_tag(&ev, "buzz-channel", "channel-id"));
|
||
assert!(has_tag(&ev, "future-metadata", "preserve-me"));
|
||
}
|
||
|
||
#[test]
|
||
fn repo_announcement_rejects_empty_repo_id() {
|
||
let err = build_repo_announcement("", None, None, &[], None, &[]).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn repo_announcement_rejects_leading_dot() {
|
||
let err = build_repo_announcement(".hidden", None, None, &[], None, &[]).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn repo_announcement_rejects_double_dot() {
|
||
let err = build_repo_announcement("some..repo", None, None, &[], None, &[]).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn repo_announcement_rejects_repo_id_over_64_chars() {
|
||
let long_id = "a".repeat(65);
|
||
let err = build_repo_announcement(&long_id, None, None, &[], None, &[]).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn repo_announcement_rejects_invalid_chars_in_repo_id() {
|
||
let err = build_repo_announcement("bad repo!", None, None, &[], None, &[]).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn repo_announcement_multiple_clone_urls_multi_value_tag() {
|
||
let ev = sign(
|
||
build_repo_announcement(
|
||
"multi-clone",
|
||
None,
|
||
None,
|
||
&[
|
||
"https://relay.example.com/git/abc/multi-clone",
|
||
"ssh://git@github.com/org/multi-clone.git",
|
||
],
|
||
None,
|
||
&[],
|
||
)
|
||
.unwrap(),
|
||
);
|
||
// clone is a multi-value tag per NIP-34: ["clone", url1, url2, ...]
|
||
let clone_tag = ev
|
||
.tags
|
||
.iter()
|
||
.find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("clone"))
|
||
.expect("clone tag missing");
|
||
let vals: Vec<&str> = clone_tag
|
||
.as_slice()
|
||
.iter()
|
||
.skip(1)
|
||
.map(|v| v.as_str())
|
||
.collect();
|
||
assert_eq!(vals.len(), 2);
|
||
assert_eq!(vals[0], "https://relay.example.com/git/abc/multi-clone");
|
||
assert_eq!(vals[1], "ssh://git@github.com/org/multi-clone.git");
|
||
}
|
||
|
||
#[test]
|
||
fn git_patch_happy_path_minimal() {
|
||
let owner = "a".repeat(64);
|
||
let repo = GitRepoCoord {
|
||
owner: owner.clone(),
|
||
id: "my-repo".to_string(),
|
||
};
|
||
let ev =
|
||
sign(build_git_patch(&repo, "diff --git a/x b/x", &GitPatchMeta::default()).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 1617);
|
||
assert_eq!(ev.content, "diff --git a/x b/x");
|
||
assert!(has_tag(&ev, "a", &format!("30617:{owner}:my-repo")));
|
||
assert!(has_tag(&ev, "p", &owner));
|
||
}
|
||
|
||
#[test]
|
||
fn git_patch_root_and_metadata_tags() {
|
||
let owner = "a".repeat(64);
|
||
let commit = "c".repeat(40);
|
||
let parent = "d".repeat(40);
|
||
let repo = GitRepoCoord {
|
||
owner,
|
||
id: "repo".to_string(),
|
||
};
|
||
let meta = GitPatchMeta {
|
||
root: true,
|
||
commit: Some(commit.clone()),
|
||
parent_commit: Some(parent.clone()),
|
||
..Default::default()
|
||
};
|
||
let ev = sign(build_git_patch(&repo, "patch body", &meta).unwrap());
|
||
assert!(has_tag(&ev, "t", "root"));
|
||
assert!(has_tag(&ev, "commit", &commit));
|
||
assert!(has_tag(&ev, "parent-commit", &parent));
|
||
assert!(has_tag(&ev, "r", &commit));
|
||
}
|
||
|
||
#[test]
|
||
fn git_patch_rejects_root_and_root_revision_together() {
|
||
let repo = GitRepoCoord {
|
||
owner: "a".repeat(64),
|
||
id: "repo".to_string(),
|
||
};
|
||
let meta = GitPatchMeta {
|
||
root: true,
|
||
root_revision: true,
|
||
..Default::default()
|
||
};
|
||
let err = build_git_patch(&repo, "x", &meta).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn git_patch_rejects_oversized_content() {
|
||
let repo = GitRepoCoord {
|
||
owner: "a".repeat(64),
|
||
id: "repo".to_string(),
|
||
};
|
||
let big = "x".repeat(60 * 1024 + 1);
|
||
let err = build_git_patch(&repo, &big, &GitPatchMeta::default()).unwrap_err();
|
||
assert!(matches!(err, SdkError::ContentTooLarge { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn git_patch_rejects_bad_repo_owner() {
|
||
let repo = GitRepoCoord {
|
||
owner: "not-hex".to_string(),
|
||
id: "repo".to_string(),
|
||
};
|
||
let err = build_git_patch(&repo, "x", &GitPatchMeta::default()).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn git_issue_happy_path() {
|
||
let owner = "a".repeat(64);
|
||
let repo = GitRepoCoord {
|
||
owner: owner.clone(),
|
||
id: "repo".to_string(),
|
||
};
|
||
let meta = GitIssueMeta {
|
||
labels: vec!["bug".to_string(), "p1".to_string()],
|
||
recipients: vec![],
|
||
};
|
||
let ev =
|
||
sign(build_git_issue(&repo, "Crashes on startup", "steps to repro", &meta).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 1621);
|
||
assert_eq!(ev.content, "steps to repro");
|
||
assert!(has_tag(&ev, "a", &format!("30617:{owner}:repo")));
|
||
assert!(has_tag(&ev, "subject", "Crashes on startup"));
|
||
assert!(has_tag(&ev, "t", "bug"));
|
||
assert!(has_tag(&ev, "t", "p1"));
|
||
}
|
||
|
||
#[test]
|
||
fn git_issue_rejects_empty_subject() {
|
||
let repo = GitRepoCoord {
|
||
owner: "a".repeat(64),
|
||
id: "repo".to_string(),
|
||
};
|
||
let err = build_git_issue(&repo, "", "body", &GitIssueMeta::default()).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn git_status_open_happy_path() {
|
||
let root = event_id().to_hex();
|
||
let meta = GitStatusMeta {
|
||
root_event: root.clone(),
|
||
..Default::default()
|
||
};
|
||
let ev = sign(build_git_status(GitStatus::Open, "", &meta).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 1630);
|
||
assert!(has_tag(&ev, "e", &root));
|
||
}
|
||
|
||
#[test]
|
||
fn git_status_merged_with_applied_patches() {
|
||
let root = event_id().to_hex();
|
||
let patch_id = event_id().to_hex();
|
||
let merge_commit = "f".repeat(40);
|
||
let meta = GitStatusMeta {
|
||
root_event: root,
|
||
applied_patches: vec![GitAppliedPatchRef {
|
||
id: patch_id.clone(),
|
||
relay: None,
|
||
pubkey: None,
|
||
}],
|
||
merge_commit: Some(merge_commit.clone()),
|
||
..Default::default()
|
||
};
|
||
let ev =
|
||
sign(build_git_status(GitStatus::AppliedOrResolved, "merged, thanks!", &meta).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 1631);
|
||
assert!(has_tag(&ev, "q", &patch_id));
|
||
assert!(has_tag(&ev, "merge-commit", &merge_commit));
|
||
assert!(has_tag(&ev, "r", &merge_commit));
|
||
}
|
||
|
||
#[test]
|
||
fn git_status_rejects_merge_fields_on_non_merged_status() {
|
||
let root = event_id().to_hex();
|
||
let meta = GitStatusMeta {
|
||
root_event: root,
|
||
merge_commit: Some("f".repeat(40)),
|
||
..Default::default()
|
||
};
|
||
let err = build_git_status(GitStatus::Closed, "", &meta).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn git_status_rejects_bad_root_event() {
|
||
let meta = GitStatusMeta {
|
||
root_event: "not-an-event-id".to_string(),
|
||
..Default::default()
|
||
};
|
||
let err = build_git_status(GitStatus::Open, "", &meta).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn git_patch_rejects_empty_content() {
|
||
let repo = GitRepoCoord {
|
||
owner: "a".repeat(64),
|
||
id: "repo".to_string(),
|
||
};
|
||
let err = build_git_patch(&repo, "", &GitPatchMeta::default()).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn git_patch_rejects_whitespace_only_content() {
|
||
// Regression: a failed `git format-patch | buzz patches send
|
||
// --patch-file -` must not silently publish a whitespace-only
|
||
// (i.e. unappliable) patch.
|
||
let repo = GitRepoCoord {
|
||
owner: "a".repeat(64),
|
||
id: "repo".to_string(),
|
||
};
|
||
let err = build_git_patch(&repo, " \n\t\n", &GitPatchMeta::default()).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn git_repo_coord_rejects_invalid_repo_id_chars() {
|
||
let repo = GitRepoCoord {
|
||
owner: "a".repeat(64),
|
||
id: "../etc/passwd".to_string(),
|
||
};
|
||
let err = build_git_patch(&repo, "diff", &GitPatchMeta::default()).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn git_patch_rejects_short_commit_hex() {
|
||
let repo = GitRepoCoord {
|
||
owner: "a".repeat(64),
|
||
id: "repo".to_string(),
|
||
};
|
||
let meta = GitPatchMeta {
|
||
commit: Some("a".to_string()),
|
||
..Default::default()
|
||
};
|
||
let err = build_git_patch(&repo, "diff", &meta).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn git_patch_accepts_full_sha1_and_sha256_commit_hex() {
|
||
let repo = GitRepoCoord {
|
||
owner: "a".repeat(64),
|
||
id: "repo".to_string(),
|
||
};
|
||
let meta_sha1 = GitPatchMeta {
|
||
commit: Some("c".repeat(40)),
|
||
..Default::default()
|
||
};
|
||
assert!(build_git_patch(&repo, "diff", &meta_sha1).is_ok());
|
||
let meta_sha256 = GitPatchMeta {
|
||
commit: Some("c".repeat(64)),
|
||
..Default::default()
|
||
};
|
||
assert!(build_git_patch(&repo, "diff", &meta_sha256).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn git_status_defaults_p_tag_to_repo_owner_when_repo_given() {
|
||
// SDK-level: GitStatusMeta.recipients is the caller's responsibility
|
||
// (the CLI defaults it), but verify the repo owner ends up p-tagged
|
||
// when the CLI-style recipients list includes it.
|
||
let owner = "a".repeat(64);
|
||
let root = event_id().to_hex();
|
||
let meta = GitStatusMeta {
|
||
root_event: root,
|
||
repo: Some(GitRepoCoord {
|
||
owner: owner.clone(),
|
||
id: "repo".to_string(),
|
||
}),
|
||
recipients: vec![owner.clone()],
|
||
..Default::default()
|
||
};
|
||
let ev = sign(build_git_status(GitStatus::Open, "", &meta).unwrap());
|
||
assert!(has_tag(&ev, "p", &owner));
|
||
}
|
||
|
||
#[test]
|
||
fn git_applied_patch_ref_parse_id_only() {
|
||
let id = "a".repeat(64);
|
||
let parsed = GitAppliedPatchRef::parse(&id).unwrap();
|
||
assert_eq!(parsed.id, id);
|
||
assert_eq!(parsed.relay, None);
|
||
assert_eq!(parsed.pubkey, None);
|
||
}
|
||
|
||
#[test]
|
||
fn git_applied_patch_ref_parse_id_and_relay() {
|
||
let id = "a".repeat(64);
|
||
let spec = format!("{id}:wss://relay.example.com");
|
||
let parsed = GitAppliedPatchRef::parse(&spec).unwrap();
|
||
assert_eq!(parsed.id, id);
|
||
assert_eq!(parsed.relay, Some("wss://relay.example.com".to_string()));
|
||
assert_eq!(parsed.pubkey, None);
|
||
}
|
||
|
||
#[test]
|
||
fn git_applied_patch_ref_parse_id_relay_and_pubkey() {
|
||
let id = "a".repeat(64);
|
||
let pubkey = "b".repeat(64);
|
||
let spec = format!("{id}:wss://relay.example.com:{pubkey}");
|
||
let parsed = GitAppliedPatchRef::parse(&spec).unwrap();
|
||
assert_eq!(parsed.id, id);
|
||
assert_eq!(parsed.relay, Some("wss://relay.example.com".to_string()));
|
||
assert_eq!(parsed.pubkey, Some(pubkey));
|
||
}
|
||
|
||
#[test]
|
||
fn git_status_q_tag_includes_relay_and_pubkey_hints() {
|
||
let root = event_id().to_hex();
|
||
let patch_id = event_id().to_hex();
|
||
let pubkey = "b".repeat(64);
|
||
let meta = GitStatusMeta {
|
||
root_event: root,
|
||
applied_patches: vec![GitAppliedPatchRef {
|
||
id: patch_id.clone(),
|
||
relay: Some("wss://relay.example.com".to_string()),
|
||
pubkey: Some(pubkey.clone()),
|
||
}],
|
||
..Default::default()
|
||
};
|
||
let ev = sign(build_git_status(GitStatus::AppliedOrResolved, "", &meta).unwrap());
|
||
let q_tag = ev
|
||
.tags
|
||
.iter()
|
||
.find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("q"))
|
||
.expect("q tag present");
|
||
let parts = q_tag.as_slice();
|
||
assert_eq!(parts.get(1).map(|v| v.as_str()), Some(patch_id.as_str()));
|
||
assert_eq!(
|
||
parts.get(2).map(|v| v.as_str()),
|
||
Some("wss://relay.example.com")
|
||
);
|
||
assert_eq!(parts.get(3).map(|v| v.as_str()), Some(pubkey.as_str()));
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_def_happy_path() {
|
||
let cid = uuid();
|
||
let wid = uuid();
|
||
let ev = sign(build_workflow_def(cid, wid, "name: test\ntrigger:\n on: webhook").unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 30620);
|
||
assert!(has_tag(&ev, "d", &wid.to_string()));
|
||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||
assert!(ev.content.contains("name: test"));
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_def_rejects_oversized_yaml() {
|
||
let big = "x".repeat(65 * 1024);
|
||
let err = build_workflow_def(uuid(), uuid(), &big).unwrap_err();
|
||
assert!(matches!(err, SdkError::ContentTooLarge { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_update_includes_h_tag() {
|
||
let cid = uuid();
|
||
let wid = uuid();
|
||
let ev = sign(build_workflow_update(cid, wid, "name: updated").unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 30620);
|
||
assert!(has_tag(&ev, "d", &wid.to_string()));
|
||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_update_rejects_oversized_yaml() {
|
||
let big = "x".repeat(65 * 1024);
|
||
let err = build_workflow_update(uuid(), uuid(), &big).unwrap_err();
|
||
assert!(matches!(err, SdkError::ContentTooLarge { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_delete_happy_path() {
|
||
let pk = "a".repeat(64);
|
||
let wid = uuid();
|
||
let ev = sign(build_workflow_delete(&pk, wid).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 5);
|
||
let a_vals = tag_values(&ev, "a");
|
||
assert_eq!(a_vals.len(), 1);
|
||
assert!(a_vals[0].starts_with("30620:"));
|
||
assert!(a_vals[0].contains(&wid.to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_delete_rejects_bad_pubkey() {
|
||
let err = build_workflow_delete("bad", uuid()).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_trigger_happy_path() {
|
||
let wid = uuid();
|
||
let ev = sign(build_workflow_trigger(wid).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 46020);
|
||
assert!(has_tag(&ev, "d", &wid.to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_approval_grant() {
|
||
let hash = "a".repeat(64);
|
||
let ev = sign(build_workflow_approval(&hash, true, "lgtm").unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 46030);
|
||
assert!(has_tag(&ev, "d", &hash));
|
||
assert_eq!(ev.content, "lgtm");
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_approval_deny() {
|
||
let hash = "b".repeat(64);
|
||
let ev = sign(build_workflow_approval(&hash, false, "").unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 46031);
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_approval_rejects_bad_token_hash() {
|
||
let err = build_workflow_approval("not-hex", true, "").unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn workflow_approval_rejects_short_hash() {
|
||
let short = "a".repeat(32);
|
||
let err = build_workflow_approval(&short, true, "").unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn dm_open_happy_path() {
|
||
let pk = "a".repeat(64);
|
||
let ev = sign(build_dm_open(&[&pk]).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 41010);
|
||
assert!(has_tag(&ev, "p", &pk));
|
||
}
|
||
|
||
#[test]
|
||
fn dm_open_rejects_empty_pubkeys() {
|
||
let err = build_dm_open(&[]).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn dm_open_rejects_over_8_pubkeys() {
|
||
let pk = "a".repeat(64);
|
||
let pks: Vec<&str> = vec![pk.as_str(); 9];
|
||
let err = build_dm_open(&pks).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn dm_open_rejects_bad_pubkey() {
|
||
let err = build_dm_open(&["bad-hex"]).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn dm_add_member_happy_path() {
|
||
let cid = uuid();
|
||
let pk = "b".repeat(64);
|
||
let ev = sign(build_dm_add_member(cid, &pk).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 41011);
|
||
assert!(has_tag(&ev, "h", &cid.to_string()));
|
||
assert!(has_tag(&ev, "p", &pk));
|
||
}
|
||
|
||
#[test]
|
||
fn dm_add_member_rejects_bad_pubkey() {
|
||
let err = build_dm_add_member(uuid(), "short").unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn presence_update_content_is_status() {
|
||
let ev = sign(build_presence_update("online").unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 20001);
|
||
assert_eq!(ev.content, "online");
|
||
assert!(has_tag(&ev, "status", "online"));
|
||
}
|
||
|
||
#[test]
|
||
fn presence_update_away() {
|
||
let ev = sign(build_presence_update("away").unwrap());
|
||
assert_eq!(ev.content, "away");
|
||
}
|
||
|
||
#[test]
|
||
fn presence_update_offline() {
|
||
let ev = sign(build_presence_update("offline").unwrap());
|
||
assert_eq!(ev.content, "offline");
|
||
}
|
||
|
||
#[test]
|
||
fn presence_update_rejects_invalid_status() {
|
||
let err = build_presence_update("dnd").unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
// ── build_git_pull_request / build_git_pr_update ──────────────────────────
|
||
|
||
fn pr_repo() -> GitRepoCoord {
|
||
GitRepoCoord {
|
||
owner: "a".repeat(64),
|
||
id: "repo".to_string(),
|
||
}
|
||
}
|
||
|
||
fn full_clone_tag(event: &nostr::Event) -> Vec<String> {
|
||
event
|
||
.tags
|
||
.iter()
|
||
.find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("clone"))
|
||
.map(|t| t.as_slice()[1..].iter().map(|v| v.to_string()).collect())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
#[test]
|
||
fn git_pr_happy_path() {
|
||
let meta = GitPullRequestMeta {
|
||
subject: "Add feature X".to_string(),
|
||
commit: "c".repeat(40),
|
||
clone_urls: vec!["https://example.com/repo.git".to_string()],
|
||
branch_name: Some("feat/x".to_string()),
|
||
labels: vec!["enhancement".to_string()],
|
||
channel_id: Some("11111111-1111-4111-8111-111111111111".to_string()),
|
||
..Default::default()
|
||
};
|
||
let ev = sign(build_git_pull_request(&pr_repo(), "PR body", &meta).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 1618);
|
||
assert_eq!(ev.content, "PR body");
|
||
assert!(has_tag(&ev, "a", &format!("30617:{}:repo", "a".repeat(64))));
|
||
assert!(has_tag(&ev, "p", &"a".repeat(64)));
|
||
assert!(has_tag(&ev, "subject", "Add feature X"));
|
||
assert!(has_tag(&ev, "c", &"c".repeat(40)));
|
||
assert!(has_tag(&ev, "t", "enhancement"));
|
||
assert!(has_tag(&ev, "h", "11111111-1111-4111-8111-111111111111"));
|
||
assert!(has_tag(&ev, "branch-name", "feat/x"));
|
||
assert_eq!(
|
||
full_clone_tag(&ev),
|
||
vec!["https://example.com/repo.git".to_string()]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn git_pr_emits_multi_url_clone_tag() {
|
||
let meta = GitPullRequestMeta {
|
||
subject: "s".to_string(),
|
||
commit: "c".repeat(40),
|
||
clone_urls: vec![
|
||
"https://a.example/repo.git".to_string(),
|
||
"https://b.example/repo.git".to_string(),
|
||
],
|
||
..Default::default()
|
||
};
|
||
let ev = sign(build_git_pull_request(&pr_repo(), "", &meta).unwrap());
|
||
assert_eq!(full_clone_tag(&ev).len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn git_pr_rejects_empty_subject() {
|
||
let meta = GitPullRequestMeta {
|
||
subject: String::new(),
|
||
commit: "c".repeat(40),
|
||
clone_urls: vec!["https://example.com/repo.git".to_string()],
|
||
..Default::default()
|
||
};
|
||
let err = build_git_pull_request(&pr_repo(), "body", &meta).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn git_pr_rejects_missing_clone_url() {
|
||
let meta = GitPullRequestMeta {
|
||
subject: "s".to_string(),
|
||
commit: "c".repeat(40),
|
||
clone_urls: vec![],
|
||
..Default::default()
|
||
};
|
||
let err = build_git_pull_request(&pr_repo(), "body", &meta).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn git_pr_rejects_short_commit() {
|
||
let meta = GitPullRequestMeta {
|
||
subject: "s".to_string(),
|
||
commit: "abc".to_string(),
|
||
clone_urls: vec!["https://example.com/repo.git".to_string()],
|
||
..Default::default()
|
||
};
|
||
let err = build_git_pull_request(&pr_repo(), "body", &meta).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn git_pr_rejects_invalid_channel_id() {
|
||
for channel_id in ["not-a-uuid", " 11111111-1111-4111-8111-111111111111 "] {
|
||
let meta = GitPullRequestMeta {
|
||
subject: "s".to_string(),
|
||
commit: "c".repeat(40),
|
||
clone_urls: vec!["https://example.com/repo.git".to_string()],
|
||
channel_id: Some(channel_id.to_string()),
|
||
..Default::default()
|
||
};
|
||
let err = build_git_pull_request(&pr_repo(), "body", &meta).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn git_pr_canonicalizes_channel_id() {
|
||
let meta = GitPullRequestMeta {
|
||
subject: "s".to_string(),
|
||
commit: "c".repeat(40),
|
||
clone_urls: vec!["https://example.com/repo.git".to_string()],
|
||
channel_id: Some("AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE".to_string()),
|
||
..Default::default()
|
||
};
|
||
let ev = sign(build_git_pull_request(&pr_repo(), "body", &meta).unwrap());
|
||
assert!(has_tag(&ev, "h", "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"));
|
||
}
|
||
|
||
#[test]
|
||
fn git_pr_revision_of_emits_e_tag() {
|
||
let patch = event_id().to_hex();
|
||
let meta = GitPullRequestMeta {
|
||
subject: "s".to_string(),
|
||
commit: "c".repeat(40),
|
||
clone_urls: vec!["https://example.com/repo.git".to_string()],
|
||
revision_of: Some(patch.clone()),
|
||
..Default::default()
|
||
};
|
||
let ev = sign(build_git_pull_request(&pr_repo(), "", &meta).unwrap());
|
||
assert!(has_tag(&ev, "e", &patch));
|
||
}
|
||
|
||
#[test]
|
||
fn git_pr_update_happy_path() {
|
||
let pr = event_id().to_hex();
|
||
let meta = GitPrUpdateMeta {
|
||
pr_event: pr.clone(),
|
||
pr_author: "b".repeat(64),
|
||
commit: "d".repeat(40),
|
||
clone_urls: vec!["https://example.com/repo.git".to_string()],
|
||
merge_base: Some("e".repeat(40)),
|
||
..Default::default()
|
||
};
|
||
let ev = sign(build_git_pr_update(&pr_repo(), "rebased", &meta).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), 1619);
|
||
assert!(has_tag(&ev, "E", &pr));
|
||
assert!(has_tag(&ev, "P", &"b".repeat(64)));
|
||
assert!(has_tag(&ev, "c", &"d".repeat(40)));
|
||
assert!(has_tag(&ev, "merge-base", &"e".repeat(40)));
|
||
assert_eq!(full_clone_tag(&ev).len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn git_pr_update_rejects_bad_pr_event() {
|
||
let meta = GitPrUpdateMeta {
|
||
pr_event: "not-hex".to_string(),
|
||
pr_author: "b".repeat(64),
|
||
commit: "d".repeat(40),
|
||
clone_urls: vec!["https://example.com/repo.git".to_string()],
|
||
..Default::default()
|
||
};
|
||
let err = build_git_pr_update(&pr_repo(), "", &meta).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn git_pr_update_rejects_missing_clone_url() {
|
||
let meta = GitPrUpdateMeta {
|
||
pr_event: event_id().to_hex(),
|
||
pr_author: "b".repeat(64),
|
||
commit: "d".repeat(40),
|
||
clone_urls: vec![],
|
||
..Default::default()
|
||
};
|
||
let err = build_git_pr_update(&pr_repo(), "", &meta).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
// --- community moderation commands (9040–9044) ------------------------
|
||
|
||
#[test]
|
||
fn moderation_ban_permanent() {
|
||
let pk = "a".repeat(64);
|
||
let ev = sign(build_moderation_ban(&pk, None, None).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), KIND_MODERATION_BAN as u16);
|
||
assert!(has_tag(&ev, "p", &pk));
|
||
assert!(tag_values(&ev, "expiration").is_empty());
|
||
assert!(tag_values(&ev, "reason").is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn moderation_ban_temporary_with_reason() {
|
||
let pk = "b".repeat(64);
|
||
let ev = sign(build_moderation_ban(&pk, Some(1783500000), Some("spam")).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), KIND_MODERATION_BAN as u16);
|
||
assert!(has_tag(&ev, "p", &pk));
|
||
assert!(has_tag(&ev, "expiration", "1783500000"));
|
||
assert!(has_tag(&ev, "reason", "spam"));
|
||
}
|
||
|
||
#[test]
|
||
fn moderation_ban_lowercases_pubkey() {
|
||
let ev = sign(build_moderation_ban(&"A".repeat(64), None, None).unwrap());
|
||
assert!(has_tag(&ev, "p", &"a".repeat(64)));
|
||
}
|
||
|
||
#[test]
|
||
fn moderation_ban_rejects_short_pubkey() {
|
||
let err = build_moderation_ban("abc", None, None).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn moderation_ban_rejects_overlong_pubkey() {
|
||
// Relay `extract_p_tag_bytes` requires exactly 64 hex; the SDK must
|
||
// reject 65+ hex here rather than sign a `p` tag the relay drops.
|
||
let err = build_moderation_ban(&"a".repeat(65), None, None).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn moderation_unban_shape() {
|
||
let pk = "c".repeat(64);
|
||
let ev = sign(build_moderation_unban(&pk).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), KIND_MODERATION_UNBAN as u16);
|
||
assert!(has_tag(&ev, "p", &pk));
|
||
}
|
||
|
||
#[test]
|
||
fn moderation_timeout_shape() {
|
||
let pk = "d".repeat(64);
|
||
let ev = sign(build_moderation_timeout(&pk, 1783500000, Some("cool off")).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), KIND_MODERATION_TIMEOUT as u16);
|
||
assert!(has_tag(&ev, "p", &pk));
|
||
assert!(has_tag(&ev, "expiration", "1783500000"));
|
||
assert!(has_tag(&ev, "reason", "cool off"));
|
||
}
|
||
|
||
#[test]
|
||
fn moderation_untimeout_shape() {
|
||
let pk = "e".repeat(64);
|
||
let ev = sign(build_moderation_untimeout(&pk).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), KIND_MODERATION_UNTIMEOUT as u16);
|
||
assert!(has_tag(&ev, "p", &pk));
|
||
}
|
||
|
||
#[test]
|
||
fn moderation_resolve_shape() {
|
||
let rid = event_id().to_hex();
|
||
let ev =
|
||
sign(build_moderation_resolve_report(&rid, "resolved", "ban", Some("rule 3")).unwrap());
|
||
assert_eq!(ev.kind.as_u16(), KIND_MODERATION_RESOLVE_REPORT as u16);
|
||
assert!(has_tag(&ev, "report", &rid));
|
||
assert!(has_tag(&ev, "status", "resolved"));
|
||
assert!(has_tag(&ev, "action", "ban"));
|
||
assert!(has_tag(&ev, "reason", "rule 3"));
|
||
}
|
||
|
||
#[test]
|
||
fn moderation_resolve_dismiss_no_reason() {
|
||
let rid = event_id().to_hex();
|
||
let ev = sign(build_moderation_resolve_report(&rid, "dismissed", "dismiss", None).unwrap());
|
||
assert!(has_tag(&ev, "status", "dismissed"));
|
||
assert!(has_tag(&ev, "action", "dismiss"));
|
||
assert!(tag_values(&ev, "reason").is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn moderation_resolve_rejects_bad_status() {
|
||
let rid = event_id().to_hex();
|
||
let err = build_moderation_resolve_report(&rid, "escalated", "ban", None).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn moderation_resolve_rejects_bad_action() {
|
||
let rid = event_id().to_hex();
|
||
let err = build_moderation_resolve_report(&rid, "resolved", "nuke", None).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn moderation_resolve_rejects_short_report_id() {
|
||
let err = build_moderation_resolve_report("abc", "resolved", "ban", None).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn moderation_resolve_rejects_overlong_report_id() {
|
||
// Relay `extract_report_tag` requires exactly 64 hex; the SDK must
|
||
// reject 65+ hex here rather than sign a `report` tag the relay drops.
|
||
let err =
|
||
build_moderation_resolve_report(&"a".repeat(65), "resolved", "ban", None).unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
// ── NIP-IA identity archival (kinds 9035/9036) ────────────────────────
|
||
//
|
||
// Mirrors `desktop/src-tauri/src/events.rs`'s own tests so both clients
|
||
// are pinned to the same wire form. See NIP-IA.md §Vector 1.
|
||
|
||
const IA_OWNER_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
|
||
const IA_TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
|
||
const IA_CONDITIONS: &str = "kind=1&created_at<1713957000";
|
||
const IA_SIG: &str = "8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369";
|
||
|
||
#[test]
|
||
fn archive_identity_request_matches_spec_vector_1_layout() {
|
||
let auth: [String; 4] = [
|
||
"auth".into(),
|
||
IA_OWNER_HEX.into(),
|
||
IA_CONDITIONS.into(),
|
||
IA_SIG.into(),
|
||
];
|
||
let ev = sign(
|
||
build_archive_identity_request(
|
||
IA_TARGET_HEX,
|
||
"Archiving zombie agent after rebuild.",
|
||
Some("bot-rebuilt"),
|
||
None,
|
||
Some(&auth),
|
||
)
|
||
.unwrap(),
|
||
);
|
||
|
||
let tags: Vec<Vec<String>> = ev.tags.iter().map(|t| t.as_slice().to_vec()).collect();
|
||
assert_eq!(ev.kind, Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16));
|
||
// Spec layout: ["-"], ["p", target], ["reason", code], ["auth", ...]
|
||
assert_eq!(tags[0], vec!["-"]);
|
||
assert_eq!(tags[1], vec!["p", IA_TARGET_HEX]);
|
||
assert_eq!(tags[2], vec!["reason", "bot-rebuilt"]);
|
||
assert_eq!(tags[3], vec!["auth", IA_OWNER_HEX, IA_CONDITIONS, IA_SIG]);
|
||
assert_eq!(tags.len(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn archive_request_rejects_replaced_by_equal_target() {
|
||
let err =
|
||
build_archive_identity_request(IA_TARGET_HEX, "", None, Some(IA_TARGET_HEX), None)
|
||
.unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn unarchive_request_layout_self_path() {
|
||
// Self-unarchive: actor == target, so the `p` tag points at the
|
||
// signer. Pins that `.allow_self_tagging()` survives nostr 0.44's
|
||
// default same-pubkey `p`-tag scrub, and that no `auth` tag rides
|
||
// along on the self path.
|
||
let builder = build_unarchive_identity_request(
|
||
IA_TARGET_HEX,
|
||
"I am active again.",
|
||
Some("returned"),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
let target_secret = nostr::SecretKey::from_hex(
|
||
"0000000000000000000000000000000000000000000000000000000000000002",
|
||
)
|
||
.unwrap();
|
||
let ev = builder
|
||
.sign_with_keys(&Keys::new(target_secret))
|
||
.expect("sign");
|
||
let tags: Vec<Vec<String>> = ev.tags.iter().map(|t| t.as_slice().to_vec()).collect();
|
||
assert_eq!(ev.kind, Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16));
|
||
assert_eq!(tags[0], vec!["-"]);
|
||
assert_eq!(tags[1], vec!["p", IA_TARGET_HEX]);
|
||
assert_eq!(tags[2], vec!["reason", "returned"]);
|
||
assert_eq!(tags.len(), 3, "self unarchive must not carry an auth tag");
|
||
assert_eq!(ev.pubkey.to_hex(), IA_TARGET_HEX);
|
||
}
|
||
|
||
#[test]
|
||
fn identity_archive_reason_accepts_64_bytes() {
|
||
// check_reason compares `.len()` (bytes), not chars — use a
|
||
// multi-byte char so a chars-based off-by-one would slip through.
|
||
let reason: String = "é".repeat(32); // 32 * 2 bytes = 64 bytes exactly
|
||
assert_eq!(reason.len(), 64);
|
||
build_archive_identity_request(IA_TARGET_HEX, "", Some(&reason), None, None)
|
||
.expect("64-byte reason must be accepted");
|
||
}
|
||
|
||
#[test]
|
||
fn identity_archive_reason_rejects_65_bytes() {
|
||
let reason: String = "a".repeat(65);
|
||
let err = build_archive_identity_request(IA_TARGET_HEX, "", Some(&reason), None, None)
|
||
.unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn identity_archive_reason_rejects_control_chars() {
|
||
let err = build_unarchive_identity_request(IA_TARGET_HEX, "", Some("bad\nreason"), None)
|
||
.unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn identity_archive_rejects_malformed_auth_tag() {
|
||
let bad_auth: [String; 4] = [
|
||
"auth".into(),
|
||
IA_OWNER_HEX.into(),
|
||
IA_CONDITIONS.into(),
|
||
"not-hex".into(),
|
||
];
|
||
let err = build_archive_identity_request(IA_TARGET_HEX, "", None, None, Some(&bad_auth))
|
||
.unwrap_err();
|
||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn unarchive_request_has_no_replaced_by_param() {
|
||
// 9036 has no `replaced_by` parameter at all (unlike 9035) — this is
|
||
// a compile-time guarantee, but pin the tag count so a future
|
||
// signature change that reintroduces it doesn't silently widen the
|
||
// wire form without a test failing.
|
||
let ev = sign(
|
||
build_unarchive_identity_request(IA_TARGET_HEX, "", Some("returned"), None).unwrap(),
|
||
);
|
||
assert!(!ev
|
||
.tags
|
||
.iter()
|
||
.any(|t| t.as_slice().first().map(String::as_str) == Some("replaced-by")));
|
||
}
|
||
}
|