From ab7188bb706ef04bf7c993489e3f23ec5c8be9d5 Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 8 Aug 2026 22:49:54 +0000 Subject: [PATCH] move draft/multiline into its own module file --- src/coremods/core_message.rs | 34 +-------- src/ircd.rs | 3 +- src/modules/mod.rs | 3 + src/modules/multiline.rs | 138 +++++++++++++++++++++++++++++++++++ src/server.rs | 77 +------------------ src/users.rs | 3 +- 6 files changed, 148 insertions(+), 110 deletions(-) create mode 100644 src/modules/multiline.rs diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs index a3edab5..725538b 100644 --- a/src/coremods/core_message.rs +++ b/src/coremods/core_message.rs @@ -125,40 +125,9 @@ pub fn commands() -> Vec> { Box::new(TagMsg), Box::new(ChatHistory), Box::new(Redact), - 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 - } -} - /// REDACT — delete a previously-sent channel message (draft/message-redaction). /// `REDACT <#chan> [:reason]`. Allowed for the message's author, a channel /// half-op-or-above, or an oper. Relayed to channel members who enabled the cap, @@ -463,7 +432,8 @@ impl Command for ChatHistory { } /// Shared PRIVMSG/NOTICE delivery. NOTICE never generates automatic replies. -fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResult { +/// `pub(crate)` so the multiline module can replay an assembled batch through it. +pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResult { let cmd = if notice { "NOTICE" } else { "PRIVMSG" }; if params.is_empty() { if !notice { diff --git a/src/ircd.rs b/src/ircd.rs index c94a59b..414b043 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -195,7 +195,8 @@ impl Ircd { // 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( + let consumed = crate::modules::multiline::accumulate( + &mut self.server, uid, bref, cmd == "NOTICE", diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 271e973..480e68f 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -10,6 +10,7 @@ pub mod filter; pub mod flood; pub mod markread; pub mod metadata; +pub mod multiline; pub mod snoop; use crate::command::Command; @@ -25,6 +26,7 @@ pub fn default_modules() -> Vec> { Box::new(filter::Filter), Box::new(metadata::Metadata), Box::new(markread::MarkRead), + Box::new(multiline::Multiline), ] } @@ -35,5 +37,6 @@ pub fn module_commands() -> Vec> { .into_iter() .chain(metadata::commands()) .chain(markread::commands()) + .chain(multiline::commands()) .collect() } diff --git a/src/modules/multiline.rs b/src/modules/multiline.rs new file mode 100644 index 0000000..9596dc3 --- /dev/null +++ b/src/modules/multiline.rs @@ -0,0 +1,138 @@ +//! multiline — the server side of IRCv3 draft/multiline. A client wraps one long +//! message in a `BATCH + draft/multiline `; the `@batch=`-tagged +//! PRIVMSG/NOTICE lines are buffered here (see `Ircd::dispatch`) and, when +//! `BATCH -` closes, reassembled (honouring `draft/multiline-concat`) and +//! delivered as normal messages. Self-contained: the in-flight batches live in +//! `Server.ext`, cleaned up by the on_user_quit hook; the BATCH command and the +//! accumulate/close logic are all here. + +use std::collections::HashMap; + +use crate::command::{CmdResult, Command}; +use crate::coremods::core_message::deliver; +use crate::module::Module; +use crate::server::Server; +use crate::Uid; + +/// Limits advertised in the `draft/multiline` cap and enforced while buffering. +pub const MAX_BYTES: usize = 4096; +pub const MAX_LINES: usize = 24; + +/// An in-progress inbound 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, +} + +/// uid -> its open batch. Stored in `Server.ext`. +#[derive(Default)] +pub struct Mline(pub HashMap); + +/// Open an inbound batch for `uid` (a client assembling one long message from +/// several tagged PRIVMSG/NOTICE lines). +fn open(s: &mut Server, uid: Uid, bref: &str, target: &str) { + s.ext.get_or_insert_with::(Mline::default).0.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 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. Called from dispatch. +pub fn accumulate( + s: &mut Server, + uid: Uid, + bref: &str, + notice: bool, + text: &str, + concat: bool, +) -> bool { + match s.ext.get_mut::().and_then(|m| m.0.get_mut(&uid)) { + Some(mb) if mb.bref == bref => { + if mb.parts.len() < MAX_LINES && mb.bytes + text.len() <= MAX_BYTES { + mb.notice = notice; + mb.bytes += text.len(); + mb.parts.push((text.to_string(), concat)); + } + true + } + _ => false, + } +} + +/// Close `uid`'s batch `bref` and return `(target, is_notice, lines)` with `concat` +/// parts joined into single logical lines. `None` if no match. +fn close(s: &mut Server, uid: Uid, bref: &str) -> Option<(String, bool, Vec)> { + let store = s.ext.get_mut::()?; + match store.0.get(&uid) { + Some(mb) if mb.bref == bref => {} + _ => return None, + } + let mb = store.0.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)) +} + +/// Cleanup hook: drop any half-open batch on disconnect. +pub struct Multiline; +impl Module for Multiline { + fn name(&self) -> &'static str { + "multiline" + } + fn on_user_quit(&mut self, s: &mut Server, uid: Uid, _reason: &str) { + if let Some(m) = s.ext.get_mut::() { + m.0.remove(&uid); + } + } +} + +pub fn commands() -> Vec> { + vec![Box::new(Batch)] +} + +/// BATCH — the client side of draft/multiline. `BATCH + draft/multiline +/// ` opens a batch; the tagged lines are buffered; `BATCH -` assembles +/// them and delivers each logical line as a normal PRIVMSG/NOTICE. +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(); + open(s, uid, bref, &target); + } + } else if let Some(bref) = tag.strip_prefix('-') { + if let Some((target, notice, lines)) = close(s, uid, bref) { + for line in lines { + deliver(s, uid, &[target.clone(), line], notice); + } + } + } + CmdResult::Ok + } +} diff --git a/src/server.rs b/src/server.rs index dea798d..9fee56f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -97,20 +97,6 @@ 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, @@ -164,8 +150,7 @@ pub struct Server { // output primitives are `&self`. pub label_capture: RefCell)>>, pub history: HashMap>, // channel key -> recent messages (CHATHISTORY) - pub mline: HashMap, // in-progress inbound multiline batches - pub event_tx: Sender, // self-inject events (DNS results) + pub event_tx: Sender, // self-inject events (DNS results) pub conn_counter: Arc, // mints connection uids (for CONNECT dials) /// Module-owned server state, keyed by type — the InspIRCd `ExtensionItem` /// equivalent. Each `modules/*.rs` stores its own struct here so features live @@ -213,7 +198,6 @@ impl Server { webirc: cfg.webirc, label_capture: RefCell::new(None), history: HashMap::new(), - mline: HashMap::new(), event_tx, conn_counter, ext: Extensible::default(), @@ -269,64 +253,6 @@ 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)) - } - // --- connection lifecycle ------------------------------------------------ pub fn add_conn( @@ -504,7 +430,6 @@ impl Server { return; }; self.uuid_local.remove(&user.uuid); - 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 8039f84..7e14347 100644 --- a/src/users.rs +++ b/src/users.rs @@ -8,7 +8,8 @@ use std::net::{SocketAddr, TcpStream}; use crate::extensible::Extensible; use crate::module::Hook; use crate::numeric::*; -use crate::server::{Server, MLINE_MAX_BYTES, MLINE_MAX_LINES, VERSION}; +use crate::modules::multiline::{MAX_BYTES as MLINE_MAX_BYTES, MAX_LINES as MLINE_MAX_LINES}; +use crate::server::{Server, VERSION}; use crate::socketengine::OutSink; use crate::Uid;