Group the module crates under modules/

The service pseudo-clients and the ircd protocol link sat flat at the
repo root, mixed in with the daemon core and the SDK. Move them all
under modules/ so the tree separates concerns cleanly: the daemon in
src/, the SDK every module links against in api/, and the loadable
modules — the pseudo-clients plus the protocol link — in modules/.

Workspace members, the daemon's per-crate dependency paths, and each
module's api path are updated to match; the docs follow. No code change.
This commit is contained in:
Jean Chevronnet 2026-07-14 14:19:43 +00:00
parent 6f76f9722c
commit ad2a623120
No known key found for this signature in database
116 changed files with 69 additions and 46 deletions

View file

@ -0,0 +1,8 @@
[package]
name = "fedserv-botserv"
version = "0.0.1"
edition = "2021"
description = "BotServ: registers and manages service bots that sit in channels."
[dependencies]
fedserv-api = { path = "../../api" }

View file

@ -0,0 +1,45 @@
use fedserv_api::{Priv, Sender, ServiceCtx, Store};
// ASSIGN <#channel> <bot> / UNASSIGN <#channel>: put a bot in a channel (or take
// it out). Channel founder only (or a services admin).
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store, assigning: bool) {
let Some(&chan) = args.get(1) else {
let syntax = if assigning { "Syntax: ASSIGN <#channel> <bot>" } else { "Syntax: UNASSIGN <#channel>" };
ctx.notice(me, from.uid, syntax);
return;
};
if !super::require_channel_admin(me, from, chan, ctx, db) {
return;
}
// NOBOT reserves (un)assignment for services operators.
let is_admin = from.privs.has(Priv::Admin);
if !is_admin && db.channel(chan).is_some_and(|c| c.nobot) {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 is set \x02NOBOT\x02 — only a services operator can change its bot."));
return;
}
if !assigning {
match db.unassign_bot(chan) {
Ok(true) => ctx.notice(me, from.uid, format!("The bot has left \x02{chan}\x02.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no bot assigned.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
return;
}
let Some(&bot) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: ASSIGN <#channel> <bot>");
return;
};
let Some(target) = db.bots().into_iter().find(|b| b.nick.eq_ignore_ascii_case(bot)) else {
ctx.notice(me, from.uid, format!("There's no bot named \x02{bot}\x02. See \x02BOT LIST\x02."));
return;
};
if target.private && !is_admin {
ctx.notice(me, from.uid, format!("Bot \x02{}\x02 is private — only a services operator can assign it.", target.nick));
return;
}
let botnick = target.nick;
match db.assign_bot(chan, &botnick) {
Ok(()) => ctx.notice(me, from.uid, format!("Bot \x02{botnick}\x02 is now assigned to \x02{chan}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -0,0 +1,63 @@
use fedserv_api::{ChanError, Sender, ServiceCtx, Store};
// BADWORDS <#channel> ADD <regex> | DEL <regex> | LIST | CLEAR: manage the
// channel's badword patterns. Each entry is a regular expression, so a channel
// can match whatever it likes. Enable the kicker itself with
// KICK <#channel> BADWORDS ON. Founder-or-admin.
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: BADWORDS <#channel> ADD|DEL|LIST|CLEAR [pattern]");
return;
};
// LIST is readable by the founder/admin too; gate everything the same way.
if !super::require_channel_admin(me, from, chan, ctx, db) {
return;
}
match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ADD") => {
if args.len() < 4 {
ctx.notice(me, from.uid, "Syntax: BADWORDS <#channel> ADD <regex>");
return;
}
let pattern = args[3..].join(" ");
match db.badword_add(chan, &pattern) {
Ok(true) => ctx.notice(me, from.uid, format!("Added badword pattern to \x02{chan}\x02: {pattern}")),
Ok(false) => ctx.notice(me, from.uid, "That pattern is already on the list."),
Err(ChanError::InvalidPattern) => ctx.notice(me, from.uid, format!("\x02{pattern}\x02 isn't a valid regular expression.")),
Err(_) => reg_error(me, from, chan, ctx),
}
}
Some("DEL") => {
if args.len() < 4 {
ctx.notice(me, from.uid, "Syntax: BADWORDS <#channel> DEL <regex>");
return;
}
let pattern = args[3..].join(" ");
match db.badword_del(chan, &pattern) {
Ok(true) => ctx.notice(me, from.uid, format!("Removed badword pattern from \x02{chan}\x02.")),
Ok(false) => ctx.notice(me, from.uid, "That pattern isn't on the list."),
Err(_) => reg_error(me, from, chan, ctx),
}
}
Some("CLEAR") => match db.badword_clear(chan) {
Ok(n) => ctx.notice(me, from.uid, format!("Cleared \x02{n}\x02 badword pattern(s) from \x02{chan}\x02.")),
Err(_) => reg_error(me, from, chan, ctx),
},
None | Some("LIST") => {
let words = db.badwords(chan);
if words.is_empty() {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no badword patterns."));
return;
}
ctx.notice(me, from.uid, format!("Badword patterns for \x02{chan}\x02 ({}):", words.len()));
for (i, w) in words.iter().enumerate() {
ctx.notice(me, from.uid, format!(" {}. {w}", i + 1));
}
}
Some(other) => ctx.notice(me, from.uid, format!("Unknown BADWORDS command \x02{other}\x02. Use ADD, DEL, LIST or CLEAR.")),
}
}
fn reg_error(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, format!("Couldn't update \x02{chan}\x02 — please try again in a moment."));
}

View file

@ -0,0 +1,73 @@
use fedserv_api::{ChanError, Priv, Sender, ServiceCtx, Store};
// BOT ADD <nick> <user> <host> [gecos] | BOT DEL <nick> | BOT LIST — manage the
// bot registry. Administering bots is oper-only (Priv::Admin).
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — managing bots is for services operators.");
return;
}
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ADD") => {
let (Some(&nick), Some(&user), Some(&host)) = (args.get(2), args.get(3), args.get(4)) else {
ctx.notice(me, from.uid, "Syntax: BOT ADD <nick> <user> <host> [gecos]");
return;
};
let gecos = if args.len() > 5 { args[5..].join(" ") } else { "Service Bot".to_string() };
match db.bot_add(nick, user, host, &gecos) {
Ok(()) => ctx.notice(me, from.uid, format!("Bot \x02{nick}\x02 (\x02{user}@{host}\x02) added.")),
Err(_) => ctx.notice(me, from.uid, format!("A bot named \x02{nick}\x02 already exists, or that didn't work.")),
}
}
Some("CHANGE") => {
let (Some(&old), Some(&newnick)) = (args.get(2), args.get(3)) else {
ctx.notice(me, from.uid, "Syntax: BOT CHANGE <oldnick> <newnick> [user [host [gecos]]]");
return;
};
// Omitted fields keep the bot's current values.
let Some(cur) = db.bots().into_iter().find(|b| b.nick.eq_ignore_ascii_case(old)) else {
ctx.notice(me, from.uid, format!("There's no bot named \x02{old}\x02."));
return;
};
let user = args.get(4).map(|s| s.to_string()).unwrap_or(cur.user);
let host = args.get(5).map(|s| s.to_string()).unwrap_or(cur.host);
let gecos = if args.len() > 6 { args[6..].join(" ") } else { cur.gecos };
match db.bot_change(old, newnick, &user, &host, &gecos) {
Ok(()) => ctx.notice(me, from.uid, format!("Bot \x02{old}\x02 is now \x02{newnick}\x02 (\x02{user}@{host}\x02).")),
Err(ChanError::Exists) => ctx.notice(me, from.uid, format!("A bot named \x02{newnick}\x02 already exists.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
Some("DEL") => {
let Some(&nick) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: BOT DEL <nick> (or \x02*\x02 for all)");
return;
};
if nick == "*" {
match db.bot_del_all() {
Ok(n) => ctx.notice(me, from.uid, format!("Removed all \x02{n}\x02 bot(s).")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
return;
}
match db.bot_del(nick) {
Ok(true) => ctx.notice(me, from.uid, format!("Bot \x02{nick}\x02 deleted.")),
Ok(false) => ctx.notice(me, from.uid, format!("There's no bot named \x02{nick}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
None | Some("LIST") => {
let bots = db.bots();
if bots.is_empty() {
ctx.notice(me, from.uid, "No bots have been added yet. Add one with \x02BOT ADD\x02.");
return;
}
ctx.notice(me, from.uid, format!("Bots ({}):", bots.len()));
for b in &bots {
let flag = if b.private { " \x02[private]\x02" } else { "" };
ctx.notice(me, from.uid, format!(" \x02{}\x02 ({}@{}) — {}{flag}", b.nick, b.user, b.host, b.gecos));
}
}
Some(other) => ctx.notice(me, from.uid, format!("Unknown BOT command \x02{other}\x02. Use \x02ADD\x02, \x02CHANGE\x02, \x02DEL\x02 or \x02LIST\x02.")),
}
}

View file

@ -0,0 +1,17 @@
use fedserv_api::{Sender, ServiceCtx, Store};
// COPY <#source> <#dest>: copy a channel's bot configuration — kickers,
// badwords, greet and nobot — onto another. Requires founder-or-admin on both.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let (Some(&src), Some(&dst)) = (args.get(1), args.get(2)) else {
ctx.notice(me, from.uid, "Syntax: COPY <#source> <#dest>");
return;
};
if !super::require_channel_admin(me, from, src, ctx, db) || !super::require_channel_admin(me, from, dst, ctx, db) {
return;
}
match db.copy_bot_config(src, dst) {
Ok(()) => ctx.notice(me, from.uid, format!("Copied \x02{src}\x02's bot settings (kickers, badwords, greet, nobot) to \x02{dst}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -0,0 +1,55 @@
use fedserv_api::{Sender, ServiceCtx, Store};
// INFO <bot> — describe a bot and list the channels it serves.
// INFO <#channel> — show which bot (if any) is assigned to a channel.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) {
let Some(&target) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: INFO <bot | #channel>");
return;
};
if target.starts_with('#') {
let Some(chan) = db.channel(target) else {
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
return;
};
match &chan.assigned_bot {
Some(bot) => ctx.notice(me, from.uid, format!("\x02{target}\x02 is served by bot \x02{bot}\x02.")),
None => ctx.notice(me, from.uid, format!("\x02{target}\x02 has no bot assigned. Assign one with \x02ASSIGN\x02 {target} <bot>.")),
}
// The BotServ options in effect on this channel.
let mut opts = Vec::new();
if chan.bot_greet {
opts.push("greets");
}
if chan.kickers_active {
opts.push("kickers");
}
if chan.nobot {
opts.push("nobot");
}
let summary = if opts.is_empty() { "none".to_string() } else { opts.join(", ") };
ctx.notice(me, from.uid, format!(" Options: {summary}"));
return;
}
let Some(bot) = db.bots().into_iter().find(|b| b.nick.eq_ignore_ascii_case(target)) else {
ctx.notice(me, from.uid, format!("There's no bot named \x02{target}\x02. See \x02BOT LIST\x02."));
return;
};
let channels: Vec<String> = db
.channels()
.into_iter()
.filter(|c| c.assigned_bot.as_deref().is_some_and(|b| b.eq_ignore_ascii_case(&bot.nick)))
.map(|c| c.name)
.collect();
let privacy = if bot.private { " (private)" } else { "" };
ctx.notice(me, from.uid, format!("Bot \x02{}\x02{}@{}{privacy}", bot.nick, bot.user, bot.host));
ctx.notice(me, from.uid, format!(" Real name: {}", bot.gecos));
if channels.is_empty() {
ctx.notice(me, from.uid, " Not assigned to any channel.");
} else {
ctx.notice(me, from.uid, format!(" Serving {} channel(s): {}", channels.len(), channels.join(", ")));
}
}

143
modules/botserv/src/kick.rs Normal file
View file

@ -0,0 +1,143 @@
use fedserv_api::{Kicker, Sender, ServiceCtx, Store};
// KICK <#channel> <type> {ON|OFF} [params]: configure the bot's kickers.
// Types: CAPS [min [percent]], BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS,
// and the exemption DONTKICKOPS. Founder-or-admin.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let (Some(&chan), Some(kind)) = (args.get(1), args.get(2)) else {
ctx.notice(me, from.uid, "Syntax: KICK <#channel> <CAPS|FLOOD|REPEAT|BADWORDS|BOLDS|COLORS|UNDERLINES|REVERSES|ITALICS|WARN|DONTKICKOPS|DONTKICKVOICES> <ON|OFF> [params], or KICK <#channel> TTB <n> / TEST <message>");
return;
};
if !super::require_channel_admin(me, from, chan, ctx, db) {
return;
}
// TTB takes a number, not ON/OFF: kicks-before-ban (0 = never ban).
if kind.eq_ignore_ascii_case("TTB") {
match args.get(3).and_then(|s| s.parse::<u16>().ok()) {
Some(n) => match db.set_ttb(chan, n) {
Ok(()) if n == 0 => ctx.notice(me, from.uid, format!("The bot will only kick (not ban) in \x02{chan}\x02.")),
Ok(()) => ctx.notice(me, from.uid, format!("The bot will ban a user after \x02{n}\x02 kick(s) in \x02{chan}\x02.")),
Err(_) => reg_error(me, from, chan, ctx),
},
None => ctx.notice(me, from.uid, "Syntax: KICK <#channel> TTB <number>"),
}
return;
}
// TEST dry-runs the content kickers against a line, kicking nobody — so an
// op can tune caps%/badword patterns safely.
if kind.eq_ignore_ascii_case("TEST") {
if args.len() < 4 {
ctx.notice(me, from.uid, "Syntax: KICK <#channel> TEST <message>");
return;
}
let text = args[3..].join(" ");
match db.kicker_test(chan, &text) {
Some(reason) => ctx.notice(me, from.uid, format!("That line \x02would be kicked\x02: {reason}")),
None => ctx.notice(me, from.uid, "That line trips no content kicker (flood/repeat depend on timing and repetition, so they aren't tested here)."),
}
return;
}
let on = match args.get(3).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ON") | Some("TRUE") => true,
Some("OFF") | Some("FALSE") => false,
_ => {
ctx.notice(me, from.uid, "Say \x02ON\x02 or \x02OFF\x02, e.g. KICK #channel CAPS ON.");
return;
}
};
// CAPS/FLOOD/REPEAT carry optional thresholds when turned on.
if kind.eq_ignore_ascii_case("CAPS") {
if on {
let min = args.get(4).and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
let percent = args.get(5).and_then(|s| s.parse::<u16>().ok()).filter(|p| (1..=100).contains(p)).unwrap_or(0);
match db.set_caps_kicker(chan, min, percent) {
Ok(()) => ctx.notice(me, from.uid, format!("The bot will now kick for \x02caps\x02 in \x02{chan}\x02.")),
Err(_) => reg_error(me, from, chan, ctx),
}
} else {
toggle(me, from, chan, Kicker::Caps, false, ctx, db);
}
return;
}
if kind.eq_ignore_ascii_case("FLOOD") {
if on {
let lines = args.get(4).and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
let secs = args.get(5).and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
match db.set_flood_kicker(chan, lines, secs) {
Ok(()) => ctx.notice(me, from.uid, format!("The bot will now kick for \x02flooding\x02 in \x02{chan}\x02.")),
Err(_) => reg_error(me, from, chan, ctx),
}
} else {
toggle(me, from, chan, Kicker::Flood, false, ctx, db);
}
return;
}
if kind.eq_ignore_ascii_case("REPEAT") {
if on {
let times = args.get(4).and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
match db.set_repeat_kicker(chan, times) {
Ok(()) => ctx.notice(me, from.uid, format!("The bot will now kick for \x02repeating\x02 in \x02{chan}\x02.")),
Err(_) => reg_error(me, from, chan, ctx),
}
} else {
toggle(me, from, chan, Kicker::Repeat, false, ctx, db);
}
return;
}
let kicker = match kind.to_ascii_uppercase().as_str() {
"BOLDS" => Kicker::Bolds,
"COLORS" | "COLOURS" => Kicker::Colors,
"UNDERLINES" => Kicker::Underlines,
"REVERSES" => Kicker::Reverses,
"ITALICS" => Kicker::Italics,
"BADWORDS" => Kicker::Badwords,
"WARN" => Kicker::Warn,
"DONTKICKOPS" => Kicker::DontKickOps,
"DONTKICKVOICES" => Kicker::DontKickVoices,
other => {
ctx.notice(me, from.uid, format!("Unknown kicker \x02{other}\x02. Try CAPS, FLOOD, REPEAT, BADWORDS, BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS, WARN, DONTKICKOPS or DONTKICKVOICES."));
return;
}
};
toggle(me, from, chan, kicker, on, ctx, db);
}
fn label(kicker: Kicker) -> &'static str {
match kicker {
Kicker::Caps => "caps",
Kicker::Bolds => "bolds",
Kicker::Colors => "colours",
Kicker::Underlines => "underlines",
Kicker::Reverses => "reverses",
Kicker::Italics => "italics",
Kicker::Flood => "flood",
Kicker::Repeat => "repeat",
Kicker::Badwords => "badwords",
Kicker::Warn => "warn",
Kicker::DontKickOps => "dontkickops",
Kicker::DontKickVoices => "dontkickvoices",
}
}
fn toggle(me: &str, from: &Sender, chan: &str, kicker: Kicker, on: bool, ctx: &mut ServiceCtx, db: &mut dyn Store) {
match db.set_kicker(chan, kicker, on) {
Ok(()) if kicker == Kicker::DontKickOps && on => ctx.notice(me, from.uid, format!("The bot will no longer kick channel operators in \x02{chan}\x02.")),
Ok(()) if kicker == Kicker::DontKickOps => ctx.notice(me, from.uid, format!("The bot may again kick channel operators in \x02{chan}\x02.")),
Ok(()) if kicker == Kicker::DontKickVoices && on => ctx.notice(me, from.uid, format!("The bot will no longer kick voiced users in \x02{chan}\x02.")),
Ok(()) if kicker == Kicker::DontKickVoices => ctx.notice(me, from.uid, format!("The bot may again kick voiced users in \x02{chan}\x02.")),
Ok(()) if kicker == Kicker::Warn && on => ctx.notice(me, from.uid, format!("The bot will warn once before the first kick in \x02{chan}\x02.")),
Ok(()) if kicker == Kicker::Warn => ctx.notice(me, from.uid, format!("The bot will kick without warning in \x02{chan}\x02.")),
Ok(()) if on => ctx.notice(me, from.uid, format!("The \x02{}\x02 kicker is now on in \x02{chan}\x02.", label(kicker))),
Ok(()) => ctx.notice(me, from.uid, format!("The \x02{}\x02 kicker is now off in \x02{chan}\x02.", label(kicker))),
Err(_) => reg_error(me, from, chan, ctx),
}
}
fn reg_error(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, format!("Couldn't update \x02{chan}\x02 — please try again in a moment."));
}

View file

@ -0,0 +1,73 @@
//! BotServ registers service bots — pseudo-clients that get assigned to channels
//! and (in later slices) run fantasy commands. `lib.rs` holds the dispatcher;
//! each command lives in its own file, matching NickServ/ChanServ.
use fedserv_api::{NetView, Priv, Sender, Service, ServiceCtx, Store};
#[path = "bot.rs"]
mod bot;
#[path = "assign.rs"]
mod assign;
#[path = "info.rs"]
mod info;
#[path = "say.rs"]
mod say;
#[path = "set.rs"]
mod set;
#[path = "kick.rs"]
mod kick;
#[path = "badwords.rs"]
mod badwords;
#[path = "copy.rs"]
mod copy;
#[path = "trigger.rs"]
mod trigger;
// Shared gate: the sender may administer <chan>'s bot options only as its
// founder or a services admin. Notices and returns false on failure.
fn require_channel_admin(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &dyn Store) -> bool {
let Some(founder) = db.channel(chan).map(|c| c.founder) else {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
return false;
};
if from.account != Some(founder.as_str()) && !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can change that."));
return false;
}
true
}
pub struct BotServ {
pub uid: String,
}
impl Service for BotServ {
fn nick(&self) -> &str {
"BotServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Bot Services"
}
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("BOT") => bot::handle(me, from, args, ctx, db),
Some("ASSIGN") => assign::handle(me, from, args, ctx, db, true),
Some("UNASSIGN") => assign::handle(me, from, args, ctx, db, false),
Some("INFO") => info::handle(me, from, args, ctx, db),
Some("SAY") => say::handle(me, from, args, ctx, net, db, false),
Some("ACT") => say::handle(me, from, args, ctx, net, db, true),
Some("SET") => set::handle(me, from, args, ctx, db),
Some("KICK") => kick::handle(me, from, args, ctx, db),
Some("BADWORDS") => badwords::handle(me, from, args, ctx, db),
Some("COPY") => copy::handle(me, from, args, ctx, db),
Some("TRIGGER") => trigger::handle(me, from, args, ctx, db),
Some("HELP") | None => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels: \x02ASSIGN\x02 <#channel> <bot> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 <bot|#channel> shows details, \x02SAY\x02/\x02ACT\x02 <#channel> <text> speaks through the bot, \x02SET\x02 <#channel> GREET <on|off> toggles greets, \x02KICK\x02 <#channel> <type> <on|off> configures kickers (with \x02TEST\x02/\x02WARN\x02/\x02TTB\x02), \x02BADWORDS\x02 and \x02TRIGGER\x02 <#channel> ADD|DEL|LIST manage badword regexes and auto-responses, \x02COPY\x02 <#src> <#dst> clones settings. Operators also have \x02BOT\x02 ADD|CHANGE|DEL|LIST."),
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
}
}
}

View file

@ -0,0 +1,35 @@
use fedserv_api::{NetView, Priv, Sender, ServiceCtx, Store};
// SAY <#channel> <text> — make the channel's assigned bot say something.
// ACT <#channel> <text> — the same, as a CTCP ACTION (/me).
// Requires channel-operator access (founder, op, or a services admin).
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store, action: bool) {
let verb = if action { "ACT" } else { "SAY" };
if args.len() < 3 {
ctx.notice(me, from.uid, format!("Syntax: {verb} <#channel> <text>"));
return;
}
let chan = args[1];
let text = args[2..].join(" ");
let Some(info) = db.channel(chan) else {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
return;
};
let allowed = from.privs.has(Priv::Admin) || from.account.is_some_and(|a| info.is_op(a));
if !allowed {
ctx.notice(me, from.uid, format!("Access denied — you need operator access in \x02{chan}\x02."));
return;
}
let Some(bot) = info.assigned_bot else {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no bot assigned. Assign one with \x02ASSIGN\x02 {chan} <bot>."));
return;
};
let Some(botuid) = net.uid_by_nick(&bot) else {
ctx.notice(me, from.uid, format!("Bot \x02{bot}\x02 isn't on the network right now."));
return;
};
let botuid = botuid.to_string();
let payload = if action { format!("\x01ACTION {text}\x01") } else { text };
ctx.privmsg(&botuid, chan, payload);
}

