pub mod actions;
pub use actions::GopherClientProtocol;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tracing::{debug, error, info, trace, warn};
use crate::client::llm_budget::call_llm_for_client;
use crate::llm::actions::client_trait::{Client, ClientActionResult};
use crate::llm::actions::protocol_trait::Protocol;
use crate::llm::ollama_client::OllamaClient;
use crate::llm::ClientLlmResult;
use crate::protocol::Event;
use crate::state::app_state::AppState;
use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
use crate::state::{AccessLogOwner, ClientId, ClientStatus};
use actions::{
GOPHER_DOCUMENT_RECEIVED_EVENT, GOPHER_ERROR_RECEIVED_EVENT, GOPHER_FETCH_RESULT,
GOPHER_MENU_RECEIVED_EVENT,
};
pub const MAX_FOLLOWUP_DEPTH: usize = 6;
pub const MAX_REPLY_BYTES: usize = 1024 * 1024;
pub const MAX_MENU_ITEMS: usize = 4096;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const READ_TIMEOUT: Duration = Duration::from_secs(60);
pub const DEFAULT_GOPHER_PORT: u16 = 70;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GopherMenuItem {
pub item_type: char,
pub display: String,
pub selector: String,
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GopherReply {
Menu {
items: Vec<GopherMenuItem>,
malformed_lines: Vec<String>,
},
Document { text: String },
Error { message: String },
}
pub fn item_type_name(item_type: char) -> &'static str {
match item_type {
'0' => "text file",
'1' => "directory (menu)",
'2' => "CSO phone-book server",
'3' => "error",
'4' => "BinHex file",
'5' => "DOS binary archive",
'6' => "uuencoded file",
'7' => "search server",
'8' => "telnet session",
'9' => "binary file",
'+' => "redundant server",
'T' => "tn3270 session",
'g' => "GIF image",
'I' => "image",
'h' => "HTML",
'i' => "informational text (not a link)",
'd' => "document",
's' => "sound",
_ => "unrecognised item type",
}
}
fn body_lines(raw: &str) -> (Vec<&str>, bool) {
let mut out: Vec<&str> = Vec::new();
let mut terminated = false;
for line in raw.split('\n') {
let line = line.strip_suffix('\r').unwrap_or(line);
if line == "." {
terminated = true;
break;
}
out.push(line);
}
if !terminated {
if out.last() == Some(&"") {
out.pop();
}
}
(out, terminated)
}
fn unescape_document_line(line: &str) -> &str {
line.strip_prefix('.').unwrap_or(line)
}
fn parse_menu_line(line: &str) -> Option<GopherMenuItem> {
let mut chars = line.chars();
let item_type = chars.next()?;
let rest = chars.as_str();
let fields: Vec<&str> = rest.split('\t').collect();
if fields.len() < 4 {
return None;
}
let port = fields[3].trim().parse::<u16>().ok()?;
Some(GopherMenuItem {
item_type,
display: fields[0].to_string(),
selector: fields[1].to_string(),
host: fields[2].to_string(),
port,
})
}
pub fn parse_gopher_reply(raw: &str, requested_item_type: char) -> GopherReply {
let (lines, _terminated) = body_lines(raw);
if let Some(first) = lines.iter().find(|l| !l.is_empty()) {
if first.starts_with('3') && first.contains('\t') {
let message = first[1..].split('\t').next().unwrap_or("").to_string();
return GopherReply::Error { message };
}
}
let wants_menu = requested_item_type == '1' || requested_item_type == '7';
if wants_menu {
let mut items = Vec::new();
let mut malformed_lines = Vec::new();
for line in &lines {
if line.is_empty() {
continue;
}
match parse_menu_line(line) {
Some(item) if items.len() < MAX_MENU_ITEMS => items.push(item),
Some(_) => break,
None => malformed_lines.push((*line).to_string()),
}
}
if !items.is_empty() {
return GopherReply::Menu {
items,
malformed_lines,
};
}
}
let text = lines
.iter()
.map(|l| unescape_document_line(l))
.collect::<Vec<_>>()
.join("\n");
GopherReply::Document { text }
}
pub fn request_line(selector: &str, query: Option<&str>) -> String {
match query {
Some(q) => format!("{selector}\t{q}\r\n"),
None => format!("{selector}\r\n"),
}
}
fn split_remote_addr(remote_addr: &str) -> (String, u16) {
if let Some(rest) = remote_addr.strip_prefix('[') {
if let Some((host, tail)) = rest.split_once(']') {
let port = tail
.strip_prefix(':')
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(DEFAULT_GOPHER_PORT);
return (host.to_string(), port);
}
}
match remote_addr.rsplit_once(':') {
Some((host, port)) => match port.parse::<u16>() {
Ok(port) => (host.to_string(), port),
Err(_) => (remote_addr.to_string(), DEFAULT_GOPHER_PORT),
},
None => (remote_addr.to_string(), DEFAULT_GOPHER_PORT),
}
}
fn dial_addr(host: &str, port: u16) -> String {
if host.contains(':') && !host.starts_with('[') {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
}
}
struct Session {
protocol: Arc<GopherClientProtocol>,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
client_id: ClientId,
default_host: String,
default_port: u16,
instruction: String,
ended: Arc<AtomicBool>,
}
struct FetchRequest {
host: String,
port: u16,
selector: String,
item_type: char,
query: Option<String>,
}
struct FetchOutcome {
reply: GopherReply,
request_bytes: usize,
reply_bytes: usize,
truncated: bool,
}
pub struct GopherClient;
impl GopherClient {
pub async fn connect_with_llm_actions(
remote_addr: String,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
client_id: ClientId,
) -> Result<SocketAddr> {
let (default_host, default_port) = split_remote_addr(&remote_addr);
let dial = dial_addr(&default_host, default_port);
let probe = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&dial))
.await
.map_err(|_| anyhow!("timed out connecting to Gopher server at {dial}"))?
.with_context(|| format!("Failed to connect to Gopher server at {dial}"))?;
let local_addr = probe.local_addr()?;
let remote_sock_addr = probe.peer_addr()?;
drop(probe);
info!(
"Gopher client {} reached {} (local: {}); every request opens its own connection",
client_id, remote_sock_addr, local_addr
);
app_state
.update_client_status(client_id, ClientStatus::Connected)
.await;
let _ = status_tx.send(format!(
"[CLIENT] Gopher client {} ready ({})",
client_id, dial
));
let _ = status_tx.send("__UPDATE_UI__".to_string());
let session = Arc::new(Session {
protocol: Arc::new(GopherClientProtocol::new()),
llm_client,
app_state: app_state.clone(),
status_tx: status_tx.clone(),
client_id,
default_host,
default_port,
instruction: app_state
.get_instruction_for_client(client_id)
.await
.unwrap_or_default(),
ended: Arc::new(AtomicBool::new(false)),
});
let command_rx =
crate::client::command_support::register_command_channel(&app_state, client_id).await;
let command_session = session.clone();
let command_task = tokio::spawn(async move {
Session::command_loop(command_session, command_rx).await;
});
app_state
.register_client_task(client_id, command_task)
.await;
let browse_session = session.clone();
let browse_task = tokio::spawn(async move {
Session::open_browse(browse_session).await;
});
app_state.register_client_task(client_id, browse_task).await;
Ok(local_addr)
}
}
impl Session {
async fn open_browse(self: Arc<Self>) {
if self.instruction.trim().is_empty() {
info!(
"Gopher client {} has no instruction; waiting for an injected request",
self.client_id
);
return;
}
let memory = self
.app_state
.get_memory_for_client(self.client_id)
.await
.unwrap_or_default();
match call_llm_for_client(
&self.llm_client,
&self.app_state,
self.client_id.to_string(),
&self.instruction,
&memory,
None,
self.protocol.as_ref(),
&self.status_tx,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
self.app_state
.set_memory_for_client(self.client_id, mem)
.await;
}
Self::run_actions(self.clone(), actions, 0).await;
}
Err(e) => {
error!(
"LLM error opening Gopher browse for client {}: {}",
self.client_id, e
);
let _ = self.status_tx.send(format!(
"[WARN] Gopher client {} could not ask the model what to fetch: {} \
(a request can still be injected)",
self.client_id, e
));
}
}
}
fn run_actions(
session: Arc<Self>,
actions: Vec<serde_json::Value>,
depth: usize,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
Box::pin(async move {
for action in actions {
if session.ended.load(Ordering::SeqCst) {
debug!(
"Gopher client {} stopped: session ended before the next action",
session.client_id
);
return;
}
let result = match session.protocol.execute_action(action.clone()) {
Ok(result) => result,
Err(e) => {
error!("Gopher client {} rejected action: {}", session.client_id, e);
let _ = session.status_tx.send(format!(
"[WARN] Gopher client {} rejected an action: {}",
session.client_id, e
));
continue;
}
};
match result {
ClientActionResult::Custom { name, data } if name == GOPHER_FETCH_RESULT => {
let request = match session.resolve_fetch(&data) {
Ok(request) => request,
Err(e) => {
error!(
"Gopher client {} could not resolve a fetch: {}",
session.client_id, e
);
continue;
}
};
session.clone().fetch_and_notify(request, depth).await;
}
ClientActionResult::Disconnect => {
session.end("the model ended the browsing session").await;
return;
}
ClientActionResult::WaitForMore => {
info!(
"Gopher client {} is idle: the model asked to stop here",
session.client_id
);
}
ClientActionResult::NoAction => {}
ClientActionResult::SendData(_) | ClientActionResult::Multiple(_) => {
debug!(
"Gopher client {} ignored an action result it does not produce",
session.client_id
);
}
ClientActionResult::Custom { name, .. } => {
debug!(
"Gopher client {} ignored unexpected custom result '{}'",
session.client_id, name
);
}
}
}
})
}
fn resolve_fetch(&self, data: &serde_json::Value) -> Result<FetchRequest> {
let selector = data
.get("selector")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let item_type = data
.get("item_type")
.and_then(|v| v.as_str())
.and_then(|s| s.chars().next())
.unwrap_or(actions::DEFAULT_ITEM_TYPE);
let host = data
.get("host")
.and_then(|v| v.as_str())
.filter(|h| !h.is_empty())
.unwrap_or(&self.default_host)
.to_string();
let port = data
.get("port")
.and_then(|v| v.as_u64())
.and_then(|p| u16::try_from(p).ok())
.filter(|p| *p != 0)
.unwrap_or(self.default_port);
let query = data
.get("query")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(FetchRequest {
host,
port,
selector,
item_type,
query,
})
}
async fn fetch_and_notify(self: Arc<Self>, request: FetchRequest, depth: usize) {
let outcome = match self.perform_fetch(&request).await {
Ok(outcome) => outcome,
Err(e) => {
error!(
"Gopher client {} fetch of {:?} from {}:{} failed: {}",
self.client_id, request.selector, request.host, request.port, e
);
let _ = self.status_tx.send(format!(
"[WARN] Gopher client {} could not fetch {:?}: {}",
self.client_id, request.selector, e
));
let _ = self.status_tx.send("__UPDATE_UI__".to_string());
return;
}
};
let _ = self.status_tx.send("__UPDATE_UI__".to_string());
self.notify(&request, outcome, depth).await;
}
async fn perform_fetch(&self, request: &FetchRequest) -> Result<FetchOutcome> {
if self.ended.load(Ordering::SeqCst) {
return Err(anyhow!("the browsing session has ended"));
}
let dial = dial_addr(&request.host, request.port);
let line = request_line(&request.selector, request.query.as_deref());
debug!(
"Gopher client {} fetching {:?} (type '{}') from {}",
self.client_id, request.selector, request.item_type, dial
);
let stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&dial))
.await
.map_err(|_| anyhow!("timed out connecting to {dial}"))?
.with_context(|| format!("could not connect to {dial}"))?;
let (mut read_half, mut write_half) = tokio::io::split(stream);
write_half.write_all(line.as_bytes()).await?;
write_half.flush().await?;
trace!(
"Gopher client {} sent request line: {:?}",
self.client_id,
line
);
let mut body: Vec<u8> = Vec::new();
let mut chunk = vec![0u8; 8192];
let mut truncated = false;
loop {
let n = tokio::time::timeout(READ_TIMEOUT, read_half.read(&mut chunk))
.await
.map_err(|_| anyhow!("timed out waiting for {dial} to finish its reply"))??;
if n == 0 {
break;
}
body.extend_from_slice(&chunk[..n]);
if body.len() >= MAX_REPLY_BYTES {
body.truncate(MAX_REPLY_BYTES);
truncated = true;
warn!(
"Gopher client {} truncated a reply from {} at {} bytes",
self.client_id, dial, MAX_REPLY_BYTES
);
break;
}
}
let _ = write_half.shutdown().await;
let reply_bytes = body.len();
let raw = String::from_utf8_lossy(&body).into_owned();
trace!("Gopher client {} reply:\n{}", self.client_id, raw);
Ok(FetchOutcome {
reply: parse_gopher_reply(&raw, request.item_type),
request_bytes: line.len(),
reply_bytes,
truncated,
})
}
async fn notify(self: &Arc<Self>, request: &FetchRequest, outcome: FetchOutcome, depth: usize) {
let FetchOutcome {
reply,
reply_bytes,
truncated,
..
} = outcome;
let (event, summary) = match reply {
GopherReply::Menu {
items,
malformed_lines,
} => {
let mut data = json!({
"selector": request.selector,
"host": request.host,
"port": request.port,
"item_count": items.len(),
"items": items
.iter()
.map(|item| json!({
"item_type": item.item_type.to_string(),
"item_type_name": item_type_name(item.item_type),
"display": item.display,
"selector": item.selector,
"host": item.host,
"port": item.port,
}))
.collect::<Vec<_>>(),
});
if !malformed_lines.is_empty() {
data["malformed_lines"] = json!(malformed_lines);
}
if truncated {
data["truncated"] = json!(true);
}
let summary = format!("menu with {} item(s)", items.len());
(Event::new(&GOPHER_MENU_RECEIVED_EVENT, data), summary)
}
GopherReply::Document { text } => {
let line_count = if text.is_empty() {
0
} else {
text.split('\n').count()
};
let mut data = json!({
"selector": request.selector,
"host": request.host,
"port": request.port,
"requested_item_type": request.item_type.to_string(),
"text": text,
"line_count": line_count,
});
if truncated {
data["truncated"] = json!(true);
}
(
Event::new(&GOPHER_DOCUMENT_RECEIVED_EVENT, data),
format!("document of {line_count} line(s)"),
)
}
GopherReply::Error { message } => {
let data = json!({
"selector": request.selector,
"host": request.host,
"port": request.port,
"message": message,
});
(
Event::new(&GOPHER_ERROR_RECEIVED_EVENT, data),
format!("error item: {message}"),
)
}
};
info!(
"Gopher client {} received {} bytes for {:?}: {}",
self.client_id, reply_bytes, request.selector, summary
);
let _ = self.status_tx.send(format!(
"[CLIENT] Gopher client {} <- {} for {:?}",
self.client_id, summary, request.selector
));
if depth + 1 > MAX_FOLLOWUP_DEPTH {
warn!(
"Gopher client {} stopped following links at depth {}: the chain cap is {}",
self.client_id, depth, MAX_FOLLOWUP_DEPTH
);
let _ = self.status_tx.send(format!(
"[WARN] Gopher client {} stopped browsing after {} follow-ups",
self.client_id, MAX_FOLLOWUP_DEPTH
));
return;
}
if self.ended.load(Ordering::SeqCst) {
return;
}
let memory = self
.app_state
.get_memory_for_client(self.client_id)
.await
.unwrap_or_default();
match call_llm_for_client(
&self.llm_client,
&self.app_state,
self.client_id.to_string(),
&self.instruction,
&memory,
Some(&event),
self.protocol.as_ref(),
&self.status_tx,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
self.app_state
.set_memory_for_client(self.client_id, mem)
.await;
}
Self::run_actions(self.clone(), actions, depth + 1).await;
}
Err(e) => {
error!(
"LLM error for Gopher client {} on {}: {}",
self.client_id,
event.id(),
e
);
let _ = self.status_tx.send(format!(
"[WARN] Gopher client {} could not act on {}: {}",
self.client_id,
event.id(),
e
));
}
}
}
async fn end(&self, reason: &str) {
if self.ended.swap(true, Ordering::SeqCst) {
return;
}
info!("Gopher client {} finished: {}", self.client_id, reason);
self.app_state
.update_client_status(self.client_id, ClientStatus::Disconnected)
.await;
self.app_state.remove_client_handle(self.client_id).await;
let _ = self.status_tx.send(format!(
"[CLIENT] Gopher client {} disconnected ({})",
self.client_id, reason
));
let _ = self.status_tx.send("__UPDATE_UI__".to_string());
}
async fn command_loop(self: Arc<Self>, mut command_rx: mpsc::Receiver<ClientCommand>) {
while let Some(command) = command_rx.recv().await {
let action = command.action.clone();
let executed = self.protocol.execute_action(action.clone());
let mut pending: Option<(FetchRequest, FetchOutcome)> = None;
let mut disconnect = false;
let outcome: Result<ClientSendOutcome> = match executed {
Err(e) => Ok(ClientSendOutcome::Rejected {
error: e.to_string(),
}),
Ok(ClientActionResult::Custom { name, data }) if name == GOPHER_FETCH_RESULT => {
match self.resolve_fetch(&data) {
Err(e) => Ok(ClientSendOutcome::Rejected {
error: e.to_string(),
}),
Ok(request) => match self.perform_fetch(&request).await {
Ok(fetched) => {
let bytes_sent = fetched.request_bytes;
pending = Some((request, fetched));
Ok(ClientSendOutcome::Sent { bytes_sent })
}
Err(e) => Err(e),
},
}
}
Ok(ClientActionResult::Disconnect) => {
disconnect = true;
Ok(ClientSendOutcome::Disconnected)
}
Ok(ClientActionResult::WaitForMore) => Ok(ClientSendOutcome::Executed {
detail: "wait_for_more (Gopher replies are never partial)".to_string(),
}),
Ok(_) => Ok(ClientSendOutcome::Executed {
detail: "executed".to_string(),
}),
};
let outcome_json = match &outcome {
Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
Err(e) => json!({"error": e.to_string()}),
};
self.app_state
.record_access_log(
AccessLogOwner::Client(self.client_id.as_u32()),
self.protocol.protocol_name(),
None,
"injected_action",
action,
vec![outcome_json],
)
.await;
if let Err(e) = &outcome {
error!(
"Gopher client {} injected action failed: {}",
self.client_id, e
);
let _ = self.status_tx.send(format!(
"[WARN] Client {} injected action failed: {}",
self.client_id, e
));
}
let _ = self.status_tx.send("__UPDATE_UI__".to_string());
crate::client::command_support::reply(command, outcome);
if disconnect {
self.end("an injected disconnect ended the browsing session")
.await;
break;
}
if let Some((request, fetched)) = pending {
self.notify(&request, fetched, 0).await;
}
}
}
}