From ac50af92fc427e3b2d7b1a85993b46867a9847c1 Mon Sep 17 00:00:00 2001 From: Jean Date: Wed, 15 Jul 2026 18:58:08 +0000 Subject: [PATCH] ChanServ: add SET SUCCESSOR with founder inheritance SET <#channel> SUCCESSOR |OFF names an account that inherits the channel if the founder's account is dropped or expires, instead of the channel being released. Adds a ChannelInfo.successor field + event, and centralises the founder-gone channel release into a single release_founded_channels store method used by DROP, expiry, and the gossip account-gone path so all three transfer consistently. --- api/src/lib.rs | 4 ++++ modules/chanserv/src/lib.rs | 2 +- modules/chanserv/src/set.rs | 24 ++++++++++++++++++++- modules/nickserv/src/drop.rs | 14 +++++++----- src/engine/db/channel.rs | 42 +++++++++++++++++++++++++++++++++++- src/engine/db/event.rs | 9 +++++++- src/engine/db/mod.rs | 4 ++++ src/engine/db/store.rs | 6 ++++++ src/engine/mod.rs | 18 +++++++++++----- src/engine/tests.rs | 31 ++++++++++++++++++++++++++ src/grpc.rs | 1 + 11 files changed, 141 insertions(+), 14 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 3eb939a..7cf4d9a 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1028,6 +1028,10 @@ pub trait Store { fn memo_check(&self, account: &str, sender: &str) -> Option<(bool, u64)>; fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError>; fn set_founder(&mut self, channel: &str, account: &str) -> Result<(), ChanError>; + fn set_successor(&mut self, channel: &str, successor: Option<&str>) -> Result<(), ChanError>; + /// Transfer channels founded by `account` to their successor, or drop them. + /// Returns (transferred as (channel, successor), dropped channels). + fn release_founded_channels(&mut self, account: &str) -> (Vec<(String, String)>, Vec); fn access_add(&mut self, channel: &str, account: &str, level: &str) -> Result<(), ChanError>; fn access_del(&mut self, channel: &str, account: &str) -> Result; fn akick_add(&mut self, channel: &str, mask: &str, reason: &str) -> Result<(), ChanError>; diff --git a/modules/chanserv/src/lib.rs b/modules/chanserv/src/lib.rs index 1eb14be..c2d0599 100644 --- a/modules/chanserv/src/lib.rs +++ b/modules/chanserv/src/lib.rs @@ -51,7 +51,7 @@ const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "REGISTER", summary: "register a channel", detail: "Syntax: \x02REGISTER <#channel>\x02\nRegisters a channel to you. You must currently hold ops in it." }, HelpEntry { cmd: "INFO", summary: "show channel information", detail: "Syntax: \x02INFO <#channel>\x02\nShows a channel's registration and settings." }, HelpEntry { cmd: "LIST", summary: "list registered channels", detail: "Syntax: \x02LIST\x02\nLists registered channels." }, - HelpEntry { cmd: "SET", summary: "change founder or settings", detail: "Syntax: \x02SET <#channel> FOUNDER | DESC | SIGNKICK|PRIVATE|PEACE|SECUREOPS|KEEPTOPIC|TOPICLOCK {ON|OFF}\x02\nTransfers the founder or changes a channel setting." }, + HelpEntry { cmd: "SET", summary: "change founder or settings", detail: "Syntax: \x02SET <#channel> FOUNDER | DESC | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|KEEPTOPIC|TOPICLOCK {ON|OFF}\x02\nTransfers the founder or changes a channel setting." }, HelpEntry { cmd: "ACCESS", summary: "manage the access list", detail: "Syntax: \x02ACCESS <#channel> LIST | ADD | DEL \x02\nManages the channel access list." }, HelpEntry { cmd: "FLAGS", summary: "granular per-account flags", detail: "Syntax: \x02FLAGS <#channel> [account [+/-flags]]\x02\nViews or changes granular per-account channel flags." }, HelpEntry { cmd: "AOP/SOP/VOP", summary: "tiered access shortcuts", detail: "Syntax: \x02AOP|SOP|VOP <#channel> ADD | DEL | LIST\x02\nTiered shortcuts over ACCESS: AOP and SOP grant op, VOP grants voice." }, diff --git a/modules/chanserv/src/set.rs b/modules/chanserv/src/set.rs index 642c4d1..2dadecf 100644 --- a/modules/chanserv/src/set.rs +++ b/modules/chanserv/src/set.rs @@ -35,6 +35,28 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } } + Some("SUCCESSOR") => { + match args.get(3) { + Some(&acct) if !acct.eq_ignore_ascii_case("OFF") => { + if !db.exists(acct) { + ctx.notice(me, from.uid, format!("\x02{acct}\x02 isn't a registered account.")); + return; + } + if acct.eq_ignore_ascii_case(&founder) { + ctx.notice(me, from.uid, "The successor must be someone other than the founder."); + return; + } + match db.set_successor(chan, Some(acct)) { + Ok(()) => ctx.notice(me, from.uid, format!("Successor of \x02{chan}\x02 set to \x02{acct}\x02. They inherit it if your account is dropped or expires.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } + _ => match db.set_successor(chan, None) { + Ok(()) => ctx.notice(me, from.uid, format!("Successor of \x02{chan}\x02 cleared.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + }, + } + } Some("DESC") => { let desc = if args.len() > 3 { args[3..].join(" ") } else { String::new() }; match db.set_desc(chan, &desc) { @@ -48,7 +70,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: Some("SECUREOPS") => toggle(me, from, ctx, db, chan, ChanSetting::SecureOps, args.get(3).copied()), Some("KEEPTOPIC") => toggle(me, from, ctx, db, chan, ChanSetting::KeepTopic, args.get(3).copied()), Some("TOPICLOCK") => toggle(me, from, ctx, db, chan, ChanSetting::TopicLock, args.get(3).copied()), - _ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER | DESC | SIGNKICK {ON|OFF} | PRIVATE {ON|OFF} | PEACE {ON|OFF} | SECUREOPS {ON|OFF} | KEEPTOPIC {ON|OFF} | TOPICLOCK {ON|OFF}"), + _ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER | SUCCESSOR |OFF | DESC | SIGNKICK {ON|OFF} | PRIVATE {ON|OFF} | PEACE {ON|OFF} | SECUREOPS {ON|OFF} | KEEPTOPIC {ON|OFF} | TOPICLOCK {ON|OFF}"), } } diff --git a/modules/nickserv/src/drop.rs b/modules/nickserv/src/drop.rs index b64f529..6fb90b7 100644 --- a/modules/nickserv/src/drop.rs +++ b/modules/nickserv/src/drop.rs @@ -17,9 +17,9 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: ctx.notice(me, from.uid, "Invalid password."); return; } - let channels = db.channels_owned_by(account); - for chan in &channels { - let _ = db.drop_channel(chan); + // A channel with a successor is inherited; the rest are dropped and released. + let (inherited, dropped) = db.release_founded_channels(account); + for chan in &dropped { ctx.channel_mode("", chan, "-r"); // server-sourced: release the registered mode } let _ = db.drop_account(account); @@ -27,7 +27,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: ctx.logout(&uid); } ctx.notice(me, from.uid, format!("Your account \x02{account}\x02 has been dropped.")); - if !channels.is_empty() { - ctx.notice(me, from.uid, format!("Channels released: {}.", channels.join(", "))); + if !dropped.is_empty() { + ctx.notice(me, from.uid, format!("Channels released: {}.", dropped.join(", "))); + } + if !inherited.is_empty() { + let list: Vec = inherited.iter().map(|(c, s)| format!("{c} -> {s}")).collect(); + ctx.notice(me, from.uid, format!("Channels passed to their successor: {}.", list.join(", "))); } } diff --git a/src/engine/db/channel.rs b/src/engine/db/channel.rs index f17c789..b89c14e 100644 --- a/src/engine/db/channel.rs +++ b/src/engine/db/channel.rs @@ -11,7 +11,7 @@ impl Db { self.log .append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts }) .map_err(|_| ChanError::Internal)?; - self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None }); + self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None }); Ok(()) } @@ -134,6 +134,46 @@ impl Db { Ok(()) } + /// Set (or clear, with None) `channel`'s successor account. + pub fn set_successor(&mut self, channel: &str, successor: Option<&str>) -> Result<(), ChanError> { + let k = key(channel); + if !self.channels.contains_key(&k) { + return Err(ChanError::NoChannel); + } + let successor = successor.map(|s| s.to_string()); + self.log + .append(Event::ChannelSuccessorSet { channel: channel.to_string(), successor: successor.clone() }) + .map_err(|_| ChanError::Internal)?; + self.channels.get_mut(&k).unwrap().successor = successor; + Ok(()) + } + + /// The successor account set on `channel`, if any. + pub fn channel_successor(&self, channel: &str) -> Option { + self.channels.get(&key(channel)).and_then(|c| c.successor.clone()) + } + + /// Release the channels founded by `account` because the account is going + /// away: one with a valid successor is transferred to them, the rest are + /// dropped. Returns (transferred as (channel, successor), dropped channels). + pub fn release_founded_channels(&mut self, account: &str) -> (Vec<(String, String)>, Vec) { + let (mut transferred, mut dropped) = (Vec::new(), Vec::new()); + for chan in self.channels_owned_by(account) { + let successor = self.channel_successor(&chan).filter(|s| !s.eq_ignore_ascii_case(account) && self.exists(s)); + match successor { + Some(s) if self.set_founder(&chan, &s).is_ok() => { + let _ = self.set_successor(&chan, None); // consumed + transferred.push((chan, s)); + } + _ => { + let _ = self.drop_channel(&chan); + dropped.push(chan); + } + } + } + (transferred, dropped) + } + /// Set `channel`'s description (empty clears it). pub fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError> { let k = key(channel); diff --git a/src/engine/db/event.rs b/src/engine/db/event.rs index c31071f..c856ea2 100644 --- a/src/engine/db/event.rs +++ b/src/engine/db/event.rs @@ -39,6 +39,7 @@ pub enum Event { ChannelAkickAdd { channel: String, mask: String, reason: String }, ChannelAkickDel { channel: String, mask: String }, ChannelFounderSet { channel: String, founder: String }, + ChannelSuccessorSet { channel: String, successor: Option }, ChannelDescSet { channel: String, desc: String }, ChannelEntryMsgSet { channel: String, msg: String }, ChannelSettingsSet { channel: String, settings: ChanSettings }, @@ -184,6 +185,7 @@ impl Event { | Event::ChannelAkickAdd { .. } | Event::ChannelAkickDel { .. } | Event::ChannelFounderSet { .. } + | Event::ChannelSuccessorSet { .. } | Event::ChannelDescSet { .. } | Event::ChannelEntryMsgSet { .. } | Event::ChannelSettingsSet { .. } @@ -337,7 +339,7 @@ pub(crate) fn apply(accounts: &mut HashMap, channels: &mut Hash grouped.remove(&key(&nick)); } Event::ChannelRegistered { name, founder, ts } => { - channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None }); + channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None }); } Event::ChannelDropped { name } => { channels.remove(&key(&name)); @@ -375,6 +377,11 @@ pub(crate) fn apply(accounts: &mut HashMap, channels: &mut Hash c.founder = founder; } } + Event::ChannelSuccessorSet { channel, successor } => { + if let Some(c) = channels.get_mut(&key(&channel)) { + c.successor = successor; + } + } Event::ChannelDescSet { channel, desc } => { if let Some(c) = channels.get_mut(&key(&channel)) { c.desc = desc; diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index f41215b..4e5bb55 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -359,6 +359,10 @@ pub struct ChannelInfo { pub access: Vec, #[serde(default)] pub akick: Vec, + // Account that inherits the channel if the founder's account is dropped or + // expires; None means the channel is released instead. + #[serde(default)] + pub successor: Option, // Free-text description, shown in INFO. #[serde(default)] pub desc: String, diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index 3a7475d..b341d15 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -447,6 +447,12 @@ impl Store for Db { fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError> { Db::set_entrymsg(self, channel, msg) } + fn set_successor(&mut self, channel: &str, successor: Option<&str>) -> Result<(), ChanError> { + Db::set_successor(self, channel, successor) + } + fn release_founded_channels(&mut self, account: &str) -> (Vec<(String, String)>, Vec) { + Db::release_founded_channels(self, account) + } fn set_founder(&mut self, channel: &str, account: &str) -> Result<(), ChanError> { Db::set_founder(self, channel, account) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index d6af988..18fe30e 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -440,17 +440,21 @@ impl Engine { // channels it founded locally — their founder identity no longer exists here. fn handle_account_gone(&mut self, account: &str, reason: &str) { let victims = self.network.uids_logged_into(account); - let orphaned = self.db.channels_owned_by(account); - for chan in &orphaned { - let _ = self.db.drop_channel(chan); - } + // A channel with a valid successor is inherited rather than released. + let (inherited, orphaned) = self.db.release_founded_channels(account); let (cs, ns) = (self.chan_service.clone(), self.nick_service.clone()); - // Release the registered mode on each dropped channel. + // Release the registered mode on each dropped (not inherited) channel. for chan in &orphaned { if let Some(cs) = &cs { self.emit_irc(NetAction::ChannelMode { from: cs.clone(), channel: chan.clone(), modes: "-r".to_string() }); } } + // Tell any online successor they inherited a channel. + for (chan, s) in &inherited { + if let (Some(ns), Some(uid)) = (&ns, self.network.uids_logged_into(s).into_iter().next()) { + self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid, text: format!("You are now the founder of \x02{chan}\x02 (inherited from \x02{account}\x02).") }); + } + } // Log out and inform each local session that held the name. for uid in victims { self.network.clear_account(&uid); @@ -1088,6 +1092,10 @@ fn audit_summary(event: &db::Event) -> Option { ChannelRegistered { name, founder, .. } => format!("registered channel \x02{name}\x02 (founder \x02{founder}\x02)"), ChannelDropped { name } => format!("dropped channel \x02{name}\x02"), ChannelFounderSet { channel, founder } => format!("set founder of \x02{channel}\x02 to \x02{founder}\x02"), + ChannelSuccessorSet { channel, successor } => match successor { + Some(s) => format!("set successor of \x02{channel}\x02 to \x02{s}\x02"), + None => format!("cleared the successor of \x02{channel}\x02"), + }, ChannelAccessAdd { channel, account, level } => format!("set \x02{account}\x02 to {level} on \x02{channel}\x02"), ChannelAccessDel { channel, account } => format!("removed \x02{account}\x02 from \x02{channel}\x02 access"), ChannelAkickAdd { channel, mask, reason } => format!("added akick \x02{mask}\x02 on \x02{channel}\x02 ({reason})"), diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 55299b9..3fc3d57 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -903,6 +903,37 @@ assert!(!mode(&out, "+q 000AAAAAC"), "non-founder cannot grant owner: {out:?}"); } + // ChanServ SET SUCCESSOR: the successor inherits the channel when the founder + // account is dropped, instead of the channel being released. + #[test] + fn chanserv_successor_inherits_on_drop() { + use echo_chanserv::ChanServ; + use echo_nickserv::NickServ; + let path = std::env::temp_dir().join("echo-cssucc.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("alice", "sesame", None).unwrap(); + db.register("bob", "pw", None).unwrap(); + db.register_channel("#c", "alice").unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(ChanServ { uid: "42SAAAAAB".into() }), + ], + db, + ); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: "SET #c SUCCESSOR bob".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Successor") && text.contains("bob"))), "successor set: {out:?}"); + + // alice drops her account -> #c is inherited by bob, not released. + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "DROP sesame".into() }); + assert_eq!(e.db.channel("#c").map(|c| c.founder.clone()), Some("bob".to_string()), "channel inherited by the successor"); + assert!(e.db.account("alice").is_none(), "the dropped account is gone"); + } + // BotServ BOT ADD/LIST is oper-gated (Priv::Admin) and the bot is remembered. #[test] fn botserv_bot_add_list_is_oper_gated() { diff --git a/src/grpc.rs b/src/grpc.rs index fbe05a2..192c921 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -145,6 +145,7 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::ChannelAkickAdd { .. } | Event::ChannelAkickDel { .. } | Event::ChannelEntryMsgSet { .. } + | Event::ChannelSuccessorSet { .. } | Event::ChannelSettingsSet { .. } | Event::ChannelKickerSet { .. } | Event::ChannelBadwordsSet { .. }