1pub mod actions;
43
44pub use actions::GopherClientProtocol;
45
46use std::net::SocketAddr;
47use std::sync::atomic::{AtomicBool, Ordering};
48use std::sync::Arc;
49use std::time::Duration;
50
51use anyhow::{anyhow, Context, Result};
52use serde_json::json;
53use tokio::io::{AsyncReadExt, AsyncWriteExt};
54use tokio::net::TcpStream;
55use tokio::sync::mpsc;
56use tracing::{debug, error, info, trace, warn};
57
58use crate::client::llm_budget::call_llm_for_client;
59use crate::llm::actions::client_trait::{Client, ClientActionResult};
60use crate::llm::actions::protocol_trait::Protocol;
61use crate::llm::ollama_client::OllamaClient;
62use crate::llm::ClientLlmResult;
63use crate::protocol::Event;
64use crate::state::app_state::AppState;
65use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
66use crate::state::{AccessLogOwner, ClientId, ClientStatus};
67
68use actions::{
69 GOPHER_DOCUMENT_RECEIVED_EVENT, GOPHER_ERROR_RECEIVED_EVENT, GOPHER_FETCH_RESULT,
70 GOPHER_MENU_RECEIVED_EVENT,
71};
72
73pub const MAX_FOLLOWUP_DEPTH: usize = 6;
79
80pub const MAX_REPLY_BYTES: usize = 1024 * 1024;
83
84pub const MAX_MENU_ITEMS: usize = 4096;
86
87const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
88const READ_TIMEOUT: Duration = Duration::from_secs(60);
89
90pub const DEFAULT_GOPHER_PORT: u16 = 70;
92
93#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct GopherMenuItem {
100 pub item_type: char,
102 pub display: String,
104 pub selector: String,
106 pub host: String,
108 pub port: u16,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum GopherReply {
115 Menu {
117 items: Vec<GopherMenuItem>,
118 malformed_lines: Vec<String>,
120 },
121 Document { text: String },
123 Error { message: String },
125}
126
127pub fn item_type_name(item_type: char) -> &'static str {
133 match item_type {
134 '0' => "text file",
135 '1' => "directory (menu)",
136 '2' => "CSO phone-book server",
137 '3' => "error",
138 '4' => "BinHex file",
139 '5' => "DOS binary archive",
140 '6' => "uuencoded file",
141 '7' => "search server",
142 '8' => "telnet session",
143 '9' => "binary file",
144 '+' => "redundant server",
145 'T' => "tn3270 session",
146 'g' => "GIF image",
147 'I' => "image",
148 'h' => "HTML",
149 'i' => "informational text (not a link)",
150 'd' => "document",
151 's' => "sound",
152 _ => "unrecognised item type",
153 }
154}
155
156fn body_lines(raw: &str) -> (Vec<&str>, bool) {
162 let mut out: Vec<&str> = Vec::new();
163 let mut terminated = false;
164 for line in raw.split('\n') {
165 let line = line.strip_suffix('\r').unwrap_or(line);
166 if line == "." {
167 terminated = true;
168 break;
169 }
170 out.push(line);
171 }
172 if !terminated {
173 if out.last() == Some(&"") {
176 out.pop();
177 }
178 }
179 (out, terminated)
180}
181
182fn unescape_document_line(line: &str) -> &str {
188 line.strip_prefix('.').unwrap_or(line)
189}
190
191fn parse_menu_line(line: &str) -> Option<GopherMenuItem> {
193 let mut chars = line.chars();
194 let item_type = chars.next()?;
195 let rest = chars.as_str();
196
197 let fields: Vec<&str> = rest.split('\t').collect();
198 if fields.len() < 4 {
199 return None;
200 }
201 let port = fields[3].trim().parse::<u16>().ok()?;
205
206 Some(GopherMenuItem {
207 item_type,
208 display: fields[0].to_string(),
209 selector: fields[1].to_string(),
210 host: fields[2].to_string(),
211 port,
212 })
213}
214
215pub fn parse_gopher_reply(raw: &str, requested_item_type: char) -> GopherReply {
221 let (lines, _terminated) = body_lines(raw);
222
223 if let Some(first) = lines.iter().find(|l| !l.is_empty()) {
227 if first.starts_with('3') && first.contains('\t') {
228 let message = first[1..].split('\t').next().unwrap_or("").to_string();
229 return GopherReply::Error { message };
230 }
231 }
232
233 let wants_menu = requested_item_type == '1' || requested_item_type == '7';
234 if wants_menu {
235 let mut items = Vec::new();
236 let mut malformed_lines = Vec::new();
237 for line in &lines {
238 if line.is_empty() {
239 continue;
240 }
241 match parse_menu_line(line) {
242 Some(item) if items.len() < MAX_MENU_ITEMS => items.push(item),
243 Some(_) => break,
244 None => malformed_lines.push((*line).to_string()),
245 }
246 }
247 if !items.is_empty() {
248 return GopherReply::Menu {
249 items,
250 malformed_lines,
251 };
252 }
253 }
256
257 let text = lines
258 .iter()
259 .map(|l| unescape_document_line(l))
260 .collect::<Vec<_>>()
261 .join("\n");
262 GopherReply::Document { text }
263}
264
265pub fn request_line(selector: &str, query: Option<&str>) -> String {
267 match query {
268 Some(q) => format!("{selector}\t{q}\r\n"),
269 None => format!("{selector}\r\n"),
270 }
271}
272
273fn split_remote_addr(remote_addr: &str) -> (String, u16) {
278 if let Some(rest) = remote_addr.strip_prefix('[') {
279 if let Some((host, tail)) = rest.split_once(']') {
280 let port = tail
281 .strip_prefix(':')
282 .and_then(|p| p.parse::<u16>().ok())
283 .unwrap_or(DEFAULT_GOPHER_PORT);
284 return (host.to_string(), port);
285 }
286 }
287 match remote_addr.rsplit_once(':') {
288 Some((host, port)) => match port.parse::<u16>() {
289 Ok(port) => (host.to_string(), port),
290 Err(_) => (remote_addr.to_string(), DEFAULT_GOPHER_PORT),
292 },
293 None => (remote_addr.to_string(), DEFAULT_GOPHER_PORT),
294 }
295}
296
297fn dial_addr(host: &str, port: u16) -> String {
299 if host.contains(':') && !host.starts_with('[') {
300 format!("[{host}]:{port}")
301 } else {
302 format!("{host}:{port}")
303 }
304}
305
306struct Session {
313 protocol: Arc<GopherClientProtocol>,
314 llm_client: OllamaClient,
315 app_state: Arc<AppState>,
316 status_tx: mpsc::UnboundedSender<String>,
317 client_id: ClientId,
318 default_host: String,
320 default_port: u16,
321 instruction: String,
322 ended: Arc<AtomicBool>,
325}
326
327struct FetchRequest {
329 host: String,
330 port: u16,
331 selector: String,
332 item_type: char,
333 query: Option<String>,
334}
335
336struct FetchOutcome {
338 reply: GopherReply,
339 request_bytes: usize,
340 reply_bytes: usize,
341 truncated: bool,
342}
343
344pub struct GopherClient;
346
347impl GopherClient {
348 pub async fn connect_with_llm_actions(
356 remote_addr: String,
357 llm_client: OllamaClient,
358 app_state: Arc<AppState>,
359 status_tx: mpsc::UnboundedSender<String>,
360 client_id: ClientId,
361 ) -> Result<SocketAddr> {
362 let (default_host, default_port) = split_remote_addr(&remote_addr);
363 let dial = dial_addr(&default_host, default_port);
364
365 let probe = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&dial))
366 .await
367 .map_err(|_| anyhow!("timed out connecting to Gopher server at {dial}"))?
368 .with_context(|| format!("Failed to connect to Gopher server at {dial}"))?;
369 let local_addr = probe.local_addr()?;
370 let remote_sock_addr = probe.peer_addr()?;
371 drop(probe);
374
375 info!(
376 "Gopher client {} reached {} (local: {}); every request opens its own connection",
377 client_id, remote_sock_addr, local_addr
378 );
379
380 app_state
381 .update_client_status(client_id, ClientStatus::Connected)
382 .await;
383 let _ = status_tx.send(format!(
384 "[CLIENT] Gopher client {} ready ({})",
385 client_id, dial
386 ));
387 let _ = status_tx.send("__UPDATE_UI__".to_string());
388
389 let session = Arc::new(Session {
390 protocol: Arc::new(GopherClientProtocol::new()),
391 llm_client,
392 app_state: app_state.clone(),
393 status_tx: status_tx.clone(),
394 client_id,
395 default_host,
396 default_port,
397 instruction: app_state
398 .get_instruction_for_client(client_id)
399 .await
400 .unwrap_or_default(),
401 ended: Arc::new(AtomicBool::new(false)),
402 });
403
404 let command_rx =
409 crate::client::command_support::register_command_channel(&app_state, client_id).await;
410 let command_session = session.clone();
411 let command_task = tokio::spawn(async move {
412 Session::command_loop(command_session, command_rx).await;
413 });
414 app_state
415 .register_client_task(client_id, command_task)
416 .await;
417
418 let browse_session = session.clone();
419 let browse_task = tokio::spawn(async move {
420 Session::open_browse(browse_session).await;
421 });
422 app_state.register_client_task(client_id, browse_task).await;
423
424 Ok(local_addr)
425 }
426}
427
428impl Session {
429 async fn open_browse(self: Arc<Self>) {
437 if self.instruction.trim().is_empty() {
438 info!(
439 "Gopher client {} has no instruction; waiting for an injected request",
440 self.client_id
441 );
442 return;
443 }
444
445 let memory = self
446 .app_state
447 .get_memory_for_client(self.client_id)
448 .await
449 .unwrap_or_default();
450
451 match call_llm_for_client(
452 &self.llm_client,
453 &self.app_state,
454 self.client_id.to_string(),
455 &self.instruction,
456 &memory,
457 None,
458 self.protocol.as_ref(),
459 &self.status_tx,
460 )
461 .await
462 {
463 Ok(ClientLlmResult {
464 actions,
465 memory_updates,
466 }) => {
467 if let Some(mem) = memory_updates {
468 self.app_state
469 .set_memory_for_client(self.client_id, mem)
470 .await;
471 }
472 Self::run_actions(self.clone(), actions, 0).await;
473 }
474 Err(e) => {
475 error!(
477 "LLM error opening Gopher browse for client {}: {}",
478 self.client_id, e
479 );
480 let _ = self.status_tx.send(format!(
481 "[WARN] Gopher client {} could not ask the model what to fetch: {} \
482 (a request can still be injected)",
483 self.client_id, e
484 ));
485 }
486 }
487 }
488
489 fn run_actions(
495 session: Arc<Self>,
496 actions: Vec<serde_json::Value>,
497 depth: usize,
498 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
499 Box::pin(async move {
500 for action in actions {
501 if session.ended.load(Ordering::SeqCst) {
502 debug!(
503 "Gopher client {} stopped: session ended before the next action",
504 session.client_id
505 );
506 return;
507 }
508
509 let result = match session.protocol.execute_action(action.clone()) {
510 Ok(result) => result,
511 Err(e) => {
512 error!("Gopher client {} rejected action: {}", session.client_id, e);
513 let _ = session.status_tx.send(format!(
514 "[WARN] Gopher client {} rejected an action: {}",
515 session.client_id, e
516 ));
517 continue;
518 }
519 };
520
521 match result {
522 ClientActionResult::Custom { name, data } if name == GOPHER_FETCH_RESULT => {
523 let request = match session.resolve_fetch(&data) {
524 Ok(request) => request,
525 Err(e) => {
526 error!(
527 "Gopher client {} could not resolve a fetch: {}",
528 session.client_id, e
529 );
530 continue;
531 }
532 };
533 session.clone().fetch_and_notify(request, depth).await;
534 }
535 ClientActionResult::Disconnect => {
536 session.end("the model ended the browsing session").await;
537 return;
538 }
539 ClientActionResult::WaitForMore => {
540 info!(
541 "Gopher client {} is idle: the model asked to stop here",
542 session.client_id
543 );
544 }
545 ClientActionResult::NoAction => {}
546 ClientActionResult::SendData(_) | ClientActionResult::Multiple(_) => {
547 debug!(
549 "Gopher client {} ignored an action result it does not produce",
550 session.client_id
551 );
552 }
553 ClientActionResult::Custom { name, .. } => {
554 debug!(
555 "Gopher client {} ignored unexpected custom result '{}'",
556 session.client_id, name
557 );
558 }
559 }
560 }
561 })
562 }
563
564 fn resolve_fetch(&self, data: &serde_json::Value) -> Result<FetchRequest> {
566 let selector = data
567 .get("selector")
568 .and_then(|v| v.as_str())
569 .unwrap_or("")
570 .to_string();
571 let item_type = data
572 .get("item_type")
573 .and_then(|v| v.as_str())
574 .and_then(|s| s.chars().next())
575 .unwrap_or(actions::DEFAULT_ITEM_TYPE);
576 let host = data
577 .get("host")
578 .and_then(|v| v.as_str())
579 .filter(|h| !h.is_empty())
580 .unwrap_or(&self.default_host)
581 .to_string();
582 let port = data
583 .get("port")
584 .and_then(|v| v.as_u64())
585 .and_then(|p| u16::try_from(p).ok())
586 .filter(|p| *p != 0)
587 .unwrap_or(self.default_port);
588 let query = data
589 .get("query")
590 .and_then(|v| v.as_str())
591 .map(|s| s.to_string());
592
593 Ok(FetchRequest {
594 host,
595 port,
596 selector,
597 item_type,
598 query,
599 })
600 }
601
602 async fn fetch_and_notify(self: Arc<Self>, request: FetchRequest, depth: usize) {
608 let outcome = match self.perform_fetch(&request).await {
609 Ok(outcome) => outcome,
610 Err(e) => {
611 error!(
612 "Gopher client {} fetch of {:?} from {}:{} failed: {}",
613 self.client_id, request.selector, request.host, request.port, e
614 );
615 let _ = self.status_tx.send(format!(
616 "[WARN] Gopher client {} could not fetch {:?}: {}",
617 self.client_id, request.selector, e
618 ));
619 let _ = self.status_tx.send("__UPDATE_UI__".to_string());
620 return;
621 }
622 };
623
624 let _ = self.status_tx.send("__UPDATE_UI__".to_string());
625 self.notify(&request, outcome, depth).await;
626 }
627
628 async fn perform_fetch(&self, request: &FetchRequest) -> Result<FetchOutcome> {
630 if self.ended.load(Ordering::SeqCst) {
631 return Err(anyhow!("the browsing session has ended"));
632 }
633
634 let dial = dial_addr(&request.host, request.port);
635 let line = request_line(&request.selector, request.query.as_deref());
636
637 debug!(
638 "Gopher client {} fetching {:?} (type '{}') from {}",
639 self.client_id, request.selector, request.item_type, dial
640 );
641
642 let stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&dial))
643 .await
644 .map_err(|_| anyhow!("timed out connecting to {dial}"))?
645 .with_context(|| format!("could not connect to {dial}"))?;
646
647 let (mut read_half, mut write_half) = tokio::io::split(stream);
648 write_half.write_all(line.as_bytes()).await?;
649 write_half.flush().await?;
650 trace!(
651 "Gopher client {} sent request line: {:?}",
652 self.client_id,
653 line
654 );
655
656 let mut body: Vec<u8> = Vec::new();
660 let mut chunk = vec![0u8; 8192];
661 let mut truncated = false;
662 loop {
663 let n = tokio::time::timeout(READ_TIMEOUT, read_half.read(&mut chunk))
664 .await
665 .map_err(|_| anyhow!("timed out waiting for {dial} to finish its reply"))??;
666 if n == 0 {
667 break;
668 }
669 body.extend_from_slice(&chunk[..n]);
670 if body.len() >= MAX_REPLY_BYTES {
671 body.truncate(MAX_REPLY_BYTES);
672 truncated = true;
673 warn!(
674 "Gopher client {} truncated a reply from {} at {} bytes",
675 self.client_id, dial, MAX_REPLY_BYTES
676 );
677 break;
678 }
679 }
680 let _ = write_half.shutdown().await;
681
682 let reply_bytes = body.len();
683 let raw = String::from_utf8_lossy(&body).into_owned();
684 trace!("Gopher client {} reply:\n{}", self.client_id, raw);
685
686 Ok(FetchOutcome {
687 reply: parse_gopher_reply(&raw, request.item_type),
688 request_bytes: line.len(),
689 reply_bytes,
690 truncated,
691 })
692 }
693
694 async fn notify(self: &Arc<Self>, request: &FetchRequest, outcome: FetchOutcome, depth: usize) {
696 let FetchOutcome {
697 reply,
698 reply_bytes,
699 truncated,
700 ..
701 } = outcome;
702
703 let (event, summary) = match reply {
704 GopherReply::Menu {
705 items,
706 malformed_lines,
707 } => {
708 let mut data = json!({
709 "selector": request.selector,
710 "host": request.host,
711 "port": request.port,
712 "item_count": items.len(),
713 "items": items
714 .iter()
715 .map(|item| json!({
716 "item_type": item.item_type.to_string(),
717 "item_type_name": item_type_name(item.item_type),
718 "display": item.display,
719 "selector": item.selector,
720 "host": item.host,
721 "port": item.port,
722 }))
723 .collect::<Vec<_>>(),
724 });
725 if !malformed_lines.is_empty() {
726 data["malformed_lines"] = json!(malformed_lines);
727 }
728 if truncated {
729 data["truncated"] = json!(true);
730 }
731 let summary = format!("menu with {} item(s)", items.len());
732 (Event::new(&GOPHER_MENU_RECEIVED_EVENT, data), summary)
733 }
734 GopherReply::Document { text } => {
735 let line_count = if text.is_empty() {
736 0
737 } else {
738 text.split('\n').count()
739 };
740 let mut data = json!({
741 "selector": request.selector,
742 "host": request.host,
743 "port": request.port,
744 "requested_item_type": request.item_type.to_string(),
745 "text": text,
746 "line_count": line_count,
747 });
748 if truncated {
749 data["truncated"] = json!(true);
750 }
751 (
752 Event::new(&GOPHER_DOCUMENT_RECEIVED_EVENT, data),
753 format!("document of {line_count} line(s)"),
754 )
755 }
756 GopherReply::Error { message } => {
757 let data = json!({
758 "selector": request.selector,
759 "host": request.host,
760 "port": request.port,
761 "message": message,
762 });
763 (
764 Event::new(&GOPHER_ERROR_RECEIVED_EVENT, data),
765 format!("error item: {message}"),
766 )
767 }
768 };
769
770 info!(
771 "Gopher client {} received {} bytes for {:?}: {}",
772 self.client_id, reply_bytes, request.selector, summary
773 );
774 let _ = self.status_tx.send(format!(
775 "[CLIENT] Gopher client {} <- {} for {:?}",
776 self.client_id, summary, request.selector
777 ));
778
779 if depth + 1 > MAX_FOLLOWUP_DEPTH {
780 warn!(
781 "Gopher client {} stopped following links at depth {}: the chain cap is {}",
782 self.client_id, depth, MAX_FOLLOWUP_DEPTH
783 );
784 let _ = self.status_tx.send(format!(
785 "[WARN] Gopher client {} stopped browsing after {} follow-ups",
786 self.client_id, MAX_FOLLOWUP_DEPTH
787 ));
788 return;
789 }
790
791 if self.ended.load(Ordering::SeqCst) {
792 return;
793 }
794
795 let memory = self
796 .app_state
797 .get_memory_for_client(self.client_id)
798 .await
799 .unwrap_or_default();
800
801 match call_llm_for_client(
802 &self.llm_client,
803 &self.app_state,
804 self.client_id.to_string(),
805 &self.instruction,
806 &memory,
807 Some(&event),
808 self.protocol.as_ref(),
809 &self.status_tx,
810 )
811 .await
812 {
813 Ok(ClientLlmResult {
814 actions,
815 memory_updates,
816 }) => {
817 if let Some(mem) = memory_updates {
818 self.app_state
819 .set_memory_for_client(self.client_id, mem)
820 .await;
821 }
822 Self::run_actions(self.clone(), actions, depth + 1).await;
826 }
827 Err(e) => {
828 error!(
829 "LLM error for Gopher client {} on {}: {}",
830 self.client_id,
831 event.id(),
832 e
833 );
834 let _ = self.status_tx.send(format!(
835 "[WARN] Gopher client {} could not act on {}: {}",
836 self.client_id,
837 event.id(),
838 e
839 ));
840 }
841 }
842 }
843
844 async fn end(&self, reason: &str) {
846 if self.ended.swap(true, Ordering::SeqCst) {
847 return;
848 }
849 info!("Gopher client {} finished: {}", self.client_id, reason);
850 self.app_state
851 .update_client_status(self.client_id, ClientStatus::Disconnected)
852 .await;
853 self.app_state.remove_client_handle(self.client_id).await;
855 let _ = self.status_tx.send(format!(
856 "[CLIENT] Gopher client {} disconnected ({})",
857 self.client_id, reason
858 ));
859 let _ = self.status_tx.send("__UPDATE_UI__".to_string());
860 }
861
862 async fn command_loop(self: Arc<Self>, mut command_rx: mpsc::Receiver<ClientCommand>) {
880 while let Some(command) = command_rx.recv().await {
881 let action = command.action.clone();
882
883 let executed = self.protocol.execute_action(action.clone());
884 let mut pending: Option<(FetchRequest, FetchOutcome)> = None;
885 let mut disconnect = false;
886
887 let outcome: Result<ClientSendOutcome> = match executed {
888 Err(e) => Ok(ClientSendOutcome::Rejected {
889 error: e.to_string(),
890 }),
891 Ok(ClientActionResult::Custom { name, data }) if name == GOPHER_FETCH_RESULT => {
892 match self.resolve_fetch(&data) {
893 Err(e) => Ok(ClientSendOutcome::Rejected {
894 error: e.to_string(),
895 }),
896 Ok(request) => match self.perform_fetch(&request).await {
897 Ok(fetched) => {
898 let bytes_sent = fetched.request_bytes;
899 pending = Some((request, fetched));
900 Ok(ClientSendOutcome::Sent { bytes_sent })
901 }
902 Err(e) => Err(e),
903 },
904 }
905 }
906 Ok(ClientActionResult::Disconnect) => {
907 disconnect = true;
908 Ok(ClientSendOutcome::Disconnected)
909 }
910 Ok(ClientActionResult::WaitForMore) => Ok(ClientSendOutcome::Executed {
911 detail: "wait_for_more (Gopher replies are never partial)".to_string(),
912 }),
913 Ok(_) => Ok(ClientSendOutcome::Executed {
914 detail: "executed".to_string(),
915 }),
916 };
917
918 let outcome_json = match &outcome {
919 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
920 Err(e) => json!({"error": e.to_string()}),
921 };
922 self.app_state
923 .record_access_log(
924 AccessLogOwner::Client(self.client_id.as_u32()),
925 self.protocol.protocol_name(),
926 None,
927 "injected_action",
928 action,
929 vec![outcome_json],
930 )
931 .await;
932
933 if let Err(e) = &outcome {
934 error!(
935 "Gopher client {} injected action failed: {}",
936 self.client_id, e
937 );
938 let _ = self.status_tx.send(format!(
939 "[WARN] Client {} injected action failed: {}",
940 self.client_id, e
941 ));
942 }
943 let _ = self.status_tx.send("__UPDATE_UI__".to_string());
944 crate::client::command_support::reply(command, outcome);
945
946 if disconnect {
947 self.end("an injected disconnect ended the browsing session")
948 .await;
949 break;
950 }
951
952 if let Some((request, fetched)) = pending {
956 self.notify(&request, fetched, 0).await;
957 }
958 }
959 }
960}