diff --git a/botserv/src/assign.rs b/botserv/src/assign.rs new file mode 100644 index 0000000..157df4c --- /dev/null +++ b/botserv/src/assign.rs @@ -0,0 +1,39 @@ +use fedserv_api::{Priv, Sender, ServiceCtx, Store}; + +// ASSIGN <#channel> / 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> " } else { "Syntax: UNASSIGN <#channel>" }; + ctx.notice(me, from.uid, syntax); + return; + }; + let Some(founder) = db.channel(chan).map(|c| c.founder) else { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")); + return; + }; + 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 assign a bot to it.")); + 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> "); + return; + }; + let Some(botnick) = db.bots().into_iter().find(|b| b.nick.eq_ignore_ascii_case(bot)).map(|b| b.nick) else { + ctx.notice(me, from.uid, format!("There's no bot named \x02{bot}\x02. See \x02BOT LIST\x02.")); + return; + }; + 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."), + } +} diff --git a/botserv/src/bot.rs b/botserv/src/bot.rs new file mode 100644 index 0000000..842c750 --- /dev/null +++ b/botserv/src/bot.rs @@ -0,0 +1,46 @@ +use fedserv_api::{Priv, Sender, ServiceCtx, Store}; + +// BOT ADD [gecos] | BOT DEL | 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 [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("DEL") => { + let Some(&nick) = args.get(2) else { + ctx.notice(me, from.uid, "Syntax: BOT DEL "); + 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 { + ctx.notice(me, from.uid, format!(" \x02{}\x02 ({}@{}) — {}", 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, \x02DEL\x02 or \x02LIST\x02.")), + } +} diff --git a/botserv/src/lib.rs b/botserv/src/lib.rs index 01e10d6..027fdc9 100644 --- a/botserv/src/lib.rs +++ b/botserv/src/lib.rs @@ -1,8 +1,13 @@ -//! BotServ registers service bots — pseudo-clients that (in later slices) get -//! assigned to channels and run fantasy commands. This first slice is the bot -//! registry: BOT ADD/DEL/LIST, gated on the typed Priv::Admin. +//! 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}; +use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store}; + +#[path = "bot.rs"] +mod bot; +#[path = "assign.rs"] +mod assign; pub struct BotServ { pub uid: String, @@ -22,94 +27,11 @@ impl Service for BotServ { 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(me, from, args, ctx, db), - Some("ASSIGN") => assign(me, from, args, ctx, db, true), - Some("UNASSIGN") => assign(me, from, args, ctx, db, false), + 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("HELP") | None => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels: \x02ASSIGN\x02 <#channel> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it. Operators also have \x02BOT\x02 ADD|DEL|LIST."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), } } } - -// ASSIGN <#channel> / UNASSIGN <#channel>: put a bot in a channel (or take -// it out). Channel founder only (or a services admin). -fn assign(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> " } else { "Syntax: UNASSIGN <#channel>" }; - ctx.notice(me, from.uid, syntax); - return; - }; - let Some(founder) = db.channel(chan).map(|c| c.founder) else { - ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")); - return; - }; - 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 assign a bot to it.")); - 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> "); - return; - }; - let Some(botnick) = db.bots().into_iter().find(|b| b.nick.eq_ignore_ascii_case(bot)).map(|b| b.nick) else { - ctx.notice(me, from.uid, format!("There's no bot named \x02{bot}\x02. See \x02BOT LIST\x02.")); - return; - }; - 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."), - } -} - -// BOT ADD [gecos] | BOT DEL | BOT LIST — manage the -// bot registry. Administering bots is oper-only (Priv::Admin). -fn bot(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 [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("DEL") => { - let Some(&nick) = args.get(2) else { - ctx.notice(me, from.uid, "Syntax: BOT DEL "); - 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 { - ctx.notice(me, from.uid, format!(" \x02{}\x02 ({}@{}) — {}", 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, \x02DEL\x02 or \x02LIST\x02.")), - } -} diff --git a/memoserv/src/del.rs b/memoserv/src/del.rs new file mode 100644 index 0000000..781354a --- /dev/null +++ b/memoserv/src/del.rs @@ -0,0 +1,27 @@ +use fedserv_api::{Sender, ServiceCtx, Store}; + +// DEL |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::().ok().filter(|n| *n >= 1) else { + ctx.notice(me, from.uid, "Syntax: DEL |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 |ALL"), + } +} diff --git a/memoserv/src/lib.rs b/memoserv/src/lib.rs index ff8b49b..d915636 100644 --- a/memoserv/src/lib.rs +++ b/memoserv/src/lib.rs @@ -1,18 +1,25 @@ //! 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 (no Anope-style flat-file serialization). +//! account data (no Anope-style flat-file serialization). `lib.rs` holds the +//! dispatcher; each command lives in its own file, matching NickServ/ChanServ. -use fedserv_api::{human_time, NetView, Sender, ServiceCtx, Store}; +use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store}; -// A full mailbox rejects new memos, so nobody can be flooded. -const MAX_MEMOS: usize = 30; +#[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 fedserv_api::Service for MemoServ { +impl Service for MemoServ { fn nick(&self) -> &str { "MemoServ" } @@ -36,122 +43,12 @@ impl fedserv_api::Service for MemoServ { return; }; match cmd.as_deref() { - Some("SEND") => send(me, from, account, args, ctx, db), - Some("LIST") => list(me, from, account, ctx, db), - Some("READ") => read(me, from, account, args, ctx, db), - Some("DEL") | Some("DELETE") => del(me, from, account, args, ctx, db), + 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 => {} } } } - -fn send(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 "); - 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."), - } -} - -fn list(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))); - } -} - -fn read(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 = 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::().ok().filter(|n| *n >= 1) else { - ctx.notice(me, from.uid, "Syntax: READ |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 |NEW|ALL"), - } -} - -fn del(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::().ok().filter(|n| *n >= 1) else { - ctx.notice(me, from.uid, "Syntax: DEL |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 |ALL"), - } -} - -fn show(me: &str, from: &Sender, index: usize, m: &fedserv_api::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)); -} - -// 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::()) - } else { - line.to_string() - } -} diff --git a/memoserv/src/list.rs b/memoserv/src/list.rs new file mode 100644 index 0000000..daa0062 --- /dev/null +++ b/memoserv/src/list.rs @@ -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::()) + } else { + line.to_string() + } +} diff --git a/memoserv/src/read.rs b/memoserv/src/read.rs new file mode 100644 index 0000000..a40ccd3 --- /dev/null +++ b/memoserv/src/read.rs @@ -0,0 +1,42 @@ +use fedserv_api::{human_time, MemoView, Sender, ServiceCtx, Store}; + +// READ |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 = 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::().ok().filter(|n| *n >= 1) else { + ctx.notice(me, from.uid, "Syntax: READ |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 |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)); +} diff --git a/memoserv/src/send.rs b/memoserv/src/send.rs new file mode 100644 index 0000000..9828d81 --- /dev/null +++ b/memoserv/src/send.rs @@ -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 : 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 "); + 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."), + } +}