An opinionated, minimalistic Discord bot orchestration library for sending and receiving messages across multiple bot accounts sharing common channels.
- Multi-bot orchestration — run multiple bot accounts in a single session, automatically intersecting their accessible channels
- Event-driven message matching — register filters (
contains,starts_with, or custom closures) and receive matching messages through typed receivers - Message replies and reactions — send messages, reply to specific messages, and attach emoji reactions (Unicode or custom guild emojis)
- Resilient pipeline — configurable backoff delays, send timeouts, retry jitter, and automatic reconnection handling on disconnection
- Python bindings — full API exposed via PyO3/maturin with async support
flowchart TB
User["User Code"]
subgraph Session["Session"]
SB["SessionBuilder / Session"]
BE["BotEvents<br/>(filters → flume channels)"]
subgraph Bots["BotMap"]
B0["Bot 0<br/>(receives events)"]
B1["Bot 1"]
BN["Bot N"]
end
PL["Pipeline task<br/>(bounded queue, backoff,<br/>retry jitter, timeouts)"]
end
RX["MessageEventReceiver"]
subgraph GatewayLayer["Gateway (trait)"]
SG["SerenityGateway"]
EC["EmojiCache"]
end
subgraph DiscordAPI["Discord"]
WS["WebSocket (receive)"]
REST["REST API (send)"]
end
User -->|"register_event()"| BE
BE --> RX
RX -->|"recv_async() / recv()"| User
User -->|"schedule_message() / schedule_reaction()"| PL
SB -->|"build(): intersects<br/>accessible channels"| Bots
WS --> SG -->|"dispatch"| BE
PL -->|"send_message() / send_reaction()"| SG
SG --- EC
SG --> REST
Inbound path — the first bot in the session receives messages over the
WebSocket gateway, dispatches them through registered MessageEvent filters,
and fans them out to per-event flume channels exposed as
MessageEventReceivers (bounded, with a configurable push timeout). The
receiver loops back into user code — responses and reactions are produced by
the user's event loop and fed back into the pipeline.
Outbound path — all sends go through a single pipeline task consuming a bounded queue. Each item is dispatched to the sender bot's gateway with a send timeout, retry-on-disconnect with random jitter, and a random backoff between items.
-
Single serialized pipeline instead of per-bot concurrency. A swarm of bots posting concurrently from shared channels is a natural trigger for Discord's rate limits and spam heuristics. Serializing all outbound traffic through one queue with random inter-item delays (250–500 ms by default) trades a little throughput for predictable, human-like pacing and a single point where rate-limit policy lives. This also aligns with the use-case that motivated this project: simulating a human-like discord discussion with bots.
-
Bounded channels everywhere. Both the pipeline queue and event receiver channels are bounded (
flume), so a fast producer or slow consumer applies backpressure instead of silently accumulating unbounded memory. Event delivery additionally has a timeout, so a stalled handler can never wedge dispatch. -
Gatewayas a trait over serenity. All Discord interaction is hidden behind a small async trait (send_message,send_reaction,accessible_channels, ...). The mock gateway used in the test suite plugs in at exactly this seam, which keeps tests off the network. -
Channel intersection at session construction. With multiple accounts sharing channels, sending from a channel only some bots can see would be a source of confusing partial failures. Instead,
SessionBuilder::buildcomputes the intersection of all bots' accessible channels once, and dispatch simply filters on it — an event can never be received from, and a message never scheduled to, a channel the swarm doesn't fully share. -
One shared pipeline rather than one per bot. Sessions are assumed to be long-lived and the pipeline is a lightweight background task, so centralizing retries, timeouts, and backoff there means user code never has to think about reconnection handling.
GatewayError::Disconnectedretries are transparent; only fatal errors and timeouts propagate to the caller. -
ArcSwapfor event registration. Filters are registered at runtime (possibly after bots are connected) without blocking the dispatch hot path: readers load a snapshot lock-free, and writers serialize on a mutex only when adding a new event.
Add the dependency:
[dependencies]
dischorus = { git = "https://github.com/oteffahi/dischorus" }Set your bot token in a .env file:
DISCORD_TOKEN=your_bot_token_here
use dischorus::{
PipelineConfig,
message::{Message, MessageEvent, Reaction, ReactionEmoji},
session::SessionBuilder,
};
#[tokio::main]
async fn main() {
dischorus::logging::init_logs_from_env_or("info");
let mut builder = SessionBuilder::new();
let bot = builder.add_user("DISCORD_TOKEN");
let session = builder
.build(PipelineConfig::new(100).delays(500, 1000))
.await
.unwrap();
let receiver = session
.register_event(MessageEvent::contains("ping"))
.capacity(50)
.channel_timeout(10_000)
.await;
let wave = Reaction::new(bot, ReactionEmoji::unicode("👋"));
while let Ok(event) = receiver.recv_async().await {
session
.schedule_message(
Message::reply(event.message_id(), bot, "pong")
.reactions(vec![wave.clone()]),
)
.await
.unwrap();
}
}uv add "dischorus @ git+https://github.com/oteffahi/dischorus#subdirectory=python"Or install manually from a clone:
git clone https://github.com/oteffahi/dischorus
cd dischorus/python
uv run maturin developSet your bot token in a .env file:
DISCORD_TOKEN=your_bot_token_here
import asyncio
import dischorus
async def main():
dischorus.init_logs("info")
builder = dischorus.SessionBuilder()
bot = builder.add_user("DISCORD_TOKEN")
session = await builder.build(
dischorus.PipelineConfig.new(100, delays=(500, 1000))
)
receiver = await session.register_event(
dischorus.MessageEvent.contains("ping"),
capacity=50,
channel_timeout_ms=10000,
)
wave = dischorus.Reaction.new(bot, dischorus.ReactionEmoji.unicode("👋"))
while True:
event = await receiver.recv()
if event is None:
break
msg = dischorus.Message.reply(event.message_id(), bot, "pong", reactions=[wave])
await session.schedule_message(msg)
asyncio.run(main())This project is licensed under the MIT License.