9dfa06ffee
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>
391 lines
14 KiB
Rust
391 lines
14 KiB
Rust
use std::io::Read;
|
||
|
||
use crate::client::{normalize_write_response, BuzzClient};
|
||
use crate::error::CliError;
|
||
use crate::i18n;
|
||
use buzz_sdk::CustomEmoji;
|
||
|
||
/// d-tag for a member's own custom emoji set (kind:30030). Mirrors the SDK
|
||
/// constant; the workspace palette is the union of every member's own set.
|
||
const CUSTOM_EMOJI_SET_D_TAG: &str = buzz_sdk::CUSTOM_EMOJI_SET_D_TAG;
|
||
|
||
/// Custom emoji entry in CLI output.
|
||
#[derive(Debug, serde::Serialize)]
|
||
struct EmojiEntry {
|
||
shortcode: String,
|
||
url: String,
|
||
}
|
||
|
||
/// Parse `["emoji", shortcode, url]` tags from one event into entries.
|
||
fn emoji_tags_of(event: &serde_json::Value) -> Vec<EmojiEntry> {
|
||
let Some(tags) = event.get("tags").and_then(|v| v.as_array()) else {
|
||
return vec![];
|
||
};
|
||
let mut out = Vec::new();
|
||
for tag in tags {
|
||
let Some(parts) = tag.as_array() else {
|
||
continue;
|
||
};
|
||
if parts.first().and_then(|v| v.as_str()) != Some("emoji") {
|
||
continue;
|
||
}
|
||
let (Some(shortcode), Some(url)) = (
|
||
parts.get(1).and_then(|v| v.as_str()),
|
||
parts.get(2).and_then(|v| v.as_str()),
|
||
) else {
|
||
continue;
|
||
};
|
||
out.push(EmojiEntry {
|
||
shortcode: shortcode.to_string(),
|
||
url: url.to_string(),
|
||
});
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Union every member's kind:30030 set, collapsed to one entry per shortcode.
|
||
/// The most recently published set (`created_at`) wins; equal timestamps
|
||
/// tie-break to the lexicographically-smallest URL. Deterministic and
|
||
/// fetch-order-independent. Sorted by shortcode.
|
||
fn union_custom_emoji(events: &[serde_json::Value]) -> Vec<EmojiEntry> {
|
||
let mut by_shortcode: std::collections::HashMap<String, (String, i64)> =
|
||
std::collections::HashMap::new();
|
||
for event in events {
|
||
let created_at = event
|
||
.get("created_at")
|
||
.and_then(|v| v.as_i64())
|
||
.unwrap_or(0);
|
||
for entry in emoji_tags_of(event) {
|
||
match by_shortcode.get(&entry.shortcode) {
|
||
Some((url, at)) if *at > created_at || (*at == created_at && *url <= entry.url) => {
|
||
}
|
||
_ => {
|
||
by_shortcode.insert(entry.shortcode, (entry.url, created_at));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mut out: Vec<EmojiEntry> = by_shortcode
|
||
.into_iter()
|
||
.map(|(shortcode, (url, _))| EmojiEntry { shortcode, url })
|
||
.collect();
|
||
out.sort_by(|a, b| a.shortcode.cmp(&b.shortcode));
|
||
out
|
||
}
|
||
|
||
/// List the workspace custom emoji palette: the union of every member's
|
||
/// own kind:30030 set (d=`buzz:custom-emoji`).
|
||
async fn cmd_list(client: &BuzzClient) -> Result<(), CliError> {
|
||
let filter = serde_json::json!({
|
||
"kinds": [buzz_sdk::kind::KIND_EMOJI_SET],
|
||
"#d": [CUSTOM_EMOJI_SET_D_TAG],
|
||
});
|
||
let raw = client.query(&filter).await?;
|
||
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
|
||
.map_err(|e| CliError::Other(format!("failed to parse emoji set query: {e}")))?;
|
||
let emojis = union_custom_emoji(&events);
|
||
let output = serde_json::json!({ "emojis": emojis });
|
||
println!("{}", serde_json::to_string(&output).unwrap_or_default());
|
||
Ok(())
|
||
}
|
||
|
||
/// Fetch the caller's own current custom emoji set (latest kind:30030 under
|
||
/// the d-tag, authored by the caller). Empty when none published yet.
|
||
async fn fetch_own_emoji(client: &BuzzClient) -> Result<Vec<CustomEmoji>, CliError> {
|
||
let me = client.keys().public_key().to_hex();
|
||
let filter = serde_json::json!({
|
||
"kinds": [buzz_sdk::kind::KIND_EMOJI_SET],
|
||
"#d": [CUSTOM_EMOJI_SET_D_TAG],
|
||
"authors": [me],
|
||
"limit": 1,
|
||
});
|
||
let raw = client.query(&filter).await?;
|
||
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
|
||
.map_err(|e| CliError::Other(format!("failed to parse own emoji set: {e}")))?;
|
||
// The relay keeps only the latest per (pubkey, d_tag), but be defensive.
|
||
let Some(event) = events.last() else {
|
||
return Ok(vec![]);
|
||
};
|
||
Ok(emoji_tags_of(event)
|
||
.into_iter()
|
||
.map(|e| CustomEmoji {
|
||
shortcode: e.shortcode,
|
||
url: e.url,
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
/// Publish the caller's own (replaced) kind:30030 set, signed as the caller.
|
||
async fn publish_own_set(client: &BuzzClient, emojis: &[CustomEmoji]) -> Result<(), CliError> {
|
||
let builder = buzz_sdk::build_custom_emoji_set(emojis)
|
||
.map_err(|e| CliError::Other(format!("build_custom_emoji_set failed: {e}")))?;
|
||
let event = client.sign_event(builder)?;
|
||
let resp = client.submit_event(event).await?;
|
||
println!("{}", normalize_write_response(&resp));
|
||
Ok(())
|
||
}
|
||
|
||
/// Add/update a shortcode in the caller's own set (read-modify-write).
|
||
async fn cmd_set(client: &BuzzClient, shortcode: &str, url: &str) -> Result<(), CliError> {
|
||
let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode)
|
||
.map_err(|e| CliError::Other(format!("invalid shortcode: {e}")))?;
|
||
let mut emojis = fetch_own_emoji(client).await?;
|
||
emojis.retain(|e| e.shortcode != normalized);
|
||
emojis.push(CustomEmoji {
|
||
shortcode: normalized,
|
||
url: url.to_string(),
|
||
});
|
||
publish_own_set(client, &emojis).await
|
||
}
|
||
|
||
/// Remove a shortcode from the caller's own set (read-modify-write).
|
||
async fn cmd_rm(client: &BuzzClient, shortcode: &str) -> Result<(), CliError> {
|
||
let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode)
|
||
.map_err(|e| CliError::Other(format!("invalid shortcode: {e}")))?;
|
||
let mut emojis = fetch_own_emoji(client).await?;
|
||
let before = emojis.len();
|
||
emojis.retain(|e| e.shortcode != normalized);
|
||
if emojis.len() == before {
|
||
// Nothing to remove; avoid republishing an unchanged set.
|
||
println!(
|
||
"{}",
|
||
serde_json::json!({"accepted": true, "message": "not present"})
|
||
);
|
||
return Ok(());
|
||
}
|
||
publish_own_set(client, &emojis).await
|
||
}
|
||
|
||
/// 10 MiB — a safety rail against runaway producers. An emoji manifest will
|
||
/// never approach this size in practice.
|
||
const STDIN_MAX_BYTES: u64 = 10_000_000;
|
||
|
||
/// Read from a file path or stdin. Returns `CliError::Usage` on empty stdin,
|
||
/// `CliError::Other` on I/O failure.
|
||
fn read_source(file: Option<&str>) -> Result<String, CliError> {
|
||
match file {
|
||
Some(path) => std::fs::read_to_string(path)
|
||
.map_err(|e| CliError::Other(format!("failed to read file '{path}': {e}"))),
|
||
None => {
|
||
let mut buf = String::new();
|
||
std::io::stdin()
|
||
.take(STDIN_MAX_BYTES)
|
||
.read_to_string(&mut buf)
|
||
.map_err(|e| CliError::Other(format!("stdin read failed: {e}")))?;
|
||
if buf.is_empty() {
|
||
return Err(CliError::Usage(
|
||
"no input: provide --file or pipe JSON to stdin".into(),
|
||
));
|
||
}
|
||
Ok(buf)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Write to a file path or stdout.
|
||
fn write_output(output: &str, file: Option<&str>) -> Result<(), CliError> {
|
||
match file {
|
||
Some(path) => std::fs::write(path, output)
|
||
.map_err(|e| CliError::Other(format!("failed to write file '{path}': {e}"))),
|
||
None => {
|
||
println!("{output}");
|
||
Ok(())
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Export custom emojis to stdout or a file.
|
||
async fn cmd_export(
|
||
client: &BuzzClient,
|
||
file: Option<&str>,
|
||
scope: &crate::EmojiScope,
|
||
) -> Result<(), CliError> {
|
||
let entries: Vec<EmojiEntry> = match scope {
|
||
crate::EmojiScope::Own => {
|
||
let mut entries: Vec<EmojiEntry> = fetch_own_emoji(client)
|
||
.await?
|
||
.into_iter()
|
||
.map(|e| EmojiEntry {
|
||
shortcode: e.shortcode,
|
||
url: e.url,
|
||
})
|
||
.collect();
|
||
// Sort to match union_custom_emoji output order so repeated
|
||
// export | import --replace cycles are stable.
|
||
entries.sort_by(|a, b| a.shortcode.cmp(&b.shortcode).then(a.url.cmp(&b.url)));
|
||
entries
|
||
}
|
||
crate::EmojiScope::Workspace => {
|
||
let filter = serde_json::json!({
|
||
"kinds": [buzz_sdk::kind::KIND_EMOJI_SET],
|
||
"#d": [CUSTOM_EMOJI_SET_D_TAG],
|
||
});
|
||
let raw = client.query(&filter).await?;
|
||
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
|
||
.map_err(|e| CliError::Other(format!("failed to parse emoji set query: {e}")))?;
|
||
union_custom_emoji(&events)
|
||
}
|
||
};
|
||
let output = serde_json::to_string(&serde_json::json!({ "emojis": entries }))
|
||
.map_err(|e| CliError::Other(format!("serialization failed: {e}")))?;
|
||
write_output(&output, file)
|
||
}
|
||
|
||
/// Import custom emojis from stdin or a file into the caller's own set.
|
||
async fn cmd_import(
|
||
client: &BuzzClient,
|
||
file: Option<&str>,
|
||
replace: bool,
|
||
dry_run: bool,
|
||
) -> Result<(), CliError> {
|
||
// 1. Read raw JSON
|
||
let raw = read_source(file)?;
|
||
|
||
// 2. Parse and extract ["emojis"] array
|
||
let parsed: serde_json::Value =
|
||
serde_json::from_str(&raw).map_err(|e| CliError::Usage(format!("invalid JSON: {e}")))?;
|
||
let arr = parsed
|
||
.get("emojis")
|
||
.and_then(|v| v.as_array())
|
||
.ok_or_else(|| {
|
||
CliError::Usage("input must be a JSON object with an \"emojis\" array".into())
|
||
})?;
|
||
|
||
// 3–4. Parse each element and normalize shortcodes
|
||
let mut import_entries: Vec<CustomEmoji> = Vec::with_capacity(arr.len());
|
||
for (i, item) in arr.iter().enumerate() {
|
||
let shortcode = item
|
||
.get("shortcode")
|
||
.and_then(|v| v.as_str())
|
||
.ok_or_else(|| CliError::Usage(format!("emojis[{i}]: missing \"shortcode\" field")))?;
|
||
let url = item
|
||
.get("url")
|
||
.and_then(|v| v.as_str())
|
||
.ok_or_else(|| CliError::Usage(format!("emojis[{i}]: missing \"url\" field")))?;
|
||
let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode)
|
||
.map_err(|e| CliError::Usage(format!("emojis[{i}]: invalid shortcode: {e}")))?;
|
||
import_entries.push(CustomEmoji {
|
||
shortcode: normalized,
|
||
url: url.to_string(),
|
||
});
|
||
}
|
||
|
||
// 5. Deduplicate within the import batch (first occurrence wins)
|
||
let mut seen = std::collections::HashSet::new();
|
||
import_entries.retain(|e| seen.insert(e.shortcode.clone()));
|
||
|
||
// 6. Build final set
|
||
let final_set: Vec<CustomEmoji> = if replace {
|
||
import_entries
|
||
} else {
|
||
let mut existing = fetch_own_emoji(client).await?;
|
||
let existing_shortcodes: std::collections::HashSet<String> =
|
||
existing.iter().map(|e| e.shortcode.clone()).collect();
|
||
for entry in import_entries {
|
||
if !existing_shortcodes.contains(&entry.shortcode) {
|
||
existing.push(entry);
|
||
}
|
||
}
|
||
existing
|
||
};
|
||
|
||
// 7. Dry-run: print final set to stdout, warn to stderr
|
||
if dry_run {
|
||
let entries: Vec<EmojiEntry> = final_set
|
||
.iter()
|
||
.map(|e| EmojiEntry {
|
||
shortcode: e.shortcode.clone(),
|
||
url: e.url.clone(),
|
||
})
|
||
.collect();
|
||
let output = serde_json::to_string(&serde_json::json!({ "emojis": entries }))
|
||
.map_err(|e| CliError::Other(format!("serialization failed: {e}")))?;
|
||
println!("{output}");
|
||
eprintln!("{}", i18n::label(i18n::current(), "dry_run_not_published"));
|
||
return Ok(());
|
||
}
|
||
|
||
// 8. Publish
|
||
publish_own_set(client, &final_set).await
|
||
}
|
||
|
||
pub async fn dispatch(cmd: crate::EmojiCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||
use crate::EmojiCmd;
|
||
match cmd {
|
||
EmojiCmd::List => cmd_list(client).await,
|
||
EmojiCmd::Set { shortcode, url } => cmd_set(client, &shortcode, &url).await,
|
||
EmojiCmd::Rm { shortcode } => cmd_rm(client, &shortcode).await,
|
||
EmojiCmd::Export { file, scope } => cmd_export(client, file.as_deref(), &scope).await,
|
||
EmojiCmd::Import {
|
||
file,
|
||
replace,
|
||
dry_run,
|
||
} => cmd_import(client, file.as_deref(), replace, dry_run).await,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn union_latest_set_wins_per_shortcode() {
|
||
let events = vec![
|
||
serde_json::json!({
|
||
"created_at": 100,
|
||
"tags": [
|
||
["d", "buzz:custom-emoji"],
|
||
["emoji", "zort", "https://example.com/zort.png"],
|
||
["emoji", "narf", "https://example.com/narf.png"]
|
||
]
|
||
}),
|
||
serde_json::json!({
|
||
"created_at": 200,
|
||
"tags": [
|
||
["d", "buzz:custom-emoji"],
|
||
// newer set claims zort with a different url — newer wins
|
||
["emoji", "zort", "https://example.com/zort2.png"]
|
||
]
|
||
}),
|
||
];
|
||
let emojis = union_custom_emoji(&events);
|
||
let pairs: Vec<(&str, &str)> = emojis
|
||
.iter()
|
||
.map(|e| (e.shortcode.as_str(), e.url.as_str()))
|
||
.collect();
|
||
assert_eq!(
|
||
pairs,
|
||
vec![
|
||
("narf", "https://example.com/narf.png"),
|
||
("zort", "https://example.com/zort2.png"),
|
||
]
|
||
);
|
||
// Order-independence: reversed input yields the identical palette.
|
||
let reversed: Vec<_> = events.into_iter().rev().collect();
|
||
let emojis_rev = union_custom_emoji(&reversed);
|
||
let pairs_rev: Vec<(&str, &str)> = emojis_rev
|
||
.iter()
|
||
.map(|e| (e.shortcode.as_str(), e.url.as_str()))
|
||
.collect();
|
||
assert_eq!(pairs, pairs_rev);
|
||
}
|
||
|
||
#[test]
|
||
fn union_equal_timestamps_tie_break_to_smallest_url() {
|
||
let events = vec![
|
||
serde_json::json!({
|
||
"created_at": 100,
|
||
"tags": [["emoji", "zort", "https://example.com/zort2.png"]]
|
||
}),
|
||
serde_json::json!({
|
||
"created_at": 100,
|
||
"tags": [["emoji", "zort", "https://example.com/zort.png"]]
|
||
}),
|
||
];
|
||
let emojis = union_custom_emoji(&events);
|
||
assert_eq!(emojis.len(), 1);
|
||
assert_eq!(emojis[0].shortcode, "zort");
|
||
assert_eq!(emojis[0].url, "https://example.com/zort.png");
|
||
}
|
||
}
|