From 1eca7450920664cad284b56beed6836042df879d Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 01:24:48 +0000 Subject: [PATCH] repeat channel mode (+K ): block recently-repeated lines --- src/channels.rs | 14 +++++++----- src/coremods/core_message.rs | 31 +++++++++++++++++++++++++ src/mode.rs | 44 +++++++++++++++++++++++++++++++++++- src/users.rs | 4 ++-- 4 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/channels.rs b/src/channels.rs index f881fc6..76288e9 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -13,12 +13,13 @@ use crate::Uid; /// Per-member prefix modes (+q/+a/+o/+h/+v). Flag modes live in [`ChanModes`]. #[derive(Default)] pub struct Member { - pub owner: bool, // +q (~) - pub admin: bool, // +a (&) - pub op: bool, // +o (@) - pub halfop: bool, // +h (%) - pub voice: bool, // +v (+) - pub joined: u64, // unix ts this member joined (for +d delaymsg; 0 = unknown) + pub owner: bool, // +q (~) + pub admin: bool, // +a (&) + pub op: bool, // +o (@) + pub halfop: bool, // +h (%) + pub voice: bool, // +v (+) + pub joined: u64, // unix ts this member joined (for +d delaymsg; 0 = unknown) + pub recent_msgs: Vec, // +K repeat: this member's last few lines here } /// Prefix ranks, high→low — gate who may grant a prefix / kick whom. @@ -157,6 +158,7 @@ pub struct ChanModes { pub kicknorejoin: Option, // +J — block rejoin for N secs after a kick pub opmoderated: bool, // +U — unprivileged users' messages go to ops only pub delaymsg: Option, // +d — new joiners can't speak for N secs + pub repeat: Option, // +K — block a line repeated within your last n } impl ChanModes { diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs index f15e87f..add74b0 100644 --- a/src/coremods/core_message.rs +++ b/src/coremods/core_message.rs @@ -361,6 +361,37 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) } } } + // +K repeat — reject a line the sender just repeated; else remember it (ops exempt) + if let Some(n) = s.channels.get(&key).and_then(|c| c.modes.repeat) { + if s.rank(uid, &key) < RANK_HALFOP { + let repeated = s + .channels + .get(&key) + .and_then(|c| c.members.get(&uid)) + .map(|m| m.recent_msgs.iter().any(|p| p == &body)) + .unwrap_or(false); + if repeated { + if !notice { + s.numeric( + uid, + ERR_CANNOTSENDTOCHAN, + &format!("{target} :Cannot send to channel (+K: repeated message)"), + ); + } + return CmdResult::Fail; + } + if let Some(m) = s + .channels + .get_mut(&key) + .and_then(|c| c.members.get_mut(&uid)) + { + m.recent_msgs.push(body.clone()); + while m.recent_msgs.len() > n as usize { + m.recent_msgs.remove(0); + } + } + } + } // deliver to every member except the sender and +D (deaf) users, tagging // per-recipient (server-time + any client-only tags on the line) let line = format!(":{prefix} {cmd} {target} :{body}"); diff --git a/src/mode.rs b/src/mode.rs index 6186cb8..e1fcf8d 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -91,6 +91,7 @@ static CHAN_MODES: &[&(dyn ChanMode + Sync)] = &[ &KICKNOREJOIN, &OPMODERATED, &DELAYMSG, + &REPEAT, ]; // --- prefix modes (+q/+a/+o/+h/+v): a per-member rank, needs a nick ---------- @@ -952,6 +953,47 @@ impl ChanMode for DelayMsgMode { } } +/// +K `` — block a message identical to one of the sender's previous `` +/// lines in this channel (InspIRCd `m_repeat`, simplified). Ops are exempt. +struct RepeatMode; +static REPEAT: RepeatMode = RepeatMode; +impl ChanMode for RepeatMode { + fn letter(&self) -> char { + 'K' + } + fn wants_param(&self, adding: bool) -> bool { + adding + } + fn apply( + &self, + s: &mut Server, + _chan: &str, + key: &str, + _uid: Uid, + adding: bool, + param: Option<&str>, + ) -> Applied { + if adding { + let Some(n) = param + .and_then(|p| p.parse::().ok()) + .filter(|&n| n >= 1) + else { + return Applied::No; + }; + let n = n.min(20); + if let Some(c) = s.channels.get_mut(key) { + c.modes.repeat = Some(n); + } + Applied::Yes(Some(n.to_string())) + } else { + if let Some(c) = s.channels.get_mut(key) { + c.modes.repeat = None; + } + Applied::Yes(None) + } + } +} + // === user modes ============================================================ /// A user mode (+i/+w/+o) — same handler-object shape as [`ChanMode`], and the @@ -1192,7 +1234,7 @@ mod tests { #[test] fn registry_covers_all_channel_modes() { - for c in "qaohvbeIklmntiszpONCTcSRMfjFLgGuBQAPJUd".chars() { + for c in "qaohvbeIklmntiszpONCTcSRMfjFLgGuBQAPJUdK".chars() { assert!(chan_mode(c).is_some(), "missing handler for +{c}"); } assert!(chan_mode('y').is_none()); diff --git a/src/users.rs b/src/users.rs index 257afbf..b47f04e 100644 --- a/src/users.rs +++ b/src/users.rs @@ -418,7 +418,7 @@ impl Server { uid, RPL_MYINFO, &format!( - "{} echoircd-{VERSION} iowxsgBDIHrRzWc qaohvbeIklimnpstzCTcSNORMfjFLgGuBQAPJUd", + "{} echoircd-{VERSION} iowxsgBDIHrRzWc qaohvbeIklimnpstzCTcSNORMfjFLgGuBQAPJUdK", self.name ), ); @@ -426,7 +426,7 @@ impl Server { uid, RPL_ISUPPORT, &format!( - "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFLHBJd,ACGMNOPQRSTUcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", + "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFLHBJdK,ACGMNOPQRSTUcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", self.network ), );