From 75019c867ae1ba7029d30955a47c8c88bb8c38aa Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 13 Jul 2026 02:54:46 +0000 Subject: [PATCH] chanserv: SET SIGNKICK and PRIVATE (typed channel settings) Introduce a typed ChanSettings struct + ChanSetting enum for ChanServ's on/off options instead of a bag of string flags, so a new option is one field and the compiler proves every site handles it. First two, both functional: SIGNKICK attributes ChanServ kicks with who requested them; PRIVATE hides the channel from LIST. Folded through the event log like the other channel state (node-local, no gossip), shown in INFO. One ChannelSettingsSet event, one Store method. --- api/src/lib.rs | 11 +++++++ chanserv/src/kick.rs | 6 +++- chanserv/src/lib.rs | 6 ++++ chanserv/src/list.rs | 3 +- chanserv/src/set.rs | 23 ++++++++++++-- src/engine/db.rs | 72 +++++++++++++++++++++++++++++++++++++++++--- src/grpc.rs | 3 +- 7 files changed, 114 insertions(+), 10 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index bc082b5..0e518ef 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -291,6 +291,16 @@ pub struct ChannelView { pub akick: Vec, pub desc: String, pub entrymsg: String, + // ChanServ SET options. + pub signkick: bool, + pub private: bool, +} + +// A single ChanServ SET option, named for the typed `set_channel_setting` call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChanSetting { + SignKick, + Private, } impl ChannelView { @@ -405,6 +415,7 @@ pub trait Store { fn drop_channel(&mut self, name: &str) -> Result<(), ChanError>; fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError>; fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError>; + fn set_channel_setting(&mut self, channel: &str, setting: ChanSetting, on: bool) -> Result<(), ChanError>; fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError>; fn set_founder(&mut self, channel: &str, account: &str) -> Result<(), ChanError>; fn access_add(&mut self, channel: &str, account: &str, level: &str) -> Result<(), ChanError>; diff --git a/chanserv/src/kick.rs b/chanserv/src/kick.rs index 64baf03..5344aef 100644 --- a/chanserv/src/kick.rs +++ b/chanserv/src/kick.rs @@ -15,6 +15,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here.")); return; }; - let reason = if args.len() > 3 { args[3..].join(" ") } else { "Kicked".to_string() }; + let mut reason = if args.len() > 3 { args[3..].join(" ") } else { "Kicked".to_string() }; + // SIGNKICK: attribute the kick to whoever asked for it. + if db.channel(chan).is_some_and(|c| c.signkick) { + reason = format!("{reason} (requested by {})", from.nick); + } ctx.kick(me, chan, target, &reason); } diff --git a/chanserv/src/lib.rs b/chanserv/src/lib.rs index 5f97df6..ac9368a 100644 --- a/chanserv/src/lib.rs +++ b/chanserv/src/lib.rs @@ -101,6 +101,12 @@ impl Service for ChanServ { ctx.notice(me, from.uid, format!(" Description: {}", info.desc)); } ctx.notice(me, from.uid, format!(" Registered : {}", fedserv_api::human_time(info.ts))); + let mut opts = Vec::new(); + if info.signkick { opts.push("SIGNKICK"); } + if info.private { opts.push("PRIVATE"); } + if !opts.is_empty() { + ctx.notice(me, from.uid, format!(" Options : {}", opts.join(", "))); + } } None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")), } diff --git a/chanserv/src/list.rs b/chanserv/src/list.rs index b75900e..2e881bf 100644 --- a/chanserv/src/list.rs +++ b/chanserv/src/list.rs @@ -3,7 +3,8 @@ use fedserv_api::{Sender, ServiceCtx}; // LIST: show all registered channels. pub fn handle(me: &str, from: &Sender, _args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) { - let mut names: Vec = db.channels().into_iter().map(|c| c.name).collect(); + // PRIVATE channels are hidden from LIST. + let mut names: Vec = db.channels().into_iter().filter(|c| !c.private).map(|c| c.name).collect(); if names.is_empty() { ctx.notice(me, from.uid, "No channels are registered."); return; diff --git a/chanserv/src/set.rs b/chanserv/src/set.rs index 44655a0..7ffe211 100644 --- a/chanserv/src/set.rs +++ b/chanserv/src/set.rs @@ -1,5 +1,4 @@ -use fedserv_api::Store; -use fedserv_api::{Sender, ServiceCtx}; +use fedserv_api::{ChanSetting, Sender, ServiceCtx, Store}; // SET <#channel> FOUNDER | DESC : founder-only channel settings. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { @@ -40,6 +39,24 @@ 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."), } } - _ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER | DESC "), + Some("SIGNKICK") => toggle(me, from, ctx, db, chan, ChanSetting::SignKick, args.get(3).copied(), "SIGNKICK"), + Some("PRIVATE") => toggle(me, from, ctx, db, chan, ChanSetting::Private, args.get(3).copied(), "PRIVATE"), + _ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER | DESC | SIGNKICK {ON|OFF} | PRIVATE {ON|OFF}"), + } +} + +// Flip one on/off channel option, reporting the new state. +fn toggle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store, chan: &str, setting: ChanSetting, arg: Option<&str>, name: &str) { + let on = match arg.map(|s| s.to_ascii_uppercase()).as_deref() { + Some("ON") => true, + Some("OFF") => false, + _ => { + ctx.notice(me, from.uid, format!("Syntax: SET <#channel> {name} {{ON|OFF}}")); + return; + } + }; + match db.set_channel_setting(chan, setting, on) { + Ok(()) => ctx.notice(me, from.uid, format!("\x02{name}\x02 for \x02{chan}\x02 is now \x02{}\x02.", if on { "on" } else { "off" })), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } } diff --git a/src/engine/db.rs b/src/engine/db.rs index b648e94..64f5a52 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -15,7 +15,7 @@ use super::scram::{self, Hash}; // fedserv-api SDK crate; re-exported so the engine keeps naming them locally and // modules importing `crate::engine::db::{ChanError, ...}` are unaffected. pub use fedserv_api::{ - AccountView, AjoinView, ChanAccessView, ChanAkickView, ChanError, ChannelView, CertError, CodeKind, RegError, Store, + AccountView, AjoinView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, RegError, Store, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -82,6 +82,7 @@ pub enum Event { ChannelFounderSet { channel: String, founder: String }, ChannelDescSet { channel: String, desc: String }, ChannelEntryMsgSet { channel: String, msg: String }, + ChannelSettingsSet { channel: String, settings: ChanSettings }, } // Whether an event replicates across the federation. Account identity is Global @@ -117,7 +118,8 @@ impl Event { | Event::ChannelAkickDel { .. } | Event::ChannelFounderSet { .. } | Event::ChannelDescSet { .. } - | Event::ChannelEntryMsgSet { .. } => Scope::Local, + | Event::ChannelEntryMsgSet { .. } + | Event::ChannelSettingsSet { .. } => Scope::Local, } } } @@ -145,6 +147,18 @@ pub struct AjoinEntry { pub key: String, } +// A channel's on/off options (ChanServ SET). Typed, not a bag of string flags, +// so a new option is one field and the compiler proves every place handles it. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub struct ChanSettings { + // Append "(requested by )" to ChanServ KICK reasons. + #[serde(default)] + pub signkick: bool, + // Hide the channel from ChanServ LIST. + #[serde(default)] + pub private: bool, +} + // A registered channel and who owns it. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChannelInfo { @@ -166,6 +180,9 @@ pub struct ChannelInfo { // Message noticed to users as they join. #[serde(default)] pub entrymsg: String, + // On/off options set via ChanServ SET. + #[serde(default)] + pub settings: ChanSettings, } impl ChannelInfo { @@ -569,6 +586,9 @@ impl Db { if !c.entrymsg.is_empty() { snapshot.push(Event::ChannelEntryMsgSet { channel: c.name.clone(), msg: c.entrymsg.clone() }); } + if c.settings.signkick || c.settings.private { + snapshot.push(Event::ChannelSettingsSet { channel: c.name.clone(), settings: c.settings }); + } } for (nick, account) in &self.grouped { snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() }); @@ -951,7 +971,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() }); + 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() }); Ok(()) } @@ -1087,6 +1107,22 @@ impl Db { Ok(()) } + /// Turn one ChanServ SET option on or off for a channel. + pub fn set_channel_setting(&mut self, channel: &str, setting: ChanSetting, on: bool) -> Result<(), ChanError> { + let k = key(channel); + let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) }; + let mut settings = c.settings; + match setting { + ChanSetting::SignKick => settings.signkick = on, + ChanSetting::Private => settings.private = on, + } + self.log + .append(Event::ChannelSettingsSet { channel: channel.to_string(), settings }) + .map_err(|_| ChanError::Internal)?; + self.channels.get_mut(&k).unwrap().settings = settings; + Ok(()) + } + /// Set `channel`'s entry message (empty clears it). pub fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError> { let k = key(channel); @@ -1188,7 +1224,7 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { - 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() }); + 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() }); } Event::ChannelDropped { name } => { channels.remove(&key(&name)); @@ -1231,6 +1267,11 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + if let Some(c) = channels.get_mut(&key(&channel)) { + c.settings = settings; + } + } Event::ChannelEntryMsgSet { channel, msg } => { if let Some(c) = channels.get_mut(&key(&channel)) { c.entrymsg = msg; @@ -1402,6 +1443,9 @@ impl Store for Db { fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError> { Db::set_desc(self, channel, desc) } + fn set_channel_setting(&mut self, channel: &str, setting: ChanSetting, on: bool) -> Result<(), ChanError> { + Db::set_channel_setting(self, channel, setting, on) + } fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError> { Db::set_entrymsg(self, channel, msg) } @@ -1433,6 +1477,8 @@ fn channel_view(c: &ChannelInfo) -> ChannelView { akick: c.akick.iter().map(|k| ChanAkickView { mask: k.mask.clone(), reason: k.reason.clone() }).collect(), desc: c.desc.clone(), entrymsg: c.entrymsg.clone(), + signkick: c.settings.signkick, + private: c.settings.private, } } @@ -1549,6 +1595,24 @@ mod tests { assert_eq!(db.ajoin_list("alice")[0].channel, "#dev"); } + // Channel SET options default off, toggle, and replay from the log. + #[test] + fn channel_settings_toggle_and_persist() { + let p = tmp("chansettings"); + { + let mut db = Db::open(&p, "N1"); + db.register_channel("#c", "boss").unwrap(); + assert!(!db.channel("#c").unwrap().settings.signkick, "defaults off"); + db.set_channel_setting("#c", ChanSetting::SignKick, true).unwrap(); + db.set_channel_setting("#c", ChanSetting::Private, true).unwrap(); + db.set_channel_setting("#c", ChanSetting::Private, false).unwrap(); + let s = db.channel("#c").unwrap().settings; + assert!(s.signkick && !s.private, "one flag on, the other flipped back off"); + } + let s = Db::open(&p, "N1").channel("#c").unwrap().settings; + assert!(s.signkick && !s.private, "settings replay from the log"); + } + // A wrong code is tolerated a few times, then the code is burned so it can't // be ground down online even though it is short. #[test] diff --git a/src/grpc.rs b/src/grpc.rs index 1822d0b..def5be1 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -129,7 +129,8 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::ChannelAccessDel { .. } | Event::ChannelAkickAdd { .. } | Event::ChannelAkickDel { .. } - | Event::ChannelEntryMsgSet { .. } => return None, + | Event::ChannelEntryMsgSet { .. } + | Event::ChannelSettingsSet { .. } => return None, }; Some(ReplicationEvent { origin: entry.origin().to_string(), seq: entry.seq(), lamport: entry.lamport(), kind: Some(kind) }) }