Files
buzz/crates/buzz-cli/src/commands/moderation.rs
Tyler 863aeb79f3 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>
2026-07-08 13:36:46 -04:00

166 lines
5.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `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,
}
}