225 lines
12 KiB
Rust
225 lines
12 KiB
Rust
use super::*;
|
|
|
|
impl Engine {
|
|
// Route a PRIVMSG addressed to a service (by uid or nick) into that service,
|
|
// handing it the sender's resolved nick, login state, and the account store.
|
|
pub(crate) fn dispatch(&mut self, from: &str, to: &str, text: &str) -> Vec<NetAction> {
|
|
let nick = self.network.nick_of(from).unwrap_or(from).to_string();
|
|
let account = self.network.account_of(from).map(str::to_string);
|
|
let privs = account.as_deref().map(|a| self.oper_privs(a)).unwrap_or_default();
|
|
let mut ctx = ServiceCtx::default();
|
|
// Mark the log so we can tell exactly which events this command appends.
|
|
let audit_mark = self.db.log_len();
|
|
let sender = Sender { uid: from, nick: &nick, account: account.as_deref(), privs };
|
|
if to.starts_with('#') || to.starts_with('&') {
|
|
// A community !votekick/!voteban is handled on its own; otherwise the
|
|
// line runs through fantasy (!op …), the kickers, and triggers.
|
|
if let Some(vote_actions) = self.handle_vote(from, to, text) {
|
|
ctx.actions.extend(vote_actions);
|
|
} else {
|
|
self.fantasy(&sender, to, text, &mut ctx);
|
|
let kicks = self.kicker_check(from, to, text);
|
|
let kicked = kicks.iter().any(|a| matches!(a, NetAction::Kick { .. }));
|
|
ctx.actions.extend(kicks);
|
|
// Don't reward a line the bot just kicked for with a response.
|
|
if !kicked {
|
|
if let Some(resp) = self.trigger_response(from, to, text) {
|
|
ctx.actions.push(resp);
|
|
}
|
|
}
|
|
}
|
|
// Record activity in bot channels (surfaced by StatServ).
|
|
if self.db.channel(to).is_some_and(|c| c.assigned_bot.is_some()) {
|
|
self.network.record_line(to, &nick, text);
|
|
self.bump("botserv.messages");
|
|
}
|
|
} else {
|
|
// A services ignore silences a user's commands entirely, and flood
|
|
// control clamps a bot spamming a service in a loop — but an oper is
|
|
// exempt from both (else they could lock themselves out).
|
|
if !privs.any() {
|
|
let host = self.network.host_of(from).unwrap_or("").to_string();
|
|
if self.db.is_ignored(&nick, &host) {
|
|
return Vec::new();
|
|
}
|
|
// Flood control targets the stated vector: a bot that connects and
|
|
// spams a service in a loop without ever logging in. Identified users
|
|
// are accountable and legitimately burst (channel setup), and the
|
|
// ircd's own fakelag still governs everyone — so only anonymous
|
|
// senders are clamped, and only on lines aimed at one of our services.
|
|
if account.is_none() {
|
|
let target = self.services.iter()
|
|
.find(|s| to.eq_ignore_ascii_case(s.uid()) || to.eq_ignore_ascii_case(s.nick()))
|
|
.map(|s| s.uid().to_string());
|
|
if let Some(svc_uid) = target {
|
|
match self.cmd_limiter.check(&host) {
|
|
CmdVerdict::Allow => {}
|
|
CmdVerdict::Warn => {
|
|
let mut out = vec![NetAction::Notice {
|
|
from: svc_uid,
|
|
to: from.to_string(),
|
|
text: "You're sending commands too fast. Slow down and try again in a moment.".to_string(),
|
|
}];
|
|
self.apply_msg_style(&mut out);
|
|
return out;
|
|
}
|
|
CmdVerdict::Drop => return Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut matched: Option<String> = None;
|
|
{
|
|
let Self { services, network, db, .. } = self;
|
|
for svc in services.iter_mut() {
|
|
if to.eq_ignore_ascii_case(svc.uid()) || to.eq_ignore_ascii_case(svc.nick()) {
|
|
let args: Vec<&str> = text.split_whitespace().collect();
|
|
svc.on_command(&sender, &args, &mut ctx, network, db);
|
|
matched = Some(svc.nick().to_string()); // real case, for the feed line
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if let Some(nick) = matched {
|
|
self.bump(&format!("{}.command", nick.to_ascii_lowercase()));
|
|
// NOTIFY watch on services use: log the service + verb only (never
|
|
// the arguments, which may carry a password), as "Service:verb". A
|
|
// SET command carries the 'S' flag, everything else 's'.
|
|
let verb = text.split_whitespace().next().unwrap_or("").to_ascii_lowercase();
|
|
let flag = if verb == "set" { 'S' } else { 's' };
|
|
if let Some(line) = self.notify_line(flag, from, None, &format!("used {nick}:{verb}")) {
|
|
ctx.actions.push(line);
|
|
}
|
|
}
|
|
}
|
|
// Fold any counters the command recorded into the shared registry.
|
|
for key in std::mem::take(&mut ctx.stats) {
|
|
self.bump(&key);
|
|
}
|
|
// Staff-feed alerts a command raised (e.g. a blocked look-alike name),
|
|
// prefixed with who the sender is, routed to the log channel.
|
|
let alerts = std::mem::take(&mut ctx.alerts);
|
|
// A command may have changed the bot registry (BotServ BOT ADD/DEL).
|
|
let mut out = ctx.actions;
|
|
out.extend(self.reconcile_bots());
|
|
// Announce whatever this command changed to the staff audit channel.
|
|
out.extend(self.audit_feed(audit_mark, &nick, account.as_deref()));
|
|
for (cat, text) in alerts {
|
|
let full = format!("{} {text}", self.who(from));
|
|
// Record to the searchable incident log (OperServ LOGSEARCH) and announce.
|
|
let now = self.now_secs();
|
|
self.network.record_incident(format!("[{cat}] {full}"), now);
|
|
if let Some(line) = self.feed(&cat, full) {
|
|
out.push(line);
|
|
}
|
|
}
|
|
self.apply_dict_limit(&mut out);
|
|
self.apply_msg_style(&mut out);
|
|
out
|
|
}
|
|
|
|
// Drop DICT lookups that exceed the global rate budget, so a burst of !dict
|
|
// can't hammer the dictionary server. Non-lookup actions pass untouched, and
|
|
// the limiter is only consulted when a lookup is actually present.
|
|
fn apply_dict_limit(&mut self, out: &mut Vec<NetAction>) {
|
|
if out.iter().any(|a| matches!(a, NetAction::DictLookup { .. })) {
|
|
out.retain(|a| !matches!(a, NetAction::DictLookup { .. }) || self.dict_limiter.allow());
|
|
}
|
|
}
|
|
|
|
// Rewrite each service→user NOTICE to a server-notice (sourced from our
|
|
// server) for users who opted into SET SNOTICE; everyone else keeps a normal
|
|
// notice from the pseudoclient. A multi-line reply names the service once, on
|
|
// its first line ("*** NickServ: …"), and the rest are plain "*** …"
|
|
// continuations — so a long HELP isn't the service name on every row. Per
|
|
// target, so notices to channels or to users who didn't opt in are untouched.
|
|
fn apply_msg_style(&self, out: &mut [NetAction]) {
|
|
let mut named: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
|
|
for a in out.iter_mut() {
|
|
let NetAction::Notice { from, to, text } = a else { continue };
|
|
let Some(account) = self.network.account_of(to) else { continue };
|
|
if !self.db.account_wants_snotice(account) {
|
|
continue;
|
|
}
|
|
let Some(nick) = self.services.iter().find(|s| s.uid() == *from).map(|s| s.nick().to_string()) else { continue };
|
|
*text = if named.insert((from.clone(), to.clone())) {
|
|
format!("*** {nick}: {text}")
|
|
} else {
|
|
format!("*** {text}")
|
|
};
|
|
*from = self.sid.clone();
|
|
}
|
|
}
|
|
|
|
// fantasy word becomes the command and the channel is injected as its first
|
|
// argument — then re-source the resulting actions from the bot, so the bot is
|
|
// the visible actor.
|
|
fn fantasy(&mut self, sender: &Sender, chan: &str, text: &str, ctx: &mut ServiceCtx) {
|
|
let Some(rest) = text.strip_prefix(FANTASY_PREFIX) else { return };
|
|
let mut words = rest.split_whitespace();
|
|
let Some(cmd) = words.next() else { return };
|
|
// Fantasy only works through an assigned, live bot.
|
|
let Some(botnick) = self.db.channel(chan).and_then(|c| c.assigned_bot.clone()) else { return };
|
|
let Some(botuid) = self.network.uid_by_nick(&botnick).map(str::to_string) else { return };
|
|
|
|
// A dice command (!roll/!calc/…) goes to DiceServ, if it's loaded, and its
|
|
// reply is spoken into the channel by the bot; everything else is a channel
|
|
// command handled by ChanServ.
|
|
if matches!(cmd.to_ascii_uppercase().as_str(), "ROLL" | "CALC" | "EXROLL" | "EXCALC") {
|
|
let Some(dsuid) = self.service_uid("DiceServ") else { return };
|
|
let mark = ctx.actions.len();
|
|
let args: Vec<&str> = std::iter::once(cmd).chain(words).collect();
|
|
self.route_to(&dsuid, sender, &args, ctx);
|
|
// Turn DiceServ's private notices into the bot speaking to the channel.
|
|
for a in ctx.actions[mark..].iter_mut() {
|
|
if let NetAction::Notice { text, .. } = a {
|
|
*a = NetAction::Privmsg { from: botuid.clone(), to: chan.to_string(), text: std::mem::take(text) };
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// A dictionary command (!dict/!define/…) becomes a deferred DICT lookup the
|
|
// assigned bot speaks — only when DictServ is loaded. The rate limiter in
|
|
// dispatch() drops excess lookups so nobody can hammer the DICT server.
|
|
if let Some(l) = echo_dictserv::lookup_for(cmd) {
|
|
if self.service_uid("DictServ").is_some() {
|
|
let query = words.collect::<Vec<_>>().join(" ");
|
|
if !query.trim().is_empty() {
|
|
ctx.dict_lookup(botuid.as_str(), chan, l.database, l.label, query);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
let Some(csuid) = self.service_uid("ChanServ") else { return };
|
|
// Rewrite `!cmd args…` into `CMD #channel args…` for ChanServ.
|
|
let mark = ctx.actions.len();
|
|
let args: Vec<&str> = [cmd, chan].into_iter().chain(words).collect();
|
|
self.route_to(&csuid, sender, &args, ctx);
|
|
// Fantasy is public: the bot speaks ChanServ's text replies into the channel,
|
|
// and its actions (modes, kicks) are re-sourced from the bot.
|
|
for a in ctx.actions[mark..].iter_mut() {
|
|
match a {
|
|
NetAction::Notice { text, .. } => {
|
|
*a = NetAction::Privmsg { from: botuid.clone(), to: chan.to_string(), text: std::mem::take(text) };
|
|
}
|
|
_ => resource_action(a, &csuid, &botuid),
|
|
}
|
|
}
|
|
}
|
|
|
|
// The uid of a loaded service by nick, if present.
|
|
fn service_uid(&self, nick: &str) -> Option<String> {
|
|
self.services.iter().find(|s| s.nick().eq_ignore_ascii_case(nick)).map(|s| s.uid().to_string())
|
|
}
|
|
|
|
// Dispatch a command to a specific service (by uid), borrowing the disjoint
|
|
// engine fields it needs.
|
|
fn route_to(&mut self, uid: &str, sender: &Sender, args: &[&str], ctx: &mut ServiceCtx) {
|
|
let Self { services, network, db, .. } = self;
|
|
if let Some(svc) = services.iter_mut().find(|s| s.uid() == uid) {
|
|
svc.on_command(sender, args, ctx, network, db);
|
|
}
|
|
}
|
|
}
|