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:
parent
6f76f9722c
commit
ad2a623120
116 changed files with 69 additions and 46 deletions
8
modules/botserv/Cargo.toml
Normal file
8
modules/botserv/Cargo.toml
Normal 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" }
|
||||
45
modules/botserv/src/assign.rs
Normal file
45
modules/botserv/src/assign.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
63
modules/botserv/src/badwords.rs
Normal file
63
modules/botserv/src/badwords.rs
Normal 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."));
|
||||
}
|
||||
73
modules/botserv/src/bot.rs
Normal file
73
modules/botserv/src/bot.rs
Normal 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.")),
|
||||
}
|
||||
}
|
||||
17
modules/botserv/src/copy.rs
Normal file
17
modules/botserv/src/copy.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
55
modules/botserv/src/info.rs
Normal file
55
modules/botserv/src/info.rs
Normal 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
143
modules/botserv/src/kick.rs
Normal 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."));
|
||||
}
|
||||
73
modules/botserv/src/lib.rs
Normal file
73
modules/botserv/src/lib.rs
Normal 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.")),
|
||||
}
|
||||
}
|
||||
}
|
||||
35
modules/botserv/src/say.rs
Normal file
35
modules/botserv/src/say.rs
Normal 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
104
modules/botserv/src/set.rs
Normal 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.")),
|
||||
}
|
||||
}
|
||||
77
modules/botserv/src/trigger.rs
Normal file
77
modules/botserv/src/trigger.rs
Normal 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."));
|
||||
}
|
||||
8
modules/chanfix/Cargo.toml
Normal file
8
modules/chanfix/Cargo.toml
Normal 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
110
modules/chanfix/src/lib.rs
Normal 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."));
|
||||
}
|
||||
}
|
||||
8
modules/chanserv/Cargo.toml
Normal file
8
modules/chanserv/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[package]
|
||||
name = "fedserv-chanserv"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
description = "ChanServ channel-registration service module for fedserv."
|
||||
|
||||
[dependencies]
|
||||
fedserv-api = { path = "../../api" }
|
||||
70
modules/chanserv/src/access.rs
Normal file
70
modules/chanserv/src/access.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
use fedserv_api::Store;
|
||||
use fedserv_api::{Sender, ServiceCtx};
|
||||
|
||||
// ACCESS <#channel> LIST | ADD <account> <op|voice> | DEL <account>
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> LIST | ADD <account> <op|voice> | DEL <account>");
|
||||
return;
|
||||
};
|
||||
match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
None | Some("LIST") => match db.channel(chan) {
|
||||
None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")),
|
||||
Some(info) => {
|
||||
ctx.notice(me, from.uid, format!("Access list for \x02{}\x02:", info.name));
|
||||
ctx.notice(me, from.uid, format!(" \x02{}\x02 (founder)", info.founder));
|
||||
for a in &info.access {
|
||||
ctx.notice(me, from.uid, format!(" \x02{}\x02 ({})", a.account, a.level));
|
||||
}
|
||||
}
|
||||
},
|
||||
Some("ADD") => {
|
||||
let (Some(&account), Some(&level)) = (args.get(3), args.get(4)) else {
|
||||
ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> ADD <account> <op|voice>");
|
||||
return;
|
||||
};
|
||||
let level = level.to_ascii_lowercase();
|
||||
if level != "op" && level != "voice" {
|
||||
ctx.notice(me, from.uid, "Level must be \x02op\x02 or \x02voice\x02.");
|
||||
return;
|
||||
}
|
||||
if !is_founder(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
match db.access_add(chan, account, &level) {
|
||||
Ok(()) => ctx.notice(me, from.uid, format!("Added \x02{account}\x02 to \x02{chan}\x02 as \x02{level}\x02.")),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
Some("DEL") => {
|
||||
let Some(&account) = args.get(3) else {
|
||||
ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> DEL <account>");
|
||||
return;
|
||||
};
|
||||
if !is_founder(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
match db.access_del(chan, account) {
|
||||
Ok(true) => ctx.notice(me, from.uid, format!("Removed \x02{account}\x02 from \x02{chan}\x02.")),
|
||||
Ok(false) => ctx.notice(me, from.uid, format!("\x02{account}\x02 has no access to \x02{chan}\x02.")),
|
||||
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||
}
|
||||
}
|
||||
_ => ctx.notice(me, from.uid, "Syntax: ACCESS <#channel> LIST | ADD <account> <op|voice> | DEL <account>"),
|
||||
}
|
||||
}
|
||||
|
||||
// True if `from` is the channel's founder; otherwise notices why and returns false.
|
||||
fn is_founder(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &dyn Store) -> bool {
|
||||
match db.channel(chan) {
|
||||
None => {
|
||||
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
|
||||
false
|
||||
}
|
||||
Some(info) if from.account != Some(info.founder.as_str()) => {
|
||||
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can change access."));
|
||||
false
|
||||
}
|
||||
Some(_) => true,
|
||||
}
|
||||
}
|
||||
52
modules/chanserv/src/akick.rs
Normal file
52
modules/chanserv/src/akick.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
25
modules/chanserv/src/ban.rs
Normal file
25
modules/chanserv/src/ban.rs
Normal 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);
|
||||
}
|
||||
32
modules/chanserv/src/clone.rs
Normal file
32
modules/chanserv/src/clone.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use fedserv_api::Store;
|
||||
use fedserv_api::{Sender, ServiceCtx};
|
||||
|
||||
// CLONE <source> <target>: copy a channel's settings (mode lock, access,
|
||||
// auto-kick, description, entry message) into another. Founder of both.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||
let (Some(&src), Some(&dest)) = (args.get(1), args.get(2)) else {
|
||||
ctx.notice(me, from.uid, "Syntax: CLONE <source> <target>");
|
||||
return;
|
||||
};
|
||||
let (Some(sinfo), Some(dinfo)) = (db.channel(src), db.channel(dest)) else {
|
||||
ctx.notice(me, from.uid, "Both channels must be registered.");
|
||||
return;
|
||||
};
|
||||
if from.account != Some(sinfo.founder.as_str()) || from.account != Some(dinfo.founder.as_str()) {
|
||||
ctx.notice(me, from.uid, "You must be the founder of both channels.");
|
||||
return;
|
||||
}
|
||||
let _ = db.set_mlock(dest, &sinfo.lock_on, &sinfo.lock_off);
|
||||
for a in &sinfo.access {
|
||||
let _ = db.access_add(dest, &a.account, &a.level);
|
||||
}
|
||||
for k in &sinfo.akick {
|
||||
let _ = db.akick_add(dest, &k.mask, &k.reason);
|
||||
}
|
||||
let _ = db.set_desc(dest, &sinfo.desc);
|
||||
let _ = db.set_entrymsg(dest, &sinfo.entrymsg);
|
||||
if let Some(info) = db.channel(dest) {
|
||||
ctx.channel_mode(me, dest, &info.lock_modes());
|
||||
}
|
||||
ctx.notice(me, from.uid, format!("Copied \x02{src}\x02's settings to \x02{dest}\x02."));
|
||||
}
|
||||
35
modules/chanserv/src/enforce.rs
Normal file
35
modules/chanserv/src/enforce.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use fedserv_api::Store;
|
||||
use fedserv_api::{Sender, ServiceCtx};
|
||||
use fedserv_api::NetView;
|
||||
|
||||
// ENFORCE <#channel>: re-apply the channel's settings to everyone present —
|
||||
// the mode lock, access status modes, and the auto-kick list.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: ENFORCE <#channel>");
|
||||
return;
|
||||
};
|
||||
if !super::require_op(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
let Some(info) = db.channel(chan) else {
|
||||
return;
|
||||
};
|
||||
ctx.channel_mode(me, chan, &info.lock_modes());
|
||||
let members: Vec<String> = net.channel_members(chan);
|
||||
for uid in members {
|
||||
match net.account_of(&uid).and_then(|a| info.join_mode(a)) {
|
||||
Some(m) => ctx.channel_mode(me, chan, &format!("{m} {uid}")),
|
||||
None => {
|
||||
let nick = net.nick_of(&uid).unwrap_or("*");
|
||||
let host = net.host_of(&uid).unwrap_or("*");
|
||||
if let Some(k) = info.akick_match(&format!("{nick}!*@{host}")) {
|
||||
ctx.channel_mode(me, chan, &format!("+b {}", k.mask));
|
||||
let reason = if k.reason.is_empty() { "You are banned from this channel." } else { &k.reason };
|
||||
ctx.kick(me, chan, &uid, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.notice(me, from.uid, format!("Re-applied \x02{chan}\x02's settings to everyone present."));
|
||||
}
|
||||
36
modules/chanserv/src/entrymsg.rs
Normal file
36
modules/chanserv/src/entrymsg.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
80
modules/chanserv/src/flags.rs
Normal file
80
modules/chanserv/src/flags.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
19
modules/chanserv/src/getkey.rs
Normal file
19
modules/chanserv/src/getkey.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
use fedserv_api::Store;
|
||||
use fedserv_api::{Sender, ServiceCtx};
|
||||
use fedserv_api::NetView;
|
||||
|
||||
// GETKEY <#channel>: report the channel key (+k), for ops who need to let
|
||||
// someone in.
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: GETKEY <#channel>");
|
||||
return;
|
||||
};
|
||||
if !super::require_op(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
match net.channel_key(chan) {
|
||||
Some(key) => ctx.notice(me, from.uid, format!("Key for \x02{chan}\x02 is \x02{key}\x02.")),
|
||||
None => ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no key set.")),
|
||||
}
|
||||
}
|
||||
26
modules/chanserv/src/invite.rs
Normal file
26
modules/chanserv/src/invite.rs
Normal 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."));
|
||||
}
|
||||
27
modules/chanserv/src/kick.rs
Normal file
27
modules/chanserv/src/kick.rs
Normal 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
344
modules/chanserv/src/lib.rs
Normal 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
|
||||
}
|
||||
|
||||
17
modules/chanserv/src/list.rs
Normal file
17
modules/chanserv/src/list.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
28
modules/chanserv/src/mode.rs
Normal file
28
modules/chanserv/src/mode.rs
Normal 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."));
|
||||
}
|
||||
32
modules/chanserv/src/noexpire.rs
Normal file
32
modules/chanserv/src/noexpire.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
30
modules/chanserv/src/op.rs
Normal file
30
modules/chanserv/src/op.rs
Normal 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}"));
|
||||
}
|
||||
18
modules/chanserv/src/seen.rs
Normal file
18
modules/chanserv/src/seen.rs
Normal 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.")),
|
||||
}
|
||||
}
|
||||
84
modules/chanserv/src/set.rs
Normal file
84
modules/chanserv/src/set.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
29
modules/chanserv/src/status.rs
Normal file
29
modules/chanserv/src/status.rs
Normal 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"));
|
||||
}
|
||||
53
modules/chanserv/src/suspend.rs
Normal file
53
modules/chanserv/src/suspend.rs
Normal 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)
|
||||
}
|
||||
16
modules/chanserv/src/topic.rs
Normal file
16
modules/chanserv/src/topic.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
use fedserv_api::Store;
|
||||
use fedserv_api::{Sender, ServiceCtx};
|
||||
|
||||
// TOPIC <#channel> <text>: set the channel topic (empty clears it).
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: TOPIC <#channel> <text>");
|
||||
return;
|
||||
};
|
||||
if !super::require_op(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
let text = if args.len() > 2 { args[2..].join(" ") } else { String::new() };
|
||||
ctx.topic(me, chan, &text);
|
||||
ctx.notice(me, from.uid, format!("Topic for \x02{chan}\x02 updated."));
|
||||
}
|
||||
22
modules/chanserv/src/unban.rs
Normal file
22
modules/chanserv/src/unban.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
use fedserv_api::Store;
|
||||
use fedserv_api::{Sender, ServiceCtx};
|
||||
use fedserv_api::NetView;
|
||||
|
||||
// UNBAN <#channel> [nick]: remove the *!*@host ban of a user (self if no nick).
|
||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store) {
|
||||
let Some(&chan) = args.get(1) else {
|
||||
ctx.notice(me, from.uid, "Syntax: UNBAN <#channel> [nick]");
|
||||
return;
|
||||
};
|
||||
if !super::require_op(me, from, chan, ctx, db) {
|
||||
return;
|
||||
}
|
||||
let target = args
|
||||
.get(2)
|
||||
.and_then(|&n| net.uid_by_nick(n))
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| from.uid.to_string());
|
||||
let host = net.host_of(&target).unwrap_or("*");
|
||||
ctx.channel_mode(me, chan, &format!("-b *!*@{host}"));
|
||||
ctx.notice(me, from.uid, format!("Cleared the *!*@{host} ban on \x02{chan}\x02."));
|
||||
}
|
||||
51
modules/chanserv/src/xop.rs
Normal file
51
modules/chanserv/src/xop.rs
Normal 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")),
|
||||
}
|
||||
}
|
||||
9
modules/diceserv/Cargo.toml
Normal file
9
modules/diceserv/Cargo.toml
Normal 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"
|
||||
348
modules/diceserv/src/expr.rs
Normal file
348
modules/diceserv/src/expr.rs
Normal 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
154
modules/diceserv/src/lib.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
8
modules/example/Cargo.toml
Normal file
8
modules/example/Cargo.toml
Normal 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" }
|
||||
70
modules/example/src/lib.rs
Normal file
70
modules/example/src/lib.rs
Normal 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.")),
|
||||
}
|
||||
}
|
||||
}
|
||||
8
modules/groupserv/Cargo.toml
Normal file
8
modules/groupserv/Cargo.toml
Normal 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" }
|
||||
226
modules/groupserv/src/lib.rs
Normal file
226
modules/groupserv/src/lib.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
8
modules/helpserv/Cargo.toml
Normal file
8
modules/helpserv/Cargo.toml
Normal 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
162
modules/helpserv/src/lib.rs
Normal 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)."));
|
||||
}
|
||||
}
|
||||
8
modules/hostserv/Cargo.toml
Normal file
8
modules/hostserv/Cargo.toml
Normal 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" }
|
||||
46
modules/hostserv/src/approve.rs
Normal file
46
modules/hostserv/src/approve.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
35
modules/hostserv/src/default.rs
Normal file
35
modules/hostserv/src/default.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
26
modules/hostserv/src/del.rs
Normal file
26
modules/hostserv/src/del.rs
Normal 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.")),
|
||||
}
|
||||
}
|
||||
51
modules/hostserv/src/forbid.rs
Normal file
51
modules/hostserv/src/forbid.rs
Normal 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
141
modules/hostserv/src/lib.rs
Normal 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'-')
|
||||
})
|
||||
}
|
||||
18
modules/hostserv/src/list.rs
Normal file
18
modules/hostserv/src/list.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
22
modules/hostserv/src/off.rs
Normal file
22
modules/hostserv/src/off.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
51
modules/hostserv/src/offer.rs
Normal file
51
modules/hostserv/src/offer.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
16
modules/hostserv/src/on.rs
Normal file
16
modules/hostserv/src/on.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
33
modules/hostserv/src/request.rs
Normal file
33
modules/hostserv/src/request.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
36
modules/hostserv/src/set.rs
Normal file
36
modules/hostserv/src/set.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
31
modules/hostserv/src/take.rs
Normal file
31
modules/hostserv/src/take.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
32
modules/hostserv/src/template.rs
Normal file
32
modules/hostserv/src/template.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
modules/hostserv/src/waiting.rs
Normal file
18
modules/hostserv/src/waiting.rs
Normal 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>.");
|
||||
}
|
||||
8
modules/infoserv/Cargo.toml
Normal file
8
modules/infoserv/Cargo.toml
Normal 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
103
modules/infoserv/src/lib.rs
Normal 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()));
|
||||
}
|
||||
8
modules/inspircd/Cargo.toml
Normal file
8
modules/inspircd/Cargo.toml
Normal 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
543
modules/inspircd/src/lib.rs
Normal 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:?}");
|
||||
}
|
||||
}
|
||||
8
modules/memoserv/Cargo.toml
Normal file
8
modules/memoserv/Cargo.toml
Normal 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" }
|
||||
27
modules/memoserv/src/del.rs
Normal file
27
modules/memoserv/src/del.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
54
modules/memoserv/src/lib.rs
Normal file
54
modules/memoserv/src/lib.rs
Normal 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 => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
modules/memoserv/src/list.rs
Normal file
26
modules/memoserv/src/list.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
42
modules/memoserv/src/read.rs
Normal file
42
modules/memoserv/src/read.rs
Normal 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));
|
||||
}
|
||||
26
modules/memoserv/src/send.rs
Normal file
26
modules/memoserv/src/send.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
8
modules/nickserv/Cargo.toml
Normal file
8
modules/nickserv/Cargo.toml
Normal 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" }
|
||||
63
modules/nickserv/src/ajoin.rs
Normal file
63
modules/nickserv/src/ajoin.rs
Normal 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.")),
|
||||
}
|
||||
}
|
||||
27
modules/nickserv/src/alist.rs
Normal file
27
modules/nickserv/src/alist.rs
Normal 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})"));
|
||||
}
|
||||
}
|
||||
59
modules/nickserv/src/cert.rs
Normal file
59
modules/nickserv/src/cert.rs
Normal 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]"),
|
||||
}
|
||||
}
|
||||
26
modules/nickserv/src/confirm.rs
Normal file
26
modules/nickserv/src/confirm.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
33
modules/nickserv/src/drop.rs
Normal file
33
modules/nickserv/src/drop.rs
Normal 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(", ")));
|
||||
}
|
||||
}
|
||||
36
modules/nickserv/src/ghost.rs
Normal file
36
modules/nickserv/src/ghost.rs
Normal 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."));
|
||||
}
|
||||
17
modules/nickserv/src/glist.rs
Normal file
17
modules/nickserv/src/glist.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
23
modules/nickserv/src/group.rs
Normal file
23
modules/nickserv/src/group.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
78
modules/nickserv/src/identify.rs
Normal file
78
modules/nickserv/src/identify.rs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
40
modules/nickserv/src/info.rs
Normal file
40
modules/nickserv/src/info.rs
Normal 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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
96
modules/nickserv/src/lib.rs
Normal file
96
modules/nickserv/src/lib.rs
Normal 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 => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
modules/nickserv/src/logout.rs
Normal file
14
modules/nickserv/src/logout.rs
Normal 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));
|
||||
}
|
||||
33
modules/nickserv/src/noexpire.rs
Normal file
33
modules/nickserv/src/noexpire.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
50
modules/nickserv/src/password.rs
Normal file
50
modules/nickserv/src/password.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
21
modules/nickserv/src/register.rs
Normal file
21
modules/nickserv/src/register.rs
Normal 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(),
|
||||
});
|
||||
}
|
||||
39
modules/nickserv/src/resetpass.rs
Normal file
39
modules/nickserv/src/resetpass.rs
Normal 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>]"),
|
||||
}
|
||||
}
|
||||
50
modules/nickserv/src/set.rs
Normal file
50
modules/nickserv/src/set.rs
Normal 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]"),
|
||||
}
|
||||
}
|
||||
54
modules/nickserv/src/suspend.rs
Normal file
54
modules/nickserv/src/suspend.rs
Normal 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)
|
||||
}
|
||||
24
modules/nickserv/src/ungroup.rs
Normal file
24
modules/nickserv/src/ungroup.rs
Normal 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."),
|
||||
}
|
||||
}
|
||||
8
modules/operserv/Cargo.toml
Normal file
8
modules/operserv/Cargo.toml
Normal 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" }
|
||||
38
modules/operserv/src/chankill.rs
Normal file
38
modules/operserv/src/chankill.rs
Normal 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."));
|
||||
}
|
||||
40
modules/operserv/src/defcon.rs
Normal file
40
modules/operserv/src/defcon.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
18
modules/operserv/src/global.rs
Normal file
18
modules/operserv/src/global.rs
Normal 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.");
|
||||
}
|
||||
80
modules/operserv/src/ignore.rs
Normal file
80
modules/operserv/src/ignore.rs
Normal 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)
|
||||
}
|
||||
53
modules/operserv/src/info.rs
Normal file
53
modules/operserv/src/info.rs
Normal 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.")),
|
||||
}
|
||||
}
|
||||
49
modules/operserv/src/jupe.rs
Normal file
49
modules/operserv/src/jupe.rs
Normal 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()));
|
||||
}
|
||||
26
modules/operserv/src/kick.rs
Normal file
26
modules/operserv/src/kick.rs
Normal 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."));
|
||||
}
|
||||
21
modules/operserv/src/kill.rs
Normal file
21
modules/operserv/src/kill.rs
Normal 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."));
|
||||
}
|
||||
92
modules/operserv/src/lib.rs
Normal file
92
modules/operserv/src/lib.rs
Normal 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
Loading…
Add table
Add a link
Reference in a new issue