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(); let text = t.text.clone();
self.numeric(uid, RPL_TOPIC, &format!("{name} :{text}")); self.numeric(uid, RPL_TOPIC, &format!("{name} :{text}"));
} }
self.send_names(uid, &key); // 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.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.propagate_join(uid, name, is_new); // tell linked servers this user joined
self.events.push_back(Hook::Join(uid, key)); self.events.push_back(Hook::Join(uid, key));
@ -1229,6 +1233,7 @@ impl Server {
list.iter().any(|b| { list.iter().any(|b| {
if b.mask.as_bytes().get(1) == Some(&b':') { if b.mask.as_bytes().get(1) == Some(&b':') {
match b.mask.as_bytes().first() { 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'g') => crate::modules::securitygroups::in_group(self, uid, &b.mask[2..]),
Some(b'y') => { Some(b'y') => {
crate::modules::reputation::score_ban_match(self, uid, &b.mask[2..]) 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() .or_default()
.insert(tkey, ts); .insert(tkey, ts);
let line = format!(":{} MARKREAD {target} timestamp={}", s.name, iso_time(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 let recips: Vec<Uid> = s
.users .users
.keys() .iter()
.copied() .filter(|(&p, u)| u.caps.read_marker && marker_id(s, p) == id)
.filter(|&p| marker_id(s, p) == id) .map(|(&p, _)| p)
.collect(); .collect();
for p in recips { for p in recips {
s.send(p, line.clone()); s.send(p, line.clone());

View file

@ -4,6 +4,7 @@
//! self-contained unit. //! self-contained unit.
pub mod account_registration; pub mod account_registration;
pub mod accountban;
pub mod antimixedutf8; pub mod antimixedutf8;
pub mod antirandom; pub mod antirandom;
pub mod autodrop; 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 include_oper = self.conf_bool("operprefix", false) || self.conf_bool("ojoin", false);
let prefix = crate::modules::customprefix::isupport(include_oper); let prefix = crate::modules::customprefix::isupport(include_oper);
let mut tokens: Vec<String> = format!( 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 self.network
) )
.split(' ') .split(' ')
@ -1395,6 +1395,43 @@ mod tests {
assert!(!ann.iter().any(|l| l.contains("PART")), "no fallback on case-only: {ann:?}"); 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] #[test]
fn nick_change_reindexes_and_notifies_channel() { fn nick_change_reindexes_and_notifies_channel() {
let mut s = srv(); let mut s = srv();

View file

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