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
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:
@@ -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"] }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user