diff --git a/src/channels.rs b/src/channels.rs index a2f226e..5eaf800 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -5,8 +5,9 @@ use std::collections::{HashMap, HashSet}; use crate::module::Hook; +use crate::modules::chathistory::{HistMsg, History}; use crate::numeric::*; -use crate::server::{iso_time, now, HistMsg, Server}; +use crate::server::{iso_time, now, Server}; use crate::Uid; /// Per-member prefix modes (+q/+a/+o/+h/+v). Flag modes live in [`ChanModes`]. @@ -551,7 +552,7 @@ impl Server { } else { None }; - let out: Vec = match self.history.get(key) { + let out: Vec = match self.ext.get::().and_then(|h| h.0.get(key)) { Some(buf) => { let mut recent: Vec<&HistMsg> = buf.iter().filter(|m| m.ts >= cutoff).collect(); let start = recent.len().saturating_sub(lines as usize); diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs index 725538b..535cc0f 100644 --- a/src/coremods/core_message.rs +++ b/src/coremods/core_message.rs @@ -2,8 +2,9 @@ use crate::channels::{glob_match, RANK_HALFOP, RANK_VOICE}; use crate::command::{CmdResult, Command}; +use crate::modules::chathistory::{dm_key, record}; use crate::numeric::*; -use crate::server::{iso_time, parse_iso, HistMsg, Server, HISTORY_CAP}; +use crate::server::Server; use crate::Uid; /// mIRC/IRC formatting control bytes (bold, colour, hex-colour, reset, …). @@ -123,314 +124,9 @@ pub fn commands() -> Vec> { Box::new(PrivMsg), Box::new(Notice), Box::new(TagMsg), - Box::new(ChatHistory), - Box::new(Redact), ] } -/// 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, -/// and the message is dropped from CHATHISTORY. -struct Redact; -impl Command for Redact { - fn name(&self) -> &'static str { - "REDACT" - } - fn min_params(&self) -> usize { - 2 - } - fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { - let target = ¶ms[0]; - let msgid = ¶ms[1]; - let reason = params.get(2).cloned().unwrap_or_default(); - if !target.starts_with('#') { - s.fail( - uid, - "REDACT", - "INVALID_TARGET", - "REDACT only supports channels", - ); - return CmdResult::Fail; - } - let key = target.to_ascii_lowercase(); - let Some(author) = s.history.get(&key).and_then(|buf| { - buf.iter().find(|m| m.msgid == *msgid).map(|m| { - m.prefix - .split('!') - .next() - .unwrap_or("") - .to_ascii_lowercase() - }) - }) else { - s.fail( - uid, - "REDACT", - "UNKNOWN_MSGID", - &format!("No such message id {msgid}"), - ); - return CmdResult::Fail; - }; - let my_nick = s - .users - .get(&uid) - .map(|u| u.nick.to_ascii_lowercase()) - .unwrap_or_default(); - if my_nick != author && s.rank(uid, &key) < RANK_HALFOP && !s.is_oper(uid) { - s.fail( - uid, - "REDACT", - "REDACT_FORBIDDEN", - "You may only redact your own messages", - ); - return CmdResult::Fail; - } - if let Some(buf) = s.history.get_mut(&key) { - buf.retain(|m| m.msgid != *msgid); - } - let prefix = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default(); - let line = if reason.is_empty() { - format!(":{prefix} REDACT {target} {msgid}") - } else { - format!(":{prefix} REDACT {target} {msgid} :{reason}") - }; - let members: Vec = s - .channels - .get(&key) - .map(|c| c.members.keys().copied().collect()) - .unwrap_or_default(); - for m in members { - if s.users - .get(&m) - .map(|u| u.caps.message_redaction) - .unwrap_or(false) - { - s.send(m, line.clone()); - } - } - CmdResult::Ok - } -} - -/// Canonical CHATHISTORY key for a DM between two nicks (order-independent; the -/// `\0` prefix keeps it from ever colliding with a `#channel` key). -fn dm_key(a: &str, b: &str) -> String { - let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase()); - if a <= b { - format!("\0{a}\0{b}") - } else { - format!("\0{b}\0{a}") - } -} - -/// CHATHISTORY — replay recent messages (draft/chathistory), leveraging BATCH. -/// `CHATHISTORY <#chan|nick> -/// `; a `` is `*`, `timestamp=` or `msgid=`. Channel -/// history is members-only; a nick target replays that DM conversation. The reply -/// is a `chathistory` batch of the original lines with their server-time + msgid. -struct ChatHistory; -impl Command for ChatHistory { - fn name(&self) -> &'static str { - "CHATHISTORY" - } - fn min_params(&self) -> usize { - 4 - } - fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { - let sub = params[0].to_ascii_uppercase(); - // CHATHISTORY TARGETS — list conversations with activity - // in the window, newest-in-window timestamp each. No target param. - if sub == "TARGETS" { - let bound = |i: usize, dflt: u64| { - params - .get(i) - .and_then(|s| s.strip_prefix("timestamp=")) - .and_then(parse_iso) - .unwrap_or(dflt) - }; - let (a, b) = (bound(1, 0), bound(2, u64::MAX)); - let (lo, hi) = (a.min(b), a.max(b)); - let limit = params - .get(3) - .and_then(|l| l.parse::().ok()) - .unwrap_or(50) - .clamp(1, HISTORY_CAP); - let me = s - .users - .get(&uid) - .map(|u| u.nick.to_ascii_lowercase()) - .unwrap_or_default(); - let mut targets: Vec<(String, u64)> = Vec::new(); - for (key, buf) in &s.history { - let Some(ts) = buf - .iter() - .rev() - .find(|m| m.ts >= lo && m.ts <= hi) - .map(|m| m.ts) - else { - continue; - }; - if key.starts_with('#') { - if s.is_member(uid, key) { - let name = buf - .back() - .map(|m| m.target.clone()) - .unwrap_or_else(|| key.clone()); - targets.push((name, ts)); - } - } else if let Some(rest) = key.strip_prefix('\0') { - let p: Vec<&str> = rest.split('\0').collect(); - if p.len() == 2 && (p[0] == me || p[1] == me) { - let other = if p[0] == me { p[1] } else { p[0] }; - targets.push((other.to_string(), ts)); - } - } - } - targets.sort_by_key(|(_, ts)| *ts); - let start = targets.len().saturating_sub(limit); - let bref = s.next_msgid().replace('-', ""); - s.send( - uid, - format!(":{} BATCH +{bref} draft/chathistory-targets", s.name), - ); - for (t, ts) in &targets[start..] { - s.send( - uid, - format!( - "@batch={bref} :{} CHATHISTORY TARGETS {t} {}", - s.name, - iso_time(*ts) - ), - ); - } - s.send(uid, format!(":{} BATCH -{bref}", s.name)); - return CmdResult::Ok; - } - let target = params[1].clone(); - // channel target → channel key (members only); a nick → the DM pair key - // (the requester is inherently part of it, so no membership check) - let is_channel = target.starts_with('#'); - let key = if is_channel { - target.to_ascii_lowercase() - } else { - let me = s - .users - .get(&uid) - .map(|u| u.nick.clone()) - .unwrap_or_default(); - dm_key(&me, &target) - }; - // BETWEEN takes two selectors then the limit; the rest take one + the limit - let (sel, sel2, limit_s) = if sub == "BETWEEN" { - ( - params[2].as_str(), - params.get(3).map(|s| s.as_str()).unwrap_or("*"), - params.get(4), - ) - } else { - (params[2].as_str(), "", params.get(3)) - }; - let limit = limit_s - .and_then(|l| l.parse::().ok()) - .unwrap_or(50) - .clamp(1, HISTORY_CAP); - - let bref = s.next_msgid().replace('-', ""); - let mut lines: Vec = Vec::new(); - if !is_channel || s.is_member(uid, &key) { - if let Some(buf) = s.history.get(&key) { - // Resolve the selector to a reference position. `msgid=` matches an - // exact buffer index (so same-second messages aren't lost); - // `timestamp=` and `*` fall back to a ts bound. - let ref_idx = sel - .strip_prefix("msgid=") - .and_then(|id| buf.iter().position(|m| m.msgid == id)); - let ref_ts = sel.strip_prefix("timestamp=").and_then(parse_iso); - // resolve any selector to a buffer index (for AROUND / BETWEEN) - let idx_of = |sl: &str| -> Option { - if let Some(id) = sl.strip_prefix("msgid=") { - buf.iter().position(|m| m.msgid == id) - } else if let Some(iso) = sl.strip_prefix("timestamp=") { - parse_iso(iso).and_then(|b| buf.iter().position(|m| m.ts >= b)) - } else { - None - } - }; - let picked: Vec<&HistMsg> = match sub.as_str() { - "BEFORE" => { - let end = ref_idx.unwrap_or_else(|| { - let b = ref_ts.unwrap_or(u64::MAX); - buf.iter().position(|m| m.ts >= b).unwrap_or(buf.len()) - }); - let start = end.saturating_sub(limit); - buf.iter().take(end).skip(start).collect() - } - "AFTER" => { - let begin = match ref_idx { - Some(i) => i + 1, - None => { - let b = ref_ts.unwrap_or(0); - buf.iter().position(|m| m.ts > b).unwrap_or(buf.len()) - } - }; - buf.iter().skip(begin).take(limit).collect() - } - "AROUND" => { - // messages centred on the selector: half before, half after - let i = idx_of(sel).unwrap_or(buf.len() / 2); - let start = i.saturating_sub(limit / 2); - buf.iter().skip(start).take(limit).collect() - } - "BETWEEN" => { - // messages strictly between the two selector points - let a = idx_of(sel).unwrap_or(0); - let b = idx_of(sel2).unwrap_or(buf.len()); - let (lo, hi) = (a.min(b), a.max(b)); - buf.iter() - .skip(lo + 1) - .take(hi.saturating_sub(lo + 1)) - .take(limit) - .collect() - } - _ => { - // LATEST: newest `limit`, optionally bounded below by the selector - let begin = match (ref_idx, ref_ts) { - (Some(i), _) => i + 1, - (None, Some(b)) => { - buf.iter().position(|m| m.ts > b).unwrap_or(buf.len()) - } - _ => 0, - }; - let n = buf.len() - begin; - let start = begin + n.saturating_sub(limit); - buf.iter().skip(start).collect() - } - }; - for m in picked { - lines.push(format!( - "@time={};msgid={};batch={bref} :{} {} {} :{}", - iso_time(m.ts), - m.msgid, - m.prefix, - m.verb, - m.target, - m.text - )); - } - } - } - s.send( - uid, - format!(":{} BATCH +{bref} chathistory {target}", s.name), - ); - for l in lines { - s.send(uid, l); - } - s.send(uid, format!(":{} BATCH -{bref}", s.name)); - CmdResult::Ok - } -} - /// Shared PRIVMSG/NOTICE delivery. NOTICE never generates automatic replies. /// `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 { @@ -618,7 +314,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) let line = format!(":{prefix} {cmd} {target} :{body}"); let ctags = s.line_ctags.clone(); let msgid = s.next_msgid(); // one id shared by every recipient of this message - s.store_history(&key, &prefix, cmd, target, &body, &msgid); // for CHATHISTORY + record(s, &key, &prefix, cmd, target, &body, &msgid); // for CHATHISTORY let members: Vec = s .channels .get(&key) @@ -741,14 +437,7 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) s.send_tagged(tuid, uid, &ctags, &msgid, &pm); // store for CHATHISTORY under the canonical pair key (both parties share it) let sender_nick = prefix.split('!').next().unwrap_or_default(); - s.store_history( - &dm_key(sender_nick, target), - &prefix, - cmd, - target, - text, - &msgid, - ); + record(s, &dm_key(sender_nick, target), &prefix, cmd, target, text, &msgid); } if s.users .get(&uid) diff --git a/src/mode.rs b/src/mode.rs index 408f4bf..f5c2a86 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -419,7 +419,7 @@ impl ChanMode for ChanHistory { let Some(lines) = lines_s.parse::().ok().filter(|&n| n > 0) else { return Applied::No; }; - let lines = lines.min(crate::server::HISTORY_CAP as u32); + let lines = lines.min(crate::modules::chathistory::HISTORY_CAP as u32); let secs = secs_s.parse::().unwrap_or(0); if let Some(c) = s.channels.get_mut(key) { c.modes.history = Some((lines, secs)); diff --git a/src/modules/chathistory.rs b/src/modules/chathistory.rs new file mode 100644 index 0000000..034cfd4 --- /dev/null +++ b/src/modules/chathistory.rs @@ -0,0 +1,374 @@ +//! chathistory — InspIRCd's `m_chathistory` family (draft/chathistory + +//! draft/message-redaction). Recent PRIVMSG/NOTICE traffic is kept in a capped +//! per-conversation ring (channels and DM pairs) so clients can replay it on +//! demand or on join (the channel `+H` backlog lives in `channels::replay_chanhistory`). +//! Self-contained: the ring lives in `Server.ext`; the message path records into it +//! via [`record`], and the CHATHISTORY and REDACT commands read/edit it here. + +use std::collections::{HashMap, VecDeque}; + +use crate::channels::RANK_HALFOP; +use crate::command::{CmdResult, Command}; +use crate::server::{iso_time, now, parse_iso, Server}; +use crate::Uid; + +/// Recent messages CHATHISTORY keeps per conversation. +pub const HISTORY_CAP: usize = 256; + +/// One stored message, replayed by CHATHISTORY / the `+H` backlog. +pub struct HistMsg { + pub ts: u64, + pub msgid: String, + pub prefix: String, // sender's nick!user@host at send time + pub verb: &'static str, // "PRIVMSG" or "NOTICE" + pub target: String, // original target (channel, or the DM recipient) + pub text: String, +} + +/// conversation key (`#chan` or a DM-pair key) -> capped ring. Stored in `Server.ext`. +#[derive(Default)] +pub struct History(pub HashMap>); + +/// Record a message for CHATHISTORY replay (capped ring per conversation). Called +/// from the core message path once per delivered PRIVMSG/NOTICE. +pub fn record( + s: &mut Server, + key: &str, + prefix: &str, + verb: &'static str, + target: &str, + text: &str, + msgid: &str, +) { + let buf = s + .ext + .get_or_insert_with::(History::default) + .0 + .entry(key.to_string()) + .or_default(); + buf.push_back(HistMsg { + ts: now(), + msgid: msgid.to_string(), + prefix: prefix.to_string(), + verb, + target: target.to_string(), + text: text.to_string(), + }); + while buf.len() > HISTORY_CAP { + buf.pop_front(); + } +} + +/// Canonical CHATHISTORY key for a DM between two nicks (order-independent; the +/// `\0` prefix keeps it from ever colliding with a `#channel` key). +pub fn dm_key(a: &str, b: &str) -> String { + let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase()); + if a <= b { + format!("\0{a}\0{b}") + } else { + format!("\0{b}\0{a}") + } +} + +pub fn commands() -> Vec> { + vec![Box::new(ChatHistory), Box::new(Redact)] +} + +/// CHATHISTORY — replay recent messages (draft/chathistory), leveraging BATCH. +/// `CHATHISTORY <#chan|nick> +/// `; a `` is `*`, `timestamp=` or `msgid=`. Channel +/// history is members-only; a nick target replays that DM conversation. The reply +/// is a `chathistory` batch of the original lines with their server-time + msgid. +struct ChatHistory; +impl Command for ChatHistory { + fn name(&self) -> &'static str { + "CHATHISTORY" + } + fn min_params(&self) -> usize { + 4 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let sub = params[0].to_ascii_uppercase(); + // CHATHISTORY TARGETS — list conversations with activity + // in the window, newest-in-window timestamp each. No target param. + if sub == "TARGETS" { + let bound = |i: usize, dflt: u64| { + params + .get(i) + .and_then(|s| s.strip_prefix("timestamp=")) + .and_then(parse_iso) + .unwrap_or(dflt) + }; + let (a, b) = (bound(1, 0), bound(2, u64::MAX)); + let (lo, hi) = (a.min(b), a.max(b)); + let limit = params + .get(3) + .and_then(|l| l.parse::().ok()) + .unwrap_or(50) + .clamp(1, HISTORY_CAP); + let me = s + .users + .get(&uid) + .map(|u| u.nick.to_ascii_lowercase()) + .unwrap_or_default(); + let mut targets: Vec<(String, u64)> = Vec::new(); + if let Some(hist) = s.ext.get::() { + for (key, buf) in &hist.0 { + let Some(ts) = buf + .iter() + .rev() + .find(|m| m.ts >= lo && m.ts <= hi) + .map(|m| m.ts) + else { + continue; + }; + if key.starts_with('#') { + if s.is_member(uid, key) { + let name = buf + .back() + .map(|m| m.target.clone()) + .unwrap_or_else(|| key.clone()); + targets.push((name, ts)); + } + } else if let Some(rest) = key.strip_prefix('\0') { + let p: Vec<&str> = rest.split('\0').collect(); + if p.len() == 2 && (p[0] == me || p[1] == me) { + let other = if p[0] == me { p[1] } else { p[0] }; + targets.push((other.to_string(), ts)); + } + } + } + } + targets.sort_by_key(|(_, ts)| *ts); + let start = targets.len().saturating_sub(limit); + let bref = s.next_msgid().replace('-', ""); + s.send( + uid, + format!(":{} BATCH +{bref} draft/chathistory-targets", s.name), + ); + for (t, ts) in &targets[start..] { + s.send( + uid, + format!( + "@batch={bref} :{} CHATHISTORY TARGETS {t} {}", + s.name, + iso_time(*ts) + ), + ); + } + s.send(uid, format!(":{} BATCH -{bref}", s.name)); + return CmdResult::Ok; + } + let target = params[1].clone(); + // channel target → channel key (members only); a nick → the DM pair key + // (the requester is inherently part of it, so no membership check) + let is_channel = target.starts_with('#'); + let key = if is_channel { + target.to_ascii_lowercase() + } else { + let me = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + dm_key(&me, &target) + }; + // BETWEEN takes two selectors then the limit; the rest take one + the limit + let (sel, sel2, limit_s) = if sub == "BETWEEN" { + ( + params[2].as_str(), + params.get(3).map(|s| s.as_str()).unwrap_or("*"), + params.get(4), + ) + } else { + (params[2].as_str(), "", params.get(3)) + }; + let limit = limit_s + .and_then(|l| l.parse::().ok()) + .unwrap_or(50) + .clamp(1, HISTORY_CAP); + + let bref = s.next_msgid().replace('-', ""); + let mut lines: Vec = Vec::new(); + if !is_channel || s.is_member(uid, &key) { + if let Some(buf) = s.ext.get::().and_then(|h| h.0.get(&key)) { + // Resolve the selector to a reference position. `msgid=` matches an + // exact buffer index (so same-second messages aren't lost); + // `timestamp=` and `*` fall back to a ts bound. + let ref_idx = sel + .strip_prefix("msgid=") + .and_then(|id| buf.iter().position(|m| m.msgid == id)); + let ref_ts = sel.strip_prefix("timestamp=").and_then(parse_iso); + // resolve any selector to a buffer index (for AROUND / BETWEEN) + let idx_of = |sl: &str| -> Option { + if let Some(id) = sl.strip_prefix("msgid=") { + buf.iter().position(|m| m.msgid == id) + } else if let Some(iso) = sl.strip_prefix("timestamp=") { + parse_iso(iso).and_then(|b| buf.iter().position(|m| m.ts >= b)) + } else { + None + } + }; + let picked: Vec<&HistMsg> = match sub.as_str() { + "BEFORE" => { + let end = ref_idx.unwrap_or_else(|| { + let b = ref_ts.unwrap_or(u64::MAX); + buf.iter().position(|m| m.ts >= b).unwrap_or(buf.len()) + }); + let start = end.saturating_sub(limit); + buf.iter().take(end).skip(start).collect() + } + "AFTER" => { + let begin = match ref_idx { + Some(i) => i + 1, + None => { + let b = ref_ts.unwrap_or(0); + buf.iter().position(|m| m.ts > b).unwrap_or(buf.len()) + } + }; + buf.iter().skip(begin).take(limit).collect() + } + "AROUND" => { + // messages centred on the selector: half before, half after + let i = idx_of(sel).unwrap_or(buf.len() / 2); + let start = i.saturating_sub(limit / 2); + buf.iter().skip(start).take(limit).collect() + } + "BETWEEN" => { + // messages strictly between the two selector points + let a = idx_of(sel).unwrap_or(0); + let b = idx_of(sel2).unwrap_or(buf.len()); + let (lo, hi) = (a.min(b), a.max(b)); + buf.iter() + .skip(lo + 1) + .take(hi.saturating_sub(lo + 1)) + .take(limit) + .collect() + } + _ => { + // LATEST: newest `limit`, optionally bounded below by the selector + let begin = match (ref_idx, ref_ts) { + (Some(i), _) => i + 1, + (None, Some(b)) => { + buf.iter().position(|m| m.ts > b).unwrap_or(buf.len()) + } + _ => 0, + }; + let n = buf.len() - begin; + let start = begin + n.saturating_sub(limit); + buf.iter().skip(start).collect() + } + }; + for m in picked { + lines.push(format!( + "@time={};msgid={};batch={bref} :{} {} {} :{}", + iso_time(m.ts), + m.msgid, + m.prefix, + m.verb, + m.target, + m.text + )); + } + } + } + s.send( + uid, + format!(":{} BATCH +{bref} chathistory {target}", s.name), + ); + for l in lines { + s.send(uid, l); + } + s.send(uid, format!(":{} BATCH -{bref}", s.name)); + 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, +/// and the message is dropped from CHATHISTORY. +struct Redact; +impl Command for Redact { + fn name(&self) -> &'static str { + "REDACT" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let target = ¶ms[0]; + let msgid = ¶ms[1]; + let reason = params.get(2).cloned().unwrap_or_default(); + if !target.starts_with('#') { + s.fail( + uid, + "REDACT", + "INVALID_TARGET", + "REDACT only supports channels", + ); + return CmdResult::Fail; + } + let key = target.to_ascii_lowercase(); + let Some(author) = s + .ext + .get::() + .and_then(|h| h.0.get(&key)) + .and_then(|buf| { + buf.iter().find(|m| m.msgid == *msgid).map(|m| { + m.prefix + .split('!') + .next() + .unwrap_or("") + .to_ascii_lowercase() + }) + }) + else { + s.fail( + uid, + "REDACT", + "UNKNOWN_MSGID", + &format!("No such message id {msgid}"), + ); + return CmdResult::Fail; + }; + let my_nick = s + .users + .get(&uid) + .map(|u| u.nick.to_ascii_lowercase()) + .unwrap_or_default(); + if my_nick != author && s.rank(uid, &key) < RANK_HALFOP && !s.is_oper(uid) { + s.fail( + uid, + "REDACT", + "REDACT_FORBIDDEN", + "You may only redact your own messages", + ); + return CmdResult::Fail; + } + if let Some(buf) = s.ext.get_mut::().and_then(|h| h.0.get_mut(&key)) { + buf.retain(|m| m.msgid != *msgid); + } + let prefix = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default(); + let line = if reason.is_empty() { + format!(":{prefix} REDACT {target} {msgid}") + } else { + format!(":{prefix} REDACT {target} {msgid} :{reason}") + }; + let members: Vec = s + .channels + .get(&key) + .map(|c| c.members.keys().copied().collect()) + .unwrap_or_default(); + for m in members { + if s.users + .get(&m) + .map(|u| u.caps.message_redaction) + .unwrap_or(false) + { + s.send(m, line.clone()); + } + } + CmdResult::Ok + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 480e68f..5b996a3 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -4,6 +4,7 @@ //! than the hook bus, but lives here as its own self-contained unit. pub mod antimixedutf8; +pub mod chathistory; pub mod cloak; pub mod dnsbl; pub mod filter; @@ -38,5 +39,6 @@ pub fn module_commands() -> Vec> { .chain(metadata::commands()) .chain(markread::commands()) .chain(multiline::commands()) + .chain(chathistory::commands()) .collect() } diff --git a/src/server.rs b/src/server.rs index 9fee56f..45ed5a1 100644 --- a/src/server.rs +++ b/src/server.rs @@ -34,8 +34,6 @@ pub const TICK_SECS: u64 = 15; pub const PING_AFTER: u64 = 90; pub const PING_TIMEOUT: u64 = 60; pub const REG_TIMEOUT: u64 = 60; -/// Recent messages CHATHISTORY keeps per channel. -pub const HISTORY_CAP: usize = 256; pub fn now() -> u64 { SystemTime::now() @@ -87,16 +85,6 @@ pub fn parse_iso(s: &str) -> Option { Some((days * 86400 + h * 3600 + mi * 60 + se).max(0) as u64) } -/// One stored message, replayed by CHATHISTORY. -pub struct HistMsg { - pub ts: u64, - pub msgid: String, - pub prefix: String, // sender's nick!user@host at send time - pub verb: &'static str, // "PRIVMSG" or "NOTICE" - pub target: String, // original target (channel, or the DM recipient) - pub text: String, -} - /// A recently-departed identity, kept for WHOWAS. pub struct WhowasEntry { pub nick: String, @@ -149,9 +137,8 @@ pub struct Server { // the command's `label` (single tag, BATCH, or ACK). RefCell because the // output primitives are `&self`. pub label_capture: RefCell)>>, - pub history: HashMap>, // channel key -> recent messages (CHATHISTORY) - pub event_tx: Sender, // self-inject events (DNS results) - pub conn_counter: Arc, // mints connection uids (for CONNECT dials) + 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 /// in their own file instead of bloating this one. @@ -197,7 +184,6 @@ impl Server { sasl_server: cfg.sasl_server, webirc: cfg.webirc, label_capture: RefCell::new(None), - history: HashMap::new(), event_tx, conn_counter, ext: Extensible::default(), @@ -229,30 +215,6 @@ impl Server { } } - /// Record a channel message for CHATHISTORY replay (capped ring per channel). - pub fn store_history( - &mut self, - key: &str, - prefix: &str, - verb: &'static str, - target: &str, - text: &str, - msgid: &str, - ) { - let buf = self.history.entry(key.to_string()).or_default(); - buf.push_back(HistMsg { - ts: now(), - msgid: msgid.to_string(), - prefix: prefix.to_string(), - verb, - target: target.to_string(), - text: text.to_string(), - }); - while buf.len() > HISTORY_CAP { - buf.pop_front(); - } - } - // --- connection lifecycle ------------------------------------------------ pub fn add_conn(