modules: split services and the ircd link into external crates

nickserv, chanserv and the InspIRCd protocol move out of the binary into their
own workspace crates (fedserv-nickserv, fedserv-chanserv, fedserv-inspircd),
each depending only on fedserv-api. human_time and the branded account emails
move into the SDK crate so a module needs nothing from core; the engine keeps
its own inherent methods and builds emails via fedserv-api too. The bin now
constructs each module from its crate instead of an in-tree #[path] include.
Proves the SDK is self-sufficient: a third-party module is the same shape.
This commit is contained in:
Jean Chevronnet 2026-07-13 01:33:56 +00:00
parent 8ed1a9ab70
commit 596630df53
No known key found for this signature in database
52 changed files with 197 additions and 162 deletions

8
chanserv/Cargo.toml Normal file
View file

@ -0,0 +1,8 @@
[package]
name = "fedserv-chanserv"
version = "0.0.1"
edition = "2021"
description = "ChanServ channel-registration service module for fedserv."
[dependencies]
fedserv-api = { path = "../api" }

70
chanserv/src/access.rs Normal file
View file

@ -0,0 +1,70 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// ACCESS <#channel> LIST | ADD <account> <op|voice> | DEL <account>
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> LIST | ADD <account> <op|voice> | DEL <account>");
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!("Access list for \x02{}\x02:", info.name));
ctx.notice(me, from.uid, format!(" \x02{}\x02 (founder)", info.founder));
for a in &info.access {
ctx.notice(me, from.uid, format!(" \x02{}\x02 ({})", a.account, a.level));
}
}
},
Some("ADD") => {
let (Some(&account), Some(&level)) = (args.get(3), args.get(4)) else {
ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> ADD <account> <op|voice>");
return;
};
let level = level.to_ascii_lowercase();
if level != "op" && level != "voice" {
ctx.notice(me, from.uid, "Level must be \x02op\x02 or \x02voice\x02.");
return;
}
if !is_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 as \x02{level}\x02.")),
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, "Syntax: ACCESS <#channel> DEL <account>");
return;
};
if !is_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.")),
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, "Syntax: ACCESS <#channel> LIST | ADD <account> <op|voice> | DEL <account>"),
}
}
// True if `from` is the channel's founder; otherwise notices why and returns false.
fn is_founder(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &dyn Store) -> 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()) => {
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can change access."));
false
}
Some(_) => true,
}
}

52
chanserv/src/akick.rs Normal file
View file

@ -0,0 +1,52 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// AKICK <#channel> ADD <mask> [reason] | DEL <mask> | LIST
// Masks are nick!user@host globs; matching users are banned and kicked on join.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: AKICK <#channel> ADD <mask> [reason] | DEL <mask> | 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) if info.akick.is_empty() => ctx.notice(me, from.uid, format!("\x02{chan}\x02 has an empty auto-kick list.")),
Some(info) => {
ctx.notice(me, from.uid, format!("Auto-kick list for \x02{}\x02:", info.name));
for k in &info.akick {
ctx.notice(me, from.uid, format!(" \x02{}\x02 ({})", k.mask, k.reason));
}
}
},
Some("ADD") => {
let Some(&mask) = args.get(3) else {
ctx.notice(me, from.uid, "Syntax: AKICK <#channel> ADD <mask> [reason]");
return;
};
if !super::require_op(me, from, chan, ctx, db) {
return;
}
let reason = if args.len() > 4 { args[4..].join(" ") } else { "Auto-kicked".to_string() };
match db.akick_add(chan, mask, &reason) {
Ok(()) => ctx.notice(me, from.uid, format!("Added \x02{mask}\x02 to \x02{chan}\x02's auto-kick list.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
Some("DEL") => {
let Some(&mask) = args.get(3) else {
ctx.notice(me, from.uid, "Syntax: AKICK <#channel> DEL <mask>");
return;
};
if !super::require_op(me, from, chan, ctx, db) {
return;
}
match db.akick_del(chan, mask) {
Ok(true) => ctx.notice(me, from.uid, format!("Removed \x02{mask}\x02 from \x02{chan}\x02's auto-kick list.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{mask}\x02 isn't on \x02{chan}\x02's auto-kick list.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
_ => ctx.notice(me, from.uid, "Syntax: AKICK <#channel> ADD <mask> [reason] | DEL <mask> | LIST"),
}
}

22
chanserv/src/ban.rs Normal file
View file

@ -0,0 +1,22 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::NetView;
// BAN <#channel> <nick> [reason]: ban *!*@host and kick the user.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
let (Some(&chan), Some(&nick)) = (args.get(1), args.get(2)) else {
ctx.notice(me, from.uid, "Syntax: BAN <#channel> <nick> [reason]");
return;
};
if !super::require_op(me, from, chan, ctx, db) {
return;
}
let Some(target) = net.uid_by_nick(nick).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here."));
return;
};
let host = net.host_of(&target).unwrap_or("*");
ctx.channel_mode(me, chan, &format!("+b *!*@{host}"));
let reason = if args.len() > 3 { args[3..].join(" ") } else { "Banned".to_string() };
ctx.kick(me, chan, &target, &reason);
}

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

@ -0,0 +1,32 @@
use fedserv_api::Store;
use fedserv_api::{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 dyn Store) {
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), db.channel(dest)) 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."));
}

35
chanserv/src/enforce.rs Normal file
View file

@ -0,0 +1,35 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::NetView;
// 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: &dyn NetView, db: &dyn Store) {
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) else {
return;
};
ctx.channel_mode(me, chan, &info.lock_modes());
let members: Vec<String> = net.channel_members(chan);
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."));
}

