1pub mod actions;
7
8pub use actions::RssClientProtocol;
9
10use crate::client::llm_budget::call_llm_for_client;
11use crate::client::rss::actions::{RSS_CLIENT_CONNECTED_EVENT, RSS_CLIENT_FEED_FETCHED_EVENT};
12use crate::llm::actions::client_trait::{Client, ClientActionResult};
13use crate::llm::actions::protocol_trait::Protocol;
14use crate::llm::ollama_client::OllamaClient;
15use crate::llm::ClientLlmResult;
16use crate::protocol::Event;
17use crate::state::app_state::AppState;
18use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
19use crate::state::{AccessLogOwner, ClientId, ClientStatus};
20use anyhow::{Context, Result};
21use rss::Channel;
22use serde_json::json;
23use std::net::SocketAddr;
24use std::sync::Arc;
25use std::time::Duration;
26use tokio::sync::{mpsc, Mutex};
27use tracing::{debug, error, info, warn};
28
29const MAX_CHAINED_FETCHES: usize = 16;
31
32const REMOVAL_CHECK_INTERVAL: Duration = Duration::from_secs(5);
35
36struct ClientData {
37 memory: String,
38}
39
40struct FetchCtx {
46 base_url: String,
47 llm_client: OllamaClient,
48 app_state: Arc<AppState>,
49 status_tx: mpsc::UnboundedSender<String>,
50 client_id: ClientId,
51 instruction: String,
52 client_data: Arc<Mutex<ClientData>>,
53 protocol: Arc<RssClientProtocol>,
54}
55
56enum Applied {
58 Fetch(String),
60 Disconnect,
62 WaitForMore,
64 Ignored(String),
66}
67
68enum ChainEnd {
70 Drained,
72 Terminated,
74}
75
76enum Notified {
78 Queue(Vec<String>),
80 Disconnect,
82}
83
84pub fn resolve_feed_url(base_url: &str, url: &str) -> String {
89 if url.starts_with("http://") || url.starts_with("https://") {
90 url.to_string()
91 } else if let Some(path) = url.strip_prefix('/') {
92 format!("{}/{}", base_url.trim_end_matches('/'), path)
93 } else {
94 format!("{}/{}", base_url.trim_end_matches('/'), url)
95 }
96}
97
98pub fn channel_to_event_data(url: &str, channel: &Channel) -> serde_json::Value {
102 let items: Vec<_> = channel
103 .items()
104 .iter()
105 .map(|item| {
106 json!({
107 "title": item.title(),
108 "link": item.link(),
109 "description": item.description(),
110 "author": item.author(),
111 "pub_date": item.pub_date(),
112 "guid": item.guid().map(|g| g.value()),
113 })
114 })
115 .collect();
116
117 json!({
118 "url": url,
119 "feed_title": channel.title(),
120 "feed_link": channel.link(),
121 "feed_description": channel.description(),
122 "item_count": channel.items().len(),
123 "items": items,
124 })
125}
126
127pub struct RssClient;
129
130impl RssClient {
131 pub async fn connect_with_llm_actions(
133 remote_addr: String,
134 llm_client: OllamaClient,
135 app_state: Arc<AppState>,
136 status_tx: mpsc::UnboundedSender<String>,
137 client_id: ClientId,
138 ) -> Result<SocketAddr> {
139 let resolved: SocketAddr = tokio::net::lookup_host(remote_addr.as_str())
142 .await
143 .with_context(|| format!("Failed to resolve RSS feed host: {}", remote_addr))?
144 .next()
145 .with_context(|| format!("No addresses for RSS feed host: {}", remote_addr))?;
146
147 let base_url = format!("http://{}", remote_addr);
148
149 info!(
150 "RSS client {} targeting {} ({})",
151 client_id, base_url, resolved
152 );
153 app_state
154 .update_client_status(client_id, ClientStatus::Connected)
155 .await;
156 let _ = status_tx.send(format!(
157 "[CLIENT] RSS client {} ready ({})",
158 client_id, base_url
159 ));
160 let _ = status_tx.send("__UPDATE_UI__".to_string());
161
162 let client_data = Arc::new(Mutex::new(ClientData {
163 memory: String::new(),
164 }));
165 let protocol = Arc::new(RssClientProtocol::new());
166 let instruction = app_state
167 .get_instruction_for_client(client_id)
168 .await
169 .unwrap_or_default();
170
171 let ctx = Arc::new(FetchCtx {
172 base_url: base_url.clone(),
173 llm_client: llm_client.clone(),
174 app_state: app_state.clone(),
175 status_tx: status_tx.clone(),
176 client_id,
177 instruction: instruction.clone(),
178 client_data: client_data.clone(),
179 protocol: protocol.clone(),
180 });
181
182 let command_rx =
187 crate::client::command_support::register_command_channel(&app_state, client_id).await;
188 let cmd_ctx = ctx.clone();
189 let cmd_task = tokio::spawn(async move {
190 Self::command_loop(command_rx, cmd_ctx).await;
191 });
192 app_state.register_client_task(client_id, cmd_task).await;
193
194 let event = Event::new(
196 &RSS_CLIENT_CONNECTED_EVENT,
197 json!({ "base_url": base_url.clone() }),
198 );
199
200 let memory_snapshot = client_data.lock().await.memory.clone();
201 let first = call_llm_for_client(
202 &llm_client,
203 &app_state,
204 client_id.to_string(),
205 &instruction,
206 &memory_snapshot,
207 Some(&event),
208 protocol.as_ref() as &dyn Client,
209 &status_tx,
210 )
211 .await;
212
213 let mut pending: Vec<String> = Vec::new();
214 match first {
215 Ok(ClientLlmResult {
216 actions,
217 memory_updates,
218 }) => {
219 if let Some(mem) = memory_updates {
220 client_data.lock().await.memory = mem;
221 }
222 for action in actions {
223 match protocol.execute_action(action) {
224 Ok(action_result) => {
225 match Self::apply_action(action_result, &base_url) {
226 Applied::Fetch(url) => pending.push(url),
227 Applied::Disconnect => {
228 info!(
229 "RSS client {} disconnecting on model request",
230 client_id
231 );
232 app_state.remove_client_handle(client_id).await;
235 app_state
236 .update_client_status(client_id, ClientStatus::Disconnected)
237 .await;
238 let _ = status_tx.send("__UPDATE_UI__".to_string());
239 return Ok(resolved);
240 }
241 Applied::WaitForMore => {}
242 Applied::Ignored(detail) => {
243 debug!("RSS client {}: {}", client_id, detail)
244 }
245 }
246 }
247 Err(e) => warn!("RSS client {} rejected action: {}", client_id, e),
248 }
249 }
250 }
251 Err(e) => {
252 error!("RSS client {} LLM error on connect: {}", client_id, e);
253 let _ = status_tx.send(format!("[CLIENT] RSS LLM error on connect: {}", e));
254 }
255 }
256
257 if pending.is_empty() {
258 debug!("RSS client {} idle: model requested no fetch", client_id);
259 return Ok(resolved);
260 }
261
262 let handle = tokio::spawn(Self::fetch_loop(pending, ctx));
263 app_state.register_client_task(client_id, handle).await;
264
265 Ok(resolved)
266 }
267
268 async fn command_loop(mut command_rx: mpsc::Receiver<ClientCommand>, ctx: Arc<FetchCtx>) {
277 let mut removal_check = tokio::time::interval(REMOVAL_CHECK_INTERVAL);
278 removal_check.tick().await; loop {
281 tokio::select! {
282 received = command_rx.recv() => {
283 let Some(command) = received else { break };
284 if Self::handle_command(command, &ctx).await {
285 break;
286 }
287 }
288 _ = removal_check.tick() => {
289 if ctx.app_state.get_client(ctx.client_id).await.is_none() {
290 info!("RSS client {} stopped", ctx.client_id);
291 break;
292 }
293 }
294 }
295 }
296
297 ctx.app_state.remove_client_handle(ctx.client_id).await;
298 let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
299 }
300
301 async fn handle_command(command: ClientCommand, ctx: &Arc<FetchCtx>) -> bool {
304 let client_id = ctx.client_id;
305 let action = command.action.clone();
306 let outcome = match ctx.protocol.execute_action(action.clone()) {
307 Err(e) => Ok(ClientSendOutcome::Rejected {
308 error: e.to_string(),
309 }),
310 Ok(action_result) => match Self::apply_action(action_result, &ctx.base_url) {
311 Applied::Fetch(url) => match Self::fetch_feed(&url).await {
312 Ok(event_data) => {
316 let item_count = event_data
317 .get("item_count")
318 .and_then(|v| v.as_u64())
319 .unwrap_or(0);
320 let chain_ctx = ctx.clone();
324 let chain = tokio::spawn(async move {
325 Self::injected_chain(event_data, chain_ctx).await;
326 });
327 ctx.app_state.register_client_task(client_id, chain).await;
328 Ok(ClientSendOutcome::Executed {
329 detail: format!("fetch_rss_feed {} -> {} items", url, item_count),
330 })
331 }
332 Err(e) => Err(e),
333 },
334 Applied::Disconnect => Ok(ClientSendOutcome::Disconnected),
335 Applied::WaitForMore => Ok(ClientSendOutcome::Executed {
336 detail: "wait_for_more".to_string(),
337 }),
338 Applied::Ignored(detail) => Ok(ClientSendOutcome::Executed { detail }),
339 },
340 };
341
342 let outcome_json = match &outcome {
343 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
344 Err(e) => serde_json::json!({"error": e.to_string()}),
345 };
346 ctx.app_state
347 .record_access_log(
348 AccessLogOwner::Client(client_id.as_u32()),
349 ctx.protocol.protocol_name(),
350 None,
351 "injected_action",
352 action,
353 vec![outcome_json],
354 )
355 .await;
356
357 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
358 if let Err(e) = &outcome {
359 error!("RSS client {} injected action failed: {}", client_id, e);
360 let _ = ctx.status_tx.send(format!(
361 "[WARN] Client {} injected action failed: {}",
362 client_id, e
363 ));
364 }
365 let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
366 crate::client::command_support::reply(command, outcome);
367
368 if disconnect {
369 ctx.app_state
370 .update_client_status(client_id, ClientStatus::Disconnected)
371 .await;
372 }
373 disconnect
374 }
375
376 fn apply_action(action_result: ClientActionResult, base_url: &str) -> Applied {
379 match action_result {
380 ClientActionResult::Custom { name, data } if name == "fetch_rss_feed" => {
381 match data.get("url").and_then(|v| v.as_str()) {
382 Some(url) => Applied::Fetch(resolve_feed_url(base_url, url)),
383 None => Applied::Ignored("fetch_rss_feed without a url".to_string()),
384 }
385 }
386 ClientActionResult::Disconnect => Applied::Disconnect,
387 ClientActionResult::WaitForMore => Applied::WaitForMore,
388 ClientActionResult::NoAction => Applied::Ignored("no_action".to_string()),
389 ClientActionResult::Custom { name, .. } => Applied::Ignored(format!(
392 "custom result '{name}' is not handled by the RSS client"
393 )),
394 ClientActionResult::SendData(_) => {
395 Applied::Ignored("send_data has no meaning for an RSS client".to_string())
396 }
397 ClientActionResult::Multiple(_) => {
398 Applied::Ignored("multiple results are not produced by the RSS client".to_string())
399 }
400 }
401 }
402
403 async fn fetch_loop(pending: Vec<String>, ctx: Arc<FetchCtx>) {
406 if let ChainEnd::Drained = Self::fetch_chain(pending, ctx.clone(), 0).await {
407 ctx.app_state
408 .update_client_status(ctx.client_id, ClientStatus::Disconnected)
409 .await;
410 let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
411 }
412 }
413
414 async fn injected_chain(event_data: serde_json::Value, ctx: Arc<FetchCtx>) {
421 match Self::notify_feed_fetched(event_data, &ctx).await {
422 Notified::Queue(next) if !next.is_empty() => {
423 let _ = Self::fetch_chain(next, ctx, 1).await;
424 }
425 Notified::Queue(_) => {}
426 Notified::Disconnect => {
427 info!("RSS client {} disconnecting", ctx.client_id);
428 ctx.app_state.remove_client_handle(ctx.client_id).await;
429 ctx.app_state
430 .update_client_status(ctx.client_id, ClientStatus::Disconnected)
431 .await;
432 let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
433 }
434 }
435 }
436
437 async fn fetch_chain(
440 mut pending: Vec<String>,
441 ctx: Arc<FetchCtx>,
442 mut fetched: usize,
443 ) -> ChainEnd {
444 let client_id = ctx.client_id;
445
446 while let Some(url) = pending.pop() {
447 if fetched >= MAX_CHAINED_FETCHES {
448 warn!(
449 "RSS client {} hit the {}-fetch chain limit",
450 client_id, MAX_CHAINED_FETCHES
451 );
452 break;
453 }
454 fetched += 1;
455
456 let event_data = match Self::fetch_feed(&url).await {
457 Ok(data) => data,
458 Err(e) => {
459 error!("RSS client {} fetch of {} failed: {}", client_id, url, e);
460 let _ = ctx
461 .status_tx
462 .send(format!("[CLIENT] RSS fetch failed: {}", e));
463 ctx.app_state
464 .update_client_status(client_id, ClientStatus::Error(e.to_string()))
465 .await;
466 let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
467 return ChainEnd::Terminated;
468 }
469 };
470
471 let item_count = event_data
472 .get("item_count")
473 .and_then(|v| v.as_u64())
474 .unwrap_or(0);
475 info!(
476 "RSS client {} parsed {} ({} items)",
477 client_id, url, item_count
478 );
479 let _ = ctx
480 .status_tx
481 .send(format!("[CLIENT] RSS parsed feed: {} items", item_count));
482
483 match Self::notify_feed_fetched(event_data, &ctx).await {
484 Notified::Queue(next) => pending.extend(next),
485 Notified::Disconnect => {
486 info!("RSS client {} disconnecting", client_id);
487 ctx.app_state
488 .update_client_status(client_id, ClientStatus::Disconnected)
489 .await;
490 let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
491 return ChainEnd::Terminated;
492 }
493 }
494 }
495
496 ChainEnd::Drained
497 }
498
499 async fn notify_feed_fetched(event_data: serde_json::Value, ctx: &Arc<FetchCtx>) -> Notified {
501 let client_id = ctx.client_id;
502 let event = Event::new(&RSS_CLIENT_FEED_FETCHED_EVENT, event_data);
503 let memory_snapshot = ctx.client_data.lock().await.memory.clone();
504
505 match call_llm_for_client(
506 &ctx.llm_client,
507 &ctx.app_state,
508 client_id.to_string(),
509 &ctx.instruction,
510 &memory_snapshot,
511 Some(&event),
512 ctx.protocol.as_ref() as &dyn Client,
513 &ctx.status_tx,
514 )
515 .await
516 {
517 Ok(ClientLlmResult {
518 actions,
519 memory_updates,
520 }) => {
521 if let Some(mem) = memory_updates {
522 ctx.client_data.lock().await.memory = mem;
523 }
524 let mut next = Vec::new();
525 for action in actions {
526 match ctx.protocol.execute_action(action) {
527 Ok(action_result) => {
528 match Self::apply_action(action_result, &ctx.base_url) {
529 Applied::Fetch(url) => next.push(url),
530 Applied::Disconnect => return Notified::Disconnect,
531 Applied::WaitForMore => {}
532 Applied::Ignored(detail) => {
533 debug!("RSS client {}: {}", client_id, detail)
534 }
535 }
536 }
537 Err(e) => warn!("RSS client {} rejected action: {}", client_id, e),
538 }
539 }
540 Notified::Queue(next)
541 }
542 Err(e) => {
543 error!("RSS client {} LLM error: {}", client_id, e);
544 Notified::Queue(Vec::new())
545 }
546 }
547 }
548
549 pub async fn fetch_feed(url: &str) -> Result<serde_json::Value> {
551 let response = reqwest::get(url)
552 .await
553 .with_context(|| format!("Failed to fetch RSS feed {}", url))?;
554
555 if !response.status().is_success() {
556 return Err(anyhow::anyhow!(
557 "RSS fetch of {} failed with status {}",
558 url,
559 response.status()
560 ));
561 }
562
563 let body = response
564 .text()
565 .await
566 .context("Failed to read RSS response body")?;
567
568 let channel = Channel::read_from(body.as_bytes())
569 .with_context(|| format!("Failed to parse RSS feed at {}", url))?;
570
571 Ok(channel_to_event_data(url, &channel))
572 }
573}