My agentic slop goes here. Not intended for anyone else!

more

+19 -20
stack/zulip/examples/atom_feed_bot.ml
···
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:_ =
-
(* Parse message content *)
-
let content = match message with
-
| `O fields ->
-
(match List.assoc_opt "content" fields with
-
| Some (`String s) -> s
-
| _ -> "")
-
| _ -> ""
-
in
+
let handle_message ~config:_ ~storage:_ ~identity ~message ~env:_ =
+
(* Get message content using Message accessor *)
+
let content = Message.content message in
-
(* 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)
+
(* 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
-
Ok Bot_handler.Response.none
+
(* 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
+1 -1
stack/zulip/examples/dune
···
(public_name echo_bot)
(name echo_bot)
(package zulip_bot)
-
(libraries zulip zulip_bot eio_main cmdliner logs logs.fmt))
+
(libraries zulip zulip_bot eio_main cmdliner logs logs.fmt mirage-crypto-rng.unix))
(executable
(public_name atom_feed_bot)
+179 -177
stack/zulip/examples/echo_bot.ml
···
open Zulip_bot
-
let () = Mirage_crypto_rng_unix.use_default ()
-
(* Set up logging *)
let src = Logs.Src.create "echo_bot" ~doc:"Zulip Echo Bot"
module Log = (val Logs.src_log src : Logs.LOG)
···
let handle_message ~config:_ ~storage ~identity ~message ~env:_ =
Log.debug (fun m -> m "Received message for processing");
-
(* Parse the incoming message *)
-
let extract_field fields name =
-
match List.assoc_opt name fields with
-
| Some (`String s) ->
-
Log.debug (fun m -> m "Extracted field %s: %s" name s);
-
Some s
-
| _ ->
-
Log.debug (fun m -> m "Field %s not found or not a string" name);
-
None
-
in
-
-
let extract_int_field fields name =
-
match List.assoc_opt name fields with
-
| Some (`Float f) ->
-
let i = int_of_float f in
-
Log.debug (fun m -> m "Extracted field %s: %d" name i);
-
Some i
-
| _ ->
-
Log.debug (fun m -> m "Field %s not found or not a number" name);
-
None
-
in
-
+
(* Use the new Message type for cleaner handling *)
match message with
-
| `O fields ->
-
Log.info (fun m -> m "Processing message with %d fields" (List.length fields));
-
-
(* Extract message details *)
-
let content = extract_field fields "content" in
-
let sender_email = extract_field fields "sender_email" in
-
let sender_full_name = extract_field fields "sender_full_name" in
-
let message_type = extract_field fields "type" in
-
let sender_id = extract_int_field fields "sender_id" in
-
let message_id = extract_int_field fields "id" in
-
-
(* Log message metadata *)
-
Log.info (fun m ->
-
m "Message metadata: type=%s, sender=%s (%s), id=%s"
-
(Option.value message_type ~default:"unknown")
-
(Option.value sender_full_name ~default:"unknown")
-
(Option.value sender_email ~default:"unknown")
-
(Option.fold ~none:"none" ~some:string_of_int message_id));
+
| Message.Private { common; display_recipient = _ } ->
+
Log.info (fun m -> m "Processing private message from %s (ID: %d)"
+
common.sender_email common.id);
(* Check if this is our own message to avoid loops *)
let bot_email = Bot_handler.Identity.email identity in
-
let is_own_message = match sender_email with
-
| Some email ->
-
let is_own = email = bot_email in
-
if is_own then
-
Log.debug (fun m -> m "Ignoring own message from %s" email);
-
is_own
-
| None -> false
-
in
-
-
if is_own_message then (
-
Log.debug (fun m -> m "Skipping response to own message");
+
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 response_content = match content, sender_full_name, sender_id with
-
| Some msg, Some name, Some id ->
-
Log.info (fun m -> m "Processing message from %s (ID: %d): %s" name id msg);
+
let sender_name = common.sender_full_name in
+
let sender_id = common.sender_id in
+
let msg = common.content in
-
(* Remove bot mention if present *)
-
let mention = "@**" ^ Bot_handler.Identity.mention_name identity ^ "**" in
-
let cleaned_msg =
-
if String.starts_with ~prefix:mention msg then (
-
let cleaned = String.sub msg
-
(String.length mention)
-
(String.length msg - String.length mention) in
-
Log.debug (fun m -> m "Removed bot mention, cleaned message: %s" cleaned);
-
String.trim cleaned
-
) else (
-
Log.debug (fun m -> m "No bot mention to remove");
-
String.trim msg
-
)
-
in
+
Log.info (fun m -> m "Processing message from %s (ID: %d): %s" sender_name sender_id msg);
-
(* Create echo response *)
-
let response =
-
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!" 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 <key> <value>` - Store a value\n\
-
• `get <key>` - Retrieve a value\n\
-
• `delete <key>` - Delete a stored value\n\
-
• `list` - List all stored keys\n\
-
• Any other message - I'll echo it back!" name
-
else if lower_msg = "ping" then (
-
Log.info (fun m -> m "Responding to ping from %s" name);
-
Printf.sprintf "Pong! 🏓 (from %s)" name
-
)
-
else if String.starts_with ~prefix:"store " lower_msg then (
-
(* Parse store command: store <key> <value> *)
-
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 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 <key> <value>` - Example: `store name John`"
-
)
-
else if String.starts_with ~prefix:"get " lower_msg then (
-
(* Parse get command: get <key> *)
-
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 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 <key> *)
-
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 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 <key> <value>` 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 <key>` 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" name cleaned_msg
-
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);
-
Log.debug (fun m -> m "Generated response: %s" response);
-
response
+
(* 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 <key> <value>` - Store a value\n\
+
• `get <key>` - Retrieve a value\n\
+
• `delete <key>` - 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 <key> <value> *)
+
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 <key> <value>` - Example: `store name John`"
+
)
+
else if String.starts_with ~prefix:"get " lower_msg then (
+
(* Parse get command: get <key> *)
+
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 <key> *)
+
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 <key> <value>` 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 <key>` 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
-
| Some msg, None, _ ->
-
Log.warn (fun m -> m "Message without sender name: %s" msg);
-
Printf.sprintf "Echo: %s" msg
+
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)
-
| None, _, _ ->
-
Log.warn (fun m -> m "Received message without content");
-
"I couldn't understand that message."
+
| Message.Stream { common; display_recipient; subject; _ } ->
+
Log.info (fun m -> m "Processing stream message from %s in %s/%s"
+
common.sender_email display_recipient subject);
-
| _ ->
-
Log.warn (fun m -> m "Incomplete message data");
-
"I couldn't process that message properly."
-
in
+
(* 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
-
(* Determine response type based on original message type *)
-
let response = match message_type with
-
| Some "private" ->
-
Log.info (fun m -> m "Sending private reply: %s" response_content);
-
Bot_handler.Response.Reply response_content
-
| Some "stream" ->
-
Log.info (fun m -> m "Sending stream reply: %s" response_content);
-
Bot_handler.Response.Reply response_content
-
| Some other ->
-
Log.warn (fun m -> m "Unknown message type: %s" other);
-
Bot_handler.Response.None
-
| None ->
-
Log.warn (fun m -> m "No message type specified");
-
Bot_handler.Response.None
+
(* 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 <key> <value>` - Store a value\n\
+
• `get <key>` - Retrieve a value\n\
+
• `delete <key>` - 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 <key> <value> *)
+
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 <key> <value>` - Example: `store name John`"
+
)
+
else if String.starts_with ~prefix:"get " lower_msg then (
+
(* Parse get command: get <key> *)
+
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 <key> *)
+
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 <key> <value>` 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 <key>` 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
-
Ok response
+
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)
-
| other ->
-
Log.err (fun m -> m "Received non-object message: %s"
-
(match other with
-
| `A _ -> "array"
-
| `Bool _ -> "bool"
-
| `Float _ -> "float"
-
| `String _ -> "string"
-
| `Null -> "null"
-
| _ -> "unknown"));
+
| Message.Unknown _ ->
+
Log.err (fun m -> m "Received unknown message format");
Ok Bot_handler.Response.None
end
···
Cmd.v info Term.(const (run_echo_bot) $ config_file $ verbosity_term $ const 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))
+123
stack/zulip/examples/test_realtime_bot.ml
···
+
(* Test script to verify real-time bot event processing *)
+
+
open Zulip_bot
+
+
(* Logging setup *)
+
let src = Logs.Src.create "test_realtime_bot" ~doc:"Test real-time bot"
+
module Log = (val Logs.src_log src : Logs.LOG)
+
+
(* Simple test bot that logs everything *)
+
module Test_bot_handler : Bot_handler.S = struct
+
let initialize _config =
+
Log.info (fun m -> m "Bot initialized");
+
Ok ()
+
+
let usage () = "Test Bot - Verifies real-time event processing"
+
+
let description () = "A test bot that logs all messages received"
+
+
let handle_message ~config:_ ~storage ~identity:_ ~message ~env:_ =
+
Log.info (fun m -> m "Received message");
+
+
(* Extract and log message details *)
+
let content = Bot_handler.Message_context.content message in
+
let sender = Bot_handler.Message_context.sender_email message in
+
let is_direct = Bot_handler.Message_context.is_direct message in
+
+
Log.info (fun m -> m "Content: %s"
+
(Option.value content ~default:"<none>"));
+
Log.info (fun m -> m "Sender: %s"
+
(Option.value sender ~default:"<unknown>"));
+
Log.info (fun m -> m "Direct: %b" is_direct);
+
+
(* Test storage *)
+
let test_key = "last_message" in
+
let test_value = Option.value content ~default:"" in
+
+
(match Bot_storage.put storage ~key:test_key ~value:test_value with
+
| Ok () -> Log.info (fun m -> m "Stored message in bot storage")
+
| Error e -> Log.err (fun m -> m "Storage error: %s" (Zulip.Error.message e)));
+
+
(* Always reply with confirmation *)
+
let reply = Printf.sprintf "Test bot received: %s"
+
(Option.value content ~default:"<empty>") in
+
Ok (Bot_handler.Response.Reply reply)
+
end
+
+
let run_test 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 "Real-time Bot Test");
+
Log.info (fun m -> m "==================");
+
+
(* Load auth *)
+
let auth = match Zulip.Auth.from_zuliprc () with
+
| Ok a ->
+
Log.info (fun m -> m "Loaded auth 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));
+
exit 1
+
in
+
+
Eio.Switch.run @@ fun sw ->
+
let client = Zulip.Client.create ~sw env auth in
+
+
(* Create bot components *)
+
let config = Bot_config.create [] in
+
let bot_email = Zulip.Auth.email auth in
+
let storage = Bot_storage.create client ~bot_email in
+
let identity = Bot_handler.Identity.create
+
~full_name:"Test Bot"
+
~email:bot_email
+
~mention_name:"testbot"
+
in
+
+
(* Create handler and runner *)
+
let handler = Bot_handler.create
+
(module Test_bot_handler)
+
~config ~storage ~identity
+
in
+
let runner = Bot_runner.create ~env ~client ~handler in
+
+
Log.info (fun m -> m "Starting bot in real-time mode...");
+
Log.info (fun m -> m "The bot will:");
+
Log.info (fun m -> m "- Register for message events");
+
Log.info (fun m -> m "- Poll for new messages");
+
Log.info (fun m -> m "- Process and reply to messages");
+
Log.info (fun m -> m "- Store messages in Zulip bot storage");
+
Log.info (fun m -> m "Press Ctrl+C to stop.");
+
+
(* Run the bot *)
+
Bot_runner.run_realtime runner
+
+
(* 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 main_cmd =
+
let doc = "Test real-time bot for Zulip" in
+
let man = [
+
`S Manpage.s_description;
+
`P "This bot tests real-time event processing with the Zulip API. \
+
It will echo received messages and store them in bot storage.";
+
`P "The bot requires a configured ~/.zuliprc file with API credentials.";
+
] in
+
let info = Cmd.info "test_realtime_bot" ~version:"1.0.0" ~doc ~man in
+
let run verbosity =
+
Eio_main.run (run_test verbosity)
+
in
+
let term = Term.(const run $ verbosity) in
+
Cmd.v info term
+
+
let () = exit (Cmd.eval main_cmd)
+2 -31
stack/zulip/lib/zulip_bot/lib/bot_handler.ml
···
let mention_name t = t.mention_name
end
-
module Message_context = struct
-
type t = Zulip.Error.json
-
-
let of_json json = json
-
let create json = json (* Add create as alias for of_json *)
-
-
let sender_email t =
-
match t with
-
| `O fields ->
-
(match List.assoc_opt "sender_email" fields with
-
| Some (`String s) -> Some s
-
| _ -> None)
-
| _ -> None
-
-
let content t =
-
match t with
-
| `O fields ->
-
(match List.assoc_opt "content" fields with
-
| Some (`String s) -> Some s
-
| _ -> None)
-
| _ -> None
-
-
let is_direct t =
-
match t with
-
| `O fields ->
-
(match List.assoc_opt "type" fields with
-
| Some (`String "direct") -> true
-
| _ -> false)
-
| _ -> false
-
end
(** Module signature for bot implementations *)
module type Bot_handler = sig
···
config:Bot_config.t ->
storage:Bot_storage.t ->
identity:Identity.t ->
-
message:Message_context.t ->
+
message:Message.t ->
env:_ ->
(Response.t, Zulip.Error.t) result
end
···
~identity:t.identity
~message
~env
+
let identity t = t.identity
let usage t =
+3 -12
stack/zulip/lib/zulip_bot/lib/bot_handler.mli
···
val mention_name : t -> string
end
-
(** Message context passed to bot handlers *)
-
module Message_context : sig
-
type t = Zulip.Error.json
-
-
val of_json : Zulip.Error.json -> t
-
val create : Zulip.Error.json -> t (* Alias for of_json *)
-
val sender_email : t -> string option
-
val content : t -> string option
-
val is_direct : t -> bool
-
end
(** Module signature for bot implementations *)
module type Bot_handler = sig
···
config:Bot_config.t ->
storage:Bot_storage.t ->
identity:Identity.t ->
-
message:Message_context.t ->
+
message:Message.t ->
env:_ ->
(Response.t, Zulip.Error.t) result
end
···
t
(** Process an incoming message with EIO environment *)
-
val handle_message_with_env : t -> _ -> Message_context.t -> (Response.t, Zulip.Error.t) result
+
val handle_message_with_env : t -> _ -> Message.t -> (Response.t, Zulip.Error.t) result
+
(** Get bot identity *)
val identity : t -> Identity.t
+86 -77
stack/zulip/lib/zulip_bot/lib/bot_runner.ml
···
let src = Logs.Src.create "zulip_bot.runner" ~doc:"Zulip bot runner"
module Log = (val Logs.src_log src : Logs.LOG)
+
(* Initialize crypto RNG - now done at module load time via Mirage_crypto_rng_unix *)
+
let () =
+
try
+
let _ = Mirage_crypto_rng.generate ~g:(Mirage_crypto_rng.default_generator ()) 0 in
+
()
+
with _ ->
+
(* Generator not initialized - this will be done by applications using the library *)
+
()
+
+
(* Convert Zulip.Error.json to Yojson.Safe.t *)
+
let rec convert_json : Zulip.Error.json -> Yojson.Safe.t = function
+
| `Null -> `Null
+
| `Bool b -> `Bool b
+
| `Float f -> `Float f
+
| `String s -> `String s
+
| `A lst -> `List (List.map convert_json lst)
+
| `O pairs -> `Assoc (List.map (fun (k, v) -> (k, convert_json v)) pairs)
+
type 'env t = {
client : Zulip.Client.t;
handler : Bot_handler.t;
···
let event_data = Zulip.Event.data event in
(* Extract the actual message from the event *)
-
let message, flags =
+
let message_json, flags =
match event_data with
| `O fields ->
let msg = match List.assoc_opt "message" fields with
···
| _ -> (event_data, [])
in
-
(* Check if mentioned *)
-
let is_mentioned = List.exists (function `String "mentioned" -> true | _ -> false) flags in
+
(* Parse the message JSON into Message.t *)
+
(match Message.of_json (convert_json message_json) with
+
| Error err ->
+
Log.err (fun m -> m "Failed to parse message JSON: %s" err);
+
| Ok message ->
+
(* Get bot identity for checking mentions *)
+
let bot_email = Bot_handler.Identity.email (Bot_handler.identity t.handler) in
-
(* Check if it's a private message *)
-
let is_private =
-
match message with
-
| `O fields ->
-
(match List.assoc_opt "type" fields with
-
| Some (`String "private") -> true
-
| _ -> false)
-
| _ -> false
-
in
+
(* Check if mentioned *)
+
let is_mentioned =
+
List.exists (function `String "mentioned" -> true | _ -> false) flags ||
+
Message.is_mentioned message ~user_email:bot_email in
+
+
(* Check if it's a private message *)
+
let is_private = Message.is_private message in
+
+
(* Don't respond to our own messages *)
+
let is_from_self = Message.is_from_email message ~email:bot_email in
+
+
(* Log what we found *)
+
Log.info (fun m -> m "Message check: mentioned=%b, private=%b, from_self=%b"
+
is_mentioned is_private is_from_self);
-
(* Log what we found *)
-
Log.info (fun m -> m "Message check: mentioned=%b, private=%b" is_mentioned is_private);
+
(* Only process if bot was mentioned or it's a private message, and not from self *)
+
if (is_mentioned || is_private) && not is_from_self then (
+
Log.info (fun m -> m "Bot should respond to this message");
-
(* Only process if bot was mentioned or it's a private message *)
-
if is_mentioned || is_private then (
-
Log.info (fun m -> m "Bot should respond to this message");
+
(* Handle the message using the new Message.t type *)
+
match Bot_handler.handle_message_with_env t.handler t.env message with
+
| Ok (Bot_handler.Response.Reply content) ->
+
Log.debug (fun m -> m "Bot is sending reply: %s" content);
+
(* Send reply back using Message utilities *)
+
let message_to_send =
+
if Message.is_private message then
+
(* Reply to private message *)
+
let sender = Message.sender_email message in
+
Log.debug (fun m -> m "Replying to sender: %s" sender);
+
Zulip.Message.create ~type_:`Direct ~to_:[sender] ~content ()
+
else
+
(* Reply to stream message *)
+
let reply_to = Message.get_reply_to message in
+
let topic =
+
match message with
+
| Message.Stream { subject; _ } -> Some subject
+
| _ -> None
+
in
+
Zulip.Message.create ~type_:`Channel ~to_:[reply_to] ~content ?topic ()
+
in
-
(* Create message context and handle *)
-
let context = Bot_handler.Message_context.create message in
-
match Bot_handler.handle_message_with_env t.handler t.env context with
-
| Ok (Bot_handler.Response.Reply content) ->
-
Log.debug (fun m -> m "Bot is sending reply: %s" content);
-
(* Send reply back *)
-
let message_to_send =
-
match message with
-
| `O fields ->
-
let message_type =
-
match List.assoc_opt "type" fields with
-
| Some (`String "private") -> `Direct
-
| _ -> `Channel
-
in
-
let to_ =
-
match message_type with
-
| `Direct ->
-
(* For private messages, reply to the sender *)
-
(match List.assoc_opt "sender_email" fields with
-
| Some (`String email) ->
-
Log.debug (fun m -> m "Replying to sender: %s" email);
-
[email]
-
| _ ->
-
Log.warn (fun m -> m "Could not find sender_email");
-
[])
-
| `Channel ->
-
(* Reply to channel *)
-
(match List.assoc_opt "display_recipient" fields with
-
| Some (`String channel) -> [channel]
-
| _ -> [])
-
in
-
let topic =
-
match List.assoc_opt "subject" fields with
-
| Some (`String topic) -> Some topic
-
| _ -> None
-
in
-
Zulip.Message.create ~type_:message_type ~to_ ~content ?topic ()
-
| _ ->
-
(* Fallback: can't determine where to reply *)
-
Log.warn (fun m -> m "Could not determine message structure for reply");
-
Zulip.Message.create ~type_:`Direct ~to_:[] ~content ()
-
in
+
(match Zulip.Messages.send t.client message_to_send with
+
| Ok resp ->
+
Log.info (fun m -> m "Reply sent successfully (id: %d)"
+
(Zulip.Message_response.id resp))
+
| Error e ->
+
Log.err (fun m -> m "Error sending reply: %s" (Zulip.Error.message e)))
-
(match Zulip.Messages.send t.client message_to_send with
-
| Ok resp ->
-
Log.info (fun m -> m "Reply sent successfully (id: %d)"
-
(Zulip.Message_response.id resp))
+
| Ok (Bot_handler.Response.None) ->
+
Log.info (fun m -> m "Bot handler returned no response")
+
| Ok _ ->
+
Log.info (fun m -> m "Bot handler returned unhandled response type")
| Error e ->
-
Log.err (fun m -> m "Error sending reply: %s" (Zulip.Error.message e)))
-
-
| Ok (Bot_handler.Response.None) ->
-
Log.info (fun m -> m "Bot handler returned no response")
-
| Ok _ ->
-
Log.info (fun m -> m "Bot handler returned unhandled response type")
-
| Error e ->
-
Log.err (fun m -> m "Error handling message: %s" (Zulip.Error.message e))
-
) else (
-
Log.info (fun m -> m "Not processing message (not mentioned and not private)")
-
)
+
Log.err (fun m -> m "Error handling message: %s" (Zulip.Error.message e))
+
) else (
+
Log.info (fun m -> m "Not processing message (not mentioned and not private)")
+
)
+
)
| _ -> () (* Ignore non-message events for now *)
let run_realtime t =
···
let handle_webhook t ~webhook_data =
(* Process webhook data and route to handler *)
-
match Bot_handler.handle_message_with_env t.handler t.env webhook_data with
-
| Ok response -> Ok (Some response)
-
| Error e -> Error e
+
(* Parse the webhook data into Message.t first *)
+
match Message.of_json (convert_json webhook_data) with
+
| Error err -> Error (Zulip.Error.create ~code:(Zulip.Error.Other "parse_error") ~msg:("Failed to parse webhook message: " ^ err) ())
+
| Ok message ->
+
match Bot_handler.handle_message_with_env t.handler t.env message with
+
| Ok response -> Ok (Some response)
+
| Error e -> Error e
let shutdown t =
t.running <- false;
+2 -1
stack/zulip/lib/zulip_bot/lib/dune
···
(library
(public_name zulip_bot)
(name zulip_bot)
-
(libraries zulip unix eio logs))
+
(libraries zulip unix eio logs mirage-crypto-rng)
+
(flags (:standard -warn-error -3)))
+296
stack/zulip/lib/zulip_bot/lib/message.ml
···
+
open Yojson.Safe.Util
+
+
let logs_src = Logs.Src.create "zulip_bot.message"
+
module Log = (val Logs.src_log logs_src : Logs.LOG)
+
+
(** User representation *)
+
module User = struct
+
type t = {
+
user_id: int;
+
email: string;
+
full_name: string;
+
short_name: string option;
+
}
+
+
let user_id t = t.user_id
+
let email t = t.email
+
let full_name t = t.full_name
+
let short_name t = t.short_name
+
+
let of_json json =
+
try
+
let user_id = json |> member "user_id" |> to_int in
+
let email = json |> member "email" |> to_string in
+
let full_name = json |> member "full_name" |> to_string in
+
let short_name = json |> member "short_name" |> to_string_option in
+
Ok { user_id; email; full_name; short_name }
+
with
+
| Type_error (msg, _) -> Error ("User JSON parse error: " ^ msg)
+
| exn -> Error ("User JSON parse error: " ^ Printexc.to_string exn)
+
end
+
+
(** Reaction representation *)
+
module Reaction = struct
+
type t = {
+
emoji_name: string;
+
emoji_code: string;
+
reaction_type: string;
+
user_id: int;
+
}
+
+
let emoji_name t = t.emoji_name
+
let emoji_code t = t.emoji_code
+
let reaction_type t = t.reaction_type
+
let user_id t = t.user_id
+
+
let of_json json =
+
try
+
let emoji_name = json |> member "emoji_name" |> to_string in
+
let emoji_code = json |> member "emoji_code" |> to_string in
+
let reaction_type = json |> member "reaction_type" |> to_string in
+
let user_id = json |> member "user" |> member "user_id" |> to_int in
+
Ok { emoji_name; emoji_code; reaction_type; user_id }
+
with
+
| Type_error (msg, _) -> Error ("Reaction JSON parse error: " ^ msg)
+
| exn -> Error ("Reaction JSON parse error: " ^ Printexc.to_string exn)
+
end
+
+
(** Common message fields *)
+
type common = {
+
id: int;
+
sender_id: int;
+
sender_email: string;
+
sender_full_name: string;
+
sender_short_name: string option;
+
timestamp: float;
+
content: string;
+
content_type: string;
+
reactions: Reaction.t list;
+
submessages: Yojson.Safe.t list;
+
flags: string list;
+
is_me_message: bool;
+
client: string;
+
gravatar_hash: string;
+
avatar_url: string option;
+
}
+
+
(** Message types *)
+
type t =
+
| Private of {
+
common: common;
+
display_recipient: User.t list;
+
}
+
| Stream of {
+
common: common;
+
display_recipient: string;
+
stream_id: int;
+
subject: string;
+
}
+
| Unknown of {
+
common: common;
+
raw_json: Yojson.Safe.t;
+
}
+
+
(** Helper function to parse common fields *)
+
let parse_common json =
+
try
+
let id = json |> member "id" |> to_int in
+
let sender_id = json |> member "sender_id" |> to_int in
+
let sender_email = json |> member "sender_email" |> to_string in
+
let sender_full_name = json |> member "sender_full_name" |> to_string in
+
let sender_short_name = json |> member "sender_short_name" |> to_string_option in
+
let timestamp = json |> member "timestamp" |> to_float in
+
let content = json |> member "content" |> to_string in
+
let content_type = json |> member "content_type" |> to_string_option |> Option.value ~default:"text/html" in
+
+
let reactions =
+
try
+
json |> member "reactions" |> to_list |> List.map (fun r ->
+
match Reaction.of_json r with
+
| Ok reaction -> reaction
+
| Error err ->
+
Log.warn (fun m -> m "Failed to parse reaction: %s" err);
+
failwith "reaction parse error"
+
)
+
with
+
| Type_error _ -> []
+
| _ -> []
+
in
+
+
let submessages =
+
try json |> member "submessages" |> to_list
+
with Type_error _ -> []
+
in
+
+
let flags =
+
try json |> member "flags" |> to_list |> List.map to_string
+
with Type_error _ -> []
+
in
+
+
let is_me_message =
+
try json |> member "is_me_message" |> to_bool
+
with Type_error _ -> false
+
in
+
+
let client =
+
try json |> member "client" |> to_string
+
with Type_error _ -> ""
+
in
+
+
let gravatar_hash =
+
try json |> member "gravatar_hash" |> to_string
+
with Type_error _ -> ""
+
in
+
+
let avatar_url = json |> member "avatar_url" |> to_string_option in
+
+
Ok {
+
id; sender_id; sender_email; sender_full_name; sender_short_name;
+
timestamp; content; content_type; reactions; submessages; flags;
+
is_me_message; client; gravatar_hash; avatar_url;
+
}
+
with
+
| Type_error (msg, _) -> Error ("Common fields parse error: " ^ msg)
+
| exn -> Error ("Common fields parse error: " ^ Printexc.to_string exn)
+
+
(** JSON parsing *)
+
let of_json json =
+
Log.debug (fun m -> m "Parsing message JSON: %s" (Yojson.Safe.pretty_to_string json));
+
+
match parse_common json with
+
| Error err -> Error err
+
| Ok common ->
+
try
+
let msg_type = json |> member "type" |> to_string in
+
match msg_type with
+
| "private" ->
+
let display_recipient_json = json |> member "display_recipient" |> to_list in
+
let users = List.map (fun user_json ->
+
match User.of_json user_json with
+
| Ok user -> user
+
| Error err ->
+
Log.warn (fun m -> m "Failed to parse user in display_recipient: %s" err);
+
failwith "user parse error"
+
) display_recipient_json in
+
Ok (Private { common; display_recipient = users })
+
+
| "stream" ->
+
let display_recipient = json |> member "display_recipient" |> to_string in
+
let stream_id = json |> member "stream_id" |> to_int in
+
let subject = json |> member "subject" |> to_string in
+
Ok (Stream { common; display_recipient; stream_id; subject })
+
+
| unknown_type ->
+
Log.warn (fun m -> m "Unknown message type: %s" unknown_type);
+
Ok (Unknown { common; raw_json = json })
+
+
with
+
| Type_error (msg, _) -> Error ("Message type parse error: " ^ msg)
+
| exn -> Error ("Message type parse error: " ^ Printexc.to_string exn)
+
+
(** Accessor functions *)
+
let get_common = function
+
| Private { common; _ } -> common
+
| Stream { common; _ } -> common
+
| Unknown { common; _ } -> common
+
+
let id msg = (get_common msg).id
+
let sender_id msg = (get_common msg).sender_id
+
let sender_email msg = (get_common msg).sender_email
+
let sender_full_name msg = (get_common msg).sender_full_name
+
let sender_short_name msg = (get_common msg).sender_short_name
+
let timestamp msg = (get_common msg).timestamp
+
let content msg = (get_common msg).content
+
let content_type msg = (get_common msg).content_type
+
let reactions msg = (get_common msg).reactions
+
let submessages msg = (get_common msg).submessages
+
let flags msg = (get_common msg).flags
+
let is_me_message msg = (get_common msg).is_me_message
+
let client msg = (get_common msg).client
+
let gravatar_hash msg = (get_common msg).gravatar_hash
+
let avatar_url msg = (get_common msg).avatar_url
+
+
(** Helper functions *)
+
let is_private = function
+
| Private _ -> true
+
| _ -> false
+
+
let is_stream = function
+
| Stream _ -> true
+
| _ -> false
+
+
let is_from_self msg ~bot_user_id =
+
sender_id msg = bot_user_id
+
+
let is_from_email msg ~email =
+
sender_email msg = email
+
+
let get_reply_to = function
+
| Private { display_recipient; _ } ->
+
display_recipient
+
|> List.map User.email
+
|> String.concat ","
+
| Stream { display_recipient; _ } -> display_recipient
+
| Unknown _ -> ""
+
+
(** Utility functions *)
+
let is_mentioned msg ~user_email =
+
let content_text = content msg in
+
let mention_pattern = "@**" ^ user_email ^ "**" in
+
String.length mention_pattern > 0 &&
+
let rec search_from pos =
+
if pos > String.length content_text - String.length mention_pattern then
+
false
+
else if String.sub content_text pos (String.length mention_pattern) = mention_pattern then
+
true
+
else
+
search_from (pos + 1)
+
in
+
search_from 0
+
+
let strip_mention msg ~user_email =
+
let content_text = content msg in
+
let mention_pattern = "@**" ^ user_email ^ "**" in
+
let re = Re.Perl.compile_pat mention_pattern in
+
Re.replace_string re ~by:"" content_text |> String.trim
+
+
let extract_command msg =
+
let content_text = String.trim (content msg) in
+
if String.length content_text > 0 && content_text.[0] = '!' then
+
Some (String.sub content_text 1 (String.length content_text - 1))
+
else
+
None
+
+
let parse_command msg =
+
match extract_command msg with
+
| None -> None
+
| Some cmd_text ->
+
let parts = String.split_on_char ' ' cmd_text |> List.filter (fun s -> s <> "") in
+
match parts with
+
| [] -> None
+
| cmd :: args -> Some (cmd, args)
+
+
(** Pretty printing *)
+
let pp_user fmt user =
+
Format.fprintf fmt "{ user_id=%d; email=%s; full_name=%s }"
+
(User.user_id user) (User.email user) (User.full_name user)
+
+
let _pp_reaction fmt reaction =
+
Format.fprintf fmt "{ emoji_name=%s; user_id=%d }"
+
(Reaction.emoji_name reaction) (Reaction.user_id reaction)
+
+
let pp fmt = function
+
| Private { common; display_recipient } ->
+
Format.fprintf fmt "Private { id=%d; sender=%s; recipients=[%a]; content=%S }"
+
common.id common.sender_email
+
(Format.pp_print_list ~pp_sep:(fun fmt () -> Format.fprintf fmt "; ") pp_user)
+
display_recipient
+
common.content
+
+
| Stream { common; display_recipient; subject; _ } ->
+
Format.fprintf fmt "Stream { id=%d; sender=%s; stream=%s; subject=%s; content=%S }"
+
common.id common.sender_email display_recipient subject common.content
+
+
| Unknown { common; _ } ->
+
Format.fprintf fmt "Unknown { id=%d; sender=%s; content=%S }"
+
common.id common.sender_email common.content
+103
stack/zulip/lib/zulip_bot/lib/message.mli
···
+
(** Zulip message types and utilities *)
+
+
(** User representation *)
+
module User : sig
+
type t = {
+
user_id: int;
+
email: string;
+
full_name: string;
+
short_name: string option;
+
}
+
+
val user_id : t -> int
+
val email : t -> string
+
val full_name : t -> string
+
val short_name : t -> string option
+
end
+
+
(** Reaction representation *)
+
module Reaction : sig
+
type t = {
+
emoji_name: string;
+
emoji_code: string;
+
reaction_type: string;
+
user_id: int;
+
}
+
+
val emoji_name : t -> string
+
val emoji_code : t -> string
+
val reaction_type : t -> string
+
val user_id : t -> int
+
end
+
+
(** Common message fields *)
+
type common = {
+
id: int;
+
sender_id: int;
+
sender_email: string;
+
sender_full_name: string;
+
sender_short_name: string option;
+
timestamp: float;
+
content: string;
+
content_type: string;
+
reactions: Reaction.t list;
+
submessages: Yojson.Safe.t list;
+
flags: string list;
+
is_me_message: bool;
+
client: string;
+
gravatar_hash: string;
+
avatar_url: string option;
+
}
+
+
(** Message types *)
+
type t =
+
| Private of {
+
common: common;
+
display_recipient: User.t list;
+
}
+
| Stream of {
+
common: common;
+
display_recipient: string;
+
stream_id: int;
+
subject: string;
+
}
+
| Unknown of {
+
common: common;
+
raw_json: Yojson.Safe.t;
+
}
+
+
(** Accessor functions *)
+
val id : t -> int
+
val sender_id : t -> int
+
val sender_email : t -> string
+
val sender_full_name : t -> string
+
val sender_short_name : t -> string option
+
val timestamp : t -> float
+
val content : t -> string
+
val content_type : t -> string
+
val reactions : t -> Reaction.t list
+
val submessages : t -> Yojson.Safe.t list
+
val flags : t -> string list
+
val is_me_message : t -> bool
+
val client : t -> string
+
val gravatar_hash : t -> string
+
val avatar_url : t -> string option
+
+
(** Helper functions *)
+
val is_private : t -> bool
+
val is_stream : t -> bool
+
val is_from_self : t -> bot_user_id:int -> bool
+
val is_from_email : t -> email:string -> bool
+
val get_reply_to : t -> string
+
+
(** Utility functions *)
+
val is_mentioned : t -> user_email:string -> bool
+
val strip_mention : t -> user_email:string -> string
+
val extract_command : t -> string option
+
val parse_command : t -> (string * string list) option
+
+
(** JSON parsing *)
+
val of_json : Yojson.Safe.t -> (t, string) result
+
+
(** Pretty printing *)
+
val pp : Format.formatter -> t -> unit