BotServ: NOBOT and PRIVATE assignment protections
SET <#channel> NOBOT <on|off> reserves (un)assigning a bot for services operators (the founder is refused). SET <bot> PRIVATE <on|off> (admin only) marks a bot operators-only to assign; it's flagged in BOT LIST and the private flag survives BOT CHANGE. Both gates live in the ASSIGN path.
This commit is contained in:
parent
1cc438f4ed
commit
5bc41f2e60
5 changed files with 92 additions and 13 deletions
|
|
@ -342,6 +342,9 @@ pub struct BotView {
|
||||||
pub user: String,
|
pub user: String,
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub gecos: String,
|
pub gecos: String,
|
||||||
|
// A private bot may only be assigned by a services admin and is hidden from
|
||||||
|
// BOT LIST for everyone else.
|
||||||
|
pub private: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A services suspension on an account: who, why, when, and an optional expiry
|
// A services suspension on an account: who, why, when, and an optional expiry
|
||||||
|
|
@ -388,6 +391,8 @@ pub struct ChannelView {
|
||||||
pub assigned_bot: Option<String>,
|
pub assigned_bot: Option<String>,
|
||||||
// BotServ: whether members' personal greets are shown on join.
|
// BotServ: whether members' personal greets are shown on join.
|
||||||
pub bot_greet: bool,
|
pub bot_greet: bool,
|
||||||
|
// BotServ: whether the founder is barred from (un)assigning a bot.
|
||||||
|
pub nobot: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A single ChanServ SET option, named for the typed `set_channel_setting` call.
|
// A single ChanServ SET option, named for the typed `set_channel_setting` call.
|
||||||
|
|
@ -401,6 +406,8 @@ pub enum ChanSetting {
|
||||||
TopicLock,
|
TopicLock,
|
||||||
// BotServ: show members' personal greets on join.
|
// BotServ: show members' personal greets on join.
|
||||||
BotGreet,
|
BotGreet,
|
||||||
|
// BotServ: forbid the founder from (un)assigning a bot (admin override only).
|
||||||
|
NoBot,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A BotServ kicker: the assigned bot kicks a message that trips an enabled one.
|
// A BotServ kicker: the assigned bot kicks a message that trips an enabled one.
|
||||||
|
|
@ -571,6 +578,7 @@ pub trait Store {
|
||||||
// BotServ registry (oper-only at the command layer).
|
// BotServ registry (oper-only at the command layer).
|
||||||
fn bot_add(&mut self, nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError>;
|
fn bot_add(&mut self, nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError>;
|
||||||
fn bot_change(&mut self, old: &str, new_nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError>;
|
fn bot_change(&mut self, old: &str, new_nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError>;
|
||||||
|
fn bot_set_private(&mut self, nick: &str, private: bool) -> Result<bool, ChanError>;
|
||||||
fn bot_del(&mut self, nick: &str) -> Result<bool, ChanError>;
|
fn bot_del(&mut self, nick: &str) -> Result<bool, ChanError>;
|
||||||
fn bots(&self) -> Vec<BotView>;
|
fn bots(&self) -> Vec<BotView>;
|
||||||
fn assign_bot(&mut self, channel: &str, bot: &str) -> Result<(), ChanError>;
|
fn assign_bot(&mut self, channel: &str, bot: &str) -> Result<(), ChanError>;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use fedserv_api::{Sender, ServiceCtx, Store};
|
use fedserv_api::{Priv, Sender, ServiceCtx, Store};
|
||||||
|
|
||||||
// ASSIGN <#channel> <bot> / UNASSIGN <#channel>: put a bot in a channel (or take
|
// ASSIGN <#channel> <bot> / UNASSIGN <#channel>: put a bot in a channel (or take
|
||||||
// it out). Channel founder only (or a services admin).
|
// it out). Channel founder only (or a services admin).
|
||||||
|
|
@ -11,6 +11,12 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
if !super::require_channel_admin(me, from, chan, ctx, db) {
|
if !super::require_channel_admin(me, from, chan, ctx, db) {
|
||||||
return;
|
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 {
|
if !assigning {
|
||||||
match db.unassign_bot(chan) {
|
match db.unassign_bot(chan) {
|
||||||
Ok(true) => ctx.notice(me, from.uid, format!("The bot has left \x02{chan}\x02.")),
|
Ok(true) => ctx.notice(me, from.uid, format!("The bot has left \x02{chan}\x02.")),
|
||||||
|
|
@ -23,10 +29,15 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
ctx.notice(me, from.uid, "Syntax: ASSIGN <#channel> <bot>");
|
ctx.notice(me, from.uid, "Syntax: ASSIGN <#channel> <bot>");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(botnick) = db.bots().into_iter().find(|b| b.nick.eq_ignore_ascii_case(bot)).map(|b| b.nick) else {
|
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."));
|
ctx.notice(me, from.uid, format!("There's no bot named \x02{bot}\x02. See \x02BOT LIST\x02."));
|
||||||
return;
|
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) {
|
match db.assign_bot(chan, &botnick) {
|
||||||
Ok(()) => ctx.notice(me, from.uid, format!("Bot \x02{botnick}\x02 is now assigned to \x02{chan}\x02.")),
|
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."),
|
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,8 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
}
|
}
|
||||||
ctx.notice(me, from.uid, format!("Bots ({}):", bots.len()));
|
ctx.notice(me, from.uid, format!("Bots ({}):", bots.len()));
|
||||||
for b in &bots {
|
for b in &bots {
|
||||||
ctx.notice(me, from.uid, format!(" \x02{}\x02 ({}@{}) — {}", b.nick, b.user, b.host, b.gecos));
|
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.")),
|
Some(other) => ctx.notice(me, from.uid, format!("Unknown BOT command \x02{other}\x02. Use \x02ADD\x02, \x02CHANGE\x02, \x02DEL\x02 or \x02LIST\x02.")),
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,43 @@
|
||||||
use fedserv_api::{parse_duration, ChanSetting, Sender, ServiceCtx, Store};
|
use fedserv_api::{parse_duration, ChanSetting, Priv, Sender, ServiceCtx, Store};
|
||||||
|
|
||||||
// SET <#channel> <option> <value>: per-channel bot options. Founder-or-admin.
|
// SET <#channel> <option> <value>: per-channel bot options (founder-or-admin) —
|
||||||
// GREET <on|off> (show members' greets on join), BANEXPIRE <duration|off> (how
|
// GREET <on|off>, BANEXPIRE <duration|off>, NOBOT <on|off>. Also
|
||||||
// long kicker bans last).
|
// SET <bot> PRIVATE <on|off> (services-admin only).
|
||||||
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||||
let (Some(&chan), Some(option)) = (args.get(1), args.get(2)) else {
|
let (Some(&target), Some(option)) = (args.get(1), args.get(2)) else {
|
||||||
ctx.notice(me, from.uid, "Syntax: SET <#channel> <GREET <ON|OFF> | BANEXPIRE <duration|off>>");
|
ctx.notice(me, from.uid, "Syntax: SET <#channel> <GREET|BANEXPIRE|NOBOT> <value>, or SET <bot> PRIVATE <ON|OFF>");
|
||||||
return;
|
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) {
|
if !super::require_channel_admin(me, from, chan, ctx, db) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +81,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
Ok(()) => ctx.notice(me, from.uid, format!("Greet messages are now \x02off\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."),
|
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.")),
|
"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.")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,9 @@ pub struct Bot {
|
||||||
pub user: String,
|
pub user: String,
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub gecos: String,
|
pub gecos: String,
|
||||||
|
// Oper-only to assign, and hidden from BOT LIST for non-admins.
|
||||||
|
#[serde(default)]
|
||||||
|
pub private: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// An auto-join entry: a channel this account is joined to on identify, with an
|
// An auto-join entry: a channel this account is joined to on identify, with an
|
||||||
|
|
@ -245,6 +248,9 @@ pub struct ChanSettings {
|
||||||
// BotServ: show members' personal greets when they join.
|
// BotServ: show members' personal greets when they join.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub bot_greet: bool,
|
pub bot_greet: bool,
|
||||||
|
// BotServ: forbid the founder from (un)assigning a bot (admin override only).
|
||||||
|
#[serde(default)]
|
||||||
|
pub nobot: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A registered channel and who owns it.
|
// A registered channel and who owns it.
|
||||||
|
|
@ -1403,6 +1409,7 @@ impl Db {
|
||||||
ChanSetting::KeepTopic => settings.keeptopic = on,
|
ChanSetting::KeepTopic => settings.keeptopic = on,
|
||||||
ChanSetting::TopicLock => settings.topiclock = on,
|
ChanSetting::TopicLock => settings.topiclock = on,
|
||||||
ChanSetting::BotGreet => settings.bot_greet = on,
|
ChanSetting::BotGreet => settings.bot_greet = on,
|
||||||
|
ChanSetting::NoBot => settings.nobot = on,
|
||||||
}
|
}
|
||||||
self.log
|
self.log
|
||||||
.append(Event::ChannelSettingsSet { channel: channel.to_string(), settings })
|
.append(Event::ChannelSettingsSet { channel: channel.to_string(), settings })
|
||||||
|
|
@ -1586,12 +1593,23 @@ impl Db {
|
||||||
if self.bots.contains_key(&k) {
|
if self.bots.contains_key(&k) {
|
||||||
return Err(ChanError::Exists);
|
return Err(ChanError::Exists);
|
||||||
}
|
}
|
||||||
let bot = Bot { nick: nick.to_string(), user: user.to_string(), host: host.to_string(), gecos: gecos.to_string() };
|
let bot = Bot { nick: nick.to_string(), user: user.to_string(), host: host.to_string(), gecos: gecos.to_string(), private: false };
|
||||||
self.log.append(Event::BotAdded(bot.clone())).map_err(|_| ChanError::Internal)?;
|
self.log.append(Event::BotAdded(bot.clone())).map_err(|_| ChanError::Internal)?;
|
||||||
self.bots.insert(k, bot);
|
self.bots.insert(k, bot);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mark a bot private (oper-only assign) or public. Returns Ok(false) if
|
||||||
|
/// there is no such bot.
|
||||||
|
pub fn bot_set_private(&mut self, nick: &str, private: bool) -> Result<bool, ChanError> {
|
||||||
|
let k = key(nick);
|
||||||
|
let Some(mut bot) = self.bots.get(&k).cloned() else { return Ok(false) };
|
||||||
|
bot.private = private;
|
||||||
|
self.log.append(Event::BotAdded(bot.clone())).map_err(|_| ChanError::Internal)?;
|
||||||
|
self.bots.insert(k, bot);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete a service bot. Returns whether it existed.
|
/// Delete a service bot. Returns whether it existed.
|
||||||
pub fn bot_del(&mut self, nick: &str) -> Result<bool, ChanError> {
|
pub fn bot_del(&mut self, nick: &str) -> Result<bool, ChanError> {
|
||||||
let k = key(nick);
|
let k = key(nick);
|
||||||
|
|
@ -1616,7 +1634,9 @@ impl Db {
|
||||||
if renaming && self.bots.contains_key(&nk) {
|
if renaming && self.bots.contains_key(&nk) {
|
||||||
return Err(ChanError::Exists);
|
return Err(ChanError::Exists);
|
||||||
}
|
}
|
||||||
let bot = Bot { nick: new_nick.to_string(), user: user.to_string(), host: host.to_string(), gecos: gecos.to_string() };
|
// Keep the private flag across a change.
|
||||||
|
let private = self.bots.get(&ok).map(|b| b.private).unwrap_or(false);
|
||||||
|
let bot = Bot { nick: new_nick.to_string(), user: user.to_string(), host: host.to_string(), gecos: gecos.to_string(), private };
|
||||||
if renaming {
|
if renaming {
|
||||||
let chans: Vec<String> = self
|
let chans: Vec<String> = self
|
||||||
.channels
|
.channels
|
||||||
|
|
@ -2185,11 +2205,14 @@ impl Store for Db {
|
||||||
fn bot_change(&mut self, old: &str, new_nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError> {
|
fn bot_change(&mut self, old: &str, new_nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError> {
|
||||||
Db::bot_change(self, old, new_nick, user, host, gecos)
|
Db::bot_change(self, old, new_nick, user, host, gecos)
|
||||||
}
|
}
|
||||||
|
fn bot_set_private(&mut self, nick: &str, private: bool) -> Result<bool, ChanError> {
|
||||||
|
Db::bot_set_private(self, nick, private)
|
||||||
|
}
|
||||||
fn bot_del(&mut self, nick: &str) -> Result<bool, ChanError> {
|
fn bot_del(&mut self, nick: &str) -> Result<bool, ChanError> {
|
||||||
Db::bot_del(self, nick)
|
Db::bot_del(self, nick)
|
||||||
}
|
}
|
||||||
fn bots(&self) -> Vec<BotView> {
|
fn bots(&self) -> Vec<BotView> {
|
||||||
Db::bots(self).map(|b| BotView { nick: b.nick.clone(), user: b.user.clone(), host: b.host.clone(), gecos: b.gecos.clone() }).collect()
|
Db::bots(self).map(|b| BotView { nick: b.nick.clone(), user: b.user.clone(), host: b.host.clone(), gecos: b.gecos.clone(), private: b.private }).collect()
|
||||||
}
|
}
|
||||||
fn assign_bot(&mut self, channel: &str, bot: &str) -> Result<(), ChanError> {
|
fn assign_bot(&mut self, channel: &str, bot: &str) -> Result<(), ChanError> {
|
||||||
Db::assign_bot(self, channel, bot)
|
Db::assign_bot(self, channel, bot)
|
||||||
|
|
@ -2253,6 +2276,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView {
|
||||||
suspended: c.suspension.as_ref().is_some_and(|s| s.expires.is_none_or(|e| e > now())),
|
suspended: c.suspension.as_ref().is_some_and(|s| s.expires.is_none_or(|e| e > now())),
|
||||||
assigned_bot: c.assigned_bot.clone(),
|
assigned_bot: c.assigned_bot.clone(),
|
||||||
bot_greet: c.settings.bot_greet,
|
bot_greet: c.settings.bot_greet,
|
||||||
|
nobot: c.settings.nobot,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue