chanserv: SET founder and description
SET <#channel> FOUNDER <account> transfers ownership; SET DESC <text> stores a description shown in INFO. Both are founder-gated and persisted to the event log.
This commit is contained in:
parent
ba3803e01c
commit
674f543b40
4 changed files with 133 additions and 3 deletions
|
|
@ -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 => {}
|
||||
}
|
||||
|
|
|
|||
45
modules/chanserv/set.rs
Normal file
45
modules/chanserv/set.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use crate::engine::db::Db;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
|
||||
// SET <#channel> FOUNDER <account> | DESC <text>: 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 <account> | DESC <text>");
|
||||
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 <account>");
|
||||
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 <account> | DESC <text>"),
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ChanAccess>,
|
||||
#[serde(default)]
|
||||
pub akick: Vec<ChanAkick>,
|
||||
// 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<String, Account>, channels: &mut HashMap<String,
|
|||
}
|
||||
}
|
||||
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() });
|
||||
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<String, Account>, channels: &mut HashMap<String,
|
|||
c.akick.retain(|k| !k.mask.eq_ignore_ascii_case(&mask));
|
||||
}
|
||||
}
|
||||
Event::ChannelFounderSet { channel, founder } => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue