(* Atom Feed Bot for Zulip Posts Atom/RSS feed entries to Zulip channels organized by topic *) (* Logging setup *) let src = Logs.Src.create "atom_feed_bot" ~doc:"Atom feed bot for Zulip" module Log = (val Logs.src_log src : Logs.LOG) module Feed_parser = struct type entry = { title : string; link : string; summary : string option; published : string option; author : string option; } type feed = { title : string; entries : entry list; } (* Simple XML parser for Atom/RSS feeds *) let parse_xml_element xml element_name = let open_tag = "<" ^ element_name ^ ">" in let close_tag = "" in try (* Find the opening tag *) match String.index_opt xml '<' with | None -> None | Some _ -> (* Search for the actual open tag in the XML *) let pattern = open_tag in let pattern_start = try Some (String.index (String.lowercase_ascii xml) (String.lowercase_ascii pattern).[0]) with Not_found -> None in match pattern_start with | None -> None | Some _ -> (* Try to find the content between tags *) let rec find_substring str sub start = if start + String.length sub > String.length str then None else if String.sub str start (String.length sub) = sub then Some start else find_substring str sub (start + 1) in match find_substring xml open_tag 0 with | None -> None | Some start_pos -> let content_start = start_pos + String.length open_tag in match find_substring xml close_tag content_start with | None -> None | Some end_pos -> let content = String.sub xml content_start (end_pos - content_start) in Some (String.trim content) with _ -> None let parse_entry entry_xml = let title = parse_xml_element entry_xml "title" in let link = parse_xml_element entry_xml "link" in let summary = parse_xml_element entry_xml "summary" in let published = parse_xml_element entry_xml "published" in let author = parse_xml_element entry_xml "author" in match title, link with | Some t, Some l -> Some { title = t; link = l; summary; published; author } | _ -> None let _parse_feed xml = (* Very basic XML parsing - in production, use a proper XML library *) let feed_title = parse_xml_element xml "title" |> Option.value ~default:"Unknown Feed" in (* Extract entries between tags (Atom) or tags (RSS) *) let entries = ref [] in let rec extract_entries str pos = try let entry_start = try String.index_from str pos '<' with Not_found -> String.length str in if entry_start >= String.length str then () else let tag_end = String.index_from str entry_start '>' in let tag = String.sub str (entry_start + 1) (tag_end - entry_start - 1) in if tag = "entry" || tag = "item" then let entry_end = try String.index_from str tag_end '<' with Not_found -> String.length str in let entry_xml = String.sub str entry_start (entry_end - entry_start) in (match parse_entry entry_xml with | Some e -> entries := e :: !entries | None -> ()); extract_entries str entry_end else extract_entries str (tag_end + 1) with _ -> () in extract_entries xml 0; { title = feed_title; entries = List.rev !entries } end module Feed_bot = struct type config = { feeds : (string * string * string) list; (* URL, channel, topic *) refresh_interval : float; (* seconds *) state_file : string; } type state = { last_seen : (string, string) Hashtbl.t; (* feed_url -> last_entry_id *) } let load_state path = try let ic = open_in path in let state = { last_seen = Hashtbl.create 10 } in (try while true do let line = input_line ic in match String.split_on_char '|' line with | [url; id] -> Hashtbl.add state.last_seen url id | _ -> () done with End_of_file -> ()); close_in ic; state with _ -> { last_seen = Hashtbl.create 10 } let save_state path state = let oc = open_out path in Hashtbl.iter (fun url id -> output_string oc (url ^ "|" ^ id ^ "\n") ) state.last_seen; close_out oc let fetch_feed _url = (* In a real implementation, use an HTTP client to fetch the feed *) (* For now, return a mock feed *) Feed_parser.{ title = "Mock Feed"; entries = [ { title = "Test Entry"; link = "https://example.com/1"; summary = Some "This is a test entry"; published = Some "2024-01-01T00:00:00Z"; author = Some "Test Author" } ] } let format_entry (entry : Feed_parser.entry) = let lines = [ Printf.sprintf "**[%s](%s)**" entry.title entry.link; ] in let lines = match entry.author with | Some a -> lines @ [Printf.sprintf "*By %s*" a] | None -> lines in let lines = match entry.published with | Some p -> lines @ [Printf.sprintf "*Published: %s*" p] | None -> lines in let lines = match entry.summary with | Some s -> lines @ [""; s] | None -> lines in String.concat "\n" lines let post_entry client channel topic entry = let open Feed_parser in let message = Zulip.Message.create ~type_:`Channel ~to_:[channel] ~topic ~content:(format_entry entry) () in match Zulip.Messages.send client message with | Ok _ -> Printf.printf "Posted: %s\n" entry.title | Error e -> Printf.eprintf "Error posting: %s\n" (Zulip.error_message e) let process_feed client state (url, channel, topic) = Printf.printf "Processing feed: %s -> #%s/%s\n" url channel topic; let feed = fetch_feed url in let last_id = Hashtbl.find_opt state.last_seen url in let new_entries = match last_id with | Some id -> (* Filter entries newer than last_id *) List.filter (fun e -> Feed_parser.(e.link <> id) ) feed.entries | None -> feed.entries in (* Post new entries *) List.iter (post_entry client channel topic) new_entries; (* Update last seen *) match feed.entries with | h :: _ -> Hashtbl.replace state.last_seen url Feed_parser.(h.link) | [] -> () let run_bot env config = (* Load authentication *) let auth = match Zulip.Auth.from_zuliprc () with | Ok a -> a | Error e -> Printf.eprintf "Failed to load auth: %s\n" (Zulip.error_message e); exit 1 in (* Create client *) Eio.Switch.run @@ fun sw -> let client = Zulip.Client.create ~sw env auth in (* Load state *) let state = load_state config.state_file in (* Main loop *) let rec loop () = Printf.printf "Checking feeds...\n"; List.iter (process_feed client state) config.feeds; save_state config.state_file state; Printf.printf "Sleeping for %.0f seconds...\n" config.refresh_interval; Eio.Time.sleep (Eio.Stdenv.clock env) config.refresh_interval; loop () in loop () end (* Interactive bot that responds to commands *) module Interactive_feed_bot = struct open Zulip_bot type t = { feeds : (string, string * string) Hashtbl.t; (* name -> (url, topic) *) mutable default_channel : string; } let create () = { feeds = Hashtbl.create 10; default_channel = "general"; } let handle_command bot_state command args = match command with | "add" -> (match args with | name :: url :: topic -> let topic_str = String.concat " " topic in Hashtbl.replace bot_state.feeds name (url, topic_str); Printf.sprintf "Added feed '%s' -> %s (topic: %s)" name url topic_str | _ -> "Usage: !feed add ") | "remove" -> (match args with | name :: _ -> if Hashtbl.mem bot_state.feeds name then ( Hashtbl.remove bot_state.feeds name; Printf.sprintf "Removed feed '%s'" name ) else Printf.sprintf "Feed '%s' not found" name | _ -> "Usage: !feed remove ") | "list" -> if Hashtbl.length bot_state.feeds = 0 then "No feeds configured" else let lines = Hashtbl.fold (fun name (url, topic) acc -> (Printf.sprintf "• **%s**: %s → topic: %s" name url topic) :: acc ) bot_state.feeds [] in String.concat "\n" lines | "fetch" -> (match args with | name :: _ -> (match Hashtbl.find_opt bot_state.feeds name with | Some (url, _topic) -> Printf.sprintf "Fetching feed '%s' from %s..." name url | None -> Printf.sprintf "Feed '%s' not found" name) | _ -> "Usage: !feed fetch ") | "channel" -> (match args with | channel :: _ -> bot_state.default_channel <- channel; Printf.sprintf "Default channel set to: %s" channel | _ -> Printf.sprintf "Current default channel: %s" bot_state.default_channel) | "help" | _ -> String.concat "\n" [ "**Atom Feed Bot Commands:**"; "• `!feed add ` - Add a new feed"; "• `!feed remove ` - Remove a feed"; "• `!feed list` - List all configured feeds"; "• `!feed fetch ` - Manually fetch a feed"; "• `!feed channel ` - Set default channel"; "• `!feed help` - Show this help message"; ] let create_handler bot_state = let module Handler : Bot_handler.S = struct let initialize _ = Ok () let usage () = "Atom feed bot - use !feed help for commands" let description () = "Bot for managing and posting Atom/RSS feeds to Zulip" let handle_message ~config:_ ~storage:_ ~identity ~message ~env:_ = (* Get message content using Message accessor *) let content = Message.content message in (* Check if this is our own message to avoid loops *) let bot_email = Bot_handler.Identity.email identity in if Message.is_from_email message ~email:bot_email then Ok Bot_handler.Response.None else (* Check if message starts with !feed *) if String.starts_with ~prefix:"!feed" content then let parts = String.split_on_char ' ' (String.trim content) in match parts with | _ :: command :: args -> let response = handle_command bot_state command args in Ok (Bot_handler.Response.Reply response) | _ -> let response = handle_command bot_state "help" [] in Ok (Bot_handler.Response.Reply response) else Ok Bot_handler.Response.None end in (module Handler : Bot_handler.S) end (* Run interactive bot mode *) let run_interactive verbosity env = (* Setup logging *) Logs.set_reporter (Logs_fmt.reporter ()); Logs.set_level (Some (match verbosity with | 0 -> Logs.Info | 1 -> Logs.Debug | _ -> Logs.Debug)); Log.info (fun m -> m "Starting interactive Atom feed bot..."); let bot_state = Interactive_feed_bot.create () in let handler = Interactive_feed_bot.create_handler bot_state in (* Load auth and create bot runner *) let auth = match Zulip.Auth.from_zuliprc () with | Ok a -> a | Error e -> Log.err (fun m -> m "Failed to load auth: %s" (Zulip.error_message e)); exit 1 in Eio.Switch.run @@ fun sw -> let client = Zulip.Client.create ~sw env auth in (* Create and run bot *) let config = Zulip_bot.Bot_config.create [] in let bot_email = Zulip.Auth.email auth in let storage = Zulip_bot.Bot_storage.create client ~bot_email in let identity = Zulip_bot.Bot_handler.Identity.create ~full_name:"Atom Feed Bot" ~email:bot_email ~mention_name:"feedbot" in let bot = Zulip_bot.Bot_handler.create handler ~config ~storage ~identity in let runner = Zulip_bot.Bot_runner.create ~env ~client ~handler:bot in Zulip_bot.Bot_runner.run_realtime runner (* Run scheduled fetcher mode *) let run_scheduled verbosity env = (* Setup logging *) Logs.set_reporter (Logs_fmt.reporter ()); Logs.set_level (Some (match verbosity with | 0 -> Logs.Info | 1 -> Logs.Debug | _ -> Logs.Debug)); Log.info (fun m -> m "Starting scheduled Atom feed fetcher..."); let config = Feed_bot.{ feeds = [ ("https://example.com/feed.xml", "general", "News"); ("https://blog.example.com/atom.xml", "general", "Blog Posts"); ]; refresh_interval = 300.0; (* 5 minutes *) state_file = "feed_bot_state.txt"; } in Feed_bot.run_bot env config (* Command-line interface *) open Cmdliner let verbosity = let doc = "Increase verbosity (can be used multiple times)" in let verbosity_flags = Arg.(value & flag_all & info ["v"; "verbose"] ~doc) in Term.(const List.length $ verbosity_flags) let mode = let doc = "Bot mode (interactive or scheduled)" in let modes = ["interactive", `Interactive; "scheduled", `Scheduled] in Arg.(value & opt (enum modes) `Interactive & info ["m"; "mode"] ~docv:"MODE" ~doc) let main_cmd = let doc = "Atom feed bot for Zulip" in let man = [ `S Manpage.s_description; `P "This bot can run in two modes:"; `P "- Interactive mode: Responds to !feed commands in Zulip"; `P "- Scheduled mode: Periodically fetches configured feeds"; `P "The bot requires a configured ~/.zuliprc file with API credentials."; ] in let info = Cmd.info "atom_feed_bot" ~version:"1.0.0" ~doc ~man in let run verbosity mode = Eio_main.run @@ fun env -> match mode with | `Interactive -> run_interactive verbosity env | `Scheduled -> run_scheduled verbosity env in let term = Term.(const run $ verbosity $ mode) in Cmd.v info term let () = exit (Cmd.eval main_cmd)