pub mod actions;
pub use actions::TorrentTrackerClientProtocol;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::future::Future;
use std::net::SocketAddr;
use std::pin::Pin;
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::{error, info, trace, warn};
use crate::client::http_fetch::FetchClient;
use crate::client::llm_budget::call_llm_for_client;
use crate::client::torrent_tracker::actions::{
TRACKER_ANNOUNCE_RESPONSE_EVENT, TRACKER_SCRAPE_RESPONSE_EVENT,
};
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};
#[derive(Debug, Deserialize, Serialize)]
struct TrackerResponse {
#[serde(rename = "failure reason")]
failure_reason: Option<String>,
#[serde(rename = "warning message")]
warning_message: Option<String>,
interval: Option<i64>,
#[serde(rename = "min interval")]
min_interval: Option<i64>,
#[serde(rename = "tracker id")]
tracker_id: Option<String>,
complete: Option<i64>,
incomplete: Option<i64>,
peers: Option<serde_bencode::value::Value>,
}
#[derive(Debug, Deserialize, Serialize)]
struct ScrapeResponse {
files: Option<serde_bencode::value::Value>,
}
const MAX_FOLLOWUP_DEPTH: u8 = 6;
const MAX_TRACKER_BODY_BYTES: usize = 1024 * 1024;
const TRACKER_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[cfg(not(target_arch = "wasm32"))]
use std::collections::HashMap;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::{LazyLock, Mutex};
#[cfg(not(target_arch = "wasm32"))]
static TRACKER_CLIENTS: LazyLock<Mutex<HashMap<String, reqwest::Client>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[cfg(not(target_arch = "wasm32"))]
fn cached_tracker_client(tracker_url: &str) -> Option<reqwest::Client> {
match TRACKER_CLIENTS.lock() {
Ok(cache) => cache.get(tracker_url).cloned(),
Err(poisoned) => poisoned.into_inner().get(tracker_url).cloned(),
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn tracker_reqwest_client(tracker_url: &str) -> reqwest::Client {
if let Some(client) = cached_tracker_client(tracker_url) {
return client;
}
let url = tracker_url.to_string();
let timeout = TRACKER_REQUEST_TIMEOUT;
let built = tokio::task::spawn_blocking(move || {
crate::llm::ollama_client::client_for_endpoint_with_timeout(&url, timeout)
})
.await
.unwrap_or_else(|_| reqwest::Client::new());
let mut cache = match TRACKER_CLIENTS.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
cache
.entry(tracker_url.to_string())
.or_insert(built)
.clone()
}
async fn tracker_http_client(tracker_url: &str) -> FetchClient {
#[cfg(not(target_arch = "wasm32"))]
let client = FetchClient::from_reqwest(tracker_reqwest_client(tracker_url).await);
#[cfg(target_arch = "wasm32")]
let client = {
let _ = tracker_url;
FetchClient::transport(TRACKER_REQUEST_TIMEOUT).with_max_body(MAX_TRACKER_BODY_BYTES)
};
client
}
pub fn resolve_tracker_url(remote_addr: &str) -> String {
let trimmed = remote_addr.trim();
if trimmed.contains("://") {
return trimmed.to_string();
}
if trimmed.contains('/') {
format!("http://{trimmed}")
} else {
format!("http://{trimmed}/announce")
}
}
async fn fetch_tracker_body(tracker_url: &str, url: &str) -> Result<Vec<u8>> {
let mut response = tracker_http_client(tracker_url)
.await
.get(url)
.send()
.await?;
if let Some(len) = response.content_length() {
if len > MAX_TRACKER_BODY_BYTES as u64 {
return Err(anyhow::anyhow!(
"tracker declared a {} byte reply, over the {} byte limit",
len,
MAX_TRACKER_BODY_BYTES
));
}
}
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await? {
if body.len() + chunk.len() > MAX_TRACKER_BODY_BYTES {
return Err(anyhow::anyhow!(
"tracker reply exceeded the {} byte limit",
MAX_TRACKER_BODY_BYTES
));
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
fn percent_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 3);
for byte in bytes {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*byte as char)
}
other => out.push_str(&format!("%{:02X}", other)),
}
}
out
}
fn percent_decode(value: &str) -> Vec<u8> {
let bytes = value.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
out
}
fn encode_binary_query_value(value: &str) -> String {
let bytes = if value.len() == 40 && value.bytes().all(|b| b.is_ascii_hexdigit()) {
hex::decode(value).unwrap_or_else(|_| value.as_bytes().to_vec())
} else if value.contains('%') {
percent_decode(value)
} else {
value.as_bytes().to_vec()
};
percent_encode(&bytes)
}
fn screen_tracker_body(body: &[u8]) -> Result<()> {
crate::utils::bencode::check_bencode_structure(body)
.map_err(|e| anyhow::anyhow!("tracker reply refused before decoding: {}", e))
}
pub struct TorrentTrackerClient;
impl TorrentTrackerClient {
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 remote_addr = resolve_tracker_url(&remote_addr);
crate::client::http_fetch::check_url(&remote_addr)?;
info!(
"BitTorrent Tracker client {} initialized for {}",
client_id, remote_addr
);
app_state
.update_client_status(client_id, ClientStatus::Connected)
.await;
let _ = status_tx.send(format!(
"[CLIENT] BitTorrent Tracker client {} connected to {}",
client_id, remote_addr
));
let _ = status_tx.send("__UPDATE_UI__".to_string());
let command_rx =
crate::client::command_support::register_command_channel(&app_state, client_id).await;
let cmd_task = tokio::spawn(Self::command_loop(
command_rx,
client_id,
remote_addr.clone(),
app_state.clone(),
llm_client.clone(),
status_tx.clone(),
));
app_state.register_client_task(client_id, cmd_task).await;
if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
let protocol = Arc::new(
crate::client::torrent_tracker::actions::TorrentTrackerClientProtocol::new(),
);
let event = Event::new(
&TRACKER_ANNOUNCE_RESPONSE_EVENT,
serde_json::json!({
"tracker_url": remote_addr,
"status": "connected",
}),
);
let memory = app_state
.get_memory_for_client(client_id)
.await
.unwrap_or_default();
let app_state_clone = app_state.clone();
let status_tx_clone = status_tx.clone();
let task_registrar = app_state.clone();
let task_handle = tokio::spawn(async move {
match call_llm_for_client(
&llm_client,
&app_state_clone,
client_id.to_string(),
&instruction,
&memory,
Some(&event),
protocol.as_ref(),
&status_tx_clone,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
app_state_clone.set_memory_for_client(client_id, mem).await;
}
for action in actions {
match protocol.as_ref().execute_action(action.clone()) {
Ok(result) => {
if let Err(e) = Self::apply_action(
client_id,
result,
Notify::Inline,
0,
&remote_addr,
&app_state_clone,
&llm_client,
&status_tx_clone,
)
.await
{
error!("Failed to execute tracker action: {}", e);
}
}
Err(e) => {
error!("Tracker action execution error: {}", e);
}
}
}
}
Err(e) => {
error!("LLM error for Tracker client {}: {}", client_id, e);
}
}
});
task_registrar
.register_client_task(client_id, task_handle)
.await;
}
Ok("0.0.0.0:0".parse()?)
}
async fn command_loop(
mut command_rx: tokio::sync::mpsc::Receiver<ClientCommand>,
client_id: ClientId,
tracker_url: String,
app_state: Arc<AppState>,
llm_client: OllamaClient,
status_tx: mpsc::UnboundedSender<String>,
) {
let protocol = crate::client::torrent_tracker::actions::TorrentTrackerClientProtocol::new();
while let Some(command) = command_rx.recv().await {
let action = command.action.clone();
let outcome = match protocol.execute_action(action.clone()) {
Err(e) => Ok(ClientSendOutcome::Rejected {
error: e.to_string(),
}),
Ok(result) => Self::apply_action(
client_id,
result,
Notify::Deferred,
0,
&tracker_url,
&app_state,
&llm_client,
&status_tx,
)
.await
.map(|applied| match applied {
Applied::Disconnect => ClientSendOutcome::Disconnected,
Applied::Executed(detail) => ClientSendOutcome::Executed { detail },
}),
};
let outcome_json = match &outcome {
Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
Err(e) => serde_json::json!({"error": e.to_string()}),
};
app_state
.record_access_log(
AccessLogOwner::Client(client_id.as_u32()),
protocol.protocol_name(),
None,
"injected_action",
action,
vec![outcome_json],
)
.await;
let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
if let Err(e) = &outcome {
error!("Tracker client {} injected action failed: {}", client_id, e);
let _ = status_tx.send(format!(
"[WARN] Client {} injected action failed: {}",
client_id, e
));
}
let _ = status_tx.send("__UPDATE_UI__".to_string());
crate::client::command_support::reply(command, outcome);
if disconnect {
break;
}
}
info!("Tracker client {} command loop stopped", client_id);
app_state.remove_client_handle(client_id).await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
async fn deliver(
notify: Notify,
client_id: ClientId,
event_type: &'static crate::protocol::EventType,
event_data: serde_json::Value,
depth: u8,
tracker_url: &str,
app_state: &Arc<AppState>,
llm_client: &OllamaClient,
status_tx: &mpsc::UnboundedSender<String>,
) {
match notify {
Notify::Inline => {
Self::notify_response(
client_id,
event_type,
event_data,
depth,
tracker_url.to_string(),
app_state,
llm_client,
status_tx,
)
.await
}
Notify::Deferred => {
let state_clone = app_state.clone();
let llm_clone = llm_client.clone();
let status_clone = status_tx.clone();
let tracker_url = tracker_url.to_string();
let notify_handle = tokio::spawn(async move {
TorrentTrackerClient::notify_response(
client_id,
event_type,
event_data,
depth,
tracker_url,
&state_clone,
&llm_clone,
&status_clone,
)
.await;
});
app_state
.register_client_task(client_id, notify_handle)
.await;
}
}
}
#[allow(clippy::too_many_arguments)]
async fn notify_response(
client_id: ClientId,
event_type: &'static crate::protocol::EventType,
event_data: serde_json::Value,
depth: u8,
tracker_url: String,
app_state: &Arc<AppState>,
llm_client: &OllamaClient,
status_tx: &mpsc::UnboundedSender<String>,
) {
let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
return;
};
let event = Event::new(event_type, event_data);
let memory = app_state
.get_memory_for_client(client_id)
.await
.unwrap_or_default();
let protocol =
Arc::new(crate::client::torrent_tracker::actions::TorrentTrackerClientProtocol::new());
match call_llm_for_client(
llm_client,
app_state,
client_id.to_string(),
&instruction,
&memory,
Some(&event),
protocol.as_ref(),
status_tx,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
app_state.set_memory_for_client(client_id, mem).await;
}
if actions.is_empty() {
return;
}
if depth >= MAX_FOLLOWUP_DEPTH {
warn!(
"Tracker client {} stopped a follow-up chain at depth {}: {} action(s) \
not executed",
client_id,
depth,
actions.len()
);
let _ = status_tx.send(format!(
"[CLIENT] âš tracker client {} hit the follow-up depth limit ({}); \
{} action(s) were not executed",
client_id,
MAX_FOLLOWUP_DEPTH,
actions.len()
));
return;
}
for action in actions {
match protocol.as_ref().execute_action(action) {
Ok(result) => {
match Self::apply_action(
client_id,
result,
Notify::Inline,
depth + 1,
&tracker_url,
app_state,
llm_client,
status_tx,
)
.await
{
Ok(Applied::Disconnect) => {
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
return;
}
Ok(Applied::Executed(detail)) => {
trace!("Tracker client {} follow-up: {}", client_id, detail);
}
Err(e) => {
error!("Tracker client {} follow-up failed: {}", client_id, e);
}
}
}
Err(e) => {
error!(
"Tracker client {} rejected follow-up action: {}",
client_id, e
);
}
}
}
}
Err(e) => {
error!("LLM error: {}", e);
}
}
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
fn apply_action<'a>(
client_id: ClientId,
result: ClientActionResult,
notify: Notify,
depth: u8,
tracker_url: &'a str,
app_state: &'a Arc<AppState>,
llm_client: &'a OllamaClient,
status_tx: &'a mpsc::UnboundedSender<String>,
) -> Pin<Box<dyn Future<Output = Result<Applied>> + Send + 'a>> {
Box::pin(async move {
match result {
ClientActionResult::Custom { name, data } if name == "tracker_announce" => {
let info_hash = data
.get("info_hash")
.and_then(|v| v.as_str())
.context("Missing info_hash")?;
let peer_id = data
.get("peer_id")
.and_then(|v| v.as_str())
.context("Missing peer_id")?;
let port = data
.get("port")
.and_then(|v| v.as_u64())
.context("Missing port")?;
let port = u16::try_from(port).context("tracker port exceeds u16")?;
let uploaded = data.get("uploaded").and_then(|v| v.as_u64()).unwrap_or(0);
let downloaded = data.get("downloaded").and_then(|v| v.as_u64()).unwrap_or(0);
let left = data
.get("left")
.and_then(|v| v.as_u64())
.unwrap_or(u64::MAX);
let event_type = data
.get("event")
.and_then(|v| v.as_str())
.unwrap_or("started");
let announce_url = format!(
"{}?info_hash={}&peer_id={}&port={}&uploaded={}&downloaded={}&left={}&event={}",
tracker_url,
encode_binary_query_value(info_hash),
encode_binary_query_value(peer_id),
port,
uploaded,
downloaded,
left,
percent_encode(event_type.as_bytes())
);
trace!(
"Tracker client {} announcing to: {}",
client_id,
announce_url
);
let body = fetch_tracker_body(tracker_url, &announce_url).await?;
if let Err(e) = screen_tracker_body(&body) {
warn!("Tracker client {} refused announce reply: {}", client_id, e);
return Ok(Applied::Executed(format!(
"tracker_announce sent; the tracker's reply was refused: {e}"
)));
}
match serde_bencode::from_bytes::<TrackerResponse>(&body) {
Ok(tracker_resp) => {
trace!("Tracker response: {:?}", tracker_resp);
Self::deliver(
notify,
client_id,
&TRACKER_ANNOUNCE_RESPONSE_EVENT,
serde_json::json!({
"interval": tracker_resp.interval,
"complete": tracker_resp.complete,
"incomplete": tracker_resp.incomplete,
"peers": format!("{:?}", tracker_resp.peers),
}),
depth,
tracker_url,
app_state,
llm_client,
status_tx,
)
.await;
}
Err(e) => {
error!("Failed to parse tracker response: {}", e);
return Ok(Applied::Executed(format!(
"tracker_announce sent; the tracker's reply could not be \
bdecoded: {e}"
)));
}
}
Ok(Applied::Executed("tracker_announce completed".to_string()))
}
ClientActionResult::Custom { name, data } if name == "tracker_scrape" => {
let info_hash = data
.get("info_hash")
.and_then(|v| v.as_str())
.context("Missing info_hash")?;
let scrape_url = format!(
"{}?info_hash={}",
tracker_url,
encode_binary_query_value(info_hash)
);
trace!("Tracker client {} scraping: {}", client_id, scrape_url);
let body = fetch_tracker_body(tracker_url, &scrape_url).await?;
if let Err(e) = screen_tracker_body(&body) {
warn!("Tracker client {} refused scrape reply: {}", client_id, e);
return Ok(Applied::Executed(format!(
"tracker_scrape sent; the tracker's reply was refused: {e}"
)));
}
match serde_bencode::from_bytes::<ScrapeResponse>(&body) {
Ok(scrape_resp) => {
trace!("Scrape response: {:?}", scrape_resp);
Self::deliver(
notify,
client_id,
&TRACKER_SCRAPE_RESPONSE_EVENT,
serde_json::json!({
"files": format!("{:?}", scrape_resp.files),
}),
depth,
tracker_url,
app_state,
llm_client,
status_tx,
)
.await;
}
Err(e) => {
error!("Failed to parse scrape response: {}", e);
return Ok(Applied::Executed(format!(
"tracker_scrape sent; the tracker's reply could not be \
bdecoded: {e}"
)));
}
}
Ok(Applied::Executed("tracker_scrape completed".to_string()))
}
ClientActionResult::Disconnect => {
info!("Tracker client {} disconnecting", client_id);
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
app_state.remove_client_handle(client_id).await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
Ok(Applied::Disconnect)
}
other => Ok(Applied::Executed(format!(
"{other:?} produced no tracker request"
))),
}
})
}
}
#[derive(Clone, Copy)]
enum Notify {
Inline,
Deferred,
}
enum Applied {
Executed(String),
Disconnect,
}