draft/metadata-2: client METADATA get/list/set/clear on users+channels (op-gated), change notices, metadata batch
This commit is contained in:
parent
c641e23a81
commit
5a34ec57da
4 changed files with 167 additions and 1 deletions
|
|
@ -126,9 +126,154 @@ pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
Box::new(ChatHistory),
|
Box::new(ChatHistory),
|
||||||
Box::new(Redact),
|
Box::new(Redact),
|
||||||
Box::new(MarkRead),
|
Box::new(MarkRead),
|
||||||
|
Box::new(Metadata),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// METADATA — draft/metadata-2. `METADATA <target> <GET|LIST|SET|CLEAR> [args]`.
|
||||||
|
/// `<target>` 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 `:<setter> METADATA <target> <key> * [: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<String> = 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<Uid> = 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 <target> [timestamp=<iso>]`. With a
|
/// MARKREAD — draft/read-marker. `MARKREAD <target> [timestamp=<iso>]`. With a
|
||||||
/// timestamp it sets the read marker (only ever advancing) and echoes it to every
|
/// 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
|
/// connection sharing the user's identity (multi-device); without one it returns
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,8 @@ pub const RPL_NOTOPIC: u16 = 331;
|
||||||
pub const RPL_TOPIC: u16 = 332;
|
pub const RPL_TOPIC: u16 = 332;
|
||||||
pub const RPL_WHOREPLY: u16 = 352;
|
pub const RPL_WHOREPLY: u16 = 352;
|
||||||
pub const RPL_WHOSPCRPL: u16 = 354; // WHOX: field-selected WHO reply
|
pub const RPL_WHOSPCRPL: u16 = 354; // WHOX: field-selected WHO reply
|
||||||
|
pub const RPL_KEYVALUE: u16 = 761; // draft/metadata-2: <target> <key> <vis> :<value>
|
||||||
|
pub const RPL_KEYNOTSET: u16 = 766; // draft/metadata-2: key not set
|
||||||
pub const RPL_NAMREPLY: u16 = 353;
|
pub const RPL_NAMREPLY: u16 = 353;
|
||||||
pub const RPL_ENDOFNAMES: u16 = 366;
|
pub const RPL_ENDOFNAMES: u16 = 366;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,8 @@ pub struct Server {
|
||||||
pub label_capture: RefCell<Option<(Uid, Vec<String>)>>,
|
pub label_capture: RefCell<Option<(Uid, Vec<String>)>>,
|
||||||
pub history: HashMap<String, VecDeque<HistMsg>>, // channel key -> recent messages (CHATHISTORY)
|
pub history: HashMap<String, VecDeque<HistMsg>>, // channel key -> recent messages (CHATHISTORY)
|
||||||
pub read_markers: HashMap<String, HashMap<String, u64>>, // identity -> target -> read ts (MARKREAD)
|
pub read_markers: HashMap<String, HashMap<String, u64>>, // identity -> target -> read ts (MARKREAD)
|
||||||
pub event_tx: Sender<Event>, // self-inject events (DNS results)
|
pub metadata: HashMap<String, HashMap<String, String>>, // target key -> key -> value (draft/metadata-2)
|
||||||
|
pub event_tx: Sender<Event>, // self-inject events (DNS results)
|
||||||
pub conn_counter: Arc<AtomicU64>, // mints connection uids (for CONNECT dials)
|
pub conn_counter: Arc<AtomicU64>, // mints connection uids (for CONNECT dials)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,6 +197,7 @@ impl Server {
|
||||||
label_capture: RefCell::new(None),
|
label_capture: RefCell::new(None),
|
||||||
history: HashMap::new(),
|
history: HashMap::new(),
|
||||||
read_markers: HashMap::new(),
|
read_markers: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
event_tx,
|
event_tx,
|
||||||
conn_counter,
|
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<uid>` (stable across
|
||||||
|
/// nick changes); channel keys are the lowercased name.
|
||||||
|
pub fn meta_key(&self, target: &str) -> Option<String> {
|
||||||
|
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
|
/// 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
|
/// are shared across their devices and survive reconnects), else a per-session
|
||||||
/// key. `remove_user` prunes the session key on disconnect.
|
/// key. `remove_user` prunes the session key on disconnect.
|
||||||
|
|
@ -438,6 +452,7 @@ impl Server {
|
||||||
};
|
};
|
||||||
self.uuid_local.remove(&user.uuid);
|
self.uuid_local.remove(&user.uuid);
|
||||||
self.read_markers.remove(&format!("~{uid}")); // session read-markers (kept if account-keyed)
|
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 {
|
if user.registered {
|
||||||
self.push_whowas(
|
self.push_whowas(
|
||||||
&user.nick,
|
&user.nick,
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[
|
||||||
"draft/chathistory",
|
"draft/chathistory",
|
||||||
"draft/message-redaction",
|
"draft/message-redaction",
|
||||||
"draft/pre-away",
|
"draft/pre-away",
|
||||||
|
"draft/metadata-2",
|
||||||
"cap-notify",
|
"cap-notify",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -128,6 +129,7 @@ pub struct Caps {
|
||||||
pub chathistory: bool, // draft/chathistory — can request message history
|
pub chathistory: bool, // draft/chathistory — can request message history
|
||||||
pub message_redaction: bool, // draft/message-redaction — understands REDACT
|
pub message_redaction: bool, // draft/message-redaction — understands REDACT
|
||||||
pub pre_away: bool, // draft/pre-away — may set AWAY before registration
|
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,
|
pub cap_notify: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,6 +180,7 @@ impl Caps {
|
||||||
"draft/chathistory" => self.chathistory,
|
"draft/chathistory" => self.chathistory,
|
||||||
"draft/message-redaction" => self.message_redaction,
|
"draft/message-redaction" => self.message_redaction,
|
||||||
"draft/pre-away" => self.pre_away,
|
"draft/pre-away" => self.pre_away,
|
||||||
|
"draft/metadata-2" => self.metadata,
|
||||||
"cap-notify" => self.cap_notify,
|
"cap-notify" => self.cap_notify,
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
|
|
@ -206,6 +209,7 @@ impl Caps {
|
||||||
"draft/chathistory" => &mut self.chathistory,
|
"draft/chathistory" => &mut self.chathistory,
|
||||||
"draft/message-redaction" => &mut self.message_redaction,
|
"draft/message-redaction" => &mut self.message_redaction,
|
||||||
"draft/pre-away" => &mut self.pre_away,
|
"draft/pre-away" => &mut self.pre_away,
|
||||||
|
"draft/metadata-2" => &mut self.metadata,
|
||||||
"cap-notify" => &mut self.cap_notify,
|
"cap-notify" => &mut self.cap_notify,
|
||||||
_ => return false,
|
_ => return false,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue