Group the module crates under modules/

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

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

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

View file

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