(* Enhanced Echo Bot for Zulip with Logging and CLI Responds to direct messages and mentions by echoing back the message Features verbose logging and command-line configuration *) open Zulip_bot (* Set up logging *) let src = Logs.Src.create "echo_bot" ~doc:"Zulip Echo Bot" module Log = (val Logs.src_log src : Logs.LOG) module Echo_bot_handler : Bot_handler.S = struct let initialize _config = Log.info (fun m -> m "Initializing echo bot handler"); Log.debug (fun m -> m "Bot handler initialized"); Ok () let usage () = "Echo Bot - I repeat everything you say to me!" let description () = "A simple echo bot that repeats messages sent to it. \ Send me a direct message or mention me in a channel." let handle_message ~config:_ ~storage ~identity ~message ~env:_ = (* Log the message with colorful formatting *) Log.debug (fun m -> m "@[Received: %a@]" (Message.pp_ansi ~show_json:false) message); (* Use the new Message type for cleaner handling *) match message with | Message.Private { common; display_recipient = _ } -> (* Check if this is our own message to avoid loops *) let bot_email = Bot_handler.Identity.email identity in if common.sender_email = bot_email then ( Log.debug (fun m -> m "Ignoring own message"); Ok Bot_handler.Response.None ) else (* Process the message content *) let sender_name = common.sender_full_name in (* Remove bot mention using Message utility *) let bot_email = Bot_handler.Identity.email identity in let cleaned_msg = Message.strip_mention message ~user_email:bot_email in Log.debug (fun m -> m "Cleaned message: %s" cleaned_msg); (* Create echo response *) let response_content = let lower_msg = String.lowercase_ascii cleaned_msg in if cleaned_msg = "" then Printf.sprintf "Hello %s! Send me a message and I'll echo it back!" sender_name else if lower_msg = "help" then Printf.sprintf "Hi %s! I'm an echo bot with storage. Commands:\n\ • `help` - Show this help\n\ • `ping` - Test if I'm alive\n\ • `store ` - Store a value\n\ • `get ` - Retrieve a value\n\ • `delete ` - Delete a stored value\n\ • `list` - List all stored keys\n\ • Any other message - I'll echo it back!" sender_name else if lower_msg = "ping" then ( Log.info (fun m -> m "Responding to ping from %s" sender_name); Printf.sprintf "Pong! 🏓 (from %s)" sender_name ) else if String.starts_with ~prefix:"store " lower_msg then ( (* Parse store command: store *) let parts = String.sub cleaned_msg 6 (String.length cleaned_msg - 6) |> String.trim in match String.index_opt parts ' ' with | Some idx -> let key = String.sub parts 0 idx |> String.trim in let value = String.sub parts (idx + 1) (String.length parts - idx - 1) |> String.trim in (match Bot_storage.put storage ~key ~value with | Ok () -> Log.info (fun m -> m "Stored key=%s value=%s for user %s" key value sender_name); Printf.sprintf "✅ Stored: `%s` = `%s`" key value | Error e -> Log.err (fun m -> m "Failed to store key=%s: %s" key (Zulip.error_message e)); Printf.sprintf "❌ Failed to store: %s" (Zulip.error_message e)) | None -> "Usage: `store ` - Example: `store name John`" ) else if String.starts_with ~prefix:"get " lower_msg then ( (* Parse get command: get *) let key = String.sub cleaned_msg 4 (String.length cleaned_msg - 4) |> String.trim in match Bot_storage.get storage ~key with | Some value -> Log.info (fun m -> m "Retrieved key=%s value=%s for user %s" key value sender_name); Printf.sprintf "📦 `%s` = `%s`" key value | None -> Log.info (fun m -> m "Key not found: %s" key); Printf.sprintf "❓ Key not found: `%s`" key ) else if String.starts_with ~prefix:"delete " lower_msg then ( (* Parse delete command: delete *) let key = String.sub cleaned_msg 7 (String.length cleaned_msg - 7) |> String.trim in match Bot_storage.remove storage ~key with | Ok () -> Log.info (fun m -> m "Deleted key=%s for user %s" key sender_name); Printf.sprintf "🗑️ Deleted key: `%s`" key | Error e -> Log.err (fun m -> m "Failed to delete key=%s: %s" key (Zulip.error_message e)); Printf.sprintf "❌ Failed to delete: %s" (Zulip.error_message e) ) else if lower_msg = "list" then ( (* List all stored keys *) match Bot_storage.keys storage with | Ok keys when keys = [] -> "📭 No keys stored yet. Use `store ` to add data!" | Ok keys -> let key_list = String.concat "\n" (List.map (fun k -> "• `" ^ k ^ "`") keys) in Printf.sprintf "📋 Stored keys:\n%s\n\nUse `get ` to retrieve values." key_list | Error e -> Printf.sprintf "❌ Failed to list keys: %s" (Zulip.error_message e) ) else Printf.sprintf "Echo from %s: %s" sender_name cleaned_msg in Log.debug (fun m -> m "Generated response: %s" response_content); Log.info (fun m -> m "Sending private reply"); Ok (Bot_handler.Response.Reply response_content) | Message.Stream { common; display_recipient = _; subject = _; _ } -> (* Check if this is our own message to avoid loops *) let bot_email = Bot_handler.Identity.email identity in if common.sender_email = bot_email then ( Log.debug (fun m -> m "Ignoring own message"); Ok Bot_handler.Response.None ) else (* Process the message content *) let sender_name = common.sender_full_name in (* Remove bot mention using Message utility *) let cleaned_msg = Message.strip_mention message ~user_email:bot_email in (* Create echo response *) let response_content = let lower_msg = String.lowercase_ascii cleaned_msg in if cleaned_msg = "" then Printf.sprintf "Hello %s! Send me a message and I'll echo it back!" sender_name else if lower_msg = "help" then Printf.sprintf "Hi %s! I'm an echo bot with storage. Commands:\n\ • `help` - Show this help\n\ • `ping` - Test if I'm alive\n\ • `store ` - Store a value\n\ • `get ` - Retrieve a value\n\ • `delete ` - Delete a stored value\n\ • `list` - List all stored keys\n\ • Any other message - I'll echo it back!" sender_name else if lower_msg = "ping" then ( Log.info (fun m -> m "Responding to ping from %s" sender_name); Printf.sprintf "Pong! 🏓 (from %s)" sender_name ) else if String.starts_with ~prefix:"store " lower_msg then ( (* Parse store command: store *) let parts = String.sub cleaned_msg 6 (String.length cleaned_msg - 6) |> String.trim in match String.index_opt parts ' ' with | Some idx -> let key = String.sub parts 0 idx |> String.trim in let value = String.sub parts (idx + 1) (String.length parts - idx - 1) |> String.trim in (match Bot_storage.put storage ~key ~value with | Ok () -> Log.info (fun m -> m "Stored key=%s value=%s for user %s" key value sender_name); Printf.sprintf "✅ Stored: `%s` = `%s`" key value | Error e -> Log.err (fun m -> m "Failed to store key=%s: %s" key (Zulip.error_message e)); Printf.sprintf "❌ Failed to store: %s" (Zulip.error_message e)) | None -> "Usage: `store ` - Example: `store name John`" ) else if String.starts_with ~prefix:"get " lower_msg then ( (* Parse get command: get *) let key = String.sub cleaned_msg 4 (String.length cleaned_msg - 4) |> String.trim in match Bot_storage.get storage ~key with | Some value -> Log.info (fun m -> m "Retrieved key=%s value=%s for user %s" key value sender_name); Printf.sprintf "📦 `%s` = `%s`" key value | None -> Log.info (fun m -> m "Key not found: %s" key); Printf.sprintf "❓ Key not found: `%s`" key ) else if String.starts_with ~prefix:"delete " lower_msg then ( (* Parse delete command: delete *) let key = String.sub cleaned_msg 7 (String.length cleaned_msg - 7) |> String.trim in match Bot_storage.remove storage ~key with | Ok () -> Log.info (fun m -> m "Deleted key=%s for user %s" key sender_name); Printf.sprintf "🗑️ Deleted key: `%s`" key | Error e -> Log.err (fun m -> m "Failed to delete key=%s: %s" key (Zulip.error_message e)); Printf.sprintf "❌ Failed to delete: %s" (Zulip.error_message e) ) else if lower_msg = "list" then ( (* List all stored keys *) match Bot_storage.keys storage with | Ok keys when keys = [] -> "📭 No keys stored yet. Use `store ` to add data!" | Ok keys -> let key_list = String.concat "\n" (List.map (fun k -> "• `" ^ k ^ "`") keys) in Printf.sprintf "📋 Stored keys:\n%s\n\nUse `get ` to retrieve values." key_list | Error e -> Printf.sprintf "❌ Failed to list keys: %s" (Zulip.error_message e) ) else Printf.sprintf "Echo from %s: %s" sender_name cleaned_msg in Log.debug (fun m -> m "Generated response: %s" response_content); Log.info (fun m -> m "Sending stream reply"); Ok (Bot_handler.Response.Reply response_content) | Message.Unknown _ -> Log.err (fun m -> m "Received unknown message format"); Ok Bot_handler.Response.None end let run_echo_bot config_file verbosity env = (* Set up logging based on verbosity *) Logs.set_reporter (Logs_fmt.reporter ()); let log_level = match verbosity with | 0 -> Logs.Info | 1 -> Logs.Debug | _ -> Logs.Debug (* Cap at debug level *) in Logs.set_level (Some log_level); (* Also set levels for related modules if they exist *) Logs.Src.set_level src (Some log_level); Log.app (fun m -> m "Starting Zulip Echo Bot"); Log.app (fun m -> m "Log level: %s" (Logs.level_to_string (Some log_level))); Log.app (fun m -> m "=============================\n"); (* Load authentication from .zuliprc file *) let auth = match Zulip.Auth.from_zuliprc ?path:config_file () with | Ok a -> Log.info (fun m -> m "Loaded authentication for: %s" (Zulip.Auth.email a)); Log.info (fun m -> m "Server: %s" (Zulip.Auth.server_url a)); a | Error e -> Log.err (fun m -> m "Failed to load .zuliprc: %s" (Zulip.error_message e)); Log.app (fun m -> m "\nPlease create a ~/.zuliprc file with:"); Log.app (fun m -> m "[api]"); Log.app (fun m -> m "email=bot@example.com"); Log.app (fun m -> m "key=your-api-key"); Log.app (fun m -> m "site=https://your-domain.zulipchat.com"); exit 1 in Eio.Switch.run @@ fun sw -> Log.debug (fun m -> m "Creating Zulip client"); let client = Zulip.Client.create ~sw env auth in (* Create bot configuration *) let config = Bot_config.create [] in let bot_email = Zulip.Auth.email auth in Log.debug (fun m -> m "Creating bot storage for %s" bot_email); let storage = Bot_storage.create client ~bot_email in let identity = Bot_handler.Identity.create ~full_name:"Echo Bot" ~email:bot_email ~mention_name:"echobot" in Log.info (fun m -> m "Bot identity created: %s (%s)" (Bot_handler.Identity.full_name identity) (Bot_handler.Identity.email identity)); (* Create and run the bot *) Log.debug (fun m -> m "Creating bot handler"); let handler = Bot_handler.create (module Echo_bot_handler) ~config ~storage ~identity in Log.debug (fun m -> m "Creating bot runner"); let runner = Bot_runner.create ~env ~client ~handler in Log.app (fun m -> m "Echo bot is running!"); Log.app (fun m -> m "Send a direct message or mention @echobot in a channel."); Log.app (fun m -> m "Commands: 'help', 'ping', or any message to echo"); Log.app (fun m -> m "Press Ctrl+C to stop.\n"); (* Run in real-time mode *) Log.info (fun m -> m "Starting real-time event loop"); try Bot_runner.run_realtime runner; Log.info (fun m -> m "Bot runner exited normally") with | Sys.Break -> Log.info (fun m -> m "Received interrupt signal, shutting down"); Bot_runner.shutdown runner | exn -> Log.err (fun m -> m "Bot crashed with exception: %s" (Printexc.to_string exn)); Log.debug (fun m -> m "Backtrace: %s" (Printexc.get_backtrace ())); raise exn (* Command-line interface *) open Cmdliner let config_file = let doc = "Path to .zuliprc configuration file" in Arg.(value & opt (some string) None & info ["c"; "config"] ~docv:"FILE" ~doc) let verbosity = let doc = "Increase verbosity. Use multiple times for more verbose output." in Arg.(value & flag_all & info ["v"; "verbose"] ~doc) let verbosity_term = Term.(const List.length $ verbosity) let bot_cmd eio_env = let doc = "Zulip Echo Bot with verbose logging" in let man = [ `S Manpage.s_description; `P "A simple echo bot for Zulip that responds to messages by echoing them back. \ Features verbose logging for debugging and development."; `S "CONFIGURATION"; `P "The bot reads configuration from a .zuliprc file (default: ~/.zuliprc)."; `P "The file should contain:"; `Pre "[api]\n\ email=bot@example.com\n\ key=your-api-key\n\ site=https://your-domain.zulipchat.com"; `S "LOGGING"; `P "Use -v for info level logging, -vv for debug level logging."; `P "Log messages include:"; `P "- Message metadata (sender, type, ID)"; `P "- Message processing steps"; `P "- Bot responses"; `P "- Error conditions"; `S "COMMANDS"; `P "The bot responds to:"; `P "- 'help' - Show usage information"; `P "- 'ping' - Respond with 'Pong!'"; `P "- Any other message - Echo it back"; `S Manpage.s_examples; `P "Run with default configuration:"; `Pre " echo_bot"; `P "Run with verbose logging:"; `Pre " echo_bot -v"; `P "Run with debug logging:"; `Pre " echo_bot -vv"; `P "Run with custom config file:"; `Pre " echo_bot -c /path/to/.zuliprc"; `P "Run with maximum verbosity and custom config:"; `Pre " echo_bot -vv -c ~/my-bot.zuliprc"; `S Manpage.s_bugs; `P "Report bugs at https://github.com/your-org/zulip-ocaml/issues"; `S Manpage.s_see_also; `P "zulip(1), zulip-bot(1)"; ] in let info = Cmd.info "echo_bot" ~version:"1.0.0" ~doc ~man in Cmd.v info Term.(const (run_echo_bot) $ config_file $ verbosity_term $ const eio_env) let () = (* Initialize the cryptographic RNG for the application *) Mirage_crypto_rng_unix.use_default (); Eio_main.run @@ fun env -> exit (Cmd.eval (bot_cmd env))