36
chanserv/src/entrymsg.rs Normal file
View file

@ -0,0 +1,36 @@
use fedserv_api::Store;
use fedserv_api::{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 dyn Store) {
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."),
}
}
}
}

19
chanserv/src/getkey.rs Normal file
View file

@ -0,0 +1,19 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::NetView;
// 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: &dyn NetView, db: &dyn Store) {
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.")),
}
}

26
chanserv/src/invite.rs Normal file
View file

@ -0,0 +1,26 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::NetView;
// INVITE <#channel> [nick]: invite a user (self if no nick) into the channel.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: INVITE <#channel> [nick]");
return;
};
if !super::require_op(me, from, chan, ctx, db) {
return;
}
let target = match args.get(2) {
Some(&nick) => match net.uid_by_nick(nick) {
Some(u) => u.to_string(),
None => {
ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here."));
return;
}
},
None => from.uid.to_string(),
};
ctx.invite(me, &target, chan);
ctx.notice(me, from.uid, format!("Invited to \x02{chan}\x02."));
}

20
chanserv/src/kick.rs Normal file
View file

@ -0,0 +1,20 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::NetView;
// KICK <#channel> <nick> [reason]: kick a user.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
let (Some(&chan), Some(&nick)) = (args.get(1), args.get(2)) else {
ctx.notice(me, from.uid, "Syntax: KICK <#channel> <nick> [reason]");
return;
};
if !super::require_op(me, from, chan, ctx, db) {
return;
}
let Some(target) = net.uid_by_nick(nick) else {
ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here."));
return;
};
let reason = if args.len() > 3 { args[3..].join(" ") } else { "Kicked".to_string() };
ctx.kick(me, chan, target, &reason);
}

270
chanserv/src/lib.rs Normal file
View file

