whox (354) + ircv3 standard-replies (fail/warn/note), account-tag, and +s/+p who/names member hiding
This commit is contained in:
parent
7e0449e2dd
commit
bcfed8a621
7 changed files with 233 additions and 53 deletions
|
|
@ -507,6 +507,19 @@ impl Server {
|
|||
self.numeric(uid, RPL_ENDOFNAMES, &format!("{key} :End of /NAMES list"));
|
||||
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
|
||||
let (multi, uhost) = self
|
||||
.users
|
||||
|
|
|
|||
|
|
@ -39,17 +39,11 @@ impl Command for Knock {
|
|||
}
|
||||
let can = s.channels[&key].modes.invite_only && !s.is_member(uid, &key);
|
||||
if !can {
|
||||
let nick = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
s.send(
|
||||
s.fail(
|
||||
uid,
|
||||
format!(
|
||||
":{} NOTICE {nick} :Can't KNOCK on {chan} (not invite-only, or you're on it)",
|
||||
s.name
|
||||
),
|
||||
"KNOCK",
|
||||
"CANNOT_KNOCK",
|
||||
&format!("Can't KNOCK on {chan} (not invite-only, or you're on it)"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use crate::command::{CmdResult, Command};
|
||||
use crate::numeric::*;
|
||||
use crate::server::{Server, VERSION};
|
||||
use crate::users::User;
|
||||
use crate::Uid;
|
||||
|
||||
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 {
|
||||
let target = ¶ms[0];
|
||||
if target.starts_with('#') {
|
||||
let key = target.to_ascii_lowercase();
|
||||
let multi = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.multi_prefix)
|
||||
.unwrap_or(false);
|
||||
let rows: Vec<(Uid, String, String)> = match s.channels.get(&key) {
|
||||
// WHOX: an options token containing '%' selects the reply fields (354).
|
||||
// `WHO <target> <filter>%<fields>[,<querytype>]`, e.g. `WHO #c %cuhnat,152`.
|
||||
let whox = params
|
||||
.get(1)
|
||||
.and_then(|o| o.split_once('%'))
|
||||
.map(|(_, spec)| {
|
||||
let (fields, qtype) = spec.split_once(',').unwrap_or((spec, ""));
|
||||
(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) => {
|
||||
let name = ch.name.clone();
|
||||
ch.members
|
||||
|
|
@ -237,37 +258,131 @@ impl Command for Who {
|
|||
.collect()
|
||||
}
|
||||
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) {
|
||||
let u = &s.users[&tuid];
|
||||
let row = format!(
|
||||
"* {} {} {} {} H :0 {}",
|
||||
u.ident,
|
||||
u.host_display(),
|
||||
s.name,
|
||||
u.nick,
|
||||
u.realname
|
||||
);
|
||||
s.numeric(uid, RPL_WHOREPLY, &row);
|
||||
vec![(tuid, "*".to_string(), String::new())]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let now = crate::server::now();
|
||||
for (m, chan, pfx) in rows {
|
||||
let (code, row) = {
|
||||
let Some(u) = s.users.get(&m) else { continue };
|
||||
// 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"));
|
||||
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;
|
||||
impl Command for Lusers {
|
||||
fn name(&self) -> &'static str {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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
|
||||
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)
|
||||
.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
|
||||
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 msgid = s.next_msgid();
|
||||
if !silenced {
|
||||
s.send_tagged(tuid, &ctags, &msgid, &pm);
|
||||
s.send_tagged(tuid, uid, &ctags, &msgid, &pm);
|
||||
}
|
||||
if s.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.echo_message)
|
||||
.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 !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 msgid = s.next_msgid();
|
||||
s.send_tagged(
|
||||
uid,
|
||||
uid,
|
||||
&ctags,
|
||||
&msgid,
|
||||
|
|
@ -564,7 +565,7 @@ impl Command for TagMsg {
|
|||
.map(|u| u.caps.message_tags)
|
||||
.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) {
|
||||
|
|
@ -573,14 +574,14 @@ impl Command for TagMsg {
|
|||
.map(|u| u.caps.message_tags)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
s.send_tagged(tuid, &ctags, &msgid, &body);
|
||||
s.send_tagged(tuid, uid, &ctags, &msgid, &body);
|
||||
}
|
||||
if s.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.echo_message)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
s.send_tagged(uid, &ctags, &msgid, &body);
|
||||
s.send_tagged(uid, uid, &ctags, &msgid, &body);
|
||||
}
|
||||
}
|
||||
CmdResult::Ok
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ pub const RPL_CREATIONTIME: u16 = 329;
|
|||
pub const RPL_NOTOPIC: u16 = 331;
|
||||
pub const RPL_TOPIC: u16 = 332;
|
||||
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_ENDOFNAMES: u16 = 366;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
pub fn to_channel(&self, key: &str, line: &str, except: Option<Uid>) {
|
||||
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
|
||||
/// prefix from that client's caps: `time=` (server-time) plus the client-only
|
||||
/// tags `ctags` (message-tags). Used for PRIVMSG / NOTICE / TAGMSG delivery.
|
||||
pub fn send_tagged(&self, uid: Uid, ctags: &str, msgid: &str, body: &str) {
|
||||
/// Send a message body (`:prefix CMD …`) from `src` to `uid`, composing its
|
||||
/// IRCv3 tag prefix from *that recipient's* caps: `time=` (server-time),
|
||||
/// `account=` (account-tag, from the sender's login) plus the client-only tags
|
||||
/// `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) {
|
||||
let mut tags: Vec<String> = Vec::new();
|
||||
if u.caps.server_time {
|
||||
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
|
||||
// can reference it (reactions, replies, redaction). Tag-only feature,
|
||||
// so it goes to message-tags clients alongside any client `+`-tags.
|
||||
|
|
|
|||
10
src/users.rs
10
src/users.rs
|
|
@ -93,6 +93,8 @@ pub const SUPPORTED_CAPS: &[&str] = &[
|
|||
"invite-notify",
|
||||
"setname",
|
||||
"extended-monitor",
|
||||
"account-tag",
|
||||
"standard-replies",
|
||||
"cap-notify",
|
||||
];
|
||||
|
||||
|
|
@ -114,6 +116,8 @@ pub struct Caps {
|
|||
pub invite_notify: bool,
|
||||
pub setname: bool,
|
||||
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,
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +156,8 @@ impl Caps {
|
|||
"invite-notify" => self.invite_notify,
|
||||
"setname" => self.setname,
|
||||
"extended-monitor" => self.extended_monitor,
|
||||
"account-tag" => self.account_tag,
|
||||
"standard-replies" => self.standard_replies,
|
||||
"cap-notify" => self.cap_notify,
|
||||
_ => false,
|
||||
}
|
||||
|
|
@ -173,6 +179,8 @@ impl Caps {
|
|||
"invite-notify" => &mut self.invite_notify,
|
||||
"setname" => &mut self.setname,
|
||||
"extended-monitor" => &mut self.extended_monitor,
|
||||
"account-tag" => &mut self.account_tag,
|
||||
"standard-replies" => &mut self.standard_replies,
|
||||
"cap-notify" => &mut self.cap_notify,
|
||||
_ => return false,
|
||||
};
|
||||
|
|
@ -370,7 +378,7 @@ impl Server {
|
|||
uid,
|
||||
RPL_ISUPPORT,
|
||||
&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
|
||||
),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue