whox (354) + ircv3 standard-replies (fail/warn/note), account-tag, and +s/+p who/names member hiding

This commit is contained in:
Jean Chevronnet 2026-08-08 17:55:44 +00:00
parent 7e0449e2dd
commit bcfed8a621
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
7 changed files with 233 additions and 53 deletions

View file

@ -507,6 +507,19 @@ impl Server {
self.numeric(uid, RPL_ENDOFNAMES, &format!("{key} :End of /NAMES list")); self.numeric(uid, RPL_ENDOFNAMES, &format!("{key} :End of /NAMES list"));
return; return;
}; };
// +s (secret) / +p (private): members are hidden from non-members. Reply as
// if the channel were empty (opers still see it).
if (ch.modes.secret || ch.modes.private)
&& !ch.members.contains_key(&uid)
&& !self.is_oper(uid)
{
self.numeric(
uid,
RPL_ENDOFNAMES,
&format!("{} :End of /NAMES list", ch.name),
);
return;
}
// multi-prefix → all prefixes; userhost-in-names → full nick!user@host // multi-prefix → all prefixes; userhost-in-names → full nick!user@host
let (multi, uhost) = self let (multi, uhost) = self
.users .users

View file

@ -39,17 +39,11 @@ impl Command for Knock {
} }
let can = s.channels[&key].modes.invite_only && !s.is_member(uid, &key); let can = s.channels[&key].modes.invite_only && !s.is_member(uid, &key);
if !can { if !can {
let nick = s s.fail(
.users
.get(&uid)
.map(|u| u.nick.clone())
.unwrap_or_default();
s.send(
uid, uid,
format!( "KNOCK",
":{} NOTICE {nick} :Can't KNOCK on {chan} (not invite-only, or you're on it)", "CANNOT_KNOCK",
s.name &format!("Can't KNOCK on {chan} (not invite-only, or you're on it)"),
),
); );
return CmdResult::Fail; return CmdResult::Fail;
} }

View file