104
modules/botserv/src/set.rs Normal file
View file

@ -0,0 +1,104 @@
use fedserv_api::{parse_duration, ChanSetting, Priv, Sender, ServiceCtx, Store};
// SET <#channel> <option> <value>: per-channel bot options (founder-or-admin) —
// GREET <on|off>, BANEXPIRE <duration|off>, NOBOT <on|off>. Also
// SET <bot> PRIVATE <on|off> (services-admin only).
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let (Some(&target), Some(option)) = (args.get(1), args.get(2)) else {
ctx.notice(me, from.uid, "Syntax: SET <#channel> <GREET|BANEXPIRE|NOBOT> <value>, or SET <bot> PRIVATE <ON|OFF>");
return;
};
// A non-channel target names a bot: the only per-bot option is PRIVATE, and
// it is services-admin only.
if !target.starts_with('#') {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — managing bots is for services operators.");
return;
}
let on = match (option.eq_ignore_ascii_case("PRIVATE"), args.get(3).map(|s| s.to_ascii_uppercase()).as_deref()) {
(true, Some("ON")) => true,
(true, Some("OFF")) => false,
(true, _) => {
ctx.notice(me, from.uid, "Syntax: SET <bot> PRIVATE <ON|OFF>");
return;
}
(false, _) => {
ctx.notice(me, from.uid, format!("Unknown bot option \x02{option}\x02. Available: \x02PRIVATE\x02."));
return;
}
};
match db.bot_set_private(target, on) {
Ok(true) if on => ctx.notice(me, from.uid, format!("Bot \x02{target}\x02 is now private (operators only).")),
Ok(true) => ctx.notice(me, from.uid, format!("Bot \x02{target}\x02 is now public.")),
Ok(false) => ctx.notice(me, from.uid, format!("There's no bot named \x02{target}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
return;
}
let chan = target;
if !super::require_channel_admin(me, from, chan, ctx, db) {
return;
}
// VOTEKICK takes a vote count (0 = off), not ON/OFF.
if option.eq_ignore_ascii_case("VOTEKICK") {
match args.get(3).and_then(|s| s.parse::<u16>().ok()) {
Some(n) => match db.set_votekick(chan, n) {
Ok(()) if n == 0 => ctx.notice(me, from.uid, format!("\x02!votekick\x02 is now disabled in \x02{chan}\x02.")),
Ok(()) => ctx.notice(me, from.uid, format!("\x02{n}\x02 vote(s) will now carry a \x02!votekick\x02/\x02!voteban\x02 in \x02{chan}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
},
None => ctx.notice(me, from.uid, "Syntax: SET <#channel> VOTEKICK <number> (0 to disable)"),
}
return;
}
// BANEXPIRE takes a duration, not ON/OFF.
if option.eq_ignore_ascii_case("BANEXPIRE") {
let Some(&arg) = args.get(3) else {
ctx.notice(me, from.uid, "Syntax: SET <#channel> BANEXPIRE <duration|off> (e.g. 30m, 2h, off).");
return;
};
let secs = if matches!(arg.to_ascii_lowercase().as_str(), "off" | "none" | "0") {
0
} else {
match parse_duration(arg) {
Some(s) => s.min(u32::MAX as u64) as u32,
None => {
ctx.notice(me, from.uid, format!("\x02{arg}\x02 isn't a valid duration. Try 30m, 2h, 7d or \x02off\x02."));
return;
}
}
};
match db.set_ban_expire(chan, secs) {
Ok(()) if secs == 0 => ctx.notice(me, from.uid, format!("Kicker bans in \x02{chan}\x02 won't expire automatically.")),
Ok(()) => ctx.notice(me, from.uid, format!("Kicker bans in \x02{chan}\x02 will expire after \x02{arg}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
return;
}
let on = match args.get(3).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ON") | Some("TRUE") => true,
Some("OFF") | Some("FALSE") => false,
_ => {
ctx.notice(me, from.uid, "Syntax: SET <#channel> GREET <ON|OFF>");
return;
}
};
match option.to_ascii_uppercase().as_str() {
"GREET" => match db.set_channel_setting(chan, ChanSetting::BotGreet, on) {
Ok(()) if on => ctx.notice(me, from.uid, format!("Greet messages are now \x02on\x02 in \x02{chan}\x02.")),
Ok(()) => ctx.notice(me, from.uid, format!("Greet messages are now \x02off\x02 in \x02{chan}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
},
"NOBOT" => match db.set_channel_setting(chan, ChanSetting::NoBot, on) {
Ok(()) if on => ctx.notice(me, from.uid, format!("Only operators may (un)assign a bot in \x02{chan}\x02 now.")),
Ok(()) => ctx.notice(me, from.uid, format!("The founder may (un)assign a bot in \x02{chan}\x02 again.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
},
other => ctx.notice(me, from.uid, format!("Unknown option \x02{other}\x02. Available: \x02GREET\x02, \x02BANEXPIRE\x02, \x02NOBOT\x02.")),
}
}

View file

@ -0,0 +1,77 @@
use fedserv_api::{ChanError, Sender, ServiceCtx, Store};
// TRIGGER <#channel> ADD <regex>|<response> | DEL <num> | LIST | CLEAR: manage
// the channel's auto-responses. When a line matches <regex>, the assigned bot
// says <response> ($nick becomes the speaker's nick). Founder-or-admin.
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: TRIGGER <#channel> ADD <regex>|<response> | DEL <num> | LIST | CLEAR");
return;
};
if !super::require_channel_admin(me, from, chan, ctx, db) {
return;
}
match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ADD") => {
// "<regex>|<response>" with an optional trailing "|<cooldown-secs>".
// $1..$9 in the response fill from regex capture groups; $nick is the
// speaker.
let rest = args[3..].join(" ");
let Some((pattern, mut response)) = rest.split_once('|') else {
ctx.notice(me, from.uid, "Syntax: TRIGGER <#channel> ADD <regex>|<response>[|<cooldown-secs>]");
return;
};
// A numeric field after the last '|' is a cooldown, not part of the text.
let mut cooldown = 0;
if let Some((head, tail)) = response.rsplit_once('|') {
if let Ok(secs) = tail.trim().parse::<u32>() {
cooldown = secs;
response = head;
}
}
let (pattern, response) = (pattern.trim(), response.trim());
if pattern.is_empty() || response.is_empty() {
ctx.notice(me, from.uid, "Both a pattern and a response are required: ADD <regex>|<response>.");
return;
}
match db.trigger_add(chan, pattern, response, cooldown) {
Ok(true) => ctx.notice(me, from.uid, format!("Added a trigger to \x02{chan}\x02.")),
Ok(false) => ctx.notice(me, from.uid, "That pattern already has a trigger."),
Err(ChanError::InvalidPattern) => ctx.notice(me, from.uid, format!("\x02{pattern}\x02 isn't a valid regular expression.")),
Err(_) => reg_error(me, from, chan, ctx),
}
}
Some("DEL") => {
let Some(n) = args.get(3).and_then(|s| s.parse::<usize>().ok()) else {
ctx.notice(me, from.uid, "Syntax: TRIGGER <#channel> DEL <num> (see LIST)");
return;
};
match db.trigger_del(chan, n) {
Ok(true) => ctx.notice(me, from.uid, format!("Trigger #\x02{n}\x02 removed from \x02{chan}\x02.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no trigger #\x02{n}\x02.")),
Err(_) => reg_error(me, from, chan, ctx),
}
}
Some("CLEAR") => match db.trigger_clear(chan) {
Ok(n) => ctx.notice(me, from.uid, format!("Cleared \x02{n}\x02 trigger(s) from \x02{chan}\x02.")),
Err(_) => reg_error(me, from, chan, ctx),
},
None | Some("LIST") => {
let triggers = db.triggers(chan);
if triggers.is_empty() {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no triggers."));
return;
}
ctx.notice(me, from.uid, format!("Triggers for \x02{chan}\x02 ({}):", triggers.len()));
for (i, t) in triggers.iter().enumerate() {
let cd = if t.cooldown > 0 { format!(" (cooldown {}s)", t.cooldown) } else { String::new() };
ctx.notice(me, from.uid, format!(" {}. {} \x02\x02 {}{cd}", i + 1, t.pattern, t.response));
}
}
Some(other) => ctx.notice(me, from.uid, format!("Unknown TRIGGER command \x02{other}\x02. Use ADD, DEL, LIST or CLEAR.")),
}
}
fn reg_error(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, format!("Couldn't update \x02{chan}\x02 — please try again in a moment."));
}

View file

@ -0,0 +1,8 @@
[package]
name = "fedserv-chanfix"
version = "0.0.1"
edition = "2021"
description = "ChanFix: reop opless unregistered channels from accumulated op-time scores."
[dependencies]
fedserv-api = { path = "../../api" }

110
modules/chanfix/src/lib.rs Normal file
View file

@ -0,0 +1,110 @@
//! ChanFix helps recover an opless channel. The engine continuously scores
//! op-time per identity from live network state; ChanFix reads those scores to
//! reop the channel's trusted regulars. It **defers to ChanServ**: a registered
//! channel is ChanServ's job, so ChanFix refuses to touch one. Operator-only.
//!
//! SCORES <#channel> shows the standings; CHANFIX <#channel> performs the fix.
use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store};
// A channel with this many ops isn't opless and needs no fix.
const OP_THRESHOLD: usize = 3;
// The top score must reach this before a channel has enough history to fix.
const MIN_FIX_SCORE: u32 = 12;
// Reop identities scoring at least this fraction of the top score.
const FIX_STEP: f64 = 0.30;
pub struct ChanFix {
pub uid: String,
}
impl Service for ChanFix {
fn nick(&self) -> &str {
"ChanFix"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Channel Fixer"
}
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();
// The whole service is operator-only.
if !from.privs.any() {
ctx.notice(me, from.uid, "Access denied — ChanFix is for services operators.");
return;
}
match args.first().map(|s| s.to_ascii_uppercase()).as_deref() {
Some("SCORES") => scores(me, from, args.get(1).copied(), ctx, net),
Some("CHANFIX") | Some("FIX") => fix(me, from, args.get(1).copied(), ctx, net, db),
Some("HELP") | None => help(me, from, ctx),
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02SCORES\x02 or \x02CHANFIX\x02 <#channel>.")),
}
}
}
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, "ChanFix recovers opless channels from accumulated op-time. \x02SCORES\x02 <#channel> shows the standings; \x02CHANFIX\x02 <#channel> reops its trusted regulars. It won't touch a ChanServ-registered channel.");
}
fn scores(me: &str, from: &Sender, chan: Option<&str>, ctx: &mut ServiceCtx, net: &dyn NetView) {
let Some(chan) = chan else {
ctx.notice(me, from.uid, "Syntax: SCORES <#channel>");
return;
};
let scores = net.chanfix_scores(chan);
if scores.is_empty() {
ctx.notice(me, from.uid, format!("No op-time recorded for \x02{chan}\x02 yet."));
return;
}
for (id, score) in scores.iter().take(15) {
ctx.notice(me, from.uid, format!(" \x02{score}\x02 {id}"));
}
ctx.notice(me, from.uid, format!("Top {} of {} scored identit{} for \x02{chan}\x02.", scores.len().min(15), scores.len(), if scores.len() == 1 { "y" } else { "ies" }));
}
fn fix(me: &str, from: &Sender, chan: Option<&str>, ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
let Some(chan) = chan else {
ctx.notice(me, from.uid, "Syntax: CHANFIX <#channel>");
return;
};
// Defer to ChanServ for anything it owns.
if db.channel(chan).is_some() {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 is registered — use \x02ChanServ\x02 to manage it."));
return;
}
if net.op_count(chan) >= OP_THRESHOLD {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 already has ops — nothing to fix."));
return;
}
let scores = net.chanfix_scores(chan);
let highscore = scores.first().map(|(_, s)| *s).unwrap_or(0);
if highscore < MIN_FIX_SCORE {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 doesn't have enough op-time history to fix yet."));
return;
}
let threshold = ((highscore as f64 * FIX_STEP) as u32).max(1);
// Reop present members whose identity scores at or above the threshold and who
// aren't already opped.
let members: Vec<String> = net.channel_members(chan);
let mut opped = 0;
for uid in &members {
if net.is_op(chan, uid) {
continue;
}
let id = net.account_of(uid).or_else(|| net.host_of(uid));
let qualifies = id.is_some_and(|i| scores.iter().any(|(sid, s)| sid.eq_ignore_ascii_case(i) && *s >= threshold));
if qualifies {
ctx.channel_mode(me, chan, &format!("+o {uid}"));
opped += 1;
}
}
if opped == 0 {
ctx.notice(me, from.uid, format!("None of \x02{chan}\x02's trusted regulars are here right now."));
} else {
ctx.notice(me, from.uid, format!("Reopped \x02{opped}\x02 trusted regular(s) in \x02{chan}\x02."));
}
}

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

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

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

View file

@ -0,0 +1,25 @@
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;
};
if super::peace_blocks(me, from, chan, &target, ctx, net, db) {
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);
}

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."));
}

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."));
}

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

View file

@ -0,0 +1,80 @@
use fedserv_api::{apply_flags, Sender, ServiceCtx, Store, ACCESS_FLAGS};
// FLAGS <#channel> [account [+/-flags]]: the granular access model. With no
// account, list the access entries and their flags; with an account, show or
// change its flags. Letters: f full o auto-op O op h auto-halfop v auto-voice
// t topic i invite a access-list s settings g greet. Viewing needs op access;
// changing needs the founder or the \x02a\x02 flag.
pub fn handle(me: &str, from: &Sender, chan: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(info) = db.channel(chan) else {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
return;
};
let is_founder = from.account == Some(info.founder.as_str());
let caps = info.caps_of(from.account);
// FLAGS <#chan> — list.
let Some(&target) = args.get(2) else {
if !is_founder && !caps.op {
ctx.notice(me, from.uid, format!("You need access to \x02{chan}\x02 to view its flags."));
return;
}
ctx.notice(me, from.uid, format!("Access flags for \x02{chan}\x02:"));
ctx.notice(me, from.uid, format!(" \x02{}\x02 (founder): \x02f\x02", info.founder));
for a in &info.access {
ctx.notice(me, from.uid, format!(" \x02{}\x02: \x02{}\x02", a.account, display_flags(&a.level)));
}
ctx.notice(me, from.uid, format!("End of flags ({} entr{}).", info.access.len() + 1, if info.access.is_empty() { "y" } else { "ies" }));
return;
};
let current = info.access.iter().find(|a| a.account.eq_ignore_ascii_case(target)).map(|a| display_flags(&a.level));
// FLAGS <#chan> <account> — show one.
let Some(&delta) = args.get(3) else {
match current {
Some(f) if !f.is_empty() => ctx.notice(me, from.uid, format!("\x02{target}\x02 on \x02{chan}\x02: \x02{f}\x02")),
_ => ctx.notice(me, from.uid, format!("\x02{target}\x02 has no access to \x02{chan}\x02.")),
}
return;
};
// FLAGS <#chan> <account> <+/-flags> — modify.
if !is_founder && !caps.access {
ctx.notice(me, from.uid, format!("You need the founder or the \x02a\x02 flag to change access on \x02{chan}\x02."));
return;
}
if info.founder.eq_ignore_ascii_case(target) {
ctx.notice(me, from.uid, "The founder's access is set with \x02SET FOUNDER\x02, not flags.");
return;
}
let base = current.unwrap_or_default();
let updated = match apply_flags(&base, delta, ACCESS_FLAGS) {
Ok(f) => f,
Err(bad) => {
ctx.notice(me, from.uid, format!("\x02{bad}\x02 isn't a valid flag. Valid flags: \x02{ACCESS_FLAGS}\x02."));
return;
}
};
if updated.is_empty() {
match db.access_del(chan, target) {
Ok(true) => ctx.notice(me, from.uid, format!("Cleared \x02{target}\x02's access to \x02{chan}\x02.")),
_ => ctx.notice(me, from.uid, format!("\x02{target}\x02 had no access to \x02{chan}\x02.")),
}
return;
}
match db.access_add(chan, target, &updated) {
Ok(()) => ctx.notice(me, from.uid, format!("\x02{target}\x02 on \x02{chan}\x02 now holds \x02{updated}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
// Show a stored level as flag letters (mapping the legacy presets).
fn display_flags(level: &str) -> String {
match level {
"op" => "oti".to_string(),
"voice" => "v".to_string(),
"founder" => "f".to_string(),
flags => flags.to_string(),
}
}

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

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."));
}

View file

@ -0,0 +1,27 @@
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;
};
if super::peace_blocks(me, from, chan, target, ctx, net, db) {
return;
}
let mut reason = if args.len() > 3 { args[3..].join(" ") } else { "Kicked".to_string() };
// SIGNKICK: attribute the kick to whoever asked for it.
if db.channel(chan).is_some_and(|c| c.signkick) {
reason = format!("{reason} (requested by {})", from.nick);
}
ctx.kick(me, chan, target, &reason);
}

344
modules/chanserv/src/lib.rs Normal file
View file

@ -0,0 +1,344 @@
use fedserv_api::{ChanError, ChannelView, Priv, Store};
use fedserv_api::{Sender, Service, ServiceCtx};
use fedserv_api::NetView;
#[path = "mode.rs"]
mod mode;
#[path = "access.rs"]
mod access;
mod flags;
#[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;
#[path = "suspend.rs"]
mod suspend;
mod noexpire;
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;
}
if db.channel_regs_frozen() {
ctx.notice(me, from.uid, "Channel registrations are temporarily frozen by network staff. Please try again later.");
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.count("chanserv.register");
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)));
if let Some(s) = db.channel_suspension(chan) {
ctx.notice(me, from.uid, format!(" Suspended : by \x02{}\x02{}", s.by, s.reason));
}
let mut opts = Vec::new();
if info.signkick { opts.push("SIGNKICK"); }
if info.private { opts.push("PRIVATE"); }
if info.peace { opts.push("PEACE"); }
if info.secureops { opts.push("SECUREOPS"); }
if info.keeptopic { opts.push("KEEPTOPIC"); }
if info.topiclock { opts.push("TOPICLOCK"); }
if !opts.is_empty() {
ctx.notice(me, from.uid, format!(" Options : {}", opts.join(", ")));
}
// A staff note is shown to operators only.
if from.privs.has(Priv::Auspex) {
if let Some(note) = db.channel_note(chan) {
ctx.notice(me, from.uid, format!(" Staff note : {note}"));
}
}
}
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;
}
if suspended_block(me, from, chan, ctx, db) {
return;
}
match db.drop_channel(chan) {
Ok(()) => {
ctx.channel_mode(me, chan, "-r"); // no longer registered
ctx.count("chanserv.drop");
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;
}
if suspended_block(me, from, chan, ctx, db) {
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("FLAGS") => {
let Some(&chan) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: FLAGS <#channel> [account [+/-flags]]");
return;
};
flags::handle(me, from, chan, 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("SUSPEND") => suspend::handle(me, from, args, ctx, net, db, true),
Some("UNSUSPEND") => suspend::handle(me, from, args, ctx, net, db, false),
Some("NOEXPIRE") => noexpire::handle(me, from, args, ctx, 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, \x02FLAGS\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. Operators also have \x02SUSPEND\x02/\x02UNSUSPEND\x02 and \x02NOEXPIRE\x02 <#channel> {ON|OFF}."),
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 {
if suspended_block(me, from, chan, ctx, db) {
return false;
}
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 {
if suspended_block(me, from, chan, ctx, db) {
return false;
}
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
}
}
}
// A suspended channel is frozen: ChanServ won't manage it (returns true + notices).
fn suspended_block(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &dyn Store) -> bool {
if db.is_channel_suspended(chan) {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 is suspended by network staff and can't be managed right now."));
return true;
}
false
}
// PEACE: returns true (and notices) if `from` may not act against `target_uid`
// because the channel has PEACE set and the target holds equal-or-higher access.
fn peace_blocks(me: &str, from: &Sender, chan: &str, target_uid: &str, ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) -> bool {
let Some(info) = db.channel(chan) else { return false };
if !info.peace {
return false;
}
if info.access_rank(net.account_of(target_uid)) >= info.access_rank(from.account) {
ctx.notice(me, from.uid, "\x02PEACE\x02 is set: you can't act against someone with equal or higher access.");
return true;
}
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
}

View file

@ -0,0 +1,17 @@
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) {
// PRIVATE channels are hidden from LIST.
let mut names: Vec<String> = db.channels().into_iter().filter(|c| !c.private).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"));
}
}

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."));
}

View file

@ -0,0 +1,32 @@
use fedserv_api::{Priv, Sender, ServiceCtx, Store};
// NOEXPIRE <#channel> {ON|OFF}: pin a channel so inactivity-expiry never drops
// it (or lift the pin). Oper-only (Priv::Admin).
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — that command is for services operators.");
return;
}
let (Some(&chan), Some(on)) = (args.get(1), args.get(2).and_then(|s| parse_toggle(s))) else {
ctx.notice(me, from.uid, "Syntax: NOEXPIRE <#channel> {ON|OFF}");
return;
};
if db.channel(chan).is_none() {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
return;
}
match db.set_channel_noexpire(chan, on) {
Ok(true) if on => ctx.notice(me, from.uid, format!("\x02{chan}\x02 will no longer expire.")),
Ok(true) => ctx.notice(me, from.uid, format!("\x02{chan}\x02 can expire from inactivity again.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{chan}\x02 was already set that way.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn parse_toggle(s: &str) -> Option<bool> {
match s.to_ascii_uppercase().as_str() {
"ON" | "TRUE" | "YES" => Some(true),
"OFF" | "FALSE" | "NO" => Some(false),
_ => None,
}
}

View file

@ -0,0 +1,30 @@
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(),
};
// PEACE only guards removing status (-o/-v) from an equal-or-higher user.
if mode.starts_with('-') && super::peace_blocks(me, from, chan, &target, ctx, net, db) {
return;
}
ctx.channel_mode(me, chan, &format!("{mode} {target}"));
}

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

View file

