repeat channel mode (+K <n>): block recently-repeated lines

This commit is contained in:
Jean Chevronnet 2026-08-09 01:24:48 +00:00
parent 668f2735b2
commit 1eca745092
4 changed files with 84 additions and 9 deletions

View file

@ -13,12 +13,13 @@ use crate::Uid;
/// Per-member prefix modes (+q/+a/+o/+h/+v). Flag modes live in [`ChanModes`]. /// Per-member prefix modes (+q/+a/+o/+h/+v). Flag modes live in [`ChanModes`].
#[derive(Default)] #[derive(Default)]
pub struct Member { pub struct Member {
pub owner: bool, // +q (~) pub owner: bool, // +q (~)
pub admin: bool, // +a (&) pub admin: bool, // +a (&)
pub op: bool, // +o (@) pub op: bool, // +o (@)
pub halfop: bool, // +h (%) pub halfop: bool, // +h (%)
pub voice: bool, // +v (+) pub voice: bool, // +v (+)
pub joined: u64, // unix ts this member joined (for +d delaymsg; 0 = unknown) pub joined: u64, // unix ts this member joined (for +d delaymsg; 0 = unknown)
pub recent_msgs: Vec<String>, // +K repeat: this member's last few lines here
} }
/// Prefix ranks, high→low — gate who may grant a prefix / kick whom. /// Prefix ranks, high→low — gate who may grant a prefix / kick whom.
@ -157,6 +158,7 @@ pub struct ChanModes {
pub kicknorejoin: Option<u32>, // +J <secs> — block rejoin for N secs after a kick pub kicknorejoin: Option<u32>, // +J <secs> — block rejoin for N secs after a kick
pub opmoderated: bool, // +U — unprivileged users' messages go to ops only pub opmoderated: bool, // +U — unprivileged users' messages go to ops only
pub delaymsg: Option<u32>, // +d <secs> — new joiners can't speak for N secs pub delaymsg: Option<u32>, // +d <secs> — new joiners can't speak for N secs
pub repeat: Option<u32>, // +K <n> — block a line repeated within your last n
} }
impl ChanModes { impl ChanModes {

View file

@ -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 // deliver to every member except the sender and +D (deaf) users, tagging
// per-recipient (server-time + any client-only tags on the line) // per-recipient (server-time + any client-only tags on the line)
let line = format!(":{prefix} {cmd} {target} :{body}"); let line = format!(":{prefix} {cmd} {target} :{body}");

View file

@ -91,6 +91,7 @@ static CHAN_MODES: &[&(dyn ChanMode + Sync)] = &[
&KICKNOREJOIN, &KICKNOREJOIN,
&OPMODERATED, &OPMODERATED,
&DELAYMSG, &DELAYMSG,
&REPEAT,
]; ];
// --- prefix modes (+q/+a/+o/+h/+v): a per-member rank, needs a nick ---------- // --- prefix modes (+q/+a/+o/+h/+v): a per-member rank, needs a nick ----------
@ -952,6 +953,47 @@ impl ChanMode for DelayMsgMode {
} }
} }
/// +K `<n>` — block a message identical to one of the sender's previous `<n>`
/// 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::<u32>().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 ============================================================ // === user modes ============================================================
/// A user mode (+i/+w/+o) — same handler-object shape as [`ChanMode`], and the /// A user mode (+i/+w/+o) — same handler-object shape as [`ChanMode`], and the
@ -1192,7 +1234,7 @@ mod tests {
#[test] #[test]
fn registry_covers_all_channel_modes() { 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(c).is_some(), "missing handler for +{c}");
} }
assert!(chan_mode('y').is_none()); assert!(chan_mode('y').is_none());

View file

@ -418,7 +418,7 @@ impl Server {
uid, uid,
RPL_MYINFO, RPL_MYINFO,
&format!( &format!(
"{} echoircd-{VERSION} iowxsgBDIHrRzWc qaohvbeIklimnpstzCTcSNORMfjFLgGuBQAPJUd", "{} echoircd-{VERSION} iowxsgBDIHrRzWc qaohvbeIklimnpstzCTcSNORMfjFLgGuBQAPJUdK",
self.name self.name
), ),
); );
@ -426,7 +426,7 @@ impl Server {
uid, uid,
RPL_ISUPPORT, RPL_ISUPPORT,
&format!( &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 self.network
), ),
); );