···1313 full_name: string;
1414 registered_at: float;
1515 is_admin: bool;
1616- last_river_post_date: float option; (** Timestamp of last River post for this user *)
1716}
18171918(** Parse a user registration from JSON-like string format *)
2019let user_registration_of_string s : user_registration option =
2120 try
2222- (* Format: "email|zulip_id|full_name|timestamp|is_admin|last_river_post_date" *)
2121+ (* Format: "email|zulip_id|full_name|timestamp|is_admin" *)
2322 match String.split_on_char '|' s with
2424- | [email; zulip_id_str; full_name; timestamp_str; is_admin_str; last_river_str] ->
2525- let last_river_post_date =
2626- if last_river_str = "" || last_river_str = "none" then None
2727- else Some (float_of_string last_river_str)
2828- in
2929- Some {
3030- email;
3131- zulip_id = int_of_string zulip_id_str;
3232- full_name;
3333- registered_at = float_of_string timestamp_str;
3434- is_admin = bool_of_string is_admin_str;
3535- last_river_post_date;
3636- }
3723 | [email; zulip_id_str; full_name; timestamp_str; is_admin_str] ->
3838- (* Backward compatibility - old format without last_river_post_date *)
3924 Some {
4025 email;
4126 zulip_id = int_of_string zulip_id_str;
4227 full_name;
4328 registered_at = float_of_string timestamp_str;
4429 is_admin = bool_of_string is_admin_str;
4545- last_river_post_date = None;
4630 }
4731 | [email; zulip_id_str; full_name; timestamp_str] ->
4848- (* Backward compatibility - old format without is_admin and last_river_post_date *)
3232+ (* Backward compatibility - old format without is_admin *)
4933 Some {
5034 email;
5135 zulip_id = int_of_string zulip_id_str;
5236 full_name;
5337 registered_at = float_of_string timestamp_str;
5438 is_admin = false;
5555- last_river_post_date = None;
5639 }
5740 | _ -> None
5841 with _ -> None
59426043(** Convert a user registration to string format *)
6144let user_registration_to_string (reg : user_registration) : string =
6262- let last_river_str = match reg.last_river_post_date with
6363- | None -> "none"
6464- | Some t -> string_of_float t
6565- in
6666- Printf.sprintf "%s|%d|%s|%f|%b|%s"
4545+ Printf.sprintf "%s|%d|%s|%f|%b"
6746 reg.email
6847 reg.zulip_id
6948 reg.full_name
7049 reg.registered_at
7150 reg.is_admin
7272- last_river_str
73517452(** Storage key for a user registration by Zulip ID - this is the only storage key we use *)
7553let storage_key_for_id zulip_id = Printf.sprintf "user:id:%d" zulip_id
···150128 | None -> is_admin || (zulip_id = default_admin_id)
151129 in
152130153153- (* Preserve last_river_post_date if user already exists *)
154154- let last_river_post_date = match existing_by_id with
155155- | Some existing -> existing.last_river_post_date
156156- | None -> None
157157- in
158158-159131 let reg = {
160132 email;
161133 zulip_id;
162134 full_name;
163135 registered_at = Unix.gettimeofday ();
164136 is_admin = final_is_admin;
165165- last_river_post_date;
166137 } in
167138 let reg_str = user_registration_to_string reg in
168139···207178 String.contains domain '.'
208179 | _ -> false
209180210210-(** {1 River Integration Helper Functions} *)
211211-212212-(** Configuration storage keys for River *)
213213-let river_feeds_key = "river:feeds:list"
214214-let river_channel_key = "river:channel"
215215-let river_polling_enabled_key = "river:polling:enabled"
216216-let river_last_sync_key = "river:last_sync"
217217-let river_default_channel = "Sandbox-test"
218218-219219-(** Feed source codec *)
220220-let feed_source_jsont =
221221- let make name url = { River.name; url } in
222222- Jsont.Object.map ~kind:"FeedSource" make
223223- |> Jsont.Object.mem "name" Jsont.string ~enc:(fun s -> s.River.name)
224224- |> Jsont.Object.mem "url" Jsont.string ~enc:(fun s -> s.River.url)
225225- |> Jsont.Object.finish
226226-227227-(** Load feed sources from bot storage *)
228228-let load_feed_sources storage =
229229- match Bot_storage.get storage ~key:river_feeds_key with
230230- | Some json_str when json_str <> "" ->
231231- (match Jsont_bytesrw.decode_string' (Jsont.list feed_source_jsont) json_str with
232232- | Ok feeds ->
233233- Log.debug (fun m -> m "Loaded %d feed sources" (List.length feeds));
234234- feeds
235235- | Error err ->
236236- Log.err (fun m -> m "Failed to parse feed sources: %s" (Jsont.Error.to_string err));
237237- [])
238238- | _ ->
239239- Log.debug (fun m -> m "No feed sources configured");
240240- []
241241-242242-(** Save feed sources to bot storage *)
243243-let save_feed_sources storage feeds =
244244- match Jsont_bytesrw.encode_string' ~format:Jsont.Indent (Jsont.list feed_source_jsont) feeds with
245245- | Ok json_str ->
246246- Bot_storage.put storage ~key:river_feeds_key ~value:json_str
247247- | Error err ->
248248- let msg = Printf.sprintf "Failed to encode feed sources: %s" (Jsont.Error.to_string err) in
249249- Error (Zulip.create_error ~code:(Other "encoding_error") ~msg ())
250250-251251-(** Add a feed source *)
252252-let add_feed storage ~name ~url =
253253- let feeds = load_feed_sources storage in
254254- if List.exists (fun f -> f.River.url = url) feeds then
255255- Error (Zulip.create_error ~code:(Other "already_exists")
256256- ~msg:(Printf.sprintf "Feed with URL %s already exists" url) ())
257257- else
258258- let new_feed = { River.name; url } in
259259- save_feed_sources storage (new_feed :: feeds)
260260-261261-(** Remove a feed source *)
262262-let remove_feed storage ~name =
263263- let feeds = load_feed_sources storage in
264264- let updated_feeds = List.filter (fun f -> f.River.name <> name) feeds in
265265- if List.length updated_feeds = List.length feeds then
266266- Error (Zulip.create_error ~code:(Other "not_found")
267267- ~msg:(Printf.sprintf "Feed '%s' not found" name) ())
268268- else
269269- save_feed_sources storage updated_feeds
270270-271271-(** Get target Zulip channel *)
272272-let get_river_channel storage =
273273- match Bot_storage.get storage ~key:river_channel_key with
274274- | Some ch when ch <> "" -> ch
275275- | _ -> river_default_channel
276276-277277-(** Set target Zulip channel *)
278278-let set_river_channel storage channel =
279279- Bot_storage.put storage ~key:river_channel_key ~value:channel
280280-281281-(** Check if polling is enabled *)
282282-let is_river_polling_enabled storage =
283283- match Bot_storage.get storage ~key:river_polling_enabled_key with
284284- | Some "true" -> true
285285- | _ -> false
286286-287287-(** Enable polling *)
288288-let enable_river_polling storage =
289289- Bot_storage.put storage ~key:river_polling_enabled_key ~value:"true"
290290-291291-(** Disable polling *)
292292-let disable_river_polling storage =
293293- Bot_storage.put storage ~key:river_polling_enabled_key ~value:"false"
294294-295295-(** Get last sync timestamp *)
296296-let get_river_last_sync storage =
297297- match Bot_storage.get storage ~key:river_last_sync_key with
298298- | Some ts_str when ts_str <> "" ->
299299- (try Some (float_of_string ts_str) with _ -> None)
300300- | _ -> None
301301-302302-(** Update last sync timestamp *)
303303-let update_river_last_sync storage timestamp =
304304- Bot_storage.put storage ~key:river_last_sync_key ~value:(string_of_float timestamp)
305305-306306-(** {1 Command Handlers} *)
307307-308181(** Handle the 'register' command *)
309182let handle_register storage sender_email sender_id sender_name custom_email_opt =
310183 (* First, try to fetch the user's profile from the Zulip API to get delivery_email and email *)
···495368 (List.length user_lines)
496369 (String.concat "\n" user_lines)
497370498498-(** Handle River 'feeds' command *)
499499-let handle_river_feeds storage =
500500- let feeds = load_feed_sources storage in
501501- if feeds = [] then
502502- "📡 No River feeds configured yet.\n\nUse `river add-feed <name> <url>` to add a feed."
503503- else
504504- let feed_lines = List.mapi (fun i feed ->
505505- Printf.sprintf "%d. **%s**\n URL: `%s`" (i + 1) feed.River.name feed.River.url
506506- ) feeds in
507507- Printf.sprintf "📡 Configured River feeds (%d):\n\n%s\n\nChannel: #%s"
508508- (List.length feeds)
509509- (String.concat "\n\n" feed_lines)
510510- (get_river_channel storage)
511511-512512-(** Handle River 'add-feed' command *)
513513-let handle_river_add_feed storage args =
514514- match String.split_on_char ' ' args |> List.filter (fun s -> s <> "") with
515515- | name :: url_parts ->
516516- let url = String.concat " " url_parts in
517517- (match add_feed storage ~name ~url with
518518- | Ok () ->
519519- Printf.sprintf "✅ Added feed **%s**\n URL: `%s`\n\nUse `river sync` to fetch posts." name url
520520- | Error e ->
521521- Printf.sprintf "❌ Failed to add feed: %s" (Zulip.error_message e))
522522- | _ ->
523523- "Usage: `river add-feed <name> <url>`\n\nExample: `river add-feed \"OCaml Blog\" https://ocaml.org/blog/feed.xml`"
524524-525525-(** Handle River 'remove-feed' command *)
526526-let handle_river_remove_feed storage args =
527527- let name = String.trim args in
528528- if name = "" then
529529- "Usage: `river remove-feed <name>`\n\nExample: `river remove-feed \"OCaml Blog\"`"
530530- else
531531- match remove_feed storage ~name with
532532- | Ok () ->
533533- Printf.sprintf "✅ Removed feed: **%s**" name
534534- | Error e ->
535535- Printf.sprintf "❌ Failed to remove feed: %s" (Zulip.error_message e)
536536-537537-(** Handle River 'set-channel' command *)
538538-let handle_river_set_channel storage args =
539539- let channel = String.trim args in
540540- if channel = "" then
541541- Printf.sprintf "Current channel: #%s\n\nUsage: `river set-channel <channel-name>`\n\nExample: `river set-channel general`"
542542- (get_river_channel storage)
543543- else
544544- match set_river_channel storage channel with
545545- | Ok () ->
546546- Printf.sprintf "✅ River posts will now go to #%s" channel
547547- | Error e ->
548548- Printf.sprintf "❌ Failed to set channel: %s" (Zulip.error_message e)
549549-550550-(** Handle River 'start' command *)
551551-let handle_river_start storage =
552552- match enable_river_polling storage with
553553- | Ok () -> "✅ River polling enabled. Feeds will be checked every 5 minutes."
554554- | Error e -> Printf.sprintf "❌ Failed to enable polling: %s" (Zulip.error_message e)
555555-556556-(** Handle River 'stop' command *)
557557-let handle_river_stop storage =
558558- match disable_river_polling storage with
559559- | Ok () -> "⏸️ River polling disabled. Use `river start` to resume."
560560- | Error e -> Printf.sprintf "❌ Failed to disable polling: %s" (Zulip.error_message e)
561561-562562-(** Handle River 'status' command *)
563563-let handle_river_status storage =
564564- let feeds = load_feed_sources storage in
565565- let polling_status = if is_river_polling_enabled storage then "✅ Enabled" else "⏸️ Disabled" in
566566- let last_sync = match get_river_last_sync storage with
567567- | Some ts -> format_timestamp ts
568568- | None -> "Never"
569569- in
570570- Printf.sprintf "📊 River Feed Integration Status:\n\
571571- • Polling: %s\n\
572572- • Target channel: #%s\n\
573573- • Feeds configured: %d\n\
574574- • Last sync: %s"
575575- polling_status
576576- (get_river_channel storage)
577577- (List.length feeds)
578578- last_sync
579579-580371(** Handle the 'help' command *)
581372let handle_help sender_name sender_email =
582582- Printf.sprintf "👋 Hi %s! I'm **Vicuna**, your user registration and feed aggregation assistant.\n\n\
583583- **User Registration Commands:**\n\
373373+ Printf.sprintf "👋 Hi %s! I'm **Vicuna**, your user registration assistant.\n\n\
374374+ **Available Commands:**\n\
584375 • `register` - Auto-detect your real email or use Zulip email\n\
585376 • `register <your-email@example.com>` - Register with a specific email\n\
586377 • `whoami` - Show your registration status\n\
587378 • `whois <email|id>` - Look up a registered user\n\
588588- • `list` - List all registered users\n\n\
589589- **River Feed Commands:**\n\
590590- • `river feeds` - List all configured feeds\n\
591591- • `river add-feed <name> <url>` - Add a new feed\n\
592592- • `river remove-feed <name>` - Remove a feed\n\
593593- • `river sync` - Force immediate feed sync\n\
594594- • `river status` - Show River integration status\n\
595595- • `river set-channel <name>` - Set target Zulip channel\n\
596596- • `river start` - Enable automatic polling\n\
597597- • `river stop` - Disable automatic polling\n\
379379+ • `list` - List all registered users\n\
598380 • `help` - Show this help message\n\n\
599381 **Examples:**\n\
600382 • `register` - Auto-detect your email (your Zulip email is `%s`)\n\
601601- • `river add-feed \"OCaml Weekly\" https://ocaml.org/feed.xml`\n\
602602- • `river set-channel sandbox-test`\n\n\
383383+ • `register alice@mycompany.com` - Register with a specific email\n\
384384+ • `whois alice@example.com` - Look up Alice by email\n\
385385+ • `whois 12345` - Look up user by Zulip ID\n\n\
386386+ **Smart Email Detection:**\n\
387387+ When you use `register` without an email, I'll try to:\n\
388388+ 1. Find your delivery email from your Zulip profile (delivery_email)\n\
389389+ 2. Use your profile email if available (user.email)\n\
390390+ 3. Fall back to your Zulip message email if needed\n\n\
391391+ This means you usually don't need to manually provide your email!\n\n\
603392 Send me a direct message to get started!"
604393 sender_name sender_email
605394···667456 handle_whois storage args
668457 | "list" ->
669458 handle_list storage
670670- | "river" ->
671671- (* Parse river subcommand *)
672672- let (subcmd, subargs) = parse_command args in
673673- let subcmd_lower = String.lowercase_ascii subcmd in
674674- (match subcmd_lower with
675675- | "" | "feeds" | "list" ->
676676- handle_river_feeds storage
677677- | "add-feed" | "add" ->
678678- handle_river_add_feed storage subargs
679679- | "remove-feed" | "remove" | "rm" ->
680680- handle_river_remove_feed storage subargs
681681- | "set-channel" | "channel" ->
682682- handle_river_set_channel storage subargs
683683- | "start" | "enable" ->
684684- handle_river_start storage
685685- | "stop" | "disable" ->
686686- handle_river_stop storage
687687- | "status" ->
688688- handle_river_status storage
689689- | "sync" ->
690690- "⏳ Syncing River feeds... (Note: sync requires environment access, use CLI for now)"
691691- | _ ->
692692- Printf.sprintf "Unknown river command: `%s`\n\nAvailable: feeds, add-feed, remove-feed, set-channel, start, stop, status, sync" subcmd)
693459 | _ ->
694460 Printf.sprintf "Unknown command: `%s`. Use `help` to see available commands." command
695461 in
···730496 | Error _ as err -> err
731497 | Ok () -> Bot_storage.remove storage ~key
732498 ) (Ok ()) keys
733733-734734-(** Normalize a name for fuzzy matching *)
735735-let normalize_name name =
736736- name
737737- |> String.lowercase_ascii
738738- |> String.trim
739739- |> Str.global_replace (Str.regexp "[ \t\n\r]+") " "
740740-741741-(** Match user by exact name *)
742742-let lookup_user_by_name_exact storage name =
743743- let all_ids = get_all_user_ids storage in
744744- List.find_map (fun id ->
745745- match lookup_user_by_id storage id with
746746- | Some user when user.full_name = name -> Some user
747747- | _ -> None
748748- ) all_ids
749749-750750-(** Match user by fuzzy name *)
751751-let lookup_user_by_name_fuzzy storage name =
752752- let normalized_query = normalize_name name in
753753- let all_ids = get_all_user_ids storage in
754754- List.find_map (fun id ->
755755- match lookup_user_by_id storage id with
756756- | Some user when normalize_name user.full_name = normalized_query -> Some user
757757- | _ -> None
758758- ) all_ids
759759-760760-(** Smart user matching for a River post *)
761761-let match_user_for_post storage (post : River.post) =
762762- let author_email = River.email post in
763763- let author_name = River.author post in
764764- Log.debug (fun m -> m "Matching user for post by %s (%s)" author_name author_email);
765765- (* Try email → name exact → name fuzzy *)
766766- match lookup_user_by_email storage author_email with
767767- | Some user ->
768768- Log.debug (fun m -> m "Matched by email: %s" user.email);
769769- Some user
770770- | None ->
771771- (match lookup_user_by_name_exact storage author_name with
772772- | Some user ->
773773- Log.debug (fun m -> m "Matched by exact name: %s" user.full_name);
774774- Some user
775775- | None ->
776776- match lookup_user_by_name_fuzzy storage author_name with
777777- | Some user ->
778778- Log.debug (fun m -> m "Matched by fuzzy name: %s" user.full_name);
779779- Some user
780780- | None ->
781781- Log.debug (fun m -> m "No user match found");
782782- None)
783783-784784-(** Convert HTML content to markdown summary *)
785785-let content_to_summary content_html ~max_length =
786786- let markdown = Markdown_converter.to_markdown content_html in
787787- if String.length markdown <= max_length then markdown
788788- else String.sub markdown 0 (max_length - 3) ^ "..."
789789-790790-(** Format a River post for Zulip *)
791791-let format_river_post ~user_match (post : River.post) =
792792- let summary =
793793- match River.summary post with
794794- | Some s -> s
795795- | None -> content_to_summary (River.content post) ~max_length:200
796796- in
797797- let author_line =
798798- match user_match with
799799- | Some user -> Printf.sprintf "By @**%s**" user.full_name
800800- | None -> Printf.sprintf "By %s" (River.author post)
801801- in
802802- let link_line =
803803- match River.link post with
804804- | Some uri -> Printf.sprintf "\n\n[Read more](%s)" (Uri.to_string uri)
805805- | None -> ""
806806- in
807807- Printf.sprintf "%s\n\n%s%s" author_line summary link_line
808808-809809-(** Update user's last_river_post_date *)
810810-let update_user_river_date storage user new_date =
811811- let updated = { user with last_river_post_date = Some new_date } in
812812- let reg_str = user_registration_to_string updated in
813813- Bot_storage.put storage ~key:(storage_key_for_id user.zulip_id) ~value:reg_str
814814-815815-(** Get latest post date from a list of posts *)
816816-let get_latest_post_date posts =
817817- List.fold_left (fun acc post ->
818818- match River.date post with
819819- | Some ptime ->
820820- let timestamp = Ptime.to_float_s ptime in
821821- (match acc with
822822- | None -> Some timestamp
823823- | Some existing -> Some (max existing timestamp))
824824- | None -> acc
825825- ) None posts
826826-827827-(** Filter posts newer than a timestamp *)
828828-let filter_posts_since posts since_opt =
829829- match since_opt with
830830- | None -> posts
831831- | Some since ->
832832- List.filter (fun post ->
833833- match River.date post with
834834- | Some ptime ->
835835- Ptime.to_float_s ptime > since
836836- | None -> true
837837- ) posts
838838-839839-(** Post to Zulip channel *)
840840-let post_to_zulip client ~channel ~topic ~content =
841841- let stream_message = Zulip.Message.create ~type_:`Channel ~to_:[channel] ~topic ~content () in
842842- Zulip.Messages.send client stream_message
843843-844844-(** Sync feeds and post new items *)
845845-let sync_river_and_post ~env ~storage ~client () =
846846- Log.info (fun m -> m "Starting River feed sync");
847847- let feeds = load_feed_sources storage in
848848- if feeds = [] then (
849849- Log.info (fun m -> m "No feeds configured, skipping sync");
850850- Ok 0
851851- ) else
852852- try
853853- River.with_session env (fun session ->
854854- Log.debug (fun m -> m "Fetching %d feeds" (List.length feeds));
855855- let fetched_feeds = List.map (fun source ->
856856- Log.debug (fun m -> m "Fetching: %s" source.River.name);
857857- River.fetch session source
858858- ) feeds in
859859- let all_posts = River.posts fetched_feeds in
860860- Log.info (fun m -> m "Fetched %d total posts" (List.length all_posts));
861861-862862- (* Post new items *)
863863- let users = List.filter_map (lookup_user_by_id storage) (get_all_user_ids storage) in
864864- let posted_count = ref 0 in
865865- let channel = get_river_channel storage in
866866-867867- List.iter (fun user ->
868868- let new_posts = filter_posts_since all_posts user.last_river_post_date in
869869- List.iter (fun post ->
870870- let user_match = match_user_for_post storage post in
871871- let topic = River.title post in
872872- let content = format_river_post ~user_match post in
873873- Log.info (fun m -> m "Posting to #%s: %s" channel topic);
874874- match post_to_zulip client ~channel ~topic ~content with
875875- | Ok _response -> incr posted_count
876876- | Error e -> Log.err (fun m -> m "Failed to post: %s" (Zulip.error_message e))
877877- ) new_posts;
878878-879879- match get_latest_post_date all_posts with
880880- | Some latest -> let _ = update_user_river_date storage user latest in ()
881881- | None -> ()
882882- ) users;
883883-884884- let _ = update_river_last_sync storage (Unix.gettimeofday ()) in
885885- Log.info (fun m -> m "Sync complete, posted %d items" !posted_count);
886886- Ok !posted_count
887887- )
888888- with exn ->
889889- let msg = Printf.sprintf "Sync failed: %s" (Printexc.to_string exn) in
890890- Log.err (fun m -> m "%s" msg);
891891- Error (Zulip.create_error ~code:(Other "sync_error") ~msg ())
892499893500(** Create the bot handler instance *)
894501let create_handler config storage identity =
-57
stack/vicuna/lib/vicuna_bot.mli
···5252 full_name: string;
5353 registered_at: float;
5454 is_admin: bool;
5555- last_river_post_date: float option; (** Timestamp of last River post for this user *)
5655}
57565857val lookup_user_by_id :
···8382val clear_storage :
8483 Zulip_bot.Bot_storage.t ->
8584 (unit, Zulip.zerror) result
8686-8787-(** {1 River Feed Integration} *)
8888-8989-(** Load configured River feed sources *)
9090-val load_feed_sources :
9191- Zulip_bot.Bot_storage.t ->
9292- River.source list
9393-9494-(** Add a River feed source *)
9595-val add_feed :
9696- Zulip_bot.Bot_storage.t ->
9797- name:string ->
9898- url:string ->
9999- (unit, Zulip.zerror) result
100100-101101-(** Remove a River feed source *)
102102-val remove_feed :
103103- Zulip_bot.Bot_storage.t ->
104104- name:string ->
105105- (unit, Zulip.zerror) result
106106-107107-(** Get the target Zulip channel for River posts *)
108108-val get_river_channel :
109109- Zulip_bot.Bot_storage.t ->
110110- string
111111-112112-(** Set the target Zulip channel for River posts *)
113113-val set_river_channel :
114114- Zulip_bot.Bot_storage.t ->
115115- string ->
116116- (unit, Zulip.zerror) result
117117-118118-(** Check if River polling is enabled *)
119119-val is_river_polling_enabled :
120120- Zulip_bot.Bot_storage.t ->
121121- bool
122122-123123-(** Enable automatic River polling *)
124124-val enable_river_polling :
125125- Zulip_bot.Bot_storage.t ->
126126- (unit, Zulip.zerror) result
127127-128128-(** Disable automatic River polling *)
129129-val disable_river_polling :
130130- Zulip_bot.Bot_storage.t ->
131131- (unit, Zulip.zerror) result
132132-133133-(** Sync River feeds and post new items to Zulip *)
134134-val sync_river_and_post :
135135- env:< clock : float Eio.Time.clock_ty Eio.Resource.t;
136136- fs : Eio.Fs.dir_ty Eio.Path.t;
137137- net : [ `Generic | `Unix ] Eio.Net.ty Eio.Resource.t; .. > ->
138138- storage:Zulip_bot.Bot_storage.t ->
139139- client:Zulip.Client.t ->
140140- unit ->
141141- (int, Zulip.zerror) result
+20-2
stack/zulip/lib/zulip_bot/lib/bot_runner.ml
···120120 | Error e ->
121121 Log.err (fun m -> m "Error sending reply: %s" (Zulip.error_message e)))
122122123123+ | Ok (Bot_handler.Response.DirectMessage { to_; content }) ->
124124+ Log.debug (fun m -> m "Bot is sending direct message to: %s" to_);
125125+ let message_to_send = Zulip.Message.create ~type_:`Direct ~to_:[to_] ~content () in
126126+ (match Zulip.Messages.send t.client message_to_send with
127127+ | Ok resp ->
128128+ Log.info (fun m -> m "Direct message sent successfully (id: %d)"
129129+ (Zulip.Message_response.id resp))
130130+ | Error e ->
131131+ Log.err (fun m -> m "Error sending direct message: %s" (Zulip.error_message e)))
132132+133133+ | Ok (Bot_handler.Response.ChannelMessage { channel; topic; content }) ->
134134+ Log.debug (fun m -> m "Bot is sending channel message to #%s - %s" channel topic);
135135+ let message_to_send = Zulip.Message.create ~type_:`Channel ~to_:[channel] ~topic ~content () in
136136+ (match Zulip.Messages.send t.client message_to_send with
137137+ | Ok resp ->
138138+ Log.info (fun m -> m "Channel message sent successfully (id: %d)"
139139+ (Zulip.Message_response.id resp))
140140+ | Error e ->
141141+ Log.err (fun m -> m "Error sending channel message: %s" (Zulip.error_message e)))
142142+123143 | Ok (Bot_handler.Response.None) ->
124144 Log.info (fun m -> m "Bot handler returned no response")
125125- | Ok _ ->
126126- Log.info (fun m -> m "Bot handler returned unhandled response type")
127145 | Error e ->
128146 Log.err (fun m -> m "Error handling message: %s" (Zulip.error_message e))
129147 ) else (