@ -0,0 +1,84 @@
use fedserv_api::{ChanSetting, Sender, ServiceCtx, Store};
// 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;
}
if super::suspended_block(me, from, chan, ctx, db) {
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."),
}
}
Some("SIGNKICK") => toggle(me, from, ctx, db, chan, ChanSetting::SignKick, args.get(3).copied()),
Some("PRIVATE") => toggle(me, from, ctx, db, chan, ChanSetting::Private, args.get(3).copied()),
Some("PEACE") => toggle(me, from, ctx, db, chan, ChanSetting::Peace, args.get(3).copied()),
Some("SECUREOPS") => toggle(me, from, ctx, db, chan, ChanSetting::SecureOps, args.get(3).copied()),
Some("KEEPTOPIC") => toggle(me, from, ctx, db, chan, ChanSetting::KeepTopic, args.get(3).copied()),
Some("TOPICLOCK") => toggle(me, from, ctx, db, chan, ChanSetting::TopicLock, args.get(3).copied()),
_ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER <account> | DESC <text> | SIGNKICK {ON|OFF} | PRIVATE {ON|OFF} | PEACE {ON|OFF} | SECUREOPS {ON|OFF} | KEEPTOPIC {ON|OFF} | TOPICLOCK {ON|OFF}"),
}
}
// The SET keyword for a channel option, used in its messages.
fn label(setting: ChanSetting) -> &'static str {
match setting {
ChanSetting::SignKick => "SIGNKICK",
ChanSetting::Private => "PRIVATE",
ChanSetting::Peace => "PEACE",
ChanSetting::SecureOps => "SECUREOPS",
ChanSetting::KeepTopic => "KEEPTOPIC",
ChanSetting::TopicLock => "TOPICLOCK",
ChanSetting::BotGreet => "GREET",
ChanSetting::NoBot => "NOBOT",
}
}
// Flip one on/off channel option, reporting the new state.
fn toggle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store, chan: &str, setting: ChanSetting, arg: Option<&str>) {
let name = label(setting);
let on = match arg.map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ON") => true,
Some("OFF") => false,
_ => {
ctx.notice(me, from.uid, format!("Syntax: SET <#channel> {name} {{ON|OFF}}"));
return;
}
};
match db.set_channel_setting(chan, setting, on) {
Ok(()) => ctx.notice(me, from.uid, format!("\x02{name}\x02 for \x02{chan}\x02 is now \x02{}\x02.", if on { "on" } else { "off" })),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

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

View file

@ -0,0 +1,53 @@
use fedserv_api::{parse_duration, NetView, Priv, Sender, ServiceCtx, Store};
use std::time::{SystemTime, UNIX_EPOCH};
// SUSPEND <#channel> [+expiry] [reason] / UNSUSPEND <#channel>: freeze or unfreeze
// a channel. Its data is kept but it can't be managed while suspended. Oper-only
// (Priv::Suspend). `suspending` selects SUSPEND vs UNSUSPEND.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store, suspending: bool) {
if !from.privs.has(Priv::Suspend) {
ctx.notice(me, from.uid, "Access denied — that command is for services operators.");
return;
}
let Some(&chan) = args.get(1) else {
let syntax = if suspending { "Syntax: SUSPEND <#channel> [+expiry] [reason]" } else { "Syntax: UNSUSPEND <#channel>" };
ctx.notice(me, from.uid, syntax);
return;
};
if db.channel(chan).is_none() {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
return;
}
if !suspending {
match db.unsuspend_channel(chan) {
Ok(true) => ctx.notice(me, from.uid, format!("\x02{chan}\x02 is no longer suspended.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't suspended.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
return;
}
let mut rest = &args[2..];
let expires = rest.first().and_then(|t| t.strip_prefix('+')).and_then(parse_duration).map(|secs| now_unix() + secs);
if expires.is_some() {
rest = &rest[1..];
}
let reason = if rest.is_empty() { "This channel has been suspended.".to_string() } else { rest.join(" ") };
let by = from.account.unwrap_or(from.nick);
match db.suspend_channel(chan, by, &reason, expires) {
Ok(()) => {
// Clear the channel: kick everyone currently in it.
for uid in net.channel_members(chan) {
ctx.kick(me, chan, &uid, &reason);
}
let expiry = if expires.is_some() { " (with expiry)" } else { "" };
ctx.notice(me, from.uid, format!("\x02{chan}\x02 is now suspended{expiry}."));
}
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn now_unix() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}

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."));
}

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."));
}

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

View file

@ -0,0 +1,9 @@
[package]
name = "fedserv-diceserv"
version = "0.0.1"
edition = "2021"
description = "DiceServ: a dice-roll and math-expression service for tabletop games over IRC."
[dependencies]
fedserv-api = { path = "../../api" }
rand = "0.8"

View file

@ -0,0 +1,348 @@
//! A dice-and-math expression evaluator: tokenise, parse (recursive descent),
//! then evaluate — rolling dice as it goes and recording each roll for the
//! extended output. The reference ships its own Mersenne-Twister in C and a
//! shunting-yard parser with a variadic-argument hack; this is a plain typed AST
//! + the OS/`rand` generator, which is shorter and far easier to reason about.
use rand::Rng;
// Guardrails so a single expression can't ask us to roll forever.
const MAX_DICE: u64 = 99_999; // dice in one NdM roll
const MAX_SIDES: u64 = 99_999; // sides on a die
const MAX_TOTAL: u64 = 1_000_000; // dice rolled across the whole expression
// One resolved dice roll, kept for the extended (EX) output.
pub struct Roll {
pub spec: String, // e.g. "2d6"
pub values: Vec<u64>, // each die's face
pub total: u64,
}
pub struct Evaluated {
pub value: f64,
pub rolls: Vec<Roll>,
}
// ---- tokens ----------------------------------------------------------------
#[derive(Debug, Clone, PartialEq)]
enum Tok {
Num(f64),
Ident(String), // includes the dice operator "d"/"D", constants, function names
Op(char), // + - * / % ^
LParen,
RParen,
Comma,
}
fn tokenize(input: &str) -> Result<Vec<Tok>, String> {
let chars: Vec<char> = input.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c.is_whitespace() {
i += 1;
} else if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|d| d.is_ascii_digit())) {
let start = i;
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
i += 1;
}
let s: String = chars[start..i].iter().collect();
out.push(Tok::Num(s.parse().map_err(|_| format!("bad number '{s}'"))?));
} else if c.is_ascii_alphabetic() {
let start = i;
while i < chars.len() && chars[i].is_ascii_alphabetic() {
i += 1;
}
out.push(Tok::Ident(chars[start..i].iter().collect()));
} else {
match c {
'+' | '-' | '*' | '/' | '%' | '^' => out.push(Tok::Op(c)),
'(' | '[' | '{' => out.push(Tok::LParen),
')' | ']' | '}' => out.push(Tok::RParen),
',' => out.push(Tok::Comma),
_ => return Err(format!("unexpected character '{c}'")),
}
i += 1;
}
}
Ok(out)
}
// ---- AST -------------------------------------------------------------------
enum Ast {
Num(f64),
Neg(Box<Ast>),
Bin(char, Box<Ast>, Box<Ast>),
Dice(Box<Ast>, Box<Ast>),
Func(String, Vec<Ast>),
}
struct Parser {
toks: Vec<Tok>,
pos: usize,
}
impl Parser {
fn peek(&self) -> Option<&Tok> {
self.toks.get(self.pos)
}
fn is_dice(&self) -> bool {
matches!(self.peek(), Some(Tok::Ident(s)) if s.eq_ignore_ascii_case("d"))
}
fn parse(mut self) -> Result<Ast, String> {
let e = self.add()?;
if self.pos != self.toks.len() {
return Err("trailing characters in expression".to_string());
}
Ok(e)
}
fn add(&mut self) -> Result<Ast, String> {
let mut left = self.mul()?;
while let Some(Tok::Op(c @ ('+' | '-'))) = self.peek() {
let c = *c;
self.pos += 1;
left = Ast::Bin(c, Box::new(left), Box::new(self.mul()?));
}
Ok(left)
}
fn mul(&mut self) -> Result<Ast, String> {
let mut left = self.unary()?;
loop {
if let Some(Tok::Op(c @ ('*' | '/' | '%'))) = self.peek() {
let c = *c;
self.pos += 1;
left = Ast::Bin(c, Box::new(left), Box::new(self.unary()?));
} else if self.starts_atom() && !self.is_dice() {
// Implicit multiplication: 2pi, 3(4), 2d6 2.
left = Ast::Bin('*', Box::new(left), Box::new(self.unary()?));
} else {
return Ok(left);
}
}
}
// Whether the next token can begin an atom (for implicit multiplication).
fn starts_atom(&self) -> bool {
matches!(self.peek(), Some(Tok::Num(_) | Tok::Ident(_) | Tok::LParen))
}
fn unary(&mut self) -> Result<Ast, String> {
match self.peek() {
Some(Tok::Op('-')) => {
self.pos += 1;
Ok(Ast::Neg(Box::new(self.unary()?)))
}
Some(Tok::Op('+')) => {
self.pos += 1;
self.unary()
}
_ => self.pow(),
}
}
fn pow(&mut self) -> Result<Ast, String> {
let base = self.dice()?;
if matches!(self.peek(), Some(Tok::Op('^'))) {
self.pos += 1;
// Right-associative, and the exponent may be signed (2^-3).
return Ok(Ast::Bin('^', Box::new(base), Box::new(self.unary()?)));
}
Ok(base)
}
fn dice(&mut self) -> Result<Ast, String> {
// A leading `d` means one die: d6 == 1d6.
let mut left = if self.is_dice() { Ast::Num(1.0) } else { self.atom()? };
while self.is_dice() {
self.pos += 1;
// `d%` is percentile — 100 sides.
let sides = if matches!(self.peek(), Some(Tok::Op('%'))) {
self.pos += 1;
Ast::Num(100.0)
} else {
self.atom()?
};
left = Ast::Dice(Box::new(left), Box::new(sides));
}
Ok(left)
}
fn atom(&mut self) -> Result<Ast, String> {
match self.peek().cloned() {
Some(Tok::Num(n)) => {
self.pos += 1;
Ok(Ast::Num(n))
}
Some(Tok::LParen) => {
self.pos += 1;
let e = self.add()?;
if !matches!(self.peek(), Some(Tok::RParen)) {
return Err("missing closing parenthesis".to_string());
}
self.pos += 1;
Ok(e)
}
Some(Tok::Ident(name)) => {
let lname = name.to_ascii_lowercase();
self.pos += 1;
match lname.as_str() {
"e" => Ok(Ast::Num(std::f64::consts::E)),
"pi" => Ok(Ast::Num(std::f64::consts::PI)),
_ if matches!(self.peek(), Some(Tok::LParen)) => {
self.pos += 1;
let mut args = vec![self.add()?];
while matches!(self.peek(), Some(Tok::Comma)) {
self.pos += 1;
args.push(self.add()?);
}
if !matches!(self.peek(), Some(Tok::RParen)) {
return Err(format!("missing ) after {lname}(...)"));
}
self.pos += 1;
Ok(Ast::Func(lname, args))
}
_ => Err(format!("unknown name '{name}'")),
}
}
_ => Err("expected a value".to_string()),
}
}
}
// ---- evaluation ------------------------------------------------------------
// Evaluate a single expression (no repeat prefix), rolling dice via `rng`.
pub fn evaluate(input: &str, rng: &mut impl Rng) -> Result<Evaluated, String> {
let toks = tokenize(input)?;
if toks.is_empty() {
return Err("nothing to roll".to_string());
}
let ast = Parser { toks, pos: 0 }.parse()?;
let mut rolls = Vec::new();
let mut total_dice = 0u64;
let value = eval(&ast, rng, &mut rolls, &mut total_dice)?;
if !value.is_finite() {
return Err("that doesn't come out to a real number".to_string());
}
Ok(Evaluated { value, rolls })
}
fn eval(ast: &Ast, rng: &mut impl Rng, rolls: &mut Vec<Roll>, total: &mut u64) -> Result<f64, String> {
match ast {
Ast::Num(n) => Ok(*n),
Ast::Neg(a) => Ok(-eval(a, rng, rolls, total)?),
Ast::Bin(op, a, b) => {
let (x, y) = (eval(a, rng, rolls, total)?, eval(b, rng, rolls, total)?);
Ok(match op {
'+' => x + y,
'-' => x - y,
'*' => x * y,
'/' => {
if y == 0.0 {
return Err("division by zero".to_string());
}
x / y
}
'%' => {
if y == 0.0 {
return Err("modulo by zero".to_string());
}
x % y
}
'^' => x.powf(y),
_ => unreachable!(),
})
}
Ast::Dice(n, m) => {
let num = round_pos(eval(n, rng, rolls, total)?, "number of dice")?;
let sides = round_pos(eval(m, rng, rolls, total)?, "sides")?;
if num > MAX_DICE {
return Err(format!("that's more than {MAX_DICE} dice"));
}
if sides > MAX_SIDES {
return Err(format!("a die can't have more than {MAX_SIDES} sides"));
}
*total += num;
if *total > MAX_TOTAL {
return Err("that expression rolls too many dice".to_string());
}
let mut values = Vec::with_capacity(num as usize);
let mut sum = 0u64;
for _ in 0..num {
let face = rng.gen_range(1..=sides);
values.push(face);
sum += face;
}
rolls.push(Roll { spec: format!("{num}d{sides}"), values, total: sum });
Ok(sum as f64)
}
Ast::Func(name, args) => call(name, args, rng, rolls, total),
}
}
// Round a value to a positive integer count (dice / sides must be >= 1).
fn round_pos(v: f64, what: &str) -> Result<u64, String> {
if !v.is_finite() {
return Err(format!("the {what} isn't a number"));
}
let n = v.round();
if n < 1.0 {
return Err(format!("the {what} must be at least 1"));
}
Ok(n as u64)
}
fn call(name: &str, args: &[Ast], rng: &mut impl Rng, rolls: &mut Vec<Roll>, total: &mut u64) -> Result<f64, String> {
let mut v = Vec::with_capacity(args.len());
for a in args {
v.push(eval(a, rng, rolls, total)?);
}
let one = |v: &[f64]| -> Result<f64, String> {
match v {
[x] => Ok(*x),
_ => Err(format!("{name}() takes one argument")),
}
};
Ok(match name {
"abs" => one(&v)?.abs(),
"ceil" => one(&v)?.ceil(),
"floor" => one(&v)?.floor(),
"round" => one(&v)?.round(),
"trunc" | "int" => one(&v)?.trunc(),
"sqrt" => one(&v)?.sqrt(),
"cbrt" => one(&v)?.cbrt(),
"exp" => one(&v)?.exp(),
"ln" | "log" => one(&v)?.ln(),
"log10" => one(&v)?.log10(),
"log2" => one(&v)?.log2(),
"sin" => one(&v)?.sin(),
"cos" => one(&v)?.cos(),
"tan" => one(&v)?.tan(),
"asin" => one(&v)?.asin(),
"acos" => one(&v)?.acos(),
"atan" => one(&v)?.atan(),
"sinh" => one(&v)?.sinh(),
"cosh" => one(&v)?.cosh(),
"tanh" => one(&v)?.tanh(),
"deg" => one(&v)?.to_degrees(),
"rad" => one(&v)?.to_radians(),
"sign" => one(&v)?.signum(),
"min" if !v.is_empty() => v.into_iter().fold(f64::INFINITY, f64::min),
"max" if !v.is_empty() => v.into_iter().fold(f64::NEG_INFINITY, f64::max),
"hypot" => match v.as_slice() {
[a, b] => a.hypot(*b),
_ => return Err("hypot() takes two arguments".to_string()),
},
"pow" => match v.as_slice() {
[a, b] => a.powf(*b),
_ => return Err("pow() takes two arguments".to_string()),
},
_ => return Err(format!("unknown function '{name}'")),
})
}

154
modules/diceserv/src/lib.rs Normal file
View file

@ -0,0 +1,154 @@
//! DiceServ rolls dice and evaluates math expressions for tabletop games over
//! IRC. Stateless — a command parses an expression, rolls, and replies. ROLL/
//! CALC give the result (ROLL rounds to a whole number, CALC keeps decimals);
//! the EX variants also show each die. `N~expr` repeats a roll N times.
use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store};
#[path = "expr.rs"]
mod expr;
const MAX_REPEATS: u32 = 25;
pub struct DiceServ {
pub uid: String,
}
impl Service for DiceServ {
fn nick(&self) -> &str {
"DiceServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Dice Roller"
}
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("ROLL") => roll(me, from, &args[1..], ctx, false, true),
Some("EXROLL") => roll(me, from, &args[1..], ctx, true, true),
Some("CALC") => roll(me, from, &args[1..], ctx, false, false),
Some("EXCALC") => roll(me, from, &args[1..], ctx, true, false),
Some("HELP") | None => help(me, from, ctx),
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02ROLL\x02, \x02CALC\x02, or \x02HELP\x02.")),
}
}
}
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, "DiceServ rolls dice and does maths. \x02ROLL\x02 <expr> (whole-number result), \x02CALC\x02 <expr> (keeps decimals), \x02EXROLL\x02/\x02EXCALC\x02 also show each die. Dice: \x022d6\x02, \x02d20\x02, \x02d%\x02. Also + - * / ^ %, parentheses, functions (sqrt, floor, min, …), \x02pi\x02/\x02e\x02, and \x023~2d6\x02 to repeat.");
}
fn roll(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, extended: bool, round_result: bool) {
let input = rest.join(" ");
let input = input.trim();
if input.is_empty() {
ctx.notice(me, from.uid, "Give me something to roll, e.g. \x022d6+3\x02.");
return;
}
// `N~expr` repeats the roll N times.
let (times, body) = match input.split_once('~') {
Some((n, body)) => match n.trim().parse::<u32>() {
Ok(t) if (1..=MAX_REPEATS).contains(&t) => (t, body.trim()),
_ => {
ctx.notice(me, from.uid, format!("The repeat count before \x02~\x02 must be a number from 1 to {MAX_REPEATS}."));
return;
}
},
None => (1, input),
};
let mut rng = rand::thread_rng();
for i in 0..times {
match expr::evaluate(body, &mut rng) {
Ok(ev) => {
let result = format_value(ev.value, round_result);
let prefix = if times > 1 { format!("[{}/{}] ", i + 1, times) } else { String::new() };
let detail = if extended && !ev.rolls.is_empty() {
format!("{}", ev.rolls.iter().map(render_roll).collect::<Vec<_>>().join(", "))
} else {
String::new()
};
ctx.notice(me, from.uid, format!("{prefix}\x02{body}\x02 = \x02{result}\x02{detail}"));
}
Err(why) => {
ctx.notice(me, from.uid, format!("I couldn't roll \x02{body}\x02: {why}."));
return; // the whole batch shares the expression, so it'll fail the same way
}
}
}
}
// "2d6: 4+5=9" for the extended output.
fn render_roll(r: &expr::Roll) -> String {
if r.values.len() == 1 {
format!("{}: {}", r.spec, r.total)
} else {
let each = r.values.iter().map(u64::to_string).collect::<Vec<_>>().join("+");
format!("{}: {}={}", r.spec, each, r.total)
}
}
// ROLL rounds to a whole number; CALC keeps up to four decimals, trimmed.
fn format_value(v: f64, round_result: bool) -> String {
if round_result {
return format!("{}", v.round() as i64);
}
if v.fract() == 0.0 && v.abs() < 1e15 {
return format!("{}", v as i64);
}
let s = format!("{v:.4}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
#[cfg(test)]
mod tests {
use super::expr::evaluate;
use rand::rngs::StdRng;
use rand::SeedableRng;
fn eval(s: &str) -> f64 {
let mut rng = StdRng::seed_from_u64(1);
evaluate(s, &mut rng).unwrap().value
}
#[test]
fn arithmetic_and_precedence() {
assert_eq!(eval("2+3*4"), 14.0);
assert_eq!(eval("(2+3)*4"), 20.0);
assert_eq!(eval("2^3^2"), 512.0); // right-associative
assert_eq!(eval("-2^2"), -4.0); // unary looser than ^
assert_eq!(eval("10%3"), 1.0);
assert_eq!(eval("2pi/pi"), 2.0); // implicit multiplication + constant
assert_eq!(eval("sqrt(16)+floor(3.9)"), 7.0);
assert_eq!(eval("min(5,2,8)"), 2.0);
}
#[test]
fn dice_stay_in_range() {
let mut rng = StdRng::seed_from_u64(42);
for _ in 0..200 {
let ev = evaluate("3d6", &mut rng).unwrap();
assert!((3.0..=18.0).contains(&ev.value), "3d6 out of range: {}", ev.value);
assert_eq!(ev.rolls[0].values.len(), 3);
assert!(ev.rolls[0].values.iter().all(|&f| (1..=6).contains(&f)));
}
// A leading d is one die; percentile is 1..=100.
assert!((1.0..=20.0).contains(&evaluate("d20", &mut rng).unwrap().value));
assert!((1.0..=100.0).contains(&evaluate("d%", &mut rng).unwrap().value));
}
#[test]
fn rejects_bad_input_and_abuse() {
let mut rng = StdRng::seed_from_u64(1);
assert!(evaluate("2d6+", &mut rng).is_err());
assert!(evaluate("1/0", &mut rng).is_err());
assert!(evaluate("999999d6", &mut rng).is_err()); // too many dice
assert!(evaluate("2d999999", &mut rng).is_err()); // too many sides
assert!(evaluate("frobnicate(2)", &mut rng).is_err());
assert!(evaluate("2 @ 3", &mut rng).is_err());
}
}

View file

@ -0,0 +1,8 @@
[package]
name = "fedserv-example"
version = "0.0.1"
edition = "2021"
description = "A minimal example service module: the template a new pseudo-client is copied from."
[dependencies]
fedserv-api = { path = "../../api" }

View file

@ -0,0 +1,70 @@
//! A minimal example service module — the smallest complete `Service`, meant to
//! be copied when writing a new pseudo-client. It depends on nothing but the
//! `fedserv-api` SDK crate, reads through the `Store`/`NetView` traits, and
//! answers users by pushing notices onto the `ServiceCtx`. It never mutates the
//! store, so it is safe to enable anywhere.
//!
//! To run it: add `"example"` to `[modules] services` in the config. To ship a
//! real module, replace the commands below with your own and register the struct
//! in the daemon's `main.rs` (see MODULES.md).
use fedserv_api::{human_time, NetView, Sender, Service, ServiceCtx, Store};
// A service is a plain struct. It is introduced to the network at burst and then
// receives the commands users message it. `uid` is assigned by the daemon.
pub struct ExampleServ {
pub uid: String,
}
impl Service for ExampleServ {
// The nick, uid and gecos the pseudo-client is introduced with.
fn nick(&self) -> &str {
"ExampleServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Example Service (SDK template)"
}
// One message from a user, already split into `args`. `from` is who sent it,
// `net` is a read-only view of the live network, `store` the account/channel
// store. Emit replies by pushing onto `ctx`; the engine sends them.
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, store: &mut dyn Store) {
let me = self.uid.as_str();
match args.first().map(|s| s.to_ascii_uppercase()).as_deref() {
// Reading the sender + the account store. `from.account` is set when
// the user is logged in; `store.account` hands back a view with only
// non-secret fields (never a password hash).
Some("WHOAMI") => match from.account.and_then(|a| store.account(a)) {
Some(acct) => {
let email = acct.email.as_deref().unwrap_or("(none)");
let state = if acct.verified { "verified" } else { "unverified" };
ctx.notice(me, from.uid, format!(
"You are \x02{}\x02, registered {} ({state}), email {email}.",
acct.name, human_time(acct.ts),
));
}
None => ctx.notice(me, from.uid, "You are not logged in to an account."),
},
// Reading the live network view.
Some("LOOKUP") => {
let Some(&nick) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: LOOKUP <nick>");
return;
};
match net.uid_by_nick(nick) {
Some(uid) => {
let host = net.host_of(uid).unwrap_or("*");
let account = net.account_of(uid).unwrap_or("(not logged in)");
ctx.notice(me, from.uid, format!("\x02{nick}\x02 is online — host {host}, account {account}."));
}
None => ctx.notice(me, from.uid, format!("\x02{nick}\x02 is not online.")),
}
}
Some("HELP") | None => ctx.notice(me, from.uid, "Commands: \x02WHOAMI\x02, \x02LOOKUP\x02 <nick>."),
Some(other) => ctx.notice(me, from.uid, format!("Unknown command \x02{other}\x02. Try \x02HELP\x02.")),
}
}
}

