echo/src/engine/dispatch.rs
Jean 6f76f9722c
Reorganise the engine into a concern-split module tree
engine/mod.rs had grown past 5k lines, most of it one test module and
a few large method clusters. Lift the tests into engine/tests.rs, and
move the cohesive method groups into sibling files — sasl, kicker
(chat policing), register (account-authority relay), and dispatch
(service routing and fantasy). The Engine type, its master event
handler, shared helpers, and the small state structs stay in mod.rs;
each sibling adds its own impl Engine block. Pure reorganisation: no
behaviour change, tests unchanged and green.
2026-07-14 14:05:56 +00:00

125 lines
6.2 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);
self.bump("botserv.messages");
}
} else {
// A services ignore silences a user's commands entirely — but an oper
// can never be ignored (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();
}
}
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_ascii_lowercase());
break;
}
}
}
if let Some(nick) = matched {
self.bump(&format!("{nick}.command"));
}
}
// Fold any counters the command recorded into the shared registry.
for key in std::mem::take(&mut ctx.stats) {
self.bump(&key);
}
// 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()));
out
}
// 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;
}
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);
// Make the bot the source of everything ChanServ just emitted.
for a in ctx.actions[mark..].iter_mut() {
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);
}
}
}