dictserv: opt-in DICT (RFC 2229) lookup service — bots and /msg answer dict/define/thes/acronym/law/etc via dict.org off-reactor
All checks were successful
CI / check (push) Successful in 4m4s

This commit is contained in:
Jean Chevronnet 2026-07-18 20:21:30 +00:00
parent 76be932131
commit 623750e638
No known key found for this signature in database
15 changed files with 444 additions and 4 deletions

View file

@ -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" }

110
modules/dictserv/src/lib.rs Normal file
View file

@ -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 <word> (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 <word>\x02\nLooks a word up in WordNet. In a channel with a bot, use \x02!dict <word>\x02." },
HelpEntry { cmd: "DEFINE", summary: "define a word (GCIDE)", detail: "Syntax: \x02DEFINE <word>\x02\nLooks a word up in the GNU Collaborative International Dictionary of English." },
HelpEntry { cmd: "THES", summary: "thesaurus lookup", detail: "Syntax: \x02THES <word>\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 <term>\x02, or here as \x02/msg DictServ cmd <term>\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
}
}

View file

@ -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