1use super::wire;
9use crate::llm::actions::{
10 protocol_trait::{ActionResult, Protocol, Server},
11 ActionDefinition, Parameter,
12};
13use crate::protocol::log_template::LogTemplate;
14use crate::protocol::EventType;
15use crate::state::app_state::AppState;
16use anyhow::{anyhow, Context, Result};
17use serde_json::{json, Value};
18use std::sync::LazyLock;
19
20pub struct DictProtocol;
21
22impl DictProtocol {
23 pub fn new() -> Self {
24 Self
25 }
26}
27
28impl Default for DictProtocol {
29 fn default() -> Self {
30 Self::new()
31 }
32}
33
34impl Protocol for DictProtocol {
35 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
36 vec![
37 crate::llm::actions::ParameterDefinition {
38 name: "first_byte_timeout_secs".to_string(),
39 type_hint: "number".to_string(),
40 description: "Seconds a connected peer may send no command after the 220 \
41 banner before the server closes it. Default 300, the window a \
42 `manual` rule gives a human to answer one event - the peer may be \
43 NetGet's own TCP client with the banner parked for its operator. \
44 Lower it for a listener exposed to strangers."
45 .to_string(),
46 required: false,
47 example: json!(300),
48 default: Some(serde_json::json!(super::FIRST_COMMAND_TIMEOUT.as_secs())),
49 },
50 crate::llm::actions::ParameterDefinition {
51 name: "idle_timeout_secs".to_string(),
52 type_hint: "number".to_string(),
53 description: "Seconds the server waits for the next command after answering \
54 one. Default 300: an interactive DICT session issues several \
55 lookups down one connection, and dict(1) itself sends QUIT as \
56 soon as it has its answer."
57 .to_string(),
58 required: false,
59 example: json!(300),
60 default: Some(serde_json::json!(super::IDLE_TIMEOUT.as_secs())),
61 },
62 ]
63 }
64 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
65 Vec::new()
66 }
67 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
68 vec![
69 send_dict_definitions_action(),
70 send_dict_matches_action(),
71 send_dict_databases_action(),
72 send_dict_strategies_action(),
73 send_dict_text_action(),
74 send_dict_error_action(),
75 close_connection_action(),
76 ]
77 }
78 fn protocol_name(&self) -> &'static str {
79 "DICT"
80 }
81 fn get_event_types(&self) -> Vec<EventType> {
82 vec![
83 DICT_DEFINE_EVENT.clone(),
84 DICT_MATCH_EVENT.clone(),
85 DICT_SHOW_EVENT.clone(),
86 ]
87 }
88 fn stack_name(&self) -> &'static str {
89 "ETH>IP>TCP>DICT"
90 }
91 fn keywords(&self) -> Vec<&'static str> {
92 vec!["dict", "dictionary", "rfc 2229", "dict.org"]
93 }
94 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
95 use crate::protocol::metadata::{
96 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
97 };
98
99 ProtocolMetadataV2::builder()
100 .state(DevelopmentState::Beta)
101 .privilege_requirement(PrivilegeRequirement::None)
103 .well_known_port(2628)
104 .implementation(
105 "Hand-written RFC 2229 line protocol over tokio TCP: 220 banner with msg-id, \
106 RFC 2229 parameter quoting, dot-stuffed text blocks and OPTION MIME rendered \
107 by NetGet",
108 )
109 .llm_control(
110 "The dictionary: definitions (DEFINE), matches (MATCH), the database and \
111 strategy lists, database and server information, and 5xx refusals",
112 )
113 .e2e_testing(
114 "tests/server/dict/real_client_test.rs drives the real dict(1) client \
115 (dict 1.13, Debian/Ubuntu package `dict`, `brew install dict`): a lookup, \
116 `-m -s prefix`, `-D`, `-S`, `-i`, `-I` and `-M`, asserting on what dict(1) \
117 parsed and printed. The test fails, never skips, when the binary is absent. \
118 tests/server/dict/e2e_test.rs covers the mocked-model path on a raw socket.",
119 )
120 .notes(
121 "Implements CLIENT, DEFINE, MATCH, SHOW DB/DATABASES, SHOW STRAT/STRATEGIES, \
122 SHOW INFO, SHOW SERVER, STATUS, HELP, OPTION MIME and QUIT. AUTH, SASLAUTH and \
123 SASLRESP answer 502; any other OPTION answers 503; an unknown command answers \
124 500 without consulting the model. CLIENT, STATUS, HELP, OPTION and QUIT are \
125 answered by NetGet itself. The model is the dictionary: there are no real \
126 databases, and `!`/`*` are passed to the model as given. Command lines are \
127 capped at the RFC's 1024 bytes including CRLF. On backend failure, a reply \
128 that does not fit the command, or no reply at all, the peer gets `420 Server \
129 temporarily unavailable` and the connection closes. No pcap oracle: this \
130 Wireshark build has no DICT dissector.",
131 )
132 .max_inbound_bytes(wire::MAX_LINE_BYTES)
133 .build()
134 }
135 fn description(&self) -> &'static str {
136 "DICT dictionary server (RFC 2229) - the model is the dictionary"
137 }
138 fn example_prompt(&self) -> &'static str {
139 "DICT server on port 2628 - a dictionary of invented words, with a 'fantasy' database"
140 }
141 fn group_name(&self) -> &'static str {
142 "Core"
143 }
144 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
145 use crate::llm::actions::StartupExamples;
146 StartupExamples::new(
147 json!({
148 "type": "open_server",
149 "port": 2628,
150 "base_stack": "dict",
151 "instruction": "DICT server with one database 'fantasy' (Fantasy Lexicon) that \
152 defines invented words in the style of an old encyclopedia"
153 }),
154 json!({
155 "type": "open_server",
156 "port": 2628,
157 "base_stack": "dict",
158 "event_handlers": [{
159 "event_pattern": "dict_define",
160 "handler": {
161 "type": "script",
162 "language": "python",
163 "code": "import json, sys\nword = json.load(sys.stdin)['event'].get('word', '')\nprint(json.dumps({'actions': [{'type': 'send_dict_definitions', 'word': word, 'definitions': [{'database': 'echo', 'database_description': 'Echo Dictionary', 'text': word + '\\n n. the word you asked about'}]}]}))"
164 }
165 }, {
166 "event_pattern": "dict_show",
167 "handler": {
168 "type": "static",
169 "actions": [{
170 "type": "send_dict_databases",
171 "databases": [{"name": "echo", "description": "Echo Dictionary"}]
172 }]
173 }
174 }]
175 }),
176 json!({
177 "type": "open_server",
178 "port": 2628,
179 "base_stack": "dict",
180 "event_handlers": [{
181 "event_pattern": "dict_define",
182 "handler": {
183 "type": "static",
184 "actions": [{
185 "type": "send_dict_definitions",
186 "word": "netget",
187 "definitions": [{
188 "database": "tech",
189 "database_description": "Technical Terms",
190 "text": "netget\n n. a server whose every reply is decided by a model"
191 }]
192 }]
193 }
194 }]
195 }),
196 )
197 }
198}
199
200impl Server for DictProtocol {
201 fn spawn(
202 &self,
203 ctx: crate::protocol::SpawnContext,
204 ) -> std::pin::Pin<
205 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
206 > {
207 Box::pin(async move {
208 let secs = |name: &str| -> anyhow::Result<Option<u64>> {
209 Ok(ctx
210 .startup_params
211 .as_ref()
212 .map(|p| p.get_optional_u64(name))
213 .transpose()?
214 .flatten())
215 };
216 let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
217 let idle_timeout_secs = secs("idle_timeout_secs")?;
218
219 crate::server::dict::DictServer::spawn_with_llm_actions(
220 ctx.legacy_listen_addr(),
221 ctx.llm_client,
222 ctx.state,
223 ctx.status_tx,
224 ctx.server_id,
225 first_byte_timeout_secs,
226 idle_timeout_secs,
227 )
228 .await
229 })
230 }
231
232 fn execute_action(&self, action: Value) -> Result<ActionResult> {
233 let action_type = action
234 .get("type")
235 .and_then(|v| v.as_str())
236 .context("Missing 'type' field in action")?;
237
238 let rendered = match action_type {
239 "send_dict_definitions" => render_definitions_action(&action)?,
240 "send_dict_matches" => render_matches_action(&action)?,
241 "send_dict_databases" => {
242 wire::render_databases(&pairs(&action, "databases", "name", "description")?)
243 }
244 "send_dict_strategies" => {
245 wire::render_strategies(&pairs(&action, "strategies", "name", "description")?)
246 }
247 "send_dict_text" => {
248 let code = code_of(&action)?;
249 let text = action.get("text").and_then(Value::as_str).unwrap_or("");
250 wire::render_text(code, text).ok_or_else(|| {
251 anyhow!(
252 "send_dict_text code must be 112 (SHOW INFO), 113 (HELP) or 114 \
253 (SHOW SERVER), got {code}"
254 )
255 })?
256 }
257 "send_dict_error" => {
258 let code = code_of(&action)?;
259 let message = action.get("message").and_then(Value::as_str).unwrap_or("");
260 wire::render_error(code, message).ok_or_else(|| {
261 let allowed: Vec<String> = wire::ERROR_CODES
262 .iter()
263 .map(|(c, _)| c.to_string())
264 .collect();
265 anyhow!(
266 "send_dict_error code must be one of {}, got {code}",
267 allowed.join(", ")
268 )
269 })?
270 }
271 "close_connection" => return Ok(ActionResult::CloseConnection),
272 _ => return Err(anyhow!("Unknown DICT action: {}", action_type)),
273 };
274 Ok(ActionResult::Output(rendered.into_bytes()))
275 }
276}
277
278fn code_of(action: &Value) -> Result<u16> {
279 let code = action
280 .get("code")
281 .and_then(|v| {
282 v.as_u64()
283 .or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
284 })
285 .context("Missing or non-numeric 'code' parameter")?;
286 u16::try_from(code).map_err(|_| anyhow!("code {code} is not a DICT status code"))
287}
288
289fn str_field<'a>(item: &'a Value, key: &str) -> &'a str {
290 item.get(key).and_then(Value::as_str).unwrap_or("")
291}
292
293fn pairs(
296 action: &Value,
297 list: &str,
298 name_key: &str,
299 desc_key: &str,
300) -> Result<Vec<(String, String)>> {
301 let items = match action.get(list) {
302 None | Some(Value::Null) => return Ok(Vec::new()),
303 Some(v) => v
304 .as_array()
305 .with_context(|| format!("'{list}' must be an array"))?,
306 };
307 items
308 .iter()
309 .map(|item| {
310 let name = str_field(item, name_key).trim();
311 if name.is_empty() {
312 return Err(anyhow!(
313 "every entry in '{list}' needs a non-empty '{name_key}'"
314 ));
315 }
316 Ok((name.to_string(), str_field(item, desc_key).to_string()))
317 })
318 .collect()
319}
320
321fn render_definitions_action(action: &Value) -> Result<String> {
322 let default_word = action.get("word").and_then(Value::as_str).unwrap_or("");
323 let items = match action.get("definitions") {
324 None | Some(Value::Null) => Vec::new(),
325 Some(v) => v
326 .as_array()
327 .context("'definitions' must be an array")?
328 .clone(),
329 };
330 let mut definitions = Vec::with_capacity(items.len());
331 for item in &items {
332 let word = item
333 .get("word")
334 .and_then(Value::as_str)
335 .unwrap_or(default_word);
336 if word.trim().is_empty() {
337 return Err(anyhow!(
338 "send_dict_definitions needs 'word' (the headword each 151 line names) - copy \
339 it from the dict_define event"
340 ));
341 }
342 let database = str_field(item, "database").trim();
343 if database.is_empty() {
344 return Err(anyhow!("every definition needs a non-empty 'database'"));
345 }
346 definitions.push(wire::Definition {
347 word: word.to_string(),
348 database: database.to_string(),
349 database_description: str_field(item, "database_description").to_string(),
350 text: str_field(item, "text").to_string(),
351 });
352 }
353 Ok(wire::render_definitions(&definitions))
354}
355
356fn render_matches_action(action: &Value) -> Result<String> {
357 let matches = pairs(action, "matches", "database", "word")?;
358 for (_, word) in &matches {
359 if word.trim().is_empty() {
360 return Err(anyhow!("every match needs a non-empty 'word'"));
361 }
362 }
363 Ok(wire::render_matches(&matches))
364}
365
366fn send_dict_definitions_action() -> ActionDefinition {
367 ActionDefinition {
368 name: "send_dict_definitions".to_string(),
369 description: "Answer DEFINE with one or more definitions. NetGet writes the 150/151/250 \
370 status lines and the dot-terminated text blocks; give plain text. An \
371 empty 'definitions' list answers 552 (no match)."
372 .to_string(),
373 parameters: vec![
374 Parameter {
375 name: "word".to_string(),
376 type_hint: "string".to_string(),
377 description: "The headword the definitions are for - normally the event's \
378 'word'. A definition may override it with its own 'word'."
379 .to_string(),
380 required: true,
381 },
382 Parameter {
383 name: "definitions".to_string(),
384 type_hint: "array".to_string(),
385 description: "Array of {database, database_description, text}: the database \
386 name (a short atom such as 'wn'), its human description, and the \
387 definition text (may span several lines)."
388 .to_string(),
389 required: true,
390 },
391 ],
392 example: json!({
393 "type": "send_dict_definitions",
394 "word": "serendipity",
395 "definitions": [{
396 "database": "wn",
397 "database_description": "WordNet (r) 3.0",
398 "text": "serendipity\n n 1: good luck in making unexpected discoveries"
399 }]
400 }),
401 log_template: Some(
402 LogTemplate::new()
403 .with_info("-> DICT definitions of {word}")
404 .with_debug("DICT send_dict_definitions: word={word}"),
405 ),
406 }
407}
408
409fn send_dict_matches_action() -> ActionDefinition {
410 ActionDefinition {
411 name: "send_dict_matches".to_string(),
412 description: "Answer MATCH with the words that match. NetGet writes the 152 block and \
413 the 250. An empty 'matches' list answers 552 (no match)."
414 .to_string(),
415 parameters: vec![Parameter {
416 name: "matches".to_string(),
417 type_hint: "array".to_string(),
418 description: "Array of {database, word}".to_string(),
419 required: true,
420 }],
421 example: json!({
422 "type": "send_dict_matches",
423 "matches": [
424 {"database": "wn", "word": "serene"},
425 {"database": "wn", "word": "serendipity"}
426 ]
427 }),
428 log_template: Some(
429 LogTemplate::new()
430 .with_info("-> DICT matches")
431 .with_debug("DICT send_dict_matches"),
432 ),
433 }
434}
435
436fn send_dict_databases_action() -> ActionDefinition {
437 ActionDefinition {
438 name: "send_dict_databases".to_string(),
439 description: "Answer SHOW DB with the databases this dictionary offers (110 block). An \
440 empty list answers 554 (no databases present)."
441 .to_string(),
442 parameters: vec![Parameter {
443 name: "databases".to_string(),
444 type_hint: "array".to_string(),
445 description: "Array of {name, description}".to_string(),
446 required: true,
447 }],
448 example: json!({
449 "type": "send_dict_databases",
450 "databases": [
451 {"name": "wn", "description": "WordNet (r) 3.0"},
452 {"name": "jargon", "description": "The Jargon File"}
453 ]
454 }),
455 log_template: Some(
456 LogTemplate::new()
457 .with_info("-> DICT database list")
458 .with_debug("DICT send_dict_databases"),
459 ),
460 }
461}
462
463fn send_dict_strategies_action() -> ActionDefinition {
464 ActionDefinition {
465 name: "send_dict_strategies".to_string(),
466 description: "Answer SHOW STRAT with the match strategies this dictionary supports \
467 (111 block). An empty list answers 555 (no strategies available)."
468 .to_string(),
469 parameters: vec![Parameter {
470 name: "strategies".to_string(),
471 type_hint: "array".to_string(),
472 description: "Array of {name, description}".to_string(),
473 required: true,
474 }],
475 example: json!({
476 "type": "send_dict_strategies",
477 "strategies": [
478 {"name": "exact", "description": "Match headwords exactly"},
479 {"name": "prefix", "description": "Match prefixes"}
480 ]
481 }),
482 log_template: Some(
483 LogTemplate::new()
484 .with_info("-> DICT strategy list")
485 .with_debug("DICT send_dict_strategies"),
486 ),
487 }
488}
489
490fn send_dict_text_action() -> ActionDefinition {
491 ActionDefinition {
492 name: "send_dict_text".to_string(),
493 description: "Answer SHOW INFO <db> (code 112) or SHOW SERVER (code 114) with free \
494 text. NetGet writes the status line, the dot-terminated block and the 250."
495 .to_string(),
496 parameters: vec![
497 Parameter {
498 name: "code".to_string(),
499 type_hint: "number".to_string(),
500 description: "112 for SHOW INFO, 114 for SHOW SERVER (113 is HELP)".to_string(),
501 required: true,
502 },
503 Parameter {
504 name: "text".to_string(),
505 type_hint: "string".to_string(),
506 description: "The information text; may span several lines".to_string(),
507 required: true,
508 },
509 ],
510 example: json!({
511 "type": "send_dict_text",
512 "code": 112,
513 "text": "WordNet 3.0\nA lexical database of English."
514 }),
515 log_template: Some(
516 LogTemplate::new()
517 .with_info("-> DICT {code} text")
518 .with_debug("DICT send_dict_text: code={code}"),
519 ),
520 }
521}
522
523fn send_dict_error_action() -> ActionDefinition {
524 ActionDefinition {
525 name: "send_dict_error".to_string(),
526 description: "Refuse the command with a DICT 5xx status: 550 unknown database, 551 \
527 unknown strategy, 552 no match, 554 no databases, 555 no strategies, \
528 530 access denied, 501 bad parameters, 502 not implemented. Any other \
529 code is rejected."
530 .to_string(),
531 parameters: vec![
532 Parameter {
533 name: "code".to_string(),
534 type_hint: "number".to_string(),
535 description: "A 5xx DICT status code".to_string(),
536 required: true,
537 },
538 Parameter {
539 name: "message".to_string(),
540 type_hint: "string".to_string(),
541 description: "One line of text; omit to use the RFC's own wording".to_string(),
542 required: false,
543 },
544 ],
545 example: json!({
546 "type": "send_dict_error",
547 "code": 550,
548 "message": "Invalid database, use \"SHOW DB\" for list of databases"
549 }),
550 log_template: Some(
551 LogTemplate::new()
552 .with_info("-> DICT {code} {message}")
553 .with_debug("DICT send_dict_error: code={code}"),
554 ),
555 }
556}
557
558fn close_connection_action() -> ActionDefinition {
559 ActionDefinition {
560 name: "close_connection".to_string(),
561 description: "Close the DICT connection after any reply".to_string(),
562 parameters: vec![],
563 example: json!({"type": "close_connection"}),
564 log_template: Some(
565 LogTemplate::new()
566 .with_info("DICT connection closed")
567 .with_debug("DICT close_connection"),
568 ),
569 }
570}
571
572pub static DICT_DEFINE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
574 EventType::new(
575 "dict_define",
576 "Client sent DEFINE: look up a word. database is a name from your database list, '*' \
577 (all databases) or '!' (stop at the first database that has it).",
578 json!({
579 "type": "send_dict_definitions",
580 "word": "serendipity",
581 "definitions": [{
582 "database": "wn",
583 "database_description": "WordNet (r) 3.0",
584 "text": "serendipity\n n 1: good luck in making unexpected discoveries"
585 }]
586 }),
587 )
588 .with_parameters(vec![
589 Parameter {
590 name: "database".to_string(),
591 type_hint: "string".to_string(),
592 description: "Database name, '*' or '!'".to_string(),
593 required: true,
594 },
595 Parameter {
596 name: "word".to_string(),
597 type_hint: "string".to_string(),
598 description: "The word to define".to_string(),
599 required: true,
600 },
601 ])
602 .with_log_template(
603 LogTemplate::new()
604 .with_info("DICT DEFINE {database} {word}")
605 .with_debug("DICT dict_define: database={database} word={word}"),
606 )
607 .with_actions(vec![
608 send_dict_definitions_action(),
609 send_dict_error_action(),
610 close_connection_action(),
611 ])
612 .with_alternative_example(json!({"type": "send_dict_error", "code": 552}))
613});
614
615pub static DICT_MATCH_EVENT: LazyLock<EventType> = LazyLock::new(|| {
617 EventType::new(
618 "dict_match",
619 "Client sent MATCH: list headwords matching word under strategy ('.' means your \
620 default strategy). database is a name, '*' or '!'.",
621 json!({
622 "type": "send_dict_matches",
623 "matches": [{"database": "wn", "word": "serendipity"}]
624 }),
625 )
626 .with_parameters(vec![
627 Parameter {
628 name: "database".to_string(),
629 type_hint: "string".to_string(),
630 description: "Database name, '*' or '!'".to_string(),
631 required: true,
632 },
633 Parameter {
634 name: "strategy".to_string(),
635 type_hint: "string".to_string(),
636 description: "Strategy name, or '.' for the server default".to_string(),
637 required: true,
638 },
639 Parameter {
640 name: "word".to_string(),
641 type_hint: "string".to_string(),
642 description: "The word to match against".to_string(),
643 required: true,
644 },
645 ])
646 .with_log_template(
647 LogTemplate::new()
648 .with_info("DICT MATCH {database} {strategy} {word}")
649 .with_debug("DICT dict_match: database={database} strategy={strategy} word={word}"),
650 )
651 .with_actions(vec![
652 send_dict_matches_action(),
653 send_dict_error_action(),
654 close_connection_action(),
655 ])
656 .with_alternative_example(json!({"type": "send_dict_error", "code": 551}))
657});
658
659pub static DICT_SHOW_EVENT: LazyLock<EventType> = LazyLock::new(|| {
661 EventType::new(
662 "dict_show",
663 "Client sent SHOW. what = 'databases' (answer send_dict_databases), 'strategies' \
664 (send_dict_strategies), 'info' (send_dict_text code 112 about 'database') or 'server' \
665 (send_dict_text code 114).",
666 json!({
667 "type": "send_dict_databases",
668 "databases": [{"name": "wn", "description": "WordNet (r) 3.0"}]
669 }),
670 )
671 .with_parameters(vec![
672 Parameter {
673 name: "what".to_string(),
674 type_hint: "string".to_string(),
675 description: "What is being shown".to_string(),
676 required: true,
677 }
678 .with_choices(["databases", "strategies", "info", "server"]),
679 Parameter {
680 name: "database".to_string(),
681 type_hint: "string".to_string(),
682 description: "The database SHOW INFO asks about (only when what is 'info')".to_string(),
683 required: false,
684 },
685 ])
686 .with_log_template(
687 LogTemplate::new()
688 .with_info("DICT SHOW {what}")
689 .with_debug("DICT dict_show: what={what}"),
690 )
691 .with_actions(vec![
692 send_dict_databases_action(),
693 send_dict_strategies_action(),
694 send_dict_text_action(),
695 send_dict_error_action(),
696 close_connection_action(),
697 ])
698 .with_alternative_example(json!({
699 "type": "send_dict_text",
700 "code": 114,
701 "text": "NetGet DICT server\nThe model is the dictionary."
702 }))
703});