pub mod actions;
pub use actions::RssClientProtocol;
use crate::client::llm_budget::call_llm_for_client;
use crate::client::rss::actions::{RSS_CLIENT_CONNECTED_EVENT, RSS_CLIENT_FEED_FETCHED_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};
use anyhow::{Context, Result};
use rss::Channel;
use serde_json::json;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, Mutex};
use tracing::{debug, error, info, warn};
const MAX_CHAINED_FETCHES: usize = 16;
const REMOVAL_CHECK_INTERVAL: Duration = Duration::from_secs(5);
struct ClientData {
memory: String,
}
struct FetchCtx {
base_url: String,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
client_id: ClientId,
instruction: String,
client_data: Arc<Mutex<ClientData>>,
protocol: Arc<RssClientProtocol>,
}
enum Applied {
Fetch(String),
Disconnect,
WaitForMore,
Ignored(String),
}
enum ChainEnd {
Drained,
Terminated,
}
enum Notified {
Queue(Vec<String>),
Disconnect,
}
pub fn resolve_feed_url(base_url: &str, url: &str) -> String {
if url.starts_with("http://") || url.starts_with("https://") {
url.to_string()
} else if let Some(path) = url.strip_prefix('/') {
format!("{}/{}", base_url.trim_end_matches('/'), path)
} else {
format!("{}/{}", base_url.trim_end_matches('/'), url)
}
}
pub fn channel_to_event_data(url: &str, channel: &Channel) -> serde_json::Value {
let items: Vec<_> = channel
.items()
.iter()
.map(|item| {
json!({
"title": item.title(),
"link": item.link(),
"description": item.description(),
"author": item.author(),
"pub_date": item.pub_date(),
"guid": item.guid().map(|g| g.value()),
})
})
.collect();
json!({
"url": url,
"feed_title": channel.title(),
"feed_link": channel.link(),
"feed_description": channel.description(),
"item_count": channel.items().len(),
"items": items,
})
}
pub struct RssClient;
impl RssClient {
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 resolved: SocketAddr = tokio::net::lookup_host(remote_addr.as_str())
.await
.with_context(|| format!("Failed to resolve RSS feed host: {}", remote_addr))?
.next()
.with_context(|| format!("No addresses for RSS feed host: {}", remote_addr))?;
let base_url = format!("http://{}", remote_addr);
info!(
"RSS client {} targeting {} ({})",
client_id, base_url, resolved
);
app_state
.update_client_status(client_id, ClientStatus::Connected)
.await;
let _ = status_tx.send(format!(
"[CLIENT] RSS client {} ready ({})",
client_id, base_url
));
let _ = status_tx.send("__UPDATE_UI__".to_string());
let client_data = Arc::new(Mutex::new(ClientData {
memory: String::new(),
}));
let protocol = Arc::new(RssClientProtocol::new());
let instruction = app_state
.get_instruction_for_client(client_id)
.await
.unwrap_or_default();
let ctx = Arc::new(FetchCtx {
base_url: base_url.clone(),
llm_client: llm_client.clone(),
app_state: app_state.clone(),
status_tx: status_tx.clone(),
client_id,
instruction: instruction.clone(),
client_data: client_data.clone(),
protocol: protocol.clone(),
});
let command_rx =
crate::client::command_support::register_command_channel(&app_state, client_id).await;
let cmd_ctx = ctx.clone();
let cmd_task = tokio::spawn(async move {
Self::command_loop(command_rx, cmd_ctx).await;
});
app_state.register_client_task(client_id, cmd_task).await;
let event = Event::new(
&RSS_CLIENT_CONNECTED_EVENT,
json!({ "base_url": base_url.clone() }),
);
let memory_snapshot = client_data.lock().await.memory.clone();
let first = call_llm_for_client(
&llm_client,
&app_state,
client_id.to_string(),
&instruction,
&memory_snapshot,
Some(&event),
protocol.as_ref() as &dyn Client,
&status_tx,
)
.await;
let mut pending: Vec<String> = Vec::new();
match first {
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
client_data.lock().await.memory = mem;
}
for action in actions {
match protocol.execute_action(action) {
Ok(action_result) => {
match Self::apply_action(action_result, &base_url) {
Applied::Fetch(url) => pending.push(url),
Applied::Disconnect => {
info!(
"RSS client {} disconnecting on model request",
client_id
);
app_state.remove_client_handle(client_id).await;
app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
return Ok(resolved);
}
Applied::WaitForMore => {}
Applied::Ignored(detail) => {
debug!("RSS client {}: {}", client_id, detail)
}
}
}
Err(e) => warn!("RSS client {} rejected action: {}", client_id, e),
}
}
}
Err(e) => {
error!("RSS client {} LLM error on connect: {}", client_id, e);
let _ = status_tx.send(format!("[CLIENT] RSS LLM error on connect: {}", e));
}
}
if pending.is_empty() {
debug!("RSS client {} idle: model requested no fetch", client_id);
return Ok(resolved);
}
let handle = tokio::spawn(Self::fetch_loop(pending, ctx));
app_state.register_client_task(client_id, handle).await;
Ok(resolved)
}
async fn command_loop(mut command_rx: mpsc::Receiver<ClientCommand>, ctx: Arc<FetchCtx>) {
let mut removal_check = tokio::time::interval(REMOVAL_CHECK_INTERVAL);
removal_check.tick().await;
loop {
tokio::select! {
received = command_rx.recv() => {
let Some(command) = received else { break };
if Self::handle_command(command, &ctx).await {
break;
}
}
_ = removal_check.tick() => {
if ctx.app_state.get_client(ctx.client_id).await.is_none() {
info!("RSS client {} stopped", ctx.client_id);
break;
}
}
}
}
ctx.app_state.remove_client_handle(ctx.client_id).await;
let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
}
async fn handle_command(command: ClientCommand, ctx: &Arc<FetchCtx>) -> bool {
let client_id = ctx.client_id;
let action = command.action.clone();
let outcome = match ctx.protocol.execute_action(action.clone()) {
Err(e) => Ok(ClientSendOutcome::Rejected {
error: e.to_string(),
}),
Ok(action_result) => match Self::apply_action(action_result, &ctx.base_url) {
Applied::Fetch(url) => match Self::fetch_feed(&url).await {
Ok(event_data) => {
let item_count = event_data
.get("item_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let chain_ctx = ctx.clone();
let chain = tokio::spawn(async move {
Self::injected_chain(event_data, chain_ctx).await;
});
ctx.app_state.register_client_task(client_id, chain).await;
Ok(ClientSendOutcome::Executed {
detail: format!("fetch_rss_feed {} -> {} items", url, item_count),
})
}
Err(e) => Err(e),
},
Applied::Disconnect => Ok(ClientSendOutcome::Disconnected),
Applied::WaitForMore => Ok(ClientSendOutcome::Executed {
detail: "wait_for_more".to_string(),
}),
Applied::Ignored(detail) => Ok(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()}),
};
ctx.app_state
.record_access_log(
AccessLogOwner::Client(client_id.as_u32()),
ctx.protocol.protocol_name(),
None,
"injected_action",
action,
vec![outcome_json],
)
.await;
let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
if let Err(e) = &outcome {
error!("RSS client {} injected action failed: {}", client_id, e);
let _ = ctx.status_tx.send(format!(
"[WARN] Client {} injected action failed: {}",
client_id, e
));
}
let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
crate::client::command_support::reply(command, outcome);
if disconnect {
ctx.app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
}
disconnect
}
fn apply_action(action_result: ClientActionResult, base_url: &str) -> Applied {
match action_result {
ClientActionResult::Custom { name, data } if name == "fetch_rss_feed" => {
match data.get("url").and_then(|v| v.as_str()) {
Some(url) => Applied::Fetch(resolve_feed_url(base_url, url)),
None => Applied::Ignored("fetch_rss_feed without a url".to_string()),
}
}
ClientActionResult::Disconnect => Applied::Disconnect,
ClientActionResult::WaitForMore => Applied::WaitForMore,
ClientActionResult::NoAction => Applied::Ignored("no_action".to_string()),
ClientActionResult::Custom { name, .. } => Applied::Ignored(format!(
"custom result '{name}' is not handled by the RSS client"
)),
ClientActionResult::SendData(_) => {
Applied::Ignored("send_data has no meaning for an RSS client".to_string())
}
ClientActionResult::Multiple(_) => {
Applied::Ignored("multiple results are not produced by the RSS client".to_string())
}
}
}
async fn fetch_loop(pending: Vec<String>, ctx: Arc<FetchCtx>) {
if let ChainEnd::Drained = Self::fetch_chain(pending, ctx.clone(), 0).await {
ctx.app_state
.update_client_status(ctx.client_id, ClientStatus::Disconnected)
.await;
let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
}
}
async fn injected_chain(event_data: serde_json::Value, ctx: Arc<FetchCtx>) {
match Self::notify_feed_fetched(event_data, &ctx).await {
Notified::Queue(next) if !next.is_empty() => {
let _ = Self::fetch_chain(next, ctx, 1).await;
}
Notified::Queue(_) => {}
Notified::Disconnect => {
info!("RSS client {} disconnecting", ctx.client_id);
ctx.app_state.remove_client_handle(ctx.client_id).await;
ctx.app_state
.update_client_status(ctx.client_id, ClientStatus::Disconnected)
.await;
let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
}
}
}
async fn fetch_chain(
mut pending: Vec<String>,
ctx: Arc<FetchCtx>,
mut fetched: usize,
) -> ChainEnd {
let client_id = ctx.client_id;
while let Some(url) = pending.pop() {
if fetched >= MAX_CHAINED_FETCHES {
warn!(
"RSS client {} hit the {}-fetch chain limit",
client_id, MAX_CHAINED_FETCHES
);
break;
}
fetched += 1;
let event_data = match Self::fetch_feed(&url).await {
Ok(data) => data,
Err(e) => {
error!("RSS client {} fetch of {} failed: {}", client_id, url, e);
let _ = ctx
.status_tx
.send(format!("[CLIENT] RSS fetch failed: {}", e));
ctx.app_state
.update_client_status(client_id, ClientStatus::Error(e.to_string()))
.await;
let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
return ChainEnd::Terminated;
}
};
let item_count = event_data
.get("item_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
info!(
"RSS client {} parsed {} ({} items)",
client_id, url, item_count
);
let _ = ctx
.status_tx
.send(format!("[CLIENT] RSS parsed feed: {} items", item_count));
match Self::notify_feed_fetched(event_data, &ctx).await {
Notified::Queue(next) => pending.extend(next),
Notified::Disconnect => {
info!("RSS client {} disconnecting", client_id);
ctx.app_state
.update_client_status(client_id, ClientStatus::Disconnected)
.await;
let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
return ChainEnd::Terminated;
}
}
}
ChainEnd::Drained
}
async fn notify_feed_fetched(event_data: serde_json::Value, ctx: &Arc<FetchCtx>) -> Notified {
let client_id = ctx.client_id;
let event = Event::new(&RSS_CLIENT_FEED_FETCHED_EVENT, event_data);
let memory_snapshot = ctx.client_data.lock().await.memory.clone();
match call_llm_for_client(
&ctx.llm_client,
&ctx.app_state,
client_id.to_string(),
&ctx.instruction,
&memory_snapshot,
Some(&event),
ctx.protocol.as_ref() as &dyn Client,
&ctx.status_tx,
)
.await
{
Ok(ClientLlmResult {
actions,
memory_updates,
}) => {
if let Some(mem) = memory_updates {
ctx.client_data.lock().await.memory = mem;
}
let mut next = Vec::new();
for action in actions {
match ctx.protocol.execute_action(action) {
Ok(action_result) => {
match Self::apply_action(action_result, &ctx.base_url) {
Applied::Fetch(url) => next.push(url),
Applied::Disconnect => return Notified::Disconnect,
Applied::WaitForMore => {}
Applied::Ignored(detail) => {
debug!("RSS client {}: {}", client_id, detail)
}
}
}
Err(e) => warn!("RSS client {} rejected action: {}", client_id, e),
}
}
Notified::Queue(next)
}
Err(e) => {
error!("RSS client {} LLM error: {}", client_id, e);
Notified::Queue(Vec::new())
}
}
}
pub async fn fetch_feed(url: &str) -> Result<serde_json::Value> {
let response = reqwest::get(url)
.await
.with_context(|| format!("Failed to fetch RSS feed {}", url))?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"RSS fetch of {} failed with status {}",
url,
response.status()
));
}
let body = response
.text()
.await
.context("Failed to read RSS response body")?;
let channel = Channel::read_from(body.as_bytes())
.with_context(|| format!("Failed to parse RSS feed at {}", url))?;
Ok(channel_to_event_data(url, &channel))
}
}