ircv3: close server-support-table gaps — BOT=B ISUPPORT, account-extban (a: matcher + ACCOUNTEXTBAN=a), draft/read-marker cap (gates MARKREAD sync), and no-implicit-names (suppress the post-JOIN NAMES burst)

This commit is contained in:
Jean Chevronnet 2026-08-18 15:56:59 +00:00
parent c9bd8e7492
commit bcd958d2d3
6 changed files with 75 additions and 5 deletions

View file

@ -964,7 +964,11 @@ impl Server {
let text = t.text.clone();
self.numeric(uid, RPL_TOPIC, &format!("{name} :{text}"));
}
// no-implicit-names: a client that negotiated the cap doesn't want the
// automatic NAMES burst after JOIN (it asks with NAMES when it needs it).
if !self.users.get(&uid).map(|u| u.caps.no_implicit_names).unwrap_or(false) {
self.send_names(uid, &key);
}
self.replay_chanhistory(uid, &key); // +H: replay recent messages to the joiner
self.propagate_join(uid, name, is_new); // tell linked servers this user joined
self.events.push_back(Hook::Join(uid, key));
@ -1229,6 +1233,7 @@ impl Server {
list.iter().any(|b| {
if b.mask.as_bytes().get(1) == Some(&b':') {
match b.mask.as_bytes().first() {
Some(b'a') => crate::modules::accountban::matches(self, uid, &b.mask[2..]),
Some(b'g') => crate::modules::securitygroups::in_group(self, uid, &b.mask[2..]),
Some(b'y') => {
crate::modules::reputation::score_ban_match(self, uid, &b.mask[2..])

16
src/modules/accountban.rs Normal file
View file

@ -0,0 +1,16 @@
//! The `a:` matching extban (IRCv3 account-extban): match a user by the services
//! account they are logged into. `+b a:spammer` bans the account `spammer` (glob),
//! `+e a:trusted` exempts one. A user with no account never matches. Dispatched
//! from the channel ban matcher; advertised via `ACCOUNTEXTBAN=a`.
use crate::channels::glob_match;
use crate::server::Server;
use crate::Uid;
/// Does `uid`'s logged-in account match the glob `mask` (the part after `a:`)?
pub fn matches(s: &Server, uid: Uid, mask: &str) -> bool {
match s.users.get(&uid).and_then(|u| u.account.as_deref()) {
Some(acct) => glob_match(mask, acct),
None => false, // not logged in — an account ban can't match
}
}

View file

@ -140,11 +140,14 @@ impl Command for MarkReadCmd {
.or_default()
.insert(tkey, ts);
let line = format!(":{} MARKREAD {target} timestamp={}", s.name, iso_time(ts));
// Sync the new marker to every connection under this identity that
// negotiated draft/read-marker (multi-device); others never asked
// for read-marker traffic.
let recips: Vec<Uid> = s
.users
.keys()
.copied()
.filter(|&p| marker_id(s, p) == id)
.iter()
.filter(|(&p, u)| u.caps.read_marker && marker_id(s, p) == id)
.map(|(&p, _)| p)
.collect();
for p in recips {
s.send(p, line.clone());

View file

@ -4,6 +4,7 @@
//! self-contained unit.
pub mod account_registration;
pub mod accountban;
pub mod antimixedutf8;
pub mod antirandom;
pub mod autodrop;

View file

@ -751,7 +751,7 @@ impl Server {
let include_oper = self.conf_bool("operprefix", false) || self.conf_bool("ojoin", false);
let prefix = crate::modules::customprefix::isupport(include_oper);
let mut tokens: Vec<String> = format!(
"CHANTYPES=# PREFIX={prefix} CHANMODES=beIgXw,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnprstuz EXTBAN=,Gbcgjmnrsy WATCH={maxwatch} MONITOR={maxmon} SILENCE={maxsil} CALLERID=g WHOX CHATHISTORY={chathist} MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN={maxnick} CHANNELLEN={maxchan} NETWORK={}",
"CHANTYPES=# PREFIX={prefix} CHANMODES=beIgXw,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnprstuz EXTBAN=,aGbcgjmnrsy ACCOUNTEXTBAN=a BOT=B WATCH={maxwatch} MONITOR={maxmon} SILENCE={maxsil} CALLERID=g WHOX CHATHISTORY={chathist} MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN={maxnick} CHANNELLEN={maxchan} NETWORK={}",
self.network
)
.split(' ')
@ -1395,6 +1395,43 @@ mod tests {
assert!(!ann.iter().any(|l| l.contains("PART")), "no fallback on case-only: {ann:?}");
}
#[test]
fn isupport_advertises_bot_and_account_extban() {
let s = srv();
let joined = s.isupport_lines().join(" ");
assert!(joined.contains("BOT=B"), "bot-mode letter: {joined}");
assert!(joined.contains("ACCOUNTEXTBAN=a"), "account-extban token: {joined}");
assert!(joined.contains("EXTBAN=,aG"), "'a' listed in EXTBAN: {joined}");
}
#[test]
fn account_extban_matches_by_account_glob() {
let mut s = srv();
add_user(&mut s, 1, "ann");
s.users.get_mut(&1).unwrap().account = Some("spammer".into());
add_user(&mut s, 2, "bob"); // no account
assert!(crate::modules::accountban::matches(&s, 1, "spam*"));
assert!(!crate::modules::accountban::matches(&s, 1, "other"));
assert!(!crate::modules::accountban::matches(&s, 2, "*"), "no account never matches");
}
#[test]
fn no_implicit_names_suppresses_the_join_names_burst() {
let mut s = srv();
let arx = add_user(&mut s, 1, "ann");
s.users.get_mut(&1).unwrap().caps.no_implicit_names = true;
s.join(1, "#c", None);
let al: Vec<String> = arx.try_iter().collect();
assert!(al.iter().any(|l| l.contains("JOIN #c")), "still gets its JOIN");
assert!(!al.iter().any(|l| l.contains(" 353 ")), "no NAMREPLY: {al:?}");
assert!(!al.iter().any(|l| l.contains(" 366 ")), "no ENDOFNAMES");
// a client without the cap still gets the implicit NAMES
let brx = add_user(&mut s, 2, "bob");
s.join(2, "#c", None);
let bl: Vec<String> = brx.try_iter().collect();
assert!(bl.iter().any(|l| l.contains(" 353 ")) && bl.iter().any(|l| l.contains(" 366 ")));
}
#[test]
fn nick_change_reindexes_and_notifies_channel() {
let mut s = srv();

View file

@ -133,6 +133,8 @@ pub const SUPPORTED_CAPS: &[&str] = &[
"reverse.im/filehost",
"draft/relaymsg",
"draft/channel-rename",
"draft/read-marker",
"no-implicit-names",
"cap-notify",
];
@ -168,6 +170,8 @@ pub struct Caps {
pub filehost: bool, // reverse.im/filehost — knows the file-host extension
pub relaymsg: bool, // draft/relaymsg — may use RELAYMSG (bridge relaying)
pub channel_rename: bool, // draft/channel-rename — receives RENAME (else PART+JOIN)
pub read_marker: bool, // draft/read-marker — MARKREAD sync across the identity
pub no_implicit_names: bool, // no-implicit-names — suppress the auto NAMES after JOIN
pub cap_notify: bool,
}
@ -237,6 +241,8 @@ impl Caps {
"reverse.im/filehost" => self.filehost,
"draft/relaymsg" => self.relaymsg,
"draft/channel-rename" => self.channel_rename,
"draft/read-marker" => self.read_marker,
"no-implicit-names" => self.no_implicit_names,
"cap-notify" => self.cap_notify,
_ => false,
}
@ -273,6 +279,8 @@ impl Caps {
"reverse.im/filehost" => &mut self.filehost,
"draft/relaymsg" => &mut self.relaymsg,
"draft/channel-rename" => &mut self.channel_rename,
"draft/read-marker" => &mut self.read_marker,
"no-implicit-names" => &mut self.no_implicit_names,
"cap-notify" => &mut self.cap_notify,
_ => return false,
};