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
All checks were successful
CI / check (push) Successful in 4m4s
This commit is contained in:
parent
76be932131
commit
623750e638
15 changed files with 444 additions and 4 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -286,6 +286,7 @@ dependencies = [
|
||||||
"echo-chanserv",
|
"echo-chanserv",
|
||||||
"echo-debugserv",
|
"echo-debugserv",
|
||||||
"echo-diceserv",
|
"echo-diceserv",
|
||||||
|
"echo-dictserv",
|
||||||
"echo-example",
|
"echo-example",
|
||||||
"echo-gameserv",
|
"echo-gameserv",
|
||||||
"echo-groupserv",
|
"echo-groupserv",
|
||||||
|
|
@ -360,6 +361,13 @@ dependencies = [
|
||||||
"rand",
|
"rand",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "echo-dictserv"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"echo-api",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "echo-example"
|
name = "echo-example"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ members = [
|
||||||
"modules/hostserv",
|
"modules/hostserv",
|
||||||
"modules/operserv",
|
"modules/operserv",
|
||||||
"modules/diceserv",
|
"modules/diceserv",
|
||||||
|
"modules/dictserv",
|
||||||
"modules/gameserv",
|
"modules/gameserv",
|
||||||
"modules/infoserv",
|
"modules/infoserv",
|
||||||
"modules/reportserv",
|
"modules/reportserv",
|
||||||
|
|
@ -41,6 +42,7 @@ echo-statserv = { path = "modules/statserv" }
|
||||||
echo-hostserv = { path = "modules/hostserv" }
|
echo-hostserv = { path = "modules/hostserv" }
|
||||||
echo-operserv = { path = "modules/operserv" }
|
echo-operserv = { path = "modules/operserv" }
|
||||||
echo-diceserv = { path = "modules/diceserv" }
|
echo-diceserv = { path = "modules/diceserv" }
|
||||||
|
echo-dictserv = { path = "modules/dictserv" }
|
||||||
echo-gameserv = { path = "modules/gameserv" }
|
echo-gameserv = { path = "modules/gameserv" }
|
||||||
echo-infoserv = { path = "modules/infoserv" }
|
echo-infoserv = { path = "modules/infoserv" }
|
||||||
echo-reportserv = { path = "modules/reportserv" }
|
echo-reportserv = { path = "modules/reportserv" }
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ Every service is a first-class pseudo-client, all started by default.
|
||||||
| InfoServ | network bulletins shown on connect and login |
|
| InfoServ | network bulletins shown on connect and login |
|
||||||
| ReportServ | user abuse reports feeding the operator audit trail |
|
| ReportServ | user abuse reports feeding the operator audit trail |
|
||||||
| DiceServ | dice and math for tabletop games |
|
| DiceServ | dice and math for tabletop games |
|
||||||
|
| DictServ | dictionary, thesaurus and reference lookups (dict.org), opt-in |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,11 @@ pub enum NetAction {
|
||||||
// Internal only: send an email (plaintext + optional HTML). The link layer
|
// Internal only: send an email (plaintext + optional HTML). The link layer
|
||||||
// pipes it to the configured mail command off-thread; never serialized.
|
// pipes it to the configured mail command off-thread; never serialized.
|
||||||
SendEmail { to: String, subject: String, text: String, html: Option<String> },
|
SendEmail { to: String, subject: String, text: String, html: Option<String> },
|
||||||
|
// 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
|
// Internal only: stop the services process (OperServ SHUTDOWN/RESTART). The
|
||||||
// link layer exits cleanly; `restart` exits non-zero so a supervisor (systemd
|
// link layer exits cleanly; `restart` exits non-zero so a supervisor (systemd
|
||||||
// Restart=) brings it back. Never serialized.
|
// 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 });
|
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<String>, target: impl Into<String>, database: impl Into<String>, label: impl Into<String>, query: impl Into<String>) {
|
||||||
|
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
|
// Stop the services process (OperServ SHUTDOWN/RESTART). The link layer exits
|
||||||
// once this action is reached; `restart` exits non-zero so a supervisor
|
// once this action is reached; `restart` exits non-zero so a supervisor
|
||||||
// respawns us.
|
// respawns us.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
# still owns all channel/vhost/ban data, keyed by the account name.
|
||||||
# [auth]
|
# [auth]
|
||||||
# external = true
|
# 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 <cmd> <term>. 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"
|
||||||
|
|
|
||||||
8
modules/dictserv/Cargo.toml
Normal file
8
modules/dictserv/Cargo.toml
Normal 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
110
modules/dictserv/src/lib.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -540,7 +540,7 @@ impl Protocol for InspIrcd {
|
||||||
NetAction::Squit { target, reason } => vec![self.sourced(format!("SQUIT {} :{}", target, reason))],
|
NetAction::Squit { target, reason } => vec![self.sourced(format!("SQUIT {} :{}", target, reason))],
|
||||||
NetAction::Raw(s) => vec![s.clone()],
|
NetAction::Raw(s) => vec![s.clone()],
|
||||||
// Internal: the link layer handles these before serialization.
|
// 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
|
// A trailing parameter can carry free-form text (message bodies, kick
|
||||||
// reasons, topics, metadata). Strip CR/LF/NUL at this single choke-point
|
// reasons, topics, metadata). Strip CR/LF/NUL at this single choke-point
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,10 @@ pub struct Config {
|
||||||
// credentials (`kc_…` over SASL) are not honoured.
|
// credentials (`kc_…` over SASL) are not honoured.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub keycard: Option<Keycard>,
|
pub keycard: Option<Keycard>,
|
||||||
|
// 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<Dict>,
|
||||||
// Which InspIRCd matching-extbans AKICK may use. Absent = every extban echo
|
// Which InspIRCd matching-extbans AKICK may use. Absent = every extban echo
|
||||||
// knows (full compatibility). List `enabled` to restrict it — e.g. drop the
|
// knows (full compatibility). List `enabled` to restrict it — e.g. drop the
|
||||||
// ones your ircd doesn't provide.
|
// ones your ircd doesn't provide.
|
||||||
|
|
@ -69,6 +73,18 @@ pub struct Keycard {
|
||||||
pub api_key: String,
|
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.
|
// Account-authority configuration.
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct Auth {
|
pub struct Auth {
|
||||||
|
|
|
||||||
143
src/dict.rs
Normal file
143
src/dict.rs
Normal file
|
|
@ -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<Option<String>> {
|
||||||
|
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<String> {
|
||||||
|
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::<Vec<_>>().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::<String>().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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -101,10 +101,20 @@ impl Engine {
|
||||||
out.extend(self.reconcile_bots());
|
out.extend(self.reconcile_bots());
|
||||||
// Announce whatever this command changed to the staff audit channel.
|
// Announce whatever this command changed to the staff audit channel.
|
||||||
out.extend(self.audit_feed(audit_mark, &nick, account.as_deref()));
|
out.extend(self.audit_feed(audit_mark, &nick, account.as_deref()));
|
||||||
|
self.apply_dict_limit(&mut out);
|
||||||
self.apply_msg_style(&mut out);
|
self.apply_msg_style(&mut out);
|
||||||
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
|
// 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
|
// 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
|
// notice from the pseudoclient. A multi-line reply names the service once, on
|
||||||
|
|
@ -157,6 +167,19 @@ impl Engine {
|
||||||
return;
|
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 };
|
let Some(csuid) = self.service_uid("ChanServ") else { return };
|
||||||
// Rewrite `!cmd args…` into `CMD #channel args…` for ChanServ.
|
// Rewrite `!cmd args…` into `CMD #channel args…` for ChanServ.
|
||||||
let mark = ctx.actions.len();
|
let mark = ctx.actions.len();
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ pub struct Engine {
|
||||||
sasl_source: HashMap<String, (Instant, String)>, // client uid -> real host/IP from the SASL H message
|
sasl_source: HashMap<String, (Instant, String)>, // client uid -> real host/IP from the SASL H message
|
||||||
reg_limiter: RegLimiter,
|
reg_limiter: RegLimiter,
|
||||||
cmd_limiter: CmdLimiter, // per-host flood control for service commands
|
cmd_limiter: CmdLimiter, // per-host flood control for service commands
|
||||||
|
dict_limiter: DictLimiter, // global rate cap on outbound DICT lookups
|
||||||
chan_service: Option<String>, // uid to source channel modes from (ChanServ)
|
chan_service: Option<String>, // uid to source channel modes from (ChanServ)
|
||||||
nick_service: Option<String>, // uid of the account service (NickServ), for its notices
|
nick_service: Option<String>, // uid of the account service (NickServ), for its notices
|
||||||
irc_out: Option<mpsc::UnboundedSender<NetAction>>, // services-initiated actions -> the uplink
|
irc_out: Option<mpsc::UnboundedSender<NetAction>>, // services-initiated actions -> the uplink
|
||||||
|
|
@ -236,6 +237,7 @@ impl Engine {
|
||||||
sasl_source: HashMap::new(),
|
sasl_source: HashMap::new(),
|
||||||
reg_limiter: RegLimiter::new(),
|
reg_limiter: RegLimiter::new(),
|
||||||
cmd_limiter: CmdLimiter::default(),
|
cmd_limiter: CmdLimiter::default(),
|
||||||
|
dict_limiter: DictLimiter::new(),
|
||||||
chan_service,
|
chan_service,
|
||||||
nick_service,
|
nick_service,
|
||||||
irc_out: None,
|
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
|
// 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
|
// 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
|
// slows down — never answered per line, which would just double the flood on
|
||||||
|
|
|
||||||
|
|
@ -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");
|
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:
|
// Perf harness — not part of the normal suite. Run:
|
||||||
// cargo test --release -p echo -- --ignored --nocapture bench_engine
|
// cargo test --release -p echo -- --ignored --nocapture bench_engine
|
||||||
// Measures the two costs that actually bound the single-threaded engine: the
|
// Measures the two costs that actually bound the single-threaded engine: the
|
||||||
|
|
|
||||||
28
src/link.rs
28
src/link.rs
|
|
@ -71,7 +71,8 @@ fn redact(line: &str) -> Cow<'_, str> {
|
||||||
// One uplink session: connect, handshake + burst, then translate lines forever.
|
// 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
|
// The engine is shared with the gossip layer, so it is locked per operation and
|
||||||
// never held across the registration key-stretching await.
|
// never held across the registration key-stretching await.
|
||||||
pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver<NetAction>, email: Option<crate::config::Email>, keycard: Option<crate::config::Keycard>) -> Result<()> {
|
#[allow(clippy::too_many_arguments)] // the link driver legitimately wires up many collaborators
|
||||||
|
pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver<NetAction>, irc_tx: mpsc::UnboundedSender<NetAction>, email: Option<crate::config::Email>, keycard: Option<crate::config::Keycard>, dict_server: Option<String>) -> Result<()> {
|
||||||
let stream = TcpStream::connect(addr).await?;
|
let stream = TcpStream::connect(addr).await?;
|
||||||
// Disable Nagle: service replies are small multi-line bursts, and without this
|
// 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
|
// 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<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr:
|
||||||
dispatch_email(&email, to, subject, text, html);
|
dispatch_email(&email, to, subject, text, html);
|
||||||
continue;
|
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 {
|
if let NetAction::Shutdown { restart, reason } = act {
|
||||||
engine.lock().await.persist_stats();
|
engine.lock().await.persist_stats();
|
||||||
let _ = write.flush().await;
|
let _ = write.flush().await;
|
||||||
|
|
@ -193,6 +198,27 @@ pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr:
|
||||||
Ok(())
|
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<String>, irc_tx: &mpsc::UnboundedSender<NetAction>, 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
|
// 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
|
// 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).
|
// body it's sent as multipart/alternative (HTML + plaintext fallback).
|
||||||
|
|
|
||||||
13
src/main.rs
13
src/main.rs
|
|
@ -2,6 +2,7 @@
|
||||||
// crates (echo-nickserv, echo-chanserv, echo-inspircd), each depending
|
// crates (echo-nickserv, echo-chanserv, echo-inspircd), each depending
|
||||||
// only on the echo-api SDK.
|
// only on the echo-api SDK.
|
||||||
mod config;
|
mod config;
|
||||||
|
mod dict;
|
||||||
mod engine;
|
mod engine;
|
||||||
mod gossip;
|
mod gossip;
|
||||||
mod grpc;
|
mod grpc;
|
||||||
|
|
@ -24,6 +25,7 @@ use echo_statserv::StatServ;
|
||||||
use echo_hostserv::HostServ;
|
use echo_hostserv::HostServ;
|
||||||
use echo_operserv::OperServ;
|
use echo_operserv::OperServ;
|
||||||
use echo_diceserv::DiceServ;
|
use echo_diceserv::DiceServ;
|
||||||
|
use echo_dictserv::DictServ;
|
||||||
use echo_gameserv::GameServ;
|
use echo_gameserv::GameServ;
|
||||||
use echo_infoserv::InfoServ;
|
use echo_infoserv::InfoServ;
|
||||||
use echo_reportserv::ReportServ;
|
use echo_reportserv::ReportServ;
|
||||||
|
|
@ -133,6 +135,13 @@ async fn main() -> Result<()> {
|
||||||
uid: format!("{}AAAAAI", cfg.server.sid),
|
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") {
|
if enabled("gameserv") {
|
||||||
services.push(Box::new(GameServ::new(format!("{}AAAAAP", cfg.server.sid))));
|
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).
|
// Channel for services-initiated actions to reach the uplink (drained by the link loop).
|
||||||
let (irc_tx, irc_rx) = tokio::sync::mpsc::unbounded_channel();
|
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() {
|
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?)");
|
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.
|
// just lets systemd stop us without waiting out the kill timeout.
|
||||||
let shutdown_engine = engine.clone();
|
let shutdown_engine = engine.clone();
|
||||||
tokio::select! {
|
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() => {
|
_ = shutdown_signal() => {
|
||||||
// Flush stat counters so a clean stop/restart keeps StatServ history.
|
// Flush stat counters so a clean stop/restart keeps StatServ history.
|
||||||
shutdown_engine.lock().await.persist_stats();
|
shutdown_engine.lock().await.persist_stats();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue