chanserv: entrymsg, enforce, getkey, seen, clone, aop/sop/vop

Tracks channel membership (join/part/kick), the current key, and per-nick
last-seen in the network view. Entry messages greet joiners; enforce
re-applies the lock, access modes and auto-kicks to everyone present;
getkey reports the key; seen reports last activity; clone copies a
channel's settings; aop/sop/vop are tiered shortcuts over the access list.
This commit is contained in:
Jean Chevronnet 2026-07-12 11:53:48 +00:00
parent 674f543b40
commit e8b55bdb27
No known key found for this signature in database
12 changed files with 479 additions and 26 deletions

View file

@ -26,6 +26,18 @@ mod list;
mod status;
#[path = "set.rs"]
mod set;
#[path = "entrymsg.rs"]
mod entrymsg;
#[path = "getkey.rs"]
mod getkey;
#[path = "seen.rs"]
mod seen;
#[path = "enforce.rs"]
mod enforce;
#[path = "clone.rs"]
mod clone;
#[path = "xop.rs"]
mod xop;
pub struct ChanServ {
pub uid: String,
@ -167,13 +179,36 @@ impl Service for ChanServ {
Some("STATUS") => status::handle(me, from, args, ctx, net, db),
Some("LIST") => list::handle(me, from, args, ctx, db),
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("ENTRYMSG") => entrymsg::handle(me, from, args, ctx, db),
Some("GETKEY") => getkey::handle(me, from, args, ctx, net, db),
Some("SEEN") => seen::handle(me, from, args, ctx, net),
Some("ENFORCE") => enforce::handle(me, from, args, ctx, net, db),
Some("CLONE") => clone::handle(me, from, args, ctx, db),
Some("AOP") => xop::handle(me, from, "AOP", "op", args, ctx, db),
Some("SOP") => xop::handle(me, from, "SOP", "op", args, ctx, db),
Some("VOP") => xop::handle(me, from, "VOP", "voice", 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, \x02AOP\x02/\x02SOP\x02/\x02VOP\x02, \x02STATUS\x02, \x02OP\x02/\x02DEOP\x02, \x02VOICE\x02/\x02DEVOICE\x02, \x02KICK\x02, \x02BAN\x02/\x02UNBAN\x02, \x02AKICK\x02, \x02ENFORCE\x02, \x02TOPIC\x02, \x02ENTRYMSG\x02, \x02INVITE\x02, \x02GETKEY\x02, \x02SEEN\x02, \x02CLONE\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 => {}
}
}
}
// True if `from` is the channel's founder; otherwise notices why and returns false.
fn require_founder(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &Db) -> bool {
match db.channel(chan) {
None => {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
false
}
Some(info) if from.account == Some(info.founder.as_str()) => true,
_ => {
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can do that."));
false
}
}
}
// True if `from` is the founder or an access-list op of `chan`; else notices why.
fn require_op(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &Db) -> bool {
match (from.account, db.channel(chan)) {

32
modules/chanserv/clone.rs Normal file
View file

@ -0,0 +1,32 @@
use crate::engine::db::Db;
use crate::engine::service::{Sender, ServiceCtx};
// CLONE <source> <target>: copy a channel's settings (mode lock, access,
// auto-kick, description, entry message) into another. Founder of both.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) {
let (Some(&src), Some(&dest)) = (args.get(1), args.get(2)) else {
ctx.notice(me, from.uid, "Syntax: CLONE <source> <target>");
return;
};
let (Some(sinfo), Some(dinfo)) = (db.channel(src).cloned(), db.channel(dest).cloned()) else {
ctx.notice(me, from.uid, "Both channels must be registered.");
return;
};
if from.account != Some(sinfo.founder.as_str()) || from.account != Some(dinfo.founder.as_str()) {
ctx.notice(me, from.uid, "You must be the founder of both channels.");
return;
}
let _ = db.set_mlock(dest, &sinfo.lock_on, &sinfo.lock_off);
for a in &sinfo.access {
let _ = db.access_add(dest, &a.account, &a.level);
}
for k in &sinfo.akick {
let _ = db.akick_add(dest, &k.mask, &k.reason);
}
let _ = db.set_desc(dest, &sinfo.desc);
let _ = db.set_entrymsg(dest, &sinfo.entrymsg);
if let Some(info) = db.channel(dest) {
ctx.channel_mode(me, dest, &info.lock_modes());
}
ctx.notice(me, from.uid, format!("Copied \x02{src}\x02's settings to \x02{dest}\x02."));
}

View file

@ -0,0 +1,35 @@
use crate::engine::db::Db;
use crate::engine::service::{Sender, ServiceCtx};
use crate::engine::state::Network;
// ENFORCE <#channel>: re-apply the channel's settings to everyone present —
// the mode lock, access status modes, and the auto-kick list.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &Db) {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: ENFORCE <#channel>");
return;
};
if !super::require_op(me, from, chan, ctx, db) {
return;
}
let Some(info) = db.channel(chan).cloned() else {
return;
};
ctx.channel_mode(me, chan, &info.lock_modes());
let members: Vec<String> = net.channel_members(chan).map(str::to_string).collect();
for uid in members {
match net.account_of(&uid).and_then(|a| info.join_mode(a)) {
Some(m) => ctx.channel_mode(me, chan, &format!("{m} {uid}")),
None => {
let nick = net.nick_of(&uid).unwrap_or("*");
let host = net.host_of(&uid).unwrap_or("*");
if let Some(k) = info.akick_match(&format!("{nick}!*@{host}")) {
ctx.channel_mode(me, chan, &format!("+b {}", k.mask));
let reason = if k.reason.is_empty() { "You are banned from this channel." } else { &k.reason };
ctx.kick(me, chan, &uid, reason);
}
}
}
}
ctx.notice(me, from.uid, format!("Re-applied \x02{chan}\x02's settings to everyone present."));
}

