My agentic slop goes here. Not intended for anyone else!
1(* Atom Feed Bot for Zulip
2 Posts Atom/RSS feed entries to Zulip channels organized by topic *)
3
4(* Logging setup *)
5let src = Logs.Src.create "atom_feed_bot" ~doc:"Atom feed bot for Zulip"
6module Log = (val Logs.src_log src : Logs.LOG)
7
8module Feed_parser = struct
9 type entry = {
10 title : string;
11 link : string;
12 summary : string option;
13 published : string option;
14 author : string option;
15 }
16
17 type feed = {
18 title : string;
19 entries : entry list;
20 }
21
22 (* Simple XML parser for Atom/RSS feeds *)
23 let parse_xml_element xml element_name =
24 let open_tag = "<" ^ element_name ^ ">" in
25 let close_tag = "</" ^ element_name ^ ">" in
26 try
27 (* Find the opening tag *)
28 match String.index_opt xml '<' with
29 | None -> None
30 | Some _ ->
31 (* Search for the actual open tag in the XML *)
32 let pattern = open_tag in
33 let pattern_start =
34 try Some (String.index (String.lowercase_ascii xml)
35 (String.lowercase_ascii pattern).[0])
36 with Not_found -> None
37 in
38 match pattern_start with
39 | None -> None
40 | Some _ ->
41 (* Try to find the content between tags *)
42 let rec find_substring str sub start =
43 if start + String.length sub > String.length str then
44 None
45 else if String.sub str start (String.length sub) = sub then
46 Some start
47 else
48 find_substring str sub (start + 1)
49 in
50 match find_substring xml open_tag 0 with
51 | None -> None
52 | Some start_pos ->
53 let content_start = start_pos + String.length open_tag in
54 match find_substring xml close_tag content_start with
55 | None -> None
56 | Some end_pos ->
57 let content = String.sub xml content_start (end_pos - content_start) in
58 Some (String.trim content)
59 with _ -> None
60
61 let parse_entry entry_xml =
62 let title = parse_xml_element entry_xml "title" in
63 let link = parse_xml_element entry_xml "link" in
64 let summary = parse_xml_element entry_xml "summary" in
65 let published = parse_xml_element entry_xml "published" in
66 let author = parse_xml_element entry_xml "author" in
67 match title, link with
68 | Some t, Some l -> Some { title = t; link = l; summary; published; author }
69 | _ -> None
70
71 let _parse_feed xml =
72 (* Very basic XML parsing - in production, use a proper XML library *)
73 let feed_title = parse_xml_element xml "title" |> Option.value ~default:"Unknown Feed" in
74
75 (* Extract entries between <entry> tags (Atom) or <item> tags (RSS) *)
76 let entries = ref [] in
77 let rec extract_entries str pos =
78 try
79 let entry_start =
80 try String.index_from str pos '<'
81 with Not_found -> String.length str
82 in
83 if entry_start >= String.length str then ()
84 else
85 let tag_end = String.index_from str entry_start '>' in
86 let tag = String.sub str (entry_start + 1) (tag_end - entry_start - 1) in
87 if tag = "entry" || tag = "item" then
88 let entry_end =
89 try String.index_from str tag_end '<'
90 with Not_found -> String.length str
91 in
92 let entry_xml = String.sub str entry_start (entry_end - entry_start) in
93 (match parse_entry entry_xml with
94 | Some e -> entries := e :: !entries
95 | None -> ());
96 extract_entries str entry_end
97 else
98 extract_entries str (tag_end + 1)
99 with _ -> ()
100 in
101 extract_entries xml 0;
102 { title = feed_title; entries = List.rev !entries }
103end
104
105module Feed_bot = struct
106 type config = {
107 feeds : (string * string * string) list; (* URL, channel, topic *)
108 refresh_interval : float; (* seconds *)
109 state_file : string;
110 }
111
112 type state = {
113 last_seen : (string, string) Hashtbl.t; (* feed_url -> last_entry_id *)
114 }
115
116 let load_state path =
117 try
118 let ic = open_in path in
119 let state = { last_seen = Hashtbl.create 10 } in
120 (try
121 while true do
122 let line = input_line ic in
123 match String.split_on_char '|' line with
124 | [url; id] -> Hashtbl.add state.last_seen url id
125 | _ -> ()
126 done
127 with End_of_file -> ());
128 close_in ic;
129 state
130 with _ -> { last_seen = Hashtbl.create 10 }
131
132 let save_state path state =
133 let oc = open_out path in
134 Hashtbl.iter (fun url id ->
135 output_string oc (url ^ "|" ^ id ^ "\n")
136 ) state.last_seen;
137 close_out oc
138
139 let fetch_feed _url =
140 (* In a real implementation, use an HTTP client to fetch the feed *)
141 (* For now, return a mock feed *)
142 Feed_parser.{
143 title = "Mock Feed";
144 entries = [
145 { title = "Test Entry";
146 link = "https://example.com/1";
147 summary = Some "This is a test entry";
148 published = Some "2024-01-01T00:00:00Z";
149 author = Some "Test Author" }
150 ]
151 }
152
153 let format_entry (entry : Feed_parser.entry) =
154 let lines = [
155 Printf.sprintf "**[%s](%s)**" entry.title entry.link;
156 ] in
157 let lines = match entry.author with
158 | Some a -> lines @ [Printf.sprintf "*By %s*" a]
159 | None -> lines
160 in
161 let lines = match entry.published with
162 | Some p -> lines @ [Printf.sprintf "*Published: %s*" p]
163 | None -> lines
164 in
165 let lines = match entry.summary with
166 | Some s -> lines @ [""; s]
167 | None -> lines
168 in
169 String.concat "\n" lines
170
171 let post_entry client channel topic entry =
172 let open Feed_parser in
173 let message = Zulip.Message.create
174 ~type_:`Channel
175 ~to_:[channel]
176 ~topic
177 ~content:(format_entry entry)
178 ()
179 in
180 match Zulip.Messages.send client message with
181 | Ok _ -> Printf.printf "Posted: %s\n" entry.title
182 | Error e -> Printf.eprintf "Error posting: %s\n" (Zulip.error_message e)
183
184 let process_feed client state (url, channel, topic) =
185 Printf.printf "Processing feed: %s -> #%s/%s\n" url channel topic;
186 let feed = fetch_feed url in
187
188 let last_id = Hashtbl.find_opt state.last_seen url in
189 let new_entries = match last_id with
190 | Some id ->
191 (* Filter entries newer than last_id *)
192 List.filter (fun e ->
193 Feed_parser.(e.link <> id)
194 ) feed.entries
195 | None -> feed.entries
196 in
197
198 (* Post new entries *)
199 List.iter (post_entry client channel topic) new_entries;
200
201 (* Update last seen *)
202 match feed.entries with
203 | h :: _ -> Hashtbl.replace state.last_seen url Feed_parser.(h.link)
204 | [] -> ()
205
206 let run_bot env config =
207 (* Load authentication *)
208 let auth = match Zulip.Auth.from_zuliprc () with
209 | Ok a -> a
210 | Error e ->
211 Printf.eprintf "Failed to load auth: %s\n" (Zulip.error_message e);
212 exit 1
213 in
214
215 (* Create client *)
216 Eio.Switch.run @@ fun sw ->
217 let client = Zulip.Client.create ~sw env auth in
218
219 (* Load state *)
220 let state = load_state config.state_file in
221
222 (* Main loop *)
223 let rec loop () =
224 Printf.printf "Checking feeds...\n";
225 List.iter (process_feed client state) config.feeds;
226 save_state config.state_file state;
227
228 Printf.printf "Sleeping for %.0f seconds...\n" config.refresh_interval;
229 Eio.Time.sleep (Eio.Stdenv.clock env) config.refresh_interval;
230 loop ()
231 in
232 loop ()
233end
234
235(* Interactive bot that responds to commands *)
236module Interactive_feed_bot = struct
237 open Zulip_bot
238
239 type t = {
240 feeds : (string, string * string) Hashtbl.t; (* name -> (url, topic) *)
241 mutable default_channel : string;
242 }
243
244 let create () = {
245 feeds = Hashtbl.create 10;
246 default_channel = "general";
247 }
248
249 let handle_command bot_state command args =
250 match command with
251 | "add" ->
252 (match args with
253 | name :: url :: topic ->
254 let topic_str = String.concat " " topic in
255 Hashtbl.replace bot_state.feeds name (url, topic_str);
256 Printf.sprintf "Added feed '%s' -> %s (topic: %s)" name url topic_str
257 | _ -> "Usage: !feed add <name> <url> <topic>")
258
259 | "remove" ->
260 (match args with
261 | name :: _ ->
262 if Hashtbl.mem bot_state.feeds name then (
263 Hashtbl.remove bot_state.feeds name;
264 Printf.sprintf "Removed feed '%s'" name
265 ) else
266 Printf.sprintf "Feed '%s' not found" name
267 | _ -> "Usage: !feed remove <name>")
268
269 | "list" ->
270 if Hashtbl.length bot_state.feeds = 0 then
271 "No feeds configured"
272 else
273 let lines = Hashtbl.fold (fun name (url, topic) acc ->
274 (Printf.sprintf "• **%s**: %s → topic: %s" name url topic) :: acc
275 ) bot_state.feeds [] in
276 String.concat "\n" lines
277
278 | "fetch" ->
279 (match args with
280 | name :: _ ->
281 (match Hashtbl.find_opt bot_state.feeds name with
282 | Some (url, _topic) ->
283 Printf.sprintf "Fetching feed '%s' from %s..." name url
284 | None ->
285 Printf.sprintf "Feed '%s' not found" name)
286 | _ -> "Usage: !feed fetch <name>")
287
288 | "channel" ->
289 (match args with
290 | channel :: _ ->
291 bot_state.default_channel <- channel;
292 Printf.sprintf "Default channel set to: %s" channel
293 | _ -> Printf.sprintf "Current default channel: %s" bot_state.default_channel)
294
295 | "help" | _ ->
296 String.concat "\n" [
297 "**Atom Feed Bot Commands:**";
298 "• `!feed add <name> <url> <topic>` - Add a new feed";
299 "• `!feed remove <name>` - Remove a feed";
300 "• `!feed list` - List all configured feeds";
301 "• `!feed fetch <name>` - Manually fetch a feed";
302 "• `!feed channel <name>` - Set default channel";
303 "• `!feed help` - Show this help message";
304 ]
305
306 let create_handler bot_state =
307 let module Handler : Bot_handler.S = struct
308 let initialize _ = Ok ()
309 let usage () = "Atom feed bot - use !feed help for commands"
310 let description () = "Bot for managing and posting Atom/RSS feeds to Zulip"
311
312 let handle_message ~config:_ ~storage:_ ~identity ~message ~env:_ =
313 (* Get message content using Message accessor *)
314 let content = Message.content message in
315
316 (* Check if this is our own message to avoid loops *)
317 let bot_email = Bot_handler.Identity.email identity in
318 if Message.is_from_email message ~email:bot_email then
319 Ok Bot_handler.Response.None
320 else
321 (* Check if message starts with !feed *)
322 if String.starts_with ~prefix:"!feed" content then
323 let parts = String.split_on_char ' ' (String.trim content) in
324 match parts with
325 | _ :: command :: args ->
326 let response = handle_command bot_state command args in
327 Ok (Bot_handler.Response.Reply response)
328 | _ ->
329 let response = handle_command bot_state "help" [] in
330 Ok (Bot_handler.Response.Reply response)
331 else
332 Ok Bot_handler.Response.None
333 end in
334 (module Handler : Bot_handler.S)
335end
336
337(* Run interactive bot mode *)
338let run_interactive verbosity env =
339 (* Setup logging *)
340 Logs.set_reporter (Logs_fmt.reporter ());
341 Logs.set_level (Some (match verbosity with
342 | 0 -> Logs.Info
343 | 1 -> Logs.Debug
344 | _ -> Logs.Debug));
345
346 Log.info (fun m -> m "Starting interactive Atom feed bot...");
347
348 let bot_state = Interactive_feed_bot.create () in
349 let handler = Interactive_feed_bot.create_handler bot_state in
350
351 (* Load auth and create bot runner *)
352 let auth = match Zulip.Auth.from_zuliprc () with
353 | Ok a -> a
354 | Error e ->
355 Log.err (fun m -> m "Failed to load auth: %s" (Zulip.error_message e));
356 exit 1
357 in
358
359 Eio.Switch.run @@ fun sw ->
360 let client = Zulip.Client.create ~sw env auth in
361
362 (* Create and run bot *)
363 let config = Zulip_bot.Bot_config.create [] in
364 let bot_email = Zulip.Auth.email auth in
365 let storage = Zulip_bot.Bot_storage.create client ~bot_email in
366 let identity = Zulip_bot.Bot_handler.Identity.create
367 ~full_name:"Atom Feed Bot"
368 ~email:bot_email
369 ~mention_name:"feedbot"
370 in
371
372 let bot = Zulip_bot.Bot_handler.create handler ~config ~storage ~identity in
373 let runner = Zulip_bot.Bot_runner.create ~env ~client ~handler:bot in
374 Zulip_bot.Bot_runner.run_realtime runner
375
376(* Run scheduled fetcher mode *)
377let run_scheduled verbosity env =
378 (* Setup logging *)
379 Logs.set_reporter (Logs_fmt.reporter ());
380 Logs.set_level (Some (match verbosity with
381 | 0 -> Logs.Info
382 | 1 -> Logs.Debug
383 | _ -> Logs.Debug));
384
385 Log.info (fun m -> m "Starting scheduled Atom feed fetcher...");
386
387 let config = Feed_bot.{
388 feeds = [
389 ("https://example.com/feed.xml", "general", "News");
390 ("https://blog.example.com/atom.xml", "general", "Blog Posts");
391 ];
392 refresh_interval = 300.0; (* 5 minutes *)
393 state_file = "feed_bot_state.txt";
394 } in
395
396 Feed_bot.run_bot env config
397
398(* Command-line interface *)
399open Cmdliner
400
401let verbosity =
402 let doc = "Increase verbosity (can be used multiple times)" in
403 let verbosity_flags = Arg.(value & flag_all & info ["v"; "verbose"] ~doc) in
404 Term.(const List.length $ verbosity_flags)
405
406let mode =
407 let doc = "Bot mode (interactive or scheduled)" in
408 let modes = ["interactive", `Interactive; "scheduled", `Scheduled] in
409 Arg.(value & opt (enum modes) `Interactive & info ["m"; "mode"] ~docv:"MODE" ~doc)
410
411let main_cmd =
412 let doc = "Atom feed bot for Zulip" in
413 let man = [
414 `S Manpage.s_description;
415 `P "This bot can run in two modes:";
416 `P "- Interactive mode: Responds to !feed commands in Zulip";
417 `P "- Scheduled mode: Periodically fetches configured feeds";
418 `P "The bot requires a configured ~/.zuliprc file with API credentials.";
419 ] in
420 let info = Cmd.info "atom_feed_bot" ~version:"1.0.0" ~doc ~man in
421 let run verbosity mode =
422 Eio_main.run @@ fun env ->
423 match mode with
424 | `Interactive -> run_interactive verbosity env
425 | `Scheduled -> run_scheduled verbosity env
426 in
427 let term = Term.(const run $ verbosity $ mode) in
428 Cmd.v info term
429
430let () = exit (Cmd.eval main_cmd)