Community moderation Phase 1: reports, bans/timeouts, audit, tombstones, relay-DM notices (#1616)

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-08 13:36:46 -04:00
committed by GitHub
parent 41b187f936
commit 863aeb79f3
32 changed files with 5204 additions and 56 deletions

View File

@@ -245,6 +245,20 @@ impl BuzzClient {
self.handle_response(resp).await
}
/// GET an authed relay endpoint (NIP-98), returning the raw JSON body.
///
/// `path` is a root-relative path incl. any query string, e.g.
/// `/moderation/reports?status=open&limit=20`. Used by the moderation
/// read commands, which read structured queue/audit rows rather than
/// stored events.
pub async fn get_authed(&self, path: &str) -> Result<String, CliError> {
let url = format!("{}{path}", self.relay_url);
let auth = sign_nip98(&self.keys, "GET", &url, None)?;
let req = self.http.get(&url).header("Authorization", &auth);
let resp = self.with_auth_tag(req).send().await?;
self.handle_response(resp).await
}
/// Submit a signed Nostr event via POST /events.
pub async fn submit_event(&self, event: nostr::Event) -> Result<String, CliError> {
let url = format!("{}/events", self.relay_url);

View File

@@ -1,4 +1,4 @@
use buzz_sdk::{DiffMeta, ThreadRef, VoteDirection};
use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection};
use nostr::PublicKey;
use uuid::Uuid;
@@ -551,15 +551,29 @@ pub async fn cmd_send_diff_message(client: &BuzzClient, p: SendDiffParams) -> Re
Ok(())
}
pub async fn cmd_delete_message(client: &BuzzClient, event_id: &str) -> Result<(), CliError> {
pub async fn cmd_delete_message(
client: &BuzzClient,
event_id: &str,
action_id: Option<Uuid>,
reason_code: Option<&str>,
public_reason: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
// Resolve channel_id from the event's h-tag
let channel_uuid = resolve_channel_id(client, event_id).await?;
let target_eid = parse_event_id(event_id)?;
let builder = buzz_sdk::build_delete_message(channel_uuid, target_eid)
.map_err(|e| CliError::Other(format!("build_delete_message failed: {e}")))?;
let builder = buzz_sdk::build_delete_message_with_options(
channel_uuid,
target_eid,
DeleteMessageOptions {
action_id,
reason_code,
public_reason,
},
)
.map_err(|e| CliError::Other(format!("build_delete_message failed: {e}")))?;
let event = client.sign_event(builder)?;
@@ -684,7 +698,21 @@ pub async fn dispatch(
.await
}
MessagesCmd::Edit { event, content } => cmd_edit_message(client, &event, &content).await,
MessagesCmd::Delete { event } => cmd_delete_message(client, &event).await,
MessagesCmd::Delete {
event,
action_id,
reason_code,
public_reason,
} => {
cmd_delete_message(
client,
&event,
action_id,
reason_code.as_deref(),
public_reason.as_deref(),
)
.await
}
MessagesCmd::Get {
channel,
limit,

View File

@@ -5,6 +5,7 @@ pub mod feed;
pub mod issues;
pub mod mem;
pub mod messages;
pub mod moderation;
pub mod notes;
pub mod pack;
pub mod patches;

View File

@@ -0,0 +1,165 @@
//! `buzz moderation` — community moderation queue, enforcement, and audit.
//!
//! Mutations (`ban`/`unban`/`timeout`/`untimeout`/`resolve`) are signed
//! command events (kinds 9040–9044) submitted via `POST /events`, mirroring
//! the NIP-43 relay-admin 9030-series: the relay validates, authorizes
//! (owner/admin only), and executes them directly — they are never stored.
//!
//! Reads (`reports`/`restricted`/`audit`) hit dedicated mod-only,
//! NIP-98-authed relay endpoints under `/moderation/*`, because reports and
//! audit rows are structured queue rows, not public nostr events — serving
//! them over a REQ filter would mean synthesizing fake events and threading a
//! privileged authz check into the public read path.
//!
//! The community (tenant) is selected by the relay host — moderation commands
//! carry no channel scope.
use nostr::Timestamp;
use crate::client::{normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::validate_hex64;
use crate::{ModerationCmd, OutputFormat};
/// Resolve `--expires-in <secs>` / `--expires-at <unix>` into an absolute
/// unix-seconds expiry. At most one may be set (enforced by clap).
fn resolve_expiry(expires_in: Option<u64>, expires_at: Option<u64>) -> Option<u64> {
match (expires_in, expires_at) {
(Some(secs), _) => Some(Timestamp::now().as_secs() + secs),
(None, Some(ts)) => Some(ts),
(None, None) => None,
}
}
async fn cmd_ban(
client: &BuzzClient,
pubkey: &str,
expires_in: Option<u64>,
expires_at: Option<u64>,
reason: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let expiry = resolve_expiry(expires_in, expires_at);
let builder = buzz_sdk::build_moderation_ban(pubkey, expiry, reason)
.map_err(|e| CliError::Usage(format!("invalid ban: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
async fn cmd_unban(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let builder = buzz_sdk::build_moderation_unban(pubkey)
.map_err(|e| CliError::Usage(format!("invalid unban: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
async fn cmd_timeout(
client: &BuzzClient,
pubkey: &str,
expires_in: Option<u64>,
expires_at: Option<u64>,
reason: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let expiry = resolve_expiry(expires_in, expires_at)
.ok_or_else(|| CliError::Usage("timeout requires --expires-in or --expires-at".into()))?;
let builder = buzz_sdk::build_moderation_timeout(pubkey, expiry, reason)
.map_err(|e| CliError::Usage(format!("invalid timeout: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
async fn cmd_untimeout(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let builder = buzz_sdk::build_moderation_untimeout(pubkey)
.map_err(|e| CliError::Usage(format!("invalid untimeout: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
async fn cmd_resolve(
client: &BuzzClient,
report: &str,
status: &str,
action: &str,
reason: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(report)?;
let builder = buzz_sdk::build_moderation_resolve_report(report, status, action, reason)
.map_err(|e| CliError::Usage(format!("invalid resolution: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
async fn cmd_reports(
client: &BuzzClient,
status: Option<&str>,
limit: i64,
) -> Result<(), CliError> {
let mut path = format!("/moderation/reports?limit={limit}");
if let Some(s) = status {
path.push_str(&format!("&status={s}"));
}
let resp = client.get_authed(&path).await?;
println!("{resp}");
Ok(())
}
async fn cmd_restricted(client: &BuzzClient) -> Result<(), CliError> {
let resp = client.get_authed("/moderation/restricted").await?;
println!("{resp}");
Ok(())
}
async fn cmd_audit(client: &BuzzClient, limit: i64) -> Result<(), CliError> {
let resp = client
.get_authed(&format!("/moderation/audit?limit={limit}"))
.await?;
println!("{resp}");
Ok(())
}
pub async fn dispatch(
cmd: ModerationCmd,
client: &BuzzClient,
_format: &OutputFormat,
) -> Result<(), CliError> {
match cmd {
ModerationCmd::Reports { status, limit } => {
cmd_reports(client, status.as_deref(), limit).await
}
ModerationCmd::Resolve {
report,
status,
action,
reason,
} => cmd_resolve(client, &report, &status, &action, reason.as_deref()).await,
ModerationCmd::Ban {
pubkey,
expires_in,
expires_at,
reason,
} => cmd_ban(client, &pubkey, expires_in, expires_at, reason.as_deref()).await,
ModerationCmd::Unban { pubkey } => cmd_unban(client, &pubkey).await,
ModerationCmd::Timeout {
pubkey,
expires_in,
expires_at,
reason,
} => cmd_timeout(client, &pubkey, expires_in, expires_at, reason.as_deref()).await,
ModerationCmd::Untimeout { pubkey } => cmd_untimeout(client, &pubkey).await,
ModerationCmd::Restricted => cmd_restricted(client).await,
ModerationCmd::Audit { limit } => cmd_audit(client, limit).await,
}
}

View File

@@ -7,6 +7,7 @@ use clap::{Parser, Subcommand};
use client::BuzzClient;
use error::CliError;
use nostr::Keys;
use uuid::Uuid;
/// Run the Buzz CLI from raw arguments (including `argv[0]`).
///
@@ -213,6 +214,9 @@ enum Cmd {
/// Persona pack operations (local, no relay connection needed)
#[command(subcommand)]
Pack(PackCmd),
/// Community moderation — reports queue, bans, timeouts, audit trail
#[command(subcommand)]
Moderation(ModerationCmd),
}
#[derive(Subcommand)]
@@ -294,6 +298,15 @@ pub enum MessagesCmd {
/// Event ID to delete (64-char hex)
#[arg(long)]
event: String,
/// Optional moderation audit action UUID for the public tombstone
#[arg(long)]
action_id: Option<Uuid>,
/// Optional machine-readable public reason code for the tombstone
#[arg(long)]
reason_code: Option<String>,
/// Optional human-readable public reason for the tombstone
#[arg(long)]
public_reason: Option<String>,
},
/// Retrieve messages from a channel
#[command(
@@ -1408,6 +1421,102 @@ pub enum PackCmd {
},
}
/// Community moderation commands.
///
/// The community (tenant) is selected by the relay host in `--relay` /
/// `BUZZ_RELAY_URL` — moderation commands are community-global and carry no
/// channel scope. The signing key must be a community owner/admin; the relay
/// authorizes every command.
#[derive(Subcommand)]
pub enum ModerationCmd {
/// List reports in the moderation queue (newest first)
#[command(
after_help = "Examples:\n buzz moderation reports\n buzz moderation reports --status open --limit 20"
)]
Reports {
/// Filter by status: open | resolved | dismissed | escalated (default: all)
#[arg(long)]
status: Option<String>,
/// Maximum number of reports to return
#[arg(long, default_value_t = 50)]
limit: i64,
},
/// Resolve or dismiss a report (kind 9044)
#[command(
after_help = "Examples:\n buzz moderation resolve --report <REPORT_EVENT_ID> --status dismissed --action dismiss\n buzz moderation resolve --report <REPORT_EVENT_ID> --status resolved --action ban --reason \"rule 3\""
)]
Resolve {
/// Hex event id of the kind:1984 report being resolved
#[arg(long)]
report: String,
/// Resolution status: resolved | dismissed
#[arg(long)]
status: String,
/// Action taken: delete | kick | ban | timeout | dismiss | escalate
#[arg(long)]
action: String,
/// Optional reason — relayed to the reporter, so keep it tombstone-safe
#[arg(long)]
reason: Option<String>,
},
/// Ban a member from the community (kind 9040)
#[command(
after_help = "Examples:\n buzz moderation ban --pubkey <HEX>\n buzz moderation ban --pubkey <HEX> --expires-in 604800 --reason \"repeated spam\""
)]
Ban {
/// Target member pubkey (hex)
#[arg(long)]
pubkey: String,
/// Ban duration in seconds from now (omit for a permanent ban)
#[arg(long, conflicts_with = "expires_at")]
expires_in: Option<u64>,
/// Absolute ban expiry as a unix timestamp (seconds)
#[arg(long)]
expires_at: Option<u64>,
/// Optional private ban reason (audit only)
#[arg(long)]
reason: Option<String>,
},
/// Lift a member's ban (kind 9041)
Unban {
/// Target member pubkey (hex)
#[arg(long)]
pubkey: String,
},
/// Time out a member — a write-block, not a disconnect (kind 9042)
#[command(
after_help = "Examples:\n buzz moderation timeout --pubkey <HEX> --expires-in 3600\n buzz moderation timeout --pubkey <HEX> --expires-at 1783500000 --reason \"cool off\""
)]
Timeout {
/// Target member pubkey (hex)
#[arg(long)]
pubkey: String,
/// Timeout duration in seconds from now
#[arg(long, conflicts_with = "expires_at")]
expires_in: Option<u64>,
/// Absolute timeout expiry as a unix timestamp (seconds)
#[arg(long)]
expires_at: Option<u64>,
/// Optional private timeout reason (audit only)
#[arg(long)]
reason: Option<String>,
},
/// Clear a member's timeout early (kind 9043)
Untimeout {
/// Target member pubkey (hex)
#[arg(long)]
pubkey: String,
},
/// List currently-restricted members (active ban or timeout)
Restricted,
/// Read the moderation audit trail (newest first)
Audit {
/// Maximum number of audit rows to return
#[arg(long, default_value_t = 50)]
limit: i64,
},
}
async fn run(cli: Cli) -> Result<(), CliError> {
let relay_url = client::normalize_relay_url(&cli.relay);
@@ -1463,6 +1572,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {
Cmd::Pr(sub) => commands::pr::dispatch(sub, &client).await,
Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await,
Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await,
Cmd::Moderation(sub) => commands::moderation::dispatch(sub, &client, &cli.format).await,
Cmd::Pack(_) => unreachable!("handled above"),
}
}
@@ -1489,6 +1599,7 @@ mod tests {
"issues",
"mem",
"messages",
"moderation",
"notes",
"pack",
"patches",
@@ -1620,6 +1731,19 @@ mod tests {
);
assert_eq!(names(&cmd, "upload"), vec!["file"]);
assert_eq!(names(&cmd, "pack"), vec!["inspect", "validate"]);
assert_eq!(
names(&cmd, "moderation"),
vec![
"audit",
"ban",
"reports",
"resolve",
"restricted",
"timeout",
"unban",
"untimeout"
]
);
}
#[test]