use super::*; impl Engine { // Answer a CTCP query addressed to a service pseudo-client. Replies to the // standard introspection queries via a CTCP NOTICE (the CTCP convention); // ACTION and anything unrecognised get no reply at all — a services daemon must // not treat `\x01VERSION\x01` as a bad command. fn ctcp_reply(&self, svc_uid: &str, to: &str, text: &str) -> Option { let body = text.trim_matches('\x01'); let mut parts = body.splitn(2, ' '); let cmd = parts.next().unwrap_or("").to_ascii_uppercase(); let arg = parts.next().unwrap_or(""); let response = match cmd.as_str() { "VERSION" => format!("VERSION echo services {}", env!("CARGO_PKG_VERSION")), "PING" => format!("PING {arg}"), "TIME" => format!("TIME {}", echo_api::human_time(self.now_secs())), "CLIENTINFO" => "CLIENTINFO ACTION CLIENTINFO PING TIME VERSION".to_string(), _ => return None, // ACTION and unknown CTCPs: stay silent }; Some(NetAction::Notice { from: svc_uid.to_string(), to: to.to_string(), text: format!("\x01{response}\x01"), }) } // 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 { 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(); // Render this command's replies in the sender's language: their account // preference if set, otherwise the network default. let lang = account.as_deref().and_then(|a| self.db.language_of(a)).unwrap_or_else(|| self.db.default_language()); let mut ctx = ServiceCtx { lang, ..Default::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(), } } } } // A CTCP query to a service (a PRIVMSG wrapped in \x01) gets a proper // CTCP NOTICE reply for VERSION/PING/TIME/CLIENTINFO, and is otherwise // ignored — never bounced back as an "I don't know the command" notice. if text.starts_with('\x01') { let svc_uid = 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()); let mut out = Vec::new(); if let Some(uid) = svc_uid { if let Some(reply) = self.ctcp_reply(&uid, from, text) { out.push(reply); } } return out; } let mut matched: Option = 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); } } // Login-attempt outcomes a command reported (e.g. a failed IDENTIFY against // a cert-only account), routed through the same auth feed as the verify path. for r in std::mem::take(&mut ctx.auth_reports) { if let Some(line) = self.auth_report(r.ok, r.account.as_deref(), &r.mech, &r.client, r.reason.as_deref()) { 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) { 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::>().join(" "); if !query.trim().is_empty() { let (database, label) = echo_dictserv::database_for(l, ctx.lang()); ctx.dict_lookup(botuid.as_str(), chan, database, label, query); } } return; } // Only ChanServ's own verbs are fantasy commands. An unknown `!word` is // some other bot's command sharing the `!` prefix, not ours, so ignore it // silently instead of the bot barking "I don't know that command". if !echo_chanserv::is_command(cmd) { 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 { 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); } } }