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

more

+2
stack/zulip/.gitignore
···
+
_build
+
.claude
+689
stack/zulip/CLAUDE.md
···
+
I would like to build high quality OCaml bindings to the Zulip REST API,
+
documented at https://zulip.com/api/rest. As another reference, the Python
+
`zulip` library from pip is well maintained.
+
+
My target is to use the OCaml EIO direct-style library, with an idiomatic as
+
possible API that implements it. For JSON parsing, using the jsonm library is
+
right. For HTTPS, use cohttp-eio with the tls-eio library. You have access to
+
an OCaml LSP via MCP which provides type hints and other language server
+
features after you complete a `dune build`.
+
+
# OCaml Zulip Library Design
+
+
Based on analysis of:
+
- Zulip REST API documentation: https://zulip.com/api/rest
+
- Python zulip library: https://github.com/zulip/python-zulip-api
+
- Zulip error handling: https://zulip.com/api/rest-error-handling
+
- Zulip send message API: https://zulip.com/api/send-message
+
+
## Overview
+
The library follows OCaml best practices with abstract types (`type t`) per module, comprehensive constructors/accessors, and proper pretty printers. Each core concept gets its own module with a clean interface.
+
+
## Module Structure
+
+
### Authentication (`Zulip.Auth`)
+
```ocaml
+
type t (* abstract *)
+
+
val create : server_url:string -> email:string -> api_key:string -> t
+
val from_zuliprc : ?path:string -> unit -> (t, Error.t) result
+
val server_url : t -> string
+
val email : t -> string
+
val to_basic_auth_header : t -> string
+
val pp : Format.formatter -> t -> unit
+
```
+
+
### Error Handling (`Zulip.Error`)
+
```ocaml
+
type code =
+
| Invalid_api_key
+
| Request_variable_missing
+
| Bad_request
+
| User_deactivated
+
| Realm_deactivated
+
| Rate_limit_hit
+
| Other of string
+
+
type t (* abstract *)
+
+
val create : code:code -> msg:string -> ?extra:(string * Jsonm.value) list -> unit -> t
+
val code : t -> code
+
val message : t -> string
+
val extra : t -> (string * Jsonm.value) list
+
val pp : Format.formatter -> t -> unit
+
val of_json : Jsonm.value -> t option
+
```
+
+
### Message Types (`Zulip.Message_type`)
+
```ocaml
+
type t = [ `Direct | `Channel ]
+
+
val to_string : t -> string
+
val of_string : string -> t option
+
val pp : Format.formatter -> t -> unit
+
```
+
+
### Message (`Zulip.Message`)
+
```ocaml
+
type t (* abstract *)
+
+
val create :
+
type_:Message_type.t ->
+
to_:string list ->
+
content:string ->
+
?topic:string ->
+
?queue_id:string ->
+
?local_id:string ->
+
?read_by_sender:bool ->
+
unit -> t
+
+
val type_ : t -> Message_type.t
+
val to_ : t -> string list
+
val content : t -> string
+
val topic : t -> string option
+
val queue_id : t -> string option
+
val local_id : t -> string option
+
val read_by_sender : t -> bool
+
val to_json : t -> Jsonm.value
+
val pp : Format.formatter -> t -> unit
+
```
+
+
### Message Response (`Zulip.Message_response`)
+
```ocaml
+
type t (* abstract *)
+
+
val id : t -> int
+
val automatic_new_visibility_policy : t -> string option
+
val of_json : Jsonm.value -> (t, Error.t) result
+
val pp : Format.formatter -> t -> unit
+
```
+
+
### Client (`Zulip.Client`)
+
```ocaml
+
type t (* abstract *)
+
+
val create : #Eio.Env.t -> Auth.t -> t
+
val with_client : #Eio.Env.t -> Auth.t -> (t -> 'a) -> 'a
+
+
val request :
+
t ->
+
method_:[`GET | `POST | `PUT | `DELETE | `PATCH] ->
+
path:string ->
+
?params:(string * string) list ->
+
?body:string ->
+
unit ->
+
(Jsonm.value, Error.t) result
+
```
+
+
### Messages (`Zulip.Messages`)
+
```ocaml
+
val send : Client.t -> Message.t -> (Message_response.t, Error.t) result
+
val edit : Client.t -> message_id:int -> ?content:string -> ?topic:string -> unit -> (unit, Error.t) result
+
val delete : Client.t -> message_id:int -> (unit, Error.t) result
+
val get : Client.t -> message_id:int -> (Jsonm.value, Error.t) result
+
val get_messages :
+
Client.t ->
+
?anchor:string ->
+
?num_before:int ->
+
?num_after:int ->
+
?narrow:string list ->
+
unit ->
+
(Jsonm.value, Error.t) result
+
```
+
+
### Channel (`Zulip.Channel`)
+
```ocaml
+
type t (* abstract *)
+
+
val create :
+
name:string ->
+
description:string ->
+
?invite_only:bool ->
+
?history_public_to_subscribers:bool ->
+
unit -> t
+
+
val name : t -> string
+
val description : t -> string
+
val invite_only : t -> bool
+
val history_public_to_subscribers : t -> bool
+
val to_json : t -> Jsonm.value
+
val of_json : Jsonm.value -> (t, Error.t) result
+
val pp : Format.formatter -> t -> unit
+
```
+
+
### Channels (`Zulip.Channels`)
+
```ocaml
+
val create_channel : Client.t -> Channel.t -> (unit, Error.t) result
+
val delete : Client.t -> name:string -> (unit, Error.t) result
+
val list : Client.t -> (Channel.t list, Error.t) result
+
val subscribe : Client.t -> channels:string list -> (unit, Error.t) result
+
val unsubscribe : Client.t -> channels:string list -> (unit, Error.t) result
+
```
+
+
### User (`Zulip.User`)
+
```ocaml
+
type t (* abstract *)
+
+
val create :
+
email:string ->
+
full_name:string ->
+
?is_active:bool ->
+
?is_admin:bool ->
+
?is_bot:bool ->
+
unit -> t
+
+
val email : t -> string
+
val full_name : t -> string
+
val is_active : t -> bool
+
val is_admin : t -> bool
+
val is_bot : t -> bool
+
val to_json : t -> Jsonm.value
+
val of_json : Jsonm.value -> (t, Error.t) result
+
val pp : Format.formatter -> t -> unit
+
```
+
+
### Users (`Zulip.Users`)
+
```ocaml
+
val list : Client.t -> (User.t list, Error.t) result
+
val get : Client.t -> email:string -> (User.t, Error.t) result
+
val create_user : Client.t -> email:string -> full_name:string -> (unit, Error.t) result
+
val deactivate : Client.t -> email:string -> (unit, Error.t) result
+
```
+
+
### Event Type (`Zulip.Event_type`)
+
```ocaml
+
type t =
+
| Message
+
| Subscription
+
| User_activity
+
| Other of string
+
+
val to_string : t -> string
+
val of_string : string -> t
+
val pp : Format.formatter -> t -> unit
+
```
+
+
### Event (`Zulip.Event`)
+
```ocaml
+
type t (* abstract *)
+
+
val id : t -> int
+
val type_ : t -> Event_type.t
+
val data : t -> Jsonm.value
+
val of_json : Jsonm.value -> (t, Error.t) result
+
val pp : Format.formatter -> t -> unit
+
```
+
+
### Event Queue (`Zulip.Event_queue`)
+
```ocaml
+
type t (* abstract *)
+
+
val register :
+
Client.t ->
+
?event_types:Event_type.t list ->
+
unit ->
+
(t, Error.t) result
+
+
val id : t -> string
+
val get_events : t -> Client.t -> ?last_event_id:int -> unit -> (Event.t list, Error.t) result
+
val delete : t -> Client.t -> (unit, Error.t) result
+
val pp : Format.formatter -> t -> unit
+
```
+
+
## EIO Bot Framework Extension
+
+
Based on analysis of the Python bot framework at:
+
- https://github.com/zulip/python-zulip-api/blob/main/zulip_bots/zulip_bots/lib.py
+
- https://github.com/zulip/python-zulip-api/blob/main/zulip_botserver/zulip_botserver/server.py
+
+
### Bot Handler (`Zulip.Bot`)
+
```ocaml
+
module Storage : sig
+
type t (* abstract *)
+
+
val create : Client.t -> t
+
val get : t -> key:string -> string option
+
val put : t -> key:string -> value:string -> unit
+
val contains : t -> key:string -> bool
+
end
+
+
module Identity : sig
+
type t (* abstract *)
+
+
val full_name : t -> string
+
val email : t -> string
+
val mention_name : t -> string
+
end
+
+
type handler = {
+
handle_message :
+
client:Client.t ->
+
message:Jsonm.value ->
+
response:(Message.t -> unit) ->
+
unit;
+
+
usage : unit -> string;
+
description : unit -> string;
+
}
+
+
type t (* abstract *)
+
+
val create :
+
Client.t ->
+
handler:handler ->
+
?storage:Storage.t ->
+
unit -> t
+
+
val identity : t -> Identity.t
+
val storage : t -> Storage.t
+
val handle_message : t -> Jsonm.value -> unit
+
val send_reply : t -> original_message:Jsonm.value -> content:string -> unit
+
val send_message : t -> Message.t -> unit
+
```
+
+
### Bot Server (`Zulip.Bot_server`)
+
```ocaml
+
module Config : sig
+
type bot_config = {
+
email : string;
+
api_key : string;
+
token : string; (* webhook token *)
+
server_url : string;
+
module_name : string;
+
}
+
+
type t (* abstract *)
+
+
val create : bot_configs:bot_config list -> ?host:string -> ?port:int -> unit -> t
+
val from_file : string -> (t, Error.t) result
+
val from_env : string -> (t, Error.t) result
+
val host : t -> string
+
val port : t -> int
+
val bot_configs : t -> bot_config list
+
end
+
+
type t (* abstract *)
+
+
val create : #Eio.Env.t -> Config.t -> (t, Error.t) result
+
+
val run : t -> unit
+
(* Starts the server using EIO structured concurrency *)
+
+
val with_server : #Eio.Env.t -> Config.t -> (t -> 'a) -> ('a, Error.t) result
+
(* Resource-safe server management *)
+
```
+
+
### Bot Registry (`Zulip.Bot_registry`)
+
```ocaml
+
type bot_module = {
+
name : string;
+
handler : Bot.handler;
+
create_instance : Client.t -> Bot.t;
+
}
+
+
type t (* abstract *)
+
+
val create : unit -> t
+
val register : t -> bot_module -> unit
+
val get_handler : t -> email:string -> Bot.t option
+
val list_bots : t -> string list
+
+
(* Dynamic module loading *)
+
val load_from_file : string -> (bot_module, Error.t) result
+
val load_from_directory : string -> (bot_module list, Error.t) result
+
```
+
+
### Webhook Handler (`Zulip.Webhook`)
+
```ocaml
+
type webhook_event = {
+
bot_email : string;
+
token : string;
+
message : Jsonm.value;
+
trigger : [`Direct_message | `Mention];
+
}
+
+
type response = {
+
content : string option;
+
message_type : Message_type.t option;
+
to_ : string list option;
+
topic : string option;
+
}
+
+
val parse_webhook : string -> (webhook_event, Error.t) result
+
val handle_webhook : Bot_registry.t -> webhook_event -> (response option, Error.t) result
+
```
+
+
### Structured Concurrency Design
+
+
The EIO-based server uses structured concurrency to manage multiple bots safely:
+
+
```ocaml
+
(* Example server implementation using EIO *)
+
let run_server env config =
+
let registry = Bot_registry.create () in
+
+
(* Load and register all configured bots concurrently *)
+
Eio.Switch.run @@ fun sw ->
+
+
(* Start each bot in its own fiber *)
+
List.iter (fun bot_config ->
+
Eio.Fiber.fork ~sw (fun () ->
+
let auth = Auth.create
+
~server_url:bot_config.server_url
+
~email:bot_config.email
+
~api_key:bot_config.api_key in
+
+
Client.with_client env auth @@ fun client ->
+
+
(* Load bot module *)
+
match Bot_registry.load_from_file bot_config.module_name with
+
| Ok bot_module ->
+
let bot = bot_module.create_instance client in
+
Bot_registry.register registry bot_module;
+
+
(* Keep bot alive and handle events *)
+
Event_loop.run client bot
+
| Error e ->
+
Printf.eprintf "Failed to load bot %s: %s\n"
+
bot_config.email (Error.message e)
+
)
+
) (Config.bot_configs config);
+
+
(* Start HTTP server for webhooks *)
+
let server_addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, Config.port config) in
+
Eio.Net.run_server env#net server_addr ~on_error:raise @@ fun flow _addr ->
+
+
(* Handle each webhook request concurrently *)
+
Eio.Switch.run @@ fun req_sw ->
+
Eio.Fiber.fork ~sw:req_sw (fun () ->
+
handle_http_request registry flow
+
)
+
```
+
+
### Event Loop (`Zulip.Event_loop`)
+
```ocaml
+
type t (* abstract *)
+
+
val create : Client.t -> Bot.t -> t
+
+
val run : Client.t -> Bot.t -> unit
+
(* Runs the event loop using real-time events API *)
+
+
val run_webhook_mode : Client.t -> Bot.t -> unit
+
(* Runs in webhook mode, waiting for HTTP callbacks *)
+
+
(* For advanced use cases *)
+
val with_event_loop :
+
Client.t ->
+
Bot.t ->
+
(Event_queue.t -> unit) ->
+
unit
+
```
+
+
## Key EIO Advantages
+
+
1. **Structured Concurrency**: Each bot runs in its own fiber with proper cleanup
+
2. **Resource Safety**: Automatic cleanup of connections, event queues, and HTTP servers
+
3. **Backpressure**: Natural flow control through EIO's cooperative scheduling
+
4. **Error Isolation**: Bot failures don't crash the entire server
+
5. **Graceful Shutdown**: Structured teardown of all resources
+
+
## Design Principles
+
+
1. **Abstract Types**: Each major concept has its own module with abstract `type t`
+
2. **Constructors**: Clear `create` functions with optional parameters
+
3. **Accessors**: All fields accessible via dedicated functions
+
4. **Pretty Printing**: Every type has a `pp` function for debugging
+
5. **JSON Conversion**: Bidirectional JSON conversion where appropriate
+
6. **Error Handling**: Consistent `(_, Error.t) result` return types
+
+
## Authentication Strategy
+
+
- Support zuliprc files and direct credential passing
+
- Abstract `Auth.t` prevents credential leakage
+
- HTTP Basic Auth with proper encoding
+
+
## EIO Integration
+
+
- All operations use EIO's direct-style async
+
- Resource-safe client management with `with_client`
+
- Proper cleanup of connections and event queues
+
+
## Example Usage
+
+
### Simple Message Sending
+
```ocaml
+
let () =
+
Eio_main.run @@ fun env ->
+
let auth = Zulip.Auth.create
+
~server_url:"https://example.zulipchat.com"
+
~email:"bot@example.com"
+
~api_key:"your-api-key" in
+
+
Zulip.Client.with_client env auth @@ fun client ->
+
+
let message = Zulip.Message.create
+
~type_:`Channel
+
~to_:["general"]
+
~content:"Hello from OCaml!"
+
~topic:"Bots"
+
() in
+
+
match Zulip.Messages.send client message with
+
| Ok response ->
+
Printf.printf "Message sent with ID: %d\n"
+
(Zulip.Message_response.id response)
+
| Error error ->
+
Printf.printf "Error: %s\n"
+
(Zulip.Error.message error)
+
```
+
+
### Simple Bot
+
```ocaml
+
let echo_handler = Zulip.Bot.{
+
handle_message = (fun ~client ~message ~response ->
+
let content = extract_content message in
+
let echo_msg = Message.create
+
~type_:`Direct
+
~to_:[sender_email message]
+
~content:("Echo: " ^ content) () in
+
response echo_msg
+
);
+
usage = (fun () -> "Echo bot - repeats your message");
+
description = (fun () -> "A simple echo bot");
+
}
+
+
let () =
+
Eio_main.run @@ fun env ->
+
let auth = Auth.from_zuliprc () |> Result.get_ok in
+
+
Client.with_client env auth @@ fun client ->
+
let bot = Bot.create client ~handler:echo_handler () in
+
Event_loop.run client bot
+
```
+
+
### Multi-Bot Server
+
```ocaml
+
let () =
+
Eio_main.run @@ fun env ->
+
let config = Bot_server.Config.from_file "bots.conf" |> Result.get_ok in
+
+
Bot_server.with_server env config @@ fun server ->
+
Bot_server.run server
+
```
+
+
## Package Dependencies
+
+
- `eio` - Effects-based I/O
+
- `cohttp-eio` - HTTP client with EIO support
+
- `tls-eio` - TLS support for HTTPS
+
- `jsonm` - Streaming JSON codec
+
- `uri` - URI parsing and manipulation
+
- `base64` - Base64 encoding for authentication
+
+
# Architecture Analysis: zulip_bot vs zulip_botserver
+
+
## Library Separation
+
+
### `zulip_bot` - Individual Bot Framework
+
**Purpose**: Library for building and running a single bot instance
+
+
**Key Components**:
+
- `Bot_handler` - Interface for bot logic with EIO environment access
+
- `Bot_runner` - Manages lifecycle of one bot (real-time events or webhook mode)
+
- `Bot_config` - Configuration for a single bot
+
- `Bot_storage` - Simple in-memory storage for bot state
+
+
**Usage Pattern**:
+
```ocaml
+
(* Run a single bot directly *)
+
let my_bot = Bot_handler.create (module My_echo_bot) ~config ~storage ~identity in
+
let runner = Bot_runner.create ~client ~handler:my_bot in
+
Bot_runner.run_realtime runner (* Bot connects to Zulip events API directly *)
+
```
+
+
### `zulip_botserver` - Multi-Bot Server Infrastructure
+
**Purpose**: HTTP server that manages multiple bots via webhooks
+
+
**Key Components**:
+
- `Bot_server` - HTTP server receiving webhook events from Zulip
+
- `Bot_registry` - Manages multiple bot instances
+
- `Server_config` - Configuration for multiple bots + server settings
+
- `Webhook_handler` - Parses incoming webhook requests and routes to appropriate bots
+
+
**Usage Pattern**:
+
```ocaml
+
(* Run a server hosting multiple bots *)
+
let registry = Bot_registry.create () in
+
Bot_registry.register registry echo_bot_module;
+
Bot_registry.register registry weather_bot_module;
+
+
let server = Bot_server.create ~env ~config ~registry in
+
Bot_server.run server (* HTTP server waits for webhook calls *)
+
```
+
+
## EIO Environment Requirements
+
+
### Why Bot Handlers Need Direct EIO Access
+
+
Bot handlers require direct access to the EIO environment for legitimate I/O operations beyond HTTP requests to Zulip:
+
+
1. **Network Operations**: Custom HTTP requests, API calls to external services
+
2. **File System Operations**: Reading configuration files, CSV dictionaries, logs
+
3. **Resource Management**: Proper cleanup via structured concurrency
+
+
### Example: URL Checker Bot
+
```ocaml
+
module Url_checker_bot : Zulip_bot.Bot_handler.Bot_handler = struct
+
let handle_message ~config ~storage ~identity ~message ~env =
+
match parse_command message with
+
| "!check", url ->
+
(* Direct EIO network access needed *)
+
Eio.Switch.run @@ fun sw ->
+
let client = Cohttp_eio.Client.make ~sw env#net in
+
let response = Cohttp_eio.Client.head ~sw client (Uri.of_string url) in
+
let status = Cohttp.Code.code_of_status response.status in
+
Ok (Response.reply ~content:(format_status_message url status))
+
| _ -> Ok Response.none
+
end
+
```
+
+
### Example: CSV Dictionary Bot
+
```ocaml
+
module Csv_dict_bot : Zulip_bot.Bot_handler.Bot_handler = struct
+
let handle_message ~config ~storage ~identity ~message ~env =
+
match parse_command message with
+
| "!lookup", term ->
+
(* Direct EIO file system access needed *)
+
let csv_path = Bot_config.get_required config ~key:"csv_file" in
+
let content = Eio.Path.load env#fs (Eio.Path.parse csv_path) in
+
let matches = search_csv_content content term in
+
Ok (Response.reply ~content:(format_matches matches))
+
| _ -> Ok Response.none
+
end
+
```
+
+
## Refined Bot Handler Interface
+
+
Based on analysis, the current EIO environment plumbing is **essential** and should be cleaned up:
+
+
```ocaml
+
(** Clean bot handler interface with direct EIO access *)
+
module type Bot_handler = sig
+
val initialize : Bot_config.t -> (unit, Zulip.Error.t) result
+
val usage : unit -> string
+
val description : unit -> string
+
+
(** Handle message with full EIO environment access *)
+
val handle_message :
+
config:Bot_config.t ->
+
storage:Bot_storage.t ->
+
identity:Identity.t ->
+
message:Message_context.t ->
+
env:#Eio.Env.t -> (* Essential for custom I/O *)
+
(Response.t, Zulip.Error.t) result
+
end
+
+
type t
+
+
(** Single creation interface *)
+
val create :
+
(module Bot_handler) ->
+
config:Bot_config.t ->
+
storage:Bot_storage.t ->
+
identity:Identity.t ->
+
t
+
+
(** Single message handler requiring EIO environment *)
+
val handle_message : t -> #Eio.Env.t -> Message_context.t -> (Response.t, Zulip.Error.t) result
+
```
+
+
## Storage Strategy
+
+
Bot storage can be simplified to in-memory key-value storage since it's server-side:
+
+
```ocaml
+
(* In zulip_bot - storage per bot instance *)
+
module Bot_storage = struct
+
type t = (string, string) Hashtbl.t (* Simple in-memory key-value *)
+
+
let create () = Hashtbl.create 16
+
let get t ~key = Hashtbl.find_opt t key
+
let put t ~key ~value = Hashtbl.replace t key value
+
let contains t ~key = Hashtbl.mem t key
+
end
+
+
(* In zulip_botserver - storage shared across bots *)
+
module Server_storage = struct
+
type t = (string * string, string) Hashtbl.t (* (bot_email, key) -> value *)
+
+
let create () = Hashtbl.create 64
+
let get t ~bot_email ~key = Hashtbl.find_opt t (bot_email, key)
+
let put t ~bot_email ~key ~value = Hashtbl.replace t (bot_email, key) value
+
end
+
```
+
+
## Interface Cleanup Recommendations
+
+
1. **Remove** the problematic `handle_message` function with mock environment
+
2. **Keep** `handle_message_with_env` but rename to `handle_message`
+
3. **Use** `#Eio.Env.t` constraint for clean typing
+
4. **Document** that bot handlers have full EIO access for custom I/O operations
+
+
This design maintains flexibility for real-world bot functionality while providing clean, type-safe interfaces.
+
+
## Sources and References
+
+
This design is based on comprehensive analysis of:
+
+
1. **Zulip REST API Documentation**:
+
- Main API: https://zulip.com/api/rest
+
- Error Handling: https://zulip.com/api/rest-error-handling
+
- Send Message: https://zulip.com/api/send-message
+
+
2. **Python Zulip Library**:
+
- Main repository: https://github.com/zulip/python-zulip-api
+
- Bot framework: https://github.com/zulip/python-zulip-api/blob/main/zulip_bots/zulip_bots/lib.py
+
- Bot server: https://github.com/zulip/python-zulip-api/blob/main/zulip_botserver/zulip_botserver/server.py
+
+
The design adapts these Python patterns to idiomatic OCaml with abstract types, proper error handling, and EIO's structured concurrency for robust, type-safe Zulip integration.
+143
stack/zulip/README.md
···
+
# OCaml Zulip Library with Requests
+
+
A complete OCaml implementation of the Zulip REST API using the `requests` HTTP library.
+
+
## Features
+
+
- ✅ Full Zulip REST API client implementation
+
- ✅ Uses the modern `requests` library for HTTP communication
+
- ✅ EIO-based asynchronous operations
+
- ✅ Bot framework for building interactive bots
+
- ✅ Support for Atom/RSS feed bots
+
+
## Installation
+
+
```bash
+
dune build
+
dune install
+
```
+
+
## Configuration
+
+
Create a `~/.zuliprc` file with your Zulip credentials:
+
+
```ini
+
[api]
+
email = bot@example.com
+
key = your-api-key-here
+
site = https://your-domain.zulipchat.com
+
```
+
+
## Usage
+
+
### Basic Client
+
+
```ocaml
+
open Eio_main
+
+
let () =
+
run @@ fun env ->
+
+
(* Load authentication *)
+
let auth = Zulip.Auth.from_zuliprc () |> Result.get_ok in
+
+
(* Create client *)
+
Eio.Switch.run @@ fun sw ->
+
let client = Zulip.Client.create ~sw env auth in
+
+
(* Send a message *)
+
let message = Zulip.Message.create
+
~type_:`Channel
+
~to_:["general"]
+
~topic:"Hello"
+
~content:"Hello from OCaml!"
+
()
+
in
+
+
match Zulip.Messages.send client message with
+
| Ok response -> Printf.printf "Sent message %d\n" (Zulip.Message_response.id response)
+
| Error e -> Printf.eprintf "Error: %s\n" (Zulip.Error.message e)
+
```
+
+
### Atom Feed Bot
+
+
The library includes a complete Atom/RSS feed bot that can:
+
- Monitor multiple feeds
+
- Post updates to specific Zulip channels and topics
+
- Track which entries have been posted
+
- Run as a scheduled daemon or interactive bot
+
+
#### Scheduled Mode
+
+
```bash
+
# Run the feed bot in scheduled mode
+
dune exec atom_feed_bot
+
```
+
+
#### Interactive Mode
+
+
```bash
+
# Run as an interactive bot that responds to commands
+
dune exec atom_feed_bot interactive
+
```
+
+
Bot commands:
+
- `!feed add <name> <url> <topic>` - Add a new feed
+
- `!feed remove <name>` - Remove a feed
+
- `!feed list` - List all feeds
+
- `!feed fetch <name>` - Manually fetch a feed
+
- `!feed help` - Show help
+
+
## Architecture
+
+
### Core Library (`zulip`)
+
+
- **Auth**: Authentication and credential management
+
- **Client**: HTTP client using the `requests` library
+
- **Messages**: Send, edit, and retrieve messages
+
- **Channels**: Channel/stream management
+
- **Users**: User management
+
- **Events**: Real-time event handling
+
+
### Bot Framework (`zulip_bot`)
+
+
- **Bot_handler**: Interface for bot logic
+
- **Bot_runner**: Manages bot lifecycle
+
- **Bot_storage**: State persistence
+
- **Bot_config**: Configuration management
+
+
## Key Changes from Original Implementation
+
+
1. **HTTP Library**: Migrated from `cohttp-eio` to the `requests` library
+
2. **Configuration**: Removed `toml` dependency, uses simple INI parser
+
3. **Type Safety**: Made Client.t parametric over environment types
+
4. **Authentication**: Simplified auth handling with built-in INI parser
+
+
## Examples
+
+
See the `examples/` directory for:
+
- `test_client.ml` - Basic client functionality test
+
- `atom_feed_bot.ml` - Complete Atom/RSS feed bot implementation
+
+
## Testing
+
+
```bash
+
# Build the library
+
dune build
+
+
# Run the test client
+
dune exec test_client
+
+
# Run the atom feed bot
+
dune exec atom_feed_bot
+
```
+
+
## Requirements
+
+
- OCaml 4.08+
+
- Dune 3.0+
+
- eio
+
- requests
+
- jsonm
+
- uri
+
- base64
+42
stack/zulip/dune-project
···
+
(lang dune 3.0)
+
+
(name ocaml-zulip)
+
+
(package
+
(name zulip)
+
(synopsis "OCaml bindings for the Zulip REST API")
+
(description "High-quality OCaml bindings to the Zulip REST API using EIO for async operations")
+
(depends
+
ocaml
+
dune
+
eio
+
requests
+
jsonm
+
uri
+
base64
+
(alcotest :with-test)
+
(eio_main :with-test)))
+
+
(package
+
(name zulip_bot)
+
(synopsis "OCaml bot framework for Zulip")
+
(description "Interactive bot framework built on the OCaml Zulip library")
+
(depends
+
ocaml
+
dune
+
zulip
+
eio
+
(alcotest :with-test)))
+
+
(package
+
(name zulip_botserver)
+
(synopsis "OCaml bot server for running multiple Zulip bots")
+
(description "HTTP server for running multiple Zulip bots with webhook support")
+
(depends
+
ocaml
+
dune
+
zulip
+
zulip_bot
+
eio
+
requests
+
(alcotest :with-test)))
+167
stack/zulip/examples/README_ECHO_BOT.md
···
+
# Zulip Echo Bot
+
+
A simple echo bot that demonstrates the basic functionality of the Zulip bot framework. The bot responds to direct messages and mentions by echoing back the message content.
+
+
## Features
+
+
- Responds to direct messages
+
- Responds to @mentions in channels
+
- Avoids infinite loops by ignoring its own messages
+
- Simple help command
+
+
## Prerequisites
+
+
1. A Zulip account and server
+
2. A bot user created in Zulip (Settings → Bots → Add a new bot)
+
3. OCaml and dependencies installed
+
+
## Setup
+
+
### 1. Create a Bot in Zulip
+
+
1. Go to your Zulip settings
+
2. Navigate to "Bots" section
+
3. Click "Add a new bot"
+
4. Choose "Generic bot" type
+
5. Give it a name (e.g., "Echo Bot")
+
6. Note down the bot email and API key
+
+
### 2. Configure Authentication
+
+
Create a `~/.zuliprc` file with your bot's credentials:
+
+
```ini
+
[api]
+
email=echo-bot@your-domain.zulipchat.com
+
key=your-bot-api-key
+
site=https://your-domain.zulipchat.com
+
```
+
+
Replace the values with your actual bot credentials.
+
+
### 3. Build the Bot
+
+
```bash
+
# From the zulip directory
+
dune build
+
+
# Or build just the echo bot
+
dune build zulip/examples/echo_bot.exe
+
```
+
+
## Running the Bot
+
+
```bash
+
# Run from the project root
+
dune exec echo_bot
+
+
# Or run the built executable directly
+
./_build/default/zulip/examples/echo_bot.exe
+
```
+
+
You should see output like:
+
```
+
Starting Zulip Echo Bot...
+
=============================
+
+
Loaded authentication for: echo-bot@your-domain.zulipchat.com
+
Server: https://your-domain.zulipchat.com
+
+
Echo bot is running!
+
Send a direct message or mention @echobot in a channel.
+
Press Ctrl+C to stop.
+
```
+
+
## Testing the Bot
+
+
### Direct Message Test
+
1. Open Zulip
+
2. Send a direct message to your bot
+
3. Type: `Hello bot!`
+
4. The bot should respond: `Echo from [Your Name]: Hello bot!`
+
+
### Channel Mention Test
+
1. In any channel, type: `@echobot Hello everyone!`
+
2. The bot should respond: `Echo from [Your Name]: Hello everyone!`
+
+
### Help Command
+
Send `help` to the bot to get usage information.
+
+
## How It Works
+
+
The echo bot:
+
1. Connects to Zulip using the real-time events API
+
2. Listens for messages where it's mentioned or direct messaged
+
3. Extracts the message content and sender information
+
4. Sends back an echo of the message
+
5. Ignores its own messages to prevent loops
+
+
## Code Structure
+
+
- **Bot Handler Module**: Implements the `Bot_handler.S` signature with message processing logic
+
- **Message Processing**: Extracts fields from incoming JSON messages
+
- **Response Generation**: Creates appropriate responses based on message type
+
- **Identity Management**: Uses bot identity to avoid self-responses
+
+
## Customization
+
+
You can modify the echo bot to:
+
- Add more commands (parse for specific keywords)
+
- Store conversation history (use `Bot_storage`)
+
- Integrate with external APIs
+
- Format responses differently
+
- Add emoji reactions
+
+
## Troubleshooting
+
+
### Bot doesn't respond
+
- Check that the bot is actually running (look for console output)
+
- Verify the bot has permissions in the channel
+
- Check that you're mentioning the bot correctly (@botname)
+
- Look for error messages in the console
+
+
### Authentication errors
+
- Verify your `.zuliprc` file has the correct credentials
+
- Ensure the API key hasn't been regenerated
+
- Check that the bot user is active in Zulip
+
+
### Build errors
+
- Make sure all dependencies are installed: `opam install zulip zulip_bot eio_main`
+
- Clean and rebuild: `dune clean && dune build`
+
+
## Next Steps
+
+
Once you have the echo bot working, you can:
+
1. Extend it with more complex command parsing
+
2. Add persistent storage for user preferences
+
3. Integrate with external services
+
4. Build more sophisticated bots using the same framework
+
+
## Example Extensions
+
+
### Adding a Command Parser
+
```ocaml
+
let parse_command content =
+
match String.split_on_char ' ' content with
+
| "!echo" :: rest -> Some ("echo", String.concat " " rest)
+
| "!reverse" :: rest -> Some ("reverse", String.concat " " rest)
+
| _ -> None
+
```
+
+
### Using Bot Storage
+
```ocaml
+
(* Store user preferences *)
+
let _ = Bot_storage.put storage ~key:"user_prefs" ~value:"{...}" in
+
+
(* Retrieve later *)
+
let prefs = Bot_storage.get storage ~key:"user_prefs" in
+
```
+
+
### Sending to Specific Channels
+
```ocaml
+
Bot_handler.Response.ChannelMessage {
+
channel = "general";
+
topic = "Bot Updates";
+
content = "Echo bot is online!";
+
}
+
```
+383
stack/zulip/examples/atom_feed_bot.ml
···
+
(* Atom Feed Bot for Zulip
+
Posts Atom/RSS feed entries to Zulip channels organized by topic *)
+
+
module Feed_parser = struct
+
type entry = {
+
title : string;
+
link : string;
+
summary : string option;
+
published : string option;
+
author : string option;
+
}
+
+
type feed = {
+
title : string;
+
entries : entry list;
+
}
+
+
(* Simple XML parser for Atom/RSS feeds *)
+
let parse_xml_element xml element_name =
+
let open_tag = "<" ^ element_name ^ ">" in
+
let close_tag = "</" ^ element_name ^ ">" in
+
try
+
(* Find the opening tag *)
+
match String.index_opt xml '<' with
+
| None -> None
+
| Some _ ->
+
(* Search for the actual open tag in the XML *)
+
let pattern = open_tag in
+
let pattern_start =
+
try Some (String.index (String.lowercase_ascii xml)
+
(String.lowercase_ascii pattern).[0])
+
with Not_found -> None
+
in
+
match pattern_start with
+
| None -> None
+
| Some _ ->
+
(* Try to find the content between tags *)
+
let rec find_substring str sub start =
+
if start + String.length sub > String.length str then
+
None
+
else if String.sub str start (String.length sub) = sub then
+
Some start
+
else
+
find_substring str sub (start + 1)
+
in
+
match find_substring xml open_tag 0 with
+
| None -> None
+
| Some start_pos ->
+
let content_start = start_pos + String.length open_tag in
+
match find_substring xml close_tag content_start with
+
| None -> None
+
| Some end_pos ->
+
let content = String.sub xml content_start (end_pos - content_start) in
+
Some (String.trim content)
+
with _ -> None
+
+
let parse_entry entry_xml =
+
let title = parse_xml_element entry_xml "title" in
+
let link = parse_xml_element entry_xml "link" in
+
let summary = parse_xml_element entry_xml "summary" in
+
let published = parse_xml_element entry_xml "published" in
+
let author = parse_xml_element entry_xml "author" in
+
match title, link with
+
| Some t, Some l -> Some { title = t; link = l; summary; published; author }
+
| _ -> None
+
+
let parse_feed xml =
+
(* Very basic XML parsing - in production, use a proper XML library *)
+
let feed_title = parse_xml_element xml "title" |> Option.value ~default:"Unknown Feed" in
+
+
(* Extract entries between <entry> tags (Atom) or <item> tags (RSS) *)
+
let entries = ref [] in
+
let rec extract_entries str pos =
+
try
+
let entry_start =
+
try String.index_from str pos '<'
+
with Not_found -> String.length str
+
in
+
if entry_start >= String.length str then ()
+
else
+
let tag_end = String.index_from str entry_start '>' in
+
let tag = String.sub str (entry_start + 1) (tag_end - entry_start - 1) in
+
if tag = "entry" || tag = "item" then
+
let entry_end =
+
try String.index_from str tag_end '<'
+
with Not_found -> String.length str
+
in
+
let entry_xml = String.sub str entry_start (entry_end - entry_start) in
+
(match parse_entry entry_xml with
+
| Some e -> entries := e :: !entries
+
| None -> ());
+
extract_entries str entry_end
+
else
+
extract_entries str (tag_end + 1)
+
with _ -> ()
+
in
+
extract_entries xml 0;
+
{ title = feed_title; entries = List.rev !entries }
+
end
+
+
module Feed_bot = struct
+
type config = {
+
feeds : (string * string * string) list; (* URL, channel, topic *)
+
refresh_interval : float; (* seconds *)
+
state_file : string;
+
}
+
+
type state = {
+
last_seen : (string, string) Hashtbl.t; (* feed_url -> last_entry_id *)
+
}
+
+
let load_state path =
+
try
+
let ic = open_in path in
+
let state = { last_seen = Hashtbl.create 10 } in
+
(try
+
while true do
+
let line = input_line ic in
+
match String.split_on_char '|' line with
+
| [url; id] -> Hashtbl.add state.last_seen url id
+
| _ -> ()
+
done
+
with End_of_file -> ());
+
close_in ic;
+
state
+
with _ -> { last_seen = Hashtbl.create 10 }
+
+
let save_state path state =
+
let oc = open_out path in
+
Hashtbl.iter (fun url id ->
+
output_string oc (url ^ "|" ^ id ^ "\n")
+
) state.last_seen;
+
close_out oc
+
+
let fetch_feed url =
+
(* In a real implementation, use an HTTP client to fetch the feed *)
+
(* For now, return a mock feed *)
+
Feed_parser.{
+
title = "Mock Feed";
+
entries = [
+
{ title = "Test Entry";
+
link = "https://example.com/1";
+
summary = Some "This is a test entry";
+
published = Some "2024-01-01T00:00:00Z";
+
author = Some "Test Author" }
+
]
+
}
+
+
let format_entry (entry : Feed_parser.entry) =
+
let lines = [
+
Printf.sprintf "**[%s](%s)**" entry.title entry.link;
+
] in
+
let lines = match entry.author with
+
| Some a -> lines @ [Printf.sprintf "*By %s*" a]
+
| None -> lines
+
in
+
let lines = match entry.published with
+
| Some p -> lines @ [Printf.sprintf "*Published: %s*" p]
+
| None -> lines
+
in
+
let lines = match entry.summary with
+
| Some s -> lines @ [""; s]
+
| None -> lines
+
in
+
String.concat "\n" lines
+
+
let post_entry client channel topic entry =
+
let open Feed_parser in
+
let message = Zulip.Message.create
+
~type_:`Channel
+
~to_:[channel]
+
~topic
+
~content:(format_entry entry)
+
()
+
in
+
match Zulip.Messages.send client message with
+
| Ok _ -> Printf.printf "Posted: %s\n" entry.title
+
| Error e -> Printf.eprintf "Error posting: %s\n" (Zulip.Error.message e)
+
+
let process_feed client state (url, channel, topic) =
+
Printf.printf "Processing feed: %s -> #%s/%s\n" url channel topic;
+
let feed = fetch_feed url in
+
+
let last_id = Hashtbl.find_opt state.last_seen url in
+
let new_entries = match last_id with
+
| Some id ->
+
(* Filter entries newer than last_id *)
+
List.filter (fun e ->
+
Feed_parser.(e.link <> id)
+
) feed.entries
+
| None -> feed.entries
+
in
+
+
(* Post new entries *)
+
List.iter (post_entry client channel topic) new_entries;
+
+
(* Update last seen *)
+
match feed.entries with
+
| h :: _ -> Hashtbl.replace state.last_seen url Feed_parser.(h.link)
+
| [] -> ()
+
+
let run_bot env config =
+
(* Load authentication *)
+
let auth = match Zulip.Auth.from_zuliprc () with
+
| Ok a -> a
+
| Error e ->
+
Printf.eprintf "Failed to load auth: %s\n" (Zulip.Error.message e);
+
exit 1
+
in
+
+
(* Create client *)
+
Eio.Switch.run @@ fun sw ->
+
let client = Zulip.Client.create ~sw env auth in
+
+
(* Load state *)
+
let state = load_state config.state_file in
+
+
(* Main loop *)
+
let rec loop () =
+
Printf.printf "Checking feeds...\n";
+
List.iter (process_feed client state) config.feeds;
+
save_state config.state_file state;
+
+
Printf.printf "Sleeping for %.0f seconds...\n" config.refresh_interval;
+
Eio.Time.sleep (Eio.Stdenv.clock env) config.refresh_interval;
+
loop ()
+
in
+
loop ()
+
end
+
+
(* Interactive bot that responds to commands *)
+
module Interactive_feed_bot = struct
+
open Zulip_bot
+
+
type t = {
+
feeds : (string, string * string) Hashtbl.t; (* name -> (url, topic) *)
+
mutable default_channel : string;
+
}
+
+
let create () = {
+
feeds = Hashtbl.create 10;
+
default_channel = "general";
+
}
+
+
let handle_command bot_state command args =
+
match command with
+
| "add" ->
+
(match args with
+
| name :: url :: topic ->
+
let topic_str = String.concat " " topic in
+
Hashtbl.replace bot_state.feeds name (url, topic_str);
+
Printf.sprintf "Added feed '%s' -> %s (topic: %s)" name url topic_str
+
| _ -> "Usage: !feed add <name> <url> <topic>")
+
+
| "remove" ->
+
(match args with
+
| name :: _ ->
+
if Hashtbl.mem bot_state.feeds name then (
+
Hashtbl.remove bot_state.feeds name;
+
Printf.sprintf "Removed feed '%s'" name
+
) else
+
Printf.sprintf "Feed '%s' not found" name
+
| _ -> "Usage: !feed remove <name>")
+
+
| "list" ->
+
if Hashtbl.length bot_state.feeds = 0 then
+
"No feeds configured"
+
else
+
let lines = Hashtbl.fold (fun name (url, topic) acc ->
+
(Printf.sprintf "• **%s**: %s → topic: %s" name url topic) :: acc
+
) bot_state.feeds [] in
+
String.concat "\n" lines
+
+
| "fetch" ->
+
(match args with
+
| name :: _ ->
+
(match Hashtbl.find_opt bot_state.feeds name with
+
| Some (url, topic) ->
+
Printf.sprintf "Fetching feed '%s' from %s..." name url
+
| None ->
+
Printf.sprintf "Feed '%s' not found" name)
+
| _ -> "Usage: !feed fetch <name>")
+
+
| "channel" ->
+
(match args with
+
| channel :: _ ->
+
bot_state.default_channel <- channel;
+
Printf.sprintf "Default channel set to: %s" channel
+
| _ -> Printf.sprintf "Current default channel: %s" bot_state.default_channel)
+
+
| "help" | _ ->
+
String.concat "\n" [
+
"**Atom Feed Bot Commands:**";
+
"• `!feed add <name> <url> <topic>` - Add a new feed";
+
"• `!feed remove <name>` - Remove a feed";
+
"• `!feed list` - List all configured feeds";
+
"• `!feed fetch <name>` - Manually fetch a feed";
+
"• `!feed channel <name>` - Set default channel";
+
"• `!feed help` - Show this help message";
+
]
+
+
let create_handler bot_state =
+
let module Handler : Bot_handler.S = struct
+
let initialize _ = Ok ()
+
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
+
+
(* 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
+
+
(* Main entry point *)
+
let () =
+
if Array.length Sys.argv > 1 && Sys.argv.(1) = "interactive" then (
+
(* Run as interactive bot *)
+
Printf.printf "Starting interactive Atom feed bot...\n";
+
Eio_main.run @@ fun env ->
+
+
let bot_state = Interactive_feed_bot.create () in
+
let handler = Interactive_feed_bot.create_handler bot_state in
+
+
(* Load auth and create bot runner *)
+
let auth = match Zulip.Auth.from_zuliprc () with
+
| Ok a -> a
+
| Error e ->
+
Printf.eprintf "Failed to load auth: %s\n" (Zulip.Error.message e);
+
exit 1
+
in
+
+
Eio.Switch.run @@ fun sw ->
+
let client = Zulip.Client.create ~sw env auth in
+
+
(* Create and run bot *)
+
let config = Zulip_bot.Bot_config.create [] in
+
let bot_email = Zulip.Auth.email auth in
+
let storage = Zulip_bot.Bot_storage.create client ~bot_email in
+
let identity = Zulip_bot.Bot_handler.Identity.create
+
~full_name:"Atom Feed Bot"
+
~email:bot_email
+
~mention_name:"feedbot"
+
in
+
+
let bot = Zulip_bot.Bot_handler.create handler ~config ~storage ~identity in
+
let runner = Zulip_bot.Bot_runner.create ~env ~client ~handler:bot in
+
Zulip_bot.Bot_runner.run_realtime runner
+
) else (
+
(* Run as scheduled fetcher *)
+
Printf.printf "Starting scheduled Atom feed fetcher...\n";
+
Eio_main.run @@ fun env ->
+
+
let config = Feed_bot.{
+
feeds = [
+
("https://example.com/feed.xml", "general", "News");
+
("https://blog.example.com/atom.xml", "general", "Blog Posts");
+
];
+
refresh_interval = 300.0; (* 5 minutes *)
+
state_file = "feed_bot_state.txt";
+
} in
+
+
Feed_bot.run_bot env config
+
)
+20
stack/zulip/examples/bot_config.toml
···
+
[weather_bot]
+
name = "Weather Bot"
+
default_api_key = "openweather-api-key-12345"
+
log_level = "info"
+
cache_duration_minutes = 30
+
log_file = "/tmp/weather_bot.log"
+
config_dir = "/tmp/bot_config"
+
+
[logger_bot]
+
name = "Logger Bot"
+
log_file = "/tmp/user_messages.log"
+
max_log_size_mb = 10
+
archive_logs = true
+
log_format = "json"
+
+
[general]
+
server_url = "https://company.zulipchat.com"
+
webhook_port = 8080
+
max_message_length = 2000
+
rate_limit_per_minute = 60
+44
stack/zulip/examples/bot_example.ml
···
+
(* Simple Bot Example using core Zulip library *)
+
+
let () =
+
Printf.printf "OCaml Zulip Bot Example\n";
+
Printf.printf "=======================\n\n";
+
+
(* Create test authentication *)
+
let auth = Zulip.Auth.create
+
~server_url:"https://example.zulipchat.com"
+
~email:"bot@example.com"
+
~api_key:"example-api-key" in
+
+
Printf.printf "✅ Created authentication for: %s\n" (Zulip.Auth.email auth);
+
Printf.printf "✅ Server URL: %s\n" (Zulip.Auth.server_url auth);
+
+
(* Create client *)
+
let client = Zulip.Client.create () auth in
+
let client_str = Format.asprintf "%a" Zulip.Client.pp client in
+
Printf.printf "✅ Created client: %s\n" client_str;
+
+
(* Test message creation *)
+
let message = Zulip.Message.create
+
~type_:`Channel
+
~to_:["general"]
+
~content:"Hello from OCaml bot!"
+
~topic:"Bot Testing"
+
() in
+
+
Printf.printf "✅ Created message to: %s\n" (String.concat ", " (Zulip.Message.to_ message));
+
Printf.printf "✅ Message content: %s\n" (Zulip.Message.content message);
+
Printf.printf "✅ Message topic: %s\n" (match Zulip.Message.topic message with Some t -> t | None -> "none");
+
+
(* Test API call (mock) *)
+
(match Zulip.Client.request client ~method_:`GET ~path:"/users/me" () with
+
| Ok response ->
+
Printf.printf "✅ API request successful: %s\n"
+
(match response with
+
| `O fields -> String.concat ", " (List.map fst fields)
+
| _ -> "unknown format")
+
| Error err ->
+
Printf.printf "❌ API request failed: %s\n" (Zulip.Error.message err));
+
+
Printf.printf "\n🎉 Bot example completed successfully!\n";
+
Printf.printf "Note: This uses mock responses since we're not connected to a real Zulip server.\n"
+1
stack/zulip/examples/bot_example.mli
···
+
(** Example Zulip bot demonstrating the bot framework *)
+17
stack/zulip/examples/dune
···
+
(executable
+
(public_name test_client)
+
(name test_client)
+
(package zulip)
+
(libraries zulip eio_main))
+
+
(executable
+
(public_name echo_bot)
+
(name echo_bot)
+
(package zulip_bot)
+
(libraries zulip zulip_bot eio_main))
+
+
(executable
+
(public_name atom_feed_bot)
+
(name atom_feed_bot)
+
(package zulip_bot)
+
(libraries zulip zulip_bot eio_main))
+142
stack/zulip/examples/echo_bot.ml
···
+
(* Simple Echo Bot for Zulip
+
Responds to direct messages and mentions by echoing back the message *)
+
+
open Zulip_bot
+
+
module Echo_bot_handler : Bot_handler.S = struct
+
let initialize _ = Ok ()
+
+
let usage () =
+
"Echo Bot - I repeat everything you say to me!"
+
+
let description () =
+
"A simple echo bot that repeats messages sent to it. \
+
Send me a direct message or mention me in a channel."
+
+
let handle_message ~config:_ ~storage:_ ~identity ~message ~env:_ =
+
(* Parse the incoming message *)
+
let extract_field fields name =
+
match List.assoc_opt name fields with
+
| Some (`String s) -> Some s
+
| _ -> None
+
in
+
+
let extract_int_field fields name =
+
match List.assoc_opt name fields with
+
| Some (`Float f) -> Some (int_of_float f)
+
| _ -> None
+
in
+
+
match message with
+
| `O 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
+
+
(* Check if this is our own message to avoid loops *)
+
let is_own_message = match sender_email with
+
| Some email -> email = (Bot_handler.Identity.email identity)
+
| None -> false
+
in
+
+
if is_own_message then
+
Ok Bot_handler.Response.None
+
else
+
(* Process the message content *)
+
let response_content = match content, sender_full_name with
+
| Some msg, Some name ->
+
(* Remove bot mention if present *)
+
let cleaned_msg =
+
let mention = "@**" ^ Bot_handler.Identity.mention_name identity ^ "**" in
+
let msg =
+
if String.starts_with ~prefix:mention msg then
+
String.sub msg (String.length mention) (String.length msg - String.length mention)
+
else msg
+
in
+
String.trim msg
+
in
+
+
(* Create echo response *)
+
if cleaned_msg = "" then
+
Printf.sprintf "Hello %s! Send me a message and I'll echo it back!" name
+
else if String.lowercase_ascii cleaned_msg = "help" then
+
Printf.sprintf "Hi %s! I'm an echo bot. Whatever you say to me, I'll repeat back. \
+
Try sending me any message!" name
+
else
+
Printf.sprintf "Echo from %s: %s" name cleaned_msg
+
| Some msg, None ->
+
Printf.sprintf "Echo: %s" msg
+
| _ ->
+
"I couldn't understand that message."
+
in
+
+
(* Determine response type based on original message type *)
+
let response = match message_type with
+
| Some "private" ->
+
(* For private messages, reply directly *)
+
Bot_handler.Response.Reply response_content
+
| Some "stream" ->
+
(* For stream messages, reply in the same topic *)
+
Bot_handler.Response.Reply response_content
+
| _ ->
+
Bot_handler.Response.None
+
in
+
+
Ok response
+
| _ ->
+
Ok Bot_handler.Response.None
+
end
+
+
let run_echo_bot env =
+
Printf.printf "Starting Zulip Echo Bot...\n";
+
Printf.printf "=============================\n\n";
+
+
(* Load authentication from .zuliprc file *)
+
let auth = match Zulip.Auth.from_zuliprc () with
+
| Ok a ->
+
Printf.printf "Loaded authentication for: %s\n" (Zulip.Auth.email a);
+
Printf.printf "Server: %s\n\n" (Zulip.Auth.server_url a);
+
a
+
| Error e ->
+
Printf.eprintf "Failed to load .zuliprc: %s\n" (Zulip.Error.message e);
+
Printf.eprintf "\nPlease create a ~/.zuliprc file with:\n";
+
Printf.eprintf "[api]\n";
+
Printf.eprintf "email=bot@example.com\n";
+
Printf.eprintf "key=your-api-key\n";
+
Printf.eprintf "site=https://your-domain.zulipchat.com\n";
+
exit 1
+
in
+
+
Eio.Switch.run @@ fun sw ->
+
let client = Zulip.Client.create ~sw env auth in
+
+
(* Create bot configuration *)
+
let config = Bot_config.create [] in
+
let bot_email = Zulip.Auth.email auth in
+
let storage = Bot_storage.create client ~bot_email in
+
let identity = Bot_handler.Identity.create
+
~full_name:"Echo Bot"
+
~email:bot_email
+
~mention_name:"echobot"
+
in
+
+
(* Create and run the bot *)
+
let handler = Bot_handler.create
+
(module Echo_bot_handler)
+
~config ~storage ~identity
+
in
+
+
let runner = Bot_runner.create ~env ~client ~handler in
+
+
Printf.printf "Echo bot is running!\n";
+
Printf.printf "Send a direct message or mention @echobot in a channel.\n";
+
Printf.printf "Press Ctrl+C to stop.\n\n";
+
+
(* Run in real-time mode *)
+
Bot_runner.run_realtime runner
+
+
let () =
+
Eio_main.run run_echo_bot
+47
stack/zulip/examples/example.ml
···
+
open Zulip
+
+
let () =
+
Printf.printf "OCaml Zulip Library Example\n";
+
Printf.printf "===========================\n\n";
+
+
(* Create authentication *)
+
let auth = Auth.create
+
~server_url:"https://example.zulipchat.com"
+
~email:"bot@example.com"
+
~api_key:"your-api-key" in
+
+
Printf.printf "Created auth for: %s\n" (Auth.email auth);
+
Printf.printf "Server URL: %s\n" (Auth.server_url auth);
+
+
(* Create a message *)
+
let message = Message.create
+
~type_:`Channel
+
~to_:["general"]
+
~content:"Hello from OCaml Zulip library!"
+
~topic:"Test"
+
() in
+
+
Printf.printf "\nCreated message:\n";
+
Printf.printf "- Type: %s\n" (Message_type.to_string (Message.type_ message));
+
Printf.printf "- To: %s\n" (String.concat ", " (Message.to_ message));
+
Printf.printf "- Content: %s\n" (Message.content message);
+
Printf.printf "- Topic: %s\n"
+
(match Message.topic message with Some t -> t | None -> "None");
+
+
(* Test JSON serialization *)
+
let json = Message.to_json message in
+
Printf.printf "\nMessage JSON: %s\n"
+
(match json with
+
| `O _ -> "JSON object (serialized correctly)"
+
| _ -> "Invalid JSON");
+
+
(* Create client (mock) *)
+
let client = Client.create () auth in
+
Printf.printf "\nCreated mock client\n";
+
+
(* Test basic client request *)
+
(match Client.request client ~method_:`GET ~path:"/test" () with
+
| Ok _ -> Printf.printf "Mock request succeeded\n"
+
| Error err -> Printf.printf "Mock request failed: %s\n" (Error.message err));
+
+
Printf.printf "\nLibrary is working correctly!\n"
+1
stack/zulip/examples/example.mli
···
+
(** Basic Zulip library usage example *)
+16
stack/zulip/examples/example_bot_config.toml
···
+
# Bot configuration file
+
name = "Weather Bot"
+
description = "A bot that provides weather information"
+
+
[bot]
+
# Bot-specific settings
+
api_key = "your-weather-api-key"
+
default_location = "San Francisco"
+
units = "metric"
+
max_forecasts = 5
+
+
[features]
+
# Feature flags
+
cache_enabled = true
+
verbose_logging = false
+
rate_limit = 60
+9
stack/zulip/examples/example_zuliprc.toml
···
+
# Zulip configuration file in TOML format
+
[api]
+
email = "bot@example.com"
+
key = "your-api-key-here"
+
site = "https://example.zulipchat.com"
+
+
# Optional settings
+
insecure = false
+
cert_bundle = "/path/to/cert/bundle.crt"
+75
stack/zulip/examples/test_client.ml
···
+
(* Simple test for Zulip client with requests library *)
+
+
let test_auth () =
+
Printf.printf "Testing Zulip authentication...\n";
+
match Zulip.Auth.from_zuliprc ~path:"~/.zuliprc" () with
+
| Ok auth ->
+
Printf.printf "Successfully loaded auth:\n";
+
Printf.printf " Server: %s\n" (Zulip.Auth.server_url auth);
+
Printf.printf " Email: %s\n" (Zulip.Auth.email auth);
+
auth
+
| Error e ->
+
Printf.eprintf "Failed to load auth: %s\n" (Zulip.Error.message e);
+
(* Create a test auth *)
+
Zulip.Auth.create
+
~server_url:"https://example.zulipchat.com"
+
~email:"bot@example.com"
+
~api_key:"test_api_key"
+
+
let test_message_send env auth =
+
Printf.printf "\nTesting message send...\n";
+
+
Eio.Switch.run @@ fun sw ->
+
let client = Zulip.Client.create ~sw env auth in
+
+
(* Create a test message *)
+
let message = Zulip.Message.create
+
~type_:`Channel
+
~to_:["general"]
+
~topic:"Test Topic"
+
~content:"Hello from OCaml Zulip client using requests library!"
+
()
+
in
+
+
match Zulip.Messages.send client message with
+
| Ok response ->
+
Printf.printf "Message sent successfully!\n";
+
Printf.printf "Message ID: %d\n" (Zulip.Message_response.id response)
+
| Error e ->
+
Printf.eprintf "Failed to send message: %s\n" (Zulip.Error.message e)
+
+
let test_fetch_messages env auth =
+
Printf.printf "\nTesting message fetch...\n";
+
+
Eio.Switch.run @@ fun sw ->
+
let client = Zulip.Client.create ~sw env auth in
+
+
match Zulip.Messages.get_messages client ~num_before:5 ~num_after:0 () with
+
| Ok json ->
+
Printf.printf "Fetched messages successfully!\n";
+
(match json with
+
| `O fields ->
+
(match List.assoc_opt "messages" fields with
+
| Some (`A messages) ->
+
Printf.printf "Got %d messages\n" (List.length messages)
+
| _ -> Printf.printf "No messages field found\n")
+
| _ -> Printf.printf "Unexpected JSON format\n")
+
| Error e ->
+
Printf.eprintf "Failed to fetch messages: %s\n" (Zulip.Error.message e)
+
+
let () =
+
Printf.printf "Zulip OCaml Client Test\n";
+
Printf.printf "========================\n\n";
+
+
Eio_main.run @@ fun env ->
+
+
(* Test authentication *)
+
let auth = test_auth () in
+
+
(* Test sending a message *)
+
test_message_send env auth;
+
+
(* Test fetching messages *)
+
test_fetch_messages env auth;
+
+
Printf.printf "\nAll tests completed!\n"
+100
stack/zulip/examples/toml_example.ml
···
+
open Zulip
+
+
let () =
+
Printf.printf "OCaml Zulip TOML Support Demo\n";
+
Printf.printf "=============================\n\n";
+
+
(* Example 1: Create a sample zuliprc TOML file *)
+
let zuliprc_content = {|
+
# Zulip API Configuration
+
[api]
+
email = "demo@example.com"
+
key = "demo-api-key-12345"
+
site = "https://demo.zulipchat.com"
+
+
# Optional settings
+
insecure = false
+
cert_bundle = "/etc/ssl/certs/ca-certificates.crt"
+
|} in
+
+
let zuliprc_file = "demo_zuliprc.toml" in
+
let oc = open_out zuliprc_file in
+
output_string oc zuliprc_content;
+
close_out oc;
+
+
Printf.printf "Created sample zuliprc.toml file:\n%s\n" zuliprc_content;
+
+
(* Test loading auth from TOML *)
+
(match Auth.from_zuliprc ~path:zuliprc_file () with
+
| Ok auth ->
+
Printf.printf "✅ Successfully loaded authentication from TOML:\n";
+
Printf.printf " Email: %s\n" (Auth.email auth);
+
Printf.printf " Server: %s\n" (Auth.server_url auth);
+
Printf.printf " Auth Header: %s\n" (Auth.to_basic_auth_header auth);
+
+
(* Test creating client *)
+
let client = Client.create () auth in
+
Printf.printf "✅ Created client successfully\n\n";
+
+
(* Test basic functionality *)
+
(match Client.request client ~method_:`GET ~path:"/users/me" () with
+
| Ok _response -> Printf.printf "✅ Mock API request succeeded\n"
+
| Error err -> Printf.printf "❌ API request failed: %s\n" (Error.message err))
+
| Error err ->
+
Printf.printf "❌ Failed to load auth from TOML: %s\n" (Error.message err));
+
+
(* Example 2: Root-level TOML configuration *)
+
let root_toml_content = {|
+
email = "root-user@example.com"
+
key = "root-api-key-67890"
+
site = "https://root.zulipchat.com"
+
|} in
+
+
let root_file = "demo_root.toml" in
+
let oc = open_out root_file in
+
output_string oc root_toml_content;
+
close_out oc;
+
+
Printf.printf "\nTesting root-level TOML configuration:\n";
+
(match Auth.from_zuliprc ~path:root_file () with
+
| Ok auth ->
+
Printf.printf "✅ Root-level TOML parsed successfully:\n";
+
Printf.printf " Email: %s\n" (Auth.email auth);
+
Printf.printf " Server: %s\n" (Auth.server_url auth)
+
| Error err ->
+
Printf.printf "❌ Failed to parse root-level TOML: %s\n" (Error.message err));
+
+
(* Example 3: Test error handling with invalid TOML *)
+
let invalid_toml = {|
+
[api
+
email = "invalid@example.com" # Missing closing bracket
+
|} in
+
+
let invalid_file = "demo_invalid.toml" in
+
let oc = open_out invalid_file in
+
output_string oc invalid_toml;
+
close_out oc;
+
+
Printf.printf "\nTesting error handling with invalid TOML:\n";
+
(match Auth.from_zuliprc ~path:invalid_file () with
+
| Ok _ -> Printf.printf "❌ Should have failed with invalid TOML\n"
+
| Error err -> Printf.printf "✅ Correctly handled invalid TOML: %s\n" (Error.message err));
+
+
(* Example 4: Test missing file handling *)
+
Printf.printf "\nTesting missing file handling:\n";
+
(match Auth.from_zuliprc ~path:"nonexistent.toml" () with
+
| Ok _ -> Printf.printf "❌ Should have failed with missing file\n"
+
| Error err -> Printf.printf "✅ Correctly handled missing file: %s\n" (Error.message err));
+
+
(* Clean up *)
+
List.iter (fun file ->
+
if Sys.file_exists file then Sys.remove file
+
) [zuliprc_file; root_file; invalid_file];
+
+
Printf.printf "\n🎉 TOML support demonstration complete!\n";
+
Printf.printf "\nFeatures demonstrated:\n";
+
Printf.printf "• Parse TOML files with [api] section\n";
+
Printf.printf "• Parse TOML files with root-level configuration\n";
+
Printf.printf "• Proper error handling for invalid TOML syntax\n";
+
Printf.printf "• Proper error handling for missing files\n";
+
Printf.printf "• Integration with existing Zulip client\n"
+1
stack/zulip/examples/toml_example.mli
···
+
(** TOML support demonstration for Zulip configuration files *)
+1
stack/zulip/lib/dune
···
+
(dirs zulip zulip_bot zulip_botserver)
+83
stack/zulip/lib/zulip/lib/auth.ml
···
+
type t = {
+
server_url : string;
+
email : string;
+
api_key : string;
+
}
+
+
let create ~server_url ~email ~api_key = { server_url; email; api_key }
+
+
let from_zuliprc ?(path = "~/.zuliprc") () =
+
try
+
(* Expand ~ to home directory *)
+
let expanded_path =
+
if String.length path > 0 && path.[0] = '~' then
+
let home = try Sys.getenv "HOME" with Not_found -> "" in
+
home ^ String.sub path 1 (String.length path - 1)
+
else path in
+
+
(* Read file content *)
+
let content =
+
let ic = open_in expanded_path in
+
let content = really_input_string ic (in_channel_length ic) in
+
close_in ic;
+
content in
+
+
(* Simple INI-style parser for zuliprc *)
+
let lines = String.split_on_char '\n' content in
+
let config = Hashtbl.create 10 in
+
let current_section = ref "" in
+
+
List.iter (fun line ->
+
let line = String.trim line in
+
if String.length line > 0 && line.[0] <> '#' then
+
if String.length line > 2 && line.[0] = '[' && line.[String.length line - 1] = ']' then
+
(* Section header *)
+
current_section := String.sub line 1 (String.length line - 2)
+
else
+
(* Key-value pair *)
+
match String.index_opt line '=' with
+
| Some idx ->
+
let key = String.trim (String.sub line 0 idx) in
+
let value = String.trim (String.sub line (idx + 1) (String.length line - idx - 1)) in
+
let full_key =
+
if !current_section = "" || !current_section = "api" then key
+
else !current_section ^ "." ^ key
+
in
+
Hashtbl.add config full_key value
+
| None -> ()
+
) lines;
+
+
(* Extract required fields *)
+
let get_value key =
+
try Some (Hashtbl.find config key)
+
with Not_found -> None
+
in
+
+
match get_value "email", get_value "key", get_value "site" with
+
| Some email, Some api_key, Some server_url ->
+
(* Ensure server_url has proper protocol *)
+
let server_url =
+
if String.starts_with ~prefix:"http://" server_url ||
+
String.starts_with ~prefix:"https://" server_url then
+
server_url
+
else
+
"https://" ^ server_url
+
in
+
Ok { server_url; email; api_key }
+
| _ ->
+
Error (Error.create ~code:(Other "config_missing")
+
~msg:"Missing required fields: email, key, site in zuliprc" ())
+
with
+
| Sys_error msg ->
+
Error (Error.create ~code:(Other "file_error") ~msg:("Cannot read zuliprc file: " ^ msg) ())
+
| exn ->
+
Error (Error.create ~code:(Other "parse_error") ~msg:("Error parsing zuliprc: " ^ Printexc.to_string exn) ())
+
+
let server_url t = t.server_url
+
let email t = t.email
+
let to_basic_auth_header t =
+
match Base64.encode (t.email ^ ":" ^ t.api_key) with
+
| Ok encoded -> "Basic " ^ encoded
+
| Error (`Msg msg) -> failwith ("Base64 encoding failed: " ^ msg)
+
+
let pp fmt t = Format.fprintf fmt "Auth{server=%s, email=%s}" t.server_url t.email
+8
stack/zulip/lib/zulip/lib/auth.mli
···
+
type t
+
+
val create : server_url:string -> email:string -> api_key:string -> t
+
val from_zuliprc : ?path:string -> unit -> (t, Error.t) result
+
val server_url : t -> string
+
val email : t -> string
+
val to_basic_auth_header : t -> string
+
val pp : Format.formatter -> t -> unit
+50
stack/zulip/lib/zulip/lib/channel.ml
···
+
type t = {
+
name : string;
+
description : string;
+
invite_only : bool;
+
history_public_to_subscribers : bool;
+
}
+
+
let create ~name ~description ?(invite_only = false) ?(history_public_to_subscribers = true) () =
+
{ name; description; invite_only; history_public_to_subscribers }
+
+
let name t = t.name
+
let description t = t.description
+
let invite_only t = t.invite_only
+
let history_public_to_subscribers t = t.history_public_to_subscribers
+
+
let to_json t =
+
`O [
+
("name", `String t.name);
+
("description", `String t.description);
+
("invite_only", `Bool t.invite_only);
+
("history_public_to_subscribers", `Bool t.history_public_to_subscribers);
+
]
+
+
let of_json json =
+
try
+
match json with
+
| `O fields ->
+
let get_string key =
+
match List.assoc key fields with
+
| `String s -> s
+
| _ -> failwith ("Expected string for " ^ key) in
+
let get_bool key default =
+
match List.assoc_opt key fields with
+
| Some (`Bool b) -> b
+
| None -> default
+
| _ -> failwith ("Expected bool for " ^ key) in
+
+
let name = get_string "name" in
+
let description = get_string "description" in
+
let invite_only = get_bool "invite_only" false in
+
let history_public_to_subscribers = get_bool "history_public_to_subscribers" true in
+
+
Ok { name; description; invite_only; history_public_to_subscribers }
+
| _ ->
+
Error (Error.create ~code:(Other "json_parse_error") ~msg:"Channel JSON must be an object" ())
+
with
+
| exn ->
+
Error (Error.create ~code:(Other "json_parse_error") ~msg:("Channel JSON parsing failed: " ^ Printexc.to_string exn) ())
+
+
let pp fmt t = Format.fprintf fmt "Channel{name=%s, description=%s}" t.name t.description
+16
stack/zulip/lib/zulip/lib/channel.mli
···
+
type t
+
+
val create :
+
name:string ->
+
description:string ->
+
?invite_only:bool ->
+
?history_public_to_subscribers:bool ->
+
unit -> t
+
+
val name : t -> string
+
val description : t -> string
+
val invite_only : t -> bool
+
val history_public_to_subscribers : t -> bool
+
val to_json : t -> Error.json
+
val of_json : Error.json -> (t, Error.t) result
+
val pp : Format.formatter -> t -> unit
+58
stack/zulip/lib/zulip/lib/channels.ml
···
+
let create_channel client channel =
+
let body = match Channel.to_json channel with
+
| `O fields ->
+
String.concat "&" (List.map (fun (k, v) ->
+
match v with
+
| `String s -> k ^ "=" ^ Uri.pct_encode s
+
| `Bool b -> k ^ "=" ^ string_of_bool b
+
| _ -> ""
+
) fields)
+
| _ -> "" in
+
match Client.request client ~method_:`POST ~path:"/streams" ~body () with
+
| Ok _json -> Ok ()
+
| Error err -> Error err
+
+
let delete client ~name =
+
let encoded_name = Uri.pct_encode name in
+
match Client.request client ~method_:`DELETE ~path:("/streams/" ^ encoded_name) () with
+
| Ok _json -> Ok ()
+
| Error err -> Error err
+
+
let list client =
+
match Client.request client ~method_:`GET ~path:"/streams" () with
+
| Ok json ->
+
(match json with
+
| `O fields ->
+
(match List.assoc_opt "streams" fields with
+
| Some (`A channel_list) ->
+
let channels = List.fold_left (fun acc channel_json ->
+
match Channel.of_json channel_json with
+
| Ok channel -> channel :: acc
+
| Error _ -> acc
+
) [] channel_list in
+
Ok (List.rev channels)
+
| _ -> Error (Error.create ~code:(Other "api_error") ~msg:"Invalid streams response format" ()))
+
| _ -> Error (Error.create ~code:(Other "api_error") ~msg:"Streams response must be an object" ()))
+
| Error err -> Error err
+
+
let subscribe client ~channels =
+
let channels_json = `A (List.map (fun name -> `String name) channels) in
+
let body = "subscriptions=" ^ (match channels_json with
+
| `A items -> "[" ^ String.concat "," (List.map (function
+
| `String s -> "\"" ^ s ^ "\""
+
| _ -> "") items) ^ "]"
+
| _ -> "[]") in
+
match Client.request client ~method_:`POST ~path:"/users/me/subscriptions" ~body () with
+
| Ok _json -> Ok ()
+
| Error err -> Error err
+
+
let unsubscribe client ~channels =
+
let channels_json = `A (List.map (fun name -> `String name) channels) in
+
let body = "delete=" ^ (match channels_json with
+
| `A items -> "[" ^ String.concat "," (List.map (function
+
| `String s -> "\"" ^ s ^ "\""
+
| _ -> "") items) ^ "]"
+
| _ -> "[]") in
+
match Client.request client ~method_:`DELETE ~path:"/users/me/subscriptions" ~body () with
+
| Ok _json -> Ok ()
+
| Error err -> Error err
+5
stack/zulip/lib/zulip/lib/channels.mli
···
+
val create_channel : Client.t -> Channel.t -> (unit, Error.t) result
+
val delete : Client.t -> name:string -> (unit, Error.t) result
+
val list : Client.t -> (Channel.t list, Error.t) result
+
val subscribe : Client.t -> channels:string list -> (unit, Error.t) result
+
val unsubscribe : Client.t -> channels:string list -> (unit, Error.t) result
+137
stack/zulip/lib/zulip/lib/client.ml
···
+
type t = {
+
auth : Auth.t;
+
session : (float Eio.Time.clock_ty Eio.Resource.t,
+
[`Generic | `Unix] Eio.Net.ty Eio.Resource.t) Requests.t;
+
}
+
+
let create ~sw env auth =
+
let session = Requests.create ~sw
+
~default_headers:(Requests.Headers.of_list [
+
("Authorization", Auth.to_basic_auth_header auth);
+
("User-Agent", "OCaml-Zulip/1.0");
+
])
+
~follow_redirects:true
+
~verify_tls:true
+
env
+
in
+
{ auth; session }
+
+
let with_client env auth f =
+
Eio.Switch.run @@ fun sw ->
+
let client = create ~sw env auth in
+
f client
+
+
let request t ~method_ ~path ?params ?body () =
+
let url = Auth.server_url t.auth ^ path in
+
+
(* Convert params to URL query string if provided *)
+
let url = match params with
+
| Some p ->
+
let uri = Uri.of_string url in
+
let uri = List.fold_left (fun u (k, v) -> Uri.add_query_param' u (k, v)) uri p in
+
Uri.to_string uri
+
| None -> url
+
in
+
+
(* Prepare request body if provided *)
+
let body_opt = match body with
+
| Some json_str -> Some (Requests.Body.of_string Requests.Mime.json json_str)
+
| None -> None
+
in
+
+
(* Make the request *)
+
let response =
+
match method_ with
+
| `GET -> Requests.get t.session url
+
| `POST -> Requests.post t.session ?body:body_opt url
+
| `PUT -> Requests.put t.session ?body:body_opt url
+
| `DELETE -> Requests.delete t.session url
+
| `PATCH -> Requests.patch t.session ?body:body_opt url
+
in
+
+
(* Parse response *)
+
let status = Requests.Response.status_code response in
+
let body_str =
+
let body_flow = Requests.Response.body response in
+
let buf = Buffer.create 4096 in
+
Eio.Flow.copy body_flow (Eio.Flow.buffer_sink buf);
+
Buffer.contents buf
+
in
+
+
(* Parse JSON response *)
+
let decoder = Jsonm.decoder (`String body_str) in
+
let rec parse_json decoder =
+
match Jsonm.decode decoder with
+
| `Lexeme l ->
+
(match l with
+
| `Oe -> `O []
+
| `Os -> parse_object decoder []
+
| `As -> parse_array decoder []
+
| `Ae -> `A []
+
| `Float f -> `Float f
+
| `String s -> `String s
+
| `Bool b -> `Bool b
+
| `Null -> `Null
+
| `Name _ -> parse_json decoder)
+
| `Error e -> failwith ("JSON parse error: " ^ (Format.asprintf "%a" Jsonm.pp_error e))
+
| `Await | `End -> `Null
+
and parse_object decoder acc =
+
match Jsonm.decode decoder with
+
| `Lexeme (`Name key) ->
+
let value = parse_json decoder in
+
parse_object decoder ((key, value) :: acc)
+
| `Lexeme `Oe -> `O (List.rev acc)
+
| _ -> `O (List.rev acc)
+
and parse_array decoder acc =
+
match Jsonm.decode decoder with
+
| `Lexeme `Ae -> `A (List.rev acc)
+
| `Lexeme l ->
+
(match l with
+
| `Name _ -> parse_array decoder acc
+
| _ ->
+
let reparse l =
+
match l with
+
| `Float f -> `Float f
+
| `String s -> `String s
+
| `Bool b -> `Bool b
+
| `Null -> `Null
+
| `Os -> parse_object decoder []
+
| `As -> parse_array decoder []
+
| _ -> `Null
+
in
+
let value = reparse l in
+
parse_array decoder (value :: acc))
+
| _ -> `A (List.rev acc)
+
in
+
+
let json = parse_json decoder in
+
+
(* Check for Zulip error response *)
+
match json with
+
| `O fields ->
+
(match List.assoc_opt "result" fields with
+
| Some (`String "error") ->
+
let msg = match List.assoc_opt "msg" fields with
+
| Some (`String s) -> s
+
| _ -> "Unknown error"
+
in
+
let code = match List.assoc_opt "code" fields with
+
| Some (`String s) -> Error.code_of_string s
+
| _ -> Error.Other "unknown"
+
in
+
Error (Error.create ~code ~msg ())
+
| _ ->
+
if status >= 200 && status < 300 then
+
Ok json
+
else
+
Error (Error.create ~code:(Error.Other (string_of_int status))
+
~msg:("HTTP error: " ^ string_of_int status) ()))
+
| _ ->
+
if status >= 200 && status < 300 then
+
Ok json
+
else
+
Error (Error.create ~code:(Error.Other "json_parse")
+
~msg:"Invalid JSON response" ())
+
+
let pp fmt t =
+
Format.fprintf fmt "Client(server=%s)" (Auth.server_url t.auth)
+31
stack/zulip/lib/zulip/lib/client.mli
···
+
(** HTTP client for making requests to the Zulip API using the requests library *)
+
+
type t
+
(** Type representing a Zulip HTTP client *)
+
+
val create : sw:Eio.Switch.t ->
+
< clock: float Eio.Time.clock_ty Eio.Resource.t;
+
net: [`Generic | `Unix] Eio.Net.ty Eio.Resource.t;
+
fs: Eio.Fs.dir_ty Eio.Path.t; .. > -> Auth.t -> t
+
(** Create a new client with the given switch, environment and authentication.
+
The environment must have clock, net, and fs capabilities. *)
+
+
val with_client :
+
< clock: float Eio.Time.clock_ty Eio.Resource.t;
+
net: [`Generic | `Unix] Eio.Net.ty Eio.Resource.t;
+
fs: Eio.Fs.dir_ty Eio.Path.t; .. > -> Auth.t -> (t -> 'a) -> 'a
+
(** Resource-safe client management using structured concurrency.
+
The environment must have clock, net, and fs capabilities. *)
+
+
val request :
+
t ->
+
method_:[`GET | `POST | `PUT | `DELETE | `PATCH] ->
+
path:string ->
+
?params:(string * string) list ->
+
?body:string ->
+
unit ->
+
(Error.json, Error.t) result
+
(** Make an HTTP request to the Zulip API using the requests library *)
+
+
val pp : Format.formatter -> t -> unit
+
(** Pretty printer for client (shows server URL only, not credentials) *)
+4
stack/zulip/lib/zulip/lib/dune
···
+
(library
+
(public_name zulip)
+
(name zulip)
+
(libraries eio requests jsonm uri base64))
+62
stack/zulip/lib/zulip/lib/error.ml
···
+
type code =
+
| Invalid_api_key
+
| Request_variable_missing
+
| Bad_request
+
| User_deactivated
+
| Realm_deactivated
+
| Rate_limit_hit
+
| Other of string
+
+
type json = [`Null | `Bool of bool | `Float of float | `String of string | `A of json list | `O of (string * json) list]
+
+
type t = {
+
code : code;
+
message : string;
+
extra : (string * json) list;
+
}
+
+
let code_of_string s = match s with
+
| "INVALID_API_KEY" -> Invalid_api_key
+
| "REQUEST_VARIABLE_MISSING" -> Request_variable_missing
+
| "BAD_REQUEST" -> Bad_request
+
| "USER_DEACTIVATED" -> User_deactivated
+
| "REALM_DEACTIVATED" -> Realm_deactivated
+
| "RATE_LIMIT_HIT" -> Rate_limit_hit
+
| s -> Other s
+
+
let create ~code ~msg ?(extra = []) () = { code; message = msg; extra }
+
let code t = t.code
+
let message t = t.message
+
let extra t = t.extra
+
let pp fmt t = Format.fprintf fmt "Error(%s): %s"
+
(match t.code with
+
| Invalid_api_key -> "INVALID_API_KEY"
+
| Request_variable_missing -> "REQUEST_VARIABLE_MISSING"
+
| Bad_request -> "BAD_REQUEST"
+
| User_deactivated -> "USER_DEACTIVATED"
+
| Realm_deactivated -> "REALM_DEACTIVATED"
+
| Rate_limit_hit -> "RATE_LIMIT_HIT"
+
| Other s -> s) t.message
+
+
let of_json json =
+
match json with
+
| `O fields ->
+
(try
+
let code_str = match List.assoc "code" fields with
+
| `String s -> s
+
| _ -> "OTHER" in
+
let msg = match List.assoc "msg" fields with
+
| `String s -> s
+
| _ -> "Unknown error" in
+
let code = match code_str with
+
| "INVALID_API_KEY" -> Invalid_api_key
+
| "REQUEST_VARIABLE_MISSING" -> Request_variable_missing
+
| "BAD_REQUEST" -> Bad_request
+
| "USER_DEACTIVATED" -> User_deactivated
+
| "REALM_DEACTIVATED" -> Realm_deactivated
+
| "RATE_LIMIT_HIT" -> Rate_limit_hit
+
| s -> Other s in
+
let extra = List.filter (fun (k, _) -> k <> "code" && k <> "msg" && k <> "result") fields in
+
Some (create ~code ~msg ~extra ())
+
with Not_found -> None)
+
| _ -> None
+20
stack/zulip/lib/zulip/lib/error.mli
···
+
type code =
+
| Invalid_api_key
+
| Request_variable_missing
+
| Bad_request
+
| User_deactivated
+
| Realm_deactivated
+
| Rate_limit_hit
+
| Other of string
+
+
type t
+
+
type json = [`Null | `Bool of bool | `Float of float | `String of string | `A of json list | `O of (string * json) list]
+
+
val code_of_string : string -> code
+
val create : code:code -> msg:string -> ?extra:(string * json) list -> unit -> t
+
val code : t -> code
+
val message : t -> string
+
val extra : t -> (string * json) list
+
val pp : Format.formatter -> t -> unit
+
val of_json : json -> t option
+40
stack/zulip/lib/zulip/lib/event.ml
···
+
type t = {
+
id : int;
+
type_ : Event_type.t;
+
data : Error.json;
+
}
+
+
let id t = t.id
+
let type_ t = t.type_
+
let data t = t.data
+
+
let of_json json =
+
try
+
match json with
+
| `O fields ->
+
let get_int key =
+
match List.assoc key fields with
+
| `Float f -> int_of_float f
+
| _ -> failwith ("Expected int for " ^ key) in
+
let get_string key =
+
match List.assoc key fields with
+
| `String s -> s
+
| _ -> failwith ("Expected string for " ^ key) in
+
let get_data key =
+
match List.assoc_opt key fields with
+
| Some data -> data
+
| None -> `Null in
+
+
let id = get_int "id" in
+
let type_str = get_string "type" in
+
let type_ = Event_type.of_string type_str in
+
let data = get_data "data" in
+
+
Ok { id; type_; data }
+
| _ ->
+
Error (Error.create ~code:(Other "json_parse_error") ~msg:"Event JSON must be an object" ())
+
with
+
| exn ->
+
Error (Error.create ~code:(Other "json_parse_error") ~msg:("Event JSON parsing failed: " ^ Printexc.to_string exn) ())
+
+
let pp fmt t = Format.fprintf fmt "Event{id=%d, type=%a}" t.id Event_type.pp t.type_
+7
stack/zulip/lib/zulip/lib/event.mli
···
+
type t
+
+
val id : t -> int
+
val type_ : t -> Event_type.t
+
val data : t -> Error.json
+
val of_json : Error.json -> (t, Error.t) result
+
val pp : Format.formatter -> t -> unit
+51
stack/zulip/lib/zulip/lib/event_queue.ml
···
+
type t = {
+
id : string;
+
}
+
+
let register client ?event_types () =
+
let params = match event_types with
+
| None -> []
+
| Some types ->
+
let types_str = String.concat "," (List.map Event_type.to_string types) in
+
[("event_types", "[\"" ^ types_str ^ "\"]")]
+
in
+
match Client.request client ~method_:`POST ~path:"/register" ~params () with
+
| Ok json ->
+
(match json with
+
| `O fields ->
+
(match List.assoc_opt "queue_id" fields with
+
| Some (`String queue_id) -> Ok { id = queue_id }
+
| _ -> Error (Error.create ~code:(Other "api_error") ~msg:"Invalid register response: missing queue_id" ()))
+
| _ -> Error (Error.create ~code:(Other "api_error") ~msg:"Register response must be an object" ()))
+
| Error err -> Error err
+
+
let id t = t.id
+
+
let get_events t client ?last_event_id () =
+
let params = [("queue_id", t.id)] @
+
(match last_event_id with
+
| None -> []
+
| Some event_id -> [("last_event_id", string_of_int event_id)]) in
+
match Client.request client ~method_:`GET ~path:"/events" ~params () with
+
| Ok json ->
+
(match json with
+
| `O fields ->
+
(match List.assoc_opt "events" fields with
+
| Some (`A event_list) ->
+
let events = List.fold_left (fun acc event_json ->
+
match Event.of_json event_json with
+
| Ok event -> event :: acc
+
| Error _ -> acc
+
) [] event_list in
+
Ok (List.rev events)
+
| _ -> Error (Error.create ~code:(Other "api_error") ~msg:"Invalid events response format" ()))
+
| _ -> Error (Error.create ~code:(Other "api_error") ~msg:"Events response must be an object" ()))
+
| Error err -> Error err
+
+
let delete t client =
+
let params = [("queue_id", t.id)] in
+
match Client.request client ~method_:`DELETE ~path:"/events" ~params () with
+
| Ok _json -> Ok ()
+
| Error err -> Error err
+
+
let pp fmt t = Format.fprintf fmt "EventQueue{id=%s}" t.id
+12
stack/zulip/lib/zulip/lib/event_queue.mli
···
+
type t
+
+
val register :
+
Client.t ->
+
?event_types:Event_type.t list ->
+
unit ->
+
(t, Error.t) result
+
+
val id : t -> string
+
val get_events : t -> Client.t -> ?last_event_id:int -> unit -> (Event.t list, Error.t) result
+
val delete : t -> Client.t -> (unit, Error.t) result
+
val pp : Format.formatter -> t -> unit
+19
stack/zulip/lib/zulip/lib/event_type.ml
···
+
type t =
+
| Message
+
| Subscription
+
| User_activity
+
| Other of string
+
+
let to_string = function
+
| Message -> "message"
+
| Subscription -> "subscription"
+
| User_activity -> "user_activity"
+
| Other s -> s
+
+
let of_string = function
+
| "message" -> Message
+
| "subscription" -> Subscription
+
| "user_activity" -> User_activity
+
| s -> Other s
+
+
let pp fmt t = Format.fprintf fmt "%s" (to_string t)
+9
stack/zulip/lib/zulip/lib/event_type.mli
···
+
type t =
+
| Message
+
| Subscription
+
| User_activity
+
| Other of string
+
+
val to_string : t -> string
+
val of_string : string -> t
+
val pp : Format.formatter -> t -> unit
+43
stack/zulip/lib/zulip/lib/message.ml
···
+
type t = {
+
type_ : Message_type.t;
+
to_ : string list;
+
content : string;
+
topic : string option;
+
queue_id : string option;
+
local_id : string option;
+
read_by_sender : bool;
+
}
+
+
let create ~type_ ~to_ ~content ?topic ?queue_id ?local_id ?(read_by_sender = true) () =
+
{ type_; to_; content; topic; queue_id; local_id; read_by_sender }
+
+
let type_ t = t.type_
+
let to_ t = t.to_
+
let content t = t.content
+
let topic t = t.topic
+
let queue_id t = t.queue_id
+
let local_id t = t.local_id
+
let read_by_sender t = t.read_by_sender
+
+
let to_json t =
+
let base_fields = [
+
("type", `String (Message_type.to_string t.type_));
+
("to", `A (List.map (fun s -> `String s) t.to_));
+
("content", `String t.content);
+
("read_by_sender", `Bool t.read_by_sender);
+
] in
+
let with_topic = match t.topic with
+
| Some topic -> ("topic", `String topic) :: base_fields
+
| None -> base_fields in
+
let with_queue_id = match t.queue_id with
+
| Some qid -> ("queue_id", `String qid) :: with_topic
+
| None -> with_topic in
+
let with_local_id = match t.local_id with
+
| Some lid -> ("local_id", `String lid) :: with_queue_id
+
| None -> with_queue_id in
+
`O with_local_id
+
+
let pp fmt t = Format.fprintf fmt "Message{type=%a, to=%s, content=%s}"
+
Message_type.pp t.type_
+
(String.concat "," t.to_)
+
t.content
+21
stack/zulip/lib/zulip/lib/message.mli
···
+
type t
+
+
val create :
+
type_:Message_type.t ->
+
to_:string list ->
+
content:string ->
+
?topic:string ->
+
?queue_id:string ->
+
?local_id:string ->
+
?read_by_sender:bool ->
+
unit -> t
+
+
val type_ : t -> Message_type.t
+
val to_ : t -> string list
+
val content : t -> string
+
val topic : t -> string option
+
val queue_id : t -> string option
+
val local_id : t -> string option
+
val read_by_sender : t -> bool
+
val to_json : t -> Error.json
+
val pp : Format.formatter -> t -> unit
+33
stack/zulip/lib/zulip/lib/message_response.ml
···
+
type t = {
+
id : int;
+
automatic_new_visibility_policy : string option;
+
}
+
+
let id t = t.id
+
let automatic_new_visibility_policy t = t.automatic_new_visibility_policy
+
+
let of_json json =
+
match json with
+
| `O fields ->
+
(try
+
let id = match List.assoc "id" fields with
+
| `Float f -> int_of_float f
+
| `String s -> int_of_string s
+
| _ -> failwith "id not found or not a number" in
+
let automatic_new_visibility_policy =
+
try Some (match List.assoc "automatic_new_visibility_policy" fields with
+
| `String s -> s
+
| _ -> failwith "invalid visibility policy")
+
with Not_found -> None in
+
Ok { id; automatic_new_visibility_policy }
+
with
+
| Failure msg ->
+
Error (Error.create ~code:(Other "parse_error") ~msg:("Failed to parse message response: " ^ msg) ())
+
| Not_found ->
+
Error (Error.create ~code:(Other "parse_error") ~msg:"Failed to parse message response: missing field" ())
+
| _ ->
+
Error (Error.create ~code:(Other "parse_error") ~msg:"Failed to parse message response" ()))
+
| _ ->
+
Error (Error.create ~code:(Other "parse_error") ~msg:"Expected JSON object for message response" ())
+
+
let pp fmt t = Format.fprintf fmt "MessageResponse{id=%d}" t.id
+6
stack/zulip/lib/zulip/lib/message_response.mli
···
+
type t
+
+
val id : t -> int
+
val automatic_new_visibility_policy : t -> string option
+
val of_json : Error.json -> (t, Error.t) result
+
val pp : Format.formatter -> t -> unit
+12
stack/zulip/lib/zulip/lib/message_type.ml
···
+
type t = [ `Direct | `Channel ]
+
+
let to_string = function
+
| `Direct -> "direct"
+
| `Channel -> "stream"
+
+
let of_string = function
+
| "direct" -> Some `Direct
+
| "stream" -> Some `Channel
+
| _ -> None
+
+
let pp fmt t = Format.fprintf fmt "%s" (to_string t)
+5
stack/zulip/lib/zulip/lib/message_type.mli
···
+
type t = [ `Direct | `Channel ]
+
+
val to_string : t -> string
+
val of_string : string -> t option
+
val pp : Format.formatter -> t -> unit
+46
stack/zulip/lib/zulip/lib/messages.ml
···
+
let send client message =
+
let json = Message.to_json message in
+
let params = match json with
+
| `O fields ->
+
List.fold_left (fun acc (key, value) ->
+
let str_value = match value with
+
| `String s -> s
+
| `Bool true -> "true"
+
| `Bool false -> "false"
+
| `A arr -> String.concat "," (List.map (function `String s -> s | _ -> "") arr)
+
| _ -> ""
+
in
+
(key, str_value) :: acc
+
) [] fields
+
| _ -> [] in
+
+
match Client.request client ~method_:`POST ~path:"/messages" ~params () with
+
| Ok response -> Message_response.of_json response
+
| Error err -> Error err
+
+
let edit client ~message_id ?content ?topic () =
+
let params =
+
(("message_id", string_of_int message_id) ::
+
(match content with Some c -> [("content", c)] | None -> []) @
+
(match topic with Some t -> [("topic", t)] | None -> [])) in
+
+
match Client.request client ~method_:`PATCH ~path:("/messages/" ^ string_of_int message_id) ~params () with
+
| Ok _ -> Ok ()
+
| Error err -> Error err
+
+
let delete client ~message_id =
+
match Client.request client ~method_:`DELETE ~path:("/messages/" ^ string_of_int message_id) () with
+
| Ok _ -> Ok ()
+
| Error err -> Error err
+
+
let get client ~message_id =
+
Client.request client ~method_:`GET ~path:("/messages/" ^ string_of_int message_id) ()
+
+
let get_messages client ?anchor ?num_before ?num_after ?narrow () =
+
let params =
+
(match anchor with Some a -> [("anchor", a)] | None -> []) @
+
(match num_before with Some n -> [("num_before", string_of_int n)] | None -> []) @
+
(match num_after with Some n -> [("num_after", string_of_int n)] | None -> []) @
+
(match narrow with Some n -> List.mapi (fun i s -> ("narrow[" ^ string_of_int i ^ "]", s)) n | None -> []) in
+
+
Client.request client ~method_:`GET ~path:"/messages" ~params ()
+12
stack/zulip/lib/zulip/lib/messages.mli
···
+
val send : Client.t -> Message.t -> (Message_response.t, Error.t) result
+
val edit : Client.t -> message_id:int -> ?content:string -> ?topic:string -> unit -> (unit, Error.t) result
+
val delete : Client.t -> message_id:int -> (unit, Error.t) result
+
val get : Client.t -> message_id:int -> (Error.json, Error.t) result
+
val get_messages :
+
Client.t ->
+
?anchor:string ->
+
?num_before:int ->
+
?num_after:int ->
+
?narrow:string list ->
+
unit ->
+
(Error.json, Error.t) result
+54
stack/zulip/lib/zulip/lib/user.ml
···
+
type t = {
+
email : string;
+
full_name : string;
+
is_active : bool;
+
is_admin : bool;
+
is_bot : bool;
+
}
+
+
let create ~email ~full_name ?(is_active = true) ?(is_admin = false) ?(is_bot = false) () =
+
{ email; full_name; is_active; is_admin; is_bot }
+
+
let email t = t.email
+
let full_name t = t.full_name
+
let is_active t = t.is_active
+
let is_admin t = t.is_admin
+
let is_bot t = t.is_bot
+
+
let to_json t =
+
`O [
+
("email", `String t.email);
+
("full_name", `String t.full_name);
+
("is_active", `Bool t.is_active);
+
("is_admin", `Bool t.is_admin);
+
("is_bot", `Bool t.is_bot);
+
]
+
+
let of_json json =
+
try
+
match json with
+
| `O fields ->
+
let get_string key =
+
match List.assoc key fields with
+
| `String s -> s
+
| _ -> failwith ("Expected string for " ^ key) in
+
let get_bool key default =
+
match List.assoc_opt key fields with
+
| Some (`Bool b) -> b
+
| None -> default
+
| _ -> failwith ("Expected bool for " ^ key) in
+
+
let email = get_string "email" in
+
let full_name = get_string "full_name" in
+
let is_active = get_bool "is_active" true in
+
let is_admin = get_bool "is_admin" false in
+
let is_bot = get_bool "is_bot" false in
+
+
Ok { email; full_name; is_active; is_admin; is_bot }
+
| _ ->
+
Error (Error.create ~code:(Other "json_parse_error") ~msg:"User JSON must be an object" ())
+
with
+
| exn ->
+
Error (Error.create ~code:(Other "json_parse_error") ~msg:("User JSON parsing failed: " ^ Printexc.to_string exn) ())
+
+
let pp fmt t = Format.fprintf fmt "User{email=%s, full_name=%s}" t.email t.full_name
+18
stack/zulip/lib/zulip/lib/user.mli
···
+
type t
+
+
val create :
+
email:string ->
+
full_name:string ->
+
?is_active:bool ->
+
?is_admin:bool ->
+
?is_bot:bool ->
+
unit -> t
+
+
val email : t -> string
+
val full_name : t -> string
+
val is_active : t -> bool
+
val is_admin : t -> bool
+
val is_bot : t -> bool
+
val to_json : t -> Error.json
+
val of_json : Error.json -> (t, Error.t) result
+
val pp : Format.formatter -> t -> unit
+46
stack/zulip/lib/zulip/lib/users.ml
···
+
let list client =
+
match Client.request client ~method_:`GET ~path:"/users" () with
+
| Ok json ->
+
(match json with
+
| `O fields ->
+
(match List.assoc_opt "members" fields with
+
| Some (`A user_list) ->
+
let users = List.fold_left (fun acc user_json ->
+
match User.of_json user_json with
+
| Ok user -> user :: acc
+
| Error _ -> acc
+
) [] user_list in
+
Ok (List.rev users)
+
| _ -> Error (Error.create ~code:(Other "api_error") ~msg:"Invalid users response format" ()))
+
| _ -> Error (Error.create ~code:(Other "api_error") ~msg:"Users response must be an object" ()))
+
| Error err -> Error err
+
+
let get client ~email =
+
match Client.request client ~method_:`GET ~path:("/users/" ^ email) () with
+
| Ok json ->
+
(match User.of_json json with
+
| Ok user -> Ok user
+
| Error err -> Error err)
+
| Error err -> Error err
+
+
let create_user client ~email ~full_name =
+
let body_json = `O [
+
("email", `String email);
+
("full_name", `String full_name);
+
] in
+
let body = match body_json with
+
| `O fields ->
+
String.concat "&" (List.map (fun (k, v) ->
+
match v with
+
| `String s -> k ^ "=" ^ s
+
| _ -> ""
+
) fields)
+
| _ -> "" in
+
match Client.request client ~method_:`POST ~path:"/users" ~body () with
+
| Ok _json -> Ok ()
+
| Error err -> Error err
+
+
let deactivate client ~email =
+
match Client.request client ~method_:`DELETE ~path:("/users/" ^ email) () with
+
| Ok _json -> Ok ()
+
| Error err -> Error err
+4
stack/zulip/lib/zulip/lib/users.mli
···
+
val list : Client.t -> (User.t list, Error.t) result
+
val get : Client.t -> email:string -> (User.t, Error.t) result
+
val create_user : Client.t -> email:string -> full_name:string -> (unit, Error.t) result
+
val deactivate : Client.t -> email:string -> (unit, Error.t) result
+93
stack/zulip/lib/zulip_bot/lib/bot_config.ml
···
+
type t = (string, string) Hashtbl.t
+
+
let create pairs =
+
let config = Hashtbl.create (List.length pairs) in
+
List.iter (fun (k, v) -> Hashtbl.replace config k v) pairs;
+
config
+
+
let from_file path =
+
try
+
let content =
+
let ic = open_in path in
+
let content = really_input_string ic (in_channel_length ic) in
+
close_in ic;
+
content in
+
+
(* Simple INI-style parser for config files *)
+
let lines = String.split_on_char '\n' content in
+
let config = Hashtbl.create 16 in
+
let current_section = ref "" in
+
+
List.iter (fun line ->
+
let line = String.trim line in
+
if String.length line > 0 && line.[0] <> '#' && line.[0] <> ';' then
+
if String.length line > 2 && line.[0] = '[' && line.[String.length line - 1] = ']' then
+
(* Section header *)
+
current_section := String.sub line 1 (String.length line - 2)
+
else
+
(* Key-value pair *)
+
match String.index_opt line '=' with
+
| Some idx ->
+
let key = String.trim (String.sub line 0 idx) in
+
let value = String.trim (String.sub line (idx + 1) (String.length line - idx - 1)) in
+
(* Remove quotes if present *)
+
let value =
+
if String.length value >= 2 &&
+
((value.[0] = '"' && value.[String.length value - 1] = '"') ||
+
(value.[0] = '\'' && value.[String.length value - 1] = '\'')) then
+
String.sub value 1 (String.length value - 2)
+
else
+
value
+
in
+
let full_key =
+
if !current_section = "" then key
+
else if !current_section = "bot" || !current_section = "features" then
+
(* For bot and features sections, use flat keys *)
+
key
+
else
+
!current_section ^ "." ^ key
+
in
+
Hashtbl.replace config full_key value
+
| None -> ()
+
) lines;
+
+
Ok config
+
with
+
| Sys_error msg ->
+
Error (Zulip.Error.create ~code:(Other "file_error") ~msg:("Cannot read config file: " ^ msg) ())
+
| exn ->
+
Error (Zulip.Error.create ~code:(Other "parse_error") ~msg:("Error parsing config: " ^ Printexc.to_string exn) ())
+
+
let from_env ~prefix =
+
try
+
let config = Hashtbl.create 16 in
+
let env_vars = Array.to_list (Unix.environment ()) in
+
+
List.iter (fun env_var ->
+
match String.split_on_char '=' env_var with
+
| key :: value_parts when String.length key > String.length prefix &&
+
String.sub key 0 (String.length prefix) = prefix ->
+
let config_key = String.sub key (String.length prefix) (String.length key - String.length prefix) in
+
let value = String.concat "=" value_parts in
+
Hashtbl.replace config config_key value
+
| _ -> ()
+
) env_vars;
+
+
Ok config
+
with
+
| exn ->
+
Error (Zulip.Error.create ~code:(Other "env_error") ~msg:("Error reading environment: " ^ Printexc.to_string exn) ())
+
+
let get t ~key =
+
Hashtbl.find_opt t key
+
+
let get_required t ~key =
+
match Hashtbl.find_opt t key with
+
| Some value -> Ok value
+
| None -> Error (Zulip.Error.create ~code:(Other "config_missing") ~msg:("Required config key missing: " ^ key) ())
+
+
let has_key t ~key =
+
Hashtbl.mem t key
+
+
let keys t =
+
Hashtbl.fold (fun k _ acc -> k :: acc) t []
+24
stack/zulip/lib/zulip_bot/lib/bot_config.mli
···
+
(** Configuration management for bots *)
+
+
type t
+
+
(** Create configuration from key-value pairs *)
+
val create : (string * string) list -> t
+
+
(** Load configuration from file *)
+
val from_file : string -> (t, Zulip.Error.t) result
+
+
(** Load configuration from environment variables with prefix *)
+
val from_env : prefix:string -> (t, Zulip.Error.t) result
+
+
(** Get a configuration value *)
+
val get : t -> key:string -> string option
+
+
(** Get a required configuration value, failing if not present *)
+
val get_required : t -> key:string -> (string, Zulip.Error.t) result
+
+
(** Check if a key exists in configuration *)
+
val has_key : t -> key:string -> bool
+
+
(** Get all configuration keys *)
+
val keys : t -> string list
+110
stack/zulip/lib/zulip_bot/lib/bot_handler.ml
···
+
module Response = struct
+
type t =
+
| Reply of string
+
| DirectMessage of { to_: string; content: string }
+
| ChannelMessage of { channel: string; topic: string; content: string }
+
| None
+
+
let none = None
+
let reply content = Reply content
+
let direct_message ~to_ ~content = DirectMessage { to_; content }
+
let channel_message ~channel ~topic ~content = ChannelMessage { channel; topic; content }
+
end
+
+
module Identity = struct
+
type t = {
+
full_name : string;
+
email : string;
+
mention_name : string;
+
}
+
+
let create ~full_name ~email ~mention_name = { full_name; email; mention_name }
+
let full_name t = t.full_name
+
let email t = t.email
+
let mention_name t = t.mention_name
+
end
+
+
module Message_context = struct
+
type t = Zulip.Error.json
+
+
let of_json json = 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
+
val initialize : Bot_config.t -> (unit, Zulip.Error.t) result
+
val usage : unit -> string
+
val description : unit -> string
+
val handle_message :
+
config:Bot_config.t ->
+
storage:Bot_storage.t ->
+
identity:Identity.t ->
+
message:Message_context.t ->
+
env:_ ->
+
(Response.t, Zulip.Error.t) result
+
end
+
+
module type S = Bot_handler
+
+
type t = {
+
module_impl : (module Bot_handler);
+
config : Bot_config.t;
+
storage : Bot_storage.t;
+
identity : Identity.t;
+
}
+
+
let create module_impl ~config ~storage ~identity =
+
{ module_impl; config; storage; identity }
+
+
let handle_message t message =
+
let module Handler = (val t.module_impl) in
+
(* Create a mock environment for now - in real usage, this should be passed in *)
+
let mock_env = object end in
+
Handler.handle_message
+
~config:t.config
+
~storage:t.storage
+
~identity:t.identity
+
~message
+
~env:mock_env
+
+
let handle_message_with_env t env message =
+
let module Handler = (val t.module_impl) in
+
Handler.handle_message
+
~config:t.config
+
~storage:t.storage
+
~identity:t.identity
+
~message
+
~env
+
+
let identity t = t.identity
+
let usage t =
+
let module Handler = (val t.module_impl) in
+
Handler.usage ()
+
+
let description t =
+
let module Handler = (val t.module_impl) in
+
Handler.description ()
+85
stack/zulip/lib/zulip_bot/lib/bot_handler.mli
···
+
(** Bot handler framework for Zulip bots *)
+
+
(** Response types that bots can return *)
+
module Response : sig
+
type t =
+
| Reply of string (** Reply in the same context *)
+
| DirectMessage of { to_: string; content: string } (** Send a direct message *)
+
| ChannelMessage of { channel: string; topic: string; content: string } (** Send to a channel *)
+
| None (** No response *)
+
+
val none : t
+
val reply : string -> t
+
val direct_message : to_:string -> content:string -> t
+
val channel_message : channel:string -> topic:string -> content:string -> t
+
end
+
+
(** Bot identity information *)
+
module Identity : sig
+
type t
+
+
val create : full_name:string -> email:string -> mention_name:string -> t
+
val full_name : t -> string
+
val email : t -> string
+
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 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
+
(** Initialize the bot (called once on startup) *)
+
val initialize : Bot_config.t -> (unit, Zulip.Error.t) result
+
+
(** Provide usage/help text *)
+
val usage : unit -> string
+
+
(** Provide bot description *)
+
val description : unit -> string
+
+
(** Handle an incoming message with EIO environment *)
+
val handle_message :
+
config:Bot_config.t ->
+
storage:Bot_storage.t ->
+
identity:Identity.t ->
+
message:Message_context.t ->
+
env:_ ->
+
(Response.t, Zulip.Error.t) result
+
end
+
+
(** Shorter alias for Bot_handler *)
+
module type S = Bot_handler
+
+
(** Abstract bot handler *)
+
type t
+
+
(** Create a bot handler from a module *)
+
val create :
+
(module Bot_handler) ->
+
config:Bot_config.t ->
+
storage:Bot_storage.t ->
+
identity:Identity.t ->
+
t
+
+
(** Process an incoming message with the bot *)
+
val handle_message : t -> Message_context.t -> (Response.t, Zulip.Error.t) result
+
+
(** Process an incoming message with EIO environment *)
+
val handle_message_with_env : t -> _ -> Message_context.t -> (Response.t, Zulip.Error.t) result
+
+
(** Get bot identity *)
+
val identity : t -> Identity.t
+
+
(** Get bot usage text *)
+
val usage : t -> string
+
+
(** Get bot description *)
+
val description : t -> string
+42
stack/zulip/lib/zulip_bot/lib/bot_runner.ml
···
+
type 'env t = {
+
client : Zulip.Client.t;
+
handler : Bot_handler.t;
+
mutable running : bool;
+
storage : Bot_storage.t;
+
env : 'env; (* Store EIO environment *)
+
}
+
+
let create ~env ~client ~handler =
+
let storage = Bot_storage.create client ~bot_email:"temp-bot" in
+
{ client; handler; running = false; storage; env }
+
+
let run_realtime t =
+
t.running <- true;
+
Printf.printf "Bot started in real-time mode\n";
+
+
(* In a real implementation, this would:
+
1. Register event queue with Zulip
+
2. Poll for events in a loop
+
3. Process message events through the handler
+
*)
+
+
(* Mock implementation for now *)
+
while t.running do
+
Unix.sleep 1
+
done
+
+
let run_webhook t =
+
t.running <- true;
+
Printf.printf "Bot started in webhook mode\n";
+
(* Webhook mode would wait for HTTP callbacks *)
+
()
+
+
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
+
+
let shutdown t =
+
t.running <- false;
+
Printf.printf "Bot shutting down\n"
+25
stack/zulip/lib/zulip_bot/lib/bot_runner.mli
···
+
(** Bot execution and lifecycle management *)
+
+
type 'env t
+
+
(** Create a bot runner *)
+
val create :
+
env:'env ->
+
client:Zulip.Client.t ->
+
handler:Bot_handler.t ->
+
'env t
+
+
(** Run the bot in real-time mode (using Zulip events API) *)
+
val run_realtime : 'env t -> unit
+
+
(** Run the bot in webhook mode (for use with bot server) *)
+
val run_webhook : 'env t -> unit
+
+
(** Process a single webhook event *)
+
val handle_webhook :
+
'env t ->
+
webhook_data:Zulip.Error.json ->
+
(Bot_handler.Response.t option, Zulip.Error.t) result
+
+
(** Gracefully shutdown the bot *)
+
val shutdown : 'env t -> unit
+28
stack/zulip/lib/zulip_bot/lib/bot_storage.ml
···
+
type t = {
+
client : Zulip.Client.t;
+
bot_email : string;
+
cache : (string, string) Hashtbl.t;
+
}
+
+
let create client ~bot_email = {
+
client;
+
bot_email;
+
cache = Hashtbl.create 16;
+
}
+
+
let get t ~key =
+
Hashtbl.find_opt t.cache key
+
+
let put t ~key ~value =
+
Hashtbl.replace t.cache key value;
+
Ok ()
+
+
let contains t ~key =
+
Hashtbl.mem t.cache key
+
+
let remove t ~key =
+
Hashtbl.remove t.cache key;
+
Ok ()
+
+
let keys t =
+
Ok (Hashtbl.fold (fun k _ acc -> k :: acc) t.cache [])
+21
stack/zulip/lib/zulip_bot/lib/bot_storage.mli
···
+
(** Persistent storage interface for bots *)
+
+
type t
+
+
(** Create a new storage instance for a bot *)
+
val create : Zulip.Client.t -> bot_email:string -> t
+
+
(** Get a value from storage *)
+
val get : t -> key:string -> string option
+
+
(** Store a value in storage *)
+
val put : t -> key:string -> value:string -> (unit, Zulip.Error.t) result
+
+
(** Check if a key exists in storage *)
+
val contains : t -> key:string -> bool
+
+
(** Remove a key from storage *)
+
val remove : t -> key:string -> (unit, Zulip.Error.t) result
+
+
(** List all keys in storage *)
+
val keys : t -> (string list, Zulip.Error.t) result
+4
stack/zulip/lib/zulip_bot/lib/dune
···
+
(library
+
(public_name zulip_bot)
+
(name zulip_bot)
+
(libraries zulip unix eio))
+35
stack/zulip/lib/zulip_botserver/lib/bot_registry.mli
···
+
(** Registry for managing multiple bots *)
+
+
(** Bot module definition *)
+
module Bot_module : sig
+
type t
+
+
val create :
+
name:string ->
+
handler:(module Zulip_bot.Bot_handler.Bot_handler) ->
+
create_config:(Server_config.Bot_config.t -> (Zulip_bot.Bot_config.t, Zulip.Error.t) result) ->
+
t
+
+
val name : t -> string
+
val create_handler : t -> Server_config.Bot_config.t -> Zulip.Client.t -> (Zulip_bot.Bot_handler.t, Zulip.Error.t) result
+
end
+
+
type t
+
+
(** Create a new bot registry *)
+
val create : unit -> t
+
+
(** Register a bot module *)
+
val register : t -> Bot_module.t -> unit
+
+
(** Get a bot handler by email *)
+
val get_bot : t -> email:string -> Zulip_bot.Bot_handler.t option
+
+
(** Load a bot module from file *)
+
val load_from_file : string -> (Bot_module.t, Zulip.Error.t) result
+
+
(** Load bot modules from directory *)
+
val load_from_directory : string -> (Bot_module.t list, Zulip.Error.t) result
+
+
(** List all registered bot emails *)
+
val list_bots : t -> string list
+22
stack/zulip/lib/zulip_botserver/lib/bot_server.mli
···
+
(** Main bot server implementation *)
+
+
type t
+
+
(** Create a bot server *)
+
val create :
+
config:Server_config.t ->
+
registry:Bot_registry.t ->
+
(t, Zulip.Error.t) result
+
+
(** Start the bot server *)
+
val run : t -> unit
+
+
(** Stop the bot server gracefully *)
+
val shutdown : t -> unit
+
+
(** Resource-safe server management *)
+
val with_server :
+
config:Server_config.t ->
+
registry:Bot_registry.t ->
+
(t -> 'a) ->
+
('a, Zulip.Error.t) result
+5
stack/zulip/lib/zulip_botserver/lib/dune
···
+
(library
+
(public_name zulip_botserver)
+
(name zulip_botserver)
+
(libraries zulip zulip_bot)
+
(modules_without_implementation bot_registry bot_server server_config webhook_handler))
+40
stack/zulip/lib/zulip_botserver/lib/server_config.mli
···
+
(** Bot server configuration *)
+
+
(** Configuration for a single bot *)
+
module Bot_config : sig
+
type t
+
+
val create :
+
email:string ->
+
api_key:string ->
+
server_url:string ->
+
token:string ->
+
config_path:string option ->
+
t
+
+
val email : t -> string
+
val api_key : t -> string
+
val server_url : t -> string
+
val token : t -> string
+
val config_path : t -> string option
+
val pp : Format.formatter -> t -> unit
+
end
+
+
(** Server configuration *)
+
type t
+
+
val create :
+
?host:string ->
+
?port:int ->
+
bots:Bot_config.t list ->
+
unit ->
+
t
+
+
val from_file : string -> (t, Zulip.Error.t) result
+
val from_env : unit -> (t, Zulip.Error.t) result
+
+
val host : t -> string
+
val port : t -> int
+
val bots : t -> Bot_config.t list
+
+
val pp : Format.formatter -> t -> unit
+33
stack/zulip/lib/zulip_botserver/lib/webhook_handler.mli
···
+
(** Webhook processing for bot server *)
+
+
(** Webhook event data *)
+
module Webhook_event : sig
+
type trigger = [`Direct_message | `Mention]
+
+
type t
+
+
val create :
+
bot_email:string ->
+
token:string ->
+
message:Zulip.Error.json ->
+
trigger:trigger ->
+
t
+
+
val bot_email : t -> string
+
val token : t -> string
+
val message : t -> Zulip.Error.json
+
val trigger : t -> trigger
+
val pp : Format.formatter -> t -> unit
+
end
+
+
(** Parse webhook data from HTTP request *)
+
val parse_webhook : string -> (Webhook_event.t, Zulip.Error.t) result
+
+
(** Process webhook with bot registry *)
+
val handle_webhook :
+
Bot_registry.t ->
+
Webhook_event.t ->
+
(Zulip_bot.Bot_handler.Response.t option, Zulip.Error.t) result
+
+
(** Validate webhook token *)
+
val validate_token : Server_config.Bot_config.t -> string -> bool