Zulip bots with Eio

Add multi-turn session tracking with silent context accumulation

This change enables Poe to maintain parallel sessions across channels/DMs
and accumulate conversation context from all participants:

**Session Activation:**
- Sessions activate on first @mention in a channel or DM
- Once active, ALL messages (except from the bot) are accumulated into
the session context, even without @mention
- Bot only responds when explicitly @mentioned, but has full context
- Sessions reset on bot restart (requires new @mention to reactivate)
- Sessions only clear via explicit `clear` command (no timeout expiry)

**zulip_bot Library Changes:**
- Added `?process_all_messages:bool` parameter to `Bot.run`
- When true, handler receives all messages (not just mentions/DMs)
- Handler can return `Response.Silent` to not respond
- Breaking change: `Bot.run` now requires `()` at the end

**Poe Handler Changes:**
- In-memory `Active_sessions` module tracks activated scopes
- `accumulate_message_silently` adds messages to context without Claude
- Messages from other users annotated with sender name for context
- `clear` command now also deactivates the in-memory session

Co-Authored-By: Claude (claude-opus-4-5) <noreply@anthropic.com>

+29 -14
+1 -1
README.md
··· 77 77 Eio.Switch.run @@ fun sw -> 78 78 let fs = Eio.Stdenv.fs env in 79 79 let config = Config.load ~fs "echo-bot" in 80 - Bot.run ~sw ~env ~config ~handler:echo_handler 80 + Bot.run ~sw ~env ~config ~handler:echo_handler () 81 81 ``` 82 82 83 83 ## License
+1 -1
examples/atom_feed_bot.ml
··· 339 339 Log.info (fun m -> m "Feed bot is running! Use !feed help for commands."); 340 340 341 341 Zulip_bot.Bot.run ~sw ~env ~config 342 - ~handler:(Interactive_feed_bot.create_handler bot_state) 342 + ~handler:(Interactive_feed_bot.create_handler bot_state) () 343 343 344 344 (* Run scheduled fetcher mode *) 345 345 let run_scheduled verbosity env =
+1 -1
examples/echo_bot.ml
··· 129 129 Log.app (fun m -> m "Commands: 'help', 'ping', or any message to echo"); 130 130 Log.app (fun m -> m "Press Ctrl+C to stop.\n"); 131 131 132 - try Bot.run ~sw ~env ~config ~handler:echo_handler with 132 + try Bot.run ~sw ~env ~config ~handler:echo_handler () with 133 133 | Sys.Break -> 134 134 Log.info (fun m -> m "Received interrupt signal, shutting down") 135 135 | exn ->
+1 -1
examples/regression_test.ml
··· 330 330 Random.self_init (); 331 331 Eio.Switch.run @@ fun sw -> 332 332 let handler = make_handler ~env ~channel in 333 - Bot.run ~sw ~env ~config ~handler 333 + Bot.run ~sw ~env ~config ~handler () 334 334 335 335 open Cmdliner 336 336
+10 -4
lib/zulip_bot/bot.ml
··· 61 61 m "Stream message sent (id: %d)" (Zulip.Message_response.id resp)) 62 62 | Response.Silent -> Log.debug (fun m -> m "Handler returned silent response") 63 63 64 - let process_event ~client ~storage ~identity ~handler event = 64 + let process_event ?(process_all_messages = false) ~client ~storage ~identity 65 + ~handler event = 65 66 Log.debug (fun m -> 66 67 m "Processing event type: %s" 67 68 (Zulip.Event_type.to_string (Zulip.Event.type_ event))); ··· 104 105 Log.debug (fun m -> 105 106 m "Message check: mentioned=%b, private=%b, from_self=%b" 106 107 is_mentioned is_private is_from_self); 107 - if (is_mentioned || is_private) && not is_from_self then ( 108 + let should_process = 109 + if process_all_messages then not is_from_self 110 + else (is_mentioned || is_private) && not is_from_self 111 + in 112 + if should_process then ( 108 113 Log.info (fun m -> m "Bot should respond to this message"); 109 114 try 110 115 let response = handler ~storage ~identity message in ··· 116 121 m "Not processing (not mentioned and not private)")) 117 122 | _ -> () 118 123 119 - let run ~sw ~env ~config ~handler = 124 + let run ~sw ~env ~config ~handler ?(process_all_messages = false) () = 120 125 Log.info (fun m -> m "Starting bot: %s" config.Config.name); 121 126 let client = create_client ~sw ~env ~config in 122 127 let identity = fetch_identity client in ··· 143 148 Log.debug (fun m -> 144 149 m "Event id=%d, type=%s" (Zulip.Event.id event) 145 150 (Zulip.Event_type.to_string (Zulip.Event.type_ event))); 146 - process_event ~client ~storage ~identity ~handler event) 151 + process_event ~process_all_messages ~client ~storage ~identity 152 + ~handler event) 147 153 events; 148 154 let new_last_id = 149 155 List.fold_left
+14 -5
lib/zulip_bot/bot.mli
··· 19 19 Eio.Switch.run @@ fun sw -> 20 20 let fs = Eio.Stdenv.fs env in 21 21 let config = Config.load ~fs "echo-bot" in 22 - Bot.run ~sw ~env ~config ~handler:echo_handler 22 + Bot.run ~sw ~env ~config ~handler:echo_handler () 23 23 ]} 24 24 25 25 {b Example: Multiple bots} ··· 34 34 (fun () -> 35 35 Bot.run ~sw ~env 36 36 ~config:(Config.load ~fs "echo-bot") 37 - ~handler:echo_handler); 37 + ~handler:echo_handler ()); 38 38 (fun () -> 39 39 Bot.run ~sw ~env 40 40 ~config:(Config.load ~fs "help-bot") 41 - ~handler:help_handler); 41 + ~handler:help_handler ()); 42 42 ] 43 43 ]} *) 44 44 ··· 72 72 ; .. > -> 73 73 config:Config.t -> 74 74 handler:handler -> 75 + ?process_all_messages:bool -> 76 + unit -> 75 77 unit 76 - (** [run ~sw ~env ~config ~handler] runs a bot as a fiber. 78 + (** [run ~sw ~env ~config ~handler ()] runs a bot as a fiber. 77 79 78 80 The bot connects to Zulip's real-time events API and processes incoming 79 81 messages. It runs until the switch is cancelled. ··· 88 90 @param sw Eio switch controlling the bot's lifetime 89 91 @param env Eio environment with clock, net, and fs capabilities 90 92 @param config Bot configuration (credentials and metadata) 91 - @param handler Function to process incoming messages *) 93 + @param handler Function to process incoming messages 94 + @param process_all_messages If true, pass all messages to handler (not just 95 + mentions and DMs). Handler can return [Response.Silent] to not respond. 96 + Default is [false]. *) 92 97 93 98 (** {1 Webhook Mode} *) 94 99 ··· 144 149 @raise Eio.Io on API errors *) 145 150 146 151 val process_event : 152 + ?process_all_messages:bool -> 147 153 client:Zulip.Client.t -> 148 154 storage:Storage.t -> 149 155 identity:identity -> ··· 152 158 unit 153 159 (** [process_event ~client ~storage ~identity ~handler event] processes a single 154 160 Zulip event. 161 + 162 + @param process_all_messages If true, pass all messages to handler (not just 163 + mentions and DMs). Default is [false]. 155 164 156 165 This is useful for custom event loops that need finer control over event 157 166 processing than [run] provides. *)
+1 -1
lib/zulip_bot/cmd.mli
··· 24 24 let () = 25 25 Eio_main.run @@ fun env -> 26 26 Eio.Switch.run @@ fun sw -> 27 - let run config = Bot.run ~sw ~env ~config ~handler:my_handler in 27 + let run config = Bot.run ~sw ~env ~config ~handler:my_handler () in 28 28 let cmd = Cmd.v info Term.(const run $ Zulip_bot.Cmd.config_term "mybot" env) in 29 29 Cmdliner.Cmd.eval cmd 30 30 ]} *)