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")),
}
}

View file

@ -96,17 +96,24 @@ impl Protocol for InspIrcd {
// FJOIN <chan> <ts> <modes> [params] :<members> — channel create/burst.
// Each member is "<prefixes>,<uid>"; surface a Join for each.
"FJOIN" => {
let chan = tokens.next().unwrap_or("").to_string();
// Mode section is everything before the " :<members>" trailing.
let head: Vec<&str> = rest.split(" :").next().unwrap_or(rest).split_whitespace().collect();
let chan = head.get(1).copied().unwrap_or("");
if chan.is_empty() {
vec![]
} else {
let mut out = vec![NetEvent::ChannelCreate { channel: chan.clone() }];
let mut out = vec![NetEvent::ChannelCreate { channel: chan.to_string() }];
if let Some(modes) = head.get(3) {
if let Some(key) = scan_key(modes, head.get(4..).unwrap_or(&[])) {
out.push(NetEvent::ChannelKey { channel: chan.to_string(), key });
}
}
// Members are "<prefixes>,<uid>[:<membid>]"; take the uid.
for m in trailing(rest).split_whitespace() {
if let Some((_, member)) = m.split_once(',') {
let uid = member.split(':').next().unwrap_or("");
if !uid.is_empty() {
out.push(NetEvent::Join { uid: uid.to_string(), channel: chan.clone() });
out.push(NetEvent::Join { uid: uid.to_string(), channel: chan.to_string() });
}
}
}
@ -120,6 +127,23 @@ impl Protocol for InspIrcd {
}
_ => vec![],
},
// :<uid> PART <chan> [:reason] — a user leaving a channel.
"PART" => match (source.as_deref(), tokens.next()) {
(Some(uid), Some(chan)) if chan.starts_with('#') => {
vec![NetEvent::Part { uid: uid.to_string(), channel: chan.to_string() }]
}
_ => vec![],
},
// :<src> KICK <chan> <uid> :<reason> — a user removed from a channel.
"KICK" => {
let chan = tokens.next().unwrap_or("");
match tokens.next() {
Some(uid) if chan.starts_with('#') => {
vec![NetEvent::Part { uid: uid.to_string(), channel: chan.to_string() }]
}
_ => vec![],
}
}
// :<src> FMODE <chan> <ts> <modes> [params] — a channel mode change.
// Skip changes we made ourselves so enforcement can't loop.
"FMODE" => {
@ -127,7 +151,11 @@ impl Protocol for InspIrcd {
match (source.as_deref(), a.first(), a.get(2)) {
// Our own uids (server SID + pseudoclients) share our SID prefix.
(Some(src), Some(chan), Some(modes)) if !src.starts_with(self.sid.as_str()) && chan.starts_with('#') => {
vec![NetEvent::ChannelModeChange { channel: chan.to_string(), modes: modes.to_string() }]
let mut out = vec![NetEvent::ChannelModeChange { channel: chan.to_string(), modes: modes.to_string() }];
if let Some(key) = scan_key(modes, a.get(3..).unwrap_or(&[])) {
out.push(NetEvent::ChannelKey { channel: chan.to_string(), key });
}
out
}
_ => vec![],
}
@ -257,6 +285,38 @@ impl Protocol for InspIrcd {
}
}
// Whether a channel mode consumes a parameter, for standard InspIRCd chanmodes.
// List and prefix modes take one on both set and unset; the key takes one on
// both; the rest that take one do so only on set.
fn takes_param(m: char, adding: bool) -> bool {
match m {
'b' | 'e' | 'I' | 'q' | 'a' | 'o' | 'h' | 'v' | 'k' => true,
'l' | 'L' | 'f' | 'j' | 'J' | 'F' | 'H' => adding,
_ => false,
}
}
// Walk a mode change and its params for a key (+k/-k). Returns Some(Some(key))
// when a key is set, Some(None) when cleared, None when `k` isn't in the change.
fn scan_key(modes: &str, params: &[&str]) -> Option<Option<String>> {
let mut adding = true;
let mut pi = 0;
let mut result = None;
for m in modes.chars() {
match m {
'+' => adding = true,
'-' => adding = false,
'k' => {
result = Some(if adding { params.get(pi).map(|s| s.to_string()) } else { None });
pi += 1;
}
c if takes_param(c, adding) => pi += 1,
_ => {}
}
}
result
}
// The IRC "trailing" argument: everything after the first " :", else the last word.
fn trailing(rest: &str) -> String {
match rest.find(" :") {

View file

@ -17,9 +17,13 @@ pub enum NetEvent {
ChannelCreate { channel: String },
// A user joined a channel (an FJOIN member or an IJOIN), for auto-op.
Join { uid: String, channel: String },
// A user left a channel (PART) or was removed (KICK), for membership tracking.
Part { uid: String, channel: String },
// A channel's modes changed (FMODE), for enforcing mode locks. Our own
// changes are filtered out by the protocol layer.
ChannelModeChange { channel: String, modes: String },
// A channel's key (+k/-k) changed, tracked so GETKEY can report it.
ChannelKey { channel: String, key: Option<String> },
Quit { uid: String },
// An ircd relaying an IRCv3 account-registration request to us as the authority.
AccountRequest { reqid: String, origin: String, kind: String, account: String, p2: String, p3: String },