From 3fa9737ccb695babb6de6332fc57163307592edd Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 15 Aug 2026 13:20:04 +0000 Subject: [PATCH] =?UTF-8?q?link:=20stop=20services=20re-locking=20+r=20on?= =?UTF-8?q?=20every=20join=20=E2=80=94=20track=20the=20registered=20channe?= =?UTF-8?q?l=20mode,=20keep=20+r=20channels=20alive=20when=20empty,=20use?= =?UTF-8?q?=20ijoin=20not=20fjoin=20for=20incremental=20joins,=20and=20con?= =?UTF-8?q?sume=20s2s=20channel-mode=20params=20through=20the=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/channels.rs | 11 ++++- src/coremods/core_channel.rs | 9 +++- src/link.rs | 92 ++++++++++++++++++++++++++++-------- src/modules/operprefix.rs | 4 +- src/server.rs | 2 +- 5 files changed, 92 insertions(+), 26 deletions(-) diff --git a/src/channels.rs b/src/channels.rs index 73ed17a..8b98492 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -253,6 +253,8 @@ pub struct ChanModes { pub delaymsg: Option, // +d — new joiners can't speak for N secs pub repeat: Option, // +K — 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)); } diff --git a/src/coremods/core_channel.rs b/src/coremods/core_channel.rs index d17723c..c492e8b 100644 --- a/src/coremods/core_channel.rs +++ b/src/coremods/core_channel.rs @@ -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 } } diff --git a/src/link.rs b/src/link.rs index 0d848f6..d79d43a 100644 --- a/src/link.rs +++ b/src/link.rs @@ -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) = ¶m { + 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); + } + } } } } diff --git a/src/modules/operprefix.rs b/src/modules/operprefix.rs index 8d48306..5b9fb4e 100644 --- a/src/modules/operprefix.rs +++ b/src/modules/operprefix.rs @@ -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). diff --git a/src/server.rs b/src/server.rs index be1ff47..b2f0661 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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) {