@ -3,6 +3,7 @@
use crate::command::{CmdResult, Command}; use crate::command::{CmdResult, Command};
use crate::numeric::*; use crate::numeric::*;
use crate::server::{Server, VERSION}; use crate::server::{Server, VERSION};
use crate::users::User;
use crate::Uid; use crate::Uid;
pub fn commands() -> Vec<Box<dyn Command>> { pub fn commands() -> Vec<Box<dyn Command>> {
@ -214,14 +215,34 @@ impl Command for Who {
} }
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
let target = &params[0]; let target = &params[0];
if target.starts_with('#') { // WHOX: an options token containing '%' selects the reply fields (354).
let key = target.to_ascii_lowercase(); // `WHO <target> <filter>%<fields>[,<querytype>]`, e.g. `WHO #c %cuhnat,152`.
let multi = s let whox = params
.users .get(1)
.get(&uid) .and_then(|o| o.split_once('%'))
.map(|u| u.caps.multi_prefix) .map(|(_, spec)| {
.unwrap_or(false); let (fields, qtype) = spec.split_once(',').unwrap_or((spec, ""));
let rows: Vec<(Uid, String, String)> = match s.channels.get(&key) { (fields.to_string(), qtype.to_string())
});
let asker_oper = s.is_oper(uid);
let multi = s
.users
.get(&uid)
.map(|u| u.caps.multi_prefix)
.unwrap_or(false);
// (uid, channel-name-or-"*", prefix-string) for each user to report
let rows: Vec<(Uid, String, String)> = if target.starts_with('#') {
match s.channels.get(&target.to_ascii_lowercase()) {
// +s/+p: don't reveal a secret/private channel's members to
// non-members (opers excepted)
Some(ch)
if (ch.modes.secret || ch.modes.private)
&& !ch.members.contains_key(&uid)
&& !asker_oper =>
{
Vec::new()
}
Some(ch) => { Some(ch) => {
let name = ch.name.clone(); let name = ch.name.clone();
ch.members ch.members
@ -237,37 +258,131 @@ impl Command for Who {
.collect() .collect()
} }
None => Vec::new(), None => Vec::new(),
};
for (m, name, pfx) in rows {
if let Some(u) = s.users.get(&m) {
let row = format!(
"{name} {} {} {} {} H{pfx} :0 {}",
u.ident,
u.host_display(),
s.name,
u.nick,
u.realname
);
s.numeric(uid, RPL_WHOREPLY, &row);
}
} }
} else if let Some(tuid) = s.find_nick(target) { } else if let Some(tuid) = s.find_nick(target) {
let u = &s.users[&tuid]; vec![(tuid, "*".to_string(), String::new())]
let row = format!( } else {
"* {} {} {} {} H :0 {}", Vec::new()
u.ident, };
u.host_display(),
s.name, let now = crate::server::now();
u.nick, for (m, chan, pfx) in rows {
u.realname let (code, row) = {
); let Some(u) = s.users.get(&m) else { continue };
s.numeric(uid, RPL_WHOREPLY, &row); // flags: H (here) / G (gone/away), then * for opers, then prefixes
let mut flags = String::from(if u.flags.away.is_some() { "G" } else { "H" });
if u.flags.oper && (!u.flags.hideoper || asker_oper) {
flags.push('*');
}
flags.push_str(&pfx);
match &whox {
Some((fields, qtype)) => (
RPL_WHOSPCRPL,
whox_row(
&s.name,
u,
&chan,
&flags,
fields,
qtype,
asker_oper,
m == uid,
now,
),
),
None => (
RPL_WHOREPLY,
format!(
"{chan} {} {} {} {} {flags} :0 {}",
u.ident,
u.host_display(),
s.name,
u.nick,
u.realname
),
),
}
};
s.numeric(uid, code, &row);
} }
s.numeric(uid, RPL_ENDOFWHO, &format!("{target} :End of /WHO list")); s.numeric(uid, RPL_ENDOFWHO, &format!("{target} :End of /WHO list"));
CmdResult::Ok CmdResult::Ok
} }
} }
/// Build a WHOX (354) reply body: the requested `fields` in their fixed output
/// order (never the request order), realname always last. Unknown field letters
/// are ignored. The real IP (`i`) is shown only to opers or to the user
/// themselves, so host-cloaking isn't defeated.
#[allow(clippy::too_many_arguments)]
fn whox_row(
server: &str,
u: &User,
chan: &str,
flags: &str,
fields: &str,
qtype: &str,
asker_oper: bool,
is_self: bool,
now: u64,
) -> String {
let has = |c: char| fields.contains(c);
let mut parts: Vec<String> = Vec::new();
if has('t') {
parts.push(if qtype.is_empty() {
"0".to_string()
} else {
qtype.to_string()
});
}
if has('c') {
parts.push(chan.to_string());
}
if has('u') {
parts.push(u.ident.clone());
}
if has('i') {
parts.push(if asker_oper || is_self {
u.addr.ip().to_string()
} else {
"255.255.255.255".to_string()
});
}
if has('h') {
parts.push(u.host_display().to_string());
}
if has('s') {
parts.push(server.to_string());
}
if has('n') {
parts.push(u.nick.clone());
}
if has('f') {
parts.push(flags.to_string());
}
if has('d') {
parts.push("0".to_string()); // hopcount (local users)
}
if has('l') {
parts.push(now.saturating_sub(u.last_active).to_string()); // idle seconds
}
if has('a') {
parts.push(u.account.clone().unwrap_or_else(|| "0".to_string()));
}
if has('o') {
parts.push("n/a".to_string()); // channel op-level
}
let mut row = parts.join(" ");
if has('r') {
if !row.is_empty() {
row.push(' ');
}
row.push(':');
row.push_str(&u.realname);
}
row
}
struct Lusers; struct Lusers;
impl Command for Lusers { impl Command for Lusers {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {

View file

@ -317,7 +317,7 @@ fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResu
if m == uid || s.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) { if m == uid || s.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) {
continue; continue;
} }
s.send_tagged(m, &ctags, &msgid, &line); s.send_tagged(m, uid, &ctags, &msgid, &line);
} }
// echo-message: give the sender their own copy if they asked for one // echo-message: give the sender their own copy if they asked for one
if s.users if s.users
@ -325,7 +325,7 @@ fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResu
.map(|u| u.caps.echo_message) .map(|u| u.caps.echo_message)
.unwrap_or(false) .unwrap_or(false)
{ {
s.send_tagged(uid, &ctags, &msgid, &line); s.send_tagged(uid, uid, &ctags, &msgid, &line);
} }
// propagate to linked servers that have members in this channel // propagate to linked servers that have members in this channel
s.send_channel_to_links(uid, &key, target, cmd, &body); s.send_channel_to_links(uid, &key, target, cmd, &body);
@ -427,14 +427,14 @@ fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResu
let ctags = s.line_ctags.clone(); let ctags = s.line_ctags.clone();
let msgid = s.next_msgid(); let msgid = s.next_msgid();
if !silenced { if !silenced {
s.send_tagged(tuid, &ctags, &msgid, &pm); s.send_tagged(tuid, uid, &ctags, &msgid, &pm);
} }
if s.users if s.users
.get(&uid) .get(&uid)
.map(|u| u.caps.echo_message) .map(|u| u.caps.echo_message)
.unwrap_or(false) .unwrap_or(false)
{ {
s.send_tagged(uid, &ctags, &msgid, &pm); s.send_tagged(uid, uid, &ctags, &msgid, &pm);
} }
// if the recipient is away, tell the sender (PRIVMSG only, not if silenced) // if the recipient is away, tell the sender (PRIVMSG only, not if silenced)
if !notice && !silenced { if !notice && !silenced {
@ -467,6 +467,7 @@ fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResu
let ctags = s.line_ctags.clone(); let ctags = s.line_ctags.clone();
let msgid = s.next_msgid(); let msgid = s.next_msgid();
s.send_tagged( s.send_tagged(
uid,
uid, uid,
&ctags, &ctags,
&msgid, &msgid,
@ -564,7 +565,7 @@ impl Command for TagMsg {
.map(|u| u.caps.message_tags) .map(|u| u.caps.message_tags)
.unwrap_or(false) .unwrap_or(false)
{ {
s.send_tagged(m, &ctags, &msgid, &body); s.send_tagged(m, uid, &ctags, &msgid, &body);
} }
} }
} else if let Some(tuid) = s.find_nick(target) { } else if let Some(tuid) = s.find_nick(target) {
@ -573,14 +574,14 @@ impl Command for TagMsg {
.map(|u| u.caps.message_tags) .map(|u| u.caps.message_tags)
.unwrap_or(false) .unwrap_or(false)
{ {
s.send_tagged(tuid, &ctags, &msgid, &body); s.send_tagged(tuid, uid, &ctags, &msgid, &body);
} }
if s.users if s.users
.get(&uid) .get(&uid)
.map(|u| u.caps.echo_message) .map(|u| u.caps.echo_message)
.unwrap_or(false) .unwrap_or(false)
{ {
s.send_tagged(uid, &ctags, &msgid, &body); s.send_tagged(uid, uid, &ctags, &msgid, &body);
} }
} }
CmdResult::Ok CmdResult::Ok

View file

@ -96,6 +96,7 @@ pub const RPL_CREATIONTIME: u16 = 329;
pub const RPL_NOTOPIC: u16 = 331; pub const RPL_NOTOPIC: u16 = 331;
pub const RPL_TOPIC: u16 = 332; pub const RPL_TOPIC: u16 = 332;
pub const RPL_WHOREPLY: u16 = 352; pub const RPL_WHOREPLY: u16 = 352;
pub const RPL_WHOSPCRPL: u16 = 354; // WHOX: field-selected WHO reply
pub const RPL_NAMREPLY: u16 = 353; pub const RPL_NAMREPLY: u16 = 353;
pub const RPL_ENDOFNAMES: u16 = 366; pub const RPL_ENDOFNAMES: u16 = 366;

View file

@ -446,6 +446,46 @@ impl Server {
} }
} }
/// IRCv3 standard reply (`FAIL`/`WARN`/`NOTE`): structured, machine-readable
/// command feedback. Sent in the `:server FAIL <command> <code> :<desc>` form
/// to clients that negotiated `standard-replies`; others get the description as
/// a plain server NOTICE so the human-readable text still reaches them.
pub fn fail(&self, uid: Uid, command: &str, code: &str, desc: &str) {
self.standard_reply(uid, "FAIL", command, code, desc);
}
pub fn warn(&self, uid: Uid, command: &str, code: &str, desc: &str) {
self.standard_reply(uid, "WARN", command, code, desc);
}
pub fn note(&self, uid: Uid, command: &str, code: &str, desc: &str) {
self.standard_reply(uid, "NOTE", command, code, desc);
}
fn standard_reply(&self, uid: Uid, kind: &str, command: &str, code: &str, desc: &str) {
let cap = self
.users
.get(&uid)
.map(|u| u.caps.standard_replies)
.unwrap_or(false);
if cap {
self.send(
uid,
format!(":{} {kind} {command} {code} :{desc}", self.name),
);
} else {
let nick = self
.users
.get(&uid)
.map(|u| {
if u.nick.is_empty() {
"*".to_string()
} else {
u.nick.clone()
}
})
.unwrap_or_else(|| "*".to_string());
self.send(uid, format!(":{} NOTICE {nick} :{desc}", self.name));
}
}
/// Send a line to every member of a channel, optionally skipping one uid. /// Send a line to every member of a channel, optionally skipping one uid.
pub fn to_channel(&self, key: &str, line: &str, except: Option<Uid>) { pub fn to_channel(&self, key: &str, line: &str, except: Option<Uid>) {
if let Some(ch) = self.channels.get(key) { if let Some(ch) = self.channels.get(key) {
@ -457,15 +497,23 @@ impl Server {
} }
} }
/// Send a message body (`:prefix CMD …`) to `uid`, composing its IRCv3 tag /// Send a message body (`:prefix CMD …`) from `src` to `uid`, composing its
/// prefix from that client's caps: `time=` (server-time) plus the client-only /// IRCv3 tag prefix from *that recipient's* caps: `time=` (server-time),
/// tags `ctags` (message-tags). Used for PRIVMSG / NOTICE / TAGMSG delivery. /// `account=` (account-tag, from the sender's login) plus the client-only tags
pub fn send_tagged(&self, uid: Uid, ctags: &str, msgid: &str, body: &str) { /// `ctags` and `msgid` (message-tags). For PRIVMSG / NOTICE / TAGMSG delivery.
pub fn send_tagged(&self, uid: Uid, src: Uid, ctags: &str, msgid: &str, body: &str) {
if let Some(u) = self.users.get(&uid) { if let Some(u) = self.users.get(&uid) {
let mut tags: Vec<String> = Vec::new(); let mut tags: Vec<String> = Vec::new();
if u.caps.server_time { if u.caps.server_time {
tags.push(format!("time={}", iso_time(now()))); tags.push(format!("time={}", iso_time(now())));
} }
// account-tag: label a message with the sender's services account, so
// recipients see who's authenticated without a separate WHOIS.
if u.caps.account_tag {
if let Some(acct) = self.users.get(&src).and_then(|su| su.account.as_deref()) {
tags.push(format!("account={acct}"));
}
}
// msgid (IRCv3): a unique, server-assigned id per message so clients // msgid (IRCv3): a unique, server-assigned id per message so clients
// can reference it (reactions, replies, redaction). Tag-only feature, // can reference it (reactions, replies, redaction). Tag-only feature,
// so it goes to message-tags clients alongside any client `+`-tags. // so it goes to message-tags clients alongside any client `+`-tags.

View file

@ -93,6 +93,8 @@ pub const SUPPORTED_CAPS: &[&str] = &[
"invite-notify", "invite-notify",
"setname", "setname",
"extended-monitor", "extended-monitor",
"account-tag",
"standard-replies",
"cap-notify", "cap-notify",
]; ];
@ -114,6 +116,8 @@ pub struct Caps {
pub invite_notify: bool, pub invite_notify: bool,
pub setname: bool, pub setname: bool,
pub extended_monitor: bool, // route away/account/chghost/setname for MONITOR targets pub extended_monitor: bool, // route away/account/chghost/setname for MONITOR targets
pub account_tag: bool, // prepend account=<name> tag on messages from logged-in users
pub standard_replies: bool, // understands FAIL/WARN/NOTE structured replies
pub cap_notify: bool, pub cap_notify: bool,
} }
@ -152,6 +156,8 @@ impl Caps {
"invite-notify" => self.invite_notify, "invite-notify" => self.invite_notify,
"setname" => self.setname, "setname" => self.setname,
"extended-monitor" => self.extended_monitor, "extended-monitor" => self.extended_monitor,
"account-tag" => self.account_tag,
"standard-replies" => self.standard_replies,
"cap-notify" => self.cap_notify, "cap-notify" => self.cap_notify,
_ => false, _ => false,
} }
@ -173,6 +179,8 @@ impl Caps {
"invite-notify" => &mut self.invite_notify, "invite-notify" => &mut self.invite_notify,
"setname" => &mut self.setname, "setname" => &mut self.setname,
"extended-monitor" => &mut self.extended_monitor, "extended-monitor" => &mut self.extended_monitor,
"account-tag" => &mut self.account_tag,
"standard-replies" => &mut self.standard_replies,
"cap-notify" => &mut self.cap_notify, "cap-notify" => &mut self.cap_notify,
_ => return false, _ => return false,
}; };
@ -370,7 +378,7 @@ impl Server {
uid, uid,
RPL_ISUPPORT, RPL_ISUPPORT,
&format!( &format!(
"CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFL,CGMNORSTcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFL,CGMNORSTcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server",
self.network self.network
), ),
); );