@ -0,0 +1,270 @@
use fedserv_api::{ChanError, ChannelView, Store};
use fedserv_api::{Sender, Service, ServiceCtx};
use fedserv_api::NetView;
#[path = "mode.rs"]
mod mode;
#[path = "access.rs"]
mod access;
#[path = "op.rs"]
mod op;
#[path = "kick.rs"]
mod kick;
#[path = "ban.rs"]
mod ban;
#[path = "unban.rs"]
mod unban;
#[path = "topic.rs"]
mod topic;
#[path = "invite.rs"]
mod invite;
#[path = "akick.rs"]
mod akick;
#[path = "list.rs"]
mod list;
#[path = "status.rs"]
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,
}
impl Service for ChanServ {
fn nick(&self) -> &str {
"ChanServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Channel Services"
}
fn manages_channels(&self) -> bool {
true
}
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
let me = self.uid.as_str();
match args.first().map(|s| s.to_ascii_uppercase()).as_deref() {
Some("REGISTER") => {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: REGISTER <#channel>");
return;
};
if !chan.starts_with('#') {
ctx.notice(me, from.uid, "Channel names start with \x02#\x02.");
return;
}
// The founder is the account the sender is identified to.
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to be logged in to register a channel. Identify to NickServ first.");
return;
};
// You can only register a channel you actually control right now.
if !net.is_op(chan, from.uid) {
ctx.notice(me, from.uid, format!("You must be a channel operator (\x02@\x02) in \x02{chan}\x02 to register it."));
return;
}
match db.register_channel(chan, account) {
Ok(()) => {
ctx.channel_mode(me, chan, "+r"); // mark the channel registered
ctx.notice(me, from.uid, format!("\x02{chan}\x02 is now registered and you are its founder. Enjoy!"));
}
Err(ChanError::Exists) => ctx.notice(me, from.uid, format!("\x02{chan}\x02 is already registered. Try \x02INFO {chan}\x02 to see who owns it.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
Some("INFO") => {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: INFO <#channel>");
return;
};
match db.channel(chan) {
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 : {}", fedserv_api::human_time(info.ts)));
}
None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")),
}
}
Some("DROP") => {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: DROP <#channel>");
return;
};
// Read the founder, then drop, without holding the borrow across the mutation.
let founder = match db.channel(chan) {
Some(info) => info.founder.clone(),
None => {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
return;
}
};
if from.account != Some(founder.as_str()) {
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can drop it."));
return;
}
match db.drop_channel(chan) {
Ok(()) => {
ctx.channel_mode(me, chan, "-r"); // no longer registered
ctx.notice(me, from.uid, format!("\x02{chan}\x02 has been dropped and is no longer registered."));
}
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
Some("MLOCK") => {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: MLOCK <#channel> [modes], e.g. MLOCK #chan +nt-s");
return;
};
// No modes given: show the current lock.
if args.len() <= 2 {
match db.channel(chan) {
None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")),
Some(info) if info.lock_on.is_empty() && info.lock_off.is_empty() => {
ctx.notice(me, from.uid, format!("\x02{}\x02 has no mode lock set.", info.name));
}
Some(info) => ctx.notice(me, from.uid, format!("Mode lock for \x02{}\x02: \x02{}\x02", info.name, show_mlock(&info))),
}
return;
}
// Setting the lock: founder only.
let founder = match db.channel(chan) {
Some(info) => info.founder.clone(),
None => {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
return;
}
};
if from.account != Some(founder.as_str()) {
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can set its mode lock."));
return;
}
let (on, off) = parse_mlock(&args[2..].concat());
match db.set_mlock(chan, &on, &off) {
Ok(()) => {
if let Some(info) = db.channel(chan) {
ctx.channel_mode(me, chan, &info.lock_modes()); // apply it now
}
ctx.notice(me, from.uid, format!("Mode lock for \x02{chan}\x02 updated."));
}
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
Some("MODE") => mode::handle(me, from, args, ctx, db),
Some("ACCESS") => access::handle(me, from, args, ctx, db),
Some("OP") => op::handle(me, from, "+o", args, ctx, net, db),
Some("DEOP") => op::handle(me, from, "-o", args, ctx, net, db),
Some("VOICE") => op::handle(me, from, "+v", args, ctx, net, db),
Some("DEVOICE") => op::handle(me, from, "-v", args, ctx, net, db),
Some("KICK") => kick::handle(me, from, args, ctx, net, db),
Some("BAN") => ban::handle(me, from, args, ctx, net, db),
Some("UNBAN") => unban::handle(me, from, args, ctx, net, db),
Some("TOPIC") => topic::handle(me, from, args, ctx, db),
Some("INVITE") => invite::handle(me, from, args, ctx, net, db),
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("SET") => set::handle(me, from, args, ctx, db),
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: &dyn Store) -> 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: &dyn Store) -> bool {
match (from.account, db.channel(chan)) {
(_, None) => {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
false
}
(Some(acc), Some(info)) if info.is_op(acc) => true,
_ => {
ctx.notice(me, from.uid, format!("You need operator access to \x02{chan}\x02."));
false
}
}
}
// Parse a mode spec like "+nt-s" into (locked-on, locked-off) mode chars. `r` is
// implicit for a registered channel and is ignored here.
fn parse_mlock(spec: &str) -> (String, String) {
let (mut on, mut off) = (String::new(), String::new());
let mut adding = true;
for ch in spec.chars() {
match ch {
'+' => adding = true,
'-' => adding = false,
'r' => {}
m if m.is_ascii_alphabetic() => {
on.retain(|c| c != m);
off.retain(|c| c != m);
if adding {
on.push(m);
} else {
off.push(m);
}
}
_ => {}
}
}
(on, off)
}
// Render a channel's lock as "+on-off" for display.
fn show_mlock(info: &ChannelView) -> String {
let mut s = String::new();
if !info.lock_on.is_empty() {
s.push('+');
s.push_str(&info.lock_on);
}
if !info.lock_off.is_empty() {
s.push('-');
s.push_str(&info.lock_off);
}
s
}

16
chanserv/src/list.rs Normal file
View file

@ -0,0 +1,16 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// LIST: show all registered channels.
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();
if names.is_empty() {
ctx.notice(me, from.uid, "No channels are registered.");
return;
}
names.sort_unstable();
ctx.notice(me, from.uid, format!("Registered channels ({}):", names.len()));
for n in names {
ctx.notice(me, from.uid, format!(" \x02{n}\x02"));
}
}

28
chanserv/src/mode.rs Normal file
View file

@ -0,0 +1,28 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// MODE <#channel> <modes>: the founder sets channel modes via ChanServ.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: MODE <#channel> <modes>, e.g. MODE #chan +nt");
return;
};
if args.len() <= 2 {
ctx.notice(me, from.uid, "Syntax: MODE <#channel> <modes>, e.g. MODE #chan +nt");
return;
}
let founder = match db.channel(chan) {
Some(info) => info.founder.clone(),
None => {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
return;
}
};
if from.account != Some(founder.as_str()) {
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can change its modes."));
return;
}
let modes = args[2..].join(" ");
ctx.channel_mode(me, chan, &modes);
ctx.notice(me, from.uid, format!("Set \x02{modes}\x02 on \x02{chan}\x02."));
}

