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 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 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()));
}

View file

@ -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

View file

@ -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 {

View file

@ -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, &params[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::<Swhois>();

View file

@ -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));
}

View file

@ -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());

View file

@ -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
}
}

View file

@ -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
),
);