From 8dc51a281b5ee52f6030fb485231ef28b5f08c3b Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 00:47:10 +0000 Subject: [PATCH] channel modes +Q nokicks / +A allowinvite / +P permchannels; fix SWHOIS (full multi-word text, no doubled nick in WHOIS) --- src/channels.rs | 16 ++++++++++++++-- src/coremods/core_channel.rs | 24 ++++++++++++++++++------ src/coremods/core_info.rs | 5 +++-- src/coremods/core_oper.rs | 7 ++++--- src/link.rs | 6 +++--- src/mode.rs | 26 +++++++++++++++++++++++++- src/server.rs | 2 +- src/users.rs | 4 ++-- 8 files changed, 70 insertions(+), 20 deletions(-) diff --git a/src/channels.rs b/src/channels.rs index e5562a4..ce720c1 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -149,6 +149,9 @@ pub struct ChanModes { 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 + pub nokicks: bool, // +Q — KICK is disabled on the channel + pub allowinvite: bool, // +A — any member (not just ops) may INVITE + pub permanent: bool, // +P — channel persists with zero members } impl ChanModes { @@ -172,6 +175,9 @@ impl ChanModes { 'M' => self.reg_moderated = on, 'G' => self.censor = on, 'u' => self.auditorium = on, + 'Q' => self.nokicks = on, + 'A' => self.allowinvite = on, + 'P' => self.permanent = on, _ => {} } } @@ -305,6 +311,12 @@ impl Channel { pub fn is_empty(&self) -> bool { self.members.is_empty() && self.rmembers.is_empty() } + + /// Whether to keep this channel in the table: it has members, or it's +P + /// (permanent). The predicate every `channels.retain` prune uses. + pub fn keep_alive(&self) -> bool { + !self.is_empty() || self.modes.permanent + } } impl Server { @@ -350,7 +362,7 @@ impl Server { if let Some(u) = self.users.get_mut(&uid) { u.channels.remove(&key); } - self.channels.retain(|_, c| !c.is_empty()); + self.channels.retain(|_, c| c.keep_alive()); } /// Join a user to a channel (creating it if new, giving the creator +o), @@ -745,7 +757,7 @@ impl Server { if let Some(u) = self.users.get_mut(&uid) { u.channels.remove(key); } - self.channels.retain(|_, c| !c.is_empty()); + self.channels.retain(|_, c| c.keep_alive()); self.events .push_back(Hook::Part(uid, key.to_string(), "flood".to_string())); } diff --git a/src/coremods/core_channel.rs b/src/coremods/core_channel.rs index 0936545..24a96f5 100644 --- a/src/coremods/core_channel.rs +++ b/src/coremods/core_channel.rs @@ -99,7 +99,7 @@ impl Command for Cycle { if let Some(u) = s.users.get_mut(&uid) { u.channels.remove(&key); } - s.channels.retain(|_, c| !c.is_empty()); + s.channels.retain(|_, c| c.keep_alive()); s.join(uid, chan, None); CmdResult::Ok } @@ -167,7 +167,7 @@ impl Command for Remove { if let Some(u) = s.users.get_mut(&tuid) { u.channels.remove(&key); } - s.channels.retain(|_, c| !c.is_empty()); + s.channels.retain(|_, c| c.keep_alive()); CmdResult::Ok } } @@ -195,8 +195,11 @@ impl Command for Invite { ); return CmdResult::Fail; } - // only ops may invite into an +i channel - if s.channels[&key].modes.invite_only && !s.is_op(uid, &key) { + // only ops may invite into an +i channel — unless +A (allow anyone to invite) + if s.channels[&key].modes.invite_only + && !s.channels[&key].modes.allowinvite + && !s.is_op(uid, &key) + { s.numeric( uid, ERR_CHANOPRIVSNEEDED, @@ -373,7 +376,7 @@ impl Command for Part { if let Some(u) = s.users.get_mut(&uid) { u.channels.remove(&key); } - s.channels.retain(|_, c| !c.is_empty()); + s.channels.retain(|_, c| c.keep_alive()); s.events.push_back(Hook::Part(uid, key, reason.clone())); } CmdResult::Ok @@ -428,6 +431,15 @@ impl Command for Kick { ); return CmdResult::Fail; } + // +Q — kicks disabled (IRC operators bypass; SAKICK is a separate path) + if s.channels[&key].modes.nokicks && !s.is_oper(uid) { + s.numeric( + uid, + ERR_CHANOPRIVSNEEDED, + &format!("{chan} :Kicks are disabled here (+Q)"), + ); + return CmdResult::Fail; + } let kicker = s.users[&uid].nick.clone(); let reason = params.get(2).cloned().unwrap_or(kicker); let prefix = s.users[&uid].prefix(); @@ -443,7 +455,7 @@ impl Command for Kick { if let Some(u) = s.users.get_mut(&tuid) { u.channels.remove(&key); } - s.channels.retain(|_, c| !c.is_empty()); + s.channels.retain(|_, c| c.keep_alive()); s.events .push_back(Hook::Part(tuid, key, "kicked".to_string())); CmdResult::Ok diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs index 81e3c4d..dfbfea5 100644 --- a/src/coremods/core_info.rs +++ b/src/coremods/core_info.rs @@ -175,9 +175,10 @@ impl Command for Whois { &format!("{nick} :is an IRC operator"), ); } - // 320: oper-set SWHOIS line + // 320: oper-set SWHOIS line. No redundant target-nick param — just the + // text — so clients that don't special-case 320 don't echo the nick. if let Some(line) = &swhois { - s.numeric(uid, RPL_WHOISSPECIAL, &format!("{nick} :{line}")); + s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}")); } // opers can see through the cloak to the real host/ip if asker_oper && disp != realhost { diff --git a/src/coremods/core_oper.rs b/src/coremods/core_oper.rs index dbb9549..6db5873 100644 --- a/src/coremods/core_oper.rs +++ b/src/coremods/core_oper.rs @@ -975,7 +975,7 @@ impl Command for SaKick { if let Some(u) = s.users.get_mut(&tuid) { u.channels.remove(&key); } - s.channels.retain(|_, c| !c.is_empty()); + s.channels.retain(|_, c| c.keep_alive()); s.events .push_back(Hook::Part(tuid, key, "kicked".to_string())); let by = oper_nick(s, uid); @@ -1099,7 +1099,7 @@ impl Command for ClearChan { s.events .push_back(Hook::Part(tuid, key.clone(), "cleared".to_string())); } - s.channels.retain(|_, c| !c.is_empty()); + s.channels.retain(|_, c| c.keep_alive()); let by = oper_nick(s, uid); s.snotice(&format!("{by} used CLEARCHAN on {chan}")); CmdResult::Ok @@ -1208,7 +1208,8 @@ impl Command for SwhoisCmd { let Some(t) = oper_target(s, uid, ¶ms[0]) else { return CmdResult::Fail; }; - let text = params[1].clone(); + // everything after the nick is the line — works with or without a `:` + let text = params[1..].join(" "); if let Some(u) = s.users.get_mut(&t) { if text.is_empty() { u.ext.take::(); diff --git a/src/link.rs b/src/link.rs index dce2d21..270f79e 100644 --- a/src/link.rs +++ b/src/link.rs @@ -915,7 +915,7 @@ impl Server { format!(":{prefix} PART {chan} :{reason}") }; self.to_channel(&key, &line, None); - self.channels.retain(|_, c| !c.is_empty()); + self.channels.retain(|_, c| c.keep_alive()); let fwd = if reason.is_empty() { format!(":{uuid} PART {chan}") } else { @@ -950,7 +950,7 @@ impl Server { for m in notify { self.send(m, line.clone()); } - self.channels.retain(|_, c| !c.is_empty()); + self.channels.retain(|_, c| c.keep_alive()); if let Some(ru) = self.remote_users.remove(uuid) { self.remote_nick.remove(&ru.nick.to_ascii_lowercase()); } @@ -1056,7 +1056,7 @@ impl Server { &format!(":{prefix} KICK {chan} {victim} :{reason}"), None, ); - self.channels.retain(|_, c| !c.is_empty()); + self.channels.retain(|_, c| c.keep_alive()); self.propagate(&format!(":{src} KICK {chan} {victim} :{reason}"), Some(via)); } diff --git a/src/mode.rs b/src/mode.rs index 0860527..a4d3cc2 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -85,6 +85,9 @@ static CHAN_MODES: &[&(dyn ChanMode + Sync)] = &[ &REDIRECT, &CHANHISTORY, &ANTICAPS, + &NOKICKS, + &ALLOWINVITE, + &PERMANENT, ]; // --- prefix modes (+q/+a/+o/+h/+v): a per-member rank, needs a nick ---------- @@ -294,6 +297,27 @@ static AUDITORIUM: Flag = Flag { ch: 'u', set: set_auditorium, }; +fn set_nokicks(m: &mut ChanModes, v: bool) { + m.nokicks = v; +} +fn set_allowinvite(m: &mut ChanModes, v: bool) { + m.allowinvite = v; +} +fn set_permanent(m: &mut ChanModes, v: bool) { + m.permanent = v; +} +static NOKICKS: Flag = Flag { + ch: 'Q', + set: set_nokicks, +}; +static ALLOWINVITE: Flag = Flag { + ch: 'A', + set: set_allowinvite, +}; +static PERMANENT: Flag = Flag { + ch: 'P', + set: set_permanent, +}; impl ChanMode for Flag { fn letter(&self) -> char { @@ -1059,7 +1083,7 @@ mod tests { #[test] fn registry_covers_all_channel_modes() { - for c in "qaohvbeIklmntiszpONCTcSRMfjFLgGuB".chars() { + for c in "qaohvbeIklmntiszpONCTcSRMfjFLgGuBQAP".chars() { assert!(chan_mode(c).is_some(), "missing handler for +{c}"); } assert!(chan_mode('y').is_none()); diff --git a/src/server.rs b/src/server.rs index 45ed5a1..bf396f4 100644 --- a/src/server.rs +++ b/src/server.rs @@ -423,7 +423,7 @@ impl Server { for m in seen { self.send(m, line.clone()); } - self.channels.retain(|_, c| !c.is_empty()); + self.channels.retain(|_, c| c.keep_alive()); self.watch_notify_offline(&user.nick); // tell WATCH/MONITOR watchers } } diff --git a/src/users.rs b/src/users.rs index a595780..aea9ead 100644 --- a/src/users.rs +++ b/src/users.rs @@ -409,7 +409,7 @@ impl Server { uid, RPL_MYINFO, &format!( - "{} echoircd-{VERSION} iowxsgBDIHrRz qaohvbeIklimnpstzCTcSNORMfjFLgGuB", + "{} echoircd-{VERSION} iowxsgBDIHrRz qaohvbeIklimnpstzCTcSNORMfjFLgGuBQAP", self.name ), ); @@ -417,7 +417,7 @@ impl Server { uid, RPL_ISUPPORT, &format!( - "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", + "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFLHB,ACGMNOPQRSTcimnpstuz 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 ), );