ChanServ: add SET SUCCESSOR with founder inheritance
All checks were successful
CI / check (push) Successful in 3m59s

SET <#channel> SUCCESSOR <account>|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.
This commit is contained in:
Jean Chevronnet 2026-07-15 18:58:08 +00:00
parent 6e3a758cc1
commit ac50af92fc
No known key found for this signature in database
11 changed files with 141 additions and 14 deletions

View file

@ -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<String> {
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<String>) {
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);

View file

@ -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<String> },
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<String, Account>, 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<String, Account>, 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;

View file

@ -359,6 +359,10 @@ pub struct ChannelInfo {
pub access: Vec<ChanAccess>,
#[serde(default)]
pub akick: Vec<ChanAkick>,
// 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<String>,
// Free-text description, shown in INFO.
#[serde(default)]
pub desc: String,

View file

@ -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<String>) {
Db::release_founded_channels(self, account)
}
fn set_founder(&mut self, channel: &str, account: &str) -> Result<(), ChanError> {
Db::set_founder(self, channel, account)
}