feat(cli): manage sidebar sections with buzz sidebar sections

Sidebar sections were desktop-only: they live in the encrypted
kind:30078 `channel-sections` preference, and the CLI had no way to
read or write it. Agents that mirror a team's channels could create the
channels but not group them.

Add `buzz sidebar sections list|create|update|delete|reorder|move|unassign`.
The commands port desktop's per-field LWW register document
(channelSectionsSync.ts / sidebarLwwMap.ts) so CLI writes merge with
desktop edits field by field, and they import the flat format mobile
writes. Each command is one head read and one publish, so a
multi-channel `move` lands atomically. A head the CLI cannot decrypt,
verify, or decode is never overwritten, and a dominated write exits 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Blake McAnally <bmcanally@squareup.com>
This commit is contained in:
Blake McAnally
2026-10-30 11:39:59 -05:00
parent 1bb2ad443d
commit 22a12bf079
6 changed files with 1289 additions and 0 deletions

View File

@@ -140,6 +140,11 @@ buzz messages vote --event <event-id> --direction up
buzz canvas get --channel <uuid>
buzz canvas set --channel <uuid> --content "# Welcome"
# Sidebar sections (your own sidebar, synced with Buzz desktop)
buzz sidebar sections create --name "Engineering" --icon "🛠"
buzz sidebar sections move --section "Engineering" --channel <uuid> --channel <uuid>
buzz sidebar sections list
# Agent Memory (NIP-AE)
buzz mem ls
buzz mem get <slug>
@@ -156,6 +161,11 @@ buzz repos protect remove --id my-repo --ref refs/heads/main
buzz channels list | jq '.[].name'
```
`sidebar sections` edits the signing identity's own sidebar. Each command is
one read and one publish, so a multi-channel `move` lands together. `--section`
takes a section id or a unique section name. A repeated command prints
`"message": "unchanged"` and publishes nothing.
`protect set` replaces every existing rule for the exact ref pattern. Any
constraint omitted from the command is removed. `protect list` reports malformed
stored rules in `validation_error` so an owner can remove and repair them.
@@ -196,6 +206,13 @@ The table below mirrors that tree for readers who are not at a terminal.
| | `remove-member` | Remove a member |
| `canvas` | `get` | Get channel canvas |
| | `set` | Set channel canvas |
| `sidebar` | `sections list` | List your sections and their channels |
| | `sections create` | Create a section at the end of your sidebar |
| | `sections update` | Rename a section or change its icon |
| | `sections delete` | Delete a section |
| | `sections reorder` | Put sections in order |
| | `sections move` | Move one or more channels into a section |
| | `sections unassign` | Return channels to the default channel list |
| `reactions` | `add` | React to a message |
| | `remove` | Remove a reaction |
| | `get` | List reactions |

View File

@@ -25,6 +25,8 @@ pub(crate) mod read_state;
mod repo_default_branch;
pub mod repos;
pub(crate) mod sent_from_thread;
pub mod sidebar;
mod sidebar_doc;
pub mod social;
pub mod upload;
pub mod users;

View File

@@ -0,0 +1,643 @@
//! `buzz sidebar sections` — your own channel sections, the same synced
//! preference the desktop sidebar edits (kind:30078, d=`channel-sections`,
//! NIP-44 encrypted to yourself).
//!
//! Every write is one read of the current head followed by one publish, so a
//! multi-channel `move` lands atomically. A head this CLI cannot read is never
//! overwritten.
use std::time::SystemTime;
use buzz_core::kind::KIND_READ_STATE;
use nostr::nips::nip44;
use nostr::{EventBuilder, Kind, Tag, Timestamp};
use serde_json::{json, Value};
use super::parse_write_response;
use super::sidebar_doc::{self, Edit, Projection, Section, Stamp, Tree};
use crate::client::BuzzClient;
use crate::error::CliError;
use crate::{SidebarCmd, SidebarSectionsCmd};
const D_TAG: &str = "channel-sections";
/// Relay event size ceiling shared with desktop's sidebar blobs.
const MAX_CIPHERTEXT_BYTES: usize = 65_535;
struct Head {
created_at: u64,
tree: Tree,
}
fn now() -> std::time::Duration {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
}
/// The relay's current sections event for this identity, decoded. An absent
/// head is an empty tree; an unreadable one is an error, never an empty list.
async fn fetch_head(client: &BuzzClient) -> Result<Head, CliError> {
let me = client.keys().public_key();
let filter = json!({
"kinds": [KIND_READ_STATE],
"authors": [me.to_hex()],
"#d": [D_TAG],
"limit": 1,
});
let raw = client.query(&filter).await?;
let events: Vec<Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse sidebar sections query: {e}")))?;
let returned = events.len();
let head = events
.into_iter()
.filter_map(|event| serde_json::from_value::<nostr::Event>(event).ok())
.filter(|event| {
event.pubkey == me && event.tags.identifier() == Some(D_TAG) && event.verify().is_ok()
})
.min_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| a.id.to_hex().cmp(&b.id.to_hex()))
});
let Some(event) = head else {
if returned > 0 {
return Err(CliError::Other(
"the relay returned a sidebar sections event that is not yours or fails verification; refusing to overwrite it".into(),
));
}
return Ok(Head {
created_at: 0,
tree: Tree::new(),
});
};
let unreadable = |detail: String| {
CliError::Other(format!(
"your sidebar sections event {} is unreadable ({detail}); refusing to read or overwrite it",
event.id.to_hex()
))
};
let plaintext = nip44::decrypt(client.keys().secret_key(), &me, &event.content)
.map_err(|e| unreadable(e.to_string()))?;
let json: Value = serde_json::from_str(&plaintext).map_err(|e| unreadable(e.to_string()))?;
let created_at = event.created_at.as_secs();
let legacy_v = i64::try_from(created_at.saturating_mul(1_000)).unwrap_or(i64::MAX);
let tree = sidebar_doc::decode(&json, legacy_v).ok_or_else(|| {
unreadable("unsupported format; a newer client may have written it".into())
})?;
Ok(Head { created_at, tree })
}
/// A live section by exact id, or by exact name when that name is unique.
fn resolve<'a>(view: &'a Projection, key: &str) -> Result<&'a Section, CliError> {
if let Some(section) = view.sections.iter().find(|s| s.id == key) {
return Ok(section);
}
let key = key.trim();
let mut named = view.sections.iter().filter(|s| s.name == key);
match (named.next(), named.next()) {
(Some(section), None) => Ok(section),
(Some(_), Some(_)) => Err(CliError::Usage(format!(
"more than one section is named \"{key}\"; pass the section id from `buzz sidebar sections list`"
))),
(None, _) => Err(CliError::NotFound(format!(
"no section has the id or name \"{key}\""
))),
}
}
fn section_name(name: &str) -> Result<String, CliError> {
let name = name.trim();
if name.is_empty() {
return Err(CliError::Usage("section name must not be empty".into()));
}
Ok(name.to_string())
}
fn section_icon(icon: Option<&str>) -> Result<Option<String>, CliError> {
match icon.map(str::trim) {
Some("") => Err(CliError::Usage(
"--icon must not be empty; use --clear-icon to remove an icon".into(),
)),
icon => Ok(icon.map(str::to_string)),
}
}
/// Channel ids in the lowercase hyphenated form desktop keys assignments by.
fn channel_ids(channels: &[String]) -> Result<Vec<String>, CliError> {
channels
.iter()
.map(|channel| {
uuid::Uuid::parse_str(channel.trim())
.map(|id| id.hyphenated().to_string())
.map_err(|_| CliError::Usage(format!("invalid channel UUID: {channel}")))
})
.collect()
}
/// Reads the head, applies `apply`, and publishes the result once. Returns
/// the write response, or an `unchanged` response without publishing.
async fn edit(
client: &BuzzClient,
apply: impl FnOnce(&mut Edit, &Projection) -> Result<(), CliError>,
) -> Result<Value, CliError> {
let head = fetch_head(client).await?;
let view = sidebar_doc::project(&head.tree);
let mut tree = head.tree.clone();
let stamp = Stamp {
now_ms: i64::try_from(now().as_millis()).unwrap_or(i64::MAX),
dev: format!("{:016x}", rand::random::<u64>()),
};
apply(&mut Edit::new(&mut tree, &stamp), &view)?;
if tree == head.tree {
return Ok(json!({"event_id": null, "accepted": true, "message": "unchanged"}));
}
if !sidebar_doc::project(&tree).within_limits() {
return Err(CliError::Usage(format!(
"the sidebar supports at most {} sections and {} channel assignments",
sidebar_doc::MAX_SECTIONS,
sidebar_doc::MAX_ASSIGNMENTS
)));
}
let keys = client.keys();
let plaintext = serde_json::to_string(&sidebar_doc::encode(&tree))
.map_err(|e| CliError::Other(format!("failed to encode sidebar sections: {e}")))?;
let too_large =
|| CliError::Usage("sidebar sections would exceed the relay's event size limit".into());
// NIP-44 rejects longer plaintexts outright.
if plaintext.len() > u16::MAX as usize {
return Err(too_large());
}
let content = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
plaintext,
nip44::Version::V2,
)
.map_err(|e| CliError::Other(format!("failed to encrypt sidebar sections: {e}")))?;
if content.len() > MAX_CIPHERTEXT_BYTES {
return Err(too_large());
}
let created_at = now().as_secs().max(head.created_at + 1);
let builder = EventBuilder::new(Kind::Custom(KIND_READ_STATE as u16), content)
.tags([Tag::identifier(D_TAG), Tag::hashtag(D_TAG)])
.custom_created_at(Timestamp::from(created_at));
let raw = client.submit_event(client.sign_event(builder)?).await?;
let normalized = parse_write_response(
&raw,
"a newer sidebar sections update won; run the command again",
)?;
serde_json::from_str(&normalized)
.map_err(|e| CliError::Other(format!("relay response is not JSON: {e}")))
}
async fn run(cmd: SidebarSectionsCmd, client: &BuzzClient) -> Result<Value, CliError> {
match cmd {
SidebarSectionsCmd::List => {
let head = fetch_head(client).await?;
serde_json::to_value(sidebar_doc::project(&head.tree))
.map_err(|e| CliError::Other(format!("failed to encode sections: {e}")))
}
SidebarSectionsCmd::Create { name, icon } => {
let name = section_name(&name)?;
let icon = section_icon(icon.as_deref())?;
let id = uuid::Uuid::new_v4().to_string();
let mut response = edit(client, |edit, _| {
edit.create_section(&id, &name, icon.as_deref());
Ok(())
})
.await?;
response["section_id"] = json!(id);
Ok(response)
}
SidebarSectionsCmd::Update {
section,
name,
icon,
clear_icon,
} => {
if name.is_none() && icon.is_none() && !clear_icon {
return Err(CliError::Usage(
"pass --name, --icon, or --clear-icon".into(),
));
}
let name = name.as_deref().map(section_name).transpose()?;
let icon = section_icon(icon.as_deref())?;
edit(client, |edit, view| {
let id = resolve(view, &section)?.id.clone();
if let Some(name) = name {
edit.set(&["s", &id, "name"], json!(name));
}
if clear_icon || icon.is_some() {
edit.set(&["s", &id, "icon"], json!(icon));
}
Ok(())
})
.await
}
SidebarSectionsCmd::Delete { section } => {
edit(client, |edit, view| {
let id = resolve(view, &section)?.id.clone();
edit.set(&["s", &id, "live"], json!(false));
Ok(())
})
.await
}
SidebarSectionsCmd::Reorder { sections } => {
edit(client, |edit, view| {
let ids = sections
.iter()
.map(|key| Ok(resolve(view, key)?.id.clone()))
.collect::<Result<Vec<_>, CliError>>()?;
edit.reorder(&ids);
Ok(())
})
.await
}
SidebarSectionsCmd::Move { channels, section } => {
let channels = channel_ids(&channels)?;
edit(client, |edit, view| {
let id = resolve(view, &section)?.id.clone();
for channel in &channels {
edit.set(&["a", channel], json!(id));
}
Ok(())
})
.await
}
SidebarSectionsCmd::Unassign { channels } => {
let channels = channel_ids(&channels)?;
edit(client, |edit, _| {
for channel in &channels {
edit.unassign(channel);
}
Ok(())
})
.await
}
}
}
pub async fn dispatch(cmd: SidebarCmd, client: &BuzzClient) -> Result<(), CliError> {
let SidebarCmd::Sections(cmd) = cmd;
let output = run(cmd, client).await?;
println!("{output}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{body::Bytes, extract::State, routing::post, Json, Router};
use nostr::Keys;
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
#[derive(Default)]
struct Relay {
events: Vec<Value>,
duplicate: bool,
}
type Shared = Arc<Mutex<Relay>>;
/// Fake relay: `/query` checks the head filter and returns every stored
/// event matching its author and d-tag (more than the relay's `limit`, so
/// head selection is exercised); `/events` stores one.
async fn fake_relay(relay: Shared) -> String {
let app = Router::new()
.route(
"/query",
post(|State(relay): State<Shared>, body: Bytes| async move {
let filters: Vec<Value> = serde_json::from_slice(&body).unwrap();
let [filter] = filters.as_slice() else {
panic!("one filter expected: {filters:?}")
};
assert_eq!(filter["kinds"], json!([KIND_READ_STATE]));
assert_eq!(filter["#d"], json!([D_TAG]));
assert_eq!(filter["limit"], json!(1));
let author = &filter["authors"][0];
let relay = relay.lock().unwrap();
let matching: Vec<_> = relay
.events
.iter()
.filter(|e| &e["pubkey"] == author && e["tags"][0] == json!(["d", D_TAG]))
.cloned()
.collect();
Json(json!(matching))
}),
)
.route(
"/events",
post(|State(relay): State<Shared>, body: Bytes| async move {
let event: Value = serde_json::from_slice(&body).unwrap();
let mut relay = relay.lock().unwrap();
let message = if relay.duplicate {
"duplicate: newer head"
} else {
""
};
if !relay.duplicate {
relay.events.push(event.clone());
}
Json(json!({"event_id": event["id"], "accepted": true, "message": message}))
}),
)
.with_state(relay);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
format!("http://{addr}")
}
async fn setup() -> (BuzzClient, Shared) {
let relay = Shared::default();
let url = fake_relay(relay.clone()).await;
let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap();
(client, relay)
}
fn seed_at(client: &BuzzClient, relay: &Shared, plaintext: &str, created_at: u64) -> Value {
let keys = client.keys();
let content = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
plaintext,
nip44::Version::V2,
)
.unwrap();
let event = EventBuilder::new(Kind::Custom(KIND_READ_STATE as u16), content)
.tags([Tag::identifier(D_TAG)])
.custom_created_at(Timestamp::from(created_at))
.sign_with_keys(keys)
.unwrap();
let event = serde_json::to_value(event).unwrap();
relay.lock().unwrap().events.push(event.clone());
event
}
fn seed(client: &BuzzClient, relay: &Shared, plaintext: &str) {
seed_at(client, relay, plaintext, now().as_secs());
}
fn doc_with_sections(names: &[&str]) -> String {
let mut tree = Tree::new();
let stamp = Stamp {
now_ms: 1,
dev: "0123456789abcdef".into(),
};
let mut edit = Edit::new(&mut tree, &stamp);
for name in names {
edit.create_section(&format!("id-{name}"), name, None);
}
sidebar_doc::encode(&tree).to_string()
}
/// The decrypted document of the newest published event.
fn published(client: &BuzzClient, relay: &Shared) -> (nostr::Event, Value) {
let raw = relay.lock().unwrap().events.last().cloned().unwrap();
let event: nostr::Event = serde_json::from_value(raw).unwrap();
let keys = client.keys();
let plaintext =
nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content).unwrap();
(event, serde_json::from_str(&plaintext).unwrap())
}
fn published_count(relay: &Shared) -> usize {
relay.lock().unwrap().events.len()
}
const C1: &str = "11111111-1111-4111-8111-111111111111";
const C2: &str = "22222222-2222-4222-8222-222222222222";
const C3: &str = "abcdef01-2345-4678-89ab-cdef01234567";
fn mv(channels: &[&str], section: &str) -> SidebarSectionsCmd {
SidebarSectionsCmd::Move {
channels: channels.iter().map(|c| c.to_string()).collect(),
section: section.into(),
}
}
#[tokio::test]
async fn sets_up_a_sidebar_in_one_pass_and_syncs_desktop_format() {
let (client, relay) = setup().await;
let list = run(SidebarSectionsCmd::List, &client).await.unwrap();
assert_eq!(list, json!({"sections": [], "assignments": {}}));
let eng = run(
SidebarSectionsCmd::Create {
name: " Engineering ".into(),
icon: Some("🛠".into()),
},
&client,
)
.await
.unwrap();
let eng_id = eng["section_id"].as_str().unwrap().to_string();
run(
SidebarSectionsCmd::Create {
name: "Design".into(),
icon: None,
},
&client,
)
.await
.unwrap();
// Both channels land in one publish, addressed by section name.
let before = published_count(&relay);
run(mv(&[C1, C2], "Engineering"), &client).await.unwrap();
assert_eq!(published_count(&relay), before + 1);
let (event, doc) = published(&client, &relay);
assert_eq!(event.kind.as_u16() as u32, KIND_READ_STATE);
assert!(event.tags.iter().any(|t| t.as_slice() == ["d", D_TAG]));
assert!(event.tags.iter().any(|t| t.as_slice() == ["t", D_TAG]));
assert_eq!(doc["meta"]["v"], json!(1));
assert_eq!(doc["assignments"][C1], json!(eng_id));
assert_eq!(doc["sections"][0]["icon"], json!("🛠"));
// A repeated command publishes nothing.
let again = run(mv(&[C1, C2], &eng_id), &client).await.unwrap();
assert_eq!(again["message"], json!("unchanged"));
assert_eq!(published_count(&relay), before + 1);
// Invalid input fails before any publish.
let err = run(mv(&[C1, "not-a-uuid"], "Design"), &client)
.await
.unwrap_err();
assert!(matches!(err, CliError::Usage(_)), "{err:?}");
let err = run(mv(&[C1], "Nope"), &client).await.unwrap_err();
assert!(matches!(err, CliError::NotFound(_)), "{err:?}");
assert_eq!(published_count(&relay), before + 1);
run(
SidebarSectionsCmd::Reorder {
sections: vec!["Design".into()],
},
&client,
)
.await
.unwrap();
run(
SidebarSectionsCmd::Update {
section: eng_id.clone(),
name: Some("Eng".into()),
icon: None,
clear_icon: true,
},
&client,
)
.await
.unwrap();
run(
SidebarSectionsCmd::Unassign {
channels: vec![C2.into()],
},
&client,
)
.await
.unwrap();
run(
SidebarSectionsCmd::Delete {
section: "Design".into(),
},
&client,
)
.await
.unwrap();
let list = run(SidebarSectionsCmd::List, &client).await.unwrap();
assert_eq!(
list,
json!({
"sections": [{"id": eng_id, "name": "Eng", "order": 0}],
"assignments": {C1: eng_id},
})
);
let (_, doc) = published(&client, &relay);
assert_eq!(doc["sections"], list["sections"]);
}
#[tokio::test]
async fn keeps_a_mobile_head_and_never_overwrites_an_unreadable_one() {
let (client, relay) = setup().await;
seed(
&client,
&relay,
r#"{"version":1,"sections":[{"id":"m1","name":"Mobile","order":0}],"assignments":{}}"#,
);
run(mv(&[C1], "Mobile"), &client).await.unwrap();
let list = run(SidebarSectionsCmd::List, &client).await.unwrap();
assert_eq!(list["sections"][0]["name"], json!("Mobile"));
assert_eq!(list["assignments"][C1], json!("m1"));
seed_at(
&client,
&relay,
r#"{"version":1,"meta":{"v":2}}"#,
now().as_secs() + 100,
);
let count = published_count(&relay);
assert!(run(SidebarSectionsCmd::List, &client).await.is_err());
assert!(run(mv(&[C2], "Mobile"), &client).await.is_err());
assert_eq!(published_count(&relay), count);
}
#[tokio::test]
async fn picks_the_newest_head_and_publishes_after_it() {
let (client, relay) = setup().await;
let future = now().as_secs() + 1_000;
seed_at(&client, &relay, &doc_with_sections(&["Old"]), future - 1);
let a = seed_at(&client, &relay, &doc_with_sections(&["A"]), future);
let b = seed_at(&client, &relay, &doc_with_sections(&["B"]), future);
// Equal `created_at`: the lower id wins, as on the relay.
let winner = if a["id"].as_str() < b["id"].as_str() {
"A"
} else {
"B"
};
let list = run(SidebarSectionsCmd::List, &client).await.unwrap();
assert_eq!(list["sections"][0]["name"], json!(winner));
// Another author's head is never read as ours.
let other = BuzzClient::new(String::new(), Keys::generate(), None, None).unwrap();
seed_at(&other, &relay, &doc_with_sections(&["Theirs"]), future + 5);
// An uppercase channel id is stored in desktop's canonical form.
run(mv(&[&C3.to_uppercase()], winner), &client)
.await
.unwrap();
let (event, doc) = published(&client, &relay);
assert_eq!(event.created_at.as_secs(), future + 1);
assert_eq!(doc["assignments"][C3], json!(format!("id-{winner}")));
}
#[tokio::test]
async fn refuses_a_tampered_head_and_oversized_documents() {
let (client, relay) = setup().await;
let mut head = seed_at(&client, &relay, &doc_with_sections(&["A"]), 10);
head["content"] = json!("tampered");
relay.lock().unwrap().events = vec![head];
let err = run(mv(&[C1], "A"), &client).await.unwrap_err();
assert!(matches!(err, CliError::Other(_)), "{err:?}");
assert_eq!(published_count(&relay), 1);
let names: Vec<String> = (0..sidebar_doc::MAX_SECTIONS)
.map(|i| i.to_string())
.collect();
let names: Vec<&str> = names.iter().map(String::as_str).collect();
relay.lock().unwrap().events.clear();
seed(&client, &relay, &doc_with_sections(&names));
let over_count = SidebarSectionsCmd::Create {
name: "one too many".into(),
icon: None,
};
let rename = |len: usize| SidebarSectionsCmd::Update {
section: "0".into(),
name: Some("x".repeat(len)),
icon: None,
clear_icon: false,
};
let err = run(over_count, &client).await.unwrap_err();
assert!(matches!(err, CliError::Usage(_)), "{err:?}");
// Ciphertext over the relay cap, then plaintext over the NIP-44 cap.
relay.lock().unwrap().events.clear();
seed(&client, &relay, &doc_with_sections(&["0"]));
for cmd in [rename(25_000), rename(70_000)] {
let err = run(cmd, &client).await.unwrap_err();
assert!(matches!(err, CliError::Usage(_)), "{err:?}");
}
assert_eq!(published_count(&relay), 1);
let err = run(
SidebarSectionsCmd::Update {
section: "0".into(),
name: None,
icon: Some(" ".into()),
clear_icon: false,
},
&client,
)
.await
.unwrap_err();
assert!(matches!(err, CliError::Usage(_)), "{err:?}");
}
#[tokio::test]
async fn a_dominated_write_is_a_conflict() {
let (client, relay) = setup().await;
relay.lock().unwrap().duplicate = true;
let err = run(
SidebarSectionsCmd::Create {
name: "Late".into(),
icon: None,
},
&client,
)
.await
.unwrap_err();
assert!(matches!(err, CliError::Conflict(_)), "{err:?}");
}
}

View File

@@ -0,0 +1,556 @@
//! The synced channel-sections document (kind:30078, d=`channel-sections`).
//!
//! Wire-compatible with desktop `channelSectionsSync.ts`, `sidebarLaneStore.ts`
//! and `sidebarLwwMap.ts`: a tree of per-field last-writer-wins registers
//! `[v, dev, value]` under `meta`, plus the projected `sections`/`assignments`
//! fields that meta-less clients (mobile) read and write.
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
/// Register tree: `{"s": {section_id: {name, icon, order, live}}, "a": {channel_id: section_id|null}}`.
pub(crate) type Tree = Map<String, Value>;
/// Live-section cap shared with desktop; larger documents are never published.
pub(crate) const MAX_SECTIONS: usize = 100;
/// Live-assignment cap shared with desktop.
pub(crate) const MAX_ASSIGNMENTS: usize = 1_000;
/// Device id desktop stamps on registers imported from meta-less payloads.
const LEGACY_DEV: &str = "0000000000000000";
const MAX_SAFE_INTEGER: i64 = (1 << 53) - 1;
/// One live section, densely renumbered by `(order, id)`.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) struct Section {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
pub order: usize,
}
/// The view every client renders: live sections and assignments to them.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) struct Projection {
pub sections: Vec<Section>,
pub assignments: BTreeMap<String, String>,
}
impl Projection {
pub(crate) fn within_limits(&self) -> bool {
self.sections.len() <= MAX_SECTIONS && self.assignments.len() <= MAX_ASSIGNMENTS
}
}
/// `Number.isSafeInteger`, accepting integral floats such as `1.0`.
fn safe_int(value: &Value) -> Option<i64> {
let n = match value.as_i64() {
Some(n) => n,
None => {
let f = value.as_f64()?;
if f.fract() != 0.0 || f.abs() > MAX_SAFE_INTEGER as f64 {
return None;
}
f as i64
}
};
(-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER)
.contains(&n)
.then_some(n)
}
fn is_dev(value: &Value) -> bool {
value
.as_str()
.is_some_and(|s| s.len() == 16 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')))
}
fn valid_reg(node: &Value, leaf: Leaf) -> Option<Value> {
let reg = node.as_array().filter(|reg| reg.len() == 3)?;
let v = safe_int(&reg[0]).filter(|v| *v >= 0)?;
(is_dev(&reg[1]) && leaf(&reg[2])).then(|| json!([v, reg[1], reg[2]]))
}
fn is_string(value: &Value) -> bool {
value.is_string()
}
fn is_string_or_null(value: &Value) -> bool {
value.is_string() || value.is_null()
}
fn is_order(value: &Value) -> bool {
safe_int(value).is_some()
}
fn is_bool(value: &Value) -> bool {
value.is_boolean()
}
type Leaf = fn(&Value) -> bool;
const SECTION_FIELDS: [(&str, Leaf); 4] = [
("name", is_string),
("icon", is_string_or_null),
("order", is_order),
("live", is_bool),
];
/// Own entries of a JSON object, skipping the `__proto__` key desktop drops.
fn entries(node: &Value) -> impl Iterator<Item = (&String, &Value)> {
node.as_object()
.into_iter()
.flatten()
.filter(|(key, _)| key.as_str() != "__proto__")
}
/// Keeps only well-formed registers; an invalid one is dropped while its
/// valid siblings survive (desktop `validTree` with the sections shape).
fn valid_tree(fields: &Value) -> Option<Tree> {
fields.as_object()?;
let mut tree = Tree::new();
if let Some(s) = fields.get("s").filter(|s| s.is_object()) {
let mut sections = Map::new();
for (id, node) in entries(s) {
if !node.is_object() {
continue;
}
let mut out = Map::new();
for (field, leaf) in SECTION_FIELDS {
if let Some(reg) = node.get(field).and_then(|n| valid_reg(n, leaf)) {
out.insert(field.to_string(), reg);
}
}
sections.insert(id.clone(), Value::Object(out));
}
tree.insert("s".into(), Value::Object(sections));
}
if let Some(a) = fields.get("a").filter(|a| a.is_object()) {
let assignments = entries(a)
.filter_map(|(id, node)| Some((id.clone(), valid_reg(node, is_string_or_null)?)))
.collect();
tree.insert("a".into(), Value::Object(assignments));
}
Some(tree)
}
/// JS `Math.round`, as an integer when it is a safe one.
fn js_round(f: f64) -> Value {
let rounded = (f + 0.5).floor();
if rounded.abs() <= MAX_SAFE_INTEGER as f64 {
json!(rounded as i64)
} else {
json!(rounded)
}
}
/// Registers for a meta-less (legacy/mobile) payload, every value stamped at `legacy_v`.
fn from_legacy(json: &Value, legacy_v: i64) -> Value {
let stamp = |value: Value| json!([legacy_v, LEGACY_DEV, value]);
let mut s = Map::new();
for entry in json
.get("sections")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let (Some(id), Some(name), Some(order)) = (
entry.get("id").and_then(Value::as_str),
entry.get("name").and_then(Value::as_str),
entry.get("order").and_then(Value::as_f64),
) else {
continue;
};
let icon = entry
.get("icon")
.and_then(Value::as_str)
.map(str::trim)
.filter(|icon| !icon.is_empty());
s.insert(
id.to_string(),
json!({
"name": stamp(json!(name)),
"icon": stamp(json!(icon)),
"order": stamp(js_round(order)),
"live": stamp(json!(true)),
}),
);
}
let a: Map<String, Value> = entries(json.get("assignments").unwrap_or(&Value::Null))
.filter(|(_, target)| target.as_str().is_some_and(|t| s.contains_key(t)))
.map(|(id, target)| (id.clone(), stamp(target.clone())))
.collect();
json!({ "s": s, "a": a })
}
/// Decodes a `version: 1` payload, importing a meta-less one at `legacy_v`.
/// Returns `None` when unreadable, including an unknown `meta.v`.
pub(crate) fn decode(json: &Value, legacy_v: i64) -> Option<Tree> {
let is_one = |value: Option<&Value>| value.and_then(safe_int) == Some(1);
if !is_one(json.get("version")) {
return None;
}
match json.get("meta") {
None => valid_tree(&from_legacy(json, legacy_v)),
Some(meta) if is_one(meta.get("v")) => {
let mut fields = meta.as_object()?.clone();
fields.remove("v");
valid_tree(&Value::Object(fields))
}
Some(_) => None,
}
}
fn val<'a>(node: &'a Value, field: &str) -> Option<&'a Value> {
node.get(field)?.as_array()?.get(2)
}
/// Live sections by `(order, id)` with their stored `order`.
fn ranked(tree: &Tree) -> Vec<(String, String, Option<String>, i64)> {
let mut out: Vec<_> = tree
.get("s")
.map(entries)
.into_iter()
.flatten()
.filter(|(_, node)| val(node, "live") == Some(&json!(true)))
.filter_map(|(id, node)| {
let name = val(node, "name")?.as_str()?.to_string();
let icon = val(node, "icon")
.and_then(Value::as_str)
.filter(|icon| !icon.is_empty())
.map(str::to_string);
let order = val(node, "order").and_then(safe_int).unwrap_or(0);
Some((id.clone(), name, icon, order))
})
.collect();
// JS compares strings by UTF-16 code unit.
out.sort_by(|a, b| {
a.3.cmp(&b.3)
.then_with(|| a.0.encode_utf16().cmp(b.0.encode_utf16()))
});
out
}
/// Highest stored `order` among live sections, or -1 when there are none.
fn max_live_order(tree: &Tree) -> i64 {
ranked(tree).iter().map(|s| s.3).max().unwrap_or(-1)
}
pub(crate) fn project(tree: &Tree) -> Projection {
let sections: Vec<Section> = ranked(tree)
.into_iter()
.enumerate()
.map(|(order, (id, name, icon, _))| Section {
id,
name,
icon,
order,
})
.collect();
let assignments = tree
.get("a")
.map(entries)
.into_iter()
.flatten()
.filter_map(|(channel, reg)| {
let target = reg.as_array()?.get(2)?.as_str()?;
sections
.iter()
.any(|s| s.id == target)
.then(|| (channel.clone(), target.to_string()))
})
.collect();
Projection {
sections,
assignments,
}
}
/// The plaintext document: projected legacy fields plus the register tree.
pub(crate) fn encode(tree: &Tree) -> Value {
let Projection {
sections,
assignments,
} = project(tree);
let mut meta = tree.clone();
meta.insert("v".into(), json!(1));
json!({
"version": 1,
"sections": sections,
"assignments": assignments,
"meta": meta,
})
}
fn max_version(node: &Value) -> i64 {
match node {
Value::Array(reg) => reg.first().and_then(safe_int).unwrap_or(0),
Value::Object(map) => map.values().map(max_version).max().unwrap_or(0),
_ => 0,
}
}
/// The version and device every register written by one edit shares.
#[derive(Debug, Clone)]
pub(crate) struct Stamp {
pub now_ms: i64,
pub dev: String,
}
/// One edit against a tree. Each call to [`Edit::set`] that changes a leaf
/// writes a register stamped `max(now, newest + 1)`, like desktop `setRegs`.
pub(crate) struct Edit<'a> {
tree: &'a mut Tree,
reg: (i64, String),
}
impl<'a> Edit<'a> {
pub(crate) fn new(tree: &'a mut Tree, stamp: &Stamp) -> Self {
let newest = tree.values().map(max_version).max().unwrap_or(0);
let v = stamp.now_ms.max(newest + 1);
Self {
tree,
reg: (v, stamp.dev.clone()),
}
}
fn current(&self, path: &[&str]) -> Option<&Value> {
let (first, rest) = path.split_first()?;
let mut node = self.tree.get(*first)?;
for key in rest {
node = node.get(key)?;
}
node.as_array()?.get(2)
}
fn write(&mut self, parents: &[&str], leaf: &str, value: Value) {
let reg = json!([self.reg.0, self.reg.1, value]);
let mut node = &mut *self.tree;
for key in parents {
let child = node.entry(key.to_string()).or_insert_with(|| json!({}));
if !child.is_object() {
*child = json!({});
}
let Value::Object(map) = child else {
return;
};
node = map;
}
node.insert(leaf.to_string(), reg);
}
/// Writes `value` at `path` unless it already holds that value.
pub(crate) fn set(&mut self, path: &[&str], value: Value) {
let Some((leaf, parents)) = path.split_last() else {
return;
};
if self.current(path) != Some(&value) {
self.write(parents, leaf, value);
}
}
pub(crate) fn create_section(&mut self, id: &str, name: &str, icon: Option<&str>) {
let order = max_live_order(self.tree) + 1;
self.set(&["s", id, "name"], json!(name));
self.set(&["s", id, "icon"], json!(icon));
self.set(&["s", id, "order"], json!(order));
self.set(&["s", id, "live"], json!(true));
}
/// Orders live sections `requested` first, then the rest in their current
/// relative order. Any change rewrites every live `order` register.
pub(crate) fn reorder(&mut self, requested: &[String]) {
let live: Vec<String> = project(self.tree)
.sections
.into_iter()
.map(|s| s.id)
.collect();
let mut ids: Vec<String> = Vec::with_capacity(live.len());
for id in requested.iter().chain(&live) {
if live.contains(id) && !ids.contains(id) {
ids.push(id.clone());
}
}
let unchanged = ids
.iter()
.enumerate()
.all(|(index, id)| self.current(&["s", id, "order"]) == Some(&json!(index)));
if unchanged {
return;
}
for (index, id) in ids.iter().enumerate() {
self.write(&["s", id], "order", json!(index));
}
}
/// Clears an assignment that exists (as a value or tombstone).
pub(crate) fn unassign(&mut self, channel_id: &str) {
let exists = self.tree.get("a").and_then(|a| a.get(channel_id)).is_some();
if exists {
self.set(&["a", channel_id], Value::Null);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const DEV: &str = "0123456789abcdef";
fn stamp(now_ms: i64) -> Stamp {
Stamp {
now_ms,
dev: DEV.into(),
}
}
fn reg(v: i64, value: Value) -> Value {
json!([v, DEV, value])
}
/// A document in the shape desktop `encodeDoc` publishes.
fn desktop_doc() -> Value {
json!({
"version": 1,
"sections": [],
"assignments": {},
"meta": {
"v": 1,
"s": {
"b": {"name": reg(5, json!("Beta")), "icon": reg(5, Value::Null),
"order": reg(5, json!(1)), "live": reg(5, json!(true))},
"a": {"name": reg(4, json!("Alpha")), "icon": reg(4, json!("🚀")),
"order": reg(4, json!(0)), "live": reg(4, json!(true))},
"dead": {"name": reg(3, json!("Gone")), "icon": reg(3, Value::Null),
"order": reg(3, json!(2)), "live": reg(6, json!(false))},
"bad": {"name": [7, "not-a-dev", "Bad"], "order": reg(7, json!(3)),
"live": reg(7, json!(true))},
},
"a": {
"c1": reg(8, json!("a")),
"c2": reg(8, json!("dead")),
"c3": reg(9, Value::Null),
},
"future": reg(1, json!("ignored")),
},
})
}
#[test]
fn decodes_desktop_document_and_projects_live_state() {
let tree = decode(&desktop_doc(), 0).expect("readable");
let view = project(&tree);
let names: Vec<_> = view
.sections
.iter()
.map(|s| (s.name.as_str(), s.order))
.collect();
// `bad` keeps its valid registers but has no name, so it is not live.
assert_eq!(names, [("Alpha", 0), ("Beta", 1)]);
assert_eq!(view.sections[0].icon.as_deref(), Some("🚀"));
assert_eq!(
view.assignments,
BTreeMap::from([("c1".to_string(), "a".to_string())])
);
assert!(!tree.contains_key("future"));
}
#[test]
fn unreadable_payloads_are_rejected() {
for payload in [
json!({"version": 2, "meta": {"v": 1}}),
json!({"version": 1, "meta": {"v": 2}}),
json!({"meta": {"v": 1}}),
json!("text"),
] {
assert!(decode(&payload, 0).is_none(), "{payload}");
}
let bad_version = json!({"version": 1.0, "meta": {"v": 1, "s": {"x": {
"order": [i64::MIN, DEV, 0], "live": [-1, DEV, true], "name": [1, DEV, "X"]}}}});
let tree = decode(&bad_version, 0).expect("readable");
assert_eq!(tree["s"]["x"], json!({"name": [1, DEV, "X"]}));
}
#[test]
fn imports_mobile_flat_payload_at_the_event_version() {
let mobile = json!({
"version": 1,
"sections": [
{"id": "x", "name": "Team", "icon": " ", "order": 1.6},
{"id": "y", "name": "Ops", "order": 0},
{"name": "no id", "order": 0},
],
"assignments": {"c1": "x", "c2": "missing"},
});
let tree = decode(&mobile, 42_000).expect("readable");
assert_eq!(tree["s"]["x"]["order"], json!([42_000, LEGACY_DEV, 2]));
assert_eq!(tree["s"]["x"]["icon"], json!([42_000, LEGACY_DEV, null]));
let view = project(&tree);
let names: Vec<_> = view.sections.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["Ops", "Team"]);
assert_eq!(view.assignments.len(), 1);
}
#[test]
fn encode_round_trips_and_carries_the_projection() {
let tree = decode(&desktop_doc(), 0).expect("readable");
let doc = encode(&tree);
assert_eq!(doc["meta"]["v"], json!(1));
assert_eq!(doc["sections"][0]["name"], json!("Alpha"));
assert!(doc["sections"][1].get("icon").is_none());
assert_eq!(doc["assignments"], json!({"c1": "a"}));
assert_eq!(decode(&doc, 0).expect("readable"), tree);
}
#[test]
fn edits_share_one_stamp_newer_than_every_register() {
let mut tree = decode(&desktop_doc(), 0).expect("readable");
let mut edit = Edit::new(&mut tree, &stamp(1));
edit.create_section("n", "New", None);
edit.set(&["a", "c9"], json!("n"));
assert_eq!(tree["s"]["n"]["order"], reg(10, json!(2)));
assert_eq!(tree["a"]["c9"], reg(10, json!("n")));
let mut edit = Edit::new(&mut tree, &stamp(500));
edit.set(&["s", "n", "name"], json!("New"));
assert_eq!(
tree["s"]["n"]["name"],
reg(10, json!("New")),
"unchanged leaf"
);
}
#[test]
fn reorder_puts_requested_first_and_keeps_the_rest() {
let mut tree = Tree::new();
let mut edit = Edit::new(&mut tree, &stamp(1));
for id in ["a", "b", "c"] {
edit.create_section(id, id, None);
}
let mut edit = Edit::new(&mut tree, &stamp(2));
edit.reorder(&["c".into(), "dead".into(), "c".into()]);
let ids: Vec<_> = project(&tree).sections.into_iter().map(|s| s.id).collect();
assert_eq!(ids, ["c", "a", "b"]);
assert_eq!(tree["s"]["a"]["order"], json!([2, DEV, 1]));
let before = tree.clone();
Edit::new(&mut tree, &stamp(3)).reorder(&["c".into(), "a".into()]);
assert_eq!(
tree, before,
"a reorder to the current order writes nothing"
);
}
#[test]
fn unassign_tombstones_only_existing_assignments() {
let mut tree = Tree::new();
let mut edit = Edit::new(&mut tree, &stamp(1));
edit.create_section("s1", "S", None);
edit.set(&["a", "c1"], json!("s1"));
let mut edit = Edit::new(&mut tree, &stamp(2));
edit.unassign("c1");
edit.unassign("never");
assert_eq!(tree["a"]["c1"], json!([2, DEV, null]));
assert!(tree["a"].get("never").is_none());
}
}

View File

@@ -284,6 +284,9 @@ enum Cmd {
/// Get and set channel canvas documents
#[command(subcommand)]
Canvas(CanvasCmd),
/// Organize your own sidebar into channel sections
#[command(subcommand)]
Sidebar(SidebarCmd),
/// Add, remove, and list emoji reactions
#[command(subcommand)]
Reactions(ReactionsCmd),
@@ -1022,6 +1025,71 @@ pub enum ReactionsCmd {
},
}
#[derive(Subcommand)]
pub enum SidebarCmd {
/// List, create, and arrange your channel sections (synced with Buzz desktop)
#[command(subcommand)]
Sections(SidebarSectionsCmd),
}
/// `--section` takes a section id, or a section name that is unique.
#[derive(Subcommand)]
pub enum SidebarSectionsCmd {
/// List your sections and the channels assigned to each one
List,
/// Create a section at the end of your sidebar
Create {
/// Section name
#[arg(long)]
name: String,
/// Emoji shown next to the section name
#[arg(long)]
icon: Option<String>,
},
/// Rename a section or change its icon
Update {
/// Section id or unique name
#[arg(long)]
section: String,
/// New section name
#[arg(long)]
name: Option<String>,
/// New emoji icon
#[arg(long, conflicts_with = "clear_icon")]
icon: Option<String>,
/// Remove the section icon
#[arg(long, default_value_t = false)]
clear_icon: bool,
},
/// Delete a section; its channels return to the default channel list
Delete {
/// Section id or unique name
#[arg(long)]
section: String,
},
/// Put sections in order; unlisted sections keep their order after them
Reorder {
/// Section id or unique name, in the new order (repeatable)
#[arg(long = "section", required = true)]
sections: Vec<String>,
},
/// Move one or more channels into a section
Move {
/// Channel UUID (repeatable)
#[arg(long = "channel", required = true)]
channels: Vec<String>,
/// Section id or unique name
#[arg(long)]
section: String,
},
/// Return one or more channels to the default channel list
Unassign {
/// Channel UUID (repeatable)
#[arg(long = "channel", required = true)]
channels: Vec<String>,
},
}
#[derive(Subcommand)]
pub enum EmojiCmd {
/// List the workspace custom emoji palette (union of every member's set)
@@ -2430,6 +2498,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {
Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await,
Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await,
Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await,
Cmd::Sidebar(sub) => commands::sidebar::dispatch(sub, &client).await,
Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await,
Cmd::Emoji(sub) => commands::emoji::dispatch(sub, &client).await,
Cmd::Gifs(sub) => commands::gifs::dispatch(sub, &client).await,
@@ -2662,6 +2731,7 @@ mod tests {
"projects",
"reactions",
"repos",
"sidebar",
"social",
"upload",
"users",

View File

@@ -67,6 +67,7 @@ Output varies by command group — `--help` shows flags but not response shapes.
| `mem hash` | SHA-256 hex string |
| `mem set/patch/rm` | nothing to stdout; progress to stderr |
| `mem ls` | tab-delimited (`slug\tcreated_at\tevent_id`) by default; `--json` for JSON array |
| `sidebar sections list` | `{sections: [{id, name, icon?, order}], assignments: {channel_id: section_id}}` |
| `reactions get` | `{"reactions": [{emoji, count, pubkeys}]}` — aggregated, not raw events |
| `pack validate/inspect` | human-readable text, not JSON |