View file

@ -0,0 +1,8 @@
[package]
name = "fedserv-groupserv"
version = "0.0.1"
edition = "2021"
description = "GroupServ: user groups (!name) whose members can be granted channel access together."
[dependencies]
fedserv-api = { path = "../../api" }

View file

@ -0,0 +1,226 @@
//! GroupServ manages user groups — a `!name` owning a set of member accounts.
//! A group's real purpose is the interconnection: a channel can grant access to
//! a `!group` (via ChanServ FLAGS/ACCESS), and every member then inherits that
//! channel access. Members carry group-access flags (the same flag primitive
//! ChanServ uses): F founder, f manage members, i invite, c channel-access,
//! s set, m memo. Managing members needs the founder or the `f` flag.
use fedserv_api::{apply_flags, NetView, Sender, Service, ServiceCtx, Store, GROUP_FLAGS};
pub struct GroupServ {
pub uid: String,
}
impl Service for GroupServ {
fn nick(&self) -> &str {
"GroupServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Group Service"
}
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") => register(me, from, args.get(1).copied(), ctx, db),
Some("DROP") => drop(me, from, args.get(1).copied(), ctx, db),
Some("INFO") => info(me, from, args.get(1).copied(), ctx, db),
Some("LIST") => list(me, from, ctx, db),
Some("ADD") => add(me, from, args.get(1).copied(), args.get(2).copied(), ctx, db),
Some("DEL") => del(me, from, args.get(1).copied(), args.get(2).copied(), ctx, db),
Some("FLAGS") => flags(me, from, args.get(1).copied(), args.get(2).copied(), args.get(3).copied(), ctx, db),
Some("HELP") | None => help(me, from, ctx),
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02HELP\x02.")),
}
}
}
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, "GroupServ manages user groups. \x02REGISTER\x02 <!group>, \x02DROP\x02 <!group>, \x02INFO\x02 <!group>, \x02LIST\x02, \x02ADD\x02/\x02DEL\x02 <!group> <account>, \x02FLAGS\x02 <!group> [account [+/-flags]]. Grant a group channel access with ChanServ \x02FLAGS #chan !group +o\x02 — every member then inherits it.");
}
// The caller must be logged in; returns their account.
fn account<'a>(me: &str, from: &'a Sender, ctx: &mut ServiceCtx) -> Option<&'a str> {
match from.account {
Some(a) => Some(a),
None => {
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
None
}
}
}
// Whether `who` may manage `group` (founder, or holds the F/f flag).
fn can_manage(group: &fedserv_api::GroupView, who: &str) -> bool {
group.founder.eq_ignore_ascii_case(who)
|| group.members.iter().any(|m| m.account.eq_ignore_ascii_case(who) && (m.flags.contains('F') || m.flags.contains('f')))
}
fn register(me: &str, from: &Sender, name: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(acc) = account(me, from, ctx) else { return };
let Some(name) = name else {
ctx.notice(me, from.uid, "Syntax: REGISTER <!group>");
return;
};
if !name.starts_with('!') || name.len() < 2 {
ctx.notice(me, from.uid, "A group name starts with \x02!\x02, e.g. \x02!staff\x02.");
return;
}
let acc = acc.to_string();
match db.group_register(name, &acc) {
Ok(()) => ctx.notice(me, from.uid, format!("Group \x02{name}\x02 registered — you're the founder.")),
Err(fedserv_api::ChanError::Exists) => ctx.notice(me, from.uid, format!("\x02{name}\x02 is already registered.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn drop(me: &str, from: &Sender, name: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(acc) = account(me, from, ctx) else { return };
let Some(name) = name else {
ctx.notice(me, from.uid, "Syntax: DROP <!group>");
return;
};
let Some(g) = db.group(name) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
if !g.founder.eq_ignore_ascii_case(acc) {
ctx.notice(me, from.uid, format!("Only \x02{name}\x02's founder can drop it."));
return;
}
match db.group_drop(name) {
Ok(()) => ctx.notice(me, from.uid, format!("Group \x02{name}\x02 has been dropped.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn info(me: &str, from: &Sender, name: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(name) = name else {
ctx.notice(me, from.uid, "Syntax: INFO <!group>");
return;
};
let Some(g) = db.group(name) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
ctx.notice(me, from.uid, format!("Information for \x02{}\x02:", g.name));
ctx.notice(me, from.uid, format!(" Founder : \x02{}\x02", g.founder));
ctx.notice(me, from.uid, format!(" Members : {}", g.members.len()));
}
fn list(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) {
// Operators see every group; others see the ones they belong to.
let names = if from.privs.any() {
db.groups()
} else if let Some(acc) = from.account {
db.groups_of(acc)
} else {
Vec::new()
};
if names.is_empty() {
ctx.notice(me, from.uid, "No groups to show.");
return;
}
for n in &names {
ctx.notice(me, from.uid, format!(" \x02{n}\x02"));
}
ctx.notice(me, from.uid, format!("End of list ({} group(s)).", names.len()));
}
fn add(me: &str, from: &Sender, name: Option<&str>, target: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(acc) = account(me, from, ctx) else { return };
let (Some(name), Some(target)) = (name, target) else {
ctx.notice(me, from.uid, "Syntax: ADD <!group> <account>");
return;
};
let Some(g) = db.group(name) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
if !can_manage(&g, acc) {
ctx.notice(me, from.uid, format!("You need the founder or the \x02f\x02 flag to manage \x02{name}\x02."));
return;
}
let Some(canonical) = db.resolve_account(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
return;
};
match db.group_set_flags(name, &canonical, "") {
Ok(()) => ctx.notice(me, from.uid, format!("Added \x02{canonical}\x02 to \x02{name}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn del(me: &str, from: &Sender, name: Option<&str>, target: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(acc) = account(me, from, ctx) else { return };
let (Some(name), Some(target)) = (name, target) else {
ctx.notice(me, from.uid, "Syntax: DEL <!group> <account>");
return;
};
let Some(g) = db.group(name) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
if !can_manage(&g, acc) {
ctx.notice(me, from.uid, format!("You need the founder or the \x02f\x02 flag to manage \x02{name}\x02."));
return;
}
match db.group_del_member(name, target) {
Ok(true) => ctx.notice(me, from.uid, format!("Removed \x02{target}\x02 from \x02{name}\x02.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't in \x02{name}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn flags(me: &str, from: &Sender, name: Option<&str>, target: Option<&str>, delta: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(acc) = account(me, from, ctx) else { return };
let Some(name) = name else {
ctx.notice(me, from.uid, "Syntax: FLAGS <!group> [account [+/-flags]]");
return;
};
let Some(g) = db.group(name) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
// List.
let Some(target) = target else {
ctx.notice(me, from.uid, format!("Flags for \x02{}\x02:", g.name));
ctx.notice(me, from.uid, format!(" \x02{}\x02 (founder): \x02F\x02", g.founder));
for m in &g.members {
ctx.notice(me, from.uid, format!(" \x02{}\x02: \x02{}\x02", m.account, if m.flags.is_empty() { "(member)" } else { &m.flags }));
}
return;
};
let current = g.members.iter().find(|m| m.account.eq_ignore_ascii_case(target)).map(|m| m.flags.clone());
// Show one.
let Some(delta) = delta else {
match current {
Some(f) => ctx.notice(me, from.uid, format!("\x02{target}\x02 in \x02{name}\x02: \x02{}\x02", if f.is_empty() { "(member)" } else { &f })),
None => ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't in \x02{name}\x02.")),
}
return;
};
// Change — needs founder or f flag.
if !can_manage(&g, acc) {
ctx.notice(me, from.uid, format!("You need the founder or the \x02f\x02 flag to change \x02{name}\x02."));
return;
}
let Some(canonical) = db.resolve_account(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
return;
};
let updated = match apply_flags(current.as_deref().unwrap_or(""), delta, GROUP_FLAGS) {
Ok(f) => f,
Err(bad) => {
ctx.notice(me, from.uid, format!("\x02{bad}\x02 isn't a valid group flag. Valid: \x02{GROUP_FLAGS}\x02."));
return;
}
};
match db.group_set_flags(name, &canonical, &updated) {
Ok(()) => ctx.notice(me, from.uid, format!("\x02{canonical}\x02 in \x02{name}\x02 now holds \x02{}\x02.", if updated.is_empty() { "(member)" } else { &updated })),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -0,0 +1,8 @@
[package]
name = "fedserv-helpserv"
version = "0.0.1"
edition = "2021"
description = "HelpServ: a help-desk queue — users ask, operators take and answer."
[dependencies]
fedserv-api = { path = "../../api" }

162
modules/helpserv/src/lib.rs Normal file
View file

@ -0,0 +1,162 @@
//! HelpServ is a help desk. A user opens a ticket with REQUEST (or HELPME); it
//! joins a queue that — being event-sourced and surfaced by the engine's audit
//! feed — is announced to staff and shows up in OperServ LOGSEARCH, the same
//! trail as reports. Operators work the queue: LIST, VIEW, TAKE (claim), NEXT
//! (claim the oldest), and CLOSE. A user can CANCEL their own open ticket.
use fedserv_api::{human_time, NetView, Sender, Service, ServiceCtx, Store};
pub struct HelpServ {
pub uid: String,
}
impl Service for HelpServ {
fn nick(&self) -> &str {
"HelpServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Help Service"
}
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("REQUEST") | Some("HELPME") => request(me, from, &args[1..], ctx, db),
Some("CANCEL") => cancel(me, from, ctx, db),
Some("LIST") => list(me, from, args.get(1).copied(), ctx, db),
Some("VIEW") | Some("READ") => view(me, from, args.get(1).copied(), ctx, db),
Some("TAKE") | Some("ASSIGN") => take(me, from, args.get(1).copied(), ctx, db),
Some("NEXT") => next(me, from, ctx, db),
Some("CLOSE") | Some("RESOLVE") => close(me, from, args.get(1).copied(), ctx, db),
Some("HELP") | None => help(me, from, ctx),
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02REQUEST\x02 <message> or \x02HELP\x02.")),
}
}
}
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, "HelpServ is the help desk. \x02REQUEST\x02 <message> opens a ticket for the staff; \x02CANCEL\x02 withdraws yours. Operators: \x02LIST\x02 [ALL], \x02VIEW\x02 <id>, \x02TAKE\x02 <id>, \x02NEXT\x02, \x02CLOSE\x02 <id>.");
}
fn request(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let message = rest.join(" ");
if message.trim().is_empty() {
ctx.notice(me, from.uid, "Tell us what you need help with: REQUEST <message>");
return;
}
let requester = from.account.unwrap_or(from.nick);
match db.help_request(requester, &message) {
Some(id) => ctx.notice(me, from.uid, format!("Thanks — your request (\x02#{id}\x02) is in the queue. A staff member will be with you.")),
None => ctx.notice(me, from.uid, "You just opened a ticket — please wait a moment before opening another."),
}
}
fn cancel(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let who = from.account.unwrap_or(from.nick);
// Close the requester's own newest open ticket.
let mine = db.help_tickets(true).into_iter().find(|t| t.requester.eq_ignore_ascii_case(who));
match mine {
Some(t) => {
db.help_close(t.id);
ctx.notice(me, from.uid, format!("Your request \x02#{}\x02 has been cancelled.", t.id));
}
None => ctx.notice(me, from.uid, "You have no open request to cancel."),
}
}
fn require_oper(me: &str, from: &Sender, ctx: &mut ServiceCtx) -> bool {
if from.privs.any() {
return true;
}
ctx.notice(me, from.uid, "Access denied — working the help queue is for services operators.");
false
}
fn list(me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !require_oper(me, from, ctx) {
return;
}
let open_only = !arg.is_some_and(|a| a.eq_ignore_ascii_case("ALL"));
let tickets = db.help_tickets(open_only);
if tickets.is_empty() {
ctx.notice(me, from.uid, if open_only { "No open tickets." } else { "No tickets." });
return;
}
for t in &tickets {
let state = match (&t.handler, t.open) {
(_, false) => " (closed)".to_string(),
(Some(h), true) => format!(" (taken by {h})"),
(None, true) => String::new(),
};
let short: String = t.message.chars().take(60).collect();
ctx.notice(me, from.uid, format!("\x02#{}\x02 {}{}{}", t.id, t.requester, short, state));
}
ctx.notice(me, from.uid, format!("{} ticket(s). \x02VIEW\x02 <id> for detail.", tickets.len()));
}
fn view(me: &str, from: &Sender, id: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !require_oper(me, from, ctx) {
return;
}
let Some(t) = id.and_then(|n| n.parse::<u64>().ok()).and_then(|n| db.help_ticket(n)) else {
ctx.notice(me, from.uid, "No such ticket. Syntax: VIEW <id>");
return;
};
let state = if !t.open { "closed" } else if t.handler.is_some() { "taken" } else { "open" };
ctx.notice(me, from.uid, format!("Ticket \x02#{}\x02 ({state}):", t.id));
ctx.notice(me, from.uid, format!(" From : \x02{}\x02", t.requester));
if let Some(h) = &t.handler {
ctx.notice(me, from.uid, format!(" Handler : \x02{h}\x02"));
}
ctx.notice(me, from.uid, format!(" Opened : {}", human_time(t.ts)));
ctx.notice(me, from.uid, format!(" Message : {}", t.message));
}
fn take(me: &str, from: &Sender, id: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !require_oper(me, from, ctx) {
return;
}
let Some(n) = id.and_then(|n| n.parse::<u64>().ok()) else {
ctx.notice(me, from.uid, "Syntax: TAKE <id>");
return;
};
take_id(me, from, n, ctx, db);
}
fn next(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !require_oper(me, from, ctx) {
return;
}
match db.help_next_open() {
Some(id) => take_id(me, from, id, ctx, db),
None => ctx.notice(me, from.uid, "No unassigned tickets are waiting."),
}
}
fn take_id(me: &str, from: &Sender, id: u64, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let handler = from.account.unwrap_or(from.nick);
if db.help_take(id, handler) {
let msg = db.help_ticket(id).map(|t| t.message).unwrap_or_default();
ctx.notice(me, from.uid, format!("You took ticket \x02#{id}\x02: {msg}"));
} else {
ctx.notice(me, from.uid, format!("Ticket \x02#{id}\x02 isn't open (or doesn't exist)."));
}
}
fn close(me: &str, from: &Sender, id: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !require_oper(me, from, ctx) {
return;
}
let Some(n) = id.and_then(|n| n.parse::<u64>().ok()) else {
ctx.notice(me, from.uid, "Syntax: CLOSE <id>");
return;
};
if db.help_close(n) {
ctx.notice(me, from.uid, format!("Ticket \x02#{n}\x02 closed."));
} else {
ctx.notice(me, from.uid, format!("Ticket \x02#{n}\x02 isn't open (or doesn't exist)."));
}
}

View file

@ -0,0 +1,8 @@
[package]
name = "fedserv-hostserv"
version = "0.0.1"
edition = "2021"
description = "HostServ: assign and apply virtual hosts (vhosts)."
[dependencies]
fedserv-api = { path = "../../api" }

View file

@ -0,0 +1,46 @@
use fedserv_api::{NetView, Sender, ServiceCtx, Store};
// ACTIVATE <account> / REJECT <account>: approve a pending vhost request (setting
// and applying it) or turn it down. Operators only.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store, activate: bool) {
if !super::require_oper(me, from, ctx) {
return;
}
let Some(&account) = args.get(1) else {
let syntax = if activate { "Syntax: ACTIVATE <account>" } else { "Syntax: REJECT <account>" };
ctx.notice(me, from.uid, syntax);
return;
};
let host = match db.take_vhost_request(account) {
Ok(Some(h)) => h,
Ok(None) => {
ctx.notice(me, from.uid, format!("\x02{account}\x02 has no pending vhost request."));
return;
}
Err(_) => {
ctx.notice(me, from.uid, format!("\x02{account}\x02 isn't registered."));
return;
}
};
if !activate {
ctx.notice(me, from.uid, format!("Rejected \x02{account}\x02's vhost request."));
return;
}
// Re-check the requested host now (another account may have taken it since).
let host = match super::prepare_vhost(&host, account, db) {
Ok(h) => h,
Err(msg) => {
ctx.notice(me, from.uid, format!("Can't activate: {msg}"));
return;
}
};
match db.set_vhost(account, &host, from.nick, None) {
Ok(()) => {
for uid in net.uids_logged_into(account) {
ctx.apply_vhost(&uid, &host);
}
ctx.notice(me, from.uid, format!("Activated vhost \x02{host}\x02 for \x02{account}\x02."));
}
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -0,0 +1,35 @@
use fedserv_api::{Sender, ServiceCtx, Store};
// DEFAULT: give yourself the auto-vhost from the network template, with your
// account name substituted for $account.
pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to identify to NickServ first.");
return;
};
let Some(template) = db.vhost_template() else {
ctx.notice(me, from.uid, "This network has no auto-vhost template.");
return;
};
// Sanitise the account into a host-safe label (lowercase, alphanumerics only).
let label: String = account.to_ascii_lowercase().chars().filter(|c| c.is_ascii_alphanumeric()).collect();
if label.is_empty() {
ctx.notice(me, from.uid, "Your account name has no usable characters for a vhost.");
return;
}
let generated = template.replace("$account", &label);
let host = match super::prepare_vhost(&generated, account, db) {
Ok(h) if !db.vhost_is_forbidden(&h) => h,
_ => {
ctx.notice(me, from.uid, "Sorry, a vhost couldn't be generated for your account.");
return;
}
};
match db.set_vhost(account, &host, "template", None) {
Ok(()) => {
ctx.apply_vhost(from.uid, &host);
ctx.notice(me, from.uid, format!("You now have the vhost \x02{host}\x02."));
}
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -0,0 +1,26 @@
use fedserv_api::{NetView, Sender, ServiceCtx, Store};
// DEL <account>: remove an account's vhost, restoring the normal host on any
// online sessions. Operators only.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
if !super::require_oper(me, from, ctx) {
return;
}
let Some(&account) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: DEL <account>");
return;
};
match db.del_vhost(account) {
Ok(true) => {
for uid in net.uids_logged_into(account) {
if let Some(host) = net.host_of(&uid) {
let host = host.to_string();
ctx.set_host(&uid, &host);
}
}
ctx.notice(me, from.uid, format!("Vhost for \x02{account}\x02 removed."));
}
Ok(false) => ctx.notice(me, from.uid, format!("\x02{account}\x02 has no vhost.")),
Err(_) => ctx.notice(me, from.uid, format!("\x02{account}\x02 isn't registered.")),
}
}

View file

@ -0,0 +1,51 @@
use fedserv_api::{Sender, ServiceCtx, Store};
// FORBID <pattern>: block user-requested vhosts matching this regex (operators),
// e.g. (?i)(oper|admin|staff|services) to stop impersonation.
pub fn add(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !super::require_oper(me, from, ctx) {
return;
}
if args.len() < 2 {
ctx.notice(me, from.uid, "Syntax: FORBID <regex>");
return;
}
let pattern = args[1..].join(" ");
match db.vhost_forbid_add(&pattern) {
Ok(true) => ctx.notice(me, from.uid, format!("Forbidden vhost pattern added: {pattern}")),
Ok(false) => ctx.notice(me, from.uid, "That pattern is already forbidden."),
Err(_) => ctx.notice(me, from.uid, format!("\x02{pattern}\x02 isn't a valid regular expression.")),
}
}
// FORBIDLIST: the forbidden-pattern list (operators).
pub fn list(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) {
if !super::require_oper(me, from, ctx) {
return;
}
let forbidden = db.vhost_forbidden();
if forbidden.is_empty() {
ctx.notice(me, from.uid, "No vhost patterns are forbidden.");
return;
}
ctx.notice(me, from.uid, format!("Forbidden vhost patterns ({}):", forbidden.len()));
for (i, p) in forbidden.iter().enumerate() {
ctx.notice(me, from.uid, format!(" {}. {p}", i + 1));
}
}
// FORBIDDEL <number>: remove a forbidden pattern (operators).
pub fn del(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !super::require_oper(me, from, ctx) {
return;
}
let Some(n) = args.get(1).and_then(|s| s.parse::<usize>().ok()) else {
ctx.notice(me, from.uid, "Syntax: FORBIDDEL <number> (see FORBIDLIST)");
return;
};
match db.vhost_forbid_del(n) {
Ok(Some(pattern)) => ctx.notice(me, from.uid, format!("Removed forbidden pattern: {pattern}")),
Ok(None) => ctx.notice(me, from.uid, format!("There's no forbidden pattern #\x02{n}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

141
modules/hostserv/src/lib.rs Normal file
View file

@ -0,0 +1,141 @@
//! HostServ assigns virtual hosts (vhosts) to accounts and applies them to the
//! displayed host. Members toggle their own with ON/OFF; operators assign them
//! with SET/DEL and review with LIST. `lib.rs` holds the dispatcher; each
//! command lives in its own file.
use fedserv_api::{NetView, Priv, Sender, Service, ServiceCtx, Store};
#[path = "on.rs"]
mod on;
#[path = "off.rs"]
mod off;
#[path = "set.rs"]
mod set;
#[path = "del.rs"]
mod del;
#[path = "list.rs"]
mod list;
#[path = "request.rs"]
mod request;
#[path = "waiting.rs"]
mod waiting;
#[path = "approve.rs"]
mod approve;
#[path = "offer.rs"]
mod offer;
#[path = "take.rs"]
mod take;
#[path = "forbid.rs"]
mod forbid;
#[path = "template.rs"]
mod template;
#[path = "default.rs"]
mod default;
pub struct HostServ {
pub uid: String,
}
impl Service for HostServ {
fn nick(&self) -> &str {
"HostServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Host Services"
}
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("ON") => on::handle(me, from, ctx, db),
Some("OFF") => off::handle(me, from, ctx, net, db),
// SET(ALL)/DEL(ALL): the per-account model already covers every
// grouped nick, so ALL is an alias, and GROUP is a no-op reassurance.
Some("SET") | Some("SETALL") => set::handle(me, from, args, ctx, net, db),
Some("DEL") | Some("DELALL") => del::handle(me, from, args, ctx, net, db),
Some("GROUP") => ctx.notice(me, from.uid, "Your vhost already applies to all your grouped nicks — nothing to sync."),
Some("LIST") => list::handle(me, from, ctx, db),
Some("REQUEST") => request::handle(me, from, args, ctx, db),
Some("WAITING") => waiting::handle(me, from, ctx, db),
Some("ACTIVATE") | Some("APPROVE") => approve::handle(me, from, args, ctx, net, db, true),
Some("REJECT") => approve::handle(me, from, args, ctx, net, db, false),
Some("OFFER") => offer::add(me, from, args, ctx, db),
Some("OFFERLIST") => offer::list(me, from, ctx, db),
Some("OFFERDEL") => offer::del(me, from, args, ctx, db),
Some("TAKE") => take::handle(me, from, args, ctx, db),
Some("FORBID") => forbid::add(me, from, args, ctx, db),
Some("FORBIDLIST") => forbid::list(me, from, ctx, db),
Some("FORBIDDEL") => forbid::del(me, from, args, ctx, db),
Some("TEMPLATE") => template::handle(me, from, args, ctx, db),
Some("DEFAULT") => default::handle(me, from, ctx, db),
Some("HELP") | None => ctx.notice(me, from.uid, "HostServ gives you a vhost: \x02ON\x02 activates your assigned vhost, \x02OFF\x02 restores your normal host, \x02REQUEST\x02 <host> asks for one, \x02OFFERLIST\x02 + \x02TAKE\x02 <n> pick from the menu. Operators use \x02SET\x02/\x02DEL\x02 <account>, \x02LIST\x02, \x02WAITING\x02 + \x02ACTIVATE\x02/\x02REJECT\x02, and \x02OFFER\x02/\x02OFFERDEL\x02 for the menu."),
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
}
}
}
// Operator gate for vhost administration.
fn require_oper(me: &str, from: &Sender, ctx: &mut ServiceCtx) -> bool {
if from.privs.has(Priv::Admin) {
return true;
}
ctx.notice(me, from.uid, "Access denied — assigning vhosts is for services operators.");
false
}
// Canonicalise a vhost the way the ircd will actually display it: the host part
// only allows letters, digits, dots and hyphens, so a disallowed character it
// would rewrite (an underscore becomes a hyphen) is rewritten here first. This
// keeps what we store identical to what shows on the network, so two inputs
// that would collapse to the same host are detected as one.
pub(crate) fn normalize_vhost(spec: &str) -> String {
let (ident, host) = match spec.split_once('@') {
Some((i, h)) => (Some(i), h),
None => (None, spec),
};
let norm_host: String = host.chars().map(|c| if c == '_' { '-' } else { c }).collect();
match ident {
Some(i) => format!("{i}@{norm_host}"),
None => norm_host,
}
}
// Normalise a requested vhost and check it's valid and not already another
// account's. Returns the canonical spec to store, or a message to show.
pub(crate) fn prepare_vhost(spec: &str, account: &str, db: &dyn Store) -> Result<String, String> {
let host = normalize_vhost(spec);
if !valid_vhost(&host) {
return Err(format!("\x02{host}\x02 isn't a valid host (letters, digits, hyphens and dots)."));
}
if db.vhost_owner(&host).is_some_and(|owner| !owner.eq_ignore_ascii_case(account)) {
return Err(format!("\x02{host}\x02 is already in use. Please choose another."));
}
Ok(host)
}
// Whether `spec` is a valid vhost: an optional `ident@` (letters, digits, a few
// punctuation) followed by a hostname of dot-separated alphanumeric/hyphen labels.
pub(crate) fn valid_vhost(spec: &str) -> bool {
let (ident, host) = match spec.split_once('@') {
Some((i, h)) => (Some(i), h),
None => (None, spec),
};
if let Some(i) = ident {
if i.is_empty() || i.len() > 12 || !i.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') {
return false;
}
}
if host.is_empty() || host.len() > 64 || !host.contains('.') {
return false;
}
host.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
})
}

View file

@ -0,0 +1,18 @@
use fedserv_api::{Sender, ServiceCtx, Store};
// LIST: every account with an assigned vhost. Operators only.
pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) {
if !super::require_oper(me, from, ctx) {
return;
}
let vhosts = db.vhosts();
if vhosts.is_empty() {
ctx.notice(me, from.uid, "No vhosts have been assigned.");
return;
}
ctx.notice(me, from.uid, format!("Assigned vhosts ({}):", vhosts.len()));
for v in &vhosts {
let temp = if v.expires.is_some() { ", temporary" } else { "" };
ctx.notice(me, from.uid, format!(" \x02{}\x02{} (by {}{temp})", v.account, v.host, v.setter));
}
}

View file

@ -0,0 +1,22 @@
use fedserv_api::{NetView, Sender, ServiceCtx, Store};
// OFF: restore your normal host for this session (the vhost stays assigned and
// re-applies next time you identify).
pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to identify to NickServ first.");
return;
};
if db.vhost(account).is_none() {
ctx.notice(me, from.uid, "You have no vhost assigned.");
return;
}
match net.host_of(from.uid) {
Some(host) => {
let host = host.to_string();
ctx.set_host(from.uid, &host);
ctx.notice(me, from.uid, "Your vhost is off; your normal host is restored.");
}
None => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -0,0 +1,51 @@
use fedserv_api::{Sender, ServiceCtx, Store};
// OFFER <host>: add a vhost to the self-serve menu (operators).
pub fn add(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !super::require_oper(me, from, ctx) {
return;
}
let Some(&host) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: OFFER <host>");
return;
};
if !super::valid_vhost(host) {
ctx.notice(me, from.uid, format!("\x02{host}\x02 isn't a valid host."));
return;
}
match db.vhost_offer_add(host) {
Ok(true) => ctx.notice(me, from.uid, format!("\x02{host}\x02 is now on the vhost menu.")),
Ok(false) => ctx.notice(me, from.uid, "That vhost is already on the menu."),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
// OFFERLIST: show the self-serve vhost menu (anyone).
pub fn list(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) {
let offers = db.vhost_offers();
if offers.is_empty() {
ctx.notice(me, from.uid, "No vhosts are on offer.");
return;
}
ctx.notice(me, from.uid, format!("Vhosts on offer ({}):", offers.len()));
for (i, host) in offers.iter().enumerate() {
ctx.notice(me, from.uid, format!(" {}. {host}", i + 1));
}
ctx.notice(me, from.uid, "Take one with \x02TAKE\x02 <number>.");
}
// OFFERDEL <number>: remove an offer from the menu (operators).
pub fn del(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !super::require_oper(me, from, ctx) {
return;
}
let Some(n) = args.get(1).and_then(|s| s.parse::<usize>().ok()) else {
ctx.notice(me, from.uid, "Syntax: OFFERDEL <number> (see OFFERLIST)");
return;
};
match db.vhost_offer_del(n) {
Ok(Some(host)) => ctx.notice(me, from.uid, format!("Removed \x02{host}\x02 from the menu.")),
Ok(None) => ctx.notice(me, from.uid, format!("There's no offer #\x02{n}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -0,0 +1,16 @@
use fedserv_api::{Sender, ServiceCtx, Store};
// ON: activate the vhost assigned to your account.
pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to identify to NickServ first.");
return;
};
match db.vhost(account) {
Some(v) => {
ctx.apply_vhost(from.uid, &v.host);
ctx.notice(me, from.uid, format!("Your vhost \x02{}\x02 is now active.", v.host));
}
None => ctx.notice(me, from.uid, "You have no vhost assigned."),
}
}

View file

@ -0,0 +1,33 @@
use fedserv_api::{Sender, ServiceCtx, Store};
// REQUEST <host>: ask for a vhost, to be approved by an operator.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to identify to NickServ first.");
return;
};
let Some(&host) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: REQUEST <host>");
return;
};
let host = match super::prepare_vhost(host, account, db) {
Ok(h) => h,
Err(msg) => {
ctx.notice(me, from.uid, msg);
return;
}
};
if db.vhost_is_forbidden(&host) {
ctx.notice(me, from.uid, format!("\x02{host}\x02 isn't allowed here. Please choose another."));
return;
}
let wait = db.vhost_request_wait(account);
if wait > 0 {
ctx.notice(me, from.uid, format!("Please wait \x02{wait}\x02s before requesting another vhost."));
return;
}
match db.request_vhost(account, &host) {
Ok(()) => ctx.notice(me, from.uid, format!("Requested vhost \x02{host}\x02 — an operator will review it.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -0,0 +1,36 @@
use fedserv_api::{parse_duration, NetView, Sender, ServiceCtx, Store};
// SET <account> <host> [duration]: assign a vhost to an account, applying it at
// once to online sessions. An optional duration (e.g. 30d) makes it temporary.
// Operators only.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
if !super::require_oper(me, from, ctx) {
return;
}
let (Some(&account), Some(&host)) = (args.get(1), args.get(2)) else {
ctx.notice(me, from.uid, "Syntax: SET <account> <host> [duration]");
return;
};
if db.account(account).is_none() {
ctx.notice(me, from.uid, format!("\x02{account}\x02 isn't registered."));
return;
}
let host = match super::prepare_vhost(host, account, db) {
Ok(h) => h,
Err(msg) => {
ctx.notice(me, from.uid, msg);
return;
}
};
let ttl = args.get(3).and_then(|s| parse_duration(s));
match db.set_vhost(account, &host, from.nick, ttl) {
Ok(()) => {
for uid in net.uids_logged_into(account) {
ctx.apply_vhost(&uid, &host);
}
let when = args.get(3).filter(|_| ttl.is_some()).map(|d| format!(" (expires in {d})")).unwrap_or_default();
ctx.notice(me, from.uid, format!("Vhost \x02{host}\x02 assigned to \x02{account}\x02{when}."));
}
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -0,0 +1,31 @@
use fedserv_api::{Sender, ServiceCtx, Store};
// TAKE <number>: assign yourself the vhost offered at that position on the menu.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to identify to NickServ first.");
return;
};
let Some(n) = args.get(1).and_then(|s| s.parse::<usize>().ok()) else {
ctx.notice(me, from.uid, "Syntax: TAKE <number> (see OFFERLIST)");
return;
};
let Some(offer) = n.checked_sub(1).and_then(|i| db.vhost_offers().into_iter().nth(i)) else {
ctx.notice(me, from.uid, format!("There's no offer #\x02{n}\x02. See \x02OFFERLIST\x02."));
return;
};
let host = match super::prepare_vhost(&offer, account, db) {
Ok(h) => h,
Err(msg) => {
ctx.notice(me, from.uid, msg);
return;
}
};
match db.set_vhost(account, &host, "offer", None) {
Ok(()) => {
ctx.apply_vhost(from.uid, &host);
ctx.notice(me, from.uid, format!("You now have the vhost \x02{host}\x02."));
}
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -0,0 +1,32 @@
use fedserv_api::{Sender, ServiceCtx, Store};
// TEMPLATE [<pattern>]: show the auto-vhost template, or (operators) set it.
// Use $account for the requester's sanitised account name, e.g.
// $account.users.example. Give no argument to show it, OFF to clear it.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
match args.get(1) {
None => match db.vhost_template() {
Some(t) => ctx.notice(me, from.uid, format!("Auto-vhost template: \x02{t}\x02. Users apply it with \x02DEFAULT\x02.")),
None => ctx.notice(me, from.uid, "No auto-vhost template is set."),
},
Some(&arg) => {
if !super::require_oper(me, from, ctx) {
return;
}
if arg.eq_ignore_ascii_case("OFF") {
let _ = db.set_vhost_template(None);
ctx.notice(me, from.uid, "Auto-vhost template cleared.");
return;
}
let template = args[1..].join(" ");
if !template.contains("$account") || !super::valid_vhost(&template.replace("$account", "x")) {
ctx.notice(me, from.uid, "The template must contain \x02$account\x02 and form a valid host, e.g. \x02$account.users.example\x02.");
return;
}
match db.set_vhost_template(Some(template.clone())) {
Ok(()) => ctx.notice(me, from.uid, format!("Auto-vhost template set to \x02{template}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
}
}

View file

@ -0,0 +1,18 @@
use fedserv_api::{Sender, ServiceCtx, Store};
// WAITING: pending vhost requests awaiting approval. Operators only.
pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) {
if !super::require_oper(me, from, ctx) {
return;
}
let requests = db.vhost_requests();
if requests.is_empty() {
ctx.notice(me, from.uid, "No vhost requests are waiting.");
return;
}
ctx.notice(me, from.uid, format!("Pending vhost requests ({}):", requests.len()));
for (account, host) in &requests {
ctx.notice(me, from.uid, format!(" \x02{account}\x02{host}"));
}
ctx.notice(me, from.uid, "Approve with \x02ACTIVATE\x02 <account> or turn down with \x02REJECT\x02 <account>.");
}

View file

@ -0,0 +1,8 @@
[package]
name = "fedserv-infoserv"
version = "0.0.1"
edition = "2021"
description = "InfoServ: network information bulletins shown on connect (public) and on oper login."
[dependencies]
fedserv-api = { path = "../../api" }

103
modules/infoserv/src/lib.rs Normal file
View file

@ -0,0 +1,103 @@
//! InfoServ manages the network information bulletins. Public bulletins are
//! shown to every user as they connect; oper bulletins are shown to operators
//! when they log in (both are displayed by the engine — the same hooks OperServ
//! once drove through its NEWS command). This is the dedicated front-end over
//! that one shared news store, so there aren't two ways to post the same thing.
//!
//! POST/LIST/DEL manage public bulletins; OPOST/OLIST/ODEL the oper ones.
//! Posting and deleting are admin-only; anyone may LIST the public bulletins,
//! and any operator may OLIST.
use fedserv_api::{NetView, Priv, Sender, Service, ServiceCtx, Store};
// The two bulletin kinds in the shared news store.
const PUBLIC: &str = "logon";
const OPER: &str = "oper";
pub struct InfoServ {
pub uid: String,
}
impl Service for InfoServ {
fn nick(&self) -> &str {
"InfoServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Information Service"
}
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("POST") | Some("ADD") => post(me, from, PUBLIC, &args[1..], ctx, db),
Some("OPOST") | Some("OADD") => post(me, from, OPER, &args[1..], ctx, db),
Some("DEL") | Some("REMOVE") => del(me, from, PUBLIC, args.get(1).copied(), ctx, db),
Some("ODEL") => del(me, from, OPER, args.get(1).copied(), ctx, db),
// Public bulletins are, well, public — anyone may list them.
Some("LIST") | None => list(me, from, PUBLIC, false, ctx, db),
// Oper bulletins are for operators only.
Some("OLIST") => list(me, from, OPER, true, ctx, db),
Some("HELP") => help(me, from, ctx),
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02LIST\x02 or \x02HELP\x02.")),
}
}
}
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, "InfoServ holds the network's information bulletins. \x02LIST\x02 shows the public ones. Operators: \x02POST\x02 <message> / \x02DEL\x02 <number> (public, shown on connect), \x02OPOST\x02 / \x02OLIST\x02 / \x02ODEL\x02 (oper-only, shown on login).");
}
fn post(me: &str, from: &Sender, kind: &str, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — posting bulletins is for services operators.");
return;
}
let message = rest.join(" ");
if message.trim().is_empty() {
ctx.notice(me, from.uid, format!("Syntax: {} <message>", if kind == OPER { "OPOST" } else { "POST" }));
return;
}
let setter = from.account.unwrap_or(from.nick);
db.news_add(kind, &message, setter);
let which = if kind == OPER { "oper" } else { "public" };
ctx.notice(me, from.uid, format!("Added a \x02{which}\x02 bulletin."));
}
fn del(me: &str, from: &Sender, kind: &str, num: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — that command is for services operators.");
return;
}
let which = if kind == OPER { "oper" } else { "public" };
let Some(n) = num.and_then(|n| n.parse::<usize>().ok()) else {
ctx.notice(me, from.uid, format!("Syntax: {} <number>", if kind == OPER { "ODEL" } else { "DEL" }));
return;
};
match db.news(kind).get(n.wrapping_sub(1)) {
Some(item) if n >= 1 => {
db.news_del(item.id);
ctx.notice(me, from.uid, format!("Removed \x02{which}\x02 bulletin \x02{n}\x02."));
}
_ => ctx.notice(me, from.uid, format!("There's no \x02{which}\x02 bulletin \x02{n}\x02.")),
}
}
fn list(me: &str, from: &Sender, kind: &str, oper_only: bool, ctx: &mut ServiceCtx, db: &mut dyn Store) {
if oper_only && !from.privs.any() {
ctx.notice(me, from.uid, "Access denied — oper bulletins are for services operators.");
return;
}
let items = db.news(kind);
let which = if kind == OPER { "oper" } else { "public" };
if items.is_empty() {
ctx.notice(me, from.uid, format!("There are no {which} bulletins."));
return;
}
for (i, item) in items.iter().enumerate() {
ctx.notice(me, from.uid, format!("{}. {} — by {}", i + 1, item.text, item.setter));
}
ctx.notice(me, from.uid, format!("End of {which} bulletins ({} shown).", items.len()));
}

View file

@ -0,0 +1,8 @@
[package]
name = "fedserv-inspircd"
version = "0.0.1"
edition = "2021"
description = "InspIRCd server-to-server protocol module for fedserv."
[dependencies]
fedserv-api = { path = "../../api" }

543
modules/inspircd/src/lib.rs Normal file
View file

@ -0,0 +1,543 @@
// InspIRCd spanning-tree link protocol. Handshake + UID + PING mirror the
// sequence in Network-Links (protocols/inspircd.py); mode/burst details get
// firmed up against a live insp4 uplink.
use fedserv_api::{NetAction, NetEvent, Protocol};
use std::time::{SystemTime, UNIX_EPOCH};
pub struct InspIrcd {
sid: String,
name: String,
description: String,
password: String,
protocol: u32,
ts: u64,
}
impl InspIrcd {
pub fn new(name: String, description: String, sid: String, password: String, protocol: u32, ts: u64) -> Self {
Self { sid, name, description, password, protocol, ts }
}
fn sourced(&self, cmd: String) -> String {
format!(":{} {}", self.sid, cmd)
}
}
impl Protocol for InspIrcd {
fn handshake(&mut self) -> Vec<String> {
vec![
format!("CAPAB START {}", self.protocol),
format!("CAPAB CAPABILITIES :PROTOCOL={}", self.protocol),
"CAPAB END".to_string(),
format!("SERVER {} {} {} :{}", self.name, self.password, self.sid, self.description),
]
}
fn parse(&mut self, line: &str) -> Vec<NetEvent> {
// Strip an optional IRCv3 message-tag prefix (@k=v;… ) — insp tags PRIVMSGs
// with time/msgid, and it sits before the :source.
let line = match line.strip_prefix('@') {
Some(rest) => rest.split_once(' ').map(|x| x.1).unwrap_or(""),
None => line,
};
let (source, rest) = match line.strip_prefix(':') {
Some(s) => {
let mut it = s.splitn(2, ' ');
(Some(it.next().unwrap_or("").to_string()), it.next().unwrap_or(""))
}
None => (None, line),
};
let mut tokens = rest.split(' ');
let cmd = match tokens.next() {
Some(c) => c,
None => return vec![],
};
match cmd.to_ascii_uppercase().as_str() {
"SERVER" => vec![NetEvent::Registered],
"ENDBURST" => vec![NetEvent::EndBurst],
"PING" => {
let token = tokens
.next()
.unwrap_or("")
.trim_start_matches(':')
.to_string();
vec![NetEvent::Ping { token, from: source }]
}
"PRIVMSG" => {
let to = tokens.next().unwrap_or("").to_string();
vec![NetEvent::Privmsg {
from: source.unwrap_or_default(),
to,
text: trailing(rest),
}]
}
// UID <uuid> <nickts> <nick> … — track who's online so services can
// resolve a sender's current nick.
// UID <uuid> <nickts> <nick> <realhost> <disphost> <realuser> <dispuser>
// <ip> <signon> … — take the displayed host for ban masks and the ip
// (index 7) for session limiting.
"UID" => {
let a: Vec<&str> = tokens.collect();
match (a.first(), a.get(2)) {
(Some(uid), Some(nick)) => {
let host = a.get(4).unwrap_or(&"").to_string();
let ip = a.get(7).unwrap_or(&"").to_string();
vec![NetEvent::UserConnect { uid: uid.to_string(), nick: nick.to_string(), host, ip }]
}
_ => vec![],
}
}
// :<uid> NICK <newnick> <newts> — keep the sender's current nick
// fresh, so nick-based commands (IDENTIFY/REGISTER) act on who they
// are now, not their nick at burst time (e.g. after a guest rename).
"NICK" => match (source, tokens.next()) {
(Some(uid), Some(nick)) if !nick.is_empty() => {
vec![NetEvent::NickChange { uid, nick: nick.to_string() }]
}
_ => vec![],
},
// FJOIN <chan> <ts> <modes> [params] :<members> — channel create/burst.
// Each member is "<prefixes>,<uid>"; surface a Join for each.
"FJOIN" => {
// 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.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>]"; the prefixes are the
// status mode letters, so 'o' means they join opped.
for m in trailing(rest).split_whitespace() {
if let Some((prefix, 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.to_string(), op: prefix.contains('o') });
if prefix.contains('v') {
out.push(NetEvent::ChannelVoice { channel: chan.to_string(), uid: uid.to_string(), voice: true });
}
}
}
}
out
}
}
// :<uid> IJOIN <chan> — a single user joining an existing channel (not opped).
"IJOIN" => match (source.as_deref(), tokens.next()) {
(Some(uid), Some(chan)) if chan.starts_with('#') => {
vec![NetEvent::Join { uid: uid.to_string(), channel: chan.to_string(), op: false }]
}
_ => 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" => {
let a: Vec<&str> = tokens.collect();
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('#') => {
let params = a.get(3..).unwrap_or(&[]);
let mut out = vec![NetEvent::ChannelModeChange { channel: chan.to_string(), modes: modes.to_string() }];
if let Some(key) = scan_key(modes, params) {
out.push(NetEvent::ChannelKey { channel: chan.to_string(), key });
}
for (op, uid) in scan_status(modes, params, 'o') {
out.push(NetEvent::ChannelOp { channel: chan.to_string(), uid, op });
}
for (voice, uid) in scan_status(modes, params, 'v') {
out.push(NetEvent::ChannelVoice { channel: chan.to_string(), uid, voice });
}
out
}
_ => vec![],
}
}
// :<src> FTOPIC <chan> <chants> <topicts> [setby] :<topic> — a topic
// change. Skip changes we made ourselves so enforcement can't loop.
"FTOPIC" => {
let a: Vec<&str> = tokens.collect();
match (source.as_deref(), a.first()) {
(Some(src), Some(chan)) if !src.starts_with(self.sid.as_str()) && chan.starts_with('#') => {
vec![NetEvent::TopicChange { channel: chan.to_string(), setter: src.to_string(), topic: trailing(rest) }]
}
_ => vec![],
}
}
"QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }],
// account-registration relay from an ircd:
// ACCTREGISTER <reqid> <origin> <kind> <account> <p2> :<p3>
"ACCTREGISTER" => {
let a: Vec<&str> = tokens.by_ref().take(5).collect();
if a.len() == 5 {
vec![NetEvent::AccountRequest {
reqid: a[0].to_string(),
origin: a[1].to_string(),
kind: a[2].to_string(),
account: a[3].to_string(),
p2: a[4].to_string(),
p3: trailing(rest),
}]
} else {
vec![]
}
}
// ENCAP <target> <subcmd> … — we only care about relayed SASL:
// ENCAP <target> SASL <client> <agent> <mode> [data…]
"ENCAP" => {
let _target = tokens.next();
match tokens.next().map(|s| s.to_ascii_uppercase()).as_deref() {
Some("SASL") => {
let p: Vec<&str> = tokens.collect();
if p.len() >= 3 {
vec![NetEvent::Sasl {
client: p[0].to_string(),
agent: p[1].to_string(),
mode: p[2].to_string(),
data: p[3..].iter().map(|s| s.to_string()).collect(),
}]
} else {
vec![]
}
}
_ => vec![NetEvent::Unknown { line: line.to_string() }],
}
}
_ => vec![NetEvent::Unknown { line: line.to_string() }],
}
}
fn serialize(&mut self, action: &NetAction) -> Vec<String> {
let lines = match action {
NetAction::Burst => vec![self.sourced(format!("BURST {}", self.ts))],
NetAction::EndBurst => vec![self.sourced("ENDBURST".to_string())],
NetAction::Pong { token, from } => {
let dest = from.clone().unwrap_or_else(|| self.sid.clone());
vec![self.sourced(format!("PONG {} {}", dest, token))]
}
// insp4 UID: uuid nickts nick realhost disphost realuser dispuser ip signonts +modes :gecos
// (both a real AND a displayed user field — see m_spanningtree/uid.cpp Builder).
NetAction::IntroduceUser { uid, nick, ident, host, gecos } => vec![self.sourced(format!(
"UID {uid} {ts} {nick} {host} {host} {ident} {ident} 0.0.0.0 {ts} +i :{gecos}",
uid = uid, ts = self.ts, nick = nick, host = host, ident = ident, gecos = gecos
))],
NetAction::Privmsg { from, to, text } => {
vec![format!(":{} PRIVMSG {} :{}", from, to, text)]
}
NetAction::Notice { from, to, text } => {
vec![format!(":{} NOTICE {} :{}", from, to, text)]
}
// ACCTREGRESULT <reqid> <kind> <account> <status> <code> :<message>
NetAction::AccountResponse { reqid, kind, account, status, code, message } => {
vec![self.sourced(format!(
"ACCTREGRESULT {} {} {} {} {} :{}",
reqid, kind, account, status, code, message
))]
}
// ENCAP * SASL <agent> <client> <mode> [data…]
NetAction::Sasl { agent, client, mode, data } => {
let mut line = format!("ENCAP * SASL {} {} {}", agent, client, mode);
for d in data {
line.push(' ');
line.push_str(d);
}
vec![self.sourced(line)]
}
// METADATA <target> <key> :<value> — "*" is server-global metadata.
NetAction::Metadata { target, key, value } => {
vec![self.sourced(format!("METADATA {} {} :{}", target, key, value))]
}
// SVSNICK <uid> <newnick> <nickts> — the new nick takes the current
// time as its TS so it wins any collision resolution.
NetAction::QuitUser { uid, reason } => vec![format!(":{} QUIT :{}", uid, reason)],
NetAction::ServiceJoin { uid, channel } => vec![format!(":{} IJOIN {}", uid, channel)],
NetAction::ServicePart { uid, channel } => vec![format!(":{} PART {}", uid, channel)],
// ENCAP the target's server: CHGHOST <uid> <newhost>, to set a vhost.
NetAction::SetHost { uid, host } => {
let target = uid.get(..3).unwrap_or(uid.as_str());
vec![self.sourced(format!("ENCAP {} CHGHOST {} {}", target, uid, host))]
}
NetAction::SetIdent { uid, ident } => {
let target = uid.get(..3).unwrap_or(uid.as_str());
vec![self.sourced(format!("ENCAP {} CHGIDENT {} {}", target, uid, ident))]
}
NetAction::ForceNick { uid, nick } => {
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts);
vec![self.sourced(format!("SVSNICK {} {} {}", uid, nick, now))]
}
// SVSJOIN <uid> <chan> [key]: force a user into a channel, sourced from
// the services server. Used to apply an account's auto-join list.
NetAction::ForceJoin { uid, channel, key } => {
if key.is_empty() {
vec![self.sourced(format!("SVSJOIN {} {}", uid, channel))]
} else {
vec![self.sourced(format!("SVSJOIN {} {} {}", uid, channel, key))]
}
}
// FMODE <chan> <ts> <modes>. The ircd drops an FMODE whose TS is newer
// than the channel's, so we send TS 1 to guarantee it applies. Sourced
// from the given pseudoclient (e.g. ChanServ) so users see who set it,
// or from the services server when `from` is empty.
NetAction::ChannelMode { from, channel, modes } => {
let cmd = format!("FMODE {} 1 {}", channel, modes);
if from.is_empty() {
vec![self.sourced(cmd)]
} else {
vec![format!(":{} {}", from, cmd)]
}
}
NetAction::Kick { from, channel, uid, reason } => {
vec![format!(":{} KICK {} {} :{}", from, channel, uid, reason)]
}
// FTOPIC <chan> <chanTS> <topicTS> <setter> :<topic>. TS 1 so the ircd
// always accepts it; sourced from the given pseudoclient.
NetAction::Topic { from, channel, topic } => {
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts);
vec![format!(":{} FTOPIC {} 1 {} {} :{}", from, channel, now, from, topic)]
}
// INVITE <uid> <chan> <chanTS> <expiry>. Expiry 0 = no expiry.
NetAction::Invite { from, uid, channel } => {
vec![format!(":{} INVITE {} {} 1 0", from, uid, channel)]
}
// ADDLINE <type> <mask> <setter> <set-time> <duration> :<reason>. A
// duration of 0 is permanent; the ircd applies it to matching users
// already online and propagates it across the network.
NetAction::AddLine { kind, mask, setter, duration, reason } => {
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts);
vec![self.sourced(format!("ADDLINE {} {} {} {} {} :{}", kind, mask, setter, now, duration, reason))]
}
NetAction::DelLine { kind, mask } => vec![self.sourced(format!("DELLINE {} {}", kind, mask))],
NetAction::KillUser { from, uid, reason } => vec![format!(":{} KILL {} :{}", from, uid, reason)],
// Introduce a server behind us: :<our-sid> SERVER <name> <sid> :<desc>.
NetAction::JupeServer { name, sid, reason } => {
vec![self.sourced(format!("SERVER {} {} :JUPED: {}", name, sid, reason))]
}
NetAction::Squit { target, reason } => vec![self.sourced(format!("SQUIT {} :{}", target, reason))],
NetAction::Raw(s) => vec![s.clone()],
// Internal: the link layer handles these before serialization.
NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } | NetAction::SendEmail { .. } => vec![],
};
// A trailing parameter can carry free-form text (message bodies, kick
// reasons, topics, metadata). Strip CR/LF/NUL at this single choke-point
// so no such text — whatever its origin — can smuggle a second command
// onto the s2s link.
lines.into_iter().map(|l| l.replace(['\r', '\n', '\0'], "")).collect()
}
fn sid(&self) -> &str {
&self.sid
}
}
// Whether a channel mode consumes a parameter — the shared canonical arity, so
// parsing here and MODE-building in services never drift.
use fedserv_api::chanmode_takes_param as takes_param;
// 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
}
// Walk a mode change and its params for grants/revokes of one status mode
// (`want`, e.g. 'o' or 'v'). Returns (is_set, uid) per match, using the same
// param cursor as scan_key.
fn scan_status(modes: &str, params: &[&str], want: char) -> Vec<(bool, String)> {
let mut adding = true;
let mut pi = 0;
let mut out = Vec::new();
for m in modes.chars() {
match m {
'+' => adding = true,
'-' => adding = false,
c if c == want => {
if let Some(p) = params.get(pi) {
out.push((adding, p.to_string()));
}
pi += 1;
}
c if takes_param(c, adding) => pi += 1,
_ => {}
}
}
out
}
// The IRC "trailing" argument: everything after the first " :", else the last word.
fn trailing(rest: &str) -> String {
match rest.find(" :") {
Some(i) => rest[i + 2..].to_string(),
None => rest.rsplit(' ').next().unwrap_or("").to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn proto() -> InspIrcd {
InspIrcd::new("services.test".into(), "Federated Services".into(), "42S".into(), "pw".into(), 1206, 1)
}
// A remote nick change must surface as a NickChange for the source uid, or
// nick-based commands act on a stale nick.
#[test]
fn parses_nick_change() {
let ev = proto().parse(":0IRAAAAAB NICK newnick 1783833000");
assert!(
matches!(ev.as_slice(), [NetEvent::NickChange { uid, nick }] if uid == "0IRAAAAAB" && nick == "newnick"),
"{ev:?}"
);
}
// A UID burst introduces the user under their current nick.
#[test]
fn parses_uid_burst() {
let ev = proto().parse(":0IR UID 0IRAAAAAB 1783833000 alice host host user user 0.0.0.0 1783833000 +i :real");
assert!(
matches!(ev.as_slice(), [NetEvent::UserConnect { uid, nick, .. }] if uid == "0IRAAAAAB" && nick == "alice"),
"{ev:?}"
);
}
// FJOIN (channel create/burst) surfaces as ChannelCreate for the channel, and
// the member's uid is taken without its :membid suffix.
#[test]
fn parses_fjoin_as_channel_create() {
let ev = proto().parse(":0IR FJOIN #chan 1783845132 +tn :o,0IRAAAAAB:0");
assert!(matches!(ev.first(), Some(NetEvent::ChannelCreate { channel }) if channel == "#chan"), "{ev:?}");
assert!(ev.iter().any(|e| matches!(e, NetEvent::Join { uid, .. } if uid == "0IRAAAAAB")), "{ev:?}");
}
// A peer FMODE surfaces as a mode change; our own (sid 42S) is filtered out.
#[test]
fn parses_fmode_and_filters_own() {
let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 +m");
assert!(
matches!(ev.as_slice(), [NetEvent::ChannelModeChange { channel, modes }] if channel == "#chan" && modes == "+m"),
"{ev:?}"
);
let ev = proto().parse(":42S FMODE #chan 1783845132 -m");
assert!(!ev.iter().any(|e| matches!(e, NetEvent::ChannelModeChange { .. })), "own change filtered: {ev:?}");
}
// FMODE and an FJOIN prefix both surface voice status changes.
#[test]
fn parses_voice_status() {
// +o then +v with two params: op for the first uid, voice for the second.
let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 +ov 0IRAAAAAB 0IRAAAAAC");
assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelOp { uid, op: true, .. } if uid == "0IRAAAAAB")), "op: {ev:?}");
assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelVoice { uid, voice: true, .. } if uid == "0IRAAAAAC")), "voice: {ev:?}");
// A -v revokes voice.
let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 -v 0IRAAAAAC");
assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelVoice { uid, voice: false, .. } if uid == "0IRAAAAAC")), "devoice: {ev:?}");
// A +v join prefix surfaces a voice event alongside the Join.
let ev = proto().parse(":0IR FJOIN #chan 1783845132 +tn :v,0IRAAAAAD");
assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelVoice { uid, voice: true, .. } if uid == "0IRAAAAAD")), "join voice: {ev:?}");
}
// A vhost is set by ENCAP'ing the target's own server with CHGHOST / CHGIDENT.
#[test]
fn serializes_set_host() {
let lines = proto().serialize(&NetAction::SetHost { uid: "0IRAAAAAB".into(), host: "cool.example".into() });
assert_eq!(lines, vec![":42S ENCAP 0IR CHGHOST 0IRAAAAAB cool.example".to_string()]);
let lines = proto().serialize(&NetAction::SetIdent { uid: "0IRAAAAAB".into(), ident: "cool".into() });
assert_eq!(lines, vec![":42S ENCAP 0IR CHGIDENT 0IRAAAAAB cool".to_string()]);
}
// Free-form text with embedded line breaks must not smuggle a second line
// onto the link: the serializer strips CR/LF/NUL from every outbound line.
#[test]
fn strips_crlf_from_trailing_text() {
let lines = proto().serialize(&NetAction::Notice {
from: "42SAAAAAA".into(),
to: "0IRAAAAAB".into(),
text: "hi\r\nKILL 0IRAAAAAB :pwned".into(),
});
assert_eq!(lines, vec![":42SAAAAAA NOTICE 0IRAAAAAB :hiKILL 0IRAAAAAB :pwned".to_string()]);
assert!(!lines[0].contains('\n') && !lines[0].contains('\r'));
}
// A network ban serializes to ADDLINE / DELLINE, sourced from our server.
#[test]
fn serializes_network_bans() {
let add = proto().serialize(&NetAction::AddLine {
kind: "G".into(),
mask: "*@evil.host".into(),
setter: "staff".into(),
duration: 3600,
reason: "spamming".into(),
});
assert_eq!(add.len(), 1);
assert!(add[0].starts_with(":42S ADDLINE G *@evil.host staff "), "{add:?}");
assert!(add[0].ends_with(" 3600 :spamming"), "{add:?}");
let del = proto().serialize(&NetAction::DelLine { kind: "G".into(), mask: "*@evil.host".into() });
assert_eq!(del, vec![":42S DELLINE G *@evil.host".to_string()]);
}
#[test]
fn parses_ftopic_and_filters_own() {
let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 :Welcome all");
assert!(
matches!(ev.as_slice(), [NetEvent::TopicChange { channel, setter, topic }] if channel == "#chan" && setter == "0IRAAAAAB" && topic == "Welcome all"),
"{ev:?}"
);
// The burst/RESYNC form carries a setby field before the topic.
let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 someone!u@h :Hi there");
assert!(matches!(ev.as_slice(), [NetEvent::TopicChange { topic, .. }] if topic == "Hi there"), "{ev:?}");
// Our own topic changes are filtered so enforcement can't loop.
let ev = proto().parse(":42S FTOPIC #chan 1 1 :nope");
assert!(!ev.iter().any(|e| matches!(e, NetEvent::TopicChange { .. })), "own change filtered: {ev:?}");
}
// FJOIN members and IJOIN both surface as Join events.
#[test]
fn parses_joins() {
let ev = proto().parse(":0IR FJOIN #chan 1783845132 +nt :o,0IRAAAAAB ,0IRAAAAAC");
assert!(matches!(ev.first(), Some(NetEvent::ChannelCreate { channel }) if channel == "#chan"));
let joins: Vec<&str> = ev.iter().filter_map(|e| match e {
NetEvent::Join { uid, .. } => Some(uid.as_str()),
_ => None,
}).collect();
assert_eq!(joins, ["0IRAAAAAB", "0IRAAAAAC"]);
let ev = proto().parse(":0IRAAAAAD IJOIN #chan");
assert!(matches!(ev.as_slice(), [NetEvent::Join { uid, channel, op: false }] if uid == "0IRAAAAAD" && channel == "#chan"), "{ev:?}");
}
}

