diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs index 71191a5..bfb3a6c 100644 --- a/src/coremods/core_message.rs +++ b/src/coremods/core_message.rs @@ -127,9 +127,40 @@ pub fn commands() -> Vec> { Box::new(Redact), Box::new(MarkRead), Box::new(Metadata), + Box::new(Batch), ] } +/// BATCH — the client side of draft/multiline. `BATCH + draft/multiline +/// ` opens a batch; the `@batch=`-tagged PRIVMSG/NOTICE lines are +/// buffered (see `Ircd::dispatch`); `BATCH -` assembles them (honouring +/// `draft/multiline-concat`) and delivers each logical line normally. +struct Batch; +impl Command for Batch { + fn name(&self) -> &'static str { + "BATCH" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let tag = ¶ms[0]; + if let Some(bref) = tag.strip_prefix('+') { + if params.get(1).map(|t| t.as_str()) == Some("draft/multiline") { + let target = params.get(2).cloned().unwrap_or_default(); + s.multiline_open(uid, bref, &target); + } + } else if let Some(bref) = tag.strip_prefix('-') { + if let Some((target, notice, lines)) = s.multiline_close(uid, bref) { + for line in lines { + deliver(s, uid, &[target.clone(), line], notice); + } + } + } + CmdResult::Ok + } +} + /// 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 (`*` diff --git a/src/ircd.rs b/src/ircd.rs index d5734ae..c94a59b 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -191,6 +191,22 @@ impl Ircd { if registered && !matches!(cmd, "PING" | "PONG" | "QUIT") && self.server.user_shunned(uid) { return; } + // draft/multiline: a PRIVMSG/NOTICE tagged for an open batch is buffered, + // not delivered on its own — it's assembled and sent when the BATCH closes. + if let Some(bref) = &msg.batch { + if matches!(cmd, "PRIVMSG" | "NOTICE") && msg.params.len() >= 2 { + let consumed = self.server.multiline_accumulate( + uid, + bref, + cmd == "NOTICE", + &msg.params[1], + msg.concat, + ); + if consumed { + return; + } + } + } // module pre-command gate for m in &mut self.modules { if m.on_pre_command(&mut self.server, uid, cmd, &msg.params) == ModResult::Deny { diff --git a/src/link.rs b/src/link.rs index a702840..dce2d21 100644 --- a/src/link.rs +++ b/src/link.rs @@ -554,6 +554,8 @@ impl Server { params: msg.params[2..].to_vec(), ctags: String::new(), label: None, + batch: None, + concat: false, }; self.on_link(from, &sub); } diff --git a/src/message.rs b/src/message.rs index 45a7bd1..e562d60 100644 --- a/src/message.rs +++ b/src/message.rs @@ -15,6 +15,12 @@ pub struct Message { /// The IRCv3 `label` tag value, if the client tagged this command (for /// labeled-response); `None` otherwise. pub label: Option, + /// The `batch` tag value (which client batch this line belongs to), for + /// inbound draft/multiline. + pub batch: Option, + /// Whether the line carried the `draft/multiline-concat` tag (join to the + /// previous multiline part with no newline). + pub concat: bool, } impl Message { @@ -47,6 +53,8 @@ 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; + let mut batch = None; + let mut concat = false; if let Some(after_at) = rest.strip_prefix('@') { let (tags, r) = after_at.split_once(' ')?; ctags = tags @@ -58,6 +66,11 @@ pub fn parse(line: &str) -> Option { .split(';') .find_map(|t| t.strip_prefix("label=")) .map(|v| v.to_string()); + batch = tags + .split(';') + .find_map(|t| t.strip_prefix("batch=")) + .map(|v| v.to_string()); + concat = tags.split(';').any(|t| t == "draft/multiline-concat"); rest = r.trim_start(); } @@ -100,6 +113,8 @@ pub fn parse(line: &str) -> Option { params, ctags, label, + batch, + concat, }) } diff --git a/src/server.rs b/src/server.rs index b1e0a4c..2b6b5af 100644 --- a/src/server.rs +++ b/src/server.rs @@ -97,6 +97,20 @@ pub struct HistMsg { pub text: String, } +/// Limits advertised in the `draft/multiline` cap and enforced while buffering. +pub const MLINE_MAX_BYTES: usize = 4096; +pub const MLINE_MAX_LINES: usize = 24; + +/// An in-progress inbound draft/multiline batch — one long client message being +/// assembled from several `@batch=`-tagged PRIVMSG/NOTICE lines. +pub struct MlineBatch { + pub bref: String, + pub target: String, + pub notice: bool, + pub parts: Vec<(String, bool)>, // (text, concat-with-previous-part) + pub bytes: usize, +} + /// A recently-departed identity, kept for WHOWAS. pub struct WhowasEntry { pub nick: String, @@ -152,6 +166,7 @@ pub struct Server { pub history: HashMap>, // channel key -> recent messages (CHATHISTORY) pub read_markers: HashMap>, // identity -> target -> read ts (MARKREAD) pub metadata: HashMap>, // target key -> key -> value (draft/metadata-2) + pub mline: HashMap, // in-progress inbound multiline batches pub event_tx: Sender, // self-inject events (DNS results) pub conn_counter: Arc, // mints connection uids (for CONNECT dials) } @@ -198,6 +213,7 @@ impl Server { history: HashMap::new(), read_markers: HashMap::new(), metadata: HashMap::new(), + mline: HashMap::new(), event_tx, conn_counter, } @@ -252,6 +268,64 @@ impl Server { } } + /// Open an inbound draft/multiline batch for `uid` (a client assembling one + /// long message from several tagged PRIVMSG/NOTICE lines). + pub fn multiline_open(&mut self, uid: Uid, bref: &str, target: &str) { + self.mline.insert( + uid, + MlineBatch { + bref: bref.to_string(), + target: target.to_string(), + notice: false, + parts: Vec::new(), + bytes: 0, + }, + ); + } + + /// Buffer one PRIVMSG/NOTICE line into `uid`'s open multiline batch when `bref` + /// matches (bounded by the advertised byte/line limits). Returns true if it was + /// part of the batch — i.e. it should not be delivered on its own. + pub fn multiline_accumulate( + &mut self, + uid: Uid, + bref: &str, + notice: bool, + text: &str, + concat: bool, + ) -> bool { + match self.mline.get_mut(&uid) { + Some(mb) if mb.bref == bref => { + if mb.parts.len() < MLINE_MAX_LINES && mb.bytes + text.len() <= MLINE_MAX_BYTES { + mb.notice = notice; + mb.bytes += text.len(); + mb.parts.push((text.to_string(), concat)); + } + true + } + _ => false, + } + } + + /// Close `uid`'s multiline batch `bref` and return `(target, is_notice, lines)` + /// with `concat` parts joined into single logical lines. `None` if no match. + pub fn multiline_close(&mut self, uid: Uid, bref: &str) -> Option<(String, bool, Vec)> { + match self.mline.get(&uid) { + Some(mb) if mb.bref == bref => {} + _ => return None, + } + let mb = self.mline.remove(&uid)?; + let mut lines: Vec = Vec::new(); + for (text, concat) in mb.parts { + if concat && !lines.is_empty() { + lines.last_mut().unwrap().push_str(&text); + } else { + lines.push(text); + } + } + Some((mb.target, mb.notice, lines)) + } + /// 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. @@ -453,6 +527,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 + self.mline.remove(&uid); // any half-open multiline batch if user.registered { self.push_whowas( &user.nick, diff --git a/src/users.rs b/src/users.rs index 3404d38..6c725c3 100644 --- a/src/users.rs +++ b/src/users.rs @@ -8,7 +8,7 @@ use std::net::{SocketAddr, TcpStream}; use crate::extensible::Extensible; use crate::module::Hook; use crate::numeric::*; -use crate::server::{Server, VERSION}; +use crate::server::{Server, MLINE_MAX_BYTES, MLINE_MAX_LINES, VERSION}; use crate::socketengine::OutSink; use crate::Uid; @@ -101,6 +101,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[ "draft/message-redaction", "draft/pre-away", "draft/metadata-2", + "draft/multiline", "cap-notify", ]; @@ -130,6 +131,7 @@ pub struct Caps { 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 multiline: bool, // draft/multiline — may send multiline message batches pub cap_notify: bool, } @@ -150,6 +152,10 @@ impl Caps { } else { "sasl=PLAIN".to_string() } + } else if *c == "draft/multiline" && cap302 { + format!( + "draft/multiline=max-bytes={MLINE_MAX_BYTES},max-lines={MLINE_MAX_LINES}" + ) } else { (*c).to_string() } @@ -181,6 +187,7 @@ impl Caps { "draft/message-redaction" => self.message_redaction, "draft/pre-away" => self.pre_away, "draft/metadata-2" => self.metadata, + "draft/multiline" => self.multiline, "cap-notify" => self.cap_notify, _ => false, } @@ -210,6 +217,7 @@ impl Caps { "draft/message-redaction" => &mut self.message_redaction, "draft/pre-away" => &mut self.pre_away, "draft/metadata-2" => &mut self.metadata, + "draft/multiline" => &mut self.multiline, "cap-notify" => &mut self.cap_notify, _ => return false, };