mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
165
crates/buzz-cli/src/commands/moderation.rs
Normal file
165
crates/buzz-cli/src/commands/moderation.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user