Skip to main content

netget/client/rss/
mod.rs

1//! RSS (Really Simple Syndication) Feed Client
2//!
3//! Fetches and parses RSS 2.0 XML feeds with LLM-controlled interpretation.
4//! The LLM decides which feeds to fetch and how to process items.
5
6pub 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
29/// Guard against a model that keeps chaining `fetch_rss_feed` forever.
30const MAX_CHAINED_FETCHES: usize = 16;
31
32/// How often the command loop re-checks that its client still exists. RSS is a
33/// fetch-on-demand client with no socket to notice a close on.
34const REMOVAL_CHECK_INTERVAL: Duration = Duration::from_secs(5);
35
36struct ClientData {
37    memory: String,
38}
39
40/// Everything the fetch machinery needs besides the URL queue.
41///
42/// Bundled because the injected-command loop starts a fetch chain with exactly the
43/// same context the connect path uses, and passing nine arguments to three functions
44/// invites them to drift apart.
45struct 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
56/// What one executed action asked for.
57enum Applied {
58    /// Fetch this (already resolved) feed URL.
59    Fetch(String),
60    /// End the session.
61    Disconnect,
62    /// Take no action and wait.
63    WaitForMore,
64    /// The action ran but this client has nothing to do with the result.
65    Ignored(String),
66}
67
68/// How a chain of fetches ended.
69enum ChainEnd {
70    /// The queue emptied (or hit the chain limit) with the client still alive.
71    Drained,
72    /// The chain set a terminal client status itself (fetch error, or disconnect).
73    Terminated,
74}
75
76/// What the model said about a fetched feed.
77enum Notified {
78    /// Fetch these next.
79    Queue(Vec<String>),
80    /// End the session.
81    Disconnect,
82}
83
84/// Turn whatever the model put in `url` into an absolute URL.
85///
86/// A bare path (`/news.xml`) is resolved against the address the client was opened on;
87/// an absolute URL is used verbatim.
88pub 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
98/// Convert a parsed RSS channel into the structured event payload the model sees.
99///
100/// Deliberately structured fields, never raw XML — models cannot reliably parse XML back out.
101pub 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
127/// RSS client
128pub struct RssClient;
129
130impl RssClient {
131    /// Connect to an RSS feed source and set up LLM integration.
132    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        // `Client::connect` must return a concrete socket address, so resolve the host now.
140        // This doubles as an early failure if the address is unusable.
141        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        // Injected commands (the dashboard's [ send ]). Registered BEFORE the
183        // connected-event LLM call below: a dashboard-created client defaults to a
184        // `*` manual rule, so that call can park for minutes waiting for a human and
185        // [ send ] has to work for the whole park.
186        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        // Ask the model which feed to fetch, then service its answer in a background task.
195        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                                    // Drop the command handle so the dashboard stops
233                                    // offering [ send ]; that also ends the command loop.
234                                    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    /// Drain injected commands until the channel closes (client removed) or an
269    /// injected `disconnect` ends the session.
270    ///
271    /// `command_support::handle_stream_client_command` cannot serve this client:
272    /// there is no socket, and `fetch_rss_feed` yields `ClientActionResult::Custom`.
273    /// So the action goes through [`Self::apply_action`] — the same function the LLM
274    /// path uses — and the outcome is recorded and replied the way the generic arm
275    /// does it.
276    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; // the first tick completes immediately
279
280        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    /// Execute one injected action, record it, and reply. Returns `true` when the
302    /// command loop should stop.
303    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                    // Never `Sent`: reqwest owns the socket and reports no wire byte
313                    // count for the GET, so a number here would be invented.
314                    // `Executed` carries the parsed item count instead.
315                    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                        // Hand the feed to the model from a separate registered task:
321                        // a dashboard client's manual rule can park that LLM call for
322                        // minutes, and the command loop must stay responsive.
323                        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    /// Decide what one executed action means. Shared by the connect path, the fetch
377    /// chain and the injected-command loop so URL resolution happens in one place.
378    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            // Not swallowed: an action this client cannot carry out says so, rather
390            // than looking identical to success.
391            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    /// The model's own fetch chain, started from the connect path: run it, and mark
404    /// the client disconnected once it drains.
405    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    /// An injected fetch's follow-on: hand the parsed feed to the model and service
415    /// whatever it asks for next.
416    ///
417    /// Unlike [`Self::fetch_loop`] this does *not* mark the client disconnected when
418    /// the chain drains — an injected fetch is one operation on a client the operator
419    /// is still holding open.
420    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    /// Fetch each queued feed, hand the parsed result to the model, and queue whatever it
438    /// asks for next. Bounded so a looping model cannot spin forever.
439    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    /// Hand one parsed feed to the model and collect the URLs it wants next.
500    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    /// Fetch one feed over HTTP and turn it into structured event data.
550    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}