All checks were successful
CI / check (push) Successful in 3m51s
GETEMAIL (auspex) lists accounts whose email matches a glob. SET RESTRICTED makes a channel kick anyone without access (or oper) as they join, mirroring the SECUREOPS on-join enforcement.
685 lines
30 KiB
Rust
685 lines
30 KiB
Rust
use super::*;
|
|
|
|
impl Db {
|
|
/// Register `name` to `founder` (an account name).
|
|
pub fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError> {
|
|
let k = key(name);
|
|
if self.channels.contains_key(&k) {
|
|
return Err(ChanError::Exists);
|
|
}
|
|
let ts = now();
|
|
self.log
|
|
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
|
|
.map_err(|_| ChanError::Internal)?;
|
|
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
|
|
Ok(())
|
|
}
|
|
|
|
/// The registration for `name`, if any.
|
|
pub fn channel(&self, name: &str) -> Option<&ChannelInfo> {
|
|
self.channels.get(&key(name))
|
|
}
|
|
|
|
/// The account record for `name` (its canonical casing), if registered.
|
|
pub fn account(&self, name: &str) -> Option<&Account> {
|
|
self.accounts.get(&key(name))
|
|
}
|
|
|
|
/// All registered channels, for listing.
|
|
pub fn channels(&self) -> impl Iterator<Item = &ChannelInfo> {
|
|
self.channels.values()
|
|
}
|
|
|
|
/// All registered accounts, for a directory snapshot (see the gRPC layer).
|
|
pub fn accounts(&self) -> impl Iterator<Item = &Account> {
|
|
self.accounts.values()
|
|
}
|
|
|
|
/// Names of channels founded by `account` (case-insensitive).
|
|
pub fn channels_owned_by(&self, account: &str) -> Vec<String> {
|
|
self.channels
|
|
.values()
|
|
.filter(|c| c.founder.eq_ignore_ascii_case(account))
|
|
.map(|c| c.name.clone())
|
|
.collect()
|
|
}
|
|
|
|
/// Set the mode-lock (chars to keep set / unset) for a registered channel.
|
|
pub fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError> {
|
|
let k = key(name);
|
|
if !self.channels.contains_key(&k) {
|
|
return Err(ChanError::NoChannel);
|
|
}
|
|
self.log
|
|
.append(Event::ChannelMlock { name: name.to_string(), on: on.to_string(), off: off.to_string() })
|
|
.map_err(|_| ChanError::Internal)?;
|
|
let c = self.channels.get_mut(&k).unwrap();
|
|
c.lock_on = on.to_string();
|
|
c.lock_off = off.to_string();
|
|
Ok(())
|
|
}
|
|
|
|
/// Grant `account` a level ("op"/"voice") on `channel`.
|
|
pub fn access_add(&mut self, channel: &str, account: &str, level: &str) -> Result<(), ChanError> {
|
|
let k = key(channel);
|
|
if !self.channels.contains_key(&k) {
|
|
return Err(ChanError::NoChannel);
|
|
}
|
|
self.log
|
|
.append(Event::ChannelAccessAdd { channel: channel.to_string(), account: account.to_string(), level: level.to_string() })
|
|
.map_err(|_| ChanError::Internal)?;
|
|
let c = self.channels.get_mut(&k).unwrap();
|
|
c.access.retain(|a| !a.account.eq_ignore_ascii_case(account));
|
|
c.access.push(ChanAccess { account: account.to_string(), level: level.to_string() });
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove `account` from `channel`'s access list. Ok(false) if not present.
|
|
pub fn access_del(&mut self, channel: &str, account: &str) -> Result<bool, ChanError> {
|
|
let k = key(channel);
|
|
let Some(c) = self.channels.get(&k) else {
|
|
return Err(ChanError::NoChannel);
|
|
};
|
|
if !c.access.iter().any(|a| a.account.eq_ignore_ascii_case(account)) {
|
|
return Ok(false);
|
|
}
|
|
self.log
|
|
.append(Event::ChannelAccessDel { channel: channel.to_string(), account: account.to_string() })
|
|
.map_err(|_| ChanError::Internal)?;
|
|
self.channels.get_mut(&k).unwrap().access.retain(|a| !a.account.eq_ignore_ascii_case(account));
|
|
Ok(true)
|
|
}
|
|
|
|
/// Add an auto-kick `mask` (with `reason`) to `channel`.
|
|
pub fn akick_add(&mut self, channel: &str, mask: &str, reason: &str) -> Result<(), ChanError> {
|
|
let k = key(channel);
|
|
if !self.channels.contains_key(&k) {
|
|
return Err(ChanError::NoChannel);
|
|
}
|
|
self.log
|
|
.append(Event::ChannelAkickAdd { channel: channel.to_string(), mask: mask.to_string(), reason: reason.to_string() })
|
|
.map_err(|_| ChanError::Internal)?;
|
|
let c = self.channels.get_mut(&k).unwrap();
|
|
c.akick.retain(|a| !a.mask.eq_ignore_ascii_case(mask));
|
|
c.akick.push(ChanAkick { mask: mask.to_string(), reason: reason.to_string() });
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove auto-kick `mask` from `channel`. Ok(false) if not present.
|
|
pub fn akick_del(&mut self, channel: &str, mask: &str) -> Result<bool, ChanError> {
|
|
let k = key(channel);
|
|
let Some(c) = self.channels.get(&k) else {
|
|
return Err(ChanError::NoChannel);
|
|
};
|
|
if !c.akick.iter().any(|a| a.mask.eq_ignore_ascii_case(mask)) {
|
|
return Ok(false);
|
|
}
|
|
self.log
|
|
.append(Event::ChannelAkickDel { channel: channel.to_string(), mask: mask.to_string() })
|
|
.map_err(|_| ChanError::Internal)?;
|
|
self.channels.get_mut(&k).unwrap().akick.retain(|a| !a.mask.eq_ignore_ascii_case(mask));
|
|
Ok(true)
|
|
}
|
|
|
|
/// Transfer `channel`'s founder to `account`.
|
|
pub fn set_founder(&mut self, channel: &str, account: &str) -> Result<(), ChanError> {
|
|
let k = key(channel);
|
|
if !self.channels.contains_key(&k) {
|
|
return Err(ChanError::NoChannel);
|
|
}
|
|
self.log
|
|
.append(Event::ChannelFounderSet { channel: channel.to_string(), founder: account.to_string() })
|
|
.map_err(|_| ChanError::Internal)?;
|
|
self.channels.get_mut(&k).unwrap().founder = account.to_string();
|
|
Ok(())
|
|
}
|
|
|
|
/// Set (or clear, with None) `channel`'s successor account.
|
|
pub fn set_successor(&mut self, channel: &str, successor: Option<&str>) -> Result<(), ChanError> {
|
|
let k = key(channel);
|
|
if !self.channels.contains_key(&k) {
|
|
return Err(ChanError::NoChannel);
|
|
}
|
|
let successor = successor.map(|s| s.to_string());
|
|
self.log
|
|
.append(Event::ChannelSuccessorSet { channel: channel.to_string(), successor: successor.clone() })
|
|
.map_err(|_| ChanError::Internal)?;
|
|
self.channels.get_mut(&k).unwrap().successor = successor;
|
|
Ok(())
|
|
}
|
|
|
|
/// The successor account set on `channel`, if any.
|
|
pub fn channel_successor(&self, channel: &str) -> Option<String> {
|
|
self.channels.get(&key(channel)).and_then(|c| c.successor.clone())
|
|
}
|
|
|
|
/// Release the channels founded by `account` because the account is going
|
|
/// away: one with a valid successor is transferred to them, the rest are
|
|
/// dropped. Returns (transferred as (channel, successor), dropped channels).
|
|
pub fn release_founded_channels(&mut self, account: &str) -> (Vec<(String, String)>, Vec<String>) {
|
|
let (mut transferred, mut dropped) = (Vec::new(), Vec::new());
|
|
for chan in self.channels_owned_by(account) {
|
|
let successor = self.channel_successor(&chan).filter(|s| !s.eq_ignore_ascii_case(account) && self.exists(s));
|
|
match successor {
|
|
Some(s) if self.set_founder(&chan, &s).is_ok() => {
|
|
let _ = self.set_successor(&chan, None); // consumed
|
|
transferred.push((chan, s));
|
|
}
|
|
_ => {
|
|
let _ = self.drop_channel(&chan);
|
|
dropped.push(chan);
|
|
}
|
|
}
|
|
}
|
|
(transferred, dropped)
|
|
}
|
|
|
|
/// Set `channel`'s description (empty clears it).
|
|
pub fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError> {
|
|
let k = key(channel);
|
|
if !self.channels.contains_key(&k) {
|
|
return Err(ChanError::NoChannel);
|
|
}
|
|
self.log
|
|
.append(Event::ChannelDescSet { channel: channel.to_string(), desc: desc.to_string() })
|
|
.map_err(|_| ChanError::Internal)?;
|
|
self.channels.get_mut(&k).unwrap().desc = desc.to_string();
|
|
Ok(())
|
|
}
|
|
|
|
/// Turn one ChanServ SET option on or off for a channel.
|
|
pub fn set_channel_setting(&mut self, channel: &str, setting: ChanSetting, on: bool) -> Result<(), ChanError> {
|
|
let k = key(channel);
|
|
let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) };
|
|
let mut settings = c.settings;
|
|
match setting {
|
|
ChanSetting::SignKick => settings.signkick = on,
|
|
ChanSetting::Private => settings.private = on,
|
|
ChanSetting::Peace => settings.peace = on,
|
|
ChanSetting::SecureOps => settings.secureops = on,
|
|
ChanSetting::Restricted => settings.restricted = on,
|
|
ChanSetting::KeepTopic => settings.keeptopic = on,
|
|
ChanSetting::TopicLock => settings.topiclock = on,
|
|
ChanSetting::BotGreet => settings.bot_greet = on,
|
|
ChanSetting::NoBot => settings.nobot = on,
|
|
}
|
|
self.log
|
|
.append(Event::ChannelSettingsSet { channel: channel.to_string(), settings })
|
|
.map_err(|_| ChanError::Internal)?;
|
|
self.channels.get_mut(&k).unwrap().settings = settings;
|
|
Ok(())
|
|
}
|
|
|
|
/// Turn one BotServ kicker on or off. Caps thresholds are set via
|
|
/// `set_caps_kicker`; passing `Kicker::Caps` here only toggles it off.
|
|
pub fn set_kicker(&mut self, channel: &str, kicker: Kicker, on: bool) -> Result<(), ChanError> {
|
|
self.update_kickers(channel, |k| match kicker {
|
|
Kicker::Caps => k.caps = on,
|
|
Kicker::Bolds => k.bolds = on,
|
|
Kicker::Colors => k.colors = on,
|
|
Kicker::Underlines => k.underlines = on,
|
|
Kicker::Reverses => k.reverses = on,
|
|
Kicker::Italics => k.italics = on,
|
|
Kicker::Flood => k.flood = on,
|
|
Kicker::Repeat => k.repeat = on,
|
|
Kicker::Badwords => k.badwords = on,
|
|
Kicker::Warn => k.warn = on,
|
|
Kicker::DontKickOps => k.dontkickops = on,
|
|
Kicker::DontKickVoices => k.dontkickvoices = on,
|
|
})
|
|
}
|
|
|
|
/// Enable the caps kicker with its length/percentage thresholds (0 = default).
|
|
pub fn set_caps_kicker(&mut self, channel: &str, caps_min: u16, caps_percent: u16) -> Result<(), ChanError> {
|
|
self.update_kickers(channel, |k| {
|
|
k.caps = true;
|
|
k.caps_min = caps_min;
|
|
k.caps_percent = caps_percent;
|
|
})
|
|
}
|
|
|
|
/// Enable the flood kicker with its lines/seconds thresholds (0 = default).
|
|
pub fn set_flood_kicker(&mut self, channel: &str, lines: u16, secs: u16) -> Result<(), ChanError> {
|
|
self.update_kickers(channel, |k| {
|
|
k.flood = true;
|
|
k.flood_lines = lines;
|
|
k.flood_secs = secs;
|
|
})
|
|
}
|
|
|
|
/// Enable the repeat kicker with its repeat-count threshold (0 = default).
|
|
pub fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError> {
|
|
self.update_kickers(channel, |k| {
|
|
k.repeat = true;
|
|
k.repeat_times = times;
|
|
})
|
|
}
|
|
|
|
/// Kicks-before-ban threshold (0 = never ban, only kick).
|
|
pub fn set_ttb(&mut self, channel: &str, ttb: u16) -> Result<(), ChanError> {
|
|
self.update_kickers(channel, |k| k.ttb = ttb)
|
|
}
|
|
|
|
/// How long a kicker ban lasts, in seconds (0 = until removed).
|
|
pub fn set_ban_expire(&mut self, channel: &str, secs: u32) -> Result<(), ChanError> {
|
|
self.update_kickers(channel, |k| k.ban_expire = secs)
|
|
}
|
|
|
|
/// Votes needed for a community !votekick/!voteban (0 = disabled).
|
|
pub fn set_votekick(&mut self, channel: &str, votes: u16) -> Result<(), ChanError> {
|
|
self.update_kickers(channel, |k| k.votekick = votes)
|
|
}
|
|
|
|
/// Add a badword regex. Validates it compiles within the size limit; returns
|
|
/// Ok(false) if the exact pattern is already present.
|
|
pub fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError> {
|
|
if regex::RegexBuilder::new(pattern).size_limit(BADWORD_SIZE_LIMIT).build().is_err() {
|
|
return Err(ChanError::InvalidPattern);
|
|
}
|
|
let k = key(channel);
|
|
let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) };
|
|
if c.badwords.iter().any(|w| w == pattern) {
|
|
return Ok(false);
|
|
}
|
|
let mut list = c.badwords.clone();
|
|
list.push(pattern.to_string());
|
|
self.write_badwords(channel, &k, list)?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Remove a badword by exact pattern. Ok(false) if it wasn't listed.
|
|
pub fn badword_del(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError> {
|
|
let k = key(channel);
|
|
let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) };
|
|
if !c.badwords.iter().any(|w| w == pattern) {
|
|
return Ok(false);
|
|
}
|
|
let list: Vec<String> = c.badwords.iter().filter(|w| *w != pattern).cloned().collect();
|
|
self.write_badwords(channel, &k, list)?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Clear all badwords; returns how many were removed.
|
|
pub fn badword_clear(&mut self, channel: &str) -> Result<usize, ChanError> {
|
|
let k = key(channel);
|
|
let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) };
|
|
let n = c.badwords.len();
|
|
if n > 0 {
|
|
self.write_badwords(channel, &k, Vec::new())?;
|
|
}
|
|
Ok(n)
|
|
}
|
|
|
|
pub fn badwords(&self, channel: &str) -> &[String] {
|
|
self.channels.get(&key(channel)).map_or(&[], |c| c.badwords.as_slice())
|
|
}
|
|
|
|
/// Add an auto-response trigger. Validates the pattern; Ok(false) if the same
|
|
/// pattern is already present.
|
|
pub fn trigger_add(&mut self, channel: &str, pattern: &str, response: &str, cooldown: u32) -> Result<bool, ChanError> {
|
|
if regex::RegexBuilder::new(pattern).size_limit(BADWORD_SIZE_LIMIT).build().is_err() {
|
|
return Err(ChanError::InvalidPattern);
|
|
}
|
|
let k = key(channel);
|
|
let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) };
|
|
if c.triggers.iter().any(|t| t.pattern == pattern) {
|
|
return Ok(false);
|
|
}
|
|
let mut list = c.triggers.clone();
|
|
list.push(Trigger { pattern: pattern.to_string(), response: response.to_string(), cooldown });
|
|
self.write_triggers(channel, &k, list)?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Remove the trigger at 1-based `index`. Ok(false) if out of range.
|
|
pub fn trigger_del(&mut self, channel: &str, index: usize) -> Result<bool, ChanError> {
|
|
let k = key(channel);
|
|
let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) };
|
|
if index == 0 || index > c.triggers.len() {
|
|
return Ok(false);
|
|
}
|
|
let mut list = c.triggers.clone();
|
|
list.remove(index - 1);
|
|
self.write_triggers(channel, &k, list)?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Clear all triggers; returns how many were removed.
|
|
pub fn trigger_clear(&mut self, channel: &str) -> Result<usize, ChanError> {
|
|
let k = key(channel);
|
|
let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) };
|
|
let n = c.triggers.len();
|
|
if n > 0 {
|
|
self.write_triggers(channel, &k, Vec::new())?;
|
|
}
|
|
Ok(n)
|
|
}
|
|
|
|
pub fn triggers(&self, channel: &str) -> &[Trigger] {
|
|
self.channels.get(&key(channel)).map_or(&[], |c| c.triggers.as_slice())
|
|
}
|
|
|
|
fn write_triggers(&mut self, channel: &str, k: &str, list: Vec<Trigger>) -> Result<(), ChanError> {
|
|
self.log
|
|
.append(Event::ChannelTriggersSet { channel: channel.to_string(), triggers: list.clone() })
|
|
.map_err(|_| ChanError::Internal)?;
|
|
let c = self.channels.get_mut(k).unwrap();
|
|
c.triggers = list;
|
|
c.triggers_rev = c.triggers_rev.wrapping_add(1);
|
|
Ok(())
|
|
}
|
|
|
|
/// Dry-run the content kickers against a line: the reason it would be kicked,
|
|
/// or None. Flood/repeat are stateful/time-based and are not simulated here.
|
|
pub fn kicker_test(&self, channel: &str, text: &str) -> Option<String> {
|
|
let c = self.channels.get(&key(channel))?;
|
|
if let Some(reason) = c.kickers.violation(text) {
|
|
return Some(reason.to_string());
|
|
}
|
|
if c.kickers.badwords && !c.badwords.is_empty() && build_badword_set(&c.badwords).is_match(text) {
|
|
return Some("matches a badword".to_string());
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Copy one channel's BotServ configuration — kickers, badwords, greet and
|
|
/// nobot — onto another. Both channels must be registered.
|
|
pub fn copy_bot_config(&mut self, src: &str, dst: &str) -> Result<(), ChanError> {
|
|
let (kickers, badwords, bot_greet, nobot) = {
|
|
let s = self.channels.get(&key(src)).ok_or(ChanError::NoChannel)?;
|
|
(s.kickers.clone(), s.badwords.clone(), s.settings.bot_greet, s.settings.nobot)
|
|
};
|
|
let dk = key(dst);
|
|
if !self.channels.contains_key(&dk) {
|
|
return Err(ChanError::NoChannel);
|
|
}
|
|
self.log.append(Event::ChannelKickerSet { channel: dst.to_string(), kickers: kickers.clone() }).map_err(|_| ChanError::Internal)?;
|
|
self.channels.get_mut(&dk).unwrap().kickers = kickers;
|
|
self.write_badwords(dst, &dk, badwords)?;
|
|
self.set_channel_setting(dst, ChanSetting::BotGreet, bot_greet)?;
|
|
self.set_channel_setting(dst, ChanSetting::NoBot, nobot)?;
|
|
Ok(())
|
|
}
|
|
|
|
// Persist a new badword list (whole-list event) and apply it.
|
|
fn write_badwords(&mut self, channel: &str, k: &str, list: Vec<String>) -> Result<(), ChanError> {
|
|
self.log
|
|
.append(Event::ChannelBadwordsSet { channel: channel.to_string(), badwords: list.clone() })
|
|
.map_err(|_| ChanError::Internal)?;
|
|
let c = self.channels.get_mut(k).unwrap();
|
|
c.badwords = list;
|
|
c.badwords_rev = c.badwords_rev.wrapping_add(1);
|
|
Ok(())
|
|
}
|
|
|
|
// Read-modify-write the whole kicker struct through one event.
|
|
fn update_kickers(&mut self, channel: &str, f: impl FnOnce(&mut KickerSettings)) -> Result<(), ChanError> {
|
|
let k = key(channel);
|
|
let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) };
|
|
let mut kickers = c.kickers.clone();
|
|
f(&mut kickers);
|
|
self.log
|
|
.append(Event::ChannelKickerSet { channel: channel.to_string(), kickers: kickers.clone() })
|
|
.map_err(|_| ChanError::Internal)?;
|
|
self.channels.get_mut(&k).unwrap().kickers = kickers;
|
|
Ok(())
|
|
}
|
|
|
|
/// Remember a channel's topic (KEEPTOPIC / TOPICLOCK).
|
|
pub fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
|
|
let k = key(channel);
|
|
if !self.channels.contains_key(&k) {
|
|
return Err(ChanError::NoChannel);
|
|
}
|
|
self.log
|
|
.append(Event::ChannelTopicSet { channel: channel.to_string(), topic: topic.to_string() })
|
|
.map_err(|_| ChanError::Internal)?;
|
|
self.channels.get_mut(&k).unwrap().topic = topic.to_string();
|
|
Ok(())
|
|
}
|
|
|
|
/// Suspend a channel. `expires` is absolute unix time (None = permanent).
|
|
pub fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option<u64>) -> Result<(), ChanError> {
|
|
let k = key(channel);
|
|
if !self.channels.contains_key(&k) {
|
|
return Err(ChanError::NoChannel);
|
|
}
|
|
let ts = now();
|
|
self.log.append(Event::ChannelSuspended { channel: channel.to_string(), by: by.to_string(), reason: reason.to_string(), ts, expires }).map_err(|_| ChanError::Internal)?;
|
|
self.channels.get_mut(&k).unwrap().suspension = Some(Suspension { by: by.to_string(), reason: reason.to_string(), ts, expires });
|
|
Ok(())
|
|
}
|
|
|
|
/// Lift a channel suspension. Returns whether one was set.
|
|
pub fn unsuspend_channel(&mut self, channel: &str) -> Result<bool, ChanError> {
|
|
let k = key(channel);
|
|
match self.channels.get(&k) {
|
|
None => return Err(ChanError::NoChannel),
|
|
Some(c) if c.suspension.is_none() => return Ok(false),
|
|
Some(_) => {}
|
|
}
|
|
self.log.append(Event::ChannelUnsuspended { channel: channel.to_string() }).map_err(|_| ChanError::Internal)?;
|
|
self.channels.get_mut(&k).unwrap().suspension = None;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Whether a channel has a set, unexpired suspension (lazy — no timer).
|
|
pub fn is_channel_suspended(&self, channel: &str) -> bool {
|
|
self.channels.get(&key(channel)).and_then(|c| c.suspension.as_ref()).is_some_and(|s| s.expires.is_none_or(|e| e > now()))
|
|
}
|
|
|
|
/// The channel's suspension record, if any.
|
|
pub fn channel_suspension(&self, channel: &str) -> Option<SuspensionView> {
|
|
self.channels.get(&key(channel)).and_then(|c| c.suspension.as_ref()).map(|s| SuspensionView { by: s.by.clone(), reason: s.reason.clone(), ts: s.ts, expires: s.expires })
|
|
}
|
|
|
|
/// Register a service bot.
|
|
pub fn bot_add(&mut self, nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError> {
|
|
let k = key(nick);
|
|
if self.bots.contains_key(&k) {
|
|
return Err(ChanError::Exists);
|
|
}
|
|
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.bots.insert(k, bot);
|
|
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 every service bot at once (BOT DEL *). Returns how many went.
|
|
pub fn bot_del_all(&mut self) -> Result<usize, ChanError> {
|
|
let nicks: Vec<String> = self.bots.values().map(|b| b.nick.clone()).collect();
|
|
for n in &nicks {
|
|
self.log.append(Event::BotRemoved { nick: n.clone() }).map_err(|_| ChanError::Internal)?;
|
|
}
|
|
self.bots.clear();
|
|
Ok(nicks.len())
|
|
}
|
|
|
|
/// Delete a service bot. Returns whether it existed.
|
|
pub fn bot_del(&mut self, nick: &str) -> Result<bool, ChanError> {
|
|
let k = key(nick);
|
|
if !self.bots.contains_key(&k) {
|
|
return Ok(false);
|
|
}
|
|
self.log.append(Event::BotRemoved { nick: nick.to_string() }).map_err(|_| ChanError::Internal)?;
|
|
self.bots.remove(&k);
|
|
Ok(true)
|
|
}
|
|
|
|
/// Change a bot's nick and/or identity. `NoChannel` = no such bot,
|
|
/// `Exists` = the new nick belongs to a different bot. On a rename, every
|
|
/// channel the old bot was assigned to is moved to the new nick.
|
|
pub fn bot_change(&mut self, old: &str, new_nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError> {
|
|
let ok = key(old);
|
|
if !self.bots.contains_key(&ok) {
|
|
return Err(ChanError::NoChannel);
|
|
}
|
|
let nk = key(new_nick);
|
|
let renaming = ok != nk;
|
|
if renaming && self.bots.contains_key(&nk) {
|
|
return Err(ChanError::Exists);
|
|
}
|
|
// 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 {
|
|
let chans: Vec<String> = self
|
|
.channels
|
|
.values()
|
|
.filter(|c| c.assigned_bot.as_deref().is_some_and(|b| key(b) == ok))
|
|
.map(|c| c.name.clone())
|
|
.collect();
|
|
self.log.append(Event::BotRemoved { nick: old.to_string() }).map_err(|_| ChanError::Internal)?;
|
|
self.bots.remove(&ok);
|
|
self.log.append(Event::BotAdded(bot.clone())).map_err(|_| ChanError::Internal)?;
|
|
self.bots.insert(nk, bot);
|
|
for chan in chans {
|
|
self.log.append(Event::ChannelBotAssigned { channel: chan.clone(), bot: new_nick.to_string() }).map_err(|_| ChanError::Internal)?;
|
|
self.channels.get_mut(&key(&chan)).unwrap().assigned_bot = Some(new_nick.to_string());
|
|
}
|
|
} else {
|
|
// Same nick, new identity: re-emit BotAdded to overwrite the fields.
|
|
self.log.append(Event::BotAdded(bot.clone())).map_err(|_| ChanError::Internal)?;
|
|
self.bots.insert(nk, bot);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// All registered bots.
|
|
pub fn bots(&self) -> impl Iterator<Item = &Bot> {
|
|
self.bots.values()
|
|
}
|
|
|
|
/// Assign a bot to a channel.
|
|
pub fn assign_bot(&mut self, channel: &str, bot: &str) -> Result<(), ChanError> {
|
|
let k = key(channel);
|
|
if !self.channels.contains_key(&k) {
|
|
return Err(ChanError::NoChannel);
|
|
}
|
|
self.log.append(Event::ChannelBotAssigned { channel: channel.to_string(), bot: bot.to_string() }).map_err(|_| ChanError::Internal)?;
|
|
self.channels.get_mut(&k).unwrap().assigned_bot = Some(bot.to_string());
|
|
Ok(())
|
|
}
|
|
|
|
/// Unassign a channel's bot. Returns whether one was assigned.
|
|
pub fn unassign_bot(&mut self, channel: &str) -> Result<bool, ChanError> {
|
|
let k = key(channel);
|
|
match self.channels.get(&k) {
|
|
None => return Err(ChanError::NoChannel),
|
|
Some(c) if c.assigned_bot.is_none() => return Ok(false),
|
|
Some(_) => {}
|
|
}
|
|
self.log.append(Event::ChannelBotUnassigned { channel: channel.to_string() }).map_err(|_| ChanError::Internal)?;
|
|
self.channels.get_mut(&k).unwrap().assigned_bot = None;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Append a memo to an account's mailbox.
|
|
pub fn memo_send(&mut self, account: &str, from: &str, text: &str) -> Result<(), RegError> {
|
|
let k = key(account);
|
|
if !self.accounts.contains_key(&k) {
|
|
return Err(RegError::Internal);
|
|
}
|
|
let ts = now();
|
|
self.log.append(Event::MemoSent { account: account.to_string(), from: from.to_string(), text: text.to_string(), ts }).map_err(|_| RegError::Internal)?;
|
|
self.accounts.get_mut(&k).unwrap().memos.push(Memo { from: from.to_string(), text: text.to_string(), ts, read: false });
|
|
Ok(())
|
|
}
|
|
|
|
/// An account's memos, oldest first.
|
|
pub fn memo_list(&self, account: &str) -> Vec<MemoView> {
|
|
self.accounts.get(&key(account)).map_or(Vec::new(), |a| {
|
|
a.memos.iter().map(|m| MemoView { from: m.from.clone(), text: m.text.clone(), ts: m.ts, read: m.read }).collect()
|
|
})
|
|
}
|
|
|
|
/// Read one memo by index (marks it read), returning its contents.
|
|
pub fn memo_read(&mut self, account: &str, index: usize) -> Option<MemoView> {
|
|
let k = key(account);
|
|
let view = self.accounts.get(&k).and_then(|a| a.memos.get(index)).map(|m| MemoView { from: m.from.clone(), text: m.text.clone(), ts: m.ts, read: m.read })?;
|
|
if !view.read {
|
|
let _ = self.log.append(Event::MemoRead { account: account.to_string(), index });
|
|
if let Some(m) = self.accounts.get_mut(&k).and_then(|a| a.memos.get_mut(index)) {
|
|
m.read = true;
|
|
}
|
|
}
|
|
Some(view)
|
|
}
|
|
|
|
/// Delete one memo by index. Returns whether it existed.
|
|
pub fn memo_del(&mut self, account: &str, index: usize) -> bool {
|
|
let k = key(account);
|
|
if !self.accounts.get(&k).is_some_and(|a| index < a.memos.len()) {
|
|
return false;
|
|
}
|
|
let _ = self.log.append(Event::MemoDeleted { account: account.to_string(), index });
|
|
if let Some(a) = self.accounts.get_mut(&k) {
|
|
if index < a.memos.len() {
|
|
a.memos.remove(index);
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Recall the most recent UNREAD memo `sender` left for `account`. Returns
|
|
/// whether one was cancelled — a memo the recipient already read can't be recalled.
|
|
pub fn memo_cancel(&mut self, account: &str, sender: &str) -> bool {
|
|
let sender_lc = sender.to_ascii_lowercase();
|
|
let idx = self.accounts.get(&key(account)).and_then(|a| {
|
|
a.memos.iter().enumerate().rev().find(|(_, m)| !m.read && m.from.to_ascii_lowercase() == sender_lc).map(|(i, _)| i)
|
|
});
|
|
match idx {
|
|
Some(i) => self.memo_del(account, i),
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// The read-status and timestamp of the most recent memo `sender` left for
|
|
/// `account`, or None if they have none in that mailbox.
|
|
pub fn memo_check(&self, account: &str, sender: &str) -> Option<(bool, u64)> {
|
|
let sender_lc = sender.to_ascii_lowercase();
|
|
self.accounts.get(&key(account)).and_then(|a| {
|
|
a.memos.iter().rev().find(|m| m.from.to_ascii_lowercase() == sender_lc).map(|m| (m.read, m.ts))
|
|
})
|
|
}
|
|
|
|
/// How many unread memos an account has.
|
|
pub fn unread_memos(&self, account: &str) -> usize {
|
|
self.accounts.get(&key(account)).map_or(0, |a| a.memos.iter().filter(|m| !m.read).count())
|
|
}
|
|
|
|
/// Set `channel`'s entry message (empty clears it).
|
|
pub fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError> {
|
|
let k = key(channel);
|
|
if !self.channels.contains_key(&k) {
|
|
return Err(ChanError::NoChannel);
|
|
}
|
|
self.log
|
|
.append(Event::ChannelEntryMsgSet { channel: channel.to_string(), msg: msg.to_string() })
|
|
.map_err(|_| ChanError::Internal)?;
|
|
self.channels.get_mut(&k).unwrap().entrymsg = msg.to_string();
|
|
Ok(())
|
|
}
|
|
|
|
/// Unregister `name`.
|
|
pub fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> {
|
|
let k = key(name);
|
|
if !self.channels.contains_key(&k) {
|
|
return Err(ChanError::NoChannel);
|
|
}
|
|
self.log.append(Event::ChannelDropped { name: name.to_string() }).map_err(|_| ChanError::Internal)?;
|
|
self.channels.remove(&k);
|
|
Ok(())
|
|
}
|
|
}
|