From 5a34ec57da73ec235c0d92522209c5a6de7f3b43 Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 8 Aug 2026 22:00:46 +0000 Subject: [PATCH] draft/metadata-2: client METADATA get/list/set/clear on users+channels (op-gated), change notices, metadata batch --- src/coremods/core_message.rs | 145 +++++++++++++++++++++++++++++++++++ src/numeric.rs | 2 + src/server.rs | 17 +++- src/users.rs | 4 + 4 files changed, 167 insertions(+), 1 deletion(-) diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs index 1f4a934..71191a5 100644 --- a/src/coremods/core_message.rs +++ b/src/coremods/core_message.rs @@ -126,9 +126,154 @@ pub fn commands() -> Vec> { Box::new(ChatHistory), Box::new(Redact), Box::new(MarkRead), + Box::new(Metadata), ] } +/// METADATA — draft/metadata-2. `METADATA [args]`. +/// `` is `*` (self), a nick, or a `#channel`. Anyone may GET/LIST; only the +/// user themselves or a channel op/oper may SET/CLEAR. Values are public (`*` +/// visibility); a change is pushed as `: METADATA * [:val]` +/// to metadata-capable viewers (self for a user, members for a channel). +struct Metadata; +impl Command for Metadata { + fn name(&self) -> &'static str { + "METADATA" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let target = params[0].clone(); + let sub = params[1].to_ascii_uppercase(); + let me_key = format!("u{uid}"); + let key = if target == "*" { + me_key.clone() + } else { + match s.meta_key(&target) { + Some(k) => k, + None => { + s.fail( + uid, + "METADATA", + "INVALID_TARGET", + &format!("{target} invalid target"), + ); + return CmdResult::Fail; + } + } + }; + let reqnick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_else(|| "*".to_string()); + let disp = if target == "*" { + reqnick.clone() + } else { + target.clone() + }; + let can_set = key == me_key + || (key.starts_with('#') && s.rank(uid, &key) >= RANK_HALFOP) + || s.is_oper(uid); + + match sub.as_str() { + "GET" | "LIST" => { + // GET lists the named keys; LIST lists every set key + let keys: Vec = if sub == "LIST" { + s.metadata + .get(&key) + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default() + } else { + params[2..].to_vec() + }; + let bref = s.next_msgid().replace('-', ""); + s.send(uid, format!(":{} BATCH +{bref} metadata", s.name)); + for k in keys { + let line = match s.metadata.get(&key).and_then(|m| m.get(&k)) { + Some(v) => format!( + "@batch={bref} :{} {RPL_KEYVALUE} {reqnick} {disp} {k} * :{v}", + s.name + ), + None => format!( + "@batch={bref} :{} {RPL_KEYNOTSET} {reqnick} {disp} {k} :key not set", + s.name + ), + }; + s.send(uid, line); + } + s.send(uid, format!(":{} BATCH -{bref}", s.name)); + } + "SET" => { + if !can_set { + s.fail( + uid, + "METADATA", + "KEY_NO_PERMISSION", + &format!("{disp} permission denied"), + ); + return CmdResult::Fail; + } + let Some(mkey) = params.get(2).cloned() else { + s.fail(uid, "METADATA", "KEY_INVALID", "missing key"); + return CmdResult::Fail; + }; + let value = params.get(3).cloned(); // no value => delete the key + match &value { + Some(v) => { + s.metadata + .entry(key.clone()) + .or_default() + .insert(mkey.clone(), v.clone()); + } + None => { + if let Some(m) = s.metadata.get_mut(&key) { + m.remove(&mkey); + } + } + } + let setter = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default(); + let note = match &value { + Some(v) => format!(":{setter} METADATA {disp} {mkey} * :{v}"), + None => format!(":{setter} METADATA {disp} {mkey} *"), + }; + let recips: Vec = if key.starts_with('#') { + s.channels + .get(&key) + .map(|c| c.members.keys().copied().collect()) + .unwrap_or_default() + } else { + vec![uid] + }; + for r in recips { + if s.users.get(&r).map(|u| u.caps.metadata).unwrap_or(false) { + s.send(r, note.clone()); + } + } + } + "CLEAR" => { + if !can_set { + s.fail( + uid, + "METADATA", + "KEY_NO_PERMISSION", + &format!("{disp} permission denied"), + ); + return CmdResult::Fail; + } + s.metadata.remove(&key); + } + "SUB" | "UNSUB" => {} // all metadata is public here; subscriptions are a no-op + _ => { + s.fail(uid, "METADATA", "INVALID_SUBCOMMAND", &sub); + return CmdResult::Fail; + } + } + CmdResult::Ok + } +} + /// MARKREAD — draft/read-marker. `MARKREAD [timestamp=]`. With a /// timestamp it sets the read marker (only ever advancing) and echoes it to every /// connection sharing the user's identity (multi-device); without one it returns diff --git a/src/numeric.rs b/src/numeric.rs index 0bdf01c..a03c817 100644 --- a/src/numeric.rs +++ b/src/numeric.rs @@ -99,6 +99,8 @@ pub const RPL_NOTOPIC: u16 = 331; pub const RPL_TOPIC: u16 = 332; pub const RPL_WHOREPLY: u16 = 352; pub const RPL_WHOSPCRPL: u16 = 354; // WHOX: field-selected WHO reply +pub const RPL_KEYVALUE: u16 = 761; // draft/metadata-2: : +pub const RPL_KEYNOTSET: u16 = 766; // draft/metadata-2: key not set pub const RPL_NAMREPLY: u16 = 353; pub const RPL_ENDOFNAMES: u16 = 366; diff --git a/src/server.rs b/src/server.rs index 9b4c154..b1e0a4c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -151,7 +151,8 @@ pub struct Server { pub label_capture: RefCell)>>, pub history: HashMap>, // channel key -> recent messages (CHATHISTORY) pub read_markers: HashMap>, // identity -> target -> read ts (MARKREAD) - pub event_tx: Sender, // self-inject events (DNS results) + pub metadata: HashMap>, // target key -> key -> value (draft/metadata-2) + pub event_tx: Sender, // self-inject events (DNS results) pub conn_counter: Arc, // mints connection uids (for CONNECT dials) } @@ -196,6 +197,7 @@ impl Server { label_capture: RefCell::new(None), history: HashMap::new(), read_markers: HashMap::new(), + metadata: HashMap::new(), event_tx, conn_counter, } @@ -250,6 +252,18 @@ impl Server { } } + /// Resolve a METADATA target (a nick or `#channel`) to its metadata storage + /// key, or `None` if it doesn't exist. User keys are `u` (stable across + /// nick changes); channel keys are the lowercased name. + pub fn meta_key(&self, target: &str) -> Option { + if let Some(chan) = target.strip_prefix('#') { + let k = format!("#{}", chan.to_ascii_lowercase()); + self.channels.contains_key(&k).then_some(k) + } else { + self.find_nick(target).map(|u| format!("u{u}")) + } + } + /// The read-marker identity for `uid`: their account when logged in (so markers /// are shared across their devices and survive reconnects), else a per-session /// key. `remove_user` prunes the session key on disconnect. @@ -438,6 +452,7 @@ impl Server { }; self.uuid_local.remove(&user.uuid); self.read_markers.remove(&format!("~{uid}")); // session read-markers (kept if account-keyed) + self.metadata.remove(&format!("u{uid}")); // per-user metadata if user.registered { self.push_whowas( &user.nick, diff --git a/src/users.rs b/src/users.rs index 0f146bc..3404d38 100644 --- a/src/users.rs +++ b/src/users.rs @@ -100,6 +100,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[ "draft/chathistory", "draft/message-redaction", "draft/pre-away", + "draft/metadata-2", "cap-notify", ]; @@ -128,6 +129,7 @@ pub struct Caps { pub chathistory: bool, // draft/chathistory — can request message history pub message_redaction: bool, // draft/message-redaction — understands REDACT pub pre_away: bool, // draft/pre-away — may set AWAY before registration + pub metadata: bool, // draft/metadata-2 — wants metadata + change notices pub cap_notify: bool, } @@ -178,6 +180,7 @@ impl Caps { "draft/chathistory" => self.chathistory, "draft/message-redaction" => self.message_redaction, "draft/pre-away" => self.pre_away, + "draft/metadata-2" => self.metadata, "cap-notify" => self.cap_notify, _ => false, } @@ -206,6 +209,7 @@ impl Caps { "draft/chathistory" => &mut self.chathistory, "draft/message-redaction" => &mut self.message_redaction, "draft/pre-away" => &mut self.pre_away, + "draft/metadata-2" => &mut self.metadata, "cap-notify" => &mut self.cap_notify, _ => return false, };