link: stop services re-locking +r on every join — track the registered channel mode, keep +r channels alive when empty, use ijoin not fjoin for incremental joins, and consume s2s channel-mode params through the registry

This commit is contained in:
Jean Chevronnet 2026-08-15 13:20:04 +00:00
parent 62f901bdc8
commit 3fa9737ccb
5 changed files with 92 additions and 26 deletions

View file

@ -253,6 +253,8 @@ pub struct ChanModes {
pub delaymsg: Option<u32>, // +d <secs> — new joiners can't speak for N secs
pub repeat: Option<u32>, // +K <n> — block a line repeated within your last n
pub delayjoin: bool, // +D — hide JOINs until the user speaks/reveals
pub registered: bool, // +r — set by services on a registered channel
// (server/services-only; not user-settable)
}
impl ChanModes {
@ -281,6 +283,7 @@ impl ChanModes {
'P' => self.permanent = on,
'U' => self.opmoderated = on,
'D' => self.delayjoin = on,
'r' => self.registered = on,
_ => {}
}
}
@ -289,6 +292,7 @@ impl ChanModes {
pub fn render(&self, params: bool) -> String {
let mut s = String::from("+");
for (on, ch) in [
(self.registered, 'r'),
(self.invite_only, 'i'),
(self.moderated, 'm'),
(self.no_external, 'n'),
@ -452,7 +456,10 @@ impl Channel {
/// Keep this channel in the table: it has members, or it's +P (permanent).
pub fn keep_alive(&self) -> bool {
!self.is_empty() || self.modes.permanent
// A registered (+r) channel persists with zero members, like +P: otherwise a
// sole user leaving destroys it, and their rejoin recreates it with a new TS —
// which makes linked services re-assert the +r lock (and re-op) on every visit.
!self.is_empty() || self.modes.permanent || self.modes.registered
}
}
@ -918,7 +925,7 @@ impl Server {
}
self.send_names(uid, &key);
self.replay_chanhistory(uid, &key); // +H: replay recent messages to the joiner
self.propagate_join(uid, name); // 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));
}

View file

@ -83,7 +83,14 @@ impl Command for Tban {
});
}
s.to_channel(&key, &format!(":{prefix} MODE {chan} +b {mask}"), None);
s.propagate_from_user(uid, &format!("MODE {chan} +b {mask}"));
// links: a channel mode must go out as a timestamped FMODE, not a plain
// MODE (services ignore channel-targeted MODE)
let src = s
.users
.get(&uid)
.map(|u| u.uuid.clone())
.unwrap_or_else(|| s.sid.clone());
s.propagate_chan_mode(&src, chan, "+b", std::slice::from_ref(&mask));
CmdResult::Ok
}
}

View file

