A Zulip bot agent to sit in our Black Sun. Ever evolving

init

+718
+17
.gitignore
··· 1 + # OCaml build artifacts 2 + _build/ 3 + *.install 4 + *.merlin 5 + 6 + # Third-party sources (fetch locally with opam source) 7 + third_party/ 8 + 9 + # Editor and OS files 10 + .DS_Store 11 + *.swp 12 + *~ 13 + .vscode/ 14 + .idea/ 15 + 16 + # Opam local switch 17 + _opam/
+1
.ocamlformat
··· 1 + version=0.28.1
+53
.tangled/workflows/build.yml
··· 1 + when: 2 + - event: ["push", "pull_request"] 3 + branch: ["main"] 4 + 5 + engine: nixery 6 + 7 + dependencies: 8 + nixpkgs: 9 + - shell 10 + - stdenv 11 + - findutils 12 + - binutils 13 + - libunwind 14 + - ncurses 15 + - opam 16 + - git 17 + - gawk 18 + - gnupatch 19 + - gnum4 20 + - gnumake 21 + - gnutar 22 + - gnused 23 + - gnugrep 24 + - diffutils 25 + - gzip 26 + - bzip2 27 + - gcc 28 + - ocaml 29 + - pkg-config 30 + 31 + steps: 32 + - name: opam 33 + command: | 34 + opam init --disable-sandboxing -a -y 35 + - name: repo 36 + command: | 37 + opam repo add aoah https://tangled.org/anil.recoil.org/aoah-opam-repo.git 38 + - name: switch 39 + command: | 40 + opam install . --confirm-level=unsafe-yes --deps-only 41 + - name: build 42 + command: | 43 + opam exec -- dune build 44 + - name: switch-test 45 + command: | 46 + opam install . --confirm-level=unsafe-yes --deps-only --with-test 47 + - name: test 48 + command: | 49 + opam exec -- dune runtest --verbose 50 + - name: doc 51 + command: | 52 + opam install -y odoc 53 + opam exec -- dune build @doc
+9
CHANGES.md
··· 1 + ## v0.1.0 2 + 3 + Initial release. 4 + 5 + - Zulip bot for broadcasting daily changelog updates 6 + - Claude integration for natural language interaction 7 + - XDG-compliant configuration loading 8 + - Two operation modes: daemon and one-shot broadcast 9 + - Commands: help, status, broadcast
+15
LICENSE.md
··· 1 + ISC License 2 + 3 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+62
README.md
··· 1 + # Poe - Zulip Bot for Monorepo Changes 2 + 3 + Poe is a Zulip bot that broadcasts daily changelog updates from a monorepo to a configured channel. It integrates with Claude to interpret messages and can help extend its own functionality. 4 + 5 + ## Key Features 6 + 7 + - **Daily Broadcasts**: Automatically post changelog updates to Zulip 8 + - **Claude Integration**: Natural language interaction for queries and self-modification 9 + - **XDG Configuration**: Standard config file locations with fallbacks 10 + - **Multiple Commands**: Run as daemon or one-shot broadcast 11 + 12 + ## Usage 13 + 14 + ### Running the Bot 15 + 16 + ```bash 17 + # Run as a long-running bot service 18 + poe run 19 + 20 + # With explicit config file 21 + poe run -c /path/to/poe.toml 22 + 23 + # Broadcast changes once and exit 24 + poe broadcast 25 + ``` 26 + 27 + ### Configuration 28 + 29 + Poe searches for configuration in order: 30 + 1. Explicit path via `-c` / `--config` 31 + 2. XDG config: `~/.config/poe/config.toml` 32 + 3. Current directory: `poe.toml` 33 + 4. Built-in defaults 34 + 35 + Config file format (TOML): 36 + 37 + ```toml 38 + channel = "general" # Zulip channel to broadcast to 39 + topic = "Daily Changes" # Topic for broadcasts 40 + changes_file = "DAILY-CHANGES.md" 41 + monorepo_path = "." 42 + ``` 43 + 44 + Zulip credentials are loaded from `~/.config/zulip-bot/<name>/config` or environment variables. 45 + 46 + ### Bot Commands 47 + 48 + When running as a bot, Poe responds to: 49 + - `help` or `?` - Show help message 50 + - `status` - Show current configuration 51 + - `broadcast`, `post changes`, `post`, `changes` - Broadcast daily changes 52 + - Any other message - Passed to Claude for interpretation 53 + 54 + ## Installation 55 + 56 + ``` 57 + opam install poe 58 + ``` 59 + 60 + ## License 61 + 62 + ISC
+5
bin/dune
··· 1 + (executable 2 + (name main) 3 + (public_name poe) 4 + (package poe) 5 + (libraries poe cmdliner logs.fmt logs.cli fmt.tty fmt.cli eio_main))
+169
bin/main.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + let setup_logging style_renderer level = 7 + Fmt_tty.setup_std_outputs ?style_renderer (); 8 + Logs.set_level level; 9 + Logs.set_reporter (Logs_fmt.reporter ()) 10 + 11 + let run_bot bot_name config_file = 12 + Eio_main.run @@ fun env -> 13 + Eio.Switch.run @@ fun sw -> 14 + let fs = Eio.Stdenv.fs env in 15 + let process_mgr = Eio.Stdenv.process_mgr env in 16 + let clock = Eio.Stdenv.clock env in 17 + 18 + (* Load poe config: explicit path > XDG > current dir > defaults *) 19 + let poe_config = 20 + match config_file with 21 + | Some path -> Poe.Config.load ~fs path 22 + | None -> ( 23 + match Poe.Config.load_xdg_opt ~fs with 24 + | Some c -> c 25 + | None -> ( 26 + match Poe.Config.load_opt ~fs "poe.toml" with 27 + | Some c -> c 28 + | None -> Poe.Config.default)) 29 + in 30 + 31 + (* Load zulip bot config *) 32 + let zulip_config = Zulip_bot.Config.load_or_env ~fs bot_name in 33 + 34 + (* Create handler environment *) 35 + let handler_env : _ Poe.Handler.env = 36 + { sw; process_mgr; clock; fs } 37 + in 38 + 39 + (* Create and run the bot *) 40 + let handler = Poe.Handler.make_handler handler_env poe_config in 41 + Logs.info (fun m -> 42 + m "Starting Poe bot, broadcasting to %s/%s" poe_config.channel 43 + poe_config.topic); 44 + Zulip_bot.Bot.run ~sw ~env ~config:zulip_config ~handler 45 + 46 + let run_cmd = 47 + let open Cmdliner in 48 + let bot_name = 49 + Arg.( 50 + value 51 + & opt string "poe" 52 + & info [ "n"; "name" ] ~docv:"NAME" 53 + ~doc:"Bot name for Zulip configuration lookup.") 54 + in 55 + let config_file = 56 + Arg.( 57 + value 58 + & opt (some string) None 59 + & info [ "c"; "config" ] ~docv:"FILE" 60 + ~doc:"Path to poe.toml configuration file.") 61 + in 62 + let run style_renderer level bot_name config_file = 63 + setup_logging style_renderer level; 64 + run_bot bot_name config_file 65 + in 66 + let doc = "Run the Poe Zulip bot" in 67 + let info = Cmd.info "run" ~doc in 68 + Cmd.v info 69 + Term.( 70 + const run $ Fmt_cli.style_renderer () $ Logs_cli.level () $ bot_name 71 + $ config_file) 72 + 73 + let broadcast_cmd = 74 + let open Cmdliner in 75 + let config_file = 76 + Arg.( 77 + value 78 + & opt (some string) None 79 + & info [ "c"; "config" ] ~docv:"FILE" 80 + ~doc:"Path to poe.toml configuration file.") 81 + in 82 + let bot_name = 83 + Arg.( 84 + value 85 + & opt string "poe" 86 + & info [ "n"; "name" ] ~docv:"NAME" 87 + ~doc:"Bot name for Zulip configuration lookup.") 88 + in 89 + let broadcast style_renderer level config_file bot_name = 90 + setup_logging style_renderer level; 91 + Eio_main.run @@ fun env -> 92 + Eio.Switch.run @@ fun sw -> 93 + let fs = Eio.Stdenv.fs env in 94 + 95 + (* Load poe config: explicit path > XDG > current dir > defaults *) 96 + let poe_config = 97 + match config_file with 98 + | Some path -> Poe.Config.load ~fs path 99 + | None -> ( 100 + match Poe.Config.load_xdg_opt ~fs with 101 + | Some c -> c 102 + | None -> ( 103 + match Poe.Config.load_opt ~fs "poe.toml" with 104 + | Some c -> c 105 + | None -> Poe.Config.default)) 106 + in 107 + 108 + let zulip_config = Zulip_bot.Config.load_or_env ~fs bot_name in 109 + let client = Zulip_bot.Bot.create_client ~sw ~env ~config:zulip_config in 110 + 111 + match Poe.Handler.read_changes_file ~fs poe_config with 112 + | None -> 113 + Logs.err (fun m -> 114 + m "Could not read changes file: %s" poe_config.changes_file) 115 + | Some content -> 116 + let msg = 117 + Zulip.Message.create ~type_:`Channel ~to_:[ poe_config.channel ] 118 + ~topic:poe_config.topic ~content () 119 + in 120 + let resp = Zulip.Messages.send client msg in 121 + Logs.info (fun m -> 122 + m "Broadcast sent, message ID: %d" (Zulip.Message_response.id resp)) 123 + in 124 + let doc = "Broadcast daily changes to Zulip (one-shot)" in 125 + let info = Cmd.info "broadcast" ~doc in 126 + Cmd.v info 127 + Term.( 128 + const broadcast $ Fmt_cli.style_renderer () $ Logs_cli.level () 129 + $ config_file $ bot_name) 130 + 131 + let main_cmd = 132 + let open Cmdliner in 133 + let doc = "Poe - Zulip bot for monorepo changes with Claude integration" in 134 + let info = 135 + Cmd.info "poe" ~version:"0.1.0" ~doc 136 + ~man: 137 + [ 138 + `S Manpage.s_description; 139 + `P 140 + "Poe is a Zulip bot that broadcasts daily changelog updates from a \ 141 + monorepo to a configured channel. It integrates with Claude to \ 142 + interpret messages and can help extend its own functionality."; 143 + `S Manpage.s_commands; 144 + `P "$(b,run) - Run the bot as a long-running service"; 145 + `P "$(b,broadcast) - Send daily changes once and exit"; 146 + `S "CONFIGURATION"; 147 + `P 148 + "Poe configuration is searched in order:"; 149 + `I ("1.", "Explicit path via $(b,-c) / $(b,--config)"); 150 + `I ("2.", "XDG config: $(b,~/.config/poe/config.toml)"); 151 + `I ("3.", "Current directory: $(b,poe.toml)"); 152 + `I ("4.", "Built-in defaults"); 153 + `P "Config file format (TOML):"; 154 + `Pre 155 + "channel = \"general\" # Zulip channel to broadcast to\n\ 156 + topic = \"Daily Changes\" # Topic for broadcasts\n\ 157 + changes_file = \"DAILY-CHANGES.md\"\n\ 158 + monorepo_path = \".\""; 159 + `P 160 + "Zulip credentials are loaded from \ 161 + $(b,~/.config/zulip-bot/<name>/config) or environment variables."; 162 + ] 163 + in 164 + Cmd.group info [ run_cmd; broadcast_cmd ] 165 + 166 + let () = 167 + Fmt_tty.setup_std_outputs (); 168 + Logs.set_reporter (Logs_fmt.reporter ()); 169 + exit (Cmdliner.Cmd.eval main_cmd)
+14
config.toml.example
··· 1 + # Poe Bot Configuration 2 + # Copy this file to ~/.config/poe/config.toml 3 + 4 + # The Zulip channel/stream to broadcast daily changes to 5 + channel = "general" 6 + 7 + # The topic for broadcast messages 8 + topic = "Daily Changes" 9 + 10 + # Path to the daily changes file (relative to monorepo_path) 11 + changes_file = "DAILY-CHANGES.md" 12 + 13 + # Path to the monorepo root (where the changes file is located) 14 + monorepo_path = "."
+4
dune
··· 1 + ; Root dune file 2 + 3 + ; Ignore third_party directory (for fetched dependency sources) 4 + (data_only_dirs third_party)
+30
dune-project
··· 1 + (lang dune 3.20) 2 + (name poe) 3 + 4 + (generate_opam_files true) 5 + 6 + (license ISC) 7 + (authors "Anil Madhavapeddy") 8 + (maintainers "Anil Madhavapeddy <anil@recoil.org>") 9 + (homepage "https://tangled.org/@anil.recoil.org/poe") 10 + (bug_reports "https://tangled.org/@anil.recoil.org/poe/issues") 11 + (maintenance_intent "(latest)") 12 + 13 + (package 14 + (name poe) 15 + (synopsis "Zulip bot for broadcasting monorepo changes with Claude integration") 16 + (description 17 + "Poe is a Zulip bot that broadcasts daily changelog updates from the monorepo 18 + to a configured channel. It integrates with Claude to interpret messages 19 + and can help extend its own functionality.") 20 + (depends 21 + (ocaml (>= 5.2.0)) 22 + (dune (>= 3.20)) 23 + (eio_main (>= 1.2)) 24 + (zulip (>= 0.1.0)) 25 + (claude (>= 0.1.0)) 26 + (tomlt (>= 0.1.0)) 27 + (xdge (>= 0.1.0)) 28 + (logs (>= 0.7.0)) 29 + (cmdliner (>= 1.3.0)) 30 + (odoc :with-doc)))
+72
lib/config.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + type t = { 7 + channel : string; 8 + topic : string; 9 + changes_file : string; 10 + monorepo_path : string; 11 + } 12 + 13 + let default = { 14 + channel = "general"; 15 + topic = "Daily Changes"; 16 + changes_file = "DAILY-CHANGES.md"; 17 + monorepo_path = "."; 18 + } 19 + 20 + let codec = 21 + Tomlt.( 22 + Table.( 23 + obj (fun channel topic changes_file monorepo_path -> 24 + { channel; topic; changes_file; monorepo_path }) 25 + |> mem "channel" string ~dec_absent:default.channel 26 + ~enc:(fun c -> c.channel) 27 + |> mem "topic" string ~dec_absent:default.topic ~enc:(fun c -> c.topic) 28 + |> mem "changes_file" string ~dec_absent:default.changes_file 29 + ~enc:(fun c -> c.changes_file) 30 + |> mem "monorepo_path" string ~dec_absent:default.monorepo_path 31 + ~enc:(fun c -> c.monorepo_path) 32 + |> finish)) 33 + 34 + let load_from_path path = 35 + let content = Eio.Path.load path in 36 + match Tomlt_bytesrw.decode_string codec content with 37 + | Ok config -> config 38 + | Error e -> failwith (Tomlt.Error.to_string e) 39 + 40 + let load_from_path_opt path = 41 + try 42 + let content = Eio.Path.load path in 43 + match Tomlt_bytesrw.decode_string codec content with 44 + | Ok config -> Some config 45 + | Error _ -> None 46 + with _ -> None 47 + 48 + let xdg ~fs = Xdge.create fs "poe" 49 + 50 + let load_xdg ~fs = 51 + let xdg = xdg ~fs in 52 + match Xdge.find_config_file xdg "config.toml" with 53 + | Some path -> load_from_path path 54 + | None -> failwith "No poe configuration found in XDG config directories" 55 + 56 + let load_xdg_opt ~fs = 57 + let xdg = xdg ~fs in 58 + match Xdge.find_config_file xdg "config.toml" with 59 + | Some path -> load_from_path_opt path 60 + | None -> None 61 + 62 + let load ~fs path = 63 + let open Eio.Path in 64 + load_from_path (fs / path) 65 + 66 + let load_opt ~fs path = 67 + let open Eio.Path in 68 + load_from_path_opt (fs / path) 69 + 70 + let config_dir ~fs = 71 + let xdg = xdg ~fs in 72 + Xdge.config_dir xdg
+58
lib/config.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Poe bot configuration. 7 + 8 + Configuration is loaded from XDG-compliant locations: 9 + - [$XDG_CONFIG_HOME/poe/config.toml] (typically [~/.config/poe/config.toml]) 10 + - System config directories as fallback 11 + 12 + The config file uses TOML format: 13 + {v 14 + channel = "general" 15 + topic = "Daily Changes" 16 + changes_file = "DAILY-CHANGES.md" 17 + monorepo_path = "." 18 + v} *) 19 + 20 + type t = { 21 + channel : string; (** The Zulip channel to broadcast to *) 22 + topic : string; (** The topic for broadcast messages *) 23 + changes_file : string; (** Path to the daily changes file *) 24 + monorepo_path : string; (** Path to the monorepo root *) 25 + } 26 + 27 + val default : t 28 + (** Default configuration values. *) 29 + 30 + val codec : t Tomlt.t 31 + (** TOML codec for configuration. *) 32 + 33 + (** {1 XDG Loading} *) 34 + 35 + val load_xdg : fs:Eio.Fs.dir_ty Eio.Path.t -> t 36 + (** [load_xdg ~fs] loads configuration from XDG config directories. 37 + Searches for [config.toml] in: 38 + - [$XDG_CONFIG_HOME/poe/config.toml] 39 + - System config directories 40 + @raise Failure if no configuration file is found. *) 41 + 42 + val load_xdg_opt : fs:Eio.Fs.dir_ty Eio.Path.t -> t option 43 + (** [load_xdg_opt ~fs] is like {!load_xdg} but returns [None] if no 44 + configuration is found. *) 45 + 46 + val config_dir : fs:Eio.Fs.dir_ty Eio.Path.t -> Eio.Fs.dir_ty Eio.Path.t 47 + (** [config_dir ~fs] returns the XDG config directory for poe 48 + ([$XDG_CONFIG_HOME/poe]). *) 49 + 50 + (** {1 Path Loading} *) 51 + 52 + val load : fs:_ Eio.Path.t -> string -> t 53 + (** [load ~fs path] loads configuration from a specific [path]. 54 + @raise Failure if the file cannot be read or parsed. *) 55 + 56 + val load_opt : fs:_ Eio.Path.t -> string -> t option 57 + (** [load_opt ~fs path] loads configuration from [path], returning [None] 58 + if the file doesn't exist or can't be parsed. *)
+4
lib/dune
··· 1 + (library 2 + (name poe) 3 + (public_name poe) 4 + (libraries eio eio_main zulip zulip.bot claude tomlt tomlt.bytesrw xdge logs))
+123
lib/handler.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + let src = Logs.Src.create "poe" ~doc:"Poe Zulip bot" 7 + 8 + module Log = (val Logs.src_log src : Logs.LOG) 9 + 10 + type 'a env = { 11 + sw : Eio.Switch.t; 12 + process_mgr : 'a Eio.Process.mgr; 13 + clock : float Eio.Time.clock_ty Eio.Resource.t; 14 + fs : Eio.Fs.dir_ty Eio.Path.t; 15 + } 16 + 17 + let read_changes_file ~fs config = 18 + let open Eio.Path in 19 + let path = fs / config.Config.monorepo_path / config.Config.changes_file in 20 + try Some (load path) with _ -> None 21 + 22 + let broadcast_changes ~fs ~storage:_ ~identity:_ config _msg = 23 + match read_changes_file ~fs config with 24 + | None -> 25 + Zulip_bot.Response.reply 26 + (Printf.sprintf "Could not read changes file: %s" 27 + config.Config.changes_file) 28 + | Some content -> 29 + Zulip_bot.Response.stream ~stream:config.Config.channel 30 + ~topic:config.Config.topic ~content 31 + 32 + let create_claude_client env = 33 + let options = 34 + Claude.Options.default 35 + |> Claude.Options.with_model `Sonnet_4_5 36 + |> Claude.Options.with_permission_mode Claude.Permissions.Mode.Bypass_permissions 37 + |> Claude.Options.with_allowed_tools [ "Read"; "Glob"; "Grep" ] 38 + |> Claude.Options.with_append_system_prompt 39 + {|You are Poe, a helpful Zulip bot that manages a monorepo. 40 + You have access to your own source code in the poe/ directory. 41 + When asked to add features to yourself, you can read and understand your implementation. 42 + Be concise in your responses as they will be posted to Zulip. 43 + When suggesting code changes, format them clearly with markdown code blocks.|} 44 + in 45 + Claude.Client.create ~options ~sw:env.sw ~process_mgr:env.process_mgr 46 + ~clock:env.clock () 47 + 48 + let ask_claude env prompt = 49 + let client = create_claude_client env in 50 + Claude.Client.query client prompt; 51 + let responses = Claude.Client.receive_all client in 52 + let text = 53 + List.filter_map 54 + (function 55 + | Claude.Response.Text t -> Some (Claude.Response.Text.content t) 56 + | _ -> None) 57 + responses 58 + in 59 + String.concat "" text 60 + 61 + let handle_help () = 62 + Zulip_bot.Response.reply 63 + {|**Poe Bot Commands:** 64 + 65 + - `broadcast` or `post changes` - Broadcast the daily changes to the configured channel 66 + - `help` - Show this help message 67 + - `status` - Show bot configuration status 68 + - Any other message will be interpreted by Claude to help you understand or modify the bot 69 + 70 + **Configuration:** 71 + The bot reads its configuration from `poe.toml` with the following fields: 72 + - `channel` - The Zulip channel to broadcast to 73 + - `topic` - The topic for broadcast messages 74 + - `changes_file` - Path to the daily changes file 75 + - `monorepo_path` - Path to the monorepo root|} 76 + 77 + let handle_status config = 78 + Zulip_bot.Response.reply 79 + (Printf.sprintf 80 + {|**Poe Bot Status:** 81 + 82 + - Channel: `%s` 83 + - Topic: `%s` 84 + - Changes file: `%s` 85 + - Monorepo path: `%s`|} 86 + config.Config.channel config.Config.topic config.Config.changes_file 87 + config.Config.monorepo_path) 88 + 89 + let handle_claude_query env msg = 90 + let content = Zulip_bot.Message.content msg in 91 + Log.info (fun m -> m "Asking Claude: %s" content); 92 + let prompt = 93 + Printf.sprintf 94 + {|The user sent this message to the Poe Zulip bot: 95 + 96 + %s 97 + 98 + Please help them. If they're asking about adding features to the bot, read the bot's source code in the poe/ directory first. 99 + If they're asking about the monorepo or daily changes, help them understand the content. 100 + Keep your response concise and suitable for a Zulip message.|} 101 + content 102 + in 103 + let response = ask_claude env prompt in 104 + Log.info (fun m -> m "Claude response: %s" response); 105 + Zulip_bot.Response.reply response 106 + 107 + let make_handler env config = 108 + fun ~storage ~identity msg -> 109 + let bot_email = identity.Zulip_bot.Bot.email in 110 + let sender_email = Zulip_bot.Message.sender_email msg in 111 + if sender_email = bot_email then Zulip_bot.Response.silent 112 + else 113 + let content = 114 + Zulip_bot.Message.strip_mention msg ~user_email:bot_email 115 + |> String.trim |> String.lowercase_ascii 116 + in 117 + Log.info (fun m -> m "Received message: %s" content); 118 + match content with 119 + | "help" | "?" -> handle_help () 120 + | "status" -> handle_status config 121 + | "broadcast" | "post changes" | "post" | "changes" -> 122 + broadcast_changes ~fs:env.fs ~storage ~identity config msg 123 + | _ -> handle_claude_query env msg
+33
lib/handler.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Poe bot message handler. *) 7 + 8 + val src : Logs.Src.t 9 + (** Log source for the handler. *) 10 + 11 + type 'a env = { 12 + sw : Eio.Switch.t; 13 + process_mgr : 'a Eio.Process.mgr; 14 + clock : float Eio.Time.clock_ty Eio.Resource.t; 15 + fs : Eio.Fs.dir_ty Eio.Path.t; 16 + } 17 + (** Environment required for the handler. *) 18 + 19 + val make_handler : _ env -> Config.t -> Zulip_bot.Bot.handler 20 + (** [make_handler env config] creates a bot handler with the given 21 + environment and configuration. 22 + 23 + The handler responds to: 24 + - "help" or "?" - Shows help message 25 + - "status" - Shows current configuration 26 + - "broadcast", "post changes", "post", "changes" - Broadcasts daily changes 27 + - Any other message - Passed to Claude for interpretation *) 28 + 29 + val ask_claude : _ env -> string -> string 30 + (** [ask_claude env prompt] sends a prompt to Claude and returns the response. *) 31 + 32 + val read_changes_file : fs:_ Eio.Path.t -> Config.t -> string option 33 + (** [read_changes_file ~fs config] reads the daily changes file. *)
+9
lib/poe.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Poe - A Zulip bot for broadcasting monorepo changes with Claude integration. *) 7 + 8 + module Config = Config 9 + module Handler = Handler
+40
poe.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: 4 + "Zulip bot for broadcasting monorepo changes with Claude integration" 5 + description: """ 6 + Poe is a Zulip bot that broadcasts daily changelog updates from the monorepo 7 + to a configured channel. It integrates with Claude to interpret messages 8 + and can help extend its own functionality.""" 9 + maintainer: ["Anil Madhavapeddy <anil@recoil.org>"] 10 + authors: ["Anil Madhavapeddy"] 11 + license: "ISC" 12 + homepage: "https://tangled.org/@anil.recoil.org/poe" 13 + bug-reports: "https://tangled.org/@anil.recoil.org/poe/issues" 14 + depends: [ 15 + "ocaml" {>= "5.2.0"} 16 + "dune" {>= "3.20" & >= "3.20"} 17 + "eio_main" {>= "1.2"} 18 + "zulip" {>= "0.1.0"} 19 + "claude" {>= "0.1.0"} 20 + "tomlt" {>= "0.1.0"} 21 + "xdge" {>= "0.1.0"} 22 + "logs" {>= "0.7.0"} 23 + "cmdliner" {>= "1.3.0"} 24 + "odoc" {with-doc} 25 + ] 26 + build: [ 27 + ["dune" "subst"] {dev} 28 + [ 29 + "dune" 30 + "build" 31 + "-p" 32 + name 33 + "-j" 34 + jobs 35 + "@install" 36 + "@runtest" {with-test} 37 + "@doc" {with-doc} 38 + ] 39 + ] 40 + x-maintenance-intent: ["(latest)"]