ircbot
An async IRC bot framework for Rust powered by Tokio and procedural macros.
Write clean, declarative bots without boilerplate:
use ;
async
Features
- Proc-macro API — annotate handler methods with
#[command]or#[on]and let the#[bot]macro wire everything up. - Flexible triggers — commands (
!ping), glob message patterns ("you are *"), raw IRC events (JOIN,PRIVMSG, …), bot-mention detection ("botname: …"), and cron-scheduled handlers (#[on(cron = "0 0 8-16 * * MON-FRI")]), all with optional target-channel and regex filters. - Context helpers —
ctx.reply(),ctx.say(),ctx.action(),ctx.notice(), andctx.whisper()cover the most common reply patterns. - Async / non-blocking — built on Tokio; every handler is an
async fn. - Active keepalive — the bot sends a periodic
PINGto the server (default every 30 s) and reconnects automatically if noPONGarrives within the timeout (default 10 s). Interval and timeout are configurable viaState::with_keepalive(). - Automatic reconnection — on TCP drop or keepalive timeout the bot re-dials and re-joins all configured channels, preserving all handler registrations.
- Hot reload — replace the running bot binary without dropping the IRC connection. On Unix, sending
SIGHUPexecs the new binary with the live TCP socket inherited; no reconnect, no missed messages. See Hot reload. - Concurrent write loop — outgoing messages are serialised through an in-process channel so handlers can send replies without blocking each other.
- Flood protection — a token-bucket rate limiter in the write loop ensures the bot cannot send messages faster than the server allows (default: burst of 4, then 1 message per 500 ms). Configurable via
State::with_flood_control(). - Automatic message splitting — any outgoing message that would exceed the IRC 512-byte line limit is automatically split across multiple lines, with word-boundary awareness and UTF-8 safety.
- Output sanitization —
\r,\n, and\0are stripped from every outgoing message, preventing IRC injection attacks.
Workspace layout
ircbot/ ← library crate (public API)
src/
lib.rs ← re-exports, type aliases, and internal::run_bot reconnection loop
irc.rs ← RFC 1459 IRC line parser
connection.rs ← TCP connect + NICK/USER/JOIN, State, with_keepalive
context.rs ← Context, User
handler.rs ← Trigger, HandlerEntry type aliases
bot.rs ← run_bot_internal, trigger matching, glob, keepalive ping
tests/
irc_parsing.rs ← unit tests (IRC parsing)
trigger_matching.rs ← unit tests (trigger dispatch)
keepalive.rs ← unit tests (keepalive timeout, automatic reconnection)
cron.rs ← unit tests (cron/periodic handlers)
flood_control.rs ← unit + integration tests (message splitting, rate limiting)
examples/
basic_bot.rs ← minimal demo
ircbot-macros/ ← proc-macro crate
src/
lib.rs ← #[bot], #[command], #[on]
Getting started
Add ircbot to your Cargo.toml:
[]
= "0.1"
= { = "1", = ["full"] }
Macros
#[bot]
Placed on an impl block. The macro generates:
- A
structdefinition for the named type with internal connection state. YourBot::new(nick, server, channels)— connects to the server, identifies, and joins the given channels. On Unix, if this process was started viaSIGHUPhot-reload, the live TCP socket is inherited from the previous binary instead.YourBot::main_loop(self)— runs the event loop, reconnecting automatically on TCP drops or keepalive timeouts. On Unix, also listens forSIGHUPand performs a zero-disconnect binary exec-reload.
// Generated signatures (simplified):
Channel names in channels are automatically prefixed with # if they do not already start with a channel sigil (#, &, +, !).
#[command("name")]
Fires when a user sends !name (case-insensitive) in any channel or as a private message. Accepts an optional target = "#channel" filter. The text that follows !name on the same line is available as the first String parameter.
async
// The rest of the line after `!echo` is captured as `text`.
async
See ircbot::command for full reference.
#[on(…)]
The general-purpose trigger attribute. Exactly one of command, message, event, mention, or cron must be present. target, regex, and tz are optional modifiers.
| Key | Description |
|---|---|
command = "name" |
Same as #[command("name")] |
message = "pattern" |
Glob pattern on PRIVMSG text; * is a capturing wildcard |
event = "IRC_CMD" |
Any IRC command (e.g. "JOIN", "PRIVMSG", "PART") |
mention |
Fires when a PRIVMSG addresses the bot by name ("botname: …" or "botname, …") |
cron = "expr" |
Fires on a Quartz cron schedule, validated at compile time |
tz = "Timezone" |
IANA timezone for the cron schedule (default: "UTC") |
target = "#channel" |
Optional channel filter (any trigger type) |
regex = "…" |
Optional regex on the message text; capture groups become String args |
Trigger precedence: message › command › event › mention › cron.
See ircbot::on for full reference including per-trigger examples and cron quick-reference.
Keepalive & reconnection
The bot actively monitors its connection by sending a PING ircbot-keepalive to the server at a regular interval. If no matching PONG is received within the timeout window, the connection is treated as dead and a new TCP connection is established.
Defaults:
| Setting | Value |
|---|---|
| Keepalive interval | 30 s |
| PONG response timeout | 10 s |
| Reconnect delay | 5 s |
main_loop() never returns normally — it reconnects automatically whenever the connection is lost (TCP close or keepalive timeout), re-sends NICK/USER, and re-joins all configured channels.
Custom intervals — configure keepalive before starting the bot by calling State::with_keepalive(). When using the #[bot] macro, new() manages the State internally, so custom keepalive settings require the lower-level API:
use Arc;
use Duration;
use ;
let state = connect
.await?
.with_keepalive;
let handlers: = vec!;
run_bot.await?;
Hot reload
Hot reload lets you replace the running bot binary without ever dropping the IRC connection — no reconnect, no missed messages, no re-authentication.
How it works
On Unix, a TCP socket is just a file descriptor. When a process calls exec() the new process image inherits every file descriptor that does not have FD_CLOEXEC set. The hot-reload path exploits this:
SIGHUPreceived —main_loop()catches the signal.- FD prepared —
FD_CLOEXECis cleared on the live TCP socket so it survivesexec. - State encoded — the fd number, nick, server, channels, and keepalive settings are written into environment variables.
execcalled — the current process image is replaced with the new binary at the same path. The PID is unchanged; the TCP connection is never closed.- New binary starts —
new()detects the env vars, callsState::try_inherit_from_env(), and wraps the inherited fd in a TokioTcpStream. NoNICK/USER/JOINis sent; the IRC session continues seamlessly.
Using SIGHUP (zero configuration)
When using the #[bot] macro, main_loop() installs the SIGHUP handler automatically. The full workflow is:
# 1. Build the updated binary.
# 2. Send SIGHUP to the running bot.
# 3. The old process execs the new binary.
# The IRC connection is never interrupted.
Lower-level API
For programmatic control call hot_reload::exec_reload directly — for example from an IRC admin command:
use exec_reload;
// Inside a handler:
async
Flood protection
The bot's write loop enforces a token-bucket rate limiter to prevent it from overwhelming the IRC server with outgoing messages.
How it works:
- The bucket starts full with
bursttokens. - Each outgoing message consumes one token.
- While at least one token is available the message is sent immediately.
- Once the bucket is empty the write loop waits until enough time has elapsed
for a new token to be added (one token per
rateinterval) before sending the next message.
Defaults:
| Setting | Value |
|---|---|
| Burst (initial token supply) | 4 messages |
| Rate (token refill interval) | 500 ms |
| Steady-state throughput | ≈ 2 messages / second |
Custom flood-control settings — call State::with_flood_control() before
starting the bot. When using the #[bot] macro, use the lower-level API:
use Arc;
use Duration;
use ;
let state = connect
.await?
.with_flood_control; // burst of 8, ≈ 4 msg/s
let handlers: = vec!;
run_bot.await?;
Automatic message splitting
IRC limits each protocol line to 512 bytes (including the trailing \r\n).
Every Context reply method (reply, say, action, notice, whisper)
automatically splits text that would exceed this limit into multiple messages.
The splitter:
- Prefers to break at an ASCII space (word-wrapping), falling back to a hard byte-limit split when no space is available.
- Always splits on a valid UTF-8 character boundary so multi-byte characters are never corrupted.
- Accounts for the fixed overhead of the IRC command prefix (e.g.
PRIVMSG #channel :) and any CTCP suffix when computing the available space.
Splitting happens transparently — your handler code does not need to do anything special.
Handler signatures
Handlers always start with &self and ctx: Context. Additional parameters
are extracted automatically from the matched message:
// No extra args — most handlers look like this.
async async async
Multiple capture groups
When a regex (or a message glob with multiple *) produces more than one
capture, each extra String parameter receives the next capture in order:
// regex with two capture groups → two String parameters
async
If captures is empty the first String parameter falls back to the full
message text (ctx.message_text()).
Unit testing handlers
Handler methods can be tested directly without a live IRC connection using
ircbot::testing::TestContext.
How it works
- Create a bot instance with
MyBot::default()— no connection is made. - Build a fake [
Context] withTestContext::channel,TestContext::private, orTestContext::builder()for full control. - Extract the context with
tc.take_ctx()and pass it to the handler. - Assert on the captured outgoing messages with
tc.next_reply()ortc.replies().
Quick example
Testing handlers with extra parameters
When a handler takes a String capture, pass it directly — the framework's
extraction code is bypassed in a direct call:
async
If you want to exercise the full trigger-matching and argument-extraction
pipeline (including glob/regex captures), use the integration test helpers
in tests/ instead.
Checking multiple replies
tc.replies() drains all buffered replies at once:
async
Advanced: custom context via the builder
Use TestContext::builder() for scenarios that channel/private don't
cover, such as simulating an event with a specific bot nick or pre-set
captures:
let mut tc = builder
.target
.is_channel
.sender_nick
.bot_nick
.captures
.build;
Best practices
- One test per behaviour — keep each test focused on a single observable outcome (e.g. "reply text", "no reply", "two messages").
- Test channel and query —
reply()prefixes the nick in channels but not in queries; verify both when it matters. - Pass capture args directly — rather than putting capture text in the
message body and relying on the framework, pass
Stringargs directly to the method. This makes tests faster, clearer, and independent of trigger matching. - Use
tc.replies()for multi-message handlers — if a handler may emit more than one message (e.g. long text that gets split), collect withtc.replies()and assert on the slice.
Context
Context is passed to every handler and provides both metadata about the
incoming message and helper methods for sending replies.
Fields
| Field | Type | Description |
|---|---|---|
ctx.target |
String |
Channel or nick the message was directed to |
ctx.is_channel |
bool |
true when target is a channel, false for private messages |
ctx.sender |
Option<User> |
The user who sent the message |
ctx.bot_nick |
String |
The bot's own IRC nick (useful for self-detection) |
ctx.captures |
Vec<String> |
Regex or glob capture groups from the matched trigger |
ctx.raw |
irc_proto::Message |
The underlying parsed IRC message (from the irc-proto crate) |
Methods
| Method | Behaviour |
|---|---|
ctx.reply(msg) |
In a channel: nick, msg. In a query: msg to the sender. Synchronous. |
ctx.say(msg) |
Send msg to the current channel or query target, without a nick prefix. Synchronous. |
ctx.action(msg) |
Send a CTCP ACTION (/me msg) to the current target. Synchronous. |
ctx.notice(msg) |
Send a NOTICE to the current target. NOTICEs must never be replied to automatically (by convention), making them suitable for status messages and one-shot notifications. Async — use .await. |
ctx.whisper(msg) |
Send a private message directly to the sender's nick, regardless of whether the original message arrived in a channel or a query. Async — use .await. |
ctx.message_text() |
The raw trailing text of the underlying IRC message. |
User
User represents the nick!user@host prefix on an IRC message.
| Field | Type | Description |
|---|---|---|
user.nick |
String |
IRC nickname |
user.user |
String |
IRC username (ident) |
user.host |
String |
Hostname or IP |
Running the example
The example prints the API usage and exits cleanly; point it at a real server
by editing the main function.
Running the tests
Unit tests covering IRC parsing, all trigger types, keepalive timeouts, automatic reconnection, message splitting, and rate-limiting.
To also run the handler tests embedded in the example bot:
Integration tests (require Docker):
License
MIT