channel modes +Q nokicks / +A allowinvite / +P permchannels; fix SWHOIS (full multi-word text, no doubled nick in WHOIS)

This commit is contained in:
Jean Chevronnet 2026-08-09 00:47:10 +00:00
parent 050ce37612
commit 8dc51a281b
8 changed files with 70 additions and 20 deletions

View file

@ -149,6 +149,9 @@ pub struct ChanModes {
pub redirect: Option<String>, // +L <#target> — when full, send there pub redirect: Option<String>, // +L <#target> — when full, send there
pub history: Option<(u32, u64)>, // +H <lines>:<secs> — replay recent messages to joiners pub history: Option<(u32, u64)>, // +H <lines>:<secs> — replay recent messages to joiners
pub anticaps: Option<u8>, // +B <percent> — block messages that are mostly CAPS pub anticaps: Option<u8>, // +B <percent> — 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 { impl ChanModes {
@ -172,6 +175,9 @@ impl ChanModes {
'M' => self.reg_moderated = on, 'M' => self.reg_moderated = on,
'G' => self.censor = on, 'G' => self.censor = on,
'u' => self.auditorium = 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 { pub fn is_empty(&self) -> bool {
self.members.is_empty() && self.rmembers.is_empty() 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 { impl Server {
@ -350,7 +362,7 @@ impl Server {
if let Some(u) = self.users.get_mut(&uid) { if let Some(u) = self.users.get_mut(&uid) {
u.channels.remove(&key); 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), /// 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) { if let Some(u) = self.users.get_mut(&uid) {
u.channels.remove(key); u.channels.remove(key);
} }
self.channels.retain(|_, c| !c.is_empty()); self.channels.retain(|_, c| c.keep_alive());
self.events self.events
.push_back(Hook::Part(uid, key.to_string(), "flood".to_string())); .push_back(Hook::Part(uid, key.to_string(), "flood".to_string()));
} }

View file

@ -99,7 +99,7 @@ impl Command for Cycle {
if let Some(u) = s.users.get_mut(&uid) { if let Some(u) = s.users.get_mut(&uid) {
u.channels.remove(&key); u.channels.remove(&key);
} }
s.channels.retain(|_, c| !c.is_empty()); s.channels.retain(|_, c| c.keep_alive());
s.join(uid, chan, None); s.join(uid, chan, None);
CmdResult::Ok CmdResult::Ok
} }
@ -167,7 +167,7 @@ impl Command for Remove {
if let Some(u) = s.users.get_mut(&tuid) { if let Some(u) = s.users.get_mut(&tuid) {
u.channels.remove(&key); u.channels.remove(&key);
} }
s.channels.retain(|_, c| !c.is_empty()); s.channels.retain(|_, c| c.keep_alive());
CmdResult::Ok CmdResult::Ok
} }
} }
@ -195,8 +195,11 @@ impl Command for Invite {
); );
return CmdResult::Fail; return CmdResult::Fail;
} }
// only ops may invite into an +i channel // only ops may invite into an +i channel — unless +A (allow anyone to invite)
if s.channels[&key].modes.invite_only && !s.is_op(uid, &key) { if s.channels[&key].modes.invite_only
&& !s.channels[&key].modes.allowinvite
&& !s.is_op(uid, &key)
{
s.numeric( s.numeric(
uid, uid,
ERR_CHANOPRIVSNEEDED, ERR_CHANOPRIVSNEEDED,
@ -373,7 +376,7 @@ impl Command for Part {
if let Some(u) = s.users.get_mut(&uid) { if let Some(u) = s.users.get_mut(&uid) {
u.channels.remove(&key); 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())); s.events.push_back(Hook::Part(uid, key, reason.clone()));
} }
CmdResult::Ok CmdResult::Ok
@ -428,6 +431,15 @@ impl Command for Kick {
); );
return CmdResult::Fail; 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 kicker = s.users[&uid].nick.clone();
let reason = params.get(2).cloned().unwrap_or(kicker); let reason = params.get(2).cloned().unwrap_or(kicker);
let prefix = s.users[&uid].prefix(); let prefix = s.users[&uid].prefix();
@ -443,7 +455,7 @@ impl Command for Kick {
if let Some(u) = s.users.get_mut(&tuid) { if let Some(u) = s.users.get_mut(&tuid) {
u.channels.remove(&key); u.channels.remove(&key);
} }
s.channels.retain(|_, c| !c.is_empty()); s.channels.retain(|_, c| c.keep_alive());
s.events s.events
.push_back(Hook::Part(tuid, key, "kicked".to_string())); .push_back(Hook::Part(tuid, key, "kicked".to_string()));
CmdResult::Ok CmdResult::Ok

View file

@ -175,9 +175,10 @@ impl Command for Whois {
&format!("{nick} :is an IRC operator"), &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 { 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 // opers can see through the cloak to the real host/ip
if asker_oper && disp != realhost { if asker_oper && disp != realhost {

View file

@ -975,7 +975,7 @@ impl Command for SaKick {
if let Some(u) = s.users.get_mut(&tuid) { if let Some(u) = s.users.get_mut(&tuid) {
u.channels.remove(&key); u.channels.remove(&key);
} }
s.channels.retain(|_, c| !c.is_empty()); s.channels.retain(|_, c| c.keep_alive());
s.events s.events
.push_back(Hook::Part(tuid, key, "kicked".to_string())); .push_back(Hook::Part(tuid, key, "kicked".to_string()));
let by = oper_nick(s, uid); let by = oper_nick(s, uid);
@ -1099,7 +1099,7 @@ impl Command for ClearChan {
s.events s.events
.push_back(Hook::Part(tuid, key.clone(), "cleared".to_string())); .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); let by = oper_nick(s, uid);
s.snotice(&format!("{by} used CLEARCHAN on {chan}")); s.snotice(&format!("{by} used CLEARCHAN on {chan}"));
CmdResult::Ok CmdResult::Ok
@ -1208,7 +1208,8 @@ impl Command for SwhoisCmd {
let Some(t) = oper_target(s, uid, &params[0]) else { let Some(t) = oper_target(s, uid, &params[0]) else {
return CmdResult::Fail; 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 let Some(u) = s.users.get_mut(&t) {
if text.is_empty() { if text.is_empty() {
u.ext.take::<Swhois>(); u.ext.take::<Swhois>();

View file

@ -915,7 +915,7 @@ impl Server {
format!(":{prefix} PART {chan} :{reason}") format!(":{prefix} PART {chan} :{reason}")
}; };
self.to_channel(&key, &line, None); 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() { let fwd = if reason.is_empty() {
format!(":{uuid} PART {chan}") format!(":{uuid} PART {chan}")
} else { } else {
@ -950,7 +950,7 @@ impl Server {
for m in notify { for m in notify {
self.send(m, line.clone()); 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) { if let Some(ru) = self.remote_users.remove(uuid) {
self.remote_nick.remove(&ru.nick.to_ascii_lowercase()); self.remote_nick.remove(&ru.nick.to_ascii_lowercase());
} }
@ -1056,7 +1056,7 @@ impl Server {
&format!(":{prefix} KICK {chan} {victim} :{reason}"), &format!(":{prefix} KICK {chan} {victim} :{reason}"),
None, 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)); self.propagate(&format!(":{src} KICK {chan} {victim} :{reason}"), Some(via));
} }

View file

@ -85,6 +85,9 @@ static CHAN_MODES: &[&(dyn ChanMode + Sync)] = &[
&REDIRECT, &REDIRECT,
&CHANHISTORY, &CHANHISTORY,
&ANTICAPS, &ANTICAPS,
&NOKICKS,
&ALLOWINVITE,
&PERMANENT,
]; ];
// --- 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 ----------
@ -294,6 +297,27 @@ static AUDITORIUM: Flag = Flag {
ch: 'u', ch: 'u',
set: set_auditorium, 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 { impl ChanMode for Flag {
fn letter(&self) -> char { fn letter(&self) -> char {
@ -1059,7 +1083,7 @@ mod tests {
#[test] #[test]
fn registry_covers_all_channel_modes() { 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(c).is_some(), "missing handler for +{c}");
} }
assert!(chan_mode('y').is_none()); assert!(chan_mode('y').is_none());

View file

@ -423,7 +423,7 @@ impl Server {
for m in seen { for m in seen {
self.send(m, line.clone()); 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 self.watch_notify_offline(&user.nick); // tell WATCH/MONITOR watchers
} }
} }

View file

@ -409,7 +409,7 @@ impl Server {
uid, uid,
RPL_MYINFO, RPL_MYINFO,
&format!( &format!(
"{} echoircd-{VERSION} iowxsgBDIHrRz qaohvbeIklimnpstzCTcSNORMfjFLgGuB", "{} echoircd-{VERSION} iowxsgBDIHrRz qaohvbeIklimnpstzCTcSNORMfjFLgGuBQAP",
self.name self.name
), ),
); );
@ -417,7 +417,7 @@ impl Server {
uid, uid,
RPL_ISUPPORT, RPL_ISUPPORT,
&format!( &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 self.network
), ),
); );