mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
feat(cli): report a message to relay moderators (#8829)
## 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", <author>]` and `["e", <event id>, <reason>]`.
- User report: `["p", <pubkey>, <reason>]`. 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 <uuid> --content
"test"`. Copy the event id.
3. Report it: `buzz messages report --channel <uuid> --event <event-id>
--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 <uuid>`.
Success: the report does not appear in the channel.
6. Report a user: `buzz messages report --pubkey <hex> --reason
impersonation`.
Success: `accepted` is true and the report appears in the queue.
7. Run `buzz messages report --event <event-id> --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 <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,8 @@ buzz messages search --query "architecture"
|
||||
buzz messages search --author <pubkey|npub|name> --since <unix-ts>
|
||||
buzz messages edit --event <event-id> --content "Updated text"
|
||||
buzz messages delete --event <event-id>
|
||||
buzz messages report --event <event-id> --reason spam --note "why" # to moderators only
|
||||
buzz messages report --pubkey <pubkey> --reason impersonation
|
||||
|
||||
# Diffs
|
||||
buzz messages send-diff --channel <uuid> --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 |
|
||||
|
||||
475
crates/buzz-cli/src/commands/message_report.rs
Normal file
475
crates/buzz-cli/src/commands/message_report.rs
Normal file
@@ -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<Mutex<Vec<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
async fn fake_relay(
|
||||
query: serde_json::Value,
|
||||
events_status: StatusCode,
|
||||
events_body: &str,
|
||||
) -> (String, Arc<Mutex<Vec<serde_json::Value>>>) {
|
||||
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<Relay>, body: String| async move {
|
||||
let matches = serde_json::from_str::<serde_json::Value>(&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<Relay>, 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<Cmd, clap::Error> {
|
||||
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<Vec<String>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<serde_json::Value, CliError> {
|
||||
pub(super) async fn fetch_event(
|
||||
client: &BuzzClient,
|
||||
event_id: &str,
|
||||
) -> Result<serde_json::Value, CliError> {
|
||||
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<Th
|
||||
|
||||
/// Resolve the channel UUID for an event by querying for it via POST /query.
|
||||
/// Extracts the `h` tag value from the returned event's tags.
|
||||
fn channel_id_from_event(event_id: &str, event: &serde_json::Value) -> Result<Uuid, CliError> {
|
||||
pub(super) fn channel_id_from_event(
|
||||
event_id: &str,
|
||||
event: &serde_json::Value,
|
||||
) -> Result<Uuid, CliError> {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -551,6 +551,31 @@ pub enum MessagesCmd {
|
||||
#[arg(long)]
|
||||
public_reason: Option<String>,
|
||||
},
|
||||
/// 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 <EVENT_ID> --reason spam\n buzz messages report --channel <UUID> --event <EVENT_ID> --reason other --note \"off-topic ads\"\n buzz messages report --pubkey <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<String>,
|
||||
/// Event ID of the message to report (64-char hex)
|
||||
#[arg(long)]
|
||||
event: Option<String>,
|
||||
/// Pubkey of a user to report without a message (64-char hex)
|
||||
#[arg(long)]
|
||||
pubkey: Option<String>,
|
||||
/// 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<String>,
|
||||
},
|
||||
/// Retrieve messages from a channel
|
||||
#[command(
|
||||
after_help = "Examples:\n buzz messages get --channel <UUID>\n buzz messages get --channel <UUID> --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),
|
||||
|
||||
@@ -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<EventBuilder, SdkError> {
|
||||
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<Vec<String>> {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user