26
chanserv/src/op.rs Normal file
View file

@ -0,0 +1,26 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::NetView;
// OP/DEOP/VOICE/DEVOICE <#channel> [nick]: set a status mode on a user (self if
// no nick given). `mode` is the mode to apply, e.g. "+o".
pub fn handle(me: &str, from: &Sender, mode: &str, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: OP/DEOP/VOICE/DEVOICE <#channel> [nick]");
return;
};
if !super::require_op(me, from, chan, ctx, db) {
return;
}
let target = match args.get(2) {
Some(&nick) => match net.uid_by_nick(nick) {
Some(u) => u.to_string(),
None => {
ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here."));
return;
}
},
None => from.uid.to_string(),
};
ctx.channel_mode(me, chan, &format!("{mode} {target}"));
}

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

@ -0,0 +1,18 @@
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::NetView;
// SEEN <nick>: when a nick was last seen, and doing what.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) {
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, fedserv_api::human_time(s.ts), s.what)),
None => ctx.notice(me, from.uid, format!("I have no record of \x02{nick}\x02.")),
}
}

45
chanserv/src/set.rs Normal file
View file

@ -0,0 +1,45 @@
use fedserv_api::Store;
use fedserv_api::{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 dyn Store) {
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>"),
}
}

29
chanserv/src/status.rs Normal file
View file

@ -0,0 +1,29 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::NetView;
// STATUS <#channel> [nick]: show a user's access level (self if no nick).
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: STATUS <#channel> [nick]");
return;
};
let Some(info) = db.channel(chan) else {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
return;
};
let (label, account) = match args.get(2) {
Some(&nick) => (nick.to_string(), net.uid_by_nick(nick).and_then(|u| net.account_of(u)).map(str::to_string)),
None => (from.nick.to_string(), from.account.map(str::to_string)),
};
let level = match account.as_deref() {
None => "none (not logged in)",
Some(a) if info.founder.eq_ignore_ascii_case(a) => "founder",
Some(a) => match info.join_mode(a) {
Some("+o") => "op",
Some("+v") => "voice",
_ => "none",
},
};
ctx.notice(me, from.uid, format!("\x02{label}\x02 access on \x02{chan}\x02: \x02{level}\x02"));
}

16
chanserv/src/topic.rs Normal file
View file

@ -0,0 +1,16 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// TOPIC <#channel> <text>: set the channel topic (empty clears it).
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: TOPIC <#channel> <text>");
return;
};
if !super::require_op(me, from, chan, ctx, db) {
return;
}
let text = if args.len() > 2 { args[2..].join(" ") } else { String::new() };
ctx.topic(me, chan, &text);
ctx.notice(me, from.uid, format!("Topic for \x02{chan}\x02 updated."));
}

22
chanserv/src/unban.rs Normal file
View file

@ -0,0 +1,22 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::NetView;
// UNBAN <#channel> [nick]: remove the *!*@host ban of a user (self if no nick).
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: UNBAN <#channel> [nick]");
return;
};
if !super::require_op(me, from, chan, ctx, db) {
return;
}
let target = args
.get(2)
.and_then(|&n| net.uid_by_nick(n))
.map(str::to_string)
.unwrap_or_else(|| from.uid.to_string());
let host = net.host_of(&target).unwrap_or("*");
ctx.channel_mode(me, chan, &format!("-b *!*@{host}"));
ctx.notice(me, from.uid, format!("Cleared the *!*@{host} ban on \x02{chan}\x02."));
}

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

@ -0,0 +1,51 @@
use fedserv_api::Store;
use fedserv_api::{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 dyn Store) {
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")),
}
}