From 82bc5a47c5e58d4c4579420cd4dab16b8a3f3da3 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Thu, 5 Nov 2026 17:27:33 -0600 Subject: [PATCH] feat(cli): report a message to relay moderators (#8829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This change adds `buzz messages report`. The command sends a NIP-56 report (kind 1984) to the community moderators. You can report a message with `--event`. You can report a user with `--pubkey`. The `--reason` flag is required. It accepts one of seven values: `illegal`, `nudity`, `malware`, `spam`, `impersonation`, `profanity`, `other`. The `--note` flag is optional. The `--channel` flag is optional. When you give it, the CLI checks that the message is in that channel. The command prints the write envelope `{event_id, accepted, message}` and nothing else. ## Design The relay keeps a report in the moderation queue only. The relay does not store it as a normal event. The relay does not send it to subscribers. So the CLI prints no read-back. The tag shape is the same as desktop's `submitReport`: - Message report: `["p", ]` and `["e", , ]`. - User report: `["p", , ]`. The relay accepts this shape. - The note is trimmed and becomes the event content. The command adds no `h` tag. Desktop sends none. The relay reads the channel from the stored event. The task text mentioned an `h` tag. The real relay and desktop code do not use one. For `--event`, the CLI queries the event by id to learn the author's pubkey. That query uses an `ids` filter with `limit` 1. It names no kinds, because a message can have any kind. The existing `messages edit` and `messages delete` commands use the same lookup. `buzz-sdk` has a new `build_report` builder and a `REPORT_TYPES` list. The list matches the relay's `REPORT_TYPES`. The builder rejects unknown reasons, bad hex, and a note over 64 KiB. The builder allows self-tagging. Without it, the signer drops the `p` tag when you report your own message or key, and the relay rejects the report. Desktop opts in for the same reason. The CLI lowercases `--event` and `--pubkey` before it queries and signs. Clap also rejects unknown reasons, so the usage error appears before any network call. Exit codes: - Missing or unknown `--reason`, bad hex, channel mismatch: 1. - Relay rejection "invalid: report target ... not found" (exact text): 1. The relay gives this answer for a missing message and for a message you cannot read. - Other relay rejections (HTTP 400): 2. - HTTP 401 or 403: 3. The CLI README describes the new command. ## Testing What ran: - `cargo test -p buzz-cli -p buzz-sdk`: 510 CLI tests and 327 SDK tests pass. - `cargo clippy -p buzz-cli -p buzz-sdk --all-targets -- -D warnings`: clean. - `cargo fmt --all --check`: clean. - `just file-size-check`: passes. This PR does not change `desktop/` (deprecated; see #8840), so I did not run the Tauri crate tests. The CLI tests call the production `cmd_report_message` function against a local fake relay. They check the exact tags, the user-report shape, self-reports (the signer is the reported author), uppercase ids, argument parsing, the channel check, the not-found path, and the exit code for each relay rejection. Interop evidence: - I ran the real `buzz` binary against a local node HTTP server that stands in for a relay. - In the same node process, I ran desktop's own `submitReport` from `desktop/src/shared/api/moderation.ts` with a mocked signer. - Kind, tags, and content are equal for `spam` with a note and for `other` without a note. - The script prints `INTEROP OK`. - No shared relay was used. Mutation checks (break the guard, see a test fail, restore): - SDK reason check removed: fails. - SDK `e` tag without the reason: fails. - SDK `p` tag changed: fails. - SDK note trim removed: fails. - SDK note size cap removed: fails. - CLI channel check removed: fails. - CLI not-found mapping removed: fails. - CLI author read from the wrong field: fails. - CLI event-id hex check removed: fails. - SDK self-tagging removed: fails in the SDK test and in the CLI self-report test. - CLI lowercasing removed: fails. - Not-found mapping widened to any `invalid:` 400: fails. - Clap reason value parser removed: fails. - Clap required-target group removed: fails. - Clap `--channel` conflict with `--pubkey` removed: fails. One CLI `--pubkey` hex check was redundant with the builder. I removed it instead of keeping a guard no test can catch. ## Manual test steps You need a local relay and a running desktop or a second account to see the queue. Do not use a shared relay. 1. Start a local relay with `just relay`. 2. Send a message: `buzz messages send --channel --content "test"`. Copy the event id. 3. Report it: `buzz messages report --channel --event --reason spam --note "test report"`. Success: the output is `{"event_id":"...","accepted":true,"message":""}` and the exit code is 0. 4. As a moderator, run `buzz moderation reports`. Success: the report appears with type `spam` and your note. 5. Run `buzz messages get --channel `. Success: the report does not appear in the channel. 6. Report a user: `buzz messages report --pubkey --reason impersonation`. Success: `accepted` is true and the report appears in the queue. 7. Run `buzz messages report --event --reason bogus`. Success: exit code 1 and a `user_error` message. 8. Run `buzz messages report --event <64 zeros> --reason spam`. Success: exit code 1 and a not-found message. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: bmcanally Co-authored-by: Claude Opus 5.5 --- crates/buzz-cli/README.md | 3 + .../buzz-cli/src/commands/message_report.rs | 475 ++++++++++++++++++ crates/buzz-cli/src/commands/messages.rs | 29 +- crates/buzz-cli/src/commands/mod.rs | 1 + crates/buzz-cli/src/lib.rs | 28 +- crates/buzz-sdk/src/builders.rs | 164 +++++- 6 files changed, 696 insertions(+), 4 deletions(-) create mode 100644 crates/buzz-cli/src/commands/message_report.rs diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 28d83d3c53..625a7619e3 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -39,6 +39,8 @@ buzz messages search --query "architecture" buzz messages search --author --since buzz messages edit --event --content "Updated text" buzz messages delete --event +buzz messages report --event --reason spam --note "why" # to moderators only +buzz messages report --pubkey --reason impersonation # Diffs buzz messages send-diff --channel --diff - --repo https://github.com/org/repo --commit abc123 < diff.patch @@ -128,6 +130,7 @@ The table below mirrors that tree for readers who are not at a terminal. | | `send-diff` | Send a code diff with metadata | | | `edit` | Edit a message you sent | | | `delete` | Delete a message | +| | `report` | Report a message or user to community moderators (NIP-56); reasons: `illegal`, `nudity`, `malware`, `spam`, `impersonation`, `profanity`, `other` | | | `get` | List messages in a channel | | | `thread` | Get a message thread | | | `search` | Full-text search, filterable by author | diff --git a/crates/buzz-cli/src/commands/message_report.rs b/crates/buzz-cli/src/commands/message_report.rs new file mode 100644 index 0000000000..55630df59e --- /dev/null +++ b/crates/buzz-cli/src/commands/message_report.rs @@ -0,0 +1,475 @@ +//! `buzz messages report` — NIP-56 report (kind 1984) to community moderators. +//! +//! The relay queues a report for moderators only: it is never stored as an +//! ordinary event or fanned out, so the command prints the write envelope and +//! nothing else. A message report carries the author's `p` tag and an `e` tag +//! whose third element is the report type, exactly as desktop sends it. + +use buzz_sdk::ReportTarget; + +use super::messages::{channel_id_from_event, fetch_event}; +use crate::client::{normalize_write_response, BuzzClient}; +use crate::error::CliError; +use crate::validate::validate_hex64; + +/// Parsed `buzz messages report` arguments. +pub struct ReportParams<'a> { + /// Channel UUID the message must belong to, when given. + pub channel: Option<&'a str>, + /// Hex id of the message to report. + pub event: Option<&'a str>, + /// Hex pubkey of a user to report without a message. + pub pubkey: Option<&'a str>, + /// NIP-56 report type. + pub reason: &'a str, + /// Optional free-text note for moderators. + pub note: Option<&'a str>, +} + +/// The relay rejects a report whose target it cannot read exactly like a +/// missing one, so both surface as not-found rather than a relay failure. +fn interpret_submit_error(e: CliError) -> CliError { + match e { + CliError::Relay { status: 400, body } + if body.starts_with("invalid: report target ") && body.ends_with(" not found") => + { + CliError::NotFound(body) + } + other => other, + } +} + +/// Build, sign, and submit a report; print the write envelope. +pub async fn cmd_report_message( + client: &BuzzClient, + params: ReportParams<'_>, +) -> Result<(), CliError> { + let event = params.event.map(str::to_ascii_lowercase); + let pubkey = params.pubkey.map(str::to_ascii_lowercase); + let builder = match (event.as_deref(), pubkey.as_deref()) { + (Some(event_id), None) => { + validate_hex64(event_id)?; + let event = fetch_event(client, event_id).await?; + if let Some(channel) = params.channel { + let actual = channel_id_from_event(event_id, &event)?; + if !actual.to_string().eq_ignore_ascii_case(channel.trim()) { + return Err(CliError::Usage(format!( + "event {event_id} is not in channel {channel}" + ))); + } + } + let author = event + .get("pubkey") + .and_then(|v| v.as_str()) + .ok_or_else(|| CliError::Other(format!("event {event_id} has no pubkey")))?; + buzz_sdk::build_report( + ReportTarget::Event { + author_pubkey: author, + event_id, + }, + params.reason, + params.note, + ) + } + (None, Some(pubkey)) => { + buzz_sdk::build_report(ReportTarget::Pubkey(pubkey), params.reason, params.note) + } + _ => { + return Err(CliError::Usage( + "report needs exactly one of --event or --pubkey".into(), + )) + } + } + .map_err(|e| CliError::Usage(format!("invalid report: {e}")))?; + + let event = client.sign_event(builder)?; + let resp = client + .submit_event(event) + .await + .map_err(interpret_submit_error)?; + println!("{}", normalize_write_response(&resp)); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use axum::{extract::State, http::StatusCode, routing::post, Router}; + use nostr::Keys; + use tokio::net::TcpListener; + + use super::*; + use crate::error::exit_code; + use crate::{Cli, Cmd, MessagesCmd}; + use clap::Parser; + + const CHANNEL: &str = "123e4567-e89b-12d3-a456-426614174000"; + const EVENT: &str = "cd00000000000000000000000000000000000000000000000000000000000000"; + const AUTHOR: &str = "ab00000000000000000000000000000000000000000000000000000000000000"; + + #[derive(Clone)] + struct Relay { + query: String, + events_status: StatusCode, + events_body: String, + captured: Arc>>, + } + + async fn fake_relay( + query: serde_json::Value, + events_status: StatusCode, + events_body: &str, + ) -> (String, Arc>>) { + let captured = Arc::new(Mutex::new(Vec::new())); + let state = Relay { + query: query.to_string(), + events_status, + events_body: events_body.to_string(), + captured: captured.clone(), + }; + let app = Router::new() + .route( + "/query", + post(|State(s): State, body: String| async move { + let matches = serde_json::from_str::(&s.query) + .ok() + .and_then(|events| events[0]["id"].as_str().map(|id| body.contains(id))) + .unwrap_or(true); + let reply = if matches { s.query } else { "[]".to_string() }; + ( + StatusCode::OK, + [("content-type", "application/json")], + reply, + ) + }), + ) + .route( + "/events", + post(|State(s): State, body: String| async move { + s.captured + .lock() + .unwrap() + .push(serde_json::from_str(&body).unwrap()); + ( + s.events_status, + [("content-type", "application/json")], + s.events_body, + ) + }), + ) + .with_state(state); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), captured) + } + + fn message_in_channel() -> serde_json::Value { + message_by(AUTHOR) + } + + fn message_by(author: &str) -> serde_json::Value { + serde_json::json!([{ + "id": EVENT, "pubkey": author, "kind": 9, "content": "hi", + "created_at": 1, "tags": [["h", CHANNEL]], "sig": "00" + }]) + } + + fn client(url: String) -> BuzzClient { + BuzzClient::new(url, Keys::generate(), None, None).unwrap() + } + + fn parse(args: &[&str]) -> Result { + let mut argv = vec!["buzz", "messages", "report"]; + argv.extend_from_slice(args); + Cli::try_parse_from(argv).map(|cli| cli.command) + } + + fn tags(event: &serde_json::Value) -> Vec> { + serde_json::from_value(event["tags"].clone()).unwrap() + } + + #[test] + fn parse_requires_known_reason_and_one_target() { + assert!(parse(&["--event", EVENT]).is_err(), "reason is required"); + assert!( + parse(&["--event", EVENT, "--reason", "harassment"]).is_err(), + "unknown reason" + ); + assert!(parse(&["--reason", "spam"]).is_err(), "target is required"); + assert!( + parse(&["--event", EVENT, "--pubkey", AUTHOR, "--reason", "spam"]).is_err(), + "event and pubkey conflict" + ); + assert!( + parse(&["--channel", CHANNEL, "--pubkey", AUTHOR, "--reason", "spam"]).is_err(), + "channel needs an event" + ); + for reason in buzz_sdk::REPORT_TYPES { + assert!( + parse(&["--event", EVENT, "--reason", reason]).is_ok(), + "{reason}" + ); + } + let Cmd::Messages(MessagesCmd::Report { reason, note, .. }) = + parse(&["--pubkey", AUTHOR, "--reason", "spam", "--note", "x"]).unwrap() + else { + panic!("expected messages report"); + }; + assert_eq!((reason.as_str(), note.as_deref()), ("spam", Some("x"))); + } + + #[tokio::test] + async fn reports_a_message_with_desktop_tag_shape() { + let (url, captured) = fake_relay( + message_in_channel(), + StatusCode::OK, + r#"{"event_id":"e1","accepted":true,"message":""}"#, + ) + .await; + cmd_report_message( + &client(url), + ReportParams { + channel: Some(CHANNEL), + event: Some(EVENT), + pubkey: None, + reason: "spam", + note: Some(" buy now "), + }, + ) + .await + .unwrap(); + let sent = captured.lock().unwrap(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0]["kind"], 1984); + assert_eq!(sent[0]["content"], "buy now"); + assert_eq!( + tags(&sent[0]), + vec![ + vec!["p".to_string(), AUTHOR.to_string()], + vec!["e".to_string(), EVENT.to_string(), "spam".to_string()], + ] + ); + } + + #[tokio::test] + async fn self_reports_keep_the_p_tag() { + let keys = Keys::generate(); + let me = keys.public_key().to_hex(); + let ok = r#"{"event_id":"e1","accepted":true,"message":""}"#; + + let (url, captured) = fake_relay(message_by(&me), StatusCode::OK, ok).await; + let client = BuzzClient::new(url, keys.clone(), None, None).unwrap(); + cmd_report_message( + &client, + ReportParams { + channel: None, + event: Some(EVENT), + pubkey: None, + reason: "spam", + note: None, + }, + ) + .await + .unwrap(); + assert_eq!( + tags(&captured.lock().unwrap()[0]), + vec![ + vec!["p".to_string(), me.clone()], + vec!["e".to_string(), EVENT.to_string(), "spam".to_string()], + ] + ); + + let (url, captured) = fake_relay(serde_json::json!([]), StatusCode::OK, ok).await; + let client = BuzzClient::new(url, keys, None, None).unwrap(); + cmd_report_message( + &client, + ReportParams { + channel: None, + event: None, + pubkey: Some(&me), + reason: "spam", + note: None, + }, + ) + .await + .unwrap(); + assert_eq!( + tags(&captured.lock().unwrap()[0]), + vec![vec!["p".to_string(), me, "spam".to_string()]] + ); + } + + #[tokio::test] + async fn uppercase_ids_are_normalized_before_lookup_and_signing() { + let (url, captured) = fake_relay( + message_in_channel(), + StatusCode::OK, + r#"{"event_id":"e1","accepted":true,"message":""}"#, + ) + .await; + let upper = EVENT.to_uppercase(); + cmd_report_message( + &client(url), + ReportParams { + channel: None, + event: Some(&upper), + pubkey: None, + reason: "spam", + note: None, + }, + ) + .await + .unwrap(); + assert_eq!(tags(&captured.lock().unwrap()[0])[1][1], EVENT); + } + + #[tokio::test] + async fn reports_a_user_without_a_message() { + let (url, captured) = fake_relay( + serde_json::json!([]), + StatusCode::OK, + r#"{"event_id":"e1","accepted":true,"message":""}"#, + ) + .await; + cmd_report_message( + &client(url), + ReportParams { + channel: None, + event: None, + pubkey: Some(AUTHOR), + reason: "impersonation", + note: None, + }, + ) + .await + .unwrap(); + let sent = captured.lock().unwrap(); + assert_eq!( + tags(&sent[0]), + vec![vec![ + "p".to_string(), + AUTHOR.to_string(), + "impersonation".to_string() + ]] + ); + } + + #[tokio::test] + async fn channel_mismatch_is_a_usage_error_and_sends_nothing() { + let (url, captured) = fake_relay(message_in_channel(), StatusCode::OK, "{}").await; + let err = cmd_report_message( + &client(url), + ReportParams { + channel: Some("00000000-0000-0000-0000-000000000000"), + event: Some(EVENT), + pubkey: None, + reason: "spam", + note: None, + }, + ) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Usage(_)), "{err}"); + assert_eq!(exit_code(&err), 1); + assert!(captured.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn malformed_ids_are_usage_errors_before_any_network_call() { + let dead = client("http://127.0.0.1:1".into()); + for (event, pubkey) in [(Some("abc"), None), (None, Some("abc"))] { + let err = cmd_report_message( + &dead, + ReportParams { + channel: None, + event, + pubkey, + reason: "spam", + note: None, + }, + ) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Usage(_)), "{err}"); + } + } + + #[tokio::test] + async fn missing_event_is_not_found_and_sends_nothing() { + let (url, captured) = fake_relay(serde_json::json!([]), StatusCode::OK, "{}").await; + let err = cmd_report_message( + &client(url), + ReportParams { + channel: None, + event: Some(EVENT), + pubkey: None, + reason: "spam", + note: None, + }, + ) + .await + .unwrap_err(); + assert_eq!(exit_code(&err), 1); + assert!(captured.lock().unwrap().is_empty()); + } + + async fn rejected_exit_code(status: StatusCode, body: &str) -> (CliError, i32) { + let (url, _) = fake_relay(message_in_channel(), status, body).await; + let err = cmd_report_message( + &client(url), + ReportParams { + channel: None, + event: Some(EVENT), + pubkey: None, + reason: "spam", + note: None, + }, + ) + .await + .unwrap_err(); + let code = exit_code(&err); + (err, code) + } + + #[tokio::test] + async fn relay_rejections_map_to_exit_codes() { + let (err, code) = rejected_exit_code( + StatusCode::BAD_REQUEST, + r#"{"error":"invalid: report target event not found"}"#, + ) + .await; + assert!(matches!(err, CliError::NotFound(_)), "{err}"); + assert_eq!(code, 1); + + let (err, code) = rejected_exit_code( + StatusCode::BAD_REQUEST, + r#"{"error":"invalid: report target blob not found"}"#, + ) + .await; + assert!(matches!(err, CliError::NotFound(_)), "{err}"); + assert_eq!(code, 1); + + let (_, code) = rejected_exit_code( + StatusCode::BAD_REQUEST, + r#"{"error":"invalid: report must include a p tag"}"#, + ) + .await; + assert_eq!(code, 2); + + let (_, code) = rejected_exit_code( + StatusCode::BAD_REQUEST, + r#"{"error":"invalid: unsupported report type: x"}"#, + ) + .await; + assert_eq!(code, 2); + + let (_, code) = rejected_exit_code( + StatusCode::FORBIDDEN, + r#"{"error":"restricted: community writes are fenced"}"#, + ) + .await; + assert_eq!(code, 3); + } +} diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 1ef6d9fca4..a453d57c25 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -51,7 +51,10 @@ fn thread_ref_from_parent_tags( /// - Nested reply: `root` is the parent's own root marker; `parent` is unchanged. /// /// Ensures CLI-sent replies thread correctly using the same NIP-10 logic. -async fn fetch_event(client: &BuzzClient, event_id: &str) -> Result { +pub(super) async fn fetch_event( + client: &BuzzClient, + event_id: &str, +) -> Result { let filter = serde_json::json!({ "ids": [event_id], "limit": 1 }); let raw = client.query(&filter).await?; let events: serde_json::Value = serde_json::from_str(&raw) @@ -82,7 +85,10 @@ fn thread_ref_from_event(event_id: &str, event: &serde_json::Value) -> Result Result { +pub(super) fn channel_id_from_event( + event_id: &str, + event: &serde_json::Value, +) -> Result { let tags = event .get("tags") .and_then(|tags| tags.as_array()) @@ -976,6 +982,25 @@ pub async fn dispatch( ) .await } + MessagesCmd::Report { + channel, + event, + pubkey, + reason, + note, + } => { + super::message_report::cmd_report_message( + client, + super::message_report::ReportParams { + channel: channel.as_deref(), + event: event.as_deref(), + pubkey: pubkey.as_deref(), + reason: &reason, + note: note.as_deref(), + }, + ) + .await + } MessagesCmd::Get { channel, limit, diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 830f518f82..7e18bdbd25 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -10,6 +10,7 @@ mod huddle_tracker; pub mod huddles; pub mod issues; pub mod mem; +pub mod message_report; pub mod messages; pub mod moderation; pub mod notes; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 19984a0b57..a204bd228b 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -551,6 +551,31 @@ pub enum MessagesCmd { #[arg(long)] public_reason: Option, }, + /// Report a message or user to the community moderators (NIP-56) + #[command( + group = clap::ArgGroup::new("target").required(true).args(["event", "pubkey"]), + after_help = "Examples:\n buzz messages report --event --reason spam\n buzz messages report --channel --event --reason other --note \"off-topic ads\"\n buzz messages report --pubkey --reason impersonation\n\nReasons: illegal, nudity, malware, spam, impersonation, profanity, other.\nA report goes to the moderation queue only; it is never shown in the channel." + )] + Report { + /// Channel UUID the message is in; checked against the message when given + #[arg(long, conflicts_with = "pubkey")] + channel: Option, + /// Event ID of the message to report (64-char hex) + #[arg(long)] + event: Option, + /// Pubkey of a user to report without a message (64-char hex) + #[arg(long)] + pubkey: Option, + /// Why you are reporting + #[arg( + long, + value_parser = clap::builder::PossibleValuesParser::new(buzz_sdk::REPORT_TYPES) + )] + reason: String, + /// Optional note for the moderators + #[arg(long)] + note: Option, + }, /// Retrieve messages from a channel #[command( after_help = "Examples:\n buzz messages get --channel \n buzz messages get --channel --limit 50 --kinds 1,1984" @@ -2567,6 +2592,7 @@ mod tests { "delete", "edit", "get", + "report", "search", "send", "send-diff", @@ -2710,7 +2736,7 @@ mod tests { ("huddles", 2), ("issues", 6), ("media", 1), - ("messages", 8), + ("messages", 9), ("pack", 2), ("patches", 4), ("pr", 5), diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index d0fbf02dec..10287491fd 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -12,7 +12,7 @@ use buzz_core::{ 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_PROJECT, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + KIND_REPORT, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -2091,6 +2091,70 @@ pub fn build_moderation_resolve_report( Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_RESOLVE_REPORT as u16), "").tags(tags)) } +/// NIP-56 report types the relay accepts at ingest (`report.rs` `REPORT_TYPES`). +pub const REPORT_TYPES: &[&str] = &[ + "illegal", + "nudity", + "malware", + "spam", + "impersonation", + "profanity", + "other", +]; + +/// What a NIP-56 report points at. +#[derive(Debug, Clone, Copy)] +pub enum ReportTarget<'a> { + /// A message: the reported author's pubkey and the event id. + Event { + /// Hex pubkey of the message author. + author_pubkey: &'a str, + /// Hex id of the reported event. + event_id: &'a str, + }, + /// A user, with no message. + Pubkey(&'a str), +} + +/// Build a NIP-56 report (kind 1984). +/// +/// A message report carries `["p", author]` and `["e", event_id, type]`; a +/// user report carries `["p", pubkey, type]`. No `h` tag is added: the relay +/// reads the channel from the stored event. `note` is trimmed and becomes the +/// content; blank means empty content. Self-tagging is allowed so reporting +/// your own message or key keeps the `p` tag the relay requires. +pub fn build_report( + target: ReportTarget<'_>, + report_type: &str, + note: Option<&str>, +) -> Result { + if !REPORT_TYPES.contains(&report_type) { + return Err(SdkError::InvalidInput(format!( + "report type must be one of {} (got: {report_type})", + REPORT_TYPES.join(", ") + ))); + } + let tags = match target { + ReportTarget::Event { + author_pubkey, + event_id, + } => { + let author = check_pubkey_hex(author_pubkey, "author_pubkey")?; + let event_id = check_hex_exact(event_id, 64, "event_id")?; + vec![tag(&["p", &author])?, tag(&["e", &event_id, report_type])?] + } + ReportTarget::Pubkey(pubkey) => { + let pubkey = check_pubkey_hex(pubkey, "pubkey")?; + vec![tag(&["p", &pubkey, report_type])?] + } + }; + let note = note.map(str::trim).unwrap_or(""); + check_content(note, 64 * 1024)?; + Ok(EventBuilder::new(Kind::Custom(KIND_REPORT as u16), note) + .tags(tags) + .allow_self_tagging()) +} + // --------------------------------------------------------------------------- // NIP-IA identity archival (kinds 9035/9036). // @@ -5277,6 +5341,104 @@ mod tests { assert!(tag_values(&ev, "reason").is_empty()); } + fn tag_vecs(event: &nostr::Event) -> Vec> { + event.tags.iter().map(|t| t.clone().to_vec()).collect() + } + + #[test] + fn report_message_shape_matches_desktop() { + let author = "AB".repeat(32); + let eid = event_id().to_hex(); + let ev = sign( + build_report( + ReportTarget::Event { + author_pubkey: &author, + event_id: &eid, + }, + "spam", + Some(" buy now "), + ) + .unwrap(), + ); + assert_eq!(ev.kind.as_u16(), KIND_REPORT as u16); + assert_eq!(ev.content, "buy now"); + assert_eq!( + tag_vecs(&ev), + vec![ + vec!["p".to_string(), "ab".repeat(32)], + vec!["e".to_string(), eid, "spam".to_string()], + ] + ); + } + + #[test] + fn report_keeps_p_tag_when_signer_is_the_reported_user() { + let keys = nostr::Keys::generate(); + let me = keys.public_key().to_hex(); + let eid = event_id().to_hex(); + let user = build_report(ReportTarget::Pubkey(&me), "spam", None).unwrap(); + let msg = build_report( + ReportTarget::Event { + author_pubkey: &me, + event_id: &eid, + }, + "spam", + None, + ) + .unwrap(); + for builder in [user, msg] { + let ev = builder.sign_with_keys(&keys).unwrap(); + assert!(tag_vecs(&ev).iter().any(|t| t[0] == "p" && t[1] == me)); + } + } + + #[test] + fn report_user_puts_type_on_p_tag() { + let pk = "c".repeat(64); + let ev = + sign(build_report(ReportTarget::Pubkey(&pk), "impersonation", Some(" ")).unwrap()); + assert_eq!(ev.content, ""); + assert_eq!( + tag_vecs(&ev), + vec![vec!["p".to_string(), pk, "impersonation".to_string()]] + ); + } + + #[test] + fn report_accepts_every_relay_type_and_nothing_else() { + let pk = "c".repeat(64); + for t in REPORT_TYPES { + assert!( + build_report(ReportTarget::Pubkey(&pk), t, None).is_ok(), + "{t}" + ); + } + for bad in ["", "Spam", "abuse", "harassment"] { + let err = build_report(ReportTarget::Pubkey(&pk), bad, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_)), "{bad}"); + } + } + + #[test] + fn report_rejects_malformed_ids_and_oversized_note() { + let pk = "c".repeat(64); + let eid = event_id().to_hex(); + let bad_author = ReportTarget::Event { + author_pubkey: "abc", + event_id: &eid, + }; + assert!(build_report(bad_author, "spam", None).is_err()); + let bad_event = ReportTarget::Event { + author_pubkey: &pk, + event_id: "abc", + }; + assert!(build_report(bad_event, "spam", None).is_err()); + assert!(build_report(ReportTarget::Pubkey("xyz"), "spam", None).is_err()); + let big = "a".repeat(64 * 1024 + 1); + let err = build_report(ReportTarget::Pubkey(&pk), "spam", Some(&big)).unwrap_err(); + assert!(matches!(err, SdkError::ContentTooLarge { .. })); + } + #[test] fn moderation_resolve_rejects_bad_status() { let rid = event_id().to_hex();