@ -964,7 +964,7 @@ impl Server {
}
/// Tell linked servers a local user joined a channel.
pub fn propagate_join(&self, uid: Uid, chan: &str) {
pub fn propagate_join(&self, uid: Uid, chan: &str, is_new: bool) {
if self.links.is_empty() {
return;
}
@ -975,26 +975,42 @@ impl Server {
if !u.registered {
return;
}
// introduce the join as a single-member channel burst, carrying whatever
// status the user holds (creator gets ops) and the channel's modes/TS so a
// peer that doesn't yet know the channel creates it consistently.
let letters = ch
.members
.get(&uid)
.map(|m| m.mode_letters())
.unwrap_or_default();
self.propagate(
&format!(
":{} FJOIN {} {} {} :{},{}",
self.sid,
ch.name,
ch.created,
ch.modes.render(false),
letters,
u.uuid
),
None,
);
if is_new {
// a brand-new channel: burst it (its modes + the creating member) so a
// peer that doesn't yet know the channel creates it consistently.
self.propagate(
&format!(
":{} FJOIN {} {} {} :{},{}",
self.sid,
ch.name,
ch.created,
ch.modes.render(false),
letters,
u.uuid
),
None,
);
} else {
// joining an existing channel: an incremental single-member add that must
// NOT carry the channel's modes. Re-asserting them on every join fights a
// linked services mode-lock (it would re-apply +r etc. each time someone
// enters). `IJOIN` carries only membership + status, per the standard
// incremental-join primitive.
let flags = if letters.is_empty() {
String::new()
} else {
format!(" {letters}")
};
self.propagate(
&format!(":{} IJOIN {} 1 {}{}", u.uuid, ch.name, ch.created, flags),
None,
);
}
}
/// Tell linked servers a local user parted a channel.
@ -1220,7 +1236,7 @@ impl Server {
for c in modestring.chars() {
match c {
'+' | '-' => sign = c,
'q' | 'a' | 'o' | 'h' | 'v' => {
'y' | 'q' | 'a' | 'o' | 'h' | 'v' => {
if let Some(p) = params.get(pi) {
out.push(self.nick_to_uuid(p));
pi += 1;
@ -1376,7 +1392,12 @@ impl Server {
}
let prefix = self.uuid_prefix(&src).unwrap_or_default();
self.to_channel(&key, &format!(":{prefix} TOPIC {chan} :{text}"), None);
self.propagate(&format!(":{src} TOPIC {chan} :{text}"), Some(via));
// relay onward as a timestamped FTOPIC (services/peers ignore a plain TOPIC)
let chants = self.channels.get(&key).map(|c| c.created).unwrap_or_else(now);
self.propagate(
&format!(":{src} FTOPIC {chan} {chants} {} :{text}", now()),
Some(via),
);
}
fn link_kick_recv(&mut self, via: Uid, msg: &Message) {
@ -1533,7 +1554,7 @@ impl Server {
}
let adding = sign == '+';
match c {
'q' | 'a' | 'o' | 'h' | 'v' => {
'y' | 'q' | 'a' | 'o' | 'h' | 'v' => {
if let Some(n) = args.get(argi).cloned() {
argi += 1;
self.set_member_prefix(&key, &n, c, adding);
@ -1594,8 +1615,37 @@ impl Server {
}
}
_ => {
if let Some(ch) = self.channels.get_mut(&key) {
ch.modes.set_by_letter(c, adding);
// Any other channel mode. Consult the registry for its arity so
// we consume exactly the right number of params — mis-consuming
// here shifts every later mode's argument — then apply it through
// the same handler the local MODE path uses, so parameter modes
// (+f/+j/+F/+L/+H/+B/+J/+d/+K) and list modes (+g/+X/+w) are
// stored, not silently dropped.
let handler = crate::mode::chan_mode(c);
let param = if handler.map(|h| h.wants_param(adding)).unwrap_or(false) {
let p = args.get(argi).cloned();
if p.is_some() {
argi += 1;
}
p
} else {
None
};
if let Some(p) = &param {
shown.push(p.clone());
}
match handler {
Some(h) => {
self.mode_sudo = true;
h.apply(self, &chan, &key, 0, adding, param.as_deref());
self.mode_sudo = false;
}
// no registry handler (e.g. the services-only +r): a plain flag
None => {
if let Some(ch) = self.channels.get_mut(&key) {
ch.modes.set_by_letter(c, adding);
}
}
}
}
}

View file

@ -29,7 +29,9 @@ fn set(s: &mut Server, uid: Uid, key: &str, on: bool) {
let sign = if on { '+' } else { '-' };
let name = s.channels.get(key).map(|c| c.name.clone()).unwrap_or_default();
s.to_channel(key, &format!(":{} MODE {name} {sign}y {nick}", s.name), None);
s.propagate(&format!(":{} MODE {name} {sign}y {nick}", s.sid), None);
// links: a timestamped FMODE (not a plain channel MODE), member named by uuid
let sid = s.sid.clone();
s.propagate_chan_mode(&sid, &name, &format!("{sign}y"), std::slice::from_ref(&nick));
}
/// Grant the oper prefix in `key` (used by ojoin and on-join auto-grant).

View file

@ -705,7 +705,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 lines = vec![format!(
"CHANTYPES=# PREFIX={prefix} CHANMODES=beIgXw,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnpstuz 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=,Gbcgjmnrsy WATCH={maxwatch} MONITOR={maxmon} SILENCE={maxsil} CALLERID=g WHOX CHATHISTORY={chathist} MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN={maxnick} CHANNELLEN={maxchan} NETWORK={}",
self.network
)];
if let Some(tok) = crate::modules::network_icon::isupport(self) {