docs: add a module-developer API reference (docs/api/) — Command/Module/ChanMode/UserMode traits, the Server API surface, per-entity Extensible state, and a first-module tutorial

This commit is contained in:
Jean Chevronnet 2026-08-13 02:55:56 +00:00
parent bd054f7721
commit d795d1a59c
7 changed files with 569 additions and 1 deletions

111
docs/api/modules.md Normal file
View file

@ -0,0 +1,111 @@
# The `Module` trait
A module hooks lifecycle events. Implement `crate::module::Module` on a struct and
register it in `default_modules()`. Every method has a default, so implement only
the hooks you need.
```rust
pub trait Module: Send {
fn name(&self) -> &'static str;
// pre-hooks — fired inline, can Deny the action
fn on_user_register(&mut self, srv: &mut Server, uid: Uid) -> ModResult { ModResult::Passthru }
fn on_pre_command(&mut self, srv: &mut Server, uid: Uid, cmd: &str, params: &[String]) -> ModResult { ModResult::Passthru }
fn on_pre_message(&mut self, srv: &mut Server, uid: Uid, target: &str, text: &str) -> ModResult { ModResult::Passthru }
// notify-hooks — informational, fired after the fact
fn on_user_connect(&mut self, srv: &mut Server, uid: Uid) {}
fn on_post_command(&mut self, srv: &mut Server, uid: Uid, cmd: &str) {}
fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) {}
fn on_part(&mut self, srv: &mut Server, uid: Uid, chan: &str, reason: &str) {}
fn on_user_quit(&mut self, srv: &mut Server, uid: Uid, reason: &str) {}
fn on_tick(&mut self, srv: &mut Server) {}
}
```
Note the receiver is `&mut self`: unlike commands and modes (which are stateless
`&self` handlers), a module instance can hold its own fields. In practice, prefer
the [`Extensible` typemap](server.md#per-entity-state) for per-user / per-channel
state so it lives and dies with its owner; use `self` fields only for
module-global state.
## Pre-hooks (can deny)
Pre-hooks run **inline, before** the action they gate, and return a `ModResult`:
```rust
pub enum ModResult {
Passthru, // no opinion — let other modules and the core decide
Allow, // force-allow: skip the remaining checks
Deny, // block the action
}
```
| Hook | Fires | `Deny` effect |
|------|-------|---------------|
| `on_user_register` | last gate before a client finishes registration | refuses the connection |
| `on_pre_command` | before any command runs | swallows the command silently |
| `on_pre_message` | before a `PRIVMSG` / `NOTICE` is delivered | drops the message |
Return `Deny` to block, `Allow` to force it through (bypassing other checks), or
`Passthru` to abstain. When you `Deny`, send the user an explanation yourself
(e.g. `srv.numeric(...)` or `srv.fail(...)`), since the core just stops.
```rust
fn on_pre_message(&mut self, srv: &mut Server, uid: Uid, target: &str, text: &str) -> ModResult {
if text.contains("badword") && !srv.is_oper(uid) {
srv.numeric(uid, 404, &format!("{target} :Message blocked"));
return ModResult::Deny;
}
ModResult::Passthru
}
```
## Notify-hooks (informational)
Notify-hooks run **after** the event, drained from a queue once the triggering
command finishes. They can't block, but they get `&mut Server`, so they can act —
send lines, force a join, update state.
| Hook | Fires when |
|------|-----------|
| `on_user_connect` | a client has fully registered |
| `on_post_command` | after a command completes |
| `on_join` | a user joined a channel |
| `on_part` | a user left a channel |
| `on_user_quit` | a user is disconnecting (still exists during the call) |
| `on_tick` | the background timer, every `TICK_SECS` |
Because notify-hooks fire from a queue, a hook can itself cause more events (e.g.
force a join) without re-entering the module list — no surprises.
## Timed work
Use `on_tick` for periodic jobs (expiring entries, saving state, scoring). It runs
on the core thread, so keep it cheap; for a big write, hand it to
[`disk_write`](server.md#off-core-work) rather than blocking.
```rust
fn on_tick(&mut self, srv: &mut Server) {
let store = srv.ext.get_or_insert_with::<MyStore>(MyStore::default);
store.expire(crate::server::now());
// persist off-core so a slow disk can't stall the core:
srv.disk_write(format!("{}.mystore", srv.conf_path), store.serialize());
}
```
## Registering
Add your module to `src/modules/mod.rs`:
```rust
pub mod mymod; // declare the file
// …in default_modules():
Box::new(mymod::MyMod), // add to the vec
```
Order in the vec is the order pre-hooks are consulted; the first `Deny` (or
`Allow`) wins.
See [commands](commands.md) if your module also adds commands, and
[the `Server` API](server.md) for everything you can call from a hook.