From 623750e63895eabea256b0268b2ed68f632eeea8 Mon Sep 17 00:00:00 2001 From: Jean Date: Sat, 18 Jul 2026 20:21:30 +0000 Subject: [PATCH] =?UTF-8?q?dictserv:=20opt-in=20DICT=20(RFC=202229)=20look?= =?UTF-8?q?up=20service=20=E2=80=94=20bots=20and=20/msg=20answer=20dict/de?= =?UTF-8?q?fine/thes/acronym/law/etc=20via=20dict.org=20off-reactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 8 ++ Cargo.toml | 2 + README.md | 1 + api/src/lib.rs | 11 +++ config.example.toml | 10 ++ modules/dictserv/Cargo.toml | 8 ++ modules/dictserv/src/lib.rs | 110 +++++++++++++++++++++ modules/protocol/inspircd/src/lib.rs | 2 +- src/config.rs | 16 +++ src/dict.rs | 143 +++++++++++++++++++++++++++ src/engine/dispatch.rs | 23 +++++ src/engine/mod.rs | 32 ++++++ src/engine/tests.rs | 41 ++++++++ src/link.rs | 28 +++++- src/main.rs | 13 ++- 15 files changed, 444 insertions(+), 4 deletions(-) create mode 100644 modules/dictserv/Cargo.toml create mode 100644 modules/dictserv/src/lib.rs create mode 100644 src/dict.rs diff --git a/Cargo.lock b/Cargo.lock index 2c62a43..a94204e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -286,6 +286,7 @@ dependencies = [ "echo-chanserv", "echo-debugserv", "echo-diceserv", + "echo-dictserv", "echo-example", "echo-gameserv", "echo-groupserv", @@ -360,6 +361,13 @@ dependencies = [ "rand", ] +[[package]] +name = "echo-dictserv" +version = "0.0.1" +dependencies = [ + "echo-api", +] + [[package]] name = "echo-example" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index d6719c3..97e0549 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "modules/hostserv", "modules/operserv", "modules/diceserv", + "modules/dictserv", "modules/gameserv", "modules/infoserv", "modules/reportserv", @@ -41,6 +42,7 @@ echo-statserv = { path = "modules/statserv" } echo-hostserv = { path = "modules/hostserv" } echo-operserv = { path = "modules/operserv" } echo-diceserv = { path = "modules/diceserv" } +echo-dictserv = { path = "modules/dictserv" } echo-gameserv = { path = "modules/gameserv" } echo-infoserv = { path = "modules/infoserv" } echo-reportserv = { path = "modules/reportserv" } diff --git a/README.md b/README.md index 037ac85..66bee89 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Every service is a first-class pseudo-client, all started by default. | InfoServ | network bulletins shown on connect and login | | ReportServ | user abuse reports feeding the operator audit trail | | DiceServ | dice and math for tabletop games | +| DictServ | dictionary, thesaurus and reference lookups (dict.org), opt-in | ## Architecture diff --git a/api/src/lib.rs b/api/src/lib.rs index b69b8af..c030b2a 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -209,6 +209,11 @@ pub enum NetAction { // Internal only: send an email (plaintext + optional HTML). The link layer // pipes it to the configured mail command off-thread; never serialized. SendEmail { to: String, subject: String, text: String, html: Option }, + // Internal only: look up `query` on a DICT server (RFC 2229) off the reactor, + // then speak the result as `speak_as` to `target` — a #channel (a fantasy + // lookup, spoken by the assigned bot) or a user (a direct DictServ query). + // `label` is the human dictionary name for the reply. Never serialized. + DictLookup { speak_as: String, target: String, database: String, label: String, query: String }, // Internal only: stop the services process (OperServ SHUTDOWN/RESTART). The // link layer exits cleanly; `restart` exits non-zero so a supervisor (systemd // Restart=) brings it back. Never serialized. @@ -488,6 +493,12 @@ impl ServiceCtx { self.actions.push(NetAction::SendEmail { to: to.into(), subject: subject.into(), text: text.into(), html }); } + // Look up `query` on a DICT server off the reactor and speak the result as + // `speak_as` to `target`. Used by DictServ (direct query) and channel fantasy. + pub fn dict_lookup(&mut self, speak_as: impl Into, target: impl Into, database: impl Into, label: impl Into, query: impl Into) { + self.actions.push(NetAction::DictLookup { speak_as: speak_as.into(), target: target.into(), database: database.into(), label: label.into(), query: query.into() }); + } + // Stop the services process (OperServ SHUTDOWN/RESTART). The link layer exits // once this action is reached; `restart` exits non-zero so a supervisor // respawns us. diff --git a/config.example.toml b/config.example.toml index 7d27021..d7cc507 100644 --- a/config.example.toml +++ b/config.example.toml @@ -93,3 +93,13 @@ protocol = 1206 # InspIRCd link protocol version (1206 = insp4, 1205 # still owns all channel/vhost/ban data, keyed by the account name. # [auth] # external = true + +# DictServ: dictionary / thesaurus / reference lookups over the DICT protocol +# (RFC 2229). Present = the service loads and channel bots answer !dict, !define, +# !thes, !acronym, !law, !element, !bible, and friends (DictServ LOOKUP lists them +# all); also queryable directly with /msg DictServ . Omit it and echo +# makes no outbound lookups at all — this is opt-in because it reaches the network. +# Lookups are globally rate-limited and the reply is truncated to one line. The log +# channel is unaffected; keep this off if you don't want echo talking to dict.org. +# [dictserv] +# server = "dict.org:2628" diff --git a/modules/dictserv/Cargo.toml b/modules/dictserv/Cargo.toml new file mode 100644 index 0000000..3684d3e --- /dev/null +++ b/modules/dictserv/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "echo-dictserv" +version = "0.0.1" +edition = "2021" +description = "DictServ: dictionary, thesaurus and reference lookups over DICT (RFC 2229)." + +[dependencies] +echo-api = { path = "../../api" } diff --git a/modules/dictserv/src/lib.rs b/modules/dictserv/src/lib.rs new file mode 100644 index 0000000..80f2d06 --- /dev/null +++ b/modules/dictserv/src/lib.rs @@ -0,0 +1,110 @@ +//! DictServ answers dictionary, thesaurus and reference lookups by querying a +//! DICT server (RFC 2229) — dict.org by default. Every lookup command maps to one +//! DICT database; the actual network request runs off the reactor (the link layer +//! handles `NetAction::DictLookup`), so a slow server never stalls the engine. +//! +//! Works two ways: `/msg DictServ dict cat` (DictServ replies), or the fantasy +//! `!dict cat` in a channel with an assigned bot (the bot speaks the answer). + +use echo_api::{HelpEntry, NetView, Sender, Service, ServiceCtx, Store}; + +// One lookup command: the fantasy/word, the DICT database it queries, and a human +// label shown in the reply. Shared by DictServ and the engine's fantasy router so +// the two never drift. +pub struct Lookup { + pub cmd: &'static str, + pub database: &'static str, + pub label: &'static str, +} + +pub const LOOKUPS: &[Lookup] = &[ + Lookup { cmd: "dict", database: "wn", label: "WordNet" }, + Lookup { cmd: "define", database: "gcide", label: "GCIDE" }, + Lookup { cmd: "definitions", database: "*", label: "all dictionaries" }, + Lookup { cmd: "thes", database: "moby-thesaurus", label: "Thesaurus" }, + Lookup { cmd: "element", database: "elements", label: "Elements" }, + Lookup { cmd: "acronym", database: "vera", label: "Acronyms" }, + Lookup { cmd: "jargon", database: "jargon", label: "Jargon File" }, + Lookup { cmd: "term", database: "foldoc", label: "Computing" }, + Lookup { cmd: "bible", database: "easton", label: "Easton's Bible" }, + Lookup { cmd: "biblename", database: "hitchcock", label: "Bible Names" }, + Lookup { cmd: "law", database: "bouvier", label: "Bouvier's Law" }, + Lookup { cmd: "ciainfo", database: "world02", label: "CIA Factbook" }, + Lookup { cmd: "counties", database: "gaz2k-counties", label: "US Counties" }, + Lookup { cmd: "places", database: "gaz2k-places", label: "US Places" }, + Lookup { cmd: "zipcodes", database: "gaz2k-zips", label: "US ZIP codes" }, +]; + +// The lookup a command word maps to, if any. Case-insensitive. +pub fn lookup_for(cmd: &str) -> Option<&'static Lookup> { + LOOKUPS.iter().find(|l| l.cmd.eq_ignore_ascii_case(cmd)) +} + +const BLURB: &str = "DictServ looks words up in the dict.org dictionaries. Try \x02dict\x02 (WordNet), \x02define\x02 (GCIDE), \x02thes\x02 (thesaurus), \x02acronym\x02, \x02element\x02, \x02law\x02, \x02bible\x02, and more — \x02LOOKUP\x02 lists them all."; + +const TOPICS: &[HelpEntry] = &[ + HelpEntry { cmd: "DICT", summary: "define a word (WordNet)", detail: "Syntax: \x02DICT \x02\nLooks a word up in WordNet. In a channel with a bot, use \x02!dict \x02." }, + HelpEntry { cmd: "DEFINE", summary: "define a word (GCIDE)", detail: "Syntax: \x02DEFINE \x02\nLooks a word up in the GNU Collaborative International Dictionary of English." }, + HelpEntry { cmd: "THES", summary: "thesaurus lookup", detail: "Syntax: \x02THES \x02\nMoby Thesaurus synonyms for a word." }, + HelpEntry { cmd: "LOOKUP", summary: "list every lookup command", detail: "Syntax: \x02LOOKUP\x02\nLists all dictionary, reference and thesaurus lookups DictServ knows." }, +]; + +pub struct DictServ { + pub uid: String, +} + +impl Service for DictServ { + fn nick(&self) -> &str { + "DictServ" + } + fn uid(&self) -> &str { + &self.uid + } + fn gecos(&self) -> &str { + "Dictionary Lookups" + } + fn help_topics(&self) -> (&'static str, &'static [HelpEntry]) { + (BLURB, TOPICS) + } + + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &dyn NetView, _db: &mut dyn Store) { + let me = self.uid.as_str(); + let cmd = args.first().copied().unwrap_or(""); + // A lookup command: hand the query to the deferred DICT client, which will + // reply to the user directly. Arguments (the term) are sent as one string. + if let Some(l) = lookup_for(cmd) { + let query = args[1..].join(" "); + if query.trim().is_empty() { + ctx.notice(me, from.uid, format!("Give a term to look up, e.g. \x02{} cat\x02.", l.cmd)); + return; + } + ctx.dict_lookup(me, from.uid, l.database, l.label, query); + return; + } + match cmd.to_ascii_uppercase().as_str() { + "LOOKUP" | "LIST" => { + ctx.notice(me, from.uid, "Lookup commands (use in a channel as \x02!cmd \x02, or here as \x02/msg DictServ cmd \x02):"); + for l in LOOKUPS { + ctx.notice(me, from.uid, format!(" \x02{}\x02 — {}", l.cmd, l.label)); + } + } + "HELP" | "" => echo_api::help(me, from, ctx, BLURB, TOPICS, args.get(1).copied()), + other => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02LOOKUP\x02 for the list, or \x02HELP\x02.")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_commands_to_databases_case_insensitively() { + assert_eq!(lookup_for("dict").unwrap().database, "wn"); + assert_eq!(lookup_for("DEFINE").unwrap().database, "gcide"); + assert_eq!(lookup_for("Thes").unwrap().database, "moby-thesaurus"); + assert_eq!(lookup_for("zipcodes").unwrap().database, "gaz2k-zips"); + assert!(lookup_for("nonsense").is_none()); + assert!(lookup_for("roll").is_none()); // not ours — DiceServ owns that + } +} diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index b6a4e7b..f1448d0 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -540,7 +540,7 @@ impl Protocol for InspIrcd { NetAction::Squit { target, reason } => vec![self.sourced(format!("SQUIT {} :{}", target, reason))], NetAction::Raw(s) => vec![s.clone()], // Internal: the link layer handles these before serialization. - NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } | NetAction::DeferAuthenticate { .. } | NetAction::DeferKeycard { .. } | NetAction::SendEmail { .. } | NetAction::Shutdown { .. } | NetAction::Rehash { .. } => vec![], + NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } | NetAction::DeferAuthenticate { .. } | NetAction::DeferKeycard { .. } | NetAction::SendEmail { .. } | NetAction::DictLookup { .. } | NetAction::Shutdown { .. } | NetAction::Rehash { .. } => vec![], }; // A trailing parameter can carry free-form text (message bodies, kick // reasons, topics, metadata). Strip CR/LF/NUL at this single choke-point diff --git a/src/config.rs b/src/config.rs index ba52415..296c419 100644 --- a/src/config.rs +++ b/src/config.rs @@ -45,6 +45,10 @@ pub struct Config { // credentials (`kc_…` over SASL) are not honoured. #[serde(default)] pub keycard: Option, + // DictServ dictionary lookups (dict.org / RFC 2229). Absent = the service does + // not load and echo makes no outbound lookup requests. Opt-in on purpose. + #[serde(default)] + pub dictserv: Option, // Which InspIRCd matching-extbans AKICK may use. Absent = every extban echo // knows (full compatibility). List `enabled` to restrict it — e.g. drop the // ones your ircd doesn't provide. @@ -69,6 +73,18 @@ pub struct Keycard { pub api_key: String, } +// DictServ. `server` is a DICT-protocol endpoint (RFC 2229); dict.org hosts the +// standard databases (WordNet, GCIDE, thesaurus, …). +#[derive(Debug, Deserialize, Clone)] +pub struct Dict { + #[serde(default = "default_dict_server")] + pub server: String, +} + +fn default_dict_server() -> String { + "dict.org:2628".to_string() +} + // Account-authority configuration. #[derive(Debug, Deserialize, Clone)] pub struct Auth { diff --git a/src/dict.rs b/src/dict.rs new file mode 100644 index 0000000..611c826 --- /dev/null +++ b/src/dict.rs @@ -0,0 +1,143 @@ +//! A tiny DICT-protocol (RFC 2229) client used by DictServ. Runs off the reactor +//! (spawned from the link layer), connects to a DICT server, asks for one +//! definition, and returns a single truncated line to speak into the channel. The +//! response parser is split out so it can be tested without a network. + +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; + +const TIMEOUT: Duration = Duration::from_secs(6); +const MAX_LEN: usize = 400; // keep a spoken reply to about one IRC line + +// Look `word` up in `database` on `server`. Never errors out to the caller: on a +// timeout, connection failure, or no match it returns a short human message, so +// the bot always has something safe to say. +pub async fn lookup(server: &str, database: &str, word: &str) -> String { + let word = sanitize(word); + if word.is_empty() { + return "nothing to look up.".to_string(); + } + match tokio::time::timeout(TIMEOUT, exchange(server, database, &word)).await { + Ok(Ok(Some(def))) => def, + Ok(Ok(None)) => format!("no definition found for \x02{word}\x02."), + Ok(Err(_)) | Err(_) => "the dictionary is unavailable right now.".to_string(), + } +} + +// One request/response round trip. Returns the first definition's text, or None +// for a clean "no match". +async fn exchange(server: &str, database: &str, word: &str) -> std::io::Result> { + let stream = TcpStream::connect(server).await?; + stream.set_nodelay(true).ok(); + let (rd, mut wr) = stream.into_split(); + let mut lines = BufReader::new(rd).lines(); + // Banner (220 …). + lines.next_line().await?; + wr.write_all(format!("DEFINE {database} \"{word}\"\r\n").as_bytes()).await?; + let mut raw = String::new(); + while let Some(line) = lines.next_line().await? { + // 250 ends the whole response; stop reading once we've seen it. + if line.starts_with("250 ") { + break; + } + raw.push_str(&line); + raw.push('\n'); + if raw.len() > MAX_LEN * 8 { + break; // guard against a runaway server + } + } + let _ = wr.write_all(b"QUIT\r\n").await; + Ok(parse_definition(&raw)) +} + +// Extract the first definition's body from a DICT DEFINE response. `raw` is the +// lines after the `DEFINE` command (excluding the final 250). Returns None for a +// no-match (552) or an empty body. +fn parse_definition(raw: &str) -> Option { + let mut body = String::new(); + let mut in_def = false; + for line in raw.lines() { + if !in_def { + match line.get(..3) { + Some("552") | Some("550") | Some("551") => return None, // no match / bad db + Some("151") => in_def = true, // start of the first definition block + _ => {} // 220 banner, 150 count, … + } + continue; + } + // Body runs until a lone "." — take just the first definition. + if line == "." { + break; + } + let text = line.strip_prefix("..").unwrap_or(line).trim(); // undo dot-stuffing + if !text.is_empty() { + if !body.is_empty() { + body.push(' '); + } + body.push_str(text); + } + if body.len() >= MAX_LEN { + break; + } + } + let body: String = body.split_whitespace().collect::>().join(" "); + if body.is_empty() { + return None; + } + Some(truncate(&body, MAX_LEN)) +} + +// A user-supplied term: drop control chars and quotes (which would break the DICT +// quoting), and cap the length so a giant term can't be smuggled to the server. +fn sanitize(word: &str) -> String { + word.chars().filter(|c| !c.is_control() && *c != '"').take(128).collect::().trim().to_string() +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let mut out: String = s.chars().take(max).collect(); + out.push('…'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_the_first_definition() { + let raw = "150 2 definitions retrieved\n\ + 151 \"cat\" wn \"WordNet (r) 3.0\"\n\ + cat\n n 1: feline mammal usually having thick soft fur\n n 2: an informal term for a youth or man\n\ + .\n\ + 151 \"cat\" gcide \"GCIDE\"\n\ + Cat \\Cat\\ another definition\n\ + .\n"; + let def = parse_definition(raw).expect("a definition"); + assert!(def.contains("feline mammal"), "got: {def}"); + assert!(!def.contains("GCIDE"), "should stop at the first block: {def}"); + } + + #[test] + fn no_match_returns_none() { + assert!(parse_definition("552 no match\n").is_none()); + assert!(parse_definition("150 0 definitions retrieved\n").is_none()); + } + + #[test] + fn truncates_long_bodies() { + let long = format!("151 \"x\" wn \"d\"\n{}\n.\n", "word ".repeat(300)); + let def = parse_definition(&long).unwrap(); + assert!(def.chars().count() <= MAX_LEN + 1, "len {}", def.chars().count()); + assert!(def.ends_with('…')); + } + + #[test] + fn sanitize_strips_quotes_and_controls() { + assert_eq!(sanitize("he\"llo\u{1}"), "hello"); + assert_eq!(sanitize(" spaced "), "spaced"); + } +} diff --git a/src/engine/dispatch.rs b/src/engine/dispatch.rs index afb0113..85effd5 100644 --- a/src/engine/dispatch.rs +++ b/src/engine/dispatch.rs @@ -101,10 +101,20 @@ impl Engine { 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())); + 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 @@ -157,6 +167,19 @@ impl Engine { 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() { + 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(); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 0af638d..305eb2b 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -95,6 +95,7 @@ pub struct Engine { sasl_source: HashMap, // client uid -> real host/IP from the SASL H message reg_limiter: RegLimiter, cmd_limiter: CmdLimiter, // per-host flood control for service commands + dict_limiter: DictLimiter, // global rate cap on outbound DICT lookups chan_service: Option, // uid to source channel modes from (ChanServ) nick_service: Option, // uid of the account service (NickServ), for its notices irc_out: Option>, // services-initiated actions -> the uplink @@ -236,6 +237,7 @@ impl Engine { sasl_source: HashMap::new(), reg_limiter: RegLimiter::new(), cmd_limiter: CmdLimiter::default(), + dict_limiter: DictLimiter::new(), chan_service, nick_service, irc_out: None, @@ -2046,6 +2048,36 @@ impl RegLimiter { } } +// A global token bucket bounding how fast echo hits the DICT server, so a channel +// of users spamming !dict can't hammer dict.org (and get echo throttled). Bursts +// of DICT_BURST, then DICT_REFILL_PER_SEC sustained; an over-budget lookup is +// dropped — the bot simply doesn't answer that one. +struct DictLimiter { + tokens: f64, + last: Instant, +} + +const DICT_BURST: f64 = 6.0; +const DICT_REFILL_PER_SEC: f64 = 1.0; + +impl DictLimiter { + fn new() -> Self { + Self { tokens: DICT_BURST, last: Instant::now() } + } + + fn allow(&mut self) -> bool { + let now = Instant::now(); + self.tokens = (self.tokens + now.duration_since(self.last).as_secs_f64() * DICT_REFILL_PER_SEC).min(DICT_BURST); + self.last = now; + if self.tokens >= 1.0 { + self.tokens -= 1.0; + true + } else { + false + } + } +} + // Per-host flood control for anonymous service commands. A bot that connects and // spams `/msg NickServ HELP` in a loop is warned, then silently dropped until it // slows down — never answered per line, which would just double the flood on diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 744a834..4c64ba5 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -5487,6 +5487,47 @@ fn cmd_limiter_bursts_then_throttles_per_host() { assert!(matches!(lim.check_at("1.2.3.4", next_window), CmdVerdict::Warn), "one fresh warning in a new window"); } +// A dict fantasy (!dict) in a bot channel emits a DictLookup carried by the +// assigned bot — the actual dict.org round trip happens off-reactor in the link +// layer, so this just checks the routing. +#[test] +fn fantasy_dict_emits_a_lookup_via_the_bot() { + use echo_botserv::BotServ; + use echo_dictserv::DictServ; + use echo_nickserv::NickServ; + let path = std::env::temp_dir().join("echo-fdict.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("boss", "password1", None).unwrap(); + db.register_channel("#c", "boss").unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(BotServ { uid: "42SAAAAAD".into() }), + Box::new(DictServ { uid: "42SAAAAAQ".into() }), + ], + db, + ); + e.set_sid("42S".into()); + let mut opers = std::collections::HashMap::new(); + opers.insert("boss".to_string(), Privs::default().with(echo_api::Priv::Admin)); + e.set_opers(opers); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAR".into(), nick: "rando".into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "BOT ADD Bendy bot serv.host Helper".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "ASSIGN #c Bendy".into() }); + e.handle(NetEvent::Join { uid: "000AAAAAR".into(), channel: "#c".into(), op: false }); + + let out = e.handle(NetEvent::Privmsg { from: "000AAAAAR".into(), to: "#c".into(), text: "!dict cat".into() }); + assert!( + out.iter().any(|a| matches!(a, NetAction::DictLookup { target, database, query, speak_as, .. } + if target == "#c" && database == "wn" && query == "cat" && speak_as.starts_with("42SB"))), + "expected a bot-carried DictLookup, got {out:?}" + ); +} + // Perf harness — not part of the normal suite. Run: // cargo test --release -p echo -- --ignored --nocapture bench_engine // Measures the two costs that actually bound the single-threaded engine: the diff --git a/src/link.rs b/src/link.rs index b98f330..bc212d0 100644 --- a/src/link.rs +++ b/src/link.rs @@ -71,7 +71,8 @@ fn redact(line: &str) -> Cow<'_, str> { // One uplink session: connect, handshake + burst, then translate lines forever. // The engine is shared with the gossip layer, so it is locked per operation and // never held across the registration key-stretching await. -pub async fn run(mut proto: Box, engine: Arc>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver, email: Option, keycard: Option) -> Result<()> { +#[allow(clippy::too_many_arguments)] // the link driver legitimately wires up many collaborators +pub async fn run(mut proto: Box, engine: Arc>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver, irc_tx: mpsc::UnboundedSender, email: Option, keycard: Option, dict_server: Option) -> Result<()> { let stream = TcpStream::connect(addr).await?; // Disable Nagle: service replies are small multi-line bursts, and without this // the last segment of a reply is held ~40ms waiting on a delayed ACK, so a @@ -159,6 +160,10 @@ pub async fn run(mut proto: Box, engine: Arc>, addr: dispatch_email(&email, to, subject, text, html); continue; } + if let NetAction::DictLookup { speak_as, target, database, label, query } = act { + dispatch_dict(&dict_server, &irc_tx, speak_as, target, database, label, query); + continue; + } if let NetAction::Shutdown { restart, reason } = act { engine.lock().await.persist_stats(); let _ = write.flush().await; @@ -193,6 +198,27 @@ pub async fn run(mut proto: Box, engine: Arc>, addr: Ok(()) } +// Run a DictServ lookup off the reactor and speak the result back. The DICT round +// trip (up to a few seconds) must never touch the engine lock; the reply is pushed +// through irc_tx so the link loop writes it like any services-initiated action. A +// #channel target is spoken by the assigned bot (privmsg), a user gets a notice. +fn dispatch_dict(server: &Option, irc_tx: &mpsc::UnboundedSender, speak_as: String, target: String, database: String, label: String, query: String) { + let Some(server) = server.clone() else { + return tracing::warn!("dict lookup requested but no server configured"); + }; + let tx = irc_tx.clone(); + tokio::spawn(async move { + let result = crate::dict::lookup(&server, &database, &query).await; + let text = format!("\x02{query}\x02 ({label}): {result}"); + let action = if target.starts_with('#') || target.starts_with('&') { + NetAction::Privmsg { from: speak_as, to: target, text } + } else { + NetAction::Notice { from: speak_as, to: target, text } + }; + let _ = tx.send(action); + }); +} + // Fire off an email if email is configured: pipe an RFC822 message to the mail // command on a spawned task, so a slow MTA never stalls the link. With an HTML // body it's sent as multipart/alternative (HTML + plaintext fallback). diff --git a/src/main.rs b/src/main.rs index 399e46b..d9bf8ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ // crates (echo-nickserv, echo-chanserv, echo-inspircd), each depending // only on the echo-api SDK. mod config; +mod dict; mod engine; mod gossip; mod grpc; @@ -24,6 +25,7 @@ use echo_statserv::StatServ; use echo_hostserv::HostServ; use echo_operserv::OperServ; use echo_diceserv::DiceServ; +use echo_dictserv::DictServ; use echo_gameserv::GameServ; use echo_infoserv::InfoServ; use echo_reportserv::ReportServ; @@ -133,6 +135,13 @@ async fn main() -> Result<()> { uid: format!("{}AAAAAI", cfg.server.sid), })); } + // DictServ is gated on its own [dictserv] config, not the module list — it's an + // opt-in that makes outbound lookup requests, so it never loads by default. + if cfg.dictserv.is_some() { + services.push(Box::new(DictServ { + uid: format!("{}AAAAAQ", cfg.server.sid), + })); + } if enabled("gameserv") { services.push(Box::new(GameServ::new(format!("{}AAAAAP", cfg.server.sid)))); } @@ -199,7 +208,7 @@ async fn main() -> Result<()> { // Channel for services-initiated actions to reach the uplink (drained by the link loop). let (irc_tx, irc_rx) = tokio::sync::mpsc::unbounded_channel(); - engine.lock().await.set_irc_out(irc_tx); + engine.lock().await.set_irc_out(irc_tx.clone()); for (account, name) in cfg.oper_priv_warnings() { tracing::warn!(%account, privilege = %name, valid = %echo_api::Priv::valid_names(), "unknown privilege in [[oper]] — ignored (typo?)"); } @@ -288,7 +297,7 @@ async fn main() -> Result<()> { // just lets systemd stop us without waiting out the kill timeout. let shutdown_engine = engine.clone(); tokio::select! { - res = link::run(proto, engine, &addr, irc_rx, cfg.email.clone(), cfg.keycard.clone()) => res, + res = link::run(proto, engine, &addr, irc_rx, irc_tx, cfg.email.clone(), cfg.keycard.clone(), cfg.dictserv.as_ref().map(|d| d.server.clone())) => res, _ = shutdown_signal() => { // Flush stat counters so a clean stop/restart keeps StatServ history. shutdown_engine.lock().await.persist_stats();