View file

@ -0,0 +1,8 @@
[package]
name = "fedserv-memoserv"
version = "0.0.1"
edition = "2021"
description = "MemoServ: deliver messages to registered users, online or not."
[dependencies]
fedserv-api = { path = "../../api" }

View file

@ -0,0 +1,27 @@
use fedserv_api::{Sender, ServiceCtx, Store};
// DEL <num>|ALL: remove a memo (or the whole mailbox).
pub fn handle(me: &str, from: &Sender, account: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ALL") => {
let count = db.memo_list(account).len();
// Delete from the end so earlier indices stay valid as we go.
for i in (0..count).rev() {
db.memo_del(account, i);
}
ctx.notice(me, from.uid, format!("Deleted all \x02{count}\x02 memo(s)."));
}
Some(numstr) => {
let Some(n) = numstr.parse::<usize>().ok().filter(|n| *n >= 1) else {
ctx.notice(me, from.uid, "Syntax: DEL <num>|ALL");
return;
};
if db.memo_del(account, n - 1) {
ctx.notice(me, from.uid, format!("Memo #\x02{n}\x02 deleted."));
} else {
ctx.notice(me, from.uid, format!("You have no memo #\x02{n}\x02."));
}
}
None => ctx.notice(me, from.uid, "Syntax: DEL <num>|ALL"),
}
}

