diff --git a/src/channels.rs b/src/channels.rs index 5eaf800..e5562a4 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -148,6 +148,7 @@ pub struct ChanModes { pub nickflood: Option, // +F pub redirect: Option, // +L <#target> — when full, send there pub history: Option<(u32, u64)>, // +H : — replay recent messages to joiners + pub anticaps: Option, // +B — block messages that are mostly CAPS } impl ChanModes { diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs index f85519c..c392995 100644 --- a/src/coremods/core_message.rs +++ b/src/coremods/core_message.rs @@ -119,6 +119,18 @@ fn apply_censor(body: &str, censor: &[(String, String)]) -> Option { Some(out) } +/// Percentage of the ASCII letters in `t` that are uppercase, or `None` when there +/// are too few letters to judge (so short shouts like "OK" aren't blocked). Used +/// by the +B anticaps channel mode. +fn caps_percent(t: &str) -> Option { + let letters = t.chars().filter(|c| c.is_ascii_alphabetic()).count(); + if letters < 8 { + return None; + } + let upper = t.chars().filter(|c| c.is_ascii_uppercase()).count(); + Some(((upper * 100) / letters) as u8) +} + pub fn commands() -> Vec> { vec![Box::new(PrivMsg), Box::new(Notice), Box::new(TagMsg)] } @@ -305,6 +317,21 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) } } } + // +B anticaps — reject a mostly-uppercase message (ops/opers exempt) + if !flood_exempt { + if let Some(pct) = s.channels.get(&key).and_then(|c| c.modes.anticaps) { + if caps_percent(&body).map(|p| p >= pct).unwrap_or(false) { + if !notice { + s.numeric( + uid, + ERR_CANNOTSENDTOCHAN, + &format!("{target} :Cannot send to channel (+B: too many capitals)"), + ); + } + return CmdResult::Fail; + } + } + } // 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 f5c2a86..0860527 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -84,6 +84,7 @@ static CHAN_MODES: &[&(dyn ChanMode + Sync)] = &[ &NICKFLOOD, &REDIRECT, &CHANHISTORY, + &ANTICAPS, ]; // --- prefix modes (+q/+a/+o/+h/+v): a per-member rank, needs a nick ---------- @@ -796,6 +797,44 @@ impl ChanMode for RedirectMode { } } +/// +B `` — reject channel messages that are at least `` uppercase +/// (InspIRCd `m_anticaps`). Enforced in the message path; ops are exempt. +struct AntiCapsMode; +static ANTICAPS: AntiCapsMode = AntiCapsMode; +impl ChanMode for AntiCapsMode { + fn letter(&self) -> char { + 'B' + } + 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 pct = param.and_then(|p| p.parse::().ok()).filter(|&p| p >= 1); + let Some(pct) = pct.map(|p| p.min(100)) else { + return Applied::No; // needs a 1..=100 percentage + }; + if let Some(c) = s.channels.get_mut(key) { + c.modes.anticaps = Some(pct); + } + Applied::Yes(Some(pct.to_string())) + } else { + if let Some(c) = s.channels.get_mut(key) { + c.modes.anticaps = None; + } + Applied::Yes(None) + } + } +} + // === user modes ============================================================ /// A user mode (+i/+w/+o) — same handler-object shape as [`ChanMode`], and the @@ -1020,7 +1059,7 @@ mod tests { #[test] fn registry_covers_all_channel_modes() { - for c in "qaohvbeIklmntiszpONCTcSRMfjFLgGu".chars() { + for c in "qaohvbeIklmntiszpONCTcSRMfjFLgGuB".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 c969e0f..a595780 100644 --- a/src/users.rs +++ b/src/users.rs @@ -409,7 +409,7 @@ impl Server { uid, RPL_MYINFO, &format!( - "{} echoircd-{VERSION} iowxsgBDIHrRz qaohvbeIklimnpstzCTcSNORMfjFLgGu", + "{} echoircd-{VERSION} iowxsgBDIHrRz qaohvbeIklimnpstzCTcSNORMfjFLgGuB", self.name ), ); @@ -417,7 +417,7 @@ impl Server { uid, RPL_ISUPPORT, &format!( - "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFLH,CGMNORSTcimnpstuz 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,lfjFLHB,CGMNORSTcimnpstuz 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 ), );