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:
parent
bd054f7721
commit
d795d1a59c
7 changed files with 569 additions and 1 deletions
|
|
@ -117,7 +117,8 @@ objects instead of a fragile plugin ABI. **Full design notes:**
|
||||||
|
|
||||||
## Extending
|
## Extending
|
||||||
|
|
||||||
Three small extension points, each one file + one table line:
|
Three small extension points, each one file + one table line — full reference and
|
||||||
|
a tutorial in [`docs/api/`](docs/api/):
|
||||||
|
|
||||||
- **Commands** (`src/command.rs`, `src/coremods/`) — a handler with `name`,
|
- **Commands** (`src/command.rs`, `src/coremods/`) — a handler with `name`,
|
||||||
`min_params`, `before_reg`, `handle(&mut Server, uid, params)`.
|
`min_params`, `before_reg`, `handle(&mut Server, uid, params)`.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ This folder is the reference manual. Start with whichever fits what you're doing
|
||||||
| [IRCv3](ircv3.md) | The advertised capabilities and the notable extensions. |
|
| [IRCv3](ircv3.md) | The advertised capabilities and the notable extensions. |
|
||||||
| [Anti-abuse & flood protection](anti-abuse.md) | The layered defenses, from the accept edge up to the application, and how they fit with kernel/upstream filtering. |
|
| [Anti-abuse & flood protection](anti-abuse.md) | The layered defenses, from the accept edge up to the application, and how they fit with kernel/upstream filtering. |
|
||||||
| [Deployment](deployment.md) | Running in production: release builds, a supervised service, log shipping, reverse proxies, and firewall hardening. |
|
| [Deployment](deployment.md) | Running in production: release builds, a supervised service, log shipping, reverse proxies, and firewall hardening. |
|
||||||
|
| [**Module developer API**](api/) | Write your own commands, modes, and modules — the trait reference, the `Server` API, and a first-module tutorial. |
|
||||||
|
|
||||||
## At a glance
|
## At a glance
|
||||||
|
|
||||||
|
|
|
||||||
110
docs/api/README.md
Normal file
110
docs/api/README.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Module developer API
|
||||||
|
|
||||||
|
This is the reference for extending echoIRCd. The server has five extension
|
||||||
|
points, all ordinary Rust trait objects compiled into the binary — there is no
|
||||||
|
plugin ABI, no dynamic loading, and no `unsafe`:
|
||||||
|
|
||||||
|
| You want to… | Implement | Registered in | Reference |
|
||||||
|
|--------------|-----------|---------------|-----------|
|
||||||
|
| Add a command | [`Command`](commands.md) | `modules/mod.rs::module_commands()` | [commands](commands.md) |
|
||||||
|
| Add a channel/user mode | [`ChanMode`](modes.md) / [`UserMode`](modes.md) | `mode.rs::CHAN_MODES` / `USER_MODES` | [modes](modes.md) |
|
||||||
|
| Hook lifecycle events | [`Module`](modules.md) | `modules/mod.rs::default_modules()` | [modules](modules.md) |
|
||||||
|
| Store per-user / per-server state | [`Extensible`](server.md#per-entity-state) typemap | — | [server](server.md) |
|
||||||
|
| Call into the server | the [`Server`](server.md) API | — | [server](server.md) |
|
||||||
|
|
||||||
|
## The mental model — read this first
|
||||||
|
|
||||||
|
**Everything runs on one thread.** A single core thread owns every `User` and
|
||||||
|
`Channel`. Your command handlers, mode handlers, and module hooks are all called
|
||||||
|
on that thread with `&mut Server`. That means:
|
||||||
|
|
||||||
|
- **Your code is plain synchronous Rust.** No `async`, no `.await`, no `Send +
|
||||||
|
'static` futures, no `Arc<Mutex<…>>`. You read and mutate `Server` directly.
|
||||||
|
- **You must never block.** A slow operation (a KDF hash, a network call, a big
|
||||||
|
disk write) would freeze the whole server. Offload it — see
|
||||||
|
[off-core work](server.md#off-core-work) — and handle the result as an event.
|
||||||
|
- **State is safe by construction.** Users and channels are referenced by `Uid` /
|
||||||
|
channel-key handles, not pointers, so there are no dangling references.
|
||||||
|
|
||||||
|
## Project conventions
|
||||||
|
|
||||||
|
Modules in this codebase follow a few hard rules — match them:
|
||||||
|
|
||||||
|
1. **One module per file** in `src/modules/`. A module is self-contained; it does
|
||||||
|
not add fields to `Server` or `Config`.
|
||||||
|
2. **State goes in the `Extensible` typemap**, not in new struct fields — attach
|
||||||
|
per-user data to `User.ext`, per-server (and per-channel, keyed by name) data
|
||||||
|
to `Server.ext`. It is dropped automatically with its owner. See
|
||||||
|
[per-entity state](server.md#per-entity-state).
|
||||||
|
3. **Read settings through the config accessors** (`conf`, `conf_all`, `conf_num`,
|
||||||
|
`conf_bool`) — never hardcode a tunable value; expose it as a config key with a
|
||||||
|
literal default.
|
||||||
|
4. **Register in the table**, don't touch the parser or the dispatcher. Adding a
|
||||||
|
command / mode / module is one new file plus one line in a registration table.
|
||||||
|
5. **Original Rust only.** No `unsafe`, no C/FFI, no new dependencies, and no code
|
||||||
|
copied or translated from another project. `scripts/native-rust-guard.sh`
|
||||||
|
enforces this on every edit.
|
||||||
|
|
||||||
|
## Write your first module in five steps
|
||||||
|
|
||||||
|
A module that logs connects and quits — the canonical template
|
||||||
|
(`src/modules/snoop.rs`):
|
||||||
|
|
||||||
|
**1. Create the file** `src/modules/hello.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
//! hello — a tiny example module.
|
||||||
|
use crate::module::Module;
|
||||||
|
use crate::server::Server;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
pub struct Hello;
|
||||||
|
|
||||||
|
impl Module for Hello {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"hello"
|
||||||
|
}
|
||||||
|
fn on_user_connect(&mut self, srv: &mut Server, uid: Uid) {
|
||||||
|
if let Some(u) = srv.users.get(&uid) {
|
||||||
|
srv.snotice(&format!("hello: {} connected", u.nick));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Declare the file** in `src/modules/mod.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod hello;
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Register the module** in the same file's `default_modules()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn default_modules() -> Vec<Box<dyn Module>> {
|
||||||
|
vec![
|
||||||
|
// …existing modules…
|
||||||
|
Box::new(hello::Hello),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. (Only if it adds a command)** expose a `commands()` function from your module
|
||||||
|
and chain it into `module_commands()` — see [commands](commands.md).
|
||||||
|
|
||||||
|
**5. Build, test, and check:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build && cargo test
|
||||||
|
bash scripts/native-rust-guard.sh src/modules/hello.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it — `hello` is now a first-class part of the server.
|
||||||
|
|
||||||
|
## Where to go next
|
||||||
|
|
||||||
|
- [The `Module` trait](modules.md) — every lifecycle hook and how to block actions.
|
||||||
|
- [Commands](commands.md) — add a command and reply to clients.
|
||||||
|
- [Modes](modes.md) — add a channel or user mode.
|
||||||
|
- [The `Server` API](server.md) — sending, lookups, permissions, config, state,
|
||||||
|
and off-core work.
|
||||||
102
docs/api/commands.md
Normal file
102
docs/api/commands.md
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# The `Command` trait
|
||||||
|
|
||||||
|
A command is a stateless handler registered by name. Implement
|
||||||
|
`crate::command::Command`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Command: Send {
|
||||||
|
fn name(&self) -> &'static str; // upper-case; also the registry key
|
||||||
|
fn min_params(&self) -> usize { 0 } // fewer args ⇒ core replies 461, skips you
|
||||||
|
fn before_reg(&self) -> bool { false } // may it run before registration?
|
||||||
|
fn handle(&self, srv: &mut Server, uid: Uid, params: &[String]) -> CmdResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum CmdResult { Ok, Fail } // Fail is for your own bookkeeping/logging
|
||||||
|
```
|
||||||
|
|
||||||
|
The core does the boilerplate for you before `handle` is called:
|
||||||
|
|
||||||
|
- **Arity** — if the client sent fewer than `min_params` arguments, the core
|
||||||
|
replies `461 ERR_NEEDMOREPARAMS` and never calls you.
|
||||||
|
- **Registration gate** — unless `before_reg()` returns `true`, the command is
|
||||||
|
refused until the client has registered. Only handshake commands
|
||||||
|
(`NICK`/`USER`/`CAP`/`PING`/`QUIT` and the like) set `before_reg`.
|
||||||
|
- **Module pre-hooks** — `on_pre_command` runs first and may `Deny` you.
|
||||||
|
|
||||||
|
Inside `handle`, `params` is the already-split argument list (the trailing
|
||||||
|
`:parameter` is a single element). Return `CmdResult::Ok` / `Fail`.
|
||||||
|
|
||||||
|
## A minimal command
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::command::{CmdResult, Command};
|
||||||
|
use crate::server::Server;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
pub struct Ping2;
|
||||||
|
|
||||||
|
impl Command for Ping2 {
|
||||||
|
fn name(&self) -> &'static str { "PING2" }
|
||||||
|
fn min_params(&self) -> usize { 1 }
|
||||||
|
fn handle(&self, srv: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let nick = srv.users.get(&uid).map(|u| u.nick.clone()).unwrap_or_default();
|
||||||
|
srv.send(uid, format!(":{} PONG {} :{}", srv.name, nick, params[0]));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Replying to the client
|
||||||
|
|
||||||
|
Use the [`Server`](server.md#sending) helpers rather than building raw lines by
|
||||||
|
hand where you can:
|
||||||
|
|
||||||
|
- `srv.numeric(uid, code, rest)` — send a numeric reply (`005`, `461`, …).
|
||||||
|
- `srv.send(uid, line)` — send a fully-formed protocol line.
|
||||||
|
- `srv.fail(uid, command, code, desc)` / `warn(...)` / `note(...)` — IRCv3
|
||||||
|
standard replies (`FAIL` / `WARN` / `NOTE`) for clients that support them.
|
||||||
|
- `srv.snotice(msg)` — a server notice to opers (for staff-facing feedback).
|
||||||
|
|
||||||
|
## Registering
|
||||||
|
|
||||||
|
A module exposes its commands from a `commands()` function returning boxed
|
||||||
|
handlers:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
|
vec![Box::new(Ping2)]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then chain it into `module_commands()` in `src/modules/mod.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn module_commands() -> Vec<Box<dyn Command>> {
|
||||||
|
filter::commands()
|
||||||
|
// …existing chains…
|
||||||
|
.chain(mymod::commands())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Command names are the registry key and must be unique and upper-case. The
|
||||||
|
`abbreviation` feature lets clients invoke a command by a unique prefix, so avoid
|
||||||
|
names that are prefixes of unrelated commands where it matters.
|
||||||
|
|
||||||
|
## Oper-only and gated commands
|
||||||
|
|
||||||
|
There is no separate "oper command" type — gate inside `handle`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn handle(&self, srv: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !srv.is_oper(uid) {
|
||||||
|
srv.numeric(uid, 481, ":Permission Denied- You're not an IRC operator");
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// …privileged work…
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [the `Server` API](server.md) for lookups, permissions, config, and off-core
|
||||||
|
work.
|
||||||
105
docs/api/modes.md
Normal file
105
docs/api/modes.md
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
# Writing modes
|
||||||
|
|
||||||
|
Modes are stateless `&'static` handler objects. The `MODE` parser dispatches each
|
||||||
|
letter to its handler, so adding a mode is a new handler plus one line in a table —
|
||||||
|
you never touch the parser. See the [mode reference](../modes.md) for the existing
|
||||||
|
letters (don't collide).
|
||||||
|
|
||||||
|
## User modes — the `UserMode` trait
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait UserMode: Sync {
|
||||||
|
fn letter(&self) -> char;
|
||||||
|
/// Apply +/- to the user; return true if it took effect (so the change is echoed).
|
||||||
|
fn apply(&self, s: &mut Server, uid: Uid, adding: bool) -> bool;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A user-mode handler is typically a zero-sized struct. Store the actual flag on the
|
||||||
|
user (existing flags live in `UserFlags`; module-specific state goes in
|
||||||
|
[`User.ext`](server.md#per-entity-state)).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct BotMode;
|
||||||
|
impl UserMode for BotMode {
|
||||||
|
fn letter(&self) -> char { 'B' }
|
||||||
|
fn apply(&self, s: &mut Server, uid: Uid, adding: bool) -> bool {
|
||||||
|
match s.users.get_mut(&uid) {
|
||||||
|
Some(u) => { u.flags.bot = adding; true }
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static BOT: BotMode = BotMode;
|
||||||
|
```
|
||||||
|
|
||||||
|
Register it in `src/mode.rs` by adding `&BOT` to the `USER_MODES` slice.
|
||||||
|
|
||||||
|
## Channel modes — the `ChanMode` trait
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait ChanMode: Sync {
|
||||||
|
fn letter(&self) -> char;
|
||||||
|
/// Whether this sign consumes an argument (taken only if one remains).
|
||||||
|
fn wants_param(&self, adding: bool) -> bool;
|
||||||
|
/// A list mode (like +b): a no-arg query is just viewing, so it needn't
|
||||||
|
/// require operator rank. Default false.
|
||||||
|
fn is_list(&self) -> bool { false }
|
||||||
|
/// Apply +/- to channel `key` (display name `chan`) on behalf of `uid`.
|
||||||
|
fn apply(&self, s: &mut Server, chan: &str, key: &str, uid: Uid,
|
||||||
|
adding: bool, param: Option<&str>) -> Applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Applied {
|
||||||
|
No, // nothing to echo (no-op, rejected, or a list query)
|
||||||
|
Yes(Option<String>), // echo the change; Some(param) appends a parameter
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
|
||||||
|
- `key` is the channel's lookup key (lower-cased name); `chan` is the display name.
|
||||||
|
Look the channel up with `s.channels.get_mut(key)`.
|
||||||
|
- `wants_param` controls whether the parser hands you a `param`. Return `true` for
|
||||||
|
a mode that takes an argument (a key, a limit, a mask); parameterless flags
|
||||||
|
return `false`.
|
||||||
|
- Return `Applied::Yes(None)` for a flag that flipped, `Applied::Yes(Some(p))` to
|
||||||
|
echo a parameter (e.g. the limit you set), or `Applied::No` if nothing changed
|
||||||
|
or you rejected it.
|
||||||
|
- **Enforce permissions yourself.** For an ordinary settable mode, check the
|
||||||
|
caller's rank (`s.rank(uid, key) >= RANK_OP`) before applying and reply/refuse
|
||||||
|
if they're not allowed.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct NoCtcp;
|
||||||
|
impl ChanMode for NoCtcp {
|
||||||
|
fn letter(&self) -> char { 'C' }
|
||||||
|
fn wants_param(&self, _adding: bool) -> bool { false }
|
||||||
|
fn apply(&self, s: &mut Server, _chan: &str, key: &str, uid: Uid,
|
||||||
|
adding: bool, _param: Option<&str>) -> Applied {
|
||||||
|
if s.rank(uid, key) < crate::channels::RANK_OP { return Applied::No; }
|
||||||
|
match s.channels.get_mut(key) {
|
||||||
|
Some(c) if c.modes.no_ctcp != adding => { c.modes.no_ctcp = adding; Applied::Yes(None) }
|
||||||
|
_ => Applied::No,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static NO_CTCP: NoCtcp = NoCtcp;
|
||||||
|
```
|
||||||
|
|
||||||
|
Register it in `src/mode.rs` by adding `&NO_CTCP` to the `CHAN_MODES` slice, and
|
||||||
|
add its letter to the `CHANMODES=` group in the ISUPPORT string (`server.rs`) so
|
||||||
|
clients learn about it.
|
||||||
|
|
||||||
|
## List modes
|
||||||
|
|
||||||
|
Set `is_list() -> true` and `wants_param() -> true`. A no-argument use is a list
|
||||||
|
query (rank-free); an argument adds/removes an entry. The built-in list modes
|
||||||
|
(`+b`, `+e`, `+I`, …) share a common `ListMode` handler parameterised by kind —
|
||||||
|
follow that pattern for a new list.
|
||||||
|
|
||||||
|
## Named-mode access
|
||||||
|
|
||||||
|
Every channel mode is also reachable by long name through the `PROP` command
|
||||||
|
(`namedmodes`) without extra work on your part — the mapping is derived from the
|
||||||
|
registered handlers.
|
||||||
111
docs/api/modules.md
Normal file
111
docs/api/modules.md
Normal 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.
|
||||||
138
docs/api/server.md
Normal file
138
docs/api/server.md
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
# The `Server` API
|
||||||
|
|
||||||
|
`Server` is the whole server state, passed as `&mut Server` to every command,
|
||||||
|
mode, and module hook. This is the surface you call from a module. Signatures are
|
||||||
|
abbreviated; see `src/server.rs` for the exact ones.
|
||||||
|
|
||||||
|
## The data model
|
||||||
|
|
||||||
|
- **`Uid`** — an opaque handle for a user (not a pointer). Look users up with it;
|
||||||
|
it can't dangle.
|
||||||
|
- **`srv.users: HashMap<Uid, User>`** — every local and remote user. A `User` has
|
||||||
|
`nick`, `ident`, `host`, `realname`, `account`, `flags`, `ext`, and more.
|
||||||
|
- **`srv.channels: HashMap<String, Channel>`** — keyed by the **lower-cased** name.
|
||||||
|
A `Channel` has its members, `modes` (a `ChanModes` struct), topic, and ban
|
||||||
|
lists.
|
||||||
|
- **`srv.name`, `srv.network`** — this server's name and the network name.
|
||||||
|
- **`srv.conf_path`** — the path to the loaded config file (useful for sibling
|
||||||
|
data files).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let nick = srv.users.get(&uid).map(|u| u.nick.clone());
|
||||||
|
let count = srv.channels.get(&key).map(|c| c.members.len());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Per-entity state
|
||||||
|
|
||||||
|
Attach your own typed state through the `Extensible` typemap instead of adding
|
||||||
|
struct fields. It is keyed by Rust type and dropped automatically with its owner.
|
||||||
|
|
||||||
|
- **`srv.ext`** — per-server state. For **per-channel** state, key your value by
|
||||||
|
channel name inside a map stored here (channels themselves have no `ext`).
|
||||||
|
- **`srv.users[&uid].ext`** — per-user state.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Counter(u32);
|
||||||
|
|
||||||
|
let c = srv.ext.get_or_insert_with::<Counter>(Counter::default);
|
||||||
|
c.0 += 1;
|
||||||
|
|
||||||
|
if let Some(u) = srv.users.get_mut(&uid) {
|
||||||
|
u.ext.set(MyUserState { /* … */ });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Extensible` methods: `get::<T>()`, `get_mut::<T>()`, `set::<T>(v)`,
|
||||||
|
`get_or_insert_with::<T>(f)`, `take::<T>()`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Never hardcode a tunable — read it, with a literal default:
|
||||||
|
|
||||||
|
| Method | Returns |
|
||||||
|
|--------|---------|
|
||||||
|
| `srv.conf(key)` | `Option<&str>` — the single value, if set |
|
||||||
|
| `srv.conf_all(key)` | all values for a repeatable key |
|
||||||
|
| `srv.conf_bool(key, default)` | a `yes`/`on`/`true` flag |
|
||||||
|
| `srv.conf_num(key, default)` | any `FromStr` number |
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let threshold = srv.conf_num("mymod_threshold", 8u32);
|
||||||
|
let enabled = srv.conf_bool("mymod", false);
|
||||||
|
for line in srv.conf_all("mymod_rule") { /* … */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sending
|
||||||
|
|
||||||
|
| Method | Sends |
|
||||||
|
|--------|-------|
|
||||||
|
| `srv.send(uid, line)` | a fully-formed protocol line to one user |
|
||||||
|
| `srv.numeric(uid, code, rest)` | a numeric reply (`005`, `461`, …) |
|
||||||
|
| `srv.fail(uid, cmd, code, desc)` / `warn(...)` / `note(...)` | IRCv3 standard replies |
|
||||||
|
| `srv.to_channel(key, line, except)` | a line to every member (optionally excluding one) |
|
||||||
|
| `srv.snotice(msg)` | a server notice to subscribed opers |
|
||||||
|
| `srv.announce(msg)` | a global notice to all users |
|
||||||
|
| `srv.notify_peers(uid, line, want)` | send to a user's common-channel peers whose caps match |
|
||||||
|
|
||||||
|
```rust
|
||||||
|
srv.numeric(uid, 481, ":Permission Denied- You're not an IRC operator");
|
||||||
|
srv.to_channel(&key, format!(":{} NOTICE {} :hi", srv.name, chan), Some(uid));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lookups & permissions
|
||||||
|
|
||||||
|
| Method | Result |
|
||||||
|
|--------|--------|
|
||||||
|
| `srv.is_oper(uid)` | is the user an IRC operator? |
|
||||||
|
| `srv.rank(uid, key)` | the user's channel rank (compare to `RANK_*`) |
|
||||||
|
| `srv.is_member(uid, key)` | is the user in the channel? |
|
||||||
|
| `srv.extban_active(uid, key, kind)` | does an acting extban of `kind` apply to them here? |
|
||||||
|
|
||||||
|
Rank constants (from `crate::channels`): `RANK_OWNER`, `RANK_ADMIN`, `RANK_OP`,
|
||||||
|
`RANK_HALFOP`, `RANK_VOICE`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if srv.rank(uid, &key) >= crate::channels::RANK_OP { /* ops-only */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mutating users
|
||||||
|
|
||||||
|
| Method | Effect |
|
||||||
|
|--------|--------|
|
||||||
|
| `srv.change_host_ident(uid, new_ident, new_host)` | change a user's displayed ident/host (drives `chghost`) |
|
||||||
|
| `srv.oper_up(uid)` | mark a user as an operator |
|
||||||
|
| `srv.remove_user(uid, reason)` | disconnect a user cleanly |
|
||||||
|
|
||||||
|
## Off-core work
|
||||||
|
|
||||||
|
The core thread must never block. Offload slow work:
|
||||||
|
|
||||||
|
- **`srv.disk_write(path, contents)`** — fire-and-forget, coalescing, atomic
|
||||||
|
(temp + rename) file write. Use this for saving state; a slow disk can't stall
|
||||||
|
the core. **No result to handle** — the simplest offload.
|
||||||
|
- **`srv.spawn_crypto(closure)`** — run a bounded, CPU-heavy job (a KDF hash) on a
|
||||||
|
worker thread. Returns `false` if at capacity.
|
||||||
|
- **`srv.spawn_http(...)`** — an outbound HTTP request on a worker thread.
|
||||||
|
|
||||||
|
`spawn_crypto` / `spawn_http` deliver their result back as a core `Event`, so
|
||||||
|
wiring a *new* async result type touches the core event enum in `src/ircd.rs`;
|
||||||
|
`disk_write` needs nothing extra. Prefer `disk_write` for persistence and
|
||||||
|
`on_tick` for periodic jobs.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// good: persist off-core from a hook
|
||||||
|
srv.disk_write(format!("{}.mystore", srv.conf_path), store.serialize());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Time
|
||||||
|
|
||||||
|
`crate::server::now()` → unix seconds. `iso_time(secs)` / `parse_iso(s)` convert to
|
||||||
|
and from ISO-8601 (used for `server-time` tags and timestamps).
|
||||||
|
|
||||||
|
## Rules of the road
|
||||||
|
|
||||||
|
- Do the work synchronously and quickly; **never block** — offload instead.
|
||||||
|
- Reference users/channels by `Uid` / key; don't cache references across events.
|
||||||
|
- Keep state in `ext`, read settings via `conf*`, and register in the tables. See
|
||||||
|
the [conventions](README.md#project-conventions).
|
||||||
Loading…
Add table
Add a link
Reference in a new issue