1use crate::llm::actions::{
4 protocol_trait::{ActionResult, Protocol, Server},
5 ActionDefinition, Parameter, ParameterDefinition,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::{EventType, SpawnContext};
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::pin::Pin;
13use std::sync::LazyLock;
14
15pub static RSS_FEED_REQUESTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
17 EventType::new(
18 "rss_feed_requested",
19 "A client requested an RSS feed. Answer with generate_rss_feed; the server renders the \
20 XML from the fields you supply.",
21 json!({
22 "type": "generate_rss_feed",
23 "title": "NetGet News",
24 "link": "http://localhost/news.xml",
25 "description": "Latest headlines",
26 "items": [{
27 "title": "First post",
28 "link": "http://localhost/1",
29 "description": "Hello world"
30 }]
31 }),
32 )
33 .with_actions(vec![generate_rss_feed_action()])
37 .with_parameters(vec![
38 Parameter {
39 name: "path".to_string(),
40 type_hint: "string".to_string(),
41 description: "Feed path (e.g., /news.xml)".to_string(),
42 required: true,
43 },
44 Parameter {
45 name: "headers".to_string(),
46 type_hint: "object".to_string(),
47 description: "HTTP request headers".to_string(),
48 required: true,
49 },
50 ])
51 .with_log_template(
52 LogTemplate::new()
53 .with_info("RSS request")
54 .with_debug("RSS feed request")
55 .with_trace("RSS: {json_pretty(.)}"),
56 )
57});
58
59pub struct RssProtocol;
61
62impl RssProtocol {
63 pub fn new() -> Self {
64 Self
65 }
66}
67
68impl Protocol for RssProtocol {
70 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
71 Vec::new()
72 }
73
74 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
75 Vec::new()
77 }
78
79 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
80 vec![generate_rss_feed_action()]
81 }
82
83 fn protocol_name(&self) -> &'static str {
84 "RSS"
85 }
86
87 fn get_event_types(&self) -> Vec<EventType> {
88 vec![RSS_FEED_REQUESTED_EVENT.clone()]
89 }
90
91 fn stack_name(&self) -> &'static str {
92 "ETH>IP>TCP>HTTP>RSS"
93 }
94
95 fn keywords(&self) -> Vec<&'static str> {
96 vec!["rss", "rss server", "feed", "syndication", "via rss"]
97 }
98
99 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
100 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
101
102 ProtocolMetadataV2::builder()
103 .state(DevelopmentState::Beta)
104 .implementation("rss crate for RSS 2.0 XML generation, served over HTTP")
105 .llm_control("Feed content generation (title, items, categories)")
106 .e2e_testing(
107 "feed-rs 2 is the independent reader: it parses the served feed, identifies it \
108 as FeedType::RSS2, and the test asserts channel title, description and \
109 language, three entries, the first entry's title and link, that its RFC 2822 \
110 pub_date parsed into a real timestamp, and its categories \
111 (tests/server/rss/e2e_test.rs, not #[ignore]d). The parser matters more than \
112 usual here: this server builds its XML with the `rss` crate, so parsing it \
113 back with the `rss` crate — which is what this test used to do — proved only \
114 that one crate round-trips through itself. RSS has no session, so \
115 fetch-and-parse is the whole protocol; reqwest does the GET, feed-rs is the \
116 evidence. Not proven: conditional GET / If-Modified-Since, feed \
117 autodiscovery, Atom.",
118 )
119 .request_only(
120 "RSS is HTTP request/response; a server cannot send a peer anything unprompted",
121 )
122 .build()
123 }
124
125 fn description(&self) -> &'static str {
126 "RSS feed server for web syndication"
127 }
128
129 fn example_prompt(&self) -> &'static str {
130 "Create an RSS feed server on port 8080 serving tech news at /tech.xml with categories"
131 }
132
133 fn group_name(&self) -> &'static str {
134 "Web"
135 }
136
137 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
138 use crate::llm::actions::StartupExamples;
139 use serde_json::json;
140
141 let script = r#"import json, sys
144data = json.load(sys.stdin)
145event = data["event"]
146if data["event_type_id"] == "rss_feed_requested":
147 actions = [{"type": "generate_rss_feed", "title": "NetGet Feed",
148 "link": "http://localhost:8080", "description": "NetGet RSS feed",
149 "items": []}]
150else:
151 actions = []
152print(json.dumps({"actions": actions}))"#;
153
154 StartupExamples::new(
155 json!({
157 "type": "open_server",
158 "port": 8080,
159 "base_stack": "rss",
160 "instruction": "RSS feed server generating dynamic feeds"
161 }),
162 json!({
164 "type": "open_server",
165 "port": 8080,
166 "base_stack": "rss",
167 "event_handlers": [{
168 "event_pattern": "rss_feed_requested",
169 "handler": {
170 "type": "script",
171 "language": "python",
172 "code": script
173 }
174 }]
175 }),
176 json!({
178 "type": "open_server",
179 "port": 8080,
180 "base_stack": "rss",
181 "event_handlers": [{
182 "event_pattern": "rss_feed_requested",
183 "handler": {
184 "type": "static",
185 "actions": [{
186 "type": "generate_rss_feed",
187 "title": "Default Feed",
188 "link": "http://localhost:8080",
189 "description": "Default RSS feed",
190 "items": []
191 }]
192 }
193 }]
194 }),
195 )
196 }
197}
198
199impl Server for RssProtocol {
201 fn spawn(
202 &self,
203 ctx: SpawnContext,
204 ) -> Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>> {
205 Box::pin(async move {
206 use crate::server::rss::RssServer;
207
208 RssServer::spawn_with_llm_actions(
209 ctx.legacy_listen_addr(),
210 ctx.llm_client,
211 ctx.state,
212 ctx.status_tx,
213 ctx.server_id,
214 )
215 .await
216 })
217 }
218
219 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
220 let action_type = action
221 .get("type")
222 .and_then(|v| v.as_str())
223 .context("Missing 'type' field in action")?;
224
225 match action_type {
226 "generate_rss_feed" => self.execute_generate_feed(action),
227 _ => Err(anyhow::anyhow!("Unknown RSS action: {action_type}")),
228 }
229 }
230}
231
232impl RssProtocol {
233 fn execute_generate_feed(&self, action: serde_json::Value) -> Result<ActionResult> {
235 validate_feed_data(&action)?;
239
240 Ok(ActionResult::Custom {
242 name: "generate_rss_feed".to_string(),
243 data: action,
244 })
245 }
246}
247
248pub fn validate_feed_data(action: &serde_json::Value) -> Result<()> {
260 for field in ["title", "link", "description"] {
261 match action.get(field) {
262 None | Some(serde_json::Value::Null) => {
263 anyhow::bail!(
264 "generate_rss_feed requires '{field}'. RSS 2.0 makes title, link and \
265 description mandatory on the channel, and netget will not invent them."
266 );
267 }
268 Some(serde_json::Value::String(s)) if s.trim().is_empty() => {
269 anyhow::bail!("generate_rss_feed '{field}' must not be empty");
270 }
271 Some(serde_json::Value::String(_)) => {}
272 Some(other) => {
273 anyhow::bail!(
274 "generate_rss_feed '{field}' must be a string, got {}",
275 match other {
276 serde_json::Value::Bool(_) => "a boolean",
277 serde_json::Value::Number(_) => "a number",
278 serde_json::Value::Array(_) => "an array",
279 serde_json::Value::Object(_) => "an object",
280 _ => "something else",
281 }
282 );
283 }
284 }
285 }
286
287 match action.get("items") {
288 None | Some(serde_json::Value::Null) => anyhow::bail!(
289 "generate_rss_feed requires 'items'. Use an empty array for a feed with no entries."
290 ),
291 Some(serde_json::Value::Array(_)) => {}
292 Some(_) => anyhow::bail!("generate_rss_feed 'items' must be an array"),
293 }
294
295 Ok(())
296}
297
298fn generate_rss_feed_action() -> ActionDefinition {
300 ActionDefinition {
301 name: "generate_rss_feed".to_string(),
302 description: "Generate RSS feed XML for the current request".to_string(),
303 parameters: vec![
304 Parameter {
305 name: "title".to_string(),
306 type_hint: "string".to_string(),
307 description: "Feed title".to_string(),
308 required: true,
309 },
310 Parameter {
311 name: "link".to_string(),
312 type_hint: "string".to_string(),
313 description: "Feed link (website URL)".to_string(),
314 required: true,
315 },
316 Parameter {
317 name: "description".to_string(),
318 type_hint: "string".to_string(),
319 description: "Feed description".to_string(),
320 required: true,
321 },
322 Parameter {
323 name: "language".to_string(),
324 type_hint: "string".to_string(),
325 description: "Feed language (e.g., 'en-us')".to_string(),
326 required: false,
327 },
328 Parameter {
329 name: "ttl".to_string(),
330 type_hint: "string".to_string(),
331 description: "Time to live in minutes".to_string(),
332 required: false,
333 },
334 Parameter {
335 name: "last_build_date".to_string(),
336 type_hint: "string".to_string(),
337 description: "Last build date (RFC 2822)".to_string(),
338 required: false,
339 },
340 Parameter {
341 name: "items".to_string(),
342 type_hint: "array".to_string(),
343 description: "Array of feed items".to_string(),
344 required: true,
345 },
346 ],
347 example: json!({
348 "type": "generate_rss_feed",
349 "title": "Tech News Feed",
350 "link": "https://example.com",
351 "description": "Latest technology news",
352 "language": "en-us",
353 "ttl": "60",
354 "last_build_date": "Mon, 09 Nov 2025 12:00:00 GMT",
355 "items": [
356 {
357 "title": "New AI Model Released",
358 "link": "https://example.com/ai-news",
359 "description": "Company X released a new AI model",
360 "author": "john@example.com (John Doe)",
361 "pub_date": "Mon, 09 Nov 2025 10:00:00 GMT",
362 "guid": "https://example.com/ai-news",
363 "categories": [
364 "AI",
365 "Technology",
366 {"name": "Machine Learning", "domain": "tech.example.com"}
367 ]
368 },
369 {
370 "title": "Cloud Computing Trends",
371 "link": "https://example.com/cloud-trends",
372 "description": "Latest trends in cloud computing",
373 "pub_date": "Mon, 09 Nov 2025 09:00:00 GMT",
374 "categories": ["Cloud", "Infrastructure"]
375 }
376 ]
377 }),
378 log_template: Some(
379 LogTemplate::new()
380 .with_info("-> RSS feed: {title}")
381 .with_debug("RSS generate_rss_feed: title={title} items={items_len}"),
382 ),
383 }
384}