1use crate::llm::actions::{
17 client_trait::{Client, ClientActionResult},
18 protocol_trait::Protocol,
19 ActionDefinition, Parameter,
20};
21use crate::protocol::log_template::LogTemplate;
22use crate::protocol::EventType;
23use crate::state::app_state::AppState;
24use anyhow::{bail, Context, Result};
25use serde_json::json;
26use std::sync::LazyLock;
27
28pub const GOPHER_FETCH_RESULT: &str = "gopher_fetch";
33
34pub const DEFAULT_ITEM_TYPE: char = '1';
39
40pub static GOPHER_MENU_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
42 EventType::new(
43 "gopher_menu_received",
44 "A Gopher menu was fetched and parsed into items",
45 json!({
46 "type": "send_gopher_request",
47 "selector": "/about.txt",
48 "item_type": "0",
49 "host": "127.0.0.1",
50 "port": 70
51 }),
52 )
53 .with_parameters(vec![
54 Parameter {
55 name: "selector".to_string(),
56 type_hint: "string".to_string(),
57 description: "The selector that was asked for (empty means the root menu)".to_string(),
58 required: true,
59 },
60 Parameter {
61 name: "host".to_string(),
62 type_hint: "string".to_string(),
63 description: "Host the menu came from".to_string(),
64 required: true,
65 },
66 Parameter {
67 name: "port".to_string(),
68 type_hint: "number".to_string(),
69 description: "Port the menu came from".to_string(),
70 required: true,
71 },
72 Parameter {
73 name: "item_count".to_string(),
74 type_hint: "number".to_string(),
75 description: "How many items the menu contains".to_string(),
76 required: true,
77 },
78 Parameter {
79 name: "items".to_string(),
80 type_hint: "array".to_string(),
81 description:
82 "The menu items in order. Each is an object with 'item_type' (one character), \
83 'item_type_name' (what that character means), 'display' (the text a user \
84 sees), 'selector', 'host' and 'port'. To follow an item, echo its selector, \
85 host, port and item_type back in a send_gopher_request. Items whose \
86 'item_type' is 'i' are informational text, not links - there is nothing to \
87 follow."
88 .to_string(),
89 required: true,
90 },
91 Parameter {
92 name: "malformed_lines".to_string(),
93 type_hint: "array".to_string(),
94 description:
95 "Lines the server sent that are not well-formed menu lines, kept verbatim so \
96 nothing is silently dropped. Absent when there were none."
97 .to_string(),
98 required: false,
99 },
100 Parameter {
101 name: "truncated".to_string(),
102 type_hint: "boolean".to_string(),
103 description: "True when the reply hit the client's size cap and was cut short"
104 .to_string(),
105 required: false,
106 },
107 ])
108 .with_log_template(
109 LogTemplate::new()
110 .with_info("<- Gopher menu {selector} ({item_count} items)")
111 .with_debug("Gopher menu from {host}:{port} selector={selector} items={item_count}")
112 .with_trace("Gopher menu items: {json_pretty(items)}"),
113 )
114});
115
116pub static GOPHER_DOCUMENT_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
118 EventType::new(
119 "gopher_document_received",
120 "A Gopher text document was fetched",
121 json!({
122 "type": "send_gopher_request",
123 "selector": "",
124 "item_type": "1"
125 }),
126 )
127 .with_parameters(vec![
128 Parameter {
129 name: "selector".to_string(),
130 type_hint: "string".to_string(),
131 description: "The selector that was asked for".to_string(),
132 required: true,
133 },
134 Parameter {
135 name: "host".to_string(),
136 type_hint: "string".to_string(),
137 description: "Host the document came from".to_string(),
138 required: true,
139 },
140 Parameter {
141 name: "port".to_string(),
142 type_hint: "number".to_string(),
143 description: "Port the document came from".to_string(),
144 required: true,
145 },
146 Parameter {
147 name: "requested_item_type".to_string(),
148 type_hint: "string".to_string(),
149 description: "The item type this request asked for. If it is '1' or '7' the reply was \
150 expected to be a menu and no line of it parsed as one, so it is being \
151 reported as a document instead."
152 .to_string(),
153 required: true,
154 },
155 Parameter {
156 name: "text".to_string(),
157 type_hint: "string".to_string(),
158 description:
159 "The document body as plain text: CRLF turned into newlines, the terminating \
160 '.' line removed, and any doubled leading period undone"
161 .to_string(),
162 required: true,
163 },
164 Parameter {
165 name: "line_count".to_string(),
166 type_hint: "number".to_string(),
167 description: "How many lines the document has".to_string(),
168 required: true,
169 },
170 Parameter {
171 name: "truncated".to_string(),
172 type_hint: "boolean".to_string(),
173 description: "True when the reply hit the client's size cap and was cut short"
174 .to_string(),
175 required: false,
176 },
177 ])
178 .with_log_template(
179 LogTemplate::new()
180 .with_info("<- Gopher document {selector} ({line_count} lines)")
181 .with_debug("Gopher document from {host}:{port} selector={selector}")
182 .with_trace("Gopher document: {preview(text, 400)}"),
183 )
184});
185
186pub static GOPHER_ERROR_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
188 EventType::new(
189 "gopher_error_received",
190 "The Gopher server answered with a type-3 error item",
191 json!({
192 "type": "send_gopher_request",
193 "selector": "",
194 "item_type": "1"
195 }),
196 )
197 .with_parameters(vec![
198 Parameter {
199 name: "selector".to_string(),
200 type_hint: "string".to_string(),
201 description: "The selector that was asked for".to_string(),
202 required: true,
203 },
204 Parameter {
205 name: "host".to_string(),
206 type_hint: "string".to_string(),
207 description: "Host that reported the error".to_string(),
208 required: true,
209 },
210 Parameter {
211 name: "port".to_string(),
212 type_hint: "number".to_string(),
213 description: "Port that reported the error".to_string(),
214 required: true,
215 },
216 Parameter {
217 name: "message".to_string(),
218 type_hint: "string".to_string(),
219 description: "The display text of the type-3 item".to_string(),
220 required: true,
221 },
222 ])
223 .with_log_template(
224 LogTemplate::new()
225 .with_info("<- Gopher error for {selector}: {message}")
226 .with_debug("Gopher error from {host}:{port} selector={selector}: {message}"),
227 )
228});
229
230pub fn get_gopher_client_event_types() -> Vec<EventType> {
231 vec![
232 GOPHER_MENU_RECEIVED_EVENT.clone(),
233 GOPHER_DOCUMENT_RECEIVED_EVENT.clone(),
234 GOPHER_ERROR_RECEIVED_EVENT.clone(),
235 ]
236}
237
238pub struct GopherClientProtocol;
240
241impl GopherClientProtocol {
242 pub fn new() -> Self {
243 Self
244 }
245}
246
247impl Default for GopherClientProtocol {
248 fn default() -> Self {
249 Self::new()
250 }
251}
252
253fn reject_wire_breaks(field: &str, value: &str, tab_allowed: bool) -> Result<()> {
262 if value.contains('\r') || value.contains('\n') {
263 bail!("'{field}' must not contain a carriage return or a line feed");
264 }
265 if !tab_allowed && value.contains('\t') {
266 bail!("'{field}' must not contain a tab; a tab is what turns a fetch into a search");
267 }
268 Ok(())
269}
270
271fn fetch_target(action: &serde_json::Value) -> Result<(Option<String>, Option<u16>)> {
277 let host = match action.get("host").and_then(|v| v.as_str()) {
278 Some(h) if !h.is_empty() => {
279 reject_wire_breaks("host", h, false)?;
280 Some(h.to_string())
281 }
282 _ => None,
283 };
284 let port = match action.get("port") {
285 Some(serde_json::Value::Null) | None => None,
286 Some(v) => {
287 let n = v
288 .as_u64()
289 .or_else(|| v.as_str().and_then(|s| s.parse::<u64>().ok()))
290 .context("'port' must be a number between 1 and 65535")?;
291 if n == 0 || n > u16::MAX as u64 {
292 bail!("'port' must be a number between 1 and 65535");
293 }
294 Some(n as u16)
295 }
296 };
297 Ok((host, port))
298}
299
300impl Protocol for GopherClientProtocol {
301 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
302 vec![
303 send_gopher_request_action(),
304 send_gopher_search_action(),
305 wait_for_more_action(),
306 disconnect_action(),
307 ]
308 }
309
310 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
311 Vec::new()
314 }
315
316 fn protocol_name(&self) -> &'static str {
317 "Gopher"
318 }
319
320 fn get_event_types(&self) -> Vec<EventType> {
321 get_gopher_client_event_types()
322 }
323
324 fn stack_name(&self) -> &'static str {
325 "ETH>IP>TCP>GOPHER"
326 }
327
328 fn keywords(&self) -> Vec<&'static str> {
329 vec!["gopher", "gopher client", "gopherspace", "gopher hole"]
330 }
331
332 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
333 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
334
335 ProtocolMetadataV2::builder()
336 .state(DevelopmentState::Experimental)
337 .implementation(
338 "Hand-rolled RFC 1436 over plain tokio TCP; no library. Gopher is one request \
339 per connection, so every fetch opens its own socket, writes one selector \
340 line and reads to EOF - the close is the framing. The reply is parsed \
341 according to the item type the request asked for, which is the only thing \
342 that says whether it is a menu or a document.",
343 )
344 .llm_control(
345 "Which selector to fetch, on which host and port, and what the reply was \
346 expected to be. Menus arrive parsed into structured items, so following a \
347 link is echoing an item's fields back in a send_gopher_request.",
348 )
349 .e2e_testing(
350 "tests/client/gopher/e2e_test.rs. Its peer is NetGet's own Gopher server, so \
351 the exchange is same-project evidence: it shows the two halves agree, not \
352 that either matches RFC 1436.",
353 )
354 .notes(
355 "Experimental, and the reason is the evidence rather than the code: no \
356 third-party Gopher server has been pointed at it. The one sniff is \
357 deliberate - a type-3 item is recognised whatever type was asked for, \
358 because it is the protocol's only error channel. Not implemented: Gopher+, \
359 TLS ('gophers'), binary item transfer (types 4/5/6/9/g/I are listed but not \
360 downloaded, since that would mean base64 in an event) and CSO (type 2).",
361 )
362 .build()
363 }
364
365 fn description(&self) -> &'static str {
366 "Gopher (RFC 1436) client: fetch menus, documents and searches from a gopher hole"
367 }
368
369 fn example_prompt(&self) -> &'static str {
370 "Connect to the Gopher server at 127.0.0.1:70, fetch the root menu and then read the \
371 first text file it lists"
372 }
373
374 fn group_name(&self) -> &'static str {
375 "Application"
376 }
377
378 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
379 use crate::llm::actions::StartupExamples;
380 StartupExamples::new(
381 json!({
383 "type": "open_client",
384 "remote_addr": "127.0.0.1:70",
385 "base_stack": "gopher",
386 "instruction": "Fetch the root menu, then read every text file it lists and \
387 summarise them."
388 }),
389 json!({
391 "type": "open_client",
392 "remote_addr": "127.0.0.1:70",
393 "base_stack": "gopher",
394 "event_handlers": [{
395 "event_pattern": "gopher_menu_received",
396 "handler": {
397 "type": "script",
398 "language": "python",
399 "code": "items = event.get('items', [])\ntexts = [i for i in items if i.get('item_type') == '0']\nif texts:\n first = texts[0]\n respond([{'type': 'send_gopher_request', 'selector': first['selector'], 'host': first['host'], 'port': first['port'], 'item_type': '0'}])\nelse:\n respond([{'type': 'disconnect'}])"
400 }
401 }]
402 }),
403 json!({
405 "type": "open_client",
406 "remote_addr": "127.0.0.1:70",
407 "base_stack": "gopher",
408 "event_handlers": [{
409 "event_pattern": "gopher_document_received",
410 "handler": {
411 "type": "static",
412 "actions": [{"type": "disconnect"}]
413 }
414 }]
415 }),
416 )
417 }
418}
419
420impl Client for GopherClientProtocol {
421 fn connect(
422 &self,
423 ctx: crate::protocol::ConnectContext,
424 ) -> std::pin::Pin<
425 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
426 > {
427 Box::pin(async move {
428 use crate::client::gopher::GopherClient;
429 GopherClient::connect_with_llm_actions(
430 ctx.remote_addr,
431 ctx.llm_client,
432 ctx.state,
433 ctx.status_tx,
434 ctx.client_id,
435 )
436 .await
437 })
438 }
439
440 fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
441 let action_type = action
442 .get("type")
443 .and_then(|v| v.as_str())
444 .context("Missing 'type' field in action")?;
445
446 match action_type {
447 "send_gopher_request" => {
448 let selector = action
451 .get("selector")
452 .and_then(|v| v.as_str())
453 .unwrap_or("")
454 .to_string();
455 reject_wire_breaks("selector", &selector, false)?;
456
457 let item_type = parse_item_type(&action)?;
458 let (host, port) = fetch_target(&action)?;
459
460 Ok(ClientActionResult::Custom {
461 name: GOPHER_FETCH_RESULT.to_string(),
462 data: json!({
463 "selector": selector,
464 "item_type": item_type.to_string(),
465 "host": host,
466 "port": port,
467 "query": serde_json::Value::Null,
468 }),
469 })
470 }
471 "send_gopher_search" => {
472 let selector = action
473 .get("selector")
474 .and_then(|v| v.as_str())
475 .unwrap_or("")
476 .to_string();
477 reject_wire_breaks("selector", &selector, false)?;
478
479 let query = action
480 .get("query")
481 .and_then(|v| v.as_str())
482 .context("send_gopher_search needs a 'query' - the words to search for")?
483 .to_string();
484 reject_wire_breaks("query", &query, true)?;
487
488 let (host, port) = fetch_target(&action)?;
489
490 Ok(ClientActionResult::Custom {
491 name: GOPHER_FETCH_RESULT.to_string(),
492 data: json!({
493 "selector": selector,
494 "item_type": "7",
496 "host": host,
497 "port": port,
498 "query": query,
499 }),
500 })
501 }
502 "wait_for_more" => Ok(ClientActionResult::WaitForMore),
503 "disconnect" => Ok(ClientActionResult::Disconnect),
504 other => Err(anyhow::anyhow!("Unknown Gopher client action: {}", other)),
505 }
506 }
507}
508
509fn parse_item_type(action: &serde_json::Value) -> Result<char> {
512 let Some(raw) = action.get("item_type").and_then(|v| v.as_str()) else {
513 return Ok(DEFAULT_ITEM_TYPE);
514 };
515 if raw.is_empty() {
516 return Ok(DEFAULT_ITEM_TYPE);
517 }
518 let mut chars = raw.chars();
519 match (chars.next(), chars.next()) {
520 (Some(c), None) => Ok(c),
521 _ => bail!("'item_type' is exactly one character, not {raw:?}"),
522 }
523}
524
525fn send_gopher_request_action() -> ActionDefinition {
526 ActionDefinition {
527 name: "send_gopher_request".to_string(),
528 description:
529 "Fetch one Gopher item. Gopher is one request per connection: this opens its own \
530 connection, sends the selector, reads until the server hangs up, and reports the \
531 reply back to you as a gopher_menu_received, gopher_document_received or \
532 gopher_error_received event. To follow a link from a menu, copy that item's \
533 'selector', 'host', 'port' and 'item_type' straight from the menu you were given."
534 .to_string(),
535 parameters: vec![
536 Parameter {
537 name: "selector".to_string(),
538 type_hint: "string".to_string(),
539 description:
540 "The selector to fetch. Leave it out or use the empty string for the root \
541 menu. Never include the item-type character here: a Gopher URL such as \
542 gopher://host/0/about.txt has selector '/about.txt'."
543 .to_string(),
544 required: false,
545 },
546 Parameter {
547 name: "item_type".to_string(),
548 type_hint: "string".to_string(),
549 description:
550 "One character saying what you expect back, because the reply itself does \
551 not say: '1' a menu (the default), '7' a search menu, '0' a text \
552 document. Anything else is read as a document. A menu item tells you its \
553 own type - use that."
554 .to_string(),
555 required: false,
556 },
557 Parameter {
558 name: "host".to_string(),
559 type_hint: "string".to_string(),
560 description: "Host to fetch from. Defaults to the address this client was opened \
561 against; menu items carry their own host so a gopherspace can span servers."
562 .to_string(),
563 required: false,
564 },
565 Parameter {
566 name: "port".to_string(),
567 type_hint: "number".to_string(),
568 description: "Port to fetch from. Defaults to the port this client was opened \
569 against."
570 .to_string(),
571 required: false,
572 },
573 ],
574 example: json!({
575 "type": "send_gopher_request",
576 "selector": "/about.txt",
577 "item_type": "0",
578 "host": "127.0.0.1",
579 "port": 70
580 }),
581 log_template: Some(
582 LogTemplate::new()
583 .with_info("-> Gopher fetch {selector}")
584 .with_debug("Gopher send_gopher_request: selector={selector} type={item_type}"),
585 ),
586 }
587}
588
589fn send_gopher_search_action() -> ActionDefinition {
590 ActionDefinition {
591 name: "send_gopher_search".to_string(),
592 description:
593 "Run a search against a type-7 item. Same as send_gopher_request except that the \
594 search terms are sent with the selector, and the reply is always a menu of \
595 results. Use the selector of the '7' item you found in a menu."
596 .to_string(),
597 parameters: vec![
598 Parameter {
599 name: "selector".to_string(),
600 type_hint: "string".to_string(),
601 description: "Selector of the type-7 search item".to_string(),
602 required: true,
603 },
604 Parameter {
605 name: "query".to_string(),
606 type_hint: "string".to_string(),
607 description: "The words to search for".to_string(),
608 required: true,
609 },
610 Parameter {
611 name: "host".to_string(),
612 type_hint: "string".to_string(),
613 description: "Host to search. Defaults to the address this client was opened \
614 against."
615 .to_string(),
616 required: false,
617 },
618 Parameter {
619 name: "port".to_string(),
620 type_hint: "number".to_string(),
621 description: "Port to search. Defaults to the port this client was opened \
622 against."
623 .to_string(),
624 required: false,
625 },
626 ],
627 example: json!({
628 "type": "send_gopher_search",
629 "selector": "/search",
630 "query": "burrow"
631 }),
632 log_template: Some(
633 LogTemplate::new()
634 .with_info("-> Gopher search {selector} for {query}")
635 .with_debug("Gopher send_gopher_search: selector={selector} query={query}"),
636 ),
637 }
638}
639
640fn wait_for_more_action() -> ActionDefinition {
641 ActionDefinition {
642 name: "wait_for_more".to_string(),
643 description:
644 "Stop here and do nothing further on your own. Gopher replies are never partial - \
645 the server closing the connection is what marks a reply complete - so this does \
646 not wait for more bytes. It ends the automatic browse and leaves the client idle \
647 until someone sends it another request."
648 .to_string(),
649 parameters: vec![],
650 example: json!({"type": "wait_for_more"}),
651 log_template: Some(
652 LogTemplate::new()
653 .with_info("Gopher client idle")
654 .with_debug("Gopher wait_for_more: browse stopped, client idle"),
655 ),
656 }
657}
658
659fn disconnect_action() -> ActionDefinition {
660 ActionDefinition {
661 name: "disconnect".to_string(),
662 description:
663 "End the browsing session. Gopher holds no connection between requests, so there \
664 is no socket to close - this marks the client finished, stops it acting on its \
665 own, and takes it out of the dashboard's send list."
666 .to_string(),
667 parameters: vec![],
668 example: json!({"type": "disconnect"}),
669 log_template: Some(
670 LogTemplate::new()
671 .with_info("Gopher browsing session ended")
672 .with_debug("Gopher disconnect"),
673 ),
674 }
675}