Expand description
§ircbot
An async IRC bot framework for Rust powered by Tokio and procedural macros.
Write clean, declarative bots without boilerplate:
use ircbot::{bot, Context, User, Result};
#[bot]
impl MyBot {
/// Respond to `!ping` from anywhere.
#[command("ping")]
async fn ping(&self, ctx: Context) -> Result {
ctx.reply("Pong!")
}
/// Respond to any message that looks like "you are …".
#[on(message = "you are *")]
async fn praise_me(&self, ctx: Context) -> Result {
ctx.say("Correct.")
}
/// Welcome users who join a channel.
#[on(event = "JOIN")]
async fn welcome(&self, ctx: Context, user: User) -> Result {
ctx.say(format!("Welcome to the void, {}!", user.nick))
}
/// Log every message posted to #general.
#[on(event = "PRIVMSG", target = "#general")]
async fn general_chat(&self, ctx: Context, message: String) -> Result {
println!("Message in #general: {}", message);
Ok(())
}
/// Echo messages matching the regex back to the channel.
#[on(event = "PRIVMSG", target = "#general", regex = r"^!echo (.+)$")]
async fn echo(&self, ctx: Context, message: String) -> Result {
ctx.say(message)
}
/// Respond to `!dance` with a /me action, but only in #general.
#[on(command = "dance", target = "#general")]
async fn dance(&self, ctx: Context) -> Result {
ctx.action("Dancing!")
}
/// Respond when the bot is addressed by name in any channel.
#[on(mention)]
async fn on_mention(&self, ctx: Context, text: String) -> Result {
ctx.reply(format!("You said: {}", text))
}
/// Post a morning reminder to #general every weekday at 9 a.m. UTC.
#[on(cron = "0 0 9 * * MON-FRI", target = "#general")]
async fn morning_reminder(&self, ctx: Context) -> Result {
ctx.say("Good morning, everyone!")
}
/// Send a private message directly to the caller, regardless of channel.
#[command("secret")]
async fn secret(&self, ctx: Context) -> Result {
ctx.whisper("This is just between us.").await
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let bot = MyBot::new("mybot", "localhost:6667", ["general"])
.await
.expect("Failed to create bot");
bot.main_loop().await.expect("Bot encountered an error");
Ok(())
}§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:
[dependencies]
ircbot = "0.1"
tokio = { version = "1", features = ["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):
impl YourBot {
pub async fn new(
nick: impl Into<String>,
server: impl AsRef<str>,
channels: impl IntoIterator<Item = impl Into<String>>,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>>;
pub async fn main_loop(self)
-> Result<(), Box<dyn std::error::Error + Send + Sync>>;
}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.
#[command("ping")]
async fn ping(&self, ctx: Context) -> Result {
ctx.reply("Pong!")
}
// The rest of the line after `!echo` is captured as `text`.
#[command("echo")]
async fn echo(&self, ctx: Context, text: String) -> Result {
ctx.say(text)
}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 std::sync::Arc;
use std::time::Duration;
use ircbot::{State, HandlerEntry, internal};
let state = State::connect("mybot", "irc.libera.chat:6667", vec!["#rust".into()])
.await?
.with_keepalive(Duration::from_secs(60), Duration::from_secs(15));
let handlers: Vec<HandlerEntry<()>> = vec![/* your HandlerEntry values */];
internal::run_bot(Arc::new(()), state, handlers).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.
cargo build --release
# 2. Send SIGHUP to the running bot.
kill -HUP $(pidof my_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 ircbot::hot_reload::exec_reload;
// Inside a handler:
#[command("reload")]
async fn do_reload(&self, ctx: Context) -> Result {
ctx.say("Reloading…")?;
// exec_reload only returns if exec itself failed.
let err = exec_reload(
ctx.raw_fd, // inherited TCP socket fd
&ctx.bot_nick,
"irc.libera.chat:6667",
&["#rust".to_string()],
30_000, // keepalive interval ms
10_000, // keepalive timeout ms
);
ctx.say(format!("Reload failed: {err}"))
}§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 std::sync::Arc;
use std::time::Duration;
use ircbot::{State, HandlerEntry, internal};
let state = State::connect("mybot", "irc.libera.chat:6667", vec!["#rust".into()])
.await?
.with_flood_control(8, Duration::from_millis(250)); // burst of 8, ≈ 4 msg/s
let handlers: Vec<HandlerEntry<()>> = vec![/* your HandlerEntry values */];
internal::run_bot(Arc::new(()), state, handlers).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 fn handler(&self, ctx: Context) -> Result
// User — populated from the IRC prefix (JOIN, PART, etc.)
async fn handler(&self, ctx: Context, user: User) -> Result
// String — message body, or the first regex/glob capture group.
async fn handler(&self, ctx: Context, message: String) -> Result§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
#[on(event = "PRIVMSG", regex = r"^!kick (\S+) (.*)$")]
async fn kick(&self, ctx: Context, target_nick: String, reason: String) -> Result {
ctx.say(format!("Kicking {} ({})", target_nick, reason))
}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
ContextwithTestContext::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
#[cfg(test)]
mod tests {
use super::*;
use ircbot::testing::TestContext;
#[tokio::test]
async fn ping_replies_pong_in_channel() {
let bot = MyBot::default();
let mut tc = TestContext::channel("#test", "alice", "!ping");
bot.ping(tc.take_ctx()).await.unwrap();
assert_eq!(
tc.next_reply(),
Some("PRIVMSG #test :alice, pong!\r\n".to_string()),
);
}
#[tokio::test]
async fn ping_replies_pong_in_query() {
let bot = MyBot::default();
let mut tc = TestContext::private("alice", "!ping");
bot.ping(tc.take_ctx()).await.unwrap();
assert_eq!(
tc.next_reply(),
Some("PRIVMSG alice :pong!\r\n".to_string()),
);
}
}§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:
#[tokio::test]
async fn echo_says_text() {
let bot = MyBot::default();
let mut tc = TestContext::channel("#test", "alice", "!echo hello world");
bot.echo(tc.take_ctx(), "hello world".to_string()).await.unwrap();
assert_eq!(
tc.next_reply(),
Some("PRIVMSG #test :hello world\r\n".to_string()),
);
}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:
#[tokio::test]
async fn handler_sends_two_messages() {
let bot = MyBot::default();
let mut tc = TestContext::channel("#test", "alice", "!status");
bot.status(tc.take_ctx()).await.unwrap();
let msgs = tc.replies();
assert_eq!(msgs.len(), 2);
assert!(msgs[0].contains("online"));
assert!(msgs[1].contains("uptime"));
}§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 = TestContext::builder()
.target("#rust")
.is_channel(true)
.sender_nick("newuser")
.bot_nick("mybot")
.captures(vec!["hello".to_string()])
.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
cargo run --example basic_botThe example prints the API usage and exits cleanly; point it at a real server
by editing the main function.
§Running the tests
cargo testUnit 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:
cargo test --example basic_botIntegration tests (require Docker):
cargo test --features integration -- --test-threads=1§License
MIT
Re-exports§
pub use bot::HandlerSet;pub use connection::State;pub use connection::DEFAULT_FLOOD_BURST;pub use connection::DEFAULT_FLOOD_RATE;pub use connection::DEFAULT_KEEPALIVE_INTERVAL;pub use connection::DEFAULT_KEEPALIVE_TIMEOUT;pub use context::make_messages;pub use context::Context;pub use context::User;pub use handler::BoxFuture;pub use handler::HandlerEntry;pub use handler::HandlerFn;pub use handler::Trigger;pub use irc::CtcpMessage;
Modules§
- bot
- connection
- context
- handler
- hot_
reload - internal
- Internal helpers used by the generated
main_loopcode. - irc
- IRC protocol types — backed by the
irc-protocrate. - testing
- Helpers for unit-testing bot handler methods.
Structs§
- Reload
Handle - A handle for replacing the bot’s handler list at runtime without disconnecting from IRC.
Enums§
- Error
- Errors specific to the bot framework.
Type Aliases§
- BoxError
- The standard error type used throughout the crate.
- Result
- The standard result type returned by handlers.