View file

@ -0,0 +1,36 @@
use crate::engine::db::Db;
use crate::engine::service::{Sender, ServiceCtx};
// ENTRYMSG <#channel> [CLEAR | <text>]: message noticed to users as they join.
// With no argument, show the current message.
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: ENTRYMSG <#channel> [CLEAR | <text>]");
return;
};
match args.get(2) {
None => match db.channel(chan) {
None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")),
Some(info) if info.entrymsg.is_empty() => ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no entry message.")),
Some(info) => ctx.notice(me, from.uid, format!("Entry message for \x02{chan}\x02: {}", info.entrymsg)),
},
Some(&kw) if kw.eq_ignore_ascii_case("CLEAR") => {
if !super::require_op(me, from, chan, ctx, db) {
return;
}
match db.set_entrymsg(chan, "") {
Ok(()) => ctx.notice(me, from.uid, format!("Entry message for \x02{chan}\x02 cleared.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
Some(_) => {
if !super::require_op(me, from, chan, ctx, db) {
return;
}
match db.set_entrymsg(chan, &args[2..].join(" ")) {
Ok(()) => ctx.notice(me, from.uid, format!("Entry message for \x02{chan}\x02 updated.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
}
}

View file

@ -0,0 +1,19 @@
use crate::engine::db::Db;
use crate::engine::service::{Sender, ServiceCtx};
use crate::engine::state::Network;
// GETKEY <#channel>: report the channel key (+k), for ops who need to let
// someone in.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &Db) {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: GETKEY <#channel>");
return;
};
if !super::require_op(me, from, chan, ctx, db) {
return;
}
match net.channel_key(chan) {
Some(key) => ctx.notice(me, from.uid, format!("Key for \x02{chan}\x02 is \x02{key}\x02.")),
None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no key set.")),
}
}

18
modules/chanserv/seen.rs Normal file
View file

@ -0,0 +1,18 @@
use crate::engine::service::{Sender, ServiceCtx};
use crate::engine::state::Network;
// SEEN <nick>: when a nick was last seen, and doing what.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network) {
let Some(&nick) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: SEEN <nick>");
return;
};
if net.uid_by_nick(nick).is_some() {
ctx.notice(me, from.uid, format!("\x02{nick}\x02 is currently online."));
return;
}
match net.last_seen(nick) {
Some(s) => ctx.notice(me, from.uid, format!("\x02{}\x02 was last seen {} ({}).", s.nick, super::human_time(s.ts), s.what)),
None => ctx.notice(me, from.uid, format!("I have no record of \x02{nick}\x02.")),
}
}

51
modules/chanserv/xop.rs Normal file
View file

@ -0,0 +1,51 @@
use crate::engine::db::Db;
use crate::engine::service::{Sender, ServiceCtx};
// AOP/SOP/VOP <#channel> ADD <account> | DEL <account> | LIST — tiered
// shortcuts over the access list. `level` is the access level they map to
// ("op" for AOP/SOP, "voice" for VOP); `word` is what the user typed.
pub fn handle(me: &str, from: &Sender, word: &str, level: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, format!("Syntax: {word} <#channel> ADD <account> | DEL <account> | LIST"));
return;
};
match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() {
None | Some("LIST") => match db.channel(chan) {
None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")),
Some(info) => {
ctx.notice(me, from.uid, format!("{word} list for \x02{}\x02:", info.name));
for a in info.access.iter().filter(|a| a.level == level) {
ctx.notice(me, from.uid, format!(" \x02{}\x02", a.account));
}
}
},
Some("ADD") => {
let Some(&account) = args.get(3) else {
ctx.notice(me, from.uid, format!("Syntax: {word} <#channel> ADD <account>"));
return;
};
if !super::require_founder(me, from, chan, ctx, db) {
return;
}
match db.access_add(chan, account, level) {
Ok(()) => ctx.notice(me, from.uid, format!("Added \x02{account}\x02 to \x02{chan}\x02's {word} list.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
Some("DEL") => {
let Some(&account) = args.get(3) else {
ctx.notice(me, from.uid, format!("Syntax: {word} <#channel> DEL <account>"));
return;
};
if !super::require_founder(me, from, chan, ctx, db) {
return;
}
match db.access_del(chan, account) {
Ok(true) => ctx.notice(me, from.uid, format!("Removed \x02{account}\x02 from \x02{chan}\x02's {word} list.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{account}\x02 has no access to \x02{chan}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
_ => ctx.notice(me, from.uid, format!("Syntax: {word} <#channel> ADD <account> | DEL <account> | LIST")),
}
}