View file

@ -0,0 +1,54 @@
//! MemoServ delivers short messages ("memos") to registered accounts whether or
//! not they are online — they read them next time they identify. Memos are typed
//! and event-logged on the account, so they persist and federate like any other
//! account data. `lib.rs` holds the dispatcher; each command lives in its own
//! file, matching NickServ/ChanServ.
use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store};
#[path = "send.rs"]
mod send;
#[path = "list.rs"]
mod list;
#[path = "read.rs"]
mod read;
#[path = "del.rs"]
mod del;
pub struct MemoServ {
pub uid: String,
}
impl Service for MemoServ {
fn nick(&self) -> &str {
"MemoServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Memo Services"
}
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();
let cmd = args.first().map(|s| s.to_ascii_uppercase());
if matches!(cmd.as_deref(), Some("HELP") | None) {
ctx.notice(me, from.uid, "MemoServ delivers messages to registered users, online or not. Commands: \x02SEND\x02 <nick> <text>, \x02LIST\x02, \x02READ\x02 <num>|NEW|ALL, \x02DEL\x02 <num>|ALL.");
return;
}
// Everything else needs you to be identified (memos are per-account).
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You must identify to NickServ before using MemoServ.");
return;
};
match cmd.as_deref() {
Some("SEND") => send::handle(me, from, account, args, ctx, db),
Some("LIST") => list::handle(me, from, account, ctx, db),
Some("READ") => read::handle(me, from, account, args, ctx, db),
Some("DEL") | Some("DELETE") => del::handle(me, from, account, args, ctx, db),
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
None => {}
}
}
}

View file

@ -0,0 +1,26 @@
use fedserv_api::{human_time, Sender, ServiceCtx, Store};
// LIST: show every memo with a one-line preview; \x02*\x02 marks unread.
pub fn handle(me: &str, from: &Sender, account: &str, ctx: &mut ServiceCtx, db: &dyn Store) {
let memos = db.memo_list(account);
if memos.is_empty() {
ctx.notice(me, from.uid, "You have no memos.");
return;
}
let unread = memos.iter().filter(|m| !m.read).count();
ctx.notice(me, from.uid, format!("Your memos ({} total, {unread} new). \x02*\x02 marks unread:", memos.len()));
for (i, m) in memos.iter().enumerate() {
let flag = if m.read { ' ' } else { '*' };
ctx.notice(me, from.uid, format!(" {}{flag} from \x02{}\x02 ({}): {}", i + 1, m.from, human_time(m.ts), preview(&m.text)));
}
}
// First line / first 80 chars, for LIST.
fn preview(text: &str) -> String {
let line = text.lines().next().unwrap_or("");
if line.chars().count() > 80 {
format!("{}", line.chars().take(80).collect::<String>())
} else {
line.to_string()
}
}

View file

@ -0,0 +1,42 @@
use fedserv_api::{human_time, MemoView, Sender, ServiceCtx, Store};
// READ <num>|NEW|ALL: display memos and mark them read.
pub fn handle(me: &str, from: &Sender, account: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("NEW") | Some("ALL") => {
let only_new = args[1].eq_ignore_ascii_case("new");
let targets: Vec<usize> = db
.memo_list(account)
.iter()
.enumerate()
.filter(|(_, m)| !only_new || !m.read)
.map(|(i, _)| i)
.collect();
if targets.is_empty() {
ctx.notice(me, from.uid, if only_new { "You have no new memos." } else { "You have no memos." });
return;
}
for i in targets {
if let Some(m) = db.memo_read(account, i) {
show(me, from, i, &m, ctx);
}
}
}
Some(numstr) => {
let Some(n) = numstr.parse::<usize>().ok().filter(|n| *n >= 1) else {
ctx.notice(me, from.uid, "Syntax: READ <num>|NEW|ALL");
return;
};
match db.memo_read(account, n - 1) {
Some(m) => show(me, from, n - 1, &m, ctx),
None => ctx.notice(me, from.uid, format!("You have no memo #\x02{n}\x02.")),
}
}
None => ctx.notice(me, from.uid, "Syntax: READ <num>|NEW|ALL"),
}
}
fn show(me: &str, from: &Sender, index: usize, m: &MemoView, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, format!("Memo #\x02{}\x02 from \x02{}\x02 ({}):", index + 1, m.from, human_time(m.ts)));
ctx.notice(me, from.uid, format!(" {}", m.text));
}

View file

