161 lines
6.4 KiB
Rust
161 lines
6.4 KiB
Rust
//! 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 ATTEMPT_TIMEOUT: Duration = Duration::from_secs(6);
|
|
const ATTEMPTS: usize = 2; // one retry — a database that's briefly slow under load often answers next try
|
|
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. A transient failure (timeout or
|
|
// connection error) is retried once, since dict.org databases can be momentarily
|
|
// slow and then recover; a clean "no match" is definitive and returns at once.
|
|
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();
|
|
}
|
|
for _ in 0..ATTEMPTS {
|
|
match tokio::time::timeout(ATTEMPT_TIMEOUT, exchange(server, database, &word)).await {
|
|
Ok(Ok(Some(def))) => return def,
|
|
Ok(Ok(None)) => return format!("no definition found for \x02{word}\x02."),
|
|
Ok(Err(_)) | Err(_) => continue, // transient — try again, then give up
|
|
}
|
|
}
|
|
"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();
|
|
let mut in_def = false;
|
|
while let Some(line) = lines.next_line().await? {
|
|
// Stop as soon as we have an answer, rather than blocking for a 250 that a
|
|
// no-match never sends: the first definition's terminating ".", or — before
|
|
// any definition — a terminal status ("552 no match", "550" bad db, a bare
|
|
// 250, or a 4xx/5xx error). "150 n" is just the count, so it's not terminal.
|
|
let stop = if in_def {
|
|
line == "."
|
|
} else if line.starts_with("151") {
|
|
in_def = true;
|
|
false
|
|
} else {
|
|
!line.starts_with("150") && line.chars().next().is_some_and(|c| matches!(c, '2' | '4' | '5'))
|
|
};
|
|
raw.push_str(&line);
|
|
raw.push('\n');
|
|
if stop || raw.len() > MAX_LEN * 8 {
|
|
break;
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
// Drop control characters an untrusted/MITM'd DICT server could embed (CTCP
|
|
// \x01, color/format \x02\x03, NUL truncation) before this is spoken into a
|
|
// channel. split_whitespace already removed CR/LF/TAB.
|
|
let body: String = body.split_whitespace().collect::<Vec<_>>().join(" ").chars().filter(|c| !c.is_control()).collect();
|
|
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");
|
|
}
|
|
}
|