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

@ -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<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
// 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::<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 };
// Rewrite `!cmd args…` into `CMD #channel args…` for ChanServ.
let mark = ctx.actions.len();

View file

@ -95,6 +95,7 @@ pub struct Engine {
sasl_source: HashMap<String, (Instant, String)>, // 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<String>, // uid to source channel modes from (ChanServ)
nick_service: Option<String>, // uid of the account service (NickServ), for its notices
irc_out: Option<mpsc::UnboundedSender<NetAction>>, // 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

View file

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