@ -0,0 +1,26 @@
use fedserv_api::{Sender, ServiceCtx, Store};
// A full mailbox rejects new memos, so nobody can be flooded.
const MAX_MEMOS: usize = 30;
// SEND <nick> <text>: leave a memo on a registered account's mailbox.
pub fn handle(me: &str, from: &Sender, account: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if args.len() < 3 {
ctx.notice(me, from.uid, "Syntax: SEND <nick> <text>");
return;
}
let target = args[1];
let text = args[2..].join(" ");
let Some(dest) = db.resolve_account(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
return;
};
if db.memo_list(&dest).len() >= MAX_MEMOS {
ctx.notice(me, from.uid, format!("\x02{target}\x02's mailbox is full — they'll need to clear some memos first."));
return;
}
match db.memo_send(&dest, account, &text) {
Ok(()) => ctx.notice(me, from.uid, format!("Memo sent to \x02{target}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

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

View file

@ -0,0 +1,63 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// A sane cap so a runaway list can't bloat an account or flood a user on identify.
const MAX_AJOIN: usize = 25;
// AJOIN [ADD <#channel> [key] | DEL <#channel> | LIST]: manage your auto-join
// list — the channels NickServ joins you to each time you identify.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You must identify to NickServ to use \x02AJOIN\x02.");
return;
};
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ADD") => {
let Some(&channel) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: AJOIN ADD <#channel> [key]");
return;
};
if !channel.starts_with('#') {
ctx.notice(me, from.uid, "That doesn't look like a channel name.");
return;
}
let key = args.get(3).copied().unwrap_or("");
if db.ajoin_list(account).len() >= MAX_AJOIN {
ctx.notice(me, from.uid, format!("Your auto-join list is full (max {MAX_AJOIN})."));
return;
}
match db.ajoin_add(account, channel, key) {
Ok(true) => ctx.notice(me, from.uid, format!("Added \x02{channel}\x02 to your auto-join list.")),
Ok(false) => ctx.notice(me, from.uid, format!("Updated the key for \x02{channel}\x02 on your auto-join list.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
Some("DEL") => {
let Some(&channel) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: AJOIN DEL <#channel>");
return;
};
match db.ajoin_del(account, channel) {
Ok(true) => ctx.notice(me, from.uid, format!("Removed \x02{channel}\x02 from your auto-join list.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{channel}\x02 isn't on your auto-join list.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
None | Some("LIST") => {
let list = db.ajoin_list(account);
if list.is_empty() {
ctx.notice(me, from.uid, "Your auto-join list is empty. Add channels with \x02AJOIN ADD <#channel>\x02.");
return;
}
ctx.notice(me, from.uid, format!("Your auto-join list ({}):", list.len()));
for e in &list {
if e.key.is_empty() {
ctx.notice(me, from.uid, format!(" \x02{}\x02", e.channel));
} else {
ctx.notice(me, from.uid, format!(" \x02{}\x02 (key: {})", e.channel, e.key));
}
}
}
Some(other) => ctx.notice(me, from.uid, format!("Unknown AJOIN command \x02{other}\x02. Use \x02ADD\x02, \x02DEL\x02 or \x02LIST\x02.")),
}
}

View file

@ -0,0 +1,27 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// ALIST: list the channels the sender's account founds or has access on.
pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
return;
};
let mut rows: Vec<(String, &str)> = Vec::new();
for c in db.channels() {
if c.founder.eq_ignore_ascii_case(account) {
rows.push((c.name.clone(), "founder"));
} else if let Some(a) = c.access.iter().find(|a| a.account.eq_ignore_ascii_case(account)) {
rows.push((c.name.clone(), if a.level == "voice" { "voice" } else { "op" }));
}
}
if rows.is_empty() {
ctx.notice(me, from.uid, "You have access on no channels.");
return;
}
rows.sort_by(|a, b| a.0.cmp(&b.0));
ctx.notice(me, from.uid, format!("Channels you have access on ({}):", rows.len()));
for (chan, role) in rows {
ctx.notice(me, from.uid, format!(" \x02{chan}\x02 ({role})"));
}
}

View file

@ -0,0 +1,59 @@
use fedserv_api::{CertError, Store};
use fedserv_api::{Sender, ServiceCtx};
// CERT ADD|DEL|LIST <password> [fingerprint]: manage the TLS certificate
// fingerprints that may log in to your account via SASL EXTERNAL. Each
// subcommand is password-gated, so it needs no identified-session state.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let auth = |db: &dyn Store, password: &str| db.authenticate(from.nick, password).map(str::to_string);
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("LIST") => {
let Some(password) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: CERT LIST <password>");
return;
};
let Some(account) = auth(db, password) else {
ctx.notice(me, from.uid, "Invalid password.");
return;
};
let fps = db.certfps(&account);
if fps.is_empty() {
ctx.notice(me, from.uid, "No certificate fingerprints are registered to your account.");
} else {
ctx.notice(me, from.uid, format!("Registered fingerprints: {}", fps.join(", ")));
}
}
Some("ADD") => {
let (Some(password), Some(fp)) = (args.get(2), args.get(3)) else {
ctx.notice(me, from.uid, "Syntax: CERT ADD <password> <fingerprint>");
return;
};
let Some(account) = auth(db, password) else {
ctx.notice(me, from.uid, "Invalid password.");
return;
};
match db.certfp_add(&account, fp) {
Ok(()) => ctx.notice(me, from.uid, format!("Added fingerprint \x02{fp}\x02. You can now log in with SASL EXTERNAL.")),
Err(CertError::InUse) => ctx.notice(me, from.uid, "That fingerprint is already registered."),
Err(CertError::Invalid) => ctx.notice(me, from.uid, "That does not look like a certificate fingerprint."),
Err(CertError::NoAccount | CertError::Internal) => ctx.notice(me, from.uid, "Could not add the fingerprint, please try again later."),
}
}
Some("DEL") | Some("REMOVE") => {
let (Some(password), Some(fp)) = (args.get(2), args.get(3)) else {
ctx.notice(me, from.uid, "Syntax: CERT DEL <password> <fingerprint>");
return;
};
let Some(account) = auth(db, password) else {
ctx.notice(me, from.uid, "Invalid password.");
return;
};
match db.certfp_del(&account, fp) {
Ok(true) => ctx.notice(me, from.uid, format!("Removed fingerprint \x02{fp}\x02.")),
Ok(false) => ctx.notice(me, from.uid, "No such fingerprint is registered to your account."),
Err(_) => ctx.notice(me, from.uid, "Could not remove the fingerprint, please try again later."),
}
}
_ => ctx.notice(me, from.uid, "Syntax: CERT ADD|DEL|LIST <password> [fingerprint]"),
}
}

View file

@ -0,0 +1,26 @@
use fedserv_api::{CodeKind, Store};
use fedserv_api::{Sender, ServiceCtx};
// CONFIRM <code>: confirm your account's email with the code you were emailed.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(&code) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: CONFIRM <code>");
return;
};
let Some(account) = from.account.map(str::to_string).or_else(|| db.resolve_account(from.nick).map(str::to_string)) else {
ctx.notice(me, from.uid, "You don't have an account to confirm.");
return;
};
if db.is_verified(&account) {
ctx.notice(me, from.uid, format!("\x02{account}\x02 is already confirmed."));
return;
}
if !db.take_code(&account, CodeKind::Confirm, code) {
ctx.notice(me, from.uid, "Invalid or expired confirmation code.");
return;
}
match db.verify_account(&account) {
Ok(()) => ctx.notice(me, from.uid, format!("\x02{account}\x02 is now confirmed. Thanks!")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -0,0 +1,33 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::NetView;
// DROP <password>: delete your account. Re-authenticates as confirmation, releases
// and drops the channels you found, and logs you out.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
return;
};
let Some(&password) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: DROP <password>");
return;
};
if db.authenticate(account, password).is_none() {
ctx.notice(me, from.uid, "Invalid password.");
return;
}
let channels = db.channels_owned_by(account);
for chan in &channels {
let _ = db.drop_channel(chan);
ctx.channel_mode("", chan, "-r"); // server-sourced: release the registered mode
}
let _ = db.drop_account(account);
for uid in net.uids_logged_into(account) {
ctx.logout(&uid);
}
ctx.notice(me, from.uid, format!("Your account \x02{account}\x02 has been dropped."));
if !channels.is_empty() {
ctx.notice(me, from.uid, format!("Channels released: {}.", channels.join(", ")));
}
}

View file

@ -0,0 +1,36 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::NetView;
// GHOST/RECOVER <nick> [password]: rename off a session using a nick you own,
// either by being identified to its account or giving that account's password.
// Every argument is a distinct input the guest-rename needs (the guest nick and
// its sequence counter among them), so the count is inherent.
#[allow(clippy::too_many_arguments)]
pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
let Some(&target) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: GHOST <nick> [password]");
return;
};
let Some(account) = db.resolve_account(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
return;
};
let owns = from.account == Some(account.as_str()) || args.get(2).is_some_and(|pw| db.authenticate(target, pw).is_some());
if !owns {
ctx.notice(me, from.uid, format!("Access denied. Identify to \x02{account}\x02 or give its password."));
return;
}
let Some(ghost) = net.uid_by_nick(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("Nobody is using \x02{target}\x02."));
return;
};
if ghost == from.uid {
ctx.notice(me, from.uid, "That's you.");
return;
}
let guest = format!("{guest_nick}{guest_seq}");
*guest_seq = guest_seq.wrapping_add(1);
ctx.force_nick(&ghost, &guest);
ctx.notice(me, from.uid, format!("\x02{target}\x02 has been freed."));
}

View file

@ -0,0 +1,17 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// GLIST: list the nicks grouped to your account.
pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
return;
};
let mut nicks = db.grouped_nicks(account);
nicks.sort();
ctx.notice(me, from.uid, format!("Nicks grouped to \x02{account}\x02:"));
ctx.notice(me, from.uid, format!(" \x02{account}\x02 (main)"));
for n in nicks {
ctx.notice(me, from.uid, format!(" \x02{n}\x02"));
}
}

View file

@ -0,0 +1,23 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// GROUP <account> <password>: link your current nick to an existing account, so
// you can identify to it under this nick too.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let (Some(&account), Some(&password)) = (args.get(1), args.get(2)) else {
ctx.notice(me, from.uid, "Syntax: GROUP <account> <password>");
return;
};
let Some(canonical) = db.authenticate(account, password).map(str::to_string) else {
ctx.notice(me, from.uid, "Invalid account or password.");
return;
};
if db.account(from.nick).is_some() {
ctx.notice(me, from.uid, format!("\x02{}\x02 is itself a registered account.", from.nick));
return;
}
match db.group_nick(from.nick, &canonical) {
Ok(()) => ctx.notice(me, from.uid, format!("Your nick \x02{}\x02 is now grouped to \x02{canonical}\x02.", from.nick)),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -0,0 +1,78 @@
use fedserv_api::{human_time, Store};
use fedserv_api::{Sender, ServiceCtx};
// IDENTIFY [account] <password>: log in. The account defaults to the current
// nick, so both the bare-password and account+password forms work.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let (account_name, password) = match (args.get(1), args.get(2)) {
(Some(account), Some(password)) => (*account, *password),
(Some(password), None) => (from.nick, *password),
_ => {
ctx.notice(me, from.uid, "Syntax: IDENTIFY [account] <password>");
return;
}
};
// Distinguish an unregistered account from a wrong password.
if !db.exists(account_name) {
ctx.notice(me, from.uid, format!("\x02{account_name}\x02 isn't registered."));
return;
}
// A suspended account can't be logged into (checked before the password so it
// doesn't reveal whether the password was right). Tell them who, when and why,
// and when it lifts, so they know what happened and who to reach.
if let Some(acc) = db.resolve_account(account_name).map(str::to_string) {
if db.is_suspended(&acc) {
if let Some(s) = db.suspension(&acc) {
let mut msg = format!("\x02{acc}\x02 is suspended, so you can't log in to it right now. It was suspended by \x02{}\x02 on {}", s.by, human_time(s.ts));
if s.reason.trim().is_empty() {
msg.push('.');
} else {
msg.push_str(&format!(" — reason: {}", s.reason));
}
if let Some(exp) = s.expires {
msg.push_str(&format!(" The suspension is due to lift on {}.", human_time(exp)));
}
msg.push_str(" If you think this is a mistake, please contact the network staff.");
ctx.notice(me, from.uid, msg);
}
return;
}
}
// Refuse while throttled, so a password can't be brute-forced.
if let Some(secs) = db.auth_lockout(account_name) {
ctx.notice(me, from.uid, format!("Too many failed attempts. Please wait {secs}s and try again."));
return;
}
// Take the result as owned so the account-store borrow ends before note_auth.
match db.authenticate(account_name, password).map(str::to_string) {
Some(account) => {
db.note_auth(account_name, true);
// Already identified to this account: don't re-fire the login.
if from.account == Some(account.as_str()) {
ctx.notice(me, from.uid, format!("You're already identified as \x02{}\x02.", account));
return;
}
ctx.login(from.uid, &account);
ctx.count("nickserv.identify");
ctx.notice(me, from.uid, format!("You're now identified as \x02{}\x02. Welcome back!", account));
// Apply the account's auto-join list (AJOIN).
for entry in db.ajoin_list(&account) {
ctx.force_join(from.uid, &entry.channel, &entry.key);
}
// Apply the account's vhost (HostServ), if it has one.
if let Some(v) = db.vhost(&account) {
ctx.apply_vhost(from.uid, &v.host);
}
// Let them know about waiting memos.
let unread = db.unread_memos(&account);
if unread > 0 {
ctx.notice(me, from.uid, format!("You have \x02{unread}\x02 new memo(s). Read them with \x02/msg MemoServ READ NEW\x02."));
}
}
None => {
db.note_auth(account_name, false);
ctx.count("nickserv.identify_fail");
ctx.notice(me, from.uid, "Invalid password. Please try again.");
}
}
}

View file

@ -0,0 +1,40 @@
use fedserv_api::{human_time, Priv, Store};
use fedserv_api::{Sender, ServiceCtx};
// INFO [account]: show an account's registration details. The email and other
// private fields are shown to the account's own owner, or to an oper with the
// auspex privilege.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) {
let name = args.get(1).copied().unwrap_or(from.nick);
let Some(acct) = db.account(name) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
ctx.notice(me, from.uid, format!("Information for \x02{}\x02:", acct.name));
ctx.notice(me, from.uid, format!(" Registered : {}", human_time(acct.ts)));
// A greet is public — the bot shows it in-channel to everyone anyway.
if !acct.greet.is_empty() {
ctx.notice(me, from.uid, format!(" Greet : {}", acct.greet));
}
let is_owner = from.account == Some(acct.name.as_str());
if is_owner || from.privs.has(Priv::Auspex) {
if let Some(s) = db.suspension(&acct.name) {
ctx.notice(me, from.uid, format!(" Suspended : by \x02{}\x02{}", s.by, s.reason));
}
match &acct.email {
Some(email) if acct.verified => ctx.notice(me, from.uid, format!(" Email : {email}")),
Some(email) => ctx.notice(me, from.uid, format!(" Email : {email} (unconfirmed)")),
None => ctx.notice(me, from.uid, " Email : (none set)"),
}
let ajoin = db.ajoin_list(&acct.name);
if !ajoin.is_empty() {
ctx.notice(me, from.uid, format!(" Auto-join : {} channel(s) — see \x02AJOIN LIST\x02", ajoin.len()));
}
}
// A staff note is for operators' eyes only, never the account's owner.
if from.privs.has(Priv::Auspex) {
if let Some(note) = db.account_note(&acct.name) {
ctx.notice(me, from.uid, format!(" Staff note : {note}"));
}
}
}

View file

@ -0,0 +1,96 @@
use fedserv_api::Store;
use fedserv_api::{Sender, Service, ServiceCtx};
use fedserv_api::NetView;
#[path = "register.rs"]
mod register;
#[path = "identify.rs"]
mod identify;
#[path = "logout.rs"]
mod logout;
#[path = "cert.rs"]
mod cert;
#[path = "info.rs"]
mod info;
#[path = "alist.rs"]
mod alist;
#[path = "set.rs"]
mod set;
#[path = "drop.rs"]
mod drop;
#[path = "group.rs"]
mod group;
#[path = "glist.rs"]
mod glist;
#[path = "ungroup.rs"]
mod ungroup;
#[path = "ghost.rs"]
mod ghost;
#[path = "resetpass.rs"]
mod resetpass;
#[path = "confirm.rs"]
mod confirm;
#[path = "ajoin.rs"]
mod ajoin;
#[path = "suspend.rs"]
mod suspend;
mod noexpire;
#[path = "password.rs"]
mod password;
pub struct NickServ {
pub uid: String,
// Nick prefix assigned on LOGOUT (default "Guest"); a per-session sequence is
// appended so successive guests don't collide within a run.
pub guest_nick: String,
pub guest_seq: u32,
}
impl Service for NickServ {
fn nick(&self) -> &str {
"NickServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Nickname Services"
}
fn manages_accounts(&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();
let cmd = args.first().map(|s| s.to_ascii_uppercase());
// When identity is owned externally (the website), IRC can log in but not
// create or change an account — those commands are refused.
if db.external_accounts() && matches!(cmd.as_deref(), Some("REGISTER" | "DROP" | "RESETPASS" | "CONFIRM" | "CERT" | "GROUP" | "UNGROUP")) {
ctx.notice(me, from.uid, "Your account is managed on the website — register or change it there. From IRC you can only \x02IDENTIFY\x02.");
return;
}
match cmd.as_deref() {
Some("REGISTER") => register::handle(me, from, args, ctx),
Some("IDENTIFY") | Some("ID") => identify::handle(me, from, args, ctx, db),
Some("LOGOUT") | Some("LOGOFF") => logout::handle(me, &self.guest_nick, &mut self.guest_seq, from, ctx),
Some("CERT") => cert::handle(me, from, args, ctx, db),
Some("INFO") => info::handle(me, from, args, ctx, db),
Some("ALIST") => alist::handle(me, from, ctx, db),
Some("SET") => set::handle(me, from, args, ctx, db),
Some("DROP") => drop::handle(me, from, args, ctx, net, db),
Some("GROUP") => group::handle(me, from, args, ctx, db),
Some("GLIST") => glist::handle(me, from, ctx, db),
Some("UNGROUP") => ungroup::handle(me, from, args, ctx, db),
Some("GHOST") | Some("RECOVER") => ghost::handle(me, &self.guest_nick, &mut self.guest_seq, from, args, ctx, net, db),
Some("RESETPASS") => resetpass::handle(me, from, args, ctx, db),
Some("CONFIRM") => confirm::handle(me, from, args, ctx, db),
Some("AJOIN") => ajoin::handle(me, from, args, ctx, db),
Some("SUSPEND") => suspend::handle(me, from, args, ctx, net, db, true),
Some("UNSUSPEND") => suspend::handle(me, from, args, ctx, net, db, false),
Some("NOEXPIRE") => noexpire::handle(me, from, args, ctx, db),
Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 <password> [email], \x02IDENTIFY\x02 [account] <password>, \x02INFO\x02 [account], \x02ALIST\x02, \x02GROUP\x02/\x02GLIST\x02/\x02UNGROUP\x02, \x02GHOST\x02 <nick> [password], \x02SET\x02 PASSWORD|EMAIL, \x02AJOIN\x02 ADD|DEL|LIST, \x02RESETPASS\x02 <account>, \x02CONFIRM\x02 <code>, \x02DROP\x02 <password>, \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST <password> [fingerprint]. Operators also have \x02SUSPEND\x02/\x02UNSUSPEND\x02 and \x02NOEXPIRE\x02 <account> {ON|OFF}."),
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
None => {}
}
}
}

View file

@ -0,0 +1,14 @@
use fedserv_api::{Sender, ServiceCtx};
// LOGOUT: log out and rename to a guest nick (prefix + a per-logout sequence).
pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, ctx: &mut ServiceCtx) {
if from.account.is_none() {
ctx.notice(me, from.uid, "You're not logged in.");
return;
}
let guest = format!("{guest_nick}{guest_seq}");
*guest_seq = guest_seq.wrapping_add(1);
ctx.logout(from.uid);
ctx.force_nick(from.uid, &guest);
ctx.notice(me, from.uid, format!("You're now logged out. Your nick is now \x02{}\x02.", guest));
}

View file

@ -0,0 +1,33 @@
use fedserv_api::{Priv, Sender, ServiceCtx, Store};
// NOEXPIRE <account> {ON|OFF}: pin an account so inactivity-expiry never drops
// it (or lift the pin). Oper-only (Priv::Admin) — protecting a record from
// expiry is a staff decision, not self-service.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — that command is for services operators.");
return;
}
let (Some(&target), Some(on)) = (args.get(1), args.get(2).and_then(|s| parse_toggle(s))) else {
ctx.notice(me, from.uid, "Syntax: NOEXPIRE <account> {ON|OFF}");
return;
};
let Some(account) = db.resolve_account(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
return;
};
match db.set_account_noexpire(&account, on) {
Ok(true) if on => ctx.notice(me, from.uid, format!("\x02{account}\x02 will no longer expire.")),
Ok(true) => ctx.notice(me, from.uid, format!("\x02{account}\x02 can expire from inactivity again.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{account}\x02 was already set that way.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn parse_toggle(s: &str) -> Option<bool> {
match s.to_ascii_uppercase().as_str() {
"ON" | "TRUE" | "YES" => Some(true),
"OFF" | "FALSE" | "NO" => Some(false),
_ => None,
}
}

View file

@ -0,0 +1,50 @@
// Password policy for REGISTER and SET PASSWORD: a minimum length and forbidding
// the password matching the nick, plus an upper bound so a huge password can't
// be used to burn CPU in the key
// derivation. Length is measured in characters, the cap in bytes (what the
// hasher actually processes).
pub const MIN_PASSWORD_CHARS: usize = 8;
pub const MAX_PASSWORD_BYTES: usize = 300;
// Validate a new password for the account/nick it will protect. On rejection,
// returns the message to show the user.
pub fn validate_password(password: &str, owner: &str) -> Result<(), &'static str> {
if password.chars().count() < MIN_PASSWORD_CHARS {
return Err("That password is too short — please use at least 8 characters.");
}
if password.len() > MAX_PASSWORD_BYTES {
return Err("That password is too long.");
}
if password.eq_ignore_ascii_case(owner) {
return Err("Your password can't be the same as your nick. Please choose another.");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_a_reasonable_password() {
assert!(validate_password("correct horse", "alice").is_ok());
}
#[test]
fn rejects_short_password() {
assert!(validate_password("short", "alice").is_err());
assert!(validate_password("1234567", "alice").is_err()); // 7 chars
assert!(validate_password("12345678", "alice").is_ok()); // 8 chars
}
#[test]
fn rejects_password_equal_to_nick() {
assert!(validate_password("Password", "password").is_err()); // case-insensitive
}
#[test]
fn rejects_overlong_password() {
assert!(validate_password(&"x".repeat(MAX_PASSWORD_BYTES + 1), "alice").is_err());
}
}

View file

@ -0,0 +1,21 @@
use fedserv_api::{Sender, ServiceCtx};
use fedserv_api::RegReply;
// REGISTER <password> [email]: register the sender's current nick. The engine
// derives the password off-thread, commits, and answers.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx) {
let Some(password) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: REGISTER <password> [email]");
return;
};
if let Err(reason) = crate::password::validate_password(password, from.nick) {
ctx.notice(me, from.uid, reason);
return;
}
let email = args.get(2).map(|s| s.to_string());
ctx.defer_register(from.nick, *password, email, RegReply::NickServ {
agent: me.to_string(),
uid: from.uid.to_string(),
nick: from.nick.to_string(),
});
}

View file

@ -0,0 +1,39 @@
use fedserv_api::{CodeKind, Store};
use fedserv_api::{Sender, ServiceCtx};
// RESETPASS <account>: email a reset code to the address on file.
// RESETPASS <account> <code> <newpassword>: complete the reset with that code.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !db.email_enabled() {
ctx.notice(me, from.uid, "Password reset by email isn't available here.");
return;
}
match (args.get(1), args.get(2), args.get(3)) {
(Some(&name), None, None) => {
let Some(canonical) = db.resolve_account(name).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
let Some(email) = db.account(&canonical).and_then(|a| a.email.clone()) else {
ctx.notice(me, from.uid, "That account has no email on file, so it can't be reset.");
return;
};
let code = db.issue_code(&canonical, CodeKind::Reset);
let mail = fedserv_api::email::reset(db.email_brand(), db.email_accent(), db.email_logo(), &canonical, &code);
ctx.send_email(email, mail.subject, mail.text, Some(mail.html));
ctx.notice(me, from.uid, format!("A reset code has been emailed to the address on file for \x02{canonical}\x02."));
}
(Some(&name), Some(&code), Some(&newpass)) => {
let Some(canonical) = db.resolve_account(name).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
return;
};
if !db.take_code(&canonical, CodeKind::Reset, code) {
ctx.notice(me, from.uid, "Invalid or expired reset code.");
return;
}
ctx.defer_password(&canonical, newpass, me, from.uid);
}
_ => ctx.notice(me, from.uid, "Syntax: RESETPASS <account> [<code> <newpassword>]"),
}
}

View file

@ -0,0 +1,50 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// SET PASSWORD <newpassword> | SET EMAIL [address]: change your account settings.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
return;
};
let sub = args.get(1).map(|s| s.to_ascii_uppercase());
// Credential/identity fields are owned by the website in external mode.
if db.external_accounts() && matches!(sub.as_deref(), Some("PASSWORD" | "PASS" | "EMAIL")) {
ctx.notice(me, from.uid, "That's managed on the website — change it there.");
return;
}
match sub.as_deref() {
Some("PASSWORD") | Some("PASS") => {
let Some(&password) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: SET PASSWORD <newpassword>");
return;
};
if let Err(reason) = crate::password::validate_password(password, account) {
ctx.notice(me, from.uid, reason);
return;
}
// The link layer derives the new password off-thread, then commits it.
ctx.defer_password(account, password, me, from.uid);
}
Some("EMAIL") => {
let email = args.get(2).map(|s| s.to_string());
let cleared = email.is_none();
match db.set_email(account, email) {
Ok(()) if cleared => ctx.notice(me, from.uid, format!("Email for \x02{account}\x02 cleared.")),
Ok(()) => ctx.notice(me, from.uid, format!("Email for \x02{account}\x02 updated.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
Some("GREET") => {
// A bot shows this when you join a greet-enabled channel; no arg clears it.
let greet = if args.len() > 2 { args[2..].join(" ") } else { String::new() };
let cleared = greet.is_empty();
match db.set_greet(account, &greet) {
Ok(()) if cleared => ctx.notice(me, from.uid, "Your greet has been cleared."),
Ok(()) => ctx.notice(me, from.uid, format!("Your greet is now: {greet}")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
_ => ctx.notice(me, from.uid, "Syntax: SET PASSWORD <newpassword> | SET EMAIL [address] | SET GREET [message]"),
}
}

View file

@ -0,0 +1,54 @@
use fedserv_api::{parse_duration, NetView, Priv, Sender, ServiceCtx, Store};
use std::time::{SystemTime, UNIX_EPOCH};
// SUSPEND <account> [+expiry] [reason] / UNSUSPEND <account>: freeze or unfreeze
// an account. Its data is kept but it can't be logged into. Oper-only
// (Priv::Suspend). `suspending` selects SUSPEND vs UNSUSPEND.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store, suspending: bool) {
if !from.privs.has(Priv::Suspend) {
ctx.notice(me, from.uid, "Access denied — that command is for services operators.");
return;
}
let Some(&target) = args.get(1) else {
let syntax = if suspending { "Syntax: SUSPEND <account> [+expiry] [reason]" } else { "Syntax: UNSUSPEND <account>" };
ctx.notice(me, from.uid, syntax);
return;
};
let Some(account) = db.resolve_account(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
return;
};
if !suspending {
match db.unsuspend_account(&account) {
Ok(true) => ctx.notice(me, from.uid, format!("\x02{account}\x02 is no longer suspended.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{account}\x02 isn't suspended.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
return;
}
// Optional leading +expiry (e.g. +30d), then a free-text reason.
let mut rest = &args[2..];
let expires = rest.first().and_then(|t| t.strip_prefix('+')).and_then(parse_duration).map(|secs| now_unix() + secs);
if expires.is_some() {
rest = &rest[1..];
}
let reason = if rest.is_empty() { "No reason given.".to_string() } else { rest.join(" ") };
let by = from.account.unwrap_or(from.nick);
match db.suspend_account(&account, by, &reason, expires) {
Ok(()) => {
// Log out every session currently identified to the account.
for uid in net.uids_logged_into(&account) {
ctx.logout(&uid);
}
let expiry = if expires.is_some() { " (with expiry)" } else { "" };
ctx.notice(me, from.uid, format!("\x02{account}\x02 is now suspended{expiry}."));
}
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}
fn now_unix() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}

View file

@ -0,0 +1,24 @@
use fedserv_api::Store;
use fedserv_api::{Sender, ServiceCtx};
// UNGROUP [nick]: remove a nick grouped to your account (defaults to your current nick).
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(account) = from.account else {
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
return;
};
let nick = args.get(1).copied().unwrap_or(from.nick);
if nick.eq_ignore_ascii_case(account) {
ctx.notice(me, from.uid, "You can't ungroup your main account name.");
return;
}
if db.resolve_account(nick).is_none_or(|a| !a.eq_ignore_ascii_case(account)) {
ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't grouped to your account."));
return;
}
match db.ungroup_nick(nick) {
Ok(true) => ctx.notice(me, from.uid, format!("\x02{nick}\x02 is no longer grouped to \x02{account}\x02.")),
Ok(false) => ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't a grouped nick.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
}

View file

@ -0,0 +1,8 @@
[package]
name = "fedserv-operserv"
version = "0.0.1"
edition = "2021"
description = "OperServ: network operator tools, starting with AKILL network bans."
[dependencies]
fedserv-api = { path = "../../api" }

View file

@ -0,0 +1,38 @@
use fedserv_api::{NetView, Priv, Sender, ServiceCtx, Store};
use std::collections::HashSet;
// CHANKILL <#channel> [reason]: AKILL every user in a channel by host, clearing
// a spam or attack channel in one shot. The G-lines the ircd applies also kill
// the matching sessions. Admin-only, and never bans the operator running it.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — CHANKILL needs the \x02admin\x02 privilege.");
return;
}
let Some(&chan) = args.get(1).filter(|c| c.starts_with('#') || c.starts_with('&')) else {
ctx.notice(me, from.uid, "Syntax: CHANKILL <#channel> [reason]");
return;
};
let members = net.channel_members(chan);
if members.is_empty() {
ctx.notice(me, from.uid, format!("No one is in \x02{chan}\x02 (or it isn't tracked)."));
return;
}
let reason = if args.len() > 2 { args[2..].join(" ") } else { "Channel cleared by services".to_string() };
let setter = from.account.unwrap_or(from.nick);
let mut seen: HashSet<String> = HashSet::new();
let mut banned = 0;
for uid in &members {
if uid.as_str() == from.uid {
continue; // never ban yourself
}
let Some(host) = net.host_of(uid) else { continue };
if seen.insert(host.to_string()) {
let mask = format!("*@{host}");
let _ = db.akill_add("G", &mask, setter, &reason, None);
ctx.add_line("G", &mask, from.nick, 0, &reason);
banned += 1;
}
}
ctx.notice(me, from.uid, format!("CHANKILL on \x02{chan}\x02: \x02{banned}\x02 host(s) AKILL'd."));
}

View file

@ -0,0 +1,40 @@
use fedserv_api::{Priv, Sender, ServiceCtx, Store};
// DEFCON [1-5]: read or set the network defence level. 5 is normal; each lower
// level adds a restriction — 4 freezes channel registrations, 3 freezes all
// registrations, 2 also caps everyone to one session, 1 is a full lockdown that
// turns away new connections. Changing it announces the level to the network.
// Admin-only.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — DEFCON needs the \x02admin\x02 privilege.");
return;
}
match args.get(1) {
None => ctx.notice(me, from.uid, format!("The network is at \x02DEFCON {}\x02{}.", db.defcon(), describe(db.defcon()))),
Some(&arg) => match arg.parse::<u8>() {
Ok(level) if (1..=5).contains(&level) => {
db.set_defcon(level);
ctx.notice(me, from.uid, format!("DEFCON set to \x02{level}\x02{}.", describe(level)));
// Let everyone know the posture changed.
let msg = if level == 5 {
"The network is back to normal operations (DEFCON 5).".to_string()
} else {
format!("Network defence level is now \x02DEFCON {level}\x02: {}.", describe(level))
};
ctx.global(me, msg);
}
_ => ctx.notice(me, from.uid, "Syntax: DEFCON [1-5] (5 = normal, 1 = lockdown)"),
},
}
}
fn describe(level: u8) -> &'static str {
match level {
5 => "normal operations",
4 => "channel registrations frozen",
3 => "all registrations frozen",
2 => "registrations frozen and sessions capped to one per host",
_ => "full lockdown, no new connections",
}
}

View file

@ -0,0 +1,18 @@
use fedserv_api::{Priv, Sender, ServiceCtx};
// GLOBAL <message>: send an announcement to every user on the network. Admin-
// only — it reaches everyone, so it's the heaviest voice services have.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — GLOBAL needs the \x02admin\x02 privilege.");
return;
}
let message = args[1..].join(" ");
if message.trim().is_empty() {
ctx.notice(me, from.uid, "Syntax: GLOBAL <message>");
return;
}
// A marker so users can tell an official announcement from a normal notice.
ctx.global(me, format!("[\x02Network\x02] {message}"));
ctx.notice(me, from.uid, "Your announcement has been sent to the whole network.");
}

View file

@ -0,0 +1,80 @@
use fedserv_api::{parse_duration, Priv, Sender, ServiceCtx, Store};
use std::time::{SystemTime, UNIX_EPOCH};
// IGNORE ADD [+expiry] <mask> [reason] | DEL <mask> | LIST: services silently
// drop commands from a matching user. A mask with an '@' matches nick!*@host
// (ident isn't tracked); a bare mask matches the nick. Admin-only, node-local.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — IGNORE needs the \x02admin\x02 privilege.");
return;
}
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ADD") => add(me, from, &args[2..], ctx, db),
Some("DEL") | Some("REMOVE") => del(me, from, args.get(2).copied(), ctx, db),
Some("LIST") | Some("VIEW") => list(me, from, ctx, db),
_ => ctx.notice(me, from.uid, "Syntax: IGNORE ADD [+expiry] <mask> [reason] | IGNORE DEL <mask> | IGNORE LIST"),
}
}
fn add(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let mut rest = rest;
let duration = rest.first().and_then(|t| t.strip_prefix('+')).and_then(parse_duration);
if duration.is_some() {
rest = &rest[1..];
}
let Some((&mask, reason_words)) = rest.split_first() else {
ctx.notice(me, from.uid, "Syntax: IGNORE ADD [+expiry] <mask> [reason]");
return;
};
if mask.is_empty() || mask.chars().all(|c| c == '*' || c == '?') {
ctx.notice(me, from.uid, "That mask is too wide.");
return;
}
let reason = if reason_words.is_empty() { "No reason given".to_string() } else { reason_words.join(" ") };
let expires = duration.map(|secs| now() + secs);
db.ignore_add(mask, &reason, expires);
let expiry = if expires.is_some() { " (temporary)" } else { "" };
ctx.notice(me, from.uid, format!("Now ignoring \x02{mask}\x02{expiry}."));
}
fn del(me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(mask) = arg else {
ctx.notice(me, from.uid, "Syntax: IGNORE DEL <mask>");
return;
};
if db.ignore_del(mask) {
ctx.notice(me, from.uid, format!("No longer ignoring \x02{mask}\x02."));
} else {
ctx.notice(me, from.uid, format!("\x02{mask}\x02 isn't on the ignore list."));
}
}
fn list(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let ignores = db.ignores();
if ignores.is_empty() {
ctx.notice(me, from.uid, "The services ignore list is empty.");
return;
}
for (i, ig) in ignores.iter().enumerate() {
let expiry = match ig.expires {
Some(e) => format!(", expires in {}", human_secs(e.saturating_sub(now()))),
None => String::new(),
};
ctx.notice(me, from.uid, format!("{}. \x02{}\x02{}{}", i + 1, ig.mask, ig.reason, expiry));
}
ctx.notice(me, from.uid, format!("End of ignore list ({} shown).", ignores.len()));
}
fn human_secs(secs: u64) -> String {
match secs {
0 => "moments".to_string(),
s if s < 3600 => format!("{}m", s / 60),
s if s < 86_400 => format!("{}h", s / 3600),
s => format!("{}d", s / 86_400),
}
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}

View file

@ -0,0 +1,53 @@
use fedserv_api::{Priv, Sender, ServiceCtx, Store};
// INFO <target> | INFO ADD <target> <note> | INFO DEL <target>: attach a staff
// note to an account or channel (a `#name` is a channel, else an account). The
// note is shown to operators in that service's INFO. Admin-only.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — INFO needs the \x02admin\x02 privilege.");
return;
}
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ADD") | Some("SET") => set(me, from, args.get(2).copied(), args.get(3..).unwrap_or(&[]), ctx, db),
Some("DEL") | Some("CLEAR") => set(me, from, args.get(2).copied(), &[], ctx, db),
Some(_) => show(me, from, args[1], ctx, db),
None => ctx.notice(me, from.uid, "Syntax: INFO <target> | INFO ADD <target> <note> | INFO DEL <target>"),
}
}
// `note` empty clears; otherwise it's the joined note words.
fn set(me: &str, from: &Sender, target: Option<&str>, note: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(target) = target else {
ctx.notice(me, from.uid, "Syntax: INFO ADD <target> <note> | INFO DEL <target>");
return;
};
let text = note.join(" ");
let value = if text.trim().is_empty() { None } else { Some(text) };
let (ok, kind) = if target.starts_with('#') || target.starts_with('&') {
(db.set_channel_note(target, value.clone()), "channel")
} else if let Some(account) = db.resolve_account(target).map(str::to_string) {
(db.set_account_note(&account, value.clone()), "account")
} else {
(false, "account")
};
if !ok {
ctx.notice(me, from.uid, format!("There's no {kind} \x02{target}\x02."));
} else if value.is_some() {
ctx.notice(me, from.uid, format!("Staff note set on \x02{target}\x02."));
} else {
ctx.notice(me, from.uid, format!("Staff note on \x02{target}\x02 cleared."));
}
}
fn show(me: &str, from: &Sender, target: &str, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let note = if target.starts_with('#') || target.starts_with('&') {
db.channel_note(target)
} else {
db.resolve_account(target).map(str::to_string).and_then(|a| db.account_note(&a))
};
match note {
Some(n) => ctx.notice(me, from.uid, format!("Staff note on \x02{target}\x02: {n}")),
None => ctx.notice(me, from.uid, format!("No staff note on \x02{target}\x02.")),
}
}

View file

@ -0,0 +1,49 @@
use fedserv_api::{Priv, Sender, ServiceCtx, Store};
// JUPE <server.name> [reason] | JUPE DEL <server.name> | JUPE LIST: hold a
// server name with a fake server so a rogue one can't link (or lift it). Admin-
// only. Node-local: the introducing node owns the jupe.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — JUPE needs the \x02admin\x02 privilege.");
return;
}
match args.get(1) {
Some(&sub) if sub.eq_ignore_ascii_case("DEL") || sub.eq_ignore_ascii_case("REMOVE") => del(me, from, args.get(2).copied(), ctx, db),
Some(&sub) if sub.eq_ignore_ascii_case("LIST") => list(me, from, ctx, db),
Some(&name) if name.contains('.') => {
let reason = if args.len() > 2 { args[2..].join(" ") } else { "Juped by services".to_string() };
let by = from.account.unwrap_or(from.nick);
let sid = db.jupe_add(name, &format!("({by}) {reason}"));
ctx.jupe(name, &sid, &format!("({by}) {reason}"));
ctx.notice(me, from.uid, format!("\x02{name}\x02 is now juped."));
}
_ => ctx.notice(me, from.uid, "Syntax: JUPE <server.name> [reason] | JUPE DEL <server.name> | JUPE LIST"),
}
}
fn del(me: &str, from: &Sender, name: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let Some(name) = name else {
ctx.notice(me, from.uid, "Syntax: JUPE DEL <server.name>");
return;
};
match db.jupe_del(name) {
Some(sid) => {
ctx.squit(&sid, "Jupe lifted");
ctx.notice(me, from.uid, format!("The jupe on \x02{name}\x02 has been lifted."));
}
None => ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't juped.")),
}
}
fn list(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) {
let jupes = db.jupes();
if jupes.is_empty() {
ctx.notice(me, from.uid, "No servers are juped.");
return;
}
for (name, sid, reason) in &jupes {
ctx.notice(me, from.uid, format!(" \x02{name}\x02 ({sid}) — {reason}"));
}
ctx.notice(me, from.uid, format!("End of jupe list ({} shown).", jupes.len()));
}

View file

@ -0,0 +1,26 @@
use fedserv_api::{NetView, Priv, Sender, ServiceCtx};
// KICK <#channel> <nick> [reason]: remove a user from a channel, sourced from
// OperServ so it's clearly a staff action. Admin-only.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — KICK needs the \x02admin\x02 privilege.");
return;
}
let Some(&chan) = args.get(1).filter(|c| c.starts_with('#') || c.starts_with('&')) else {
ctx.notice(me, from.uid, "Syntax: KICK <#channel> <nick> [reason]");
return;
};
let Some(&target) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: KICK <#channel> <nick> [reason]");
return;
};
let Some(uid) = net.uid_by_nick(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("There's no \x02{target}\x02 online."));
return;
};
let by = from.account.unwrap_or(from.nick);
let reason = if args.len() > 3 { args[3..].join(" ") } else { "Removed by services".to_string() };
ctx.kick(me, chan, &uid, &format!("({by}) {reason}"));
ctx.notice(me, from.uid, format!("\x02{target}\x02 kicked from \x02{chan}\x02."));
}

View file

@ -0,0 +1,21 @@
use fedserv_api::{NetView, Priv, Sender, ServiceCtx};
// KILL <nick> [reason]: disconnect a user from the network. Admin-only.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView) {
if !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, "Access denied — KILL needs the \x02admin\x02 privilege.");
return;
}
let Some(&target) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: KILL <nick> [reason]");
return;
};
let Some(uid) = net.uid_by_nick(target).map(str::to_string) else {
ctx.notice(me, from.uid, format!("There's no \x02{target}\x02 online."));
return;
};
let by = from.account.unwrap_or(from.nick);
let reason = if args.len() > 2 { args[2..].join(" ") } else { "No reason given".to_string() };
ctx.kill(me, &uid, &format!("({by}) {reason}"));
ctx.notice(me, from.uid, format!("\x02{target}\x02 has been disconnected."));
}

View file

@ -0,0 +1,92 @@
//! OperServ gives services operators network-wide tools: AKILL/SQLINE network
//! bans (event-sourced, lazily expiring, re-asserted at burst), a GLOBAL
//! announcement to every user, and KILL to disconnect one. `lib.rs` dispatches;
//! each command family is its own file.
use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store};
#[path = "xline.rs"]
mod xline;
#[path = "global.rs"]
mod global;
#[path = "kill.rs"]
mod kill;
#[path = "kick.rs"]
mod kick;
#[path = "mode.rs"]
mod mode;
#[path = "ignore.rs"]
mod ignore;
#[path = "stats.rs"]
mod stats;
#[path = "svs.rs"]
mod svs;
#[path = "info.rs"]
mod info;
#[path = "oper.rs"]
mod oper;
#[path = "session.rs"]
mod session;
#[path = "jupe.rs"]
mod jupe;
#[path = "chankill.rs"]
mod chankill;
#[path = "logsearch.rs"]
mod logsearch;
#[path = "defcon.rs"]
mod defcon;
pub struct OperServ {
pub uid: String,
}
impl Service for OperServ {
fn nick(&self) -> &str {
"OperServ"
}
fn uid(&self) -> &str {
&self.uid
}
fn gecos(&self) -> &str {
"Operator Service"
}
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();
// Every OperServ command is operator-only: reveal nothing to others.
if !from.privs.any() {
ctx.notice(me, from.uid, "Access denied — OperServ is for services operators.");
return;
}
match args.first().copied() {
Some(cmd) if cmd.eq_ignore_ascii_case("AKILL") => xline::AKILL.handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("SQLINE") => xline::SQLINE.handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("SNLINE") => xline::SNLINE.handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("SHUN") => xline::SHUN.handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("CBAN") => xline::CBAN.handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("GLOBAL") => global::handle(me, from, args, ctx),
Some(cmd) if cmd.eq_ignore_ascii_case("KILL") => kill::handle(me, from, args, ctx, net),
Some(cmd) if cmd.eq_ignore_ascii_case("KICK") => kick::handle(me, from, args, ctx, net),
Some(cmd) if cmd.eq_ignore_ascii_case("MODE") => mode::handle(me, from, args, ctx, net),
Some(cmd) if cmd.eq_ignore_ascii_case("IGNORE") => ignore::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("STATS") => stats::handle(me, from, db, ctx),
Some(cmd) if cmd.eq_ignore_ascii_case("SVSNICK") => svs::nick(me, from, args, ctx, net),
Some(cmd) if cmd.eq_ignore_ascii_case("SVSJOIN") => svs::join(me, from, args, ctx, net),
Some(cmd) if cmd.eq_ignore_ascii_case("INFO") => info::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("OPER") => oper::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("SESSION") => session::handle_session(me, from, args, ctx, net),
Some(cmd) if cmd.eq_ignore_ascii_case("EXCEPTION") => session::handle_exception(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("JUPE") => jupe::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("CHANKILL") => chankill::handle(me, from, args, ctx, net, db),
Some(cmd) if cmd.eq_ignore_ascii_case("LOGSEARCH") => logsearch::handle(me, from, args, ctx, net),
Some(cmd) if cmd.eq_ignore_ascii_case("DEFCON") => defcon::handle(me, from, args, ctx, db),
Some(cmd) if cmd.eq_ignore_ascii_case("HELP") => help(me, from, ctx),
None => help(me, from, ctx),
Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02HELP\x02.")),
}
}
}
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, "OperServ holds network operator tools (Priv::Admin): \x02AKILL\x02 ADD|DEL|LIST (user@host bans), \x02SQLINE\x02 ADD|DEL|LIST (nick bans), \x02SNLINE\x02 ADD|DEL|LIST (realname bans), \x02SHUN\x02 ADD|DEL|LIST (silence a host), \x02CBAN\x02 ADD|DEL|LIST (ban a channel name), \x02CHANKILL\x02 <#chan> (clear a channel), \x02GLOBAL\x02 <message> (announce to everyone), \x02KILL\x02 <nick> [reason] (disconnect a user), \x02KICK\x02 <#chan> <nick> [reason], \x02MODE\x02 <#chan> <modes> [params], \x02IGNORE\x02 ADD|DEL|LIST (silence a user from services), \x02STATS\x02 (enforcement overview), \x02SVSNICK\x02 <nick> <newnick>, \x02SVSJOIN\x02 <nick> <#chan> [key], \x02INFO\x02 ADD|DEL <target> (staff notes — bulletins are on \x02InfoServ\x02), \x02OPER\x02 ADD|DEL|LIST (runtime operators), \x02SESSION\x02 LIST|VIEW + \x02EXCEPTION\x02 ADD|DEL|LIST (per-IP session limits), \x02JUPE\x02 <server> | DEL | LIST, \x02LOGSEARCH\x02 [pattern] (search the action log), \x02DEFCON\x02 [1-5] (network defence level).");
}

Some files were not shown because too many files have changed in this diff Show more