commit 5206c0c0dc72a529d2f6538d82fdeaf48b57e017
parent f76d67a836e92e60fec682f0a5f219e6c05c877d
Author: triesap <tyson@radroots.org>
Date: Sat, 6 Jun 2026 04:11:20 -0700
nips: add report parser
Diffstat:
1 file changed, 364 insertions(+), 9 deletions(-)
diff --git a/crates/tangle_nips/src/lib.rs b/crates/tangle_nips/src/lib.rs
@@ -12,6 +12,7 @@ pub const NIP25_REACTION_KIND: u32 = 7;
pub const NIP23_LONG_FORM_KIND: u32 = 30_023;
pub const NIP23_LONG_FORM_DRAFT_KIND: u32 = 30_024;
pub const NIP7D_THREAD_KIND: u32 = 11;
+pub const NIP56_REPORT_KIND: u32 = 1_984;
pub const NIP32_LABEL_KIND: u32 = 1_985;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -953,6 +954,229 @@ pub fn parse_forum_thread_event(event: &Event) -> Result<Option<ForumThreadEvent
}))
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ReportType {
+ Nudity,
+ Malware,
+ Profanity,
+ Illegal,
+ Spam,
+ Impersonation,
+ Other,
+}
+
+impl ReportType {
+ pub fn canonical(self) -> &'static str {
+ match self {
+ Self::Nudity => "nudity",
+ Self::Malware => "malware",
+ Self::Profanity => "profanity",
+ Self::Illegal => "illegal",
+ Self::Spam => "spam",
+ Self::Impersonation => "impersonation",
+ Self::Other => "other",
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ReportTarget {
+ Pubkey {
+ pubkey: PublicKeyHex,
+ report_type: ReportType,
+ },
+ Event {
+ event_id: EventId,
+ report_type: ReportType,
+ },
+ Blob {
+ hash: String,
+ report_type: ReportType,
+ },
+}
+
+impl ReportTarget {
+ pub fn target_type(&self) -> &'static str {
+ match self {
+ Self::Pubkey { .. } => "pubkey",
+ Self::Event { .. } => "event",
+ Self::Blob { .. } => "blob",
+ }
+ }
+
+ pub fn target_ref(&self) -> &str {
+ match self {
+ Self::Pubkey { pubkey, .. } => pubkey.as_str(),
+ Self::Event { event_id, .. } => event_id.as_str(),
+ Self::Blob { hash, .. } => hash,
+ }
+ }
+
+ pub fn report_type(&self) -> ReportType {
+ match self {
+ Self::Pubkey { report_type, .. }
+ | Self::Event { report_type, .. }
+ | Self::Blob { report_type, .. } => *report_type,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ReportEvent {
+ event_id: EventId,
+ pubkey: PublicKeyHex,
+ created_at: UnixTimestamp,
+ content: String,
+ reported_pubkeys: Vec<PublicKeyHex>,
+ targets: Vec<ReportTarget>,
+ server_urls: Vec<String>,
+}
+
+impl ReportEvent {
+ pub fn event_id(&self) -> &EventId {
+ &self.event_id
+ }
+
+ pub fn pubkey(&self) -> &PublicKeyHex {
+ &self.pubkey
+ }
+
+ pub fn created_at(&self) -> UnixTimestamp {
+ self.created_at
+ }
+
+ pub fn content(&self) -> &str {
+ &self.content
+ }
+
+ pub fn reported_pubkeys(&self) -> &[PublicKeyHex] {
+ &self.reported_pubkeys
+ }
+
+ pub fn targets(&self) -> &[ReportTarget] {
+ &self.targets
+ }
+
+ pub fn server_urls(&self) -> &[String] {
+ &self.server_urls
+ }
+}
+
+pub fn parse_report_event(event: &Event) -> Result<Option<ReportEvent>, String> {
+ if event.unsigned().kind().as_u32() != NIP56_REPORT_KIND {
+ return Ok(None);
+ }
+ let mut reported_pubkeys = Vec::new();
+ let mut targets = Vec::new();
+ for tag in matching_tags(event, "p") {
+ let pubkey = tag
+ .first_value()
+ .ok_or_else(|| "report p tag must include a pubkey".to_owned())
+ .and_then(|value| parse_pubkey_value(value, "report p target", "pubkey"))?;
+ if let Some(report_type) = optional_report_type(&tag)? {
+ targets.push(ReportTarget::Pubkey {
+ pubkey: pubkey.clone(),
+ report_type,
+ });
+ }
+ reported_pubkeys.push(pubkey);
+ }
+ if reported_pubkeys.is_empty() {
+ return Err("report event must include at least one p tag".to_owned());
+ }
+ let mut event_context_count = 0;
+ for tag in matching_tags(event, "e") {
+ let event_id = tag
+ .first_value()
+ .ok_or_else(|| "report e tag must include an event id".to_owned())
+ .and_then(EventId::new)?;
+ event_context_count += 1;
+ if let Some(report_type) = optional_report_type(&tag)? {
+ targets.push(ReportTarget::Event {
+ event_id,
+ report_type,
+ });
+ }
+ }
+ let mut blob_count = 0;
+ for tag in matching_tags(event, "x") {
+ let hash = tag
+ .first_value()
+ .ok_or_else(|| "report x tag must include a hash".to_owned())?;
+ if hash.is_empty() {
+ return Err("report x hash must not be empty".to_owned());
+ }
+ blob_count += 1;
+ targets.push(ReportTarget::Blob {
+ hash: hash.to_owned(),
+ report_type: required_report_type(&tag, "x")?,
+ });
+ }
+ if blob_count > 0 && event_context_count == 0 {
+ return Err("report x target requires an e tag context".to_owned());
+ }
+ if targets.is_empty() {
+ return Err(
+ "report event must include at least one p e or x tag with a report type".to_owned(),
+ );
+ }
+ let mut server_urls = parse_report_server_urls(event)?;
+ reported_pubkeys.sort_by(|left, right| left.as_str().cmp(right.as_str()));
+ reported_pubkeys.dedup();
+ server_urls.sort();
+ server_urls.dedup();
+ Ok(Some(ReportEvent {
+ event_id: event.id().clone(),
+ pubkey: event.unsigned().pubkey().clone(),
+ created_at: event.unsigned().created_at(),
+ content: event.unsigned().content().to_owned(),
+ reported_pubkeys,
+ targets,
+ server_urls,
+ }))
+}
+
+fn optional_report_type(tag: &ParsedTag) -> Result<Option<ReportType>, String> {
+ tag.values()
+ .get(1)
+ .map(|value| parse_report_type(value))
+ .transpose()
+}
+
+fn required_report_type(tag: &ParsedTag, name: &str) -> Result<ReportType, String> {
+ match optional_report_type(tag)? {
+ Some(report_type) => Ok(report_type),
+ None => Err(format!("report {name} tag must include a report type")),
+ }
+}
+
+fn parse_report_type(value: &str) -> Result<ReportType, String> {
+ match value {
+ "nudity" => Ok(ReportType::Nudity),
+ "malware" => Ok(ReportType::Malware),
+ "profanity" => Ok(ReportType::Profanity),
+ "illegal" => Ok(ReportType::Illegal),
+ "spam" => Ok(ReportType::Spam),
+ "impersonation" => Ok(ReportType::Impersonation),
+ "other" => Ok(ReportType::Other),
+ "" => Err("report type must not be empty".to_owned()),
+ _ => Err(format!("report type `{value}` is unsupported")),
+ }
+}
+
+fn parse_report_server_urls(event: &Event) -> Result<Vec<String>, String> {
+ let mut urls = Vec::new();
+ for tag in matching_tags(event, "server") {
+ match tag.values() {
+ [value] if !value.is_empty() => urls.push(value.clone()),
+ [..] => {
+ return Err("report server tag must include exactly one non-empty URL".to_owned());
+ }
+ }
+ }
+ Ok(urls)
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LabelTarget {
Event(EventId),
@@ -1899,15 +2123,16 @@ mod tests {
CommentTarget, DeletionTarget, FulfillmentMethod, LabelTarget, ListingEffectiveStatus,
ListingKind, ListingProjectionEvaluation, ListingUnit, LongFormKind, NIP7D_THREAD_KIND,
NIP22_COMMENT_KIND, NIP23_LONG_FORM_DRAFT_KIND, NIP23_LONG_FORM_KIND, NIP25_REACTION_KIND,
- NIP32_LABEL_KIND, NIP99_PUBLIC_LISTING_KIND, ReactionValue, evaluate_listing_projection,
- matching_tags, optional_tag_value, optional_tag_values, parse_comment_event,
- parse_deletion_request, parse_forum_thread_event, parse_label_event,
- parse_listing_fulfillment, parse_listing_identity, parse_listing_location,
- parse_listing_price, parse_listing_status, parse_listing_taxonomy, parse_listing_text,
- parse_listing_unit, parse_long_form_event, parse_nip50_filter_search, parse_nip50_search,
- parse_reaction_event, parse_relay_auth_event, parse_required_u64_tag, parse_u64_field,
- repeated_or_missing_policy_boundary, required_tag_value, required_tag_values,
- single_letter_tag_values, single_letter_values_for, tag_count,
+ NIP32_LABEL_KIND, NIP56_REPORT_KIND, NIP99_PUBLIC_LISTING_KIND, ReactionValue,
+ ReportTarget, ReportType, evaluate_listing_projection, matching_tags, optional_tag_value,
+ optional_tag_values, parse_comment_event, parse_deletion_request, parse_forum_thread_event,
+ parse_label_event, parse_listing_fulfillment, parse_listing_identity,
+ parse_listing_location, parse_listing_price, parse_listing_status, parse_listing_taxonomy,
+ parse_listing_text, parse_listing_unit, parse_long_form_event, parse_nip50_filter_search,
+ parse_nip50_search, parse_reaction_event, parse_relay_auth_event, parse_report_event,
+ parse_required_u64_tag, parse_u64_field, repeated_or_missing_policy_boundary,
+ required_tag_value, required_tag_values, single_letter_tag_values,
+ single_letter_values_for, tag_count,
};
use tangle_protocol::{
Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent,
@@ -2569,6 +2794,136 @@ mod tests {
}
#[test]
+ fn report_parser_extracts_pubkey_event_blob_targets_and_servers() {
+ let reported_pubkey = "7".repeat(PublicKeyHex::HEX_LENGTH);
+ let reported_event = "8".repeat(EventId::HEX_LENGTH);
+ let blob_hash = "9".repeat(64);
+ let event = event_with_kind_tags_and_content(
+ NIP56_REPORT_KIND.into(),
+ vec![
+ Tag::from_parts("p", &[&reported_pubkey, "spam"]).expect("p"),
+ Tag::from_parts("e", &[&reported_event, "illegal"]).expect("e"),
+ Tag::from_parts("x", &[&blob_hash, "malware"]).expect("x"),
+ Tag::from_parts("server", &["https://media.radroots.test/blob.jpg"])
+ .expect("server"),
+ ],
+ "moderator report",
+ );
+
+ let report = parse_report_event(&event).expect("parse").expect("report");
+
+ assert_eq!(report.event_id(), event.id());
+ assert_eq!(report.pubkey(), event.unsigned().pubkey());
+ assert_eq!(report.created_at(), event.unsigned().created_at());
+ assert_eq!(report.content(), "moderator report");
+ assert_eq!(report.reported_pubkeys()[0].as_str(), reported_pubkey);
+ assert_eq!(
+ report.server_urls(),
+ &["https://media.radroots.test/blob.jpg".to_owned()]
+ );
+ assert_eq!(report.targets().len(), 3);
+ assert_eq!(report.targets()[0].target_type(), "pubkey");
+ assert_eq!(report.targets()[0].target_ref(), reported_pubkey);
+ assert_eq!(report.targets()[0].report_type(), ReportType::Spam);
+ assert_eq!(ReportType::Spam.canonical(), "spam");
+ assert_eq!(report.targets()[1].target_type(), "event");
+ assert_eq!(report.targets()[1].target_ref(), reported_event);
+ assert_eq!(report.targets()[1].report_type(), ReportType::Illegal);
+ assert!(
+ matches!(&report.targets()[2], ReportTarget::Blob { hash, report_type } if hash == &blob_hash && *report_type == ReportType::Malware)
+ );
+ }
+
+ #[test]
+ fn report_parser_accepts_note_report_pubkey_context_and_ignores_other_kinds() {
+ let reported_pubkey = "7".repeat(PublicKeyHex::HEX_LENGTH);
+ let reported_event = "8".repeat(EventId::HEX_LENGTH);
+ let event = event_with_kind_tags_and_content(
+ NIP56_REPORT_KIND.into(),
+ vec![
+ Tag::from_parts("p", &[&reported_pubkey]).expect("p"),
+ Tag::from_parts("e", &[&reported_event, "profanity"]).expect("e"),
+ ],
+ "note report",
+ );
+ let note = event_with_kind_and_tags(
+ 1,
+ vec![Tag::from_parts("p", &[&reported_pubkey, "spam"]).expect("p")],
+ );
+
+ let report = parse_report_event(&event).expect("parse").expect("report");
+
+ assert_eq!(report.reported_pubkeys()[0].as_str(), reported_pubkey);
+ assert_eq!(report.targets().len(), 1);
+ assert_eq!(report.targets()[0].target_type(), "event");
+ assert_eq!(report.targets()[0].target_ref(), reported_event);
+ assert_eq!(report.targets()[0].report_type(), ReportType::Profanity);
+ assert_eq!(parse_report_event(¬e), Ok(None));
+ }
+
+ #[test]
+ fn report_parser_rejects_missing_context_type_and_bad_tags() {
+ let reported_pubkey = "7".repeat(PublicKeyHex::HEX_LENGTH);
+ let reported_event = "8".repeat(EventId::HEX_LENGTH);
+ let blob_hash = "9".repeat(64);
+ let missing_pubkey = event_with_kind_and_tags(
+ NIP56_REPORT_KIND.into(),
+ vec![Tag::from_parts("e", &[&reported_event, "spam"]).expect("e")],
+ );
+ let missing_report_type = event_with_kind_and_tags(
+ NIP56_REPORT_KIND.into(),
+ vec![Tag::from_parts("p", &[&reported_pubkey]).expect("p")],
+ );
+ let unsupported_report_type = event_with_kind_and_tags(
+ NIP56_REPORT_KIND.into(),
+ vec![Tag::from_parts("p", &[&reported_pubkey, "scam"]).expect("p")],
+ );
+ let x_without_event = event_with_kind_and_tags(
+ NIP56_REPORT_KIND.into(),
+ vec![
+ Tag::from_parts("p", &[&reported_pubkey]).expect("p"),
+ Tag::from_parts("x", &[&blob_hash, "malware"]).expect("x"),
+ ],
+ );
+ let malformed_pubkey = event_with_kind_and_tags(
+ NIP56_REPORT_KIND.into(),
+ vec![Tag::from_parts("p", &["bad", "spam"]).expect("p")],
+ );
+ let empty_server = event_with_kind_and_tags(
+ NIP56_REPORT_KIND.into(),
+ vec![
+ Tag::from_parts("p", &[&reported_pubkey, "spam"]).expect("p"),
+ Tag::from_parts("server", &[""]).expect("server"),
+ ],
+ );
+
+ assert_eq!(
+ parse_report_event(&missing_pubkey).expect_err("missing p"),
+ "report event must include at least one p tag"
+ );
+ assert_eq!(
+ parse_report_event(&missing_report_type).expect_err("missing type"),
+ "report event must include at least one p e or x tag with a report type"
+ );
+ assert_eq!(
+ parse_report_event(&unsupported_report_type).expect_err("bad type"),
+ "report type `scam` is unsupported"
+ );
+ assert_eq!(
+ parse_report_event(&x_without_event).expect_err("x context"),
+ "report x target requires an e tag context"
+ );
+ assert_eq!(
+ parse_report_event(&malformed_pubkey).expect_err("bad pubkey"),
+ "report p target pubkey is invalid: public key must be 64 characters, got 3"
+ );
+ assert_eq!(
+ parse_report_event(&empty_server).expect_err("empty server"),
+ "report server tag must include exactly one non-empty URL"
+ );
+ }
+
+ #[test]
fn label_parser_extracts_namespaced_labels_and_targets() {
let event_id = "8".repeat(EventId::HEX_LENGTH);
let pubkey = "9".repeat(PublicKeyHex::HEX_LENGTH);