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.
This commit is contained in:
Jean Chevronnet 2026-07-13 02:54:46 +00:00
parent f388e3d650
commit 75019c867a
No known key found for this signature in database
7 changed files with 114 additions and 10 deletions

View file

@ -291,6 +291,16 @@ pub struct ChannelView {
pub akick: Vec<ChanAkickView>, pub akick: Vec<ChanAkickView>,
pub desc: String, pub desc: String,
pub entrymsg: 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 { impl ChannelView {
@ -405,6 +415,7 @@ pub trait Store {
fn drop_channel(&mut self, name: &str) -> Result<(), ChanError>; fn drop_channel(&mut self, name: &str) -> Result<(), ChanError>;
fn set_mlock(&mut self, name: &str, on: &str, off: &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_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_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError>;
fn set_founder(&mut self, channel: &str, account: &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>; fn access_add(&mut self, channel: &str, account: &str, level: &str) -> Result<(), ChanError>;

View file

@ -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.")); ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here."));
return; 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); ctx.kick(me, chan, target, &reason);
} }

View file

@ -101,6 +101,12 @@ impl Service for ChanServ {
ctx.notice(me, from.uid, format!(" Description: {}", info.desc)); ctx.notice(me, from.uid, format!(" Description: {}", info.desc));
} }
ctx.notice(me, from.uid, format!(" Registered : {}", fedserv_api::human_time(info.ts))); 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.")), None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")),
} }

View file

@ -3,7 +3,8 @@ use fedserv_api::{Sender, ServiceCtx};
// LIST: show all registered channels. // LIST: show all registered channels.
pub fn handle(me: &str, from: &Sender, _args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) { pub fn handle(me: &str, from: &Sender, _args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) {
let mut names: Vec<String> = db.channels().into_iter().map(|c| c.name).collect(); // PRIVATE channels are hidden from LIST.
let mut names: Vec<String> = db.channels().into_iter().filter(|c| !c.private).map(|c| c.name).collect();
if names.is_empty() { if names.is_empty() {
ctx.notice(me, from.uid, "No channels are registered."); ctx.notice(me, from.uid, "No channels are registered.");
return; return;

View file

@ -1,5 +1,4 @@
use fedserv_api::Store; use fedserv_api::{ChanSetting, Sender, ServiceCtx, Store};
use fedserv_api::{Sender, ServiceCtx};
// SET <#channel> FOUNDER <account> | DESC <text>: founder-only channel settings. // SET <#channel> FOUNDER <account> | DESC <text>: founder-only channel settings.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { 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."), 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 <account> | DESC <text>"), 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 <account> | DESC <text> | 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."),
} }
} }

View file

@ -15,7 +15,7 @@ use super::scram::{self, Hash};
// fedserv-api SDK crate; re-exported so the engine keeps naming them locally and // fedserv-api SDK crate; re-exported so the engine keeps naming them locally and
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected. // modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
pub use fedserv_api::{ 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -82,6 +82,7 @@ pub enum Event {
ChannelFounderSet { channel: String, founder: String }, ChannelFounderSet { channel: String, founder: String },
ChannelDescSet { channel: String, desc: String }, ChannelDescSet { channel: String, desc: String },
ChannelEntryMsgSet { channel: String, msg: String }, ChannelEntryMsgSet { channel: String, msg: String },
ChannelSettingsSet { channel: String, settings: ChanSettings },
} }
// Whether an event replicates across the federation. Account identity is Global // Whether an event replicates across the federation. Account identity is Global
@ -117,7 +118,8 @@ impl Event {
| Event::ChannelAkickDel { .. } | Event::ChannelAkickDel { .. }
| Event::ChannelFounderSet { .. } | Event::ChannelFounderSet { .. }
| Event::ChannelDescSet { .. } | Event::ChannelDescSet { .. }
| Event::ChannelEntryMsgSet { .. } => Scope::Local, | Event::ChannelEntryMsgSet { .. }
| Event::ChannelSettingsSet { .. } => Scope::Local,
} }
} }
} }
@ -145,6 +147,18 @@ pub struct AjoinEntry {
pub key: String, 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 <nick>)" 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. // A registered channel and who owns it.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelInfo { pub struct ChannelInfo {
@ -166,6 +180,9 @@ pub struct ChannelInfo {
// Message noticed to users as they join. // Message noticed to users as they join.
#[serde(default)] #[serde(default)]
pub entrymsg: String, pub entrymsg: String,
// On/off options set via ChanServ SET.
#[serde(default)]
pub settings: ChanSettings,
} }
impl ChannelInfo { impl ChannelInfo {
@ -569,6 +586,9 @@ impl Db {
if !c.entrymsg.is_empty() { if !c.entrymsg.is_empty() {
snapshot.push(Event::ChannelEntryMsgSet { channel: c.name.clone(), msg: c.entrymsg.clone() }); 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 { for (nick, account) in &self.grouped {
snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() }); snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() });
@ -951,7 +971,7 @@ impl Db {
self.log self.log
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts }) .append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
.map_err(|_| ChanError::Internal)?; .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(()) Ok(())
} }
@ -1087,6 +1107,22 @@ impl Db {
Ok(()) 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). /// Set `channel`'s entry message (empty clears it).
pub fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError> { pub fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError> {
let k = key(channel); let k = key(channel);
@ -1188,7 +1224,7 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
grouped.remove(&key(&nick)); grouped.remove(&key(&nick));
} }
Event::ChannelRegistered { name, founder, ts } => { 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() }); 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 } => { Event::ChannelDropped { name } => {
channels.remove(&key(&name)); channels.remove(&key(&name));
@ -1231,6 +1267,11 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
c.desc = desc; c.desc = desc;
} }
} }
Event::ChannelSettingsSet { channel, settings } => {
if let Some(c) = channels.get_mut(&key(&channel)) {
c.settings = settings;
}
}
Event::ChannelEntryMsgSet { channel, msg } => { Event::ChannelEntryMsgSet { channel, msg } => {
if let Some(c) = channels.get_mut(&key(&channel)) { if let Some(c) = channels.get_mut(&key(&channel)) {
c.entrymsg = msg; c.entrymsg = msg;
@ -1402,6 +1443,9 @@ impl Store for Db {
fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError> { fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError> {
Db::set_desc(self, channel, desc) 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> { fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError> {
Db::set_entrymsg(self, channel, msg) 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(), akick: c.akick.iter().map(|k| ChanAkickView { mask: k.mask.clone(), reason: k.reason.clone() }).collect(),
desc: c.desc.clone(), desc: c.desc.clone(),
entrymsg: c.entrymsg.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"); 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 // 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. // be ground down online even though it is short.
#[test] #[test]

View file

@ -129,7 +129,8 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
| Event::ChannelAccessDel { .. } | Event::ChannelAccessDel { .. }
| Event::ChannelAkickAdd { .. } | Event::ChannelAkickAdd { .. }
| Event::ChannelAkickDel { .. } | 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) }) Some(ReplicationEvent { origin: entry.origin().to_string(), seq: entry.seq(), lamport: entry.lamport(), kind: Some(kind) })
} }