feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "buzz-admin"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Operator CLI for Buzz relay administration"
[[bin]]
name = "buzz-admin"
path = "src/main.rs"
[dependencies]
buzz-db = { workspace = true }
buzz-core = { workspace = true }
buzz-auth = { workspace = true }
buzz-pubsub = { workspace = true }
buzz-search = { workspace = true }
buzz-audit = { workspace = true }
buzz-workflow = { workspace = true }
buzz-media = { workspace = true }
nostr = { workspace = true }
tokio = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
hex = { workspace = true }
deadpool-redis = { workspace = true }
# Redis TLS (rediss://, e.g. ElastiCache) uses rustls, which needs a process
# CryptoProvider installed at startup. The workspace redis TLS feature compiles
# both aws-lc-rs and ring in transitively, so rustls can't auto-select one — we
# install ring explicitly in main(). Mirrors buzz-relay's setup.
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
tracing = { workspace = true }
sqlx = { workspace = true }
url = { workspace = true }
clap = { version = "4", features = ["derive", "env"] }
+331
View File
@@ -0,0 +1,331 @@
//! buzz-admin 的人类可读界面语言选择。
use std::ffi::OsString;
use std::sync::{Mutex, OnceLock};
use clap::ValueEnum;
/// 命令行人类可读文本的语言选项。
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum Language {
#[value(name = "auto")]
Auto,
#[value(name = "en", alias = "en-us", alias = "en-gb")]
English,
#[value(name = "zh", alias = "zh-cn", alias = "zh-hans")]
SimplifiedChinese,
#[value(name = "zh-tw", alias = "zh-hant")]
TraditionalChinese,
}
impl Default for Language {
fn default() -> Self {
Self::Auto
}
}
/// 已解析的命令行界面 locale。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Locale {
En,
ZhHans,
ZhHant,
}
impl Locale {
/// 返回该 locale 是否为中文变体。
pub const fn is_chinese(self) -> bool {
matches!(self, Self::ZhHans | Self::ZhHant)
}
}
impl Language {
/// 宽松解析环境变量或命令行中的语言值。
pub fn parse_lossy(value: &str) -> Self {
match value.trim().to_ascii_lowercase().replace('_', "-").as_str() {
"zh" | "zh-cn" | "zh-hans" | "zh-sg" => Self::SimplifiedChinese,
"zh-tw" | "zh-hant" | "zh-hk" | "zh-mo" => Self::TraditionalChinese,
"en" | "en-us" | "en-gb" | "en-au" | "en-ca" => Self::English,
"auto" | "" => Self::Auto,
_ => Self::Auto,
}
}
/// 从 `BUZZ_LANGUAGE` 读取语言;未设置时使用自动检测。
pub fn from_environment() -> Self {
std::env::var("BUZZ_LANGUAGE")
.ok()
.map_or(Self::Auto, |value| Self::parse_lossy(&value))
}
/// 将语言选项解析为实际使用的 locale。
pub fn resolve(self) -> Locale {
match self {
Self::English => Locale::En,
Self::SimplifiedChinese => Locale::ZhHans,
Self::TraditionalChinese => Locale::ZhHant,
Self::Auto => detect_system_locale(),
}
}
}
/// 根据原始参数预扫描语言选项,以便在 clap 展示帮助前应用本地化。
pub fn locale_for_args(args: &[OsString]) -> Locale {
let mut language = Language::from_environment();
let mut expect_value = false;
for arg in args.iter().skip(1) {
let value = arg.to_string_lossy();
if expect_value {
language = Language::parse_lossy(&value);
expect_value = false;
} else if value == "--language" {
expect_value = true;
} else if let Some(value) = value.strip_prefix("--language=") {
language = Language::parse_lossy(value);
}
}
language.resolve()
}
fn detect_system_locale() -> Locale {
for key in ["LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"] {
let Ok(value) = std::env::var(key) else {
continue;
};
let normalized = value.trim().to_ascii_lowercase().replace('_', "-");
if normalized.starts_with("zh-tw")
|| normalized.starts_with("zh-hant")
|| normalized.starts_with("zh-hk")
|| normalized.starts_with("zh-mo")
{
return Locale::ZhHant;
}
if normalized.starts_with("zh") {
return Locale::ZhHans;
}
if !normalized.is_empty() && normalized != "c" && normalized != "posix" {
return Locale::En;
}
}
Locale::En
}
static CURRENT_LOCALE: OnceLock<Mutex<Locale>> = OnceLock::new();
fn locale_cell() -> &'static Mutex<Locale> {
CURRENT_LOCALE.get_or_init(|| Mutex::new(Locale::En))
}
/// 设置当前进程后续人类可读输出使用的 locale。
pub fn set_current(locale: Locale) {
if let Ok(mut current) = locale_cell().lock() {
*current = locale;
}
}
/// 返回当前进程的人类可读输出 locale。
pub fn current() -> Locale {
locale_cell()
.lock()
.map(|guard| *guard)
.unwrap_or(Locale::En)
}
/// 将高频命令说明替换为所选中文 locale 的文案。
pub fn localize_command(command: &mut clap::Command, locale: Locale) {
if !locale.is_chinese() {
return;
}
let name = command.get_name().to_owned();
if let Some(about) = about_for(&name, locale) {
*command = command.clone().about(about);
}
*command = command.clone().mut_args(|arg| {
let id = arg.get_id().as_str();
argument_help(&name, id, locale).map_or(arg.clone(), |help| arg.help(help))
});
for subcommand in command.get_subcommands_mut() {
localize_command(subcommand, locale);
}
}
/// Translate the visible clap prefix while preserving flag names, values and
/// usage text. clap's parser still validates the original English ids and
/// enum values; only the human-facing `error:` marker changes.
pub fn localize_clap_rendered(rendered: &str, locale: Locale) -> String {
if !locale.is_chinese() {
return rendered.to_owned();
}
if let Some(rest) = rendered.strip_prefix("error: ") {
format!("{}: {rest}", error_prefix(locale, "usage"))
} else {
rendered.to_owned()
}
}
fn argument_help(command: &str, id: &str, locale: Locale) -> Option<&'static str> {
match locale {
Locale::ZhHans => Some(match (command, id) {
("buzz-admin", "language") => "人类可读界面语言;auto 遵循 BUZZ_LANGUAGE/LANG。",
("add-member", "pubkey") | ("remove-member", "pubkey") => {
"Nostr 公钥(bech32 npub 或 64 位十六进制)。"
}
("add-member", "role") => {
"角色:\"admin\"\"member\"(默认:member)。不能设为 \"owner\";请使用 RELAY_OWNER_PUBKEY 配置。"
}
("remove-member", "role") => "仅当成员当前角色匹配此值时才移除;省略则忽略角色。",
("list", "limit") => "最多返回的记录数。",
("reconcile-channels", "relay_key") => {
"用于签名事件的中继私钥(hex)。回退到 BUZZ_RELAY_PRIVATE_KEY;都未设置时生成临时密钥。"
}
_ => return None,
}),
Locale::ZhHant => Some(match (command, id) {
("buzz-admin", "language") => "人類可讀介面語言;auto 遵循 BUZZ_LANGUAGE/LANG。",
("add-member", "pubkey") | ("remove-member", "pubkey") => {
"Nostr 公鑰(bech32 npub 或 64 位十六進位)。"
}
("add-member", "role") => {
"角色:\"admin\"\"member\"(預設:member)。不能設為 \"owner\";請使用 RELAY_OWNER_PUBKEY 設定。"
}
("remove-member", "role") => "僅在成員目前角色符合此值時移除;省略則忽略角色。",
("list", "limit") => "最多回傳的記錄數。",
("reconcile-channels", "relay_key") => {
"用於簽署事件的中繼私鑰(hex)。回退到 BUZZ_RELAY_PRIVATE_KEY;兩者皆未設定時產生暫時金鑰。"
}
_ => return None,
}),
Locale::En => None,
}
}
/// Stable error prefix used by runtime validation and anyhow failures.
pub fn error_prefix(locale: Locale, category: &str) -> &'static str {
match (locale, category) {
(Locale::ZhHans, "usage") => "用法错误",
(Locale::ZhHant, "usage") => "用法錯誤",
(Locale::ZhHans, "error") => "错误",
(Locale::ZhHant, "error") => "錯誤",
(Locale::ZhHans, "warning") => "警告",
(Locale::ZhHant, "warning") => "警告",
(_, _) => "error",
}
}
fn about_for(name: &str, locale: Locale) -> Option<&'static str> {
match locale {
Locale::ZhHans => Some(match name {
"buzz-admin" => "Buzz 实例管理命令行工具",
"add-member" => "将公钥加入中继成员列表",
"remove-member" => "从中继成员列表移除公钥",
"list-members" => "列出所有中继成员",
"generate-key" => "生成新的 Nostr 密钥对",
"migrate" => "运行待处理的数据库迁移",
"product-feedback" => "查看跨社区的 Buzz 产品反馈",
"reconcile-channels" => "为缺少发现事件的频道补发事件",
"list" => "以 JSON 列出产品反馈",
_ => return None,
}),
Locale::ZhHant => Some(match name {
"buzz-admin" => "Buzz 執行個體管理命令列工具",
"add-member" => "將公鑰加入中繼成員清單",
"remove-member" => "從中繼成員清單移除公鑰",
"list-members" => "列出所有中繼成員",
"generate-key" => "產生新的 Nostr 金鑰組",
"migrate" => "執行待處理的資料庫遷移",
"product-feedback" => "查看跨社群的 Buzz 產品回饋",
"reconcile-channels" => "為缺少探索事件的頻道補發事件",
"list" => "以 JSON 列出產品回饋",
_ => return None,
}),
Locale::En => None,
}
}
/// 返回一个稳定的人类可读标签;未知键原样返回以保持兼容。
pub fn label<'a>(locale: Locale, key: &'a str) -> &'a str {
if !locale.is_chinese() {
return match key {
"public_key" => "Public key",
"secret_key" => "Secret key",
"set_private_key" => "Set BUZZ_PRIVATE_KEY to the secret key to use this identity.",
"migrations_complete" => "Database migrations complete.",
"added" => "added",
"already_member" => "already a member",
"removed" => "removed",
"no_members" => "(no relay members)",
"pubkey" => "pubkey",
"role" => "role",
"added_by" => "added_by",
"warning" => "warning",
"no_channels" => "No channels in database.",
"reconciled" => "Reconciled",
_ => key,
};
}
match (locale, key) {
(Locale::ZhHans, "public_key") => "公钥",
(Locale::ZhHant, "public_key") => "公鑰",
(Locale::ZhHans, "secret_key") => "私钥",
(Locale::ZhHant, "secret_key") => "私鑰",
(Locale::ZhHans, "set_private_key") => "请设置 BUZZ_PRIVATE_KEY 为该私钥以使用此身份。",
(Locale::ZhHant, "set_private_key") => "請將 BUZZ_PRIVATE_KEY 設為此私鑰以使用此身分。",
(Locale::ZhHans, "migrations_complete") => "数据库迁移已完成。",
(Locale::ZhHant, "migrations_complete") => "資料庫遷移已完成。",
(Locale::ZhHans, "added") => "已添加",
(Locale::ZhHant, "added") => "已新增",
(Locale::ZhHans, "already_member") => "已经是成员",
(Locale::ZhHant, "already_member") => "已是成員",
(Locale::ZhHans, "removed") => "已移除",
(Locale::ZhHant, "removed") => "已移除",
(Locale::ZhHans, "no_members") => "(没有中继成员)",
(Locale::ZhHant, "no_members") => "(沒有中繼成員)",
(Locale::ZhHans, "pubkey") => "公钥",
(Locale::ZhHant, "pubkey") => "公鑰",
(Locale::ZhHans, "role") => "角色",
(Locale::ZhHant, "role") => "角色",
(Locale::ZhHans, "added_by") => "添加者",
(Locale::ZhHant, "added_by") => "新增者",
(Locale::ZhHans, "warning") => "警告",
(Locale::ZhHant, "warning") => "警告",
(Locale::ZhHans, "no_channels") => "数据库中没有频道。",
(Locale::ZhHant, "no_channels") => "資料庫中沒有頻道。",
(Locale::ZhHans, "reconciled") => "已核对",
(Locale::ZhHant, "reconciled") => "已核對",
(_, _) => key,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_language_resolves() {
assert_eq!(Language::SimplifiedChinese.resolve(), Locale::ZhHans);
assert_eq!(Language::TraditionalChinese.resolve(), Locale::ZhHant);
assert_eq!(Language::English.resolve(), Locale::En);
}
#[test]
fn pre_scan_supports_equals_and_separate_values() {
assert_eq!(
locale_for_args(&["buzz".into(), "--language".into(), "zh".into()]),
Locale::ZhHans
);
assert_eq!(
locale_for_args(&["buzz".into(), "--language=zh-hant".into()]),
Locale::ZhHant
);
}
#[test]
fn clap_prefix_localization_preserves_parser_detail() {
let rendered = "error: invalid value 'owner' for '--role <ROLE>'\n";
assert_eq!(
localize_clap_rendered(rendered, Locale::ZhHans),
"用法错误: invalid value 'owner' for '--role <ROLE>'\n"
);
assert_eq!(localize_clap_rendered(rendered, Locale::En), rendered);
}
}
+783
View File
@@ -0,0 +1,783 @@
#![deny(unsafe_code)]
//! Buzz instance administration CLI.
//!
//! # Member management (NIP-43)
//!
//! ## Why only kind:13534 (membership list), not kind:8000/8001 (deltas)
//!
//! CLI intentionally does not emit kind 8000/8001 deltas —
//! `publish_nip43_delta` is in-process-only (no Redis hop), so a sidecar call
//! stores but never pushes. The 13534 list snapshot is the authoritative roster
//! and rides Redis to live clients. Do not wire a delta call that passes
//! in-process tests and silently no-ops in the deployed `compose exec` path.
//!
//! ## Same-second domination guard
//!
//! The `custom_created_at = max(now, newest_existing_13534 + 1s)` bump defeats
//! same-second domination for serial invocations; it does NOT serialize
//! concurrent CLI processes — two near-simultaneous adds can read the same
//! newest timestamp and collide on the bumped second. run.sh serialization is
//! the guard against parallel adds (e.g. `xargs -P`).
use std::sync::Arc;
use anyhow::Result;
use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST;
use buzz_core::tenant::{relay_url_authority, TenantContext};
use buzz_db::{Db, DbConfig};
use buzz_pubsub::{EventTopic, PubSubManager};
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
use nostr::{EventBuilder, Keys, Kind, Tag};
use tracing::warn;
mod i18n;
#[derive(Parser)]
#[command(name = "buzz-admin", about = "Buzz instance administration")]
struct Cli {
/// Human-readable interface language; `auto` follows BUZZ_LANGUAGE/LANG.
#[arg(
long,
env = "BUZZ_LANGUAGE",
value_enum,
global = true,
default_value = "auto"
)]
language: i18n::Language,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Add a pubkey to the relay membership list.
///
/// Accepts a bech32 npub or 64-char hex pubkey. After inserting the DB row,
/// publishes a kind:13534 membership roster via Redis so live clients see
/// the updated list immediately.
AddMember {
/// Nostr public key — bech32 npub or 64-char hex.
#[arg(long)]
pubkey: String,
/// Role: "admin" or "member" (default: member). Cannot be "owner" —
/// use RELAY_OWNER_PUBKEY config to set the relay owner.
#[arg(long, default_value = "member")]
role: String,
},
/// Remove a pubkey from the relay membership list.
///
/// Accepts a bech32 npub or 64-char hex pubkey. After removing the DB row,
/// publishes a kind:13534 membership roster via Redis. Cannot remove the
/// relay owner — change RELAY_OWNER_PUBKEY config instead.
RemoveMember {
/// Nostr public key — bech32 npub or 64-char hex.
#[arg(long)]
pubkey: String,
/// Only remove if the member's current role matches this value.
/// Omit to remove regardless of role.
#[arg(long)]
role: Option<String>,
},
/// List all relay members.
ListMembers,
/// Generate a new Nostr keypair (for bootstrapping).
GenerateKey,
/// Run pending database migrations.
Migrate,
/// Inspect deployment-wide Buzz product feedback.
ProductFeedback {
#[command(subcommand)]
command: ProductFeedbackCommand,
},
/// Emit kind:39000/39002 events for channels missing them.
///
/// Channels created via direct SQL (seed scripts, pre-migration data) won't
/// have Nostr discovery events. This command creates them so pure-nostr
/// clients can see those channels. Idempotent — safe to run multiple times.
ReconcileChannels {
/// Relay private key (hex) for signing events. Falls back to
/// BUZZ_RELAY_PRIVATE_KEY env var. If neither is set, generates
/// an ephemeral key (events will be unverifiable after restart).
#[arg(long)]
relay_key: Option<String>,
},
}
#[derive(Subcommand)]
enum ProductFeedbackCommand {
/// List feedback across every community as JSON.
List {
/// Maximum records to return.
#[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u16).range(1..=1000))]
limit: u16,
},
}
#[tokio::main]
async fn main() {
// Install the ring CryptoProvider for rustls. The workspace redis TLS
// feature compiles both aws-lc-rs and ring in transitively, so rustls can't
// auto-select a provider and would panic on the first rediss:// (ElastiCache)
// Redis TLS connection without this. Mirrors buzz-relay's main().
rustls::crypto::ring::default_provider()
.install_default()
.expect("failed to install rustls crypto provider");
let args: Vec<std::ffi::OsString> = std::env::args_os().collect();
let locale = i18n::locale_for_args(&args);
i18n::set_current(locale);
let cli = match parse_cli(args, locale) {
Ok(cli) => cli,
Err(error) => {
let code = error.exit_code();
// Render first so the visible `error:` marker can follow the
// selected locale. Help/version output has no error marker and
// is therefore byte-for-byte unchanged apart from translated
// command metadata.
let rendered = i18n::localize_clap_rendered(&error.render().to_string(), locale);
if error.use_stderr() {
eprint!("{rendered}");
} else {
print!("{rendered}");
}
std::process::exit(code);
}
};
let code = match run(cli).await {
Ok(code) => code,
Err(e) => {
eprintln!("{}: {e}", i18n::error_prefix(locale, "error"));
5
}
};
std::process::exit(code);
}
fn parse_cli(args: Vec<std::ffi::OsString>, locale: i18n::Locale) -> Result<Cli, clap::Error> {
let mut command = Cli::command();
i18n::localize_command(&mut command, locale);
let matches = command.try_get_matches_from(args)?;
Cli::from_arg_matches(&matches)
}
async fn run(cli: Cli) -> Result<i32> {
i18n::set_current(cli.language.resolve());
let locale = i18n::current();
match cli.command {
Command::GenerateKey => {
let keys = Keys::generate();
println!(
"{}: {}",
i18n::label(locale, "public_key"),
keys.public_key().to_hex()
);
println!(
"{}: {}",
i18n::label(locale, "secret_key"),
keys.secret_key().display_secret()
);
if locale.is_chinese() {
println!("\n{}", i18n::label(locale, "set_private_key"));
} else {
println!("\nSet BUZZ_PRIVATE_KEY to the secret key to use this identity.");
}
Ok(0)
}
Command::Migrate => {
let db = connect_db().await?;
db.migrate().await?;
println!("{}", i18n::label(locale, "migrations_complete"));
Ok(0)
}
Command::AddMember { pubkey, role } => cmd_add_member(pubkey, role).await,
Command::RemoveMember { pubkey, role } => cmd_remove_member(pubkey, role).await,
Command::ListMembers => cmd_list_members().await,
Command::ProductFeedback {
command: ProductFeedbackCommand::List { limit },
} => cmd_list_product_feedback(limit).await,
Command::ReconcileChannels { relay_key } => {
reconcile_channels(relay_key).await?;
Ok(0)
}
}
}
async fn cmd_add_member(pubkey_arg: String, role: String) -> Result<i32> {
let locale = i18n::current();
if let Err(msg) = validate_role(&role, locale) {
eprintln!("{}: {msg}", i18n::error_prefix(locale, "error"));
return Ok(1);
}
let pubkey_hex = match parse_pubkey_hex(&pubkey_arg, locale) {
Ok(h) => h,
Err(msg) => {
eprintln!("{}: {msg}", i18n::error_prefix(locale, "error"));
return Ok(1);
}
};
let (db, pubsub, relay_keypair) = connect_member_services().await?;
let tenant = resolve_admin_tenant(&db).await?;
match db
.add_relay_member(tenant.community(), &pubkey_hex, &role, None)
.await
{
Ok(true) => {
if locale.is_chinese() {
println!(
"{} {pubkey_hex},角色:{role}",
i18n::label(locale, "added")
);
} else {
println!("added {pubkey_hex} as {role}");
}
}
Ok(false) => {
if locale.is_chinese() {
println!(
"{}{pubkey_hex}(未更改)",
i18n::label(locale, "already_member")
);
} else {
println!("already a member: {pubkey_hex} (no change)");
}
}
Err(e) => {
eprintln!(
"{}: {}",
i18n::error_prefix(locale, "error"),
if locale.is_chinese() {
format!("数据库写入失败:{e}")
} else {
format!("DB write failed: {e}")
}
);
return Ok(5);
}
}
if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await {
if locale.is_chinese() {
eprintln!("警告:成员已写入数据库,但发布成员列表失败:{e}");
} else {
eprintln!("warning: member added to DB but list publish failed: {e}");
}
}
Ok(0)
}
async fn cmd_remove_member(pubkey_arg: String, role_filter: Option<String>) -> Result<i32> {
let locale = i18n::current();
if let Some(ref role) = role_filter {
if let Err(msg) = validate_role(role, locale) {
eprintln!("{}: {msg}", i18n::error_prefix(locale, "error"));
return Ok(1);
}
}
let pubkey_hex = match parse_pubkey_hex(&pubkey_arg, locale) {
Ok(h) => h,
Err(msg) => {
eprintln!("{}: {msg}", i18n::error_prefix(locale, "error"));
return Ok(1);
}
};
let (db, pubsub, relay_keypair) = connect_member_services().await?;
let tenant = resolve_admin_tenant(&db).await?;
use buzz_db::relay_members::RemoveResult;
let result = if let Some(ref role) = role_filter {
db.remove_relay_member_if_role(tenant.community(), &pubkey_hex, role)
.await
} else {
db.remove_relay_member(tenant.community(), &pubkey_hex)
.await
};
match result {
Ok(RemoveResult::Removed) => {
if locale.is_chinese() {
println!("{} {pubkey_hex}", i18n::label(locale, "removed"));
} else {
println!("removed {pubkey_hex}");
}
}
Ok(RemoveResult::NotFound) => {
if locale.is_chinese() {
eprintln!("错误:找不到成员:{pubkey_hex}");
} else {
eprintln!("error: member not found: {pubkey_hex}");
}
return Ok(2);
}
Ok(RemoveResult::IsOwner) => {
if locale.is_chinese() {
eprintln!(
"错误:无法移除中继所有者:{pubkey_hex}\n请修改 RELAY_OWNER_PUBKEY 后重启。"
);
} else {
eprintln!(
"error: cannot remove relay owner: {pubkey_hex}\n\
To change the owner, update RELAY_OWNER_PUBKEY and restart."
);
}
return Ok(3);
}
Ok(RemoveResult::RoleMismatch) => {
let role_str = role_filter.as_deref().unwrap_or("(unknown)");
if locale.is_chinese() {
eprintln!("错误:角色不匹配——{pubkey_hex} 当前不是“{role_str}");
} else {
eprintln!("error: role mismatch — {pubkey_hex} is not currently '{role_str}'");
}
return Ok(4);
}
Err(e) => {
if locale.is_chinese() {
eprintln!("错误:数据库写入失败:{e}");
} else {
eprintln!("error: DB write failed: {e}");
}
return Ok(5);
}
}
if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await {
if locale.is_chinese() {
eprintln!("警告:成员已从数据库移除,但发布成员列表失败:{e}");
} else {
eprintln!("warning: member removed from DB but list publish failed: {e}");
}
}
Ok(0)
}
async fn cmd_list_product_feedback(limit: u16) -> Result<i32> {
let db = connect_db().await?;
let feedback = db.list_product_feedback(i64::from(limit)).await?;
println!("{}", serde_json::to_string_pretty(&feedback)?);
Ok(0)
}
async fn cmd_list_members() -> Result<i32> {
let locale = i18n::current();
let db = connect_db().await?;
let tenant = resolve_admin_tenant(&db).await?;
let members = db.list_relay_members(tenant.community()).await?;
if members.is_empty() {
println!("{}", i18n::label(locale, "no_members"));
return Ok(0);
}
println!(
"{:<66} {:<8} {:<66} created_at",
i18n::label(locale, "pubkey"),
i18n::label(locale, "role"),
i18n::label(locale, "added_by")
);
println!("{}", "-".repeat(160));
for m in &members {
let added_by = m.added_by.as_deref().unwrap_or("-");
println!(
"{:<66} {:<8} {:<66} {}",
m.pubkey,
m.role,
added_by,
m.created_at.format("%Y-%m-%dT%H:%M:%SZ")
);
}
Ok(0)
}
/// Validate that `role` is `"member"` or `"admin"`. Rejects `"owner"`.
/// Only the explanatory prose is localized; the role values remain wire
/// literals because they are persisted in the membership table and event.
fn validate_role(role: &str, locale: i18n::Locale) -> std::result::Result<(), String> {
match role {
"member" | "admin" => Ok(()),
"owner" if matches!(locale, i18n::Locale::ZhHans) => {
Err("角色 'owner' 不能通过 CLI 设置,请使用 RELAY_OWNER_PUBKEY 配置".to_string())
}
"owner" if matches!(locale, i18n::Locale::ZhHant) => {
Err("角色 'owner' 不能透过 CLI 设置,请使用 RELAY_OWNER_PUBKEY 配置".to_string())
}
"owner" => {
Err("role 'owner' cannot be set via CLI — use RELAY_OWNER_PUBKEY config".to_string())
}
other if matches!(locale, i18n::Locale::ZhHans) => {
Err(format!("无效角色 '{other}':必须是 'member' 或 'admin'"))
}
other if matches!(locale, i18n::Locale::ZhHant) => {
Err(format!("无效角色 '{other}':必须是 'member' 或 'admin'"))
}
other => Err(format!(
"invalid role '{other}': must be 'member' or 'admin'"
)),
}
}
/// Parse a bech32 npub or 64-char hex pubkey into lowercase hex.
fn parse_pubkey_hex(input: &str, locale: i18n::Locale) -> std::result::Result<String, String> {
nostr::PublicKey::parse(input)
.map(|pk| pk.to_hex())
.map_err(|e| match locale {
i18n::Locale::ZhHans => format!("无效公钥 '{input}'{e}"),
i18n::Locale::ZhHant => format!("无效公钥 '{input}'{e}"),
i18n::Locale::En => format!("invalid pubkey '{input}': {e}"),
})
}
/// Publish kind:13534 with `custom_created_at = max(now, newest_existing + 1s)`.
///
/// Guarantees the new event is not dominated by a same-second prior invocation,
/// so `replace_addressable_event` always inserts and dispatches to Redis.
///
/// See module-level doc for the TOCTOU caveat on concurrent CLI processes.
async fn publish_membership_list_with_bump(
db: &Db,
pubsub: &Arc<PubSubManager>,
relay_keypair: &Keys,
tenant: &TenantContext,
) -> Result<()> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let relay_pubkey = relay_keypair.public_key();
let relay_pubkey_bytes = relay_pubkey.to_bytes();
// Query the newest existing kind:13534 for this relay's pubkey (channel_id=None).
let newest_ts = db
.get_latest_global_replaceable(
tenant.community(),
KIND_NIP43_MEMBERSHIP_LIST as i32,
&relay_pubkey_bytes,
)
.await?
.map(|e| e.event.created_at.as_secs());
// custom_created_at = max(now, existing + 1s) — defeats same-second domination.
let ts = match newest_ts {
Some(existing) => (existing + 1).max(now),
None => now,
};
let members = db.list_relay_members(tenant.community()).await?;
let mut tags: Vec<Tag> = Vec::with_capacity(members.len() + 1);
// NIP-70 protected-event marker — prevents re-broadcasting by third parties.
tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?);
for member in &members {
tags.push(
Tag::parse(["member", &member.pubkey, &member.role])
.map_err(|e| anyhow::anyhow!("failed to build member tag: {e}"))?,
);
}
let event = EventBuilder::new(Kind::Custom(KIND_NIP43_MEMBERSHIP_LIST as u16), "")
.tags(tags)
.custom_created_at(nostr::Timestamp::from(ts))
.sign_with_keys(relay_keypair)
.map_err(|e| anyhow::anyhow!("failed to sign kind:13534: {e}"))?;
let (stored, was_inserted) = db
.replace_addressable_event(tenant.community(), &event, None)
.await?;
if was_inserted {
// Publish to Redis so live clients receive the updated roster.
// Community-global scope (EventTopic::Global) matches the relay's own
// membership-list publish path; the tenant fixes the community.
if let Err(e) = pubsub
.publish_event(tenant, EventTopic::Global, &stored.event)
.await
{
warn!("Redis publish of kind:13534 failed: {e}");
}
}
tracing::info!(
member_count = members.len(),
ts,
"NIP-43 membership list published by buzz-admin"
);
Ok(())
}
/// Connect to DB, Redis pub/sub, and load the relay keypair.
///
/// `BUZZ_RELAY_PRIVATE_KEY` is required — the CLI signs kind:13534 events.
async fn connect_member_services() -> Result<(Db, Arc<PubSubManager>, Keys)> {
let db = connect_db().await?;
let relay_keypair = {
let hex = std::env::var("BUZZ_RELAY_PRIVATE_KEY").map_err(|_| {
anyhow::anyhow!(
"BUZZ_RELAY_PRIVATE_KEY is required for add-member/remove-member.\n\
The relay must have a stable signing key to publish kind:13534 events."
)
})?;
Keys::parse(&hex).map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}"))?
};
let redis_url =
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string());
let redis_pool = {
let cfg = deadpool_redis::Config::from_url(&redis_url);
cfg.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.map_err(|e| anyhow::anyhow!("Redis pool creation failed: {e}"))?
};
let pubsub = Arc::new(
PubSubManager::new(&redis_url, redis_pool)
.await
.map_err(|e| anyhow::anyhow!("PubSub init failed: {e}"))?,
);
Ok((db, pubsub, relay_keypair))
}
async fn connect_db() -> Result<Db> {
let db_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string());
let db = Db::new(&DbConfig {
database_url: db_url,
..DbConfig::default()
})
.await?;
Ok(db)
}
/// Resolve the deployment's tenant from the configured `RELAY_URL` host.
///
/// `buzz-admin` runs inside the relay container (`compose exec relay
/// buzz-admin …`), so it shares the relay's `RELAY_URL` and resolves the same
/// single community against the durable `communities` host map. This is
/// deliberately NOT a default tenant: an unmapped host fails closed with an
/// error, mirroring the relay's own `bind_community` row-zero seam. The CLI is
/// single-community per invocation — there is no cross-community sweep.
async fn resolve_admin_tenant(db: &Db) -> Result<TenantContext> {
let relay_url =
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());
// Derive the authority the *same* way startup seeding and live request
// resolution do (`buzz_core::tenant::relay_url_authority`): host plus an
// explicit non-default port, IPv6 brackets preserved. A plain
// `Url::host_str()` drops the port/brackets, so for `ws://localhost:3000`
// the admin would look up `localhost` while startup seeded `localhost:3000`
// — and `wss://relay.example:8443` would resolve `relay.example`. Sharing
// the helper keeps buzz-admin byte-identical to the community startup seeds.
let host = relay_url_authority(&relay_url);
let record = db.lookup_community_by_host(&host).await?.ok_or_else(|| {
anyhow::anyhow!(
"RELAY_URL host '{host}' is not mapped to a community.\n\
buzz-admin operates on the configured relay's community; ensure the \
relay has started and seeded its community (or set RELAY_URL to a \
mapped host)."
)
})?;
Ok(TenantContext::resolved(record.id, record.host))
}
async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
use buzz_core::kind::KIND_NIP29_GROUP_ADMINS;
use buzz_db::event::EventQuery;
let db = connect_db().await?;
// Resolve relay signing key: arg > env > ephemeral
let locale = i18n::current();
let relay_keys = match relay_key_arg.or_else(|| std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok()) {
Some(key_hex) => {
Keys::parse(&key_hex).map_err(|e| anyhow::anyhow!("invalid relay key: {e}"))?
}
None => {
let k = Keys::generate();
if locale.is_chinese() {
eprintln!(
"警告:未提供中继密钥,将使用临时密钥 {}",
k.public_key().to_hex()
);
eprintln!("本次运行结束后,用此密钥签名的事件将无法验证。");
eprintln!("生产环境请传入 --relay-key 或设置 BUZZ_RELAY_PRIVATE_KEY。");
} else {
eprintln!(
"Warning: no relay key provided — using ephemeral key {}",
k.public_key().to_hex()
);
eprintln!("Events signed with this key won't be verifiable after this run.");
eprintln!("Pass --relay-key or set BUZZ_RELAY_PRIVATE_KEY for production use.");
}
k
}
};
let tenant = resolve_admin_tenant(&db).await?;
let channels = db.list_channels(tenant.community(), None).await?;
if channels.is_empty() {
println!("{}", i18n::label(locale, "no_channels"));
return Ok(());
}
let mut reconciled = 0u32;
let mut skipped = 0u32;
for channel in &channels {
let channel_id_str = channel.id.to_string();
// Check if kind:39000 already exists
let existing = db
.query_events(&EventQuery {
kinds: Some(vec![39000]),
d_tag: Some(channel_id_str.clone()),
limit: Some(1),
..EventQuery::for_community(tenant.community())
})
.await
.unwrap_or_default();
if !existing.is_empty() {
skipped += 1;
continue;
}
let members = db.get_members(tenant.community(), channel.id).await?;
// kind:39000 — channel metadata
{
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
tags.push(Tag::parse(["name", &channel.name])?);
if let Some(ref desc) = channel.description {
if !desc.is_empty() {
tags.push(Tag::parse(["about", desc])?);
}
}
if channel.visibility == "private" {
tags.push(Tag::parse(["private"])?);
} else {
tags.push(Tag::parse(["public"])?);
}
if channel.channel_type == "dm" {
tags.push(Tag::parse(["hidden"])?);
}
tags.push(Tag::parse(["closed"])?);
tags.push(Tag::parse(["t", &channel.channel_type])?);
let event = EventBuilder::new(Kind::Custom(39000), "")
.tags(tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?;
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
.await?;
}
// kind:39001 — admins
{
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
for m in members
.iter()
.filter(|m| m.role == "owner" || m.role == "admin")
{
let pk = hex::encode(&m.pubkey);
tags.push(Tag::parse(["p", &pk, &m.role])?);
}
let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_ADMINS as u16), "")
.tags(tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?;
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
.await?;
}
// kind:39002 — members
{
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
for m in &members {
let pk = hex::encode(&m.pubkey);
tags.push(Tag::parse(["p", &pk, "", &m.role])?);
}
let event = EventBuilder::new(Kind::Custom(39002), "")
.tags(tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?;
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
.await?;
}
reconciled += 1;
}
if locale.is_chinese() {
println!(
"{} {reconciled} 个频道({skipped} 个已有事件,共 {} 个)。",
i18n::label(locale, "reconciled"),
channels.len()
);
} else {
println!(
"Reconciled {reconciled} channels ({skipped} already had events, {} total).",
channels.len()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn member_validation_localizes_prose_but_preserves_role_literals() {
assert!(validate_role("member", i18n::Locale::ZhHans).is_ok());
assert!(validate_role("admin", i18n::Locale::ZhHans).is_ok());
let owner = validate_role("owner", i18n::Locale::ZhHans).unwrap_err();
assert!(owner.contains("不能通过 CLI 设置"));
assert!(owner.contains("'owner'"));
assert!(owner.contains("RELAY_OWNER_PUBKEY"));
let invalid = validate_role("guest", i18n::Locale::ZhHans).unwrap_err();
assert!(invalid.contains("无效角色 'guest'"));
assert!(invalid.contains("'member'"));
assert!(invalid.contains("'admin'"));
}
#[test]
fn pubkey_validation_preserves_the_rejected_input() {
let error = parse_pubkey_hex("not-a-pubkey", i18n::Locale::ZhHans).unwrap_err();
assert!(error.starts_with("无效公钥 'not-a-pubkey'"));
}
#[test]
fn localized_admin_help_keeps_flags_and_role_values_stable() {
let mut command = Cli::command();
i18n::localize_command(&mut command, i18n::Locale::ZhHans);
let add_member = command
.get_subcommands()
.find(|subcommand| subcommand.get_name() == "add-member")
.expect("add-member command");
let role_help = add_member
.get_arguments()
.find(|arg| arg.get_id().as_str() == "role")
.and_then(|arg| arg.get_help())
.map(ToString::to_string)
.expect("role help");
assert!(role_help.contains("'owner'") || role_help.contains("\"owner\""));
assert!(role_help.contains("RELAY_OWNER_PUBKEY"));
assert!(add_member
.get_arguments()
.any(|arg| arg.get_long() == Some("role")));
}
}