From 98ac147cc97b8aa7299fec7dac7bd21a6d683eab Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 8 Aug 2026 19:53:34 +0000 Subject: [PATCH] labeled-response + batch: capture a labeled command's replies and wrap them (single label tag / BATCH / ACK) --- src/ircd.rs | 72 +++++++++++++++++++++++++++++++++++++++++++++++++- src/link.rs | 1 + src/message.rs | 9 +++++++ src/server.rs | 27 +++++++++++++++++-- src/users.rs | 8 ++++++ 5 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/ircd.rs b/src/ircd.rs index 9c4de9c..9ca4f5b 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -46,6 +46,19 @@ pub enum Event { Tick, } +/// Insert an extra IRCv3 tag into a wire line's tag block, creating the `@…` +/// block if the line has none. Used to fold `label=`/`batch=` onto captured lines. +fn with_extra_tag(line: &str, tag: &str) -> String { + if let Some(rest) = line.strip_prefix('@') { + match rest.split_once(' ') { + Some((tags, body)) => format!("@{tags};{tag} {body}"), + None => format!("@{rest};{tag}"), + } + } else { + format!("@{tag} {line}") + } +} + pub struct Ircd { server: Server, commands: HashMap<&'static str, Box>, @@ -126,7 +139,6 @@ impl Ircd { u.last_active = crate::server::now(); u.ping_sent = false; } - let cmd = msg.command.as_str(); let registered = self .server .users @@ -140,6 +152,36 @@ impl Ircd { return; } + // labeled-response: if the client tagged this command with `label` and + // negotiated the cap, capture its own replies and wrap them with the label. + let label = msg.label.clone().filter(|_| { + self.server + .users + .get(&uid) + .map(|u| u.caps.labeled_response) + .unwrap_or(false) + }); + if let Some(label) = label { + *self.server.label_capture.borrow_mut() = Some((uid, Vec::new())); + self.dispatch(uid, &msg, registered); + let lines = self + .server + .label_capture + .borrow_mut() + .take() + .map(|(_, l)| l) + .unwrap_or_default(); + self.emit_labeled(uid, &label, lines); + } else { + self.dispatch(uid, &msg, registered); + } + } + + /// Run one parsed command: module gates, handler dispatch, post-hooks, and the + /// registration/quit follow-ups. Output goes through `Server::send`, so it's + /// transparently captured when a labeled command wraps this call. + fn dispatch(&mut self, uid: Uid, msg: &message::Message, registered: bool) { + let cmd = msg.command.as_str(); // module pre-command gate for m in &mut self.modules { if m.on_pre_command(&mut self.server, uid, cmd, &msg.params) == ModResult::Deny { @@ -193,6 +235,34 @@ impl Ircd { } } + /// Emit a labeled command's captured replies (labeled-response): `ACK` if it + /// produced none, the single line label-tagged if one, else a `BATCH`-wrapped + /// group. Runs after the capture is taken, so these go straight to the wire. + fn emit_labeled(&mut self, uid: Uid, label: &str, lines: Vec) { + let server = self.server.name.clone(); + match lines.len() { + 0 => self + .server + .send(uid, format!("@label={label} :{server} ACK")), + 1 => { + let l = with_extra_tag(&lines[0], &format!("label={label}")); + self.server.send(uid, l); + } + _ => { + let bref = self.server.next_msgid().replace('-', ""); // batch ref: alnum only + self.server.send( + uid, + format!("@label={label} :{server} BATCH +{bref} labeled-response"), + ); + for l in lines { + self.server + .send(uid, with_extra_tag(&l, &format!("batch={bref}"))); + } + self.server.send(uid, format!(":{server} BATCH -{bref}")); + } + } + } + /// Finish registration if NICK, USER, CAP and the reverse-DNS lookup are all /// done. Called after each command and when a DNS result arrives. fn try_register(&mut self, uid: Uid) { diff --git a/src/link.rs b/src/link.rs index 65b6305..c7a3b3f 100644 --- a/src/link.rs +++ b/src/link.rs @@ -496,6 +496,7 @@ impl Server { command: msg.params[1].to_ascii_uppercase(), params: msg.params[2..].to_vec(), ctags: String::new(), + label: None, }; self.on_link(via, &sub); } diff --git a/src/message.rs b/src/message.rs index 0dda7b9..ca2e940 100644 --- a/src/message.rs +++ b/src/message.rs @@ -12,6 +12,9 @@ pub struct Message { pub params: Vec, /// Client-only IRCv3 tags (`+key=val;…`) re-serialised for relay; `""` if none. pub ctags: String, + /// The IRCv3 `label` tag value, if the client tagged this command (for + /// labeled-response); `None` otherwise. + pub label: Option, } /// Parse one wire line. Returns `None` for an empty/garbage line. @@ -20,6 +23,7 @@ pub fn parse(line: &str) -> Option { // IRCv3 message tags — keep the client-only (`+`) tags for relay, drop the rest. let mut ctags = String::new(); + let mut label = None; if let Some(after_at) = rest.strip_prefix('@') { let (tags, r) = after_at.split_once(' ')?; ctags = tags @@ -27,6 +31,10 @@ pub fn parse(line: &str) -> Option { .filter(|t| t.starts_with('+')) .collect::>() .join(";"); + label = tags + .split(';') + .find_map(|t| t.strip_prefix("label=")) + .map(|v| v.to_string()); rest = r.trim_start(); } @@ -68,6 +76,7 @@ pub fn parse(line: &str) -> Option { command: cmd.to_ascii_uppercase(), params, ctags, + label, }) } diff --git a/src/server.rs b/src/server.rs index 3f4912f..3bf0725 100644 --- a/src/server.rs +++ b/src/server.rs @@ -5,6 +5,7 @@ //! keeps usermanager / channelmanager separate from the core. No locks: only the //! single core thread ever holds a `Server`. +use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; use std::net::{SocketAddr, TcpStream}; use std::sync::mpsc::Sender; @@ -105,7 +106,12 @@ pub struct Server { pub dnsbl_reason: String, // ban reason on a DNSBL hit pub sasl_server: String, // services server that handles SASL pub webirc: Vec<(String, String)>, // trusted web gateways: (password, name) - pub event_tx: Sender, // self-inject events (DNS results) + // labeled-response: while Some((uid, buf)), that client's own responses are + // diverted into `buf` instead of the socket, so `on_line` can wrap them with + // the command's `label` (single tag, BATCH, or ACK). RefCell because the + // output primitives are `&self`. + pub label_capture: RefCell)>>, + pub event_tx: Sender, // self-inject events (DNS results) } impl Server { @@ -146,6 +152,7 @@ impl Server { dnsbl_reason: cfg.dnsbl_reason, sasl_server: cfg.sasl_server, webirc: cfg.webirc, + label_capture: RefCell::new(None), event_tx, } } @@ -399,6 +406,22 @@ impl Server { } else { line }; + self.emit_to(uid, line); + } + } + + /// Final hop for one line to a client: diverted into the labeled-response + /// capture buffer when one is active for `uid`, otherwise written to the wire. + fn emit_to(&self, uid: Uid, line: String) { + if let Ok(mut cap) = self.label_capture.try_borrow_mut() { + if let Some((cuid, buf)) = cap.as_mut() { + if *cuid == uid { + buf.push(line); + return; + } + } + } + if let Some(u) = self.users.get(&uid) { u.out.send(line); } } @@ -536,7 +559,7 @@ impl Server { } else { format!("@{} {body}", tags.join(";")) }; - u.out.send(line); + self.emit_to(uid, line); } } diff --git a/src/users.rs b/src/users.rs index 46e755e..4e796c2 100644 --- a/src/users.rs +++ b/src/users.rs @@ -95,6 +95,8 @@ pub const SUPPORTED_CAPS: &[&str] = &[ "extended-monitor", "account-tag", "standard-replies", + "labeled-response", + "batch", "cap-notify", ]; @@ -118,6 +120,8 @@ pub struct Caps { pub extended_monitor: bool, // route away/account/chghost/setname for MONITOR targets pub account_tag: bool, // prepend account= tag on messages from logged-in users pub standard_replies: bool, // understands FAIL/WARN/NOTE structured replies + pub labeled_response: bool, // tag responses to a labeled command with its label + pub batch: bool, // understands BATCH framing pub cap_notify: bool, } @@ -163,6 +167,8 @@ impl Caps { "extended-monitor" => self.extended_monitor, "account-tag" => self.account_tag, "standard-replies" => self.standard_replies, + "labeled-response" => self.labeled_response, + "batch" => self.batch, "cap-notify" => self.cap_notify, _ => false, } @@ -186,6 +192,8 @@ impl Caps { "extended-monitor" => &mut self.extended_monitor, "account-tag" => &mut self.account_tag, "standard-replies" => &mut self.standard_replies, + "labeled-response" => &mut self.labeled_response, + "batch" => &mut self.batch, "cap-notify" => &mut self.cap_notify, _ => return false, };