My agentic slop goes here. Not intended for anyone else!
at jsont 14 kB view raw
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)