diff --git a/modules/chanserv/chanserv.rs b/modules/chanserv/chanserv.rs index 679bd5a..4a5a676 100644 --- a/modules/chanserv/chanserv.rs +++ b/modules/chanserv/chanserv.rs @@ -24,6 +24,8 @@ mod akick; mod list; #[path = "status.rs"] mod status; +#[path = "set.rs"] +mod set; pub struct ChanServ { pub uid: String, @@ -78,6 +80,9 @@ impl Service for ChanServ { Some(info) => { ctx.notice(me, from.uid, format!("Information for \x02{}\x02:", info.name)); ctx.notice(me, from.uid, format!(" Founder : \x02{}\x02", info.founder)); + if !info.desc.is_empty() { + ctx.notice(me, from.uid, format!(" Description: {}", info.desc)); + } ctx.notice(me, from.uid, format!(" Registered : {}", human_time(info.ts))); } None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")), @@ -161,7 +166,8 @@ impl Service for ChanServ { Some("AKICK") => akick::handle(me, from, args, ctx, db), Some("STATUS") => status::handle(me, from, args, ctx, net, db), Some("LIST") => list::handle(me, from, args, ctx, db), - Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02, \x02INFO\x02, \x02LIST\x02, \x02ACCESS\x02, \x02STATUS\x02, \x02OP\x02/\x02DEOP\x02, \x02VOICE\x02/\x02DEVOICE\x02, \x02KICK\x02, \x02BAN\x02/\x02UNBAN\x02, \x02AKICK\x02, \x02TOPIC\x02, \x02INVITE\x02, \x02MODE\x02, \x02MLOCK\x02, \x02DROP\x02."), + Some("SET") => set::handle(me, from, args, ctx, db), + Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02, \x02INFO\x02, \x02LIST\x02, \x02SET\x02, \x02ACCESS\x02, \x02STATUS\x02, \x02OP\x02/\x02DEOP\x02, \x02VOICE\x02/\x02DEVOICE\x02, \x02KICK\x02, \x02BAN\x02/\x02UNBAN\x02, \x02AKICK\x02, \x02TOPIC\x02, \x02INVITE\x02, \x02MODE\x02, \x02MLOCK\x02, \x02DROP\x02."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), None => {} } diff --git a/modules/chanserv/set.rs b/modules/chanserv/set.rs new file mode 100644 index 0000000..587ad28 --- /dev/null +++ b/modules/chanserv/set.rs @@ -0,0 +1,45 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; + +// SET <#channel> FOUNDER | DESC : founder-only channel settings. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER | DESC "); + return; + }; + let founder = match db.channel(chan) { + None => { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")); + return; + } + Some(info) => info.founder.clone(), + }; + if from.account != Some(founder.as_str()) { + ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can change its settings.")); + return; + } + match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() { + Some("FOUNDER") => { + let Some(&account) = args.get(3) else { + ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER "); + return; + }; + if !db.exists(account) { + ctx.notice(me, from.uid, format!("\x02{account}\x02 isn't a registered account.")); + return; + } + match db.set_founder(chan, account) { + Ok(()) => ctx.notice(me, from.uid, format!("Founder of \x02{chan}\x02 transferred to \x02{account}\x02.")), + 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) { + Ok(()) => ctx.notice(me, from.uid, format!("Description for \x02{chan}\x02 updated.")), + 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 "), + } +} diff --git a/src/engine/db.rs b/src/engine/db.rs index 789236b..f533c62 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -45,6 +45,8 @@ pub enum Event { ChannelAccessDel { channel: String, account: String }, ChannelAkickAdd { channel: String, mask: String, reason: String }, ChannelAkickDel { channel: String, mask: String }, + ChannelFounderSet { channel: String, founder: String }, + ChannelDescSet { channel: String, desc: String }, } // An access-list entry: an account and its level ("op" or "voice"). @@ -76,6 +78,9 @@ pub struct ChannelInfo { pub access: Vec, #[serde(default)] pub akick: Vec, + // Free-text description, shown in INFO. + #[serde(default)] + pub desc: String, } impl ChannelInfo { @@ -395,6 +400,9 @@ impl Db { for k in &c.akick { snapshot.push(Event::ChannelAkickAdd { channel: c.name.clone(), mask: k.mask.clone(), reason: k.reason.clone() }); } + if !c.desc.is_empty() { + snapshot.push(Event::ChannelDescSet { channel: c.name.clone(), desc: c.desc.clone() }); + } } self.log.compact(snapshot)?; tracing::info!(before, after = self.log.len(), "compacted event log"); @@ -535,7 +543,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() }); + 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() }); Ok(()) } @@ -626,6 +634,32 @@ impl Db { Ok(true) } + /// Transfer `channel`'s founder to `account`. + pub fn set_founder(&mut self, channel: &str, account: &str) -> Result<(), ChanError> { + let k = key(channel); + if !self.channels.contains_key(&k) { + return Err(ChanError::NoChannel); + } + self.log + .append(Event::ChannelFounderSet { channel: channel.to_string(), founder: account.to_string() }) + .map_err(|_| ChanError::Internal)?; + self.channels.get_mut(&k).unwrap().founder = account.to_string(); + Ok(()) + } + + /// Set `channel`'s description (empty clears it). + pub fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError> { + let k = key(channel); + if !self.channels.contains_key(&k) { + return Err(ChanError::NoChannel); + } + self.log + .append(Event::ChannelDescSet { channel: channel.to_string(), desc: desc.to_string() }) + .map_err(|_| ChanError::Internal)?; + self.channels.get_mut(&k).unwrap().desc = desc.to_string(); + Ok(()) + } + /// Unregister `name`. pub fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> { let k = key(name); @@ -658,7 +692,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() }); + 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() }); } Event::ChannelDropped { name } => { channels.remove(&key(&name)); @@ -691,6 +725,16 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + if let Some(c) = channels.get_mut(&key(&channel)) { + c.founder = founder; + } + } + Event::ChannelDescSet { channel, desc } => { + if let Some(c) = channels.get_mut(&key(&channel)) { + c.desc = desc; + } + } } } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index c0448b7..09bef76 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1089,6 +1089,41 @@ mod tests { assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("+o"))), "{out:?}"); } + // ChanServ SET: description and founder transfer, founder-gated. + #[test] + fn chanserv_set() { + use crate::chanserv::ChanServ; + let path = std::env::temp_dir().join("fedserv-cs-set.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", "hunter2", None).unwrap(); + db.register_channel("#c", "alice").unwrap(); + let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; + let cs = ChanServ { uid: "42SAAAAAB".into() }; + let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db); + let to_cs = |e: &mut Engine, from: &str, text: &str| { + e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".into(), text: text.into() }) + }; + let notice = |out: &[NetAction], needle: &str| { + out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle))) + }; + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h1".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + + // Description is stored and shows in INFO. + assert!(notice(&to_cs(&mut e, "000AAAAAB", "SET #c DESC a friendly place"), "updated")); + assert!(notice(&to_cs(&mut e, "000AAAAAB", "INFO #c"), "a friendly place")); + + // Transfer to a non-account is refused. + assert!(notice(&to_cs(&mut e, "000AAAAAB", "SET #c FOUNDER nobody"), "isn't a registered account")); + + // Transfer to bob works; alice is then no longer the founder. + assert!(notice(&to_cs(&mut e, "000AAAAAB", "SET #c FOUNDER bob"), "transferred")); + assert!(notice(&to_cs(&mut e, "000AAAAAB", "SET #c DESC nope"), "founder can change")); + } + // A registered channel (re)appearing re-asserts +r; an unregistered one does not. #[test] fn channel_create_reasserts_registered_mode() {