Compare commits
11 commits
facbde55bd
...
7c73371d64
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c73371d64 | |||
| 0094465ca9 | |||
| d9e279d8ed | |||
| b312ff0338 | |||
| f3cb9a6f30 | |||
| ea26e25546 | |||
| 33fbe7d5fc | |||
| c522e3aa3e | |||
| eb3b8fec40 | |||
| e7e805cb87 | |||
| ef80b77760 |
28 changed files with 2300 additions and 929 deletions
24
.forgejo/workflows/ci.yml
Normal file
24
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: ci
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: system deps
|
||||||
|
run: apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev curl ca-certificates build-essential
|
||||||
|
- name: install rust
|
||||||
|
run: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal -c clippy -c rustfmt
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: test
|
||||||
|
run: cargo test --verbose
|
||||||
|
- name: format
|
||||||
|
continue-on-error: true
|
||||||
|
run: cargo fmt --check
|
||||||
|
- name: clippy
|
||||||
|
continue-on-error: true
|
||||||
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
/target
|
/target
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
rustbot.service
|
rustbot.service
|
||||||
rustbot.conf
|
rustbot.conf
|
||||||
|
weather.*.json
|
||||||
11
Cargo.toml
11
Cargo.toml
|
|
@ -6,13 +6,20 @@ description = "A modular, dependency-free IRC bot in Rust with a hand-rolled IRC
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["a.srenov"]
|
authors = ["a.srenov"]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "rustbot"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "rustbot"
|
name = "rustbot"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
# Intentionally dependency-free. The IRC protocol layer lives in `src/irc.rs`
|
# The IRC protocol layer is hand-rolled in `src/irc/` (modern IRC client
|
||||||
# and follows the modern IRC client spec: https://modern.ircdocs.horse/
|
# spec: https://modern.ircdocs.horse/). The sole dependency is native-tls for
|
||||||
|
# the optional TLS transport; it wraps the system OpenSSL, so TLS security
|
||||||
|
# fixes arrive through normal OS updates rather than a vendored crypto crate.
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
native-tls = "0.2"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|
|
||||||
118
README.md
118
README.md
|
|
@ -5,20 +5,66 @@ protocol layer built to the [modern IRC client spec](https://modern.ircdocs.hors
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
| File | Layer | Responsibility |
|
rustbot is a **library crate** (`src/lib.rs`) with a thin binary on top:
|
||||||
|---------------|------------------|-----------------------------------------------------------|
|
|
||||||
| `src/irc.rs` | protocol + I/O | Parse/serialize IRC messages (incl. IRCv3 tags), TCP transport |
|
|
||||||
| `src/bot.rs` | behavior | Registration, event loop, command handling |
|
|
||||||
| `src/main.rs` | wiring | Config from env vars, connect/reconnect supervisor |
|
|
||||||
|
|
||||||
The layers are deliberately separable: `irc.rs` knows nothing about the bot, and
|
| Path | Layer | Responsibility |
|
||||||
`bot.rs` knows nothing about how the process is launched.
|
|-----------------|----------------|-------------------------------------------------------------------|
|
||||||
|
| `src/irc/` | protocol + I/O | `message` (parse IRCv3 wire format), `connection` (TCP/TLS transport), `encoding` (base64, server-time) |
|
||||||
|
| `src/bot/` | behavior | `mod` (state + loop), `caps` (IRCv3 + SASL), `commands` (routing + help), `module` (plugin trait), `modules/` (feature modules) |
|
||||||
|
| `src/config.rs` | configuration | Layered `Config`; multi-network `[section]` parsing |
|
||||||
|
| `src/lib.rs` | crate root | Ties the modules into the reusable `rustbot` library |
|
||||||
|
| `src/main.rs` | binary | Thin per-network connect/reconnect supervisor |
|
||||||
|
| `tests/` | tests | Integration tests (`config.rs`, `protocol.rs`) against the public API |
|
||||||
|
|
||||||
|
The layers are deliberately separable: `irc` knows nothing about the bot, `bot`
|
||||||
|
knows nothing about how the process is launched, and `main` only supervises.
|
||||||
|
Adding a feature is a new **module** — see [Modules](#modules).
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
Features are **modules**: self-contained units behind the `Module` trait
|
||||||
|
(`src/bot/module.rs`). A module declares the commands it provides and turns each
|
||||||
|
invocation into `Action`s — it never touches the socket, so it stays pure and
|
||||||
|
unit-testable:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Module {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
fn commands(&self) -> &'static [CommandSpec];
|
||||||
|
fn on_command(&mut self, cmd: &Command) -> Vec<Action>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
At startup the bot builds a `command → module` route table and auto-generates
|
||||||
|
`help` from every module's `CommandSpec`s. Each network gets its own fresh set
|
||||||
|
of modules, so stateful ones (like the dice roller's PRNG) keep per-network
|
||||||
|
state with no locking.
|
||||||
|
|
||||||
|
**To add a module**, create `src/bot/modules/<name>.rs` implementing `Module`,
|
||||||
|
then add one line to `all()` in `src/bot/modules/mod.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn all() -> Vec<Box<dyn Module>> {
|
||||||
|
vec![
|
||||||
|
Box::new(builtins::Builtins),
|
||||||
|
Box::new(dice::Dice::new()),
|
||||||
|
Box::new(myfeature::MyFeature::new()), // <- your module
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Shipped modules: **`builtins`** (`ping`, `echo`, `hello`), **`dice`**
|
||||||
|
(`roll [NdM]`), **`hash`** (`md5`/`sha1`/`sha256`/`hash <text>`, hand-rolled),
|
||||||
|
and **`weather`** (`add <location>` then `w [location]` via wttr.in; per-user
|
||||||
|
locations saved to a per-network JSON file, path from `RUSTBOT_DATA_DIR`).
|
||||||
|
Module tests live in `tests/` — construct a module and assert on the `Action`s
|
||||||
|
it returns, no network required.
|
||||||
|
|
||||||
## Build & run
|
## Build & run
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo build # or: cargo build --release
|
cargo build # or: cargo build --release
|
||||||
cargo test # unit tests for the message parser
|
cargo test # integration tests in tests/ (config + protocol)
|
||||||
cargo run
|
cargo run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -26,7 +72,7 @@ cargo run
|
||||||
|
|
||||||
Settings are layered, each overriding the previous:
|
Settings are layered, each overriding the previous:
|
||||||
|
|
||||||
1. **Built-in defaults** (`Config::default()` in `src/bot.rs`)
|
1. **Built-in defaults** (`Config::default()` in `src/config.rs`)
|
||||||
2. **A config file** — `key = value`, see [`rustbot.conf`](rustbot.conf)
|
2. **A config file** — `key = value`, see [`rustbot.conf`](rustbot.conf)
|
||||||
3. **Environment variables** — handy for one-off overrides
|
3. **Environment variables** — handy for one-off overrides
|
||||||
|
|
||||||
|
|
@ -39,12 +85,42 @@ cargo run -- /etc/rustbot.conf # explicit path
|
||||||
RUSTBOT_CONFIG=/etc/rustbot.conf cargo run
|
RUSTBOT_CONFIG=/etc/rustbot.conf cargo run
|
||||||
```
|
```
|
||||||
|
|
||||||
Config file keys: `server`, `port`, `nick`, `user`, `realname`,
|
Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`,
|
||||||
`channels` (comma/space-separated), `prefix`, `password`.
|
`channels` (comma/space-separated), `prefix`, `password`, `sasl_user`,
|
||||||
Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s.
|
`sasl_pass`, and `name` (a network's log label). Whole-line `#`/`;` comments
|
||||||
|
only — inline comments would clash with channel `#`s.
|
||||||
|
|
||||||
Environment overrides: `IRC_SERVER`, `IRC_PORT`, `IRC_NICK`, `IRC_REALNAME`,
|
### Multiple networks
|
||||||
`IRC_CHANNELS`, `IRC_PREFIX`, `IRC_PASSWORD`.
|
|
||||||
|
Group keys under `[name]` section headers to connect to several networks at
|
||||||
|
once — one supervisor thread each, with independent reconnect/backoff. Keys
|
||||||
|
*before* the first header are shared defaults every network inherits; each
|
||||||
|
section overrides them. A file with no headers is a single network (the
|
||||||
|
historical behaviour), and its logs stay unprefixed.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# shared defaults, inherited by every network below
|
||||||
|
nick = rubot
|
||||||
|
realname = rubot
|
||||||
|
|
||||||
|
[tchatou]
|
||||||
|
server = irc.tchatou.fr
|
||||||
|
tls = true
|
||||||
|
channels = #devs
|
||||||
|
|
||||||
|
[libera]
|
||||||
|
server = irc.libera.chat
|
||||||
|
tls = true
|
||||||
|
channels = #rust, #rust-beginners
|
||||||
|
nick = rubot_ # override just for this network
|
||||||
|
```
|
||||||
|
|
||||||
|
With more than one network, each network's log lines are tagged with its
|
||||||
|
section name (`[libera] << …`) so the interleaved output stays readable.
|
||||||
|
|
||||||
|
Environment overrides: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`,
|
||||||
|
`IRC_REALNAME`, `IRC_CHANNELS`, `IRC_PREFIX`, `IRC_PASSWORD`,
|
||||||
|
`IRC_SASL_USER`, `IRC_SASL_PASS`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
IRC_NICK=mybot IRC_CHANNELS='#test' cargo run # override the file for one run
|
IRC_NICK=mybot IRC_CHANNELS='#test' cargo run # override the file for one run
|
||||||
|
|
@ -101,9 +177,19 @@ Two of these do real work:
|
||||||
`sasl_user`/`sasl_pass` in the config (or `IRC_SASL_USER`/`IRC_SASL_PASS`).
|
`sasl_user`/`sasl_pass` in the config (or `IRC_SASL_USER`/`IRC_SASL_PASS`).
|
||||||
base64 is hand-rolled, so this stays dependency-free.
|
base64 is hand-rolled, so this stays dependency-free.
|
||||||
|
|
||||||
|
## TLS
|
||||||
|
|
||||||
|
Set `tls = true` and the port defaults to 6697. The transport wraps the socket
|
||||||
|
with `native-tls`, which verifies certificates against the **system OpenSSL
|
||||||
|
trust store** — so TLS security fixes arrive via OS updates rather than a
|
||||||
|
vendored crypto crate. Plaintext still works with `tls = false` / `port = 6667`.
|
||||||
|
|
||||||
|
Plain TCP and TLS share one code path: `Connection` holds a single
|
||||||
|
`Box<dyn Read + Write>`, since the single-threaded event loop never needs the
|
||||||
|
old read/write socket split (which TLS can't do anyway).
|
||||||
|
|
||||||
## Roadmap / natural next steps
|
## Roadmap / natural next steps
|
||||||
|
|
||||||
- **TLS** (port 6697, or the `tls` STARTTLS cap) — the one remaining item that
|
|
||||||
needs a dependency (`rustls`/`native-tls`).
|
|
||||||
- **Read timeout + client-sent PING** to detect dead connections faster.
|
- **Read timeout + client-sent PING** to detect dead connections faster.
|
||||||
|
- **STARTTLS** (the `tls` cap) for networks without a dedicated TLS port.
|
||||||
- Split commands into a registry/trait once there are many of them.
|
- Split commands into a registry/trait once there are many of them.
|
||||||
|
|
|
||||||
343
src/bot.rs
343
src/bot.rs
|
|
@ -1,343 +0,0 @@
|
||||||
//! Bot behavior: configuration, registration, the event loop, and commands.
|
|
||||||
//!
|
|
||||||
//! This layer is transport-agnostic in spirit: it drives a [`Connection`] from
|
|
||||||
//! `irc.rs` and turns incoming [`Message`]s into actions. To add a feature you
|
|
||||||
//! usually only touch [`Bot::handle`] or [`Bot::handle_command`].
|
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::io;
|
|
||||||
|
|
||||||
use crate::irc::{base64_encode, now_unix, parse_server_time, Connection, Message};
|
|
||||||
|
|
||||||
/// IRCv3 capabilities we request when the server offers them. `sasl` is added
|
|
||||||
/// separately, only when credentials are configured.
|
|
||||||
const WANTED_CAPS: &[&str] = &[
|
|
||||||
"message-tags",
|
|
||||||
"server-time",
|
|
||||||
"batch",
|
|
||||||
"echo-message",
|
|
||||||
"account-tag",
|
|
||||||
"extended-join",
|
|
||||||
"multi-prefix",
|
|
||||||
"away-notify",
|
|
||||||
"chghost",
|
|
||||||
"setname",
|
|
||||||
"userhost-in-names",
|
|
||||||
"cap-notify",
|
|
||||||
"labeled-response",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// A message whose `server-time` is older than this is treated as replayed
|
|
||||||
/// history. Wide enough to absorb network latency and modest clock skew, but
|
|
||||||
/// far below the age of real chat history (minutes to weeks).
|
|
||||||
const HISTORY_GRACE_SECS: u64 = 60;
|
|
||||||
|
|
||||||
/// Everything the bot needs to know to connect and behave.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Config {
|
|
||||||
pub server: String,
|
|
||||||
pub port: u16,
|
|
||||||
pub nick: String,
|
|
||||||
pub user: String,
|
|
||||||
pub realname: String,
|
|
||||||
pub channels: Vec<String>,
|
|
||||||
/// Prefix that marks a chat message as a bot command, e.g. `!`.
|
|
||||||
pub command_prefix: String,
|
|
||||||
/// Optional server password (`PASS`). Not the same as SASL/NickServ.
|
|
||||||
pub password: Option<String>,
|
|
||||||
/// SASL PLAIN credentials. When both are set (and the server offers `sasl`),
|
|
||||||
/// the bot authenticates during capability negotiation.
|
|
||||||
pub sasl_user: Option<String>,
|
|
||||||
pub sasl_pass: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Config {
|
|
||||||
fn default() -> Config {
|
|
||||||
Config {
|
|
||||||
server: "irc.tchatou.fr".to_string(),
|
|
||||||
port: 6667,
|
|
||||||
nick: "rubot".to_string(),
|
|
||||||
user: "rubot".to_string(),
|
|
||||||
realname: "rubot".to_string(),
|
|
||||||
channels: vec!["#devs".to_string()],
|
|
||||||
command_prefix: "!".to_string(),
|
|
||||||
password: None,
|
|
||||||
sasl_user: None,
|
|
||||||
sasl_pass: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The bot: owns its configuration and connection, and runs one session.
|
|
||||||
pub struct Bot {
|
|
||||||
config: Config,
|
|
||||||
conn: Connection,
|
|
||||||
/// The nick we are currently trying to use (may drift from `config.nick`
|
|
||||||
/// if the server reports a collision).
|
|
||||||
current_nick: String,
|
|
||||||
registered: bool,
|
|
||||||
/// Capabilities the server advertised in `CAP LS`.
|
|
||||||
available_caps: HashSet<String>,
|
|
||||||
/// Capabilities we successfully negotiated (`CAP ACK`).
|
|
||||||
enabled_caps: HashSet<String>,
|
|
||||||
/// Open IRCv3 batches: reference -> batch type (e.g. `chathistory`).
|
|
||||||
batches: HashMap<String, String>,
|
|
||||||
/// Whether the SASL exchange has begun this session.
|
|
||||||
sasl_started: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Bot {
|
|
||||||
pub fn new(config: Config, conn: Connection) -> Bot {
|
|
||||||
let current_nick = config.nick.clone();
|
|
||||||
Bot {
|
|
||||||
config,
|
|
||||||
conn,
|
|
||||||
current_nick,
|
|
||||||
registered: false,
|
|
||||||
available_caps: HashSet::new(),
|
|
||||||
enabled_caps: HashSet::new(),
|
|
||||||
batches: HashMap::new(),
|
|
||||||
sasl_started: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register with the server, then process messages until disconnect.
|
|
||||||
///
|
|
||||||
/// Returns `Ok(())` when the server closes the connection cleanly; the
|
|
||||||
/// caller (main) decides whether to reconnect.
|
|
||||||
pub fn run(&mut self) -> io::Result<()> {
|
|
||||||
self.register()?;
|
|
||||||
while let Some(msg) = self.conn.read_message()? {
|
|
||||||
self.handle(&msg)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Begin registration: open IRCv3 capability negotiation, then send
|
|
||||||
/// NICK/USER. The server withholds `001` until we send `CAP END`, which
|
|
||||||
/// happens once caps (and optional SASL) are settled — see [`Bot::handle`].
|
|
||||||
fn register(&mut self) -> io::Result<()> {
|
|
||||||
if let Some(password) = self.config.password.clone() {
|
|
||||||
self.conn.pass(&password)?;
|
|
||||||
}
|
|
||||||
self.conn.cap_ls()?;
|
|
||||||
let nick = self.current_nick.clone();
|
|
||||||
self.conn.nick(&nick)?;
|
|
||||||
let (user, realname) = (self.config.user.clone(), self.config.realname.clone());
|
|
||||||
self.conn.user(&user, &realname)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch a single incoming message.
|
|
||||||
fn handle(&mut self, msg: &Message) -> io::Result<()> {
|
|
||||||
match msg.command.as_str() {
|
|
||||||
// Keep-alive: reply immediately or the server will time us out.
|
|
||||||
"PING" => {
|
|
||||||
let token = msg.trailing().unwrap_or("").to_string();
|
|
||||||
self.conn.pong(&token)?;
|
|
||||||
}
|
|
||||||
// IRCv3 capability negotiation and SASL.
|
|
||||||
"CAP" => self.handle_cap(msg)?,
|
|
||||||
"AUTHENTICATE" => self.handle_authenticate(msg)?,
|
|
||||||
"903" => self.conn.cap_end()?, // RPL_SASLSUCCESS
|
|
||||||
// SASL failed/aborted/locked/too-long/already-authed — proceed anyway.
|
|
||||||
"902" | "904" | "905" | "906" | "907" => self.conn.cap_end()?,
|
|
||||||
// Track open/closed batches so we can recognise replayed history.
|
|
||||||
"BATCH" => self.handle_batch(msg),
|
|
||||||
// RPL_WELCOME: registration succeeded — now it's safe to join.
|
|
||||||
"001" => {
|
|
||||||
self.registered = true;
|
|
||||||
for channel in self.config.channels.clone() {
|
|
||||||
self.conn.join(&channel)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ERR_NICKNAMEINUSE: append an underscore and try again.
|
|
||||||
"433" => {
|
|
||||||
self.current_nick.push('_');
|
|
||||||
let nick = self.current_nick.clone();
|
|
||||||
self.conn.nick(&nick)?;
|
|
||||||
}
|
|
||||||
"PRIVMSG" => self.handle_privmsg(msg)?,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a `CAP` reply: collect advertised caps, request the ones we want,
|
|
||||||
/// and record what was acknowledged.
|
|
||||||
fn handle_cap(&mut self, msg: &Message) -> io::Result<()> {
|
|
||||||
// :server CAP <target> <subcommand> [*] :<space-separated caps>
|
|
||||||
match msg.param(1) {
|
|
||||||
Some("LS") => {
|
|
||||||
// A "*" in the third field means the list continues in more lines.
|
|
||||||
let more = msg.param(2) == Some("*");
|
|
||||||
for cap in msg.trailing().unwrap_or("").split_whitespace() {
|
|
||||||
// Caps may be advertised as "name=value" (e.g. sasl=PLAIN).
|
|
||||||
let name = cap.split('=').next().unwrap_or(cap);
|
|
||||||
self.available_caps.insert(name.to_string());
|
|
||||||
}
|
|
||||||
if !more {
|
|
||||||
self.request_caps()?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some("ACK") => {
|
|
||||||
for cap in msg.trailing().unwrap_or("").split_whitespace() {
|
|
||||||
self.enabled_caps
|
|
||||||
.insert(cap.trim_start_matches(['-', '~', '=']).to_string());
|
|
||||||
}
|
|
||||||
self.after_cap_ack()?;
|
|
||||||
}
|
|
||||||
Some("NAK") => self.conn.cap_end()?, // requested caps refused; carry on
|
|
||||||
_ => {} // NEW/DEL: ignored for now
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request the subset of [`WANTED_CAPS`] the server actually offers, plus
|
|
||||||
/// `sasl` when credentials are configured. Ends negotiation if none apply.
|
|
||||||
fn request_caps(&mut self) -> io::Result<()> {
|
|
||||||
let mut wanted: Vec<&str> = WANTED_CAPS
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|c| self.available_caps.contains(*c))
|
|
||||||
.collect();
|
|
||||||
if self.sasl_credentials().is_some() && self.available_caps.contains("sasl") {
|
|
||||||
wanted.push("sasl");
|
|
||||||
}
|
|
||||||
if wanted.is_empty() {
|
|
||||||
self.conn.cap_end()
|
|
||||||
} else {
|
|
||||||
self.conn.cap_req(&wanted.join(" "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// After caps are acknowledged, either start SASL or finish negotiation.
|
|
||||||
fn after_cap_ack(&mut self) -> io::Result<()> {
|
|
||||||
if self.enabled_caps.contains("sasl")
|
|
||||||
&& self.sasl_credentials().is_some()
|
|
||||||
&& !self.sasl_started
|
|
||||||
{
|
|
||||||
self.sasl_started = true;
|
|
||||||
self.conn.authenticate("PLAIN") // server replies with "AUTHENTICATE +"
|
|
||||||
} else {
|
|
||||||
self.conn.cap_end()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Respond to the server's SASL prompt with base64 PLAIN credentials.
|
|
||||||
fn handle_authenticate(&mut self, msg: &Message) -> io::Result<()> {
|
|
||||||
if msg.param(0) == Some("+") {
|
|
||||||
if let Some((user, pass)) = self.sasl_credentials() {
|
|
||||||
// SASL PLAIN payload: authzid \0 authcid \0 passwd (authzid empty).
|
|
||||||
let encoded = base64_encode(format!("\0{user}\0{pass}").as_bytes());
|
|
||||||
self.conn.authenticate(&encoded)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Track IRCv3 batches so [`Bot::is_historical`] can spot replayed history.
|
|
||||||
fn handle_batch(&mut self, msg: &Message) {
|
|
||||||
// :server BATCH +<ref> <type> [params] opens; BATCH -<ref> closes.
|
|
||||||
let Some(reference) = msg.param(0) else { return };
|
|
||||||
if let Some(name) = reference.strip_prefix('+') {
|
|
||||||
self.batches
|
|
||||||
.insert(name.to_string(), msg.param(1).unwrap_or("").to_string());
|
|
||||||
} else if let Some(name) = reference.strip_prefix('-') {
|
|
||||||
self.batches.remove(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sasl_credentials(&self) -> Option<(String, String)> {
|
|
||||||
match (&self.config.sasl_user, &self.config.sasl_pass) {
|
|
||||||
(Some(u), Some(p)) => Some((u.clone(), p.clone())),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Is this message replayed history rather than something happening now?
|
|
||||||
/// Two independent signals: membership in a chathistory/playback BATCH, or a
|
|
||||||
/// `server-time` tag older than [`HISTORY_GRACE_SECS`].
|
|
||||||
fn is_historical(&self, msg: &Message) -> bool {
|
|
||||||
if let Some(reference) = msg.tag("batch") {
|
|
||||||
if let Some(btype) = self.batches.get(reference) {
|
|
||||||
if btype.contains("chathistory") || btype.contains("playback") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(stamp) = msg.tag("time").and_then(parse_server_time) {
|
|
||||||
if now_unix().saturating_sub(stamp) > HISTORY_GRACE_SECS {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a channel or private message, routing recognised commands.
|
|
||||||
fn handle_privmsg(&mut self, msg: &Message) -> io::Result<()> {
|
|
||||||
// Ignore replayed channel history (InspIRCd's on-join playback), or the
|
|
||||||
// bot would re-answer old commands on every reconnect.
|
|
||||||
if self.is_historical(msg) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(target) = msg.param(0) else { return Ok(()) };
|
|
||||||
let text = msg.trailing().unwrap_or("");
|
|
||||||
let sender = msg.nick().unwrap_or("");
|
|
||||||
|
|
||||||
// Never react to our own messages. Some networks (like this one) echo
|
|
||||||
// channel messages back to the sender; without this guard a command
|
|
||||||
// whose reply starts with the prefix could trigger the bot on itself.
|
|
||||||
if sender.eq_ignore_ascii_case(&self.current_nick) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reply in-channel for channel messages, or back to the user for DMs.
|
|
||||||
let reply_to = if is_channel(target) {
|
|
||||||
target.to_string()
|
|
||||||
} else {
|
|
||||||
sender.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only react to text that starts with the configured command prefix.
|
|
||||||
let Some(rest) = text.strip_prefix(&self.config.command_prefix) else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut parts = rest.split_whitespace();
|
|
||||||
let command = parts.next().unwrap_or("").to_lowercase();
|
|
||||||
let args: Vec<&str> = parts.collect();
|
|
||||||
self.handle_command(&reply_to, sender, &command, &args)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The command table. Add new bot commands here.
|
|
||||||
fn handle_command(
|
|
||||||
&mut self,
|
|
||||||
reply_to: &str,
|
|
||||||
sender: &str,
|
|
||||||
command: &str,
|
|
||||||
args: &[&str],
|
|
||||||
) -> io::Result<()> {
|
|
||||||
match command {
|
|
||||||
"ping" => self.conn.privmsg(reply_to, "pong")?,
|
|
||||||
"echo" => self.conn.privmsg(reply_to, &args.join(" "))?,
|
|
||||||
"hello" => {
|
|
||||||
let text = format!("hello, {sender}!");
|
|
||||||
self.conn.privmsg(reply_to, &text)?;
|
|
||||||
}
|
|
||||||
"help" => {
|
|
||||||
let p = &self.config.command_prefix;
|
|
||||||
let text = format!("commands: {p}ping, {p}echo <text>, {p}hello, {p}help");
|
|
||||||
self.conn.privmsg(reply_to, &text)?;
|
|
||||||
}
|
|
||||||
_ => {} // Unknown command: stay silent rather than spam channels.
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Channels start with one of the standard channel-type prefixes.
|
|
||||||
fn is_channel(target: &str) -> bool {
|
|
||||||
target.starts_with(['#', '&', '+', '!'])
|
|
||||||
}
|
|
||||||
92
src/bot/caps.rs
Normal file
92
src/bot/caps.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use super::Bot;
|
||||||
|
use crate::irc::{base64_encode, Message};
|
||||||
|
|
||||||
|
const WANTED_CAPS: &[&str] = &[
|
||||||
|
"message-tags",
|
||||||
|
"server-time",
|
||||||
|
"batch",
|
||||||
|
"echo-message",
|
||||||
|
"account-tag",
|
||||||
|
"extended-join",
|
||||||
|
"multi-prefix",
|
||||||
|
"away-notify",
|
||||||
|
"chghost",
|
||||||
|
"setname",
|
||||||
|
"userhost-in-names",
|
||||||
|
"cap-notify",
|
||||||
|
"labeled-response",
|
||||||
|
];
|
||||||
|
|
||||||
|
impl Bot {
|
||||||
|
pub(super) fn handle_cap(&mut self, msg: &Message) -> io::Result<()> {
|
||||||
|
match msg.param(1) {
|
||||||
|
Some("LS") => {
|
||||||
|
let more = msg.param(2) == Some("*");
|
||||||
|
for cap in msg.trailing().unwrap_or("").split_whitespace() {
|
||||||
|
let name = cap.split('=').next().unwrap_or(cap);
|
||||||
|
self.available_caps.insert(name.to_string());
|
||||||
|
}
|
||||||
|
if !more {
|
||||||
|
self.request_caps()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("ACK") => {
|
||||||
|
for cap in msg.trailing().unwrap_or("").split_whitespace() {
|
||||||
|
self.enabled_caps
|
||||||
|
.insert(cap.trim_start_matches(['-', '~', '=']).to_string());
|
||||||
|
}
|
||||||
|
self.after_cap_ack()?;
|
||||||
|
}
|
||||||
|
Some("NAK") => self.conn.cap_end()?,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_caps(&mut self) -> io::Result<()> {
|
||||||
|
let mut wanted: Vec<&str> = WANTED_CAPS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|c| self.available_caps.contains(*c))
|
||||||
|
.collect();
|
||||||
|
if self.sasl_credentials().is_some() && self.available_caps.contains("sasl") {
|
||||||
|
wanted.push("sasl");
|
||||||
|
}
|
||||||
|
if wanted.is_empty() {
|
||||||
|
self.conn.cap_end()
|
||||||
|
} else {
|
||||||
|
self.conn.cap_req(&wanted.join(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn after_cap_ack(&mut self) -> io::Result<()> {
|
||||||
|
if self.enabled_caps.contains("sasl")
|
||||||
|
&& self.sasl_credentials().is_some()
|
||||||
|
&& !self.sasl_started
|
||||||
|
{
|
||||||
|
self.sasl_started = true;
|
||||||
|
self.conn.authenticate("PLAIN")
|
||||||
|
} else {
|
||||||
|
self.conn.cap_end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_authenticate(&mut self, msg: &Message) -> io::Result<()> {
|
||||||
|
if msg.param(0) == Some("+") {
|
||||||
|
if let Some((user, pass)) = self.sasl_credentials() {
|
||||||
|
let encoded = base64_encode(format!("\0{user}\0{pass}").as_bytes());
|
||||||
|
self.conn.authenticate(&encoded)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sasl_credentials(&self) -> Option<(String, String)> {
|
||||||
|
match (&self.config.sasl_user, &self.config.sasl_pass) {
|
||||||
|
(Some(u), Some(p)) => Some((u.clone(), p.clone())),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/bot/commands.rs
Normal file
130
src/bot/commands.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use super::module::{Action, Command};
|
||||||
|
use super::Bot;
|
||||||
|
use crate::irc::{now_unix, parse_server_time, Message};
|
||||||
|
|
||||||
|
const HISTORY_GRACE_SECS: u64 = 60;
|
||||||
|
|
||||||
|
impl Bot {
|
||||||
|
pub(super) fn handle_batch(&mut self, msg: &Message) {
|
||||||
|
let Some(reference) = msg.param(0) else { return };
|
||||||
|
if let Some(name) = reference.strip_prefix('+') {
|
||||||
|
self.batches
|
||||||
|
.insert(name.to_string(), msg.param(1).unwrap_or("").to_string());
|
||||||
|
} else if let Some(name) = reference.strip_prefix('-') {
|
||||||
|
self.batches.remove(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_historical(&self, msg: &Message) -> bool {
|
||||||
|
if let Some(reference) = msg.tag("batch") {
|
||||||
|
if let Some(btype) = self.batches.get(reference) {
|
||||||
|
if btype.contains("chathistory") || btype.contains("playback") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(stamp) = msg.tag("time").and_then(parse_server_time) {
|
||||||
|
if now_unix().saturating_sub(stamp) > HISTORY_GRACE_SECS {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_privmsg(&mut self, msg: &Message) -> io::Result<()> {
|
||||||
|
if self.is_historical(msg) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(target) = msg.param(0) else { return Ok(()) };
|
||||||
|
let text = msg.trailing().unwrap_or("");
|
||||||
|
let sender = msg.nick().unwrap_or("");
|
||||||
|
|
||||||
|
if sender.eq_ignore_ascii_case(&self.current_nick) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let reply_to = if is_channel(target) {
|
||||||
|
target.to_string()
|
||||||
|
} else {
|
||||||
|
sender.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(rest) = text.strip_prefix(&self.config.command_prefix) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parts = rest.split_whitespace();
|
||||||
|
let command = parts.next().unwrap_or("").to_lowercase();
|
||||||
|
let args: Vec<&str> = parts.collect();
|
||||||
|
self.handle_command(&reply_to, sender, &command, &args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_command(
|
||||||
|
&mut self,
|
||||||
|
reply_to: &str,
|
||||||
|
sender: &str,
|
||||||
|
command: &str,
|
||||||
|
args: &[&str],
|
||||||
|
) -> io::Result<()> {
|
||||||
|
if command == "help" {
|
||||||
|
let text = self.help_text(args.first().copied());
|
||||||
|
return self.conn.privmsg(reply_to, &text);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(&idx) = self.routes.get(command) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = Command {
|
||||||
|
sender,
|
||||||
|
reply_to,
|
||||||
|
name: command,
|
||||||
|
args,
|
||||||
|
prefix: &self.config.command_prefix,
|
||||||
|
network: &self.config.name,
|
||||||
|
};
|
||||||
|
let actions = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
self.modules[idx].on_command(&cmd)
|
||||||
|
})) {
|
||||||
|
Ok(actions) => actions,
|
||||||
|
Err(_) => {
|
||||||
|
crate::log(&format!("module panicked on '{command}'"));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for action in actions {
|
||||||
|
match action {
|
||||||
|
Action::Reply(text) => self.conn.privmsg(reply_to, &text)?,
|
||||||
|
Action::Raw(line) => self.conn.send_raw(&line)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn help_text(&self, arg: Option<&str>) -> String {
|
||||||
|
let p = &self.config.command_prefix;
|
||||||
|
if let Some(name) = arg {
|
||||||
|
for m in &self.modules {
|
||||||
|
if let Some(spec) = m.commands().iter().find(|s| s.name == name) {
|
||||||
|
return format!("{p}{}: {}", spec.usage, spec.about);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return format!("no such command '{name}'; try {p}help");
|
||||||
|
}
|
||||||
|
let mut items: Vec<String> = Vec::new();
|
||||||
|
for m in &self.modules {
|
||||||
|
for spec in m.commands() {
|
||||||
|
items.push(format!("{p}{}", spec.usage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.push(format!("{p}help"));
|
||||||
|
format!("commands: {}", items.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_channel(target: &str) -> bool {
|
||||||
|
target.starts_with(['#', '&', '+', '!'])
|
||||||
|
}
|
||||||
106
src/bot/mod.rs
Normal file
106
src/bot/mod.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::irc::{Connection, Message};
|
||||||
|
|
||||||
|
mod caps;
|
||||||
|
mod commands;
|
||||||
|
pub mod module;
|
||||||
|
pub mod modules;
|
||||||
|
|
||||||
|
pub use module::{Action, Command, CommandSpec, Module};
|
||||||
|
|
||||||
|
pub struct Bot {
|
||||||
|
config: Config,
|
||||||
|
conn: Connection,
|
||||||
|
current_nick: String,
|
||||||
|
registered: bool,
|
||||||
|
available_caps: HashSet<String>,
|
||||||
|
enabled_caps: HashSet<String>,
|
||||||
|
batches: HashMap<String, String>,
|
||||||
|
sasl_started: bool,
|
||||||
|
modules: Vec<Box<dyn Module>>,
|
||||||
|
routes: HashMap<&'static str, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bot {
|
||||||
|
pub fn new(config: Config, conn: Connection) -> Bot {
|
||||||
|
let current_nick = config.nick.clone();
|
||||||
|
|
||||||
|
let mods = modules::all(&config.name);
|
||||||
|
let mut routes: HashMap<&'static str, usize> = HashMap::new();
|
||||||
|
for (i, m) in mods.iter().enumerate() {
|
||||||
|
for spec in m.commands() {
|
||||||
|
if routes.insert(spec.name, i).is_some() {
|
||||||
|
crate::log(&format!(
|
||||||
|
"module '{}' overrides command '{}'",
|
||||||
|
m.name(),
|
||||||
|
spec.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Bot {
|
||||||
|
config,
|
||||||
|
conn,
|
||||||
|
current_nick,
|
||||||
|
registered: false,
|
||||||
|
available_caps: HashSet::new(),
|
||||||
|
enabled_caps: HashSet::new(),
|
||||||
|
batches: HashMap::new(),
|
||||||
|
sasl_started: false,
|
||||||
|
modules: mods,
|
||||||
|
routes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(&mut self) -> io::Result<()> {
|
||||||
|
self.register()?;
|
||||||
|
while let Some(msg) = self.conn.read_message()? {
|
||||||
|
self.handle(&msg)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register(&mut self) -> io::Result<()> {
|
||||||
|
if let Some(password) = self.config.password.clone() {
|
||||||
|
self.conn.pass(&password)?;
|
||||||
|
}
|
||||||
|
self.conn.cap_ls()?;
|
||||||
|
let nick = self.current_nick.clone();
|
||||||
|
self.conn.nick(&nick)?;
|
||||||
|
let (user, realname) = (self.config.user.clone(), self.config.realname.clone());
|
||||||
|
self.conn.user(&user, &realname)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(&mut self, msg: &Message) -> io::Result<()> {
|
||||||
|
match msg.command.as_str() {
|
||||||
|
"PING" => {
|
||||||
|
let token = msg.trailing().unwrap_or("").to_string();
|
||||||
|
self.conn.pong(&token)?;
|
||||||
|
}
|
||||||
|
"CAP" => self.handle_cap(msg)?,
|
||||||
|
"AUTHENTICATE" => self.handle_authenticate(msg)?,
|
||||||
|
"903" => self.conn.cap_end()?,
|
||||||
|
"902" | "904" | "905" | "906" | "907" => self.conn.cap_end()?,
|
||||||
|
"BATCH" => self.handle_batch(msg),
|
||||||
|
"001" => {
|
||||||
|
self.registered = true;
|
||||||
|
for channel in self.config.channels.clone() {
|
||||||
|
self.conn.join(&channel)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"433" => {
|
||||||
|
self.current_nick.push('_');
|
||||||
|
let nick = self.current_nick.clone();
|
||||||
|
self.conn.nick(&nick)?;
|
||||||
|
}
|
||||||
|
"PRIVMSG" => self.handle_privmsg(msg)?,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/bot/module.rs
Normal file
32
src/bot/module.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct CommandSpec {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub usage: &'static str,
|
||||||
|
pub about: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Command<'a> {
|
||||||
|
pub sender: &'a str,
|
||||||
|
pub reply_to: &'a str,
|
||||||
|
pub name: &'a str,
|
||||||
|
pub args: &'a [&'a str],
|
||||||
|
pub prefix: &'a str,
|
||||||
|
pub network: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Action {
|
||||||
|
Reply(String),
|
||||||
|
Raw(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action {
|
||||||
|
pub fn reply(text: impl Into<String>) -> Action {
|
||||||
|
Action::Reply(text.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Module {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
fn commands(&self) -> &'static [CommandSpec];
|
||||||
|
fn on_command(&mut self, cmd: &Command) -> Vec<Action>;
|
||||||
|
}
|
||||||
29
src/bot/modules/builtins.rs
Normal file
29
src/bot/modules/builtins.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
use crate::bot::module::{Action, Command, CommandSpec, Module};
|
||||||
|
|
||||||
|
pub struct Builtins;
|
||||||
|
|
||||||
|
const COMMANDS: &[CommandSpec] = &[
|
||||||
|
CommandSpec { name: "ping", usage: "ping", about: "reply with 'pong'" },
|
||||||
|
CommandSpec { name: "echo", usage: "echo <text>", about: "echo the text back" },
|
||||||
|
CommandSpec { name: "hello", usage: "hello", about: "greet the sender" },
|
||||||
|
];
|
||||||
|
|
||||||
|
impl Module for Builtins {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"builtins"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commands(&self) -> &'static [CommandSpec] {
|
||||||
|
COMMANDS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_command(&mut self, cmd: &Command) -> Vec<Action> {
|
||||||
|
let reply = match cmd.name {
|
||||||
|
"ping" => "pong".to_string(),
|
||||||
|
"echo" => cmd.args.join(" "),
|
||||||
|
"hello" => format!("hello, {}!", cmd.sender),
|
||||||
|
_ => return Vec::new(),
|
||||||
|
};
|
||||||
|
vec![Action::Reply(reply)]
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/bot/modules/dice.rs
Normal file
88
src/bot/modules/dice.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use crate::bot::module::{Action, Command, CommandSpec, Module};
|
||||||
|
|
||||||
|
const COMMANDS: &[CommandSpec] = &[CommandSpec {
|
||||||
|
name: "roll",
|
||||||
|
usage: "roll [NdM]",
|
||||||
|
about: "roll N dice of M sides (default 1d6), e.g. roll 2d20",
|
||||||
|
}];
|
||||||
|
|
||||||
|
const MAX_DICE: u64 = 100;
|
||||||
|
const MAX_SIDES: u64 = 1000;
|
||||||
|
|
||||||
|
pub struct Dice {
|
||||||
|
rng: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dice {
|
||||||
|
pub fn new() -> Dice {
|
||||||
|
let seed = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0x9E37_79B9_7F4A_7C15);
|
||||||
|
Dice { rng: seed | 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_u64(&mut self) -> u64 {
|
||||||
|
let mut x = self.rng;
|
||||||
|
x ^= x << 13;
|
||||||
|
x ^= x >> 7;
|
||||||
|
x ^= x << 17;
|
||||||
|
self.rng = x;
|
||||||
|
x
|
||||||
|
}
|
||||||
|
|
||||||
|
fn die(&mut self, sides: u64) -> u64 {
|
||||||
|
self.next_u64() % sides + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Dice {
|
||||||
|
fn default() -> Dice {
|
||||||
|
Dice::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Module for Dice {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"dice"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commands(&self) -> &'static [CommandSpec] {
|
||||||
|
COMMANDS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_command(&mut self, cmd: &Command) -> Vec<Action> {
|
||||||
|
let spec = cmd.args.first().copied().unwrap_or("1d6");
|
||||||
|
let reply = match parse_spec(spec) {
|
||||||
|
Some((count, sides)) => {
|
||||||
|
let rolls: Vec<u64> = (0..count).map(|_| self.die(sides)).collect();
|
||||||
|
let total: u64 = rolls.iter().sum();
|
||||||
|
if count == 1 {
|
||||||
|
format!("{count}d{sides}: {total}")
|
||||||
|
} else {
|
||||||
|
let parts: Vec<String> = rolls.iter().map(u64::to_string).collect();
|
||||||
|
format!("{count}d{sides}: {} = {total}", parts.join(" + "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => format!("usage: {p}roll [NdM], e.g. {p}roll 2d6", p = cmd.prefix),
|
||||||
|
};
|
||||||
|
vec![Action::Reply(reply)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_spec(s: &str) -> Option<(u64, u64)> {
|
||||||
|
let (count, sides) = match s.split_once('d') {
|
||||||
|
Some((c, m)) => {
|
||||||
|
let count = if c.is_empty() { 1 } else { c.parse().ok()? };
|
||||||
|
(count, m.parse().ok()?)
|
||||||
|
}
|
||||||
|
None => (1, s.parse().ok()?),
|
||||||
|
};
|
||||||
|
if (1..=MAX_DICE).contains(&count) && (2..=MAX_SIDES).contains(&sides) {
|
||||||
|
Some((count, sides))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
250
src/bot/modules/hash.rs
Normal file
250
src/bot/modules/hash.rs
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
use crate::bot::module::{Action, Command, CommandSpec, Module};
|
||||||
|
|
||||||
|
pub struct Hash;
|
||||||
|
|
||||||
|
const COMMANDS: &[CommandSpec] = &[
|
||||||
|
CommandSpec { name: "md5", usage: "md5 <text>", about: "MD5 hex digest of <text>" },
|
||||||
|
CommandSpec { name: "sha1", usage: "sha1 <text>", about: "SHA1 hex digest of <text>" },
|
||||||
|
CommandSpec { name: "sha256", usage: "sha256 <text>", about: "SHA256 hex digest of <text>" },
|
||||||
|
CommandSpec { name: "hash", usage: "hash <text>", about: "md5, sha1 and sha256 of <text>" },
|
||||||
|
];
|
||||||
|
|
||||||
|
impl Module for Hash {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"hash"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commands(&self) -> &'static [CommandSpec] {
|
||||||
|
COMMANDS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_command(&mut self, cmd: &Command) -> Vec<Action> {
|
||||||
|
let input = cmd.args.join(" ");
|
||||||
|
if input.is_empty() {
|
||||||
|
return vec![Action::reply(format!("usage: {}{} <text>", cmd.prefix, cmd.name))];
|
||||||
|
}
|
||||||
|
let b = input.as_bytes();
|
||||||
|
let reply = match cmd.name {
|
||||||
|
"md5" => md5(b),
|
||||||
|
"sha1" => sha1(b),
|
||||||
|
"sha256" => sha256(b),
|
||||||
|
"hash" => format!("md5: {}, sha1: {}, sha256: {}", md5(b), sha1(b), sha256(b)),
|
||||||
|
_ => return vec![],
|
||||||
|
};
|
||||||
|
vec![Action::reply(reply)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pad(data: &[u8], little_endian_len: bool) -> Vec<u8> {
|
||||||
|
let bits = (data.len() as u64).wrapping_mul(8);
|
||||||
|
let mut m = data.to_vec();
|
||||||
|
m.push(0x80);
|
||||||
|
while m.len() % 64 != 56 {
|
||||||
|
m.push(0);
|
||||||
|
}
|
||||||
|
if little_endian_len {
|
||||||
|
m.extend_from_slice(&bits.to_le_bytes());
|
||||||
|
} else {
|
||||||
|
m.extend_from_slice(&bits.to_be_bytes());
|
||||||
|
}
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_hex(bytes: &[u8]) -> String {
|
||||||
|
let mut s = String::with_capacity(bytes.len() * 2);
|
||||||
|
for b in bytes {
|
||||||
|
s.push_str(&format!("{b:02x}"));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn md5(data: &[u8]) -> String {
|
||||||
|
const S: [u32; 64] = [
|
||||||
|
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5,
|
||||||
|
9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10,
|
||||||
|
15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
|
||||||
|
];
|
||||||
|
const K: [u32; 64] = [
|
||||||
|
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613,
|
||||||
|
0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193,
|
||||||
|
0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d,
|
||||||
|
0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
|
||||||
|
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122,
|
||||||
|
0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa,
|
||||||
|
0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244,
|
||||||
|
0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
|
||||||
|
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb,
|
||||||
|
0xeb86d391,
|
||||||
|
];
|
||||||
|
|
||||||
|
let (mut a0, mut b0, mut c0, mut d0) =
|
||||||
|
(0x67452301u32, 0xefcdab89u32, 0x98badcfeu32, 0x10325476u32);
|
||||||
|
let msg = pad(data, true);
|
||||||
|
for chunk in msg.chunks(64) {
|
||||||
|
let mut m = [0u32; 16];
|
||||||
|
for (i, word) in m.iter_mut().enumerate() {
|
||||||
|
*word = u32::from_le_bytes([
|
||||||
|
chunk[i * 4],
|
||||||
|
chunk[i * 4 + 1],
|
||||||
|
chunk[i * 4 + 2],
|
||||||
|
chunk[i * 4 + 3],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
|
||||||
|
for i in 0..64 {
|
||||||
|
let (f, g) = if i < 16 {
|
||||||
|
((b & c) | (!b & d), i)
|
||||||
|
} else if i < 32 {
|
||||||
|
((d & b) | (!d & c), (5 * i + 1) % 16)
|
||||||
|
} else if i < 48 {
|
||||||
|
(b ^ c ^ d, (3 * i + 5) % 16)
|
||||||
|
} else {
|
||||||
|
(c ^ (b | !d), (7 * i) % 16)
|
||||||
|
};
|
||||||
|
let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]);
|
||||||
|
a = d;
|
||||||
|
d = c;
|
||||||
|
c = b;
|
||||||
|
b = b.wrapping_add(f.rotate_left(S[i]));
|
||||||
|
}
|
||||||
|
a0 = a0.wrapping_add(a);
|
||||||
|
b0 = b0.wrapping_add(b);
|
||||||
|
c0 = c0.wrapping_add(c);
|
||||||
|
d0 = d0.wrapping_add(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(16);
|
||||||
|
for v in [a0, b0, c0, d0] {
|
||||||
|
out.extend_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
to_hex(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha1(data: &[u8]) -> String {
|
||||||
|
let (mut h0, mut h1, mut h2, mut h3, mut h4) = (
|
||||||
|
0x67452301u32,
|
||||||
|
0xEFCDAB89u32,
|
||||||
|
0x98BADCFEu32,
|
||||||
|
0x10325476u32,
|
||||||
|
0xC3D2E1F0u32,
|
||||||
|
);
|
||||||
|
let msg = pad(data, false);
|
||||||
|
for chunk in msg.chunks(64) {
|
||||||
|
let mut w = [0u32; 80];
|
||||||
|
for i in 0..16 {
|
||||||
|
w[i] = u32::from_be_bytes([
|
||||||
|
chunk[i * 4],
|
||||||
|
chunk[i * 4 + 1],
|
||||||
|
chunk[i * 4 + 2],
|
||||||
|
chunk[i * 4 + 3],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for i in 16..80 {
|
||||||
|
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
|
||||||
|
}
|
||||||
|
let (mut a, mut b, mut c, mut d, mut e) = (h0, h1, h2, h3, h4);
|
||||||
|
for (i, &word) in w.iter().enumerate() {
|
||||||
|
let (f, k) = if i < 20 {
|
||||||
|
((b & c) | (!b & d), 0x5A827999u32)
|
||||||
|
} else if i < 40 {
|
||||||
|
(b ^ c ^ d, 0x6ED9EBA1u32)
|
||||||
|
} else if i < 60 {
|
||||||
|
((b & c) | (b & d) | (c & d), 0x8F1BBCDCu32)
|
||||||
|
} else {
|
||||||
|
(b ^ c ^ d, 0xCA62C1D6u32)
|
||||||
|
};
|
||||||
|
let temp = a
|
||||||
|
.rotate_left(5)
|
||||||
|
.wrapping_add(f)
|
||||||
|
.wrapping_add(e)
|
||||||
|
.wrapping_add(k)
|
||||||
|
.wrapping_add(word);
|
||||||
|
e = d;
|
||||||
|
d = c;
|
||||||
|
c = b.rotate_left(30);
|
||||||
|
b = a;
|
||||||
|
a = temp;
|
||||||
|
}
|
||||||
|
h0 = h0.wrapping_add(a);
|
||||||
|
h1 = h1.wrapping_add(b);
|
||||||
|
h2 = h2.wrapping_add(c);
|
||||||
|
h3 = h3.wrapping_add(d);
|
||||||
|
h4 = h4.wrapping_add(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(20);
|
||||||
|
for v in [h0, h1, h2, h3, h4] {
|
||||||
|
out.extend_from_slice(&v.to_be_bytes());
|
||||||
|
}
|
||||||
|
to_hex(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha256(data: &[u8]) -> String {
|
||||||
|
const K: [u32; 64] = [
|
||||||
|
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
|
||||||
|
0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
|
||||||
|
0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
|
||||||
|
0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
|
||||||
|
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
|
||||||
|
0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
|
||||||
|
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
|
||||||
|
0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||||
|
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
|
||||||
|
0xc67178f2,
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut h: [u32; 8] = [
|
||||||
|
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
|
||||||
|
0x5be0cd19,
|
||||||
|
];
|
||||||
|
let msg = pad(data, false);
|
||||||
|
for chunk in msg.chunks(64) {
|
||||||
|
let mut w = [0u32; 64];
|
||||||
|
for i in 0..16 {
|
||||||
|
w[i] = u32::from_be_bytes([
|
||||||
|
chunk[i * 4],
|
||||||
|
chunk[i * 4 + 1],
|
||||||
|
chunk[i * 4 + 2],
|
||||||
|
chunk[i * 4 + 3],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for i in 16..64 {
|
||||||
|
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
|
||||||
|
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
|
||||||
|
w[i] = w[i - 16]
|
||||||
|
.wrapping_add(s0)
|
||||||
|
.wrapping_add(w[i - 7])
|
||||||
|
.wrapping_add(s1);
|
||||||
|
}
|
||||||
|
let mut v = h;
|
||||||
|
for i in 0..64 {
|
||||||
|
let s1 = v[4].rotate_right(6) ^ v[4].rotate_right(11) ^ v[4].rotate_right(25);
|
||||||
|
let ch = (v[4] & v[5]) ^ (!v[4] & v[6]);
|
||||||
|
let temp1 = v[7]
|
||||||
|
.wrapping_add(s1)
|
||||||
|
.wrapping_add(ch)
|
||||||
|
.wrapping_add(K[i])
|
||||||
|
.wrapping_add(w[i]);
|
||||||
|
let s0 = v[0].rotate_right(2) ^ v[0].rotate_right(13) ^ v[0].rotate_right(22);
|
||||||
|
let maj = (v[0] & v[1]) ^ (v[0] & v[2]) ^ (v[1] & v[2]);
|
||||||
|
let temp2 = s0.wrapping_add(maj);
|
||||||
|
v[7] = v[6];
|
||||||
|
v[6] = v[5];
|
||||||
|
v[5] = v[4];
|
||||||
|
v[4] = v[3].wrapping_add(temp1);
|
||||||
|
v[3] = v[2];
|
||||||
|
v[2] = v[1];
|
||||||
|
v[1] = v[0];
|
||||||
|
v[0] = temp1.wrapping_add(temp2);
|
||||||
|
}
|
||||||
|
for i in 0..8 {
|
||||||
|
h[i] = h[i].wrapping_add(v[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(32);
|
||||||
|
for v in h {
|
||||||
|
out.extend_from_slice(&v.to_be_bytes());
|
||||||
|
}
|
||||||
|
to_hex(&out)
|
||||||
|
}
|
||||||
15
src/bot/modules/mod.rs
Normal file
15
src/bot/modules/mod.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
pub mod builtins;
|
||||||
|
pub mod dice;
|
||||||
|
pub mod hash;
|
||||||
|
pub mod weather;
|
||||||
|
|
||||||
|
use super::module::Module;
|
||||||
|
|
||||||
|
pub fn all(network: &str) -> Vec<Box<dyn Module>> {
|
||||||
|
vec![
|
||||||
|
Box::new(builtins::Builtins),
|
||||||
|
Box::new(dice::Dice::new()),
|
||||||
|
Box::new(hash::Hash),
|
||||||
|
Box::new(weather::Weather::new(network)),
|
||||||
|
]
|
||||||
|
}
|
||||||
332
src/bot/modules/weather.rs
Normal file
332
src/bot/modules/weather.rs
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
use std::iter::Peekable;
|
||||||
|
use std::net::{TcpStream, ToSocketAddrs};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::str::Chars;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use native_tls::TlsConnector;
|
||||||
|
|
||||||
|
use crate::bot::module::{Action, Command, CommandSpec, Module};
|
||||||
|
|
||||||
|
const CACHE_TTL: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
|
const COMMANDS: &[CommandSpec] = &[
|
||||||
|
CommandSpec { name: "add", usage: "add <location>", about: "save your weather location" },
|
||||||
|
CommandSpec { name: "w", usage: "w [location]", about: "weather for your saved location" },
|
||||||
|
];
|
||||||
|
|
||||||
|
pub struct Weather {
|
||||||
|
store: Store,
|
||||||
|
path: PathBuf,
|
||||||
|
cache: HashMap<String, (Instant, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Weather {
|
||||||
|
pub fn new(network: &str) -> Weather {
|
||||||
|
let dir = std::env::var("RUSTBOT_DATA_DIR").unwrap_or_else(|_| ".".to_string());
|
||||||
|
let safe: String = network
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
||||||
|
.collect();
|
||||||
|
Weather::with_path(PathBuf::from(dir).join(format!("weather.{safe}.json")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_path(path: PathBuf) -> Weather {
|
||||||
|
Weather { store: Store::load(&path), path, cache: HashMap::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn location_of(&self, nick: &str) -> Option<&str> {
|
||||||
|
self.store.get(nick).map(String::as_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Module for Weather {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"weather"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commands(&self) -> &'static [CommandSpec] {
|
||||||
|
COMMANDS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_command(&mut self, cmd: &Command) -> Vec<Action> {
|
||||||
|
match cmd.name {
|
||||||
|
"add" => {
|
||||||
|
if cmd.args.is_empty() {
|
||||||
|
return vec![Action::reply(format!("usage: {}add <location>", cmd.prefix))];
|
||||||
|
}
|
||||||
|
let loc = cmd.args.join(" ");
|
||||||
|
self.store.set(cmd.sender, &loc);
|
||||||
|
if let Err(e) = self.store.save(&self.path) {
|
||||||
|
crate::log(&format!("weather: could not save {}: {e}", self.path.display()));
|
||||||
|
return vec![Action::reply(format!("{}: could not save location", cmd.sender))];
|
||||||
|
}
|
||||||
|
vec![Action::reply(format!("{}: saved {loc} — try {}w", cmd.sender, cmd.prefix))]
|
||||||
|
}
|
||||||
|
"w" => {
|
||||||
|
let loc = if !cmd.args.is_empty() {
|
||||||
|
cmd.args.join(" ")
|
||||||
|
} else if let Some(l) = self.store.get(cmd.sender) {
|
||||||
|
l.clone()
|
||||||
|
} else {
|
||||||
|
return vec![Action::reply(format!(
|
||||||
|
"{}: no location set — {}add <location> first",
|
||||||
|
cmd.sender, cmd.prefix
|
||||||
|
))];
|
||||||
|
};
|
||||||
|
let key = loc.to_lowercase();
|
||||||
|
if let Some((at, text)) = self.cache.get(&key) {
|
||||||
|
if at.elapsed() < CACHE_TTL {
|
||||||
|
return vec![Action::reply(text.clone())];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match fetch_weather(&loc) {
|
||||||
|
Ok(text) => {
|
||||||
|
self.cache.insert(key, (Instant::now(), text.clone()));
|
||||||
|
vec![Action::reply(text)]
|
||||||
|
}
|
||||||
|
Err(e) => vec![Action::reply(format!("weather unavailable: {e}"))],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Store {
|
||||||
|
map: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
fn load(path: &Path) -> Store {
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(s) => Store { map: parse_db(&s) },
|
||||||
|
Err(_) => Store { map: BTreeMap::new() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&self, path: &Path) -> io::Result<()> {
|
||||||
|
fs::write(path, to_json(&self.map) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, nick: &str) -> Option<&String> {
|
||||||
|
self.map.get(&nick.to_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set(&mut self, nick: &str, loc: &str) {
|
||||||
|
self.map.insert(nick.to_lowercase(), loc.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_weather(location: &str) -> Result<String, String> {
|
||||||
|
let path = format!("/{}?format=%l|%c|%C|%t|%f|%w|%h&m", url_encode(location));
|
||||||
|
let mut last = "no response".to_string();
|
||||||
|
for attempt in 0..3 {
|
||||||
|
if attempt > 0 {
|
||||||
|
std::thread::sleep(Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
match https_get("wttr.in", &path) {
|
||||||
|
Ok(body) => {
|
||||||
|
let raw = body.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
|
||||||
|
if !raw.is_empty() {
|
||||||
|
return Ok(format_weather(raw, location));
|
||||||
|
}
|
||||||
|
last = "empty response".to_string();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let retry = e.retry;
|
||||||
|
last = e.msg;
|
||||||
|
if !retry {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_weather(raw: &str, location: &str) -> String {
|
||||||
|
let f: Vec<&str> = raw.split('|').map(str::trim).collect();
|
||||||
|
if f.len() >= 7 {
|
||||||
|
let temp = f[3].trim_start_matches('+');
|
||||||
|
let feels = f[4].trim_start_matches('+');
|
||||||
|
let msg = format!(
|
||||||
|
"{} {}: {}, {} (feels like {}) · 💨 {} · 💧 {}",
|
||||||
|
f[1], f[0], f[2], temp, feels, f[5], f[6]
|
||||||
|
);
|
||||||
|
return msg.chars().take(250).collect();
|
||||||
|
}
|
||||||
|
let low = raw.to_lowercase();
|
||||||
|
if low.contains("not found") || low.contains("unknown location") {
|
||||||
|
return format!("couldn't find '{location}' — try a city name or postcode");
|
||||||
|
}
|
||||||
|
raw.chars().take(250).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HttpErr {
|
||||||
|
msg: String,
|
||||||
|
retry: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn https_get(host: &str, path: &str) -> Result<String, HttpErr> {
|
||||||
|
let addr = (host, 443)
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map_err(|e| HttpErr { msg: e.to_string(), retry: false })?
|
||||||
|
.next()
|
||||||
|
.ok_or(HttpErr { msg: "dns lookup failed".to_string(), retry: false })?;
|
||||||
|
let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(4))
|
||||||
|
.map_err(|e| HttpErr { retry: retryable_io(&e), msg: e.to_string() })?;
|
||||||
|
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
|
||||||
|
stream.set_write_timeout(Some(Duration::from_secs(5))).ok();
|
||||||
|
|
||||||
|
let connector = TlsConnector::new().map_err(|e| HttpErr { msg: e.to_string(), retry: false })?;
|
||||||
|
let mut tls = connector
|
||||||
|
.connect(host, stream)
|
||||||
|
.map_err(|e| HttpErr { msg: e.to_string(), retry: true })?;
|
||||||
|
|
||||||
|
let req = format!(
|
||||||
|
"GET {path} HTTP/1.0\r\nHost: {host}\r\nUser-Agent: curl/rubot\r\nAccept: text/plain\r\nConnection: close\r\n\r\n"
|
||||||
|
);
|
||||||
|
tls.write_all(req.as_bytes())
|
||||||
|
.map_err(|e| HttpErr { retry: retryable_io(&e), msg: e.to_string() })?;
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
tls.read_to_end(&mut buf)
|
||||||
|
.map_err(|e| HttpErr { retry: retryable_io(&e), msg: e.to_string() })?;
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(&buf);
|
||||||
|
let Some((head, body)) = text.split_once("\r\n\r\n") else {
|
||||||
|
return Err(HttpErr { msg: "malformed response".to_string(), retry: true });
|
||||||
|
};
|
||||||
|
let code = head
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|c| c.parse::<u16>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
if !(200..300).contains(&code) {
|
||||||
|
return Err(HttpErr { retry: code == 429 || code >= 500, msg: format!("http {code}") });
|
||||||
|
}
|
||||||
|
Ok(body.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retryable_io(e: &io::Error) -> bool {
|
||||||
|
use io::ErrorKind::{BrokenPipe, ConnectionAborted, ConnectionRefused, ConnectionReset, UnexpectedEof};
|
||||||
|
matches!(
|
||||||
|
e.kind(),
|
||||||
|
ConnectionReset | ConnectionAborted | ConnectionRefused | BrokenPipe | UnexpectedEof
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url_encode(s: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for b in s.bytes() {
|
||||||
|
match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
|
||||||
|
_ => out.push_str(&format!("%{b:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_json(map: &BTreeMap<String, String>) -> String {
|
||||||
|
let items: Vec<String> = map
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("\"{}\":\"{}\"", json_escape(k), json_escape(v)))
|
||||||
|
.collect();
|
||||||
|
format!("{{{}}}", items.join(","))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_db(s: &str) -> BTreeMap<String, String> {
|
||||||
|
let mut map = BTreeMap::new();
|
||||||
|
let mut chars = s.chars().peekable();
|
||||||
|
skip_ws(&mut chars);
|
||||||
|
if chars.next() != Some('{') {
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
skip_ws(&mut chars);
|
||||||
|
match chars.peek() {
|
||||||
|
Some('"') => {}
|
||||||
|
Some(',') => {
|
||||||
|
chars.next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
let Some(key) = parse_string(&mut chars) else { break };
|
||||||
|
skip_ws(&mut chars);
|
||||||
|
if chars.next() != Some(':') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
skip_ws(&mut chars);
|
||||||
|
let Some(value) = parse_string(&mut chars) else { break };
|
||||||
|
map.insert(key, value);
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_ws(chars: &mut Peekable<Chars>) {
|
||||||
|
while let Some(c) = chars.peek() {
|
||||||
|
if c.is_whitespace() {
|
||||||
|
chars.next();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_string(chars: &mut Peekable<Chars>) -> Option<String> {
|
||||||
|
if chars.next() != Some('"') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = String::new();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
match c {
|
||||||
|
'"' => return Some(out),
|
||||||
|
'\\' => match chars.next()? {
|
||||||
|
'"' => out.push('"'),
|
||||||
|
'\\' => out.push('\\'),
|
||||||
|
'/' => out.push('/'),
|
||||||
|
'n' => out.push('\n'),
|
||||||
|
'r' => out.push('\r'),
|
||||||
|
't' => out.push('\t'),
|
||||||
|
'b' => out.push('\u{08}'),
|
||||||
|
'f' => out.push('\u{0C}'),
|
||||||
|
'u' => {
|
||||||
|
let mut code = 0u32;
|
||||||
|
for _ in 0..4 {
|
||||||
|
code = code * 16 + chars.next()?.to_digit(16)?;
|
||||||
|
}
|
||||||
|
if let Some(ch) = char::from_u32(code) {
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => out.push(other),
|
||||||
|
},
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_escape(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\r' => out.push_str("\\r"),
|
||||||
|
'\t' => out.push_str("\\t"),
|
||||||
|
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
227
src/config.rs
Normal file
227
src/config.rs
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::log;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub name: String,
|
||||||
|
pub server: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub tls: bool,
|
||||||
|
pub nick: String,
|
||||||
|
pub user: String,
|
||||||
|
pub realname: String,
|
||||||
|
pub channels: Vec<String>,
|
||||||
|
pub command_prefix: String,
|
||||||
|
pub verbose: bool,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub sasl_user: Option<String>,
|
||||||
|
pub sasl_pass: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Config {
|
||||||
|
Config {
|
||||||
|
name: String::new(),
|
||||||
|
server: "irc.tchatou.fr".to_string(),
|
||||||
|
port: 6667,
|
||||||
|
tls: false,
|
||||||
|
nick: "rubot".to_string(),
|
||||||
|
user: "rubot".to_string(),
|
||||||
|
realname: "rubot".to_string(),
|
||||||
|
channels: vec!["#devs".to_string()],
|
||||||
|
command_prefix: "!".to_string(),
|
||||||
|
verbose: true,
|
||||||
|
password: None,
|
||||||
|
sasl_user: None,
|
||||||
|
sasl_pass: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry = (usize, String, String);
|
||||||
|
|
||||||
|
pub fn load() -> Vec<Config> {
|
||||||
|
let path = config_path();
|
||||||
|
let content = path.as_ref().and_then(|p| match fs::read_to_string(p) {
|
||||||
|
Ok(s) => {
|
||||||
|
log(&format!("loaded config from {}", p.display()));
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log(&format!("could not read config {}: {e}", p.display()));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let (toplevel, sections) = match &content {
|
||||||
|
Some(text) => parse_sections(text),
|
||||||
|
None => (Vec::new(), Vec::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut base = Config::default();
|
||||||
|
for (line, key, value) in &toplevel {
|
||||||
|
apply_kv(&mut base, key, value, &loc(&path, *line));
|
||||||
|
}
|
||||||
|
apply_env(&mut base);
|
||||||
|
|
||||||
|
assemble(base, sections, &path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_str(content: &str) -> Vec<Config> {
|
||||||
|
let (toplevel, sections) = parse_sections(content);
|
||||||
|
let mut base = Config::default();
|
||||||
|
for (line, key, value) in &toplevel {
|
||||||
|
apply_kv(&mut base, key, value, &format!("line {line}"));
|
||||||
|
}
|
||||||
|
assemble(base, sections, &None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assemble(base: Config, sections: Vec<(String, Vec<Entry>)>, path: &Option<PathBuf>) -> Vec<Config> {
|
||||||
|
let mut configs = Vec::new();
|
||||||
|
if sections.is_empty() {
|
||||||
|
let mut c = base;
|
||||||
|
finalize(&mut c);
|
||||||
|
configs.push(c);
|
||||||
|
} else {
|
||||||
|
for (name, entries) in sections {
|
||||||
|
let mut c = base.clone();
|
||||||
|
c.name = name;
|
||||||
|
for (line, key, value) in &entries {
|
||||||
|
apply_kv(&mut c, key, value, &loc(path, *line));
|
||||||
|
}
|
||||||
|
finalize(&mut c);
|
||||||
|
configs.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize(c: &mut Config) {
|
||||||
|
if c.name.is_empty() {
|
||||||
|
c.name = c.server.clone();
|
||||||
|
}
|
||||||
|
if c.tls && c.port == 6667 {
|
||||||
|
c.port = 6697;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sections(content: &str) -> (Vec<Entry>, Vec<(String, Vec<Entry>)>) {
|
||||||
|
let mut toplevel: Vec<Entry> = Vec::new();
|
||||||
|
let mut sections: Vec<(String, Vec<Entry>)> = Vec::new();
|
||||||
|
|
||||||
|
for (i, raw) in content.lines().enumerate() {
|
||||||
|
let line = raw.trim();
|
||||||
|
let lineno = i + 1;
|
||||||
|
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(inner) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
|
||||||
|
sections.push((inner.trim().to_string(), Vec::new()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((key, value)) = line.split_once('=') else {
|
||||||
|
log(&format!("line {lineno}: ignoring line without '='"));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let entry = (lineno, key.trim().to_ascii_lowercase(), value.trim().to_string());
|
||||||
|
match sections.last_mut() {
|
||||||
|
Some((_, entries)) => entries.push(entry),
|
||||||
|
None => toplevel.push(entry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(toplevel, sections)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) {
|
||||||
|
match key {
|
||||||
|
"name" => c.name = value.to_string(),
|
||||||
|
"server" => c.server = value.to_string(),
|
||||||
|
"port" => match value.parse() {
|
||||||
|
Ok(p) => c.port = p,
|
||||||
|
Err(_) => log(&format!("{where_}: invalid port '{value}'")),
|
||||||
|
},
|
||||||
|
"tls" => c.tls = parse_bool(value),
|
||||||
|
"nick" => c.nick = value.to_string(),
|
||||||
|
"user" => c.user = value.to_string(),
|
||||||
|
"realname" => c.realname = value.to_string(),
|
||||||
|
"channels" => c.channels = split_list(value),
|
||||||
|
"prefix" | "command_prefix" => c.command_prefix = value.to_string(),
|
||||||
|
"verbose" => c.verbose = parse_bool(value),
|
||||||
|
"password" => c.password = (!value.is_empty()).then(|| value.to_string()),
|
||||||
|
"sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()),
|
||||||
|
"sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()),
|
||||||
|
other => log(&format!("{where_}: unknown key '{other}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn loc(path: &Option<PathBuf>, line: usize) -> String {
|
||||||
|
match path {
|
||||||
|
Some(p) => format!("{}:{}", p.display(), line),
|
||||||
|
None => format!("line {line}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> Option<PathBuf> {
|
||||||
|
if let Some(arg) = std::env::args().nth(1) {
|
||||||
|
return Some(PathBuf::from(arg));
|
||||||
|
}
|
||||||
|
if let Ok(env) = std::env::var("RUSTBOT_CONFIG") {
|
||||||
|
return Some(PathBuf::from(env));
|
||||||
|
}
|
||||||
|
let default = PathBuf::from("rustbot.conf");
|
||||||
|
default.exists().then_some(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_env(c: &mut Config) {
|
||||||
|
if let Ok(v) = std::env::var("IRC_SERVER") {
|
||||||
|
c.server = v;
|
||||||
|
}
|
||||||
|
if let Some(p) = std::env::var("IRC_PORT").ok().and_then(|v| v.parse().ok()) {
|
||||||
|
c.port = p;
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var("IRC_TLS") {
|
||||||
|
c.tls = parse_bool(&v);
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var("IRC_NICK") {
|
||||||
|
c.user = v.clone();
|
||||||
|
c.nick = v;
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var("IRC_REALNAME") {
|
||||||
|
c.realname = v;
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var("IRC_CHANNELS") {
|
||||||
|
c.channels = split_list(&v);
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var("IRC_PREFIX") {
|
||||||
|
c.command_prefix = v;
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var("IRC_VERBOSE") {
|
||||||
|
c.verbose = parse_bool(&v);
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var("IRC_PASSWORD") {
|
||||||
|
c.password = Some(v);
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var("IRC_SASL_USER") {
|
||||||
|
c.sasl_user = Some(v);
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var("IRC_SASL_PASS") {
|
||||||
|
c.sasl_pass = Some(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bool(value: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
value.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"1" | "true" | "yes" | "on"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_list(value: &str) -> Vec<String> {
|
||||||
|
value
|
||||||
|
.split(|ch: char| ch == ',' || ch.is_whitespace())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
421
src/irc.rs
421
src/irc.rs
|
|
@ -1,421 +0,0 @@
|
||||||
//! Minimal IRC protocol layer — the "IRC library".
|
|
||||||
//!
|
|
||||||
//! Implements just enough of the modern IRC client protocol
|
|
||||||
//! (<https://modern.ircdocs.horse/>) to drive a bot:
|
|
||||||
//!
|
|
||||||
//! * [`Message`] — parsing of the wire format `@tags :source COMMAND params`,
|
|
||||||
//! including IRCv3 message tags and trailing parameters.
|
|
||||||
//! * [`Prefix`] — the `nick!user@host` (or server name) message source.
|
|
||||||
//! * [`Connection`] — a blocking TCP transport with convenience senders.
|
|
||||||
//!
|
|
||||||
//! It is deliberately self-contained and dependency-free, so it can grow into a
|
|
||||||
//! small reusable IRC library independent of the bot logic in `bot.rs`.
|
|
||||||
#![allow(dead_code)] // This is a library-style module; not every helper is used yet.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::io::{self, BufRead, BufReader, Write};
|
|
||||||
use std::net::TcpStream;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
/// The source of a message: `nick!user@host`, or a bare nick / server name.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Prefix {
|
|
||||||
/// The full, unparsed prefix as received.
|
|
||||||
pub raw: String,
|
|
||||||
pub nick: Option<String>,
|
|
||||||
pub user: Option<String>,
|
|
||||||
pub host: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Prefix {
|
|
||||||
/// Parse a prefix per the grammar `servername / (nick [ [ "!" user ] "@" host ])`.
|
|
||||||
fn parse(raw: &str) -> Prefix {
|
|
||||||
let (nick, user, host) = if let Some((n, rest)) = raw.split_once('!') {
|
|
||||||
match rest.split_once('@') {
|
|
||||||
Some((u, h)) => (Some(n), Some(u), Some(h)),
|
|
||||||
None => (Some(n), Some(rest), None),
|
|
||||||
}
|
|
||||||
} else if let Some((n, h)) = raw.split_once('@') {
|
|
||||||
(Some(n), None, Some(h))
|
|
||||||
} else {
|
|
||||||
// A bare nick or a server name — keep it as the nick for convenience.
|
|
||||||
(Some(raw), None, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
Prefix {
|
|
||||||
raw: raw.to_string(),
|
|
||||||
nick: nick.map(str::to_string),
|
|
||||||
user: user.map(str::to_string),
|
|
||||||
host: host.map(str::to_string),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best display name for the sender: the nick if we have one, else the raw prefix.
|
|
||||||
pub fn name(&self) -> &str {
|
|
||||||
self.nick.as_deref().unwrap_or(&self.raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A parsed IRC message.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Message {
|
|
||||||
/// IRCv3 message tags (already unescaped). Empty when none were present.
|
|
||||||
pub tags: HashMap<String, String>,
|
|
||||||
/// The message source, if the server included one.
|
|
||||||
pub prefix: Option<Prefix>,
|
|
||||||
/// The command, upper-cased (e.g. `PRIVMSG`) or a 3-digit numeric (e.g. `001`).
|
|
||||||
pub command: String,
|
|
||||||
/// Command parameters; the last one may be a "trailing" value containing spaces.
|
|
||||||
pub params: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Message {
|
|
||||||
/// Parse a single line of IRC (the trailing CRLF may be present or not).
|
|
||||||
///
|
|
||||||
/// Returns `None` for blank or structurally invalid lines.
|
|
||||||
pub fn parse(line: &str) -> Option<Message> {
|
|
||||||
let mut rest = line.trim_end_matches(['\r', '\n']).trim_start();
|
|
||||||
if rest.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Tags: `@key=value;key2 ` (optional, space-terminated).
|
|
||||||
let mut tags = HashMap::new();
|
|
||||||
if let Some(stripped) = rest.strip_prefix('@') {
|
|
||||||
let (tag_str, r) = stripped.split_once(' ')?;
|
|
||||||
rest = r.trim_start();
|
|
||||||
for item in tag_str.split(';').filter(|s| !s.is_empty()) {
|
|
||||||
match item.split_once('=') {
|
|
||||||
Some((k, v)) => tags.insert(k.to_string(), unescape_tag_value(v)),
|
|
||||||
None => tags.insert(item.to_string(), String::new()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Source / prefix: `:nick!user@host ` (optional, space-terminated).
|
|
||||||
let mut prefix = None;
|
|
||||||
if let Some(stripped) = rest.strip_prefix(':') {
|
|
||||||
let (p, r) = stripped.split_once(' ')?;
|
|
||||||
prefix = Some(Prefix::parse(p));
|
|
||||||
rest = r.trim_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Command (required).
|
|
||||||
let (command, mut rest) = match rest.split_once(' ') {
|
|
||||||
Some((c, r)) => (c, r.trim_start()),
|
|
||||||
None => (rest, ""),
|
|
||||||
};
|
|
||||||
if command.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Parameters. A parameter beginning with ':' is the trailing param and
|
|
||||||
// consumes the remainder of the line verbatim (spaces included).
|
|
||||||
let mut params = Vec::new();
|
|
||||||
while !rest.is_empty() {
|
|
||||||
if let Some(trailing) = rest.strip_prefix(':') {
|
|
||||||
params.push(trailing.to_string());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match rest.split_once(' ') {
|
|
||||||
Some((p, r)) => {
|
|
||||||
if !p.is_empty() {
|
|
||||||
params.push(p.to_string());
|
|
||||||
}
|
|
||||||
rest = r.trim_start();
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
params.push(rest.to_string());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Message {
|
|
||||||
tags,
|
|
||||||
prefix,
|
|
||||||
command: command.to_uppercase(),
|
|
||||||
params,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the parameter at `index`, if present.
|
|
||||||
pub fn param(&self, index: usize) -> Option<&str> {
|
|
||||||
self.params.get(index).map(String::as_str)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The last parameter — for `PRIVMSG`/`NOTICE`/`PING` this is the message text.
|
|
||||||
pub fn trailing(&self) -> Option<&str> {
|
|
||||||
self.params.last().map(String::as_str)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The sender's nick, if the message carried a `nick!user@host` prefix.
|
|
||||||
pub fn nick(&self) -> Option<&str> {
|
|
||||||
self.prefix.as_ref().and_then(|p| p.nick.as_deref())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The value of an IRCv3 message tag (e.g. `time`, `batch`), if present.
|
|
||||||
pub fn tag(&self, key: &str) -> Option<&str> {
|
|
||||||
self.tags.get(key).map(String::as_str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unescape an IRCv3 tag value per <https://ircv3.net/specs/extensions/message-tags>.
|
|
||||||
fn unescape_tag_value(value: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(value.len());
|
|
||||||
let mut chars = value.chars();
|
|
||||||
while let Some(c) = chars.next() {
|
|
||||||
if c != '\\' {
|
|
||||||
out.push(c);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match chars.next() {
|
|
||||||
Some(':') => out.push(';'),
|
|
||||||
Some('s') => out.push(' '),
|
|
||||||
Some('\\') => out.push('\\'),
|
|
||||||
Some('r') => out.push('\r'),
|
|
||||||
Some('n') => out.push('\n'),
|
|
||||||
Some(other) => out.push(other), // unknown escape: drop the backslash
|
|
||||||
None => {} // trailing backslash: ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A blocking IRC connection over plain TCP.
|
|
||||||
///
|
|
||||||
/// Reading and writing use separate clones of the same socket, so the event
|
|
||||||
/// loop can block on `read_message` while senders write independently.
|
|
||||||
pub struct Connection {
|
|
||||||
reader: BufReader<TcpStream>,
|
|
||||||
writer: TcpStream,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Connection {
|
|
||||||
/// Open a plaintext TCP connection to `host:port`.
|
|
||||||
///
|
|
||||||
/// Note: this is cleartext (typically port 6667). TLS (port 6697) would be a
|
|
||||||
/// natural next step by wrapping the stream in `rustls`/`native-tls`.
|
|
||||||
pub fn connect(host: &str, port: u16) -> io::Result<Connection> {
|
|
||||||
let stream = TcpStream::connect((host, port))?;
|
|
||||||
let writer = stream.try_clone()?;
|
|
||||||
Ok(Connection {
|
|
||||||
reader: BufReader::new(stream),
|
|
||||||
writer,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read and parse the next message. Returns `Ok(None)` on a clean EOF
|
|
||||||
/// (the server closed the connection). Blank/unparseable lines are skipped.
|
|
||||||
pub fn read_message(&mut self) -> io::Result<Option<Message>> {
|
|
||||||
loop {
|
|
||||||
let mut line = String::new();
|
|
||||||
if self.reader.read_line(&mut line)? == 0 {
|
|
||||||
return Ok(None); // EOF
|
|
||||||
}
|
|
||||||
let trimmed = line.trim_end_matches(['\r', '\n']);
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
eprintln!("<< {trimmed}");
|
|
||||||
if let Some(msg) = Message::parse(trimmed) {
|
|
||||||
return Ok(Some(msg));
|
|
||||||
}
|
|
||||||
// Unparseable line: skip and keep reading.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send one raw, pre-formatted IRC line. The CRLF terminator is added here;
|
|
||||||
/// any embedded CR/LF are neutralised to prevent command injection.
|
|
||||||
pub fn send_raw(&mut self, line: &str) -> io::Result<()> {
|
|
||||||
let clean = line.replace(['\r', '\n'], " ");
|
|
||||||
eprintln!(">> {clean}");
|
|
||||||
write!(self.writer, "{clean}\r\n")?;
|
|
||||||
self.writer.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Convenience senders -------------------------------------------------
|
|
||||||
|
|
||||||
pub fn pass(&mut self, password: &str) -> io::Result<()> {
|
|
||||||
self.send_raw(&format!("PASS {password}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn nick(&mut self, nick: &str) -> io::Result<()> {
|
|
||||||
self.send_raw(&format!("NICK {nick}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn user(&mut self, user: &str, realname: &str) -> io::Result<()> {
|
|
||||||
self.send_raw(&format!("USER {user} 0 * :{realname}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn join(&mut self, channel: &str) -> io::Result<()> {
|
|
||||||
self.send_raw(&format!("JOIN {channel}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pong(&mut self, token: &str) -> io::Result<()> {
|
|
||||||
self.send_raw(&format!("PONG :{token}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn privmsg(&mut self, target: &str, text: &str) -> io::Result<()> {
|
|
||||||
self.send_raw(&format!("PRIVMSG {target} :{text}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn notice(&mut self, target: &str, text: &str) -> io::Result<()> {
|
|
||||||
self.send_raw(&format!("NOTICE {target} :{text}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn quit(&mut self, reason: &str) -> io::Result<()> {
|
|
||||||
self.send_raw(&format!("QUIT :{reason}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- IRCv3 capability negotiation ---------------------------------------
|
|
||||||
|
|
||||||
pub fn cap_ls(&mut self) -> io::Result<()> {
|
|
||||||
self.send_raw("CAP LS 302")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cap_req(&mut self, caps: &str) -> io::Result<()> {
|
|
||||||
self.send_raw(&format!("CAP REQ :{caps}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cap_end(&mut self) -> io::Result<()> {
|
|
||||||
self.send_raw("CAP END")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn authenticate(&mut self, payload: &str) -> io::Result<()> {
|
|
||||||
self.send_raw(&format!("AUTHENTICATE {payload}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Base64-encode bytes (standard alphabet, padded). Used for SASL PLAIN so we
|
|
||||||
/// stay dependency-free.
|
|
||||||
pub fn base64_encode(input: &[u8]) -> String {
|
|
||||||
const ALPHABET: &[u8; 64] =
|
|
||||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
||||||
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
|
|
||||||
for chunk in input.chunks(3) {
|
|
||||||
let b0 = chunk[0] as u32;
|
|
||||||
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
|
|
||||||
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
|
|
||||||
let n = (b0 << 16) | (b1 << 8) | b2;
|
|
||||||
out.push(ALPHABET[(n >> 18 & 63) as usize] as char);
|
|
||||||
out.push(ALPHABET[(n >> 12 & 63) as usize] as char);
|
|
||||||
out.push(if chunk.len() > 1 { ALPHABET[(n >> 6 & 63) as usize] as char } else { '=' });
|
|
||||||
out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' });
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Current Unix time in whole seconds (0 if the clock predates the epoch).
|
|
||||||
pub fn now_unix() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs())
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse an IRCv3 `server-time` tag (`YYYY-MM-DDThh:mm:ss[.fff]Z`) into Unix
|
|
||||||
/// seconds. Returns `None` if the value isn't in the expected UTC format.
|
|
||||||
pub fn parse_server_time(s: &str) -> Option<u64> {
|
|
||||||
let (date, time) = s.split_once('T')?;
|
|
||||||
let time = time.trim_end_matches('Z').split('.').next()?; // drop fractional secs
|
|
||||||
let mut d = date.splitn(3, '-');
|
|
||||||
let year: i64 = d.next()?.parse().ok()?;
|
|
||||||
let month: i64 = d.next()?.parse().ok()?;
|
|
||||||
let day: i64 = d.next()?.parse().ok()?;
|
|
||||||
let mut t = time.splitn(3, ':');
|
|
||||||
let hour: i64 = t.next()?.parse().ok()?;
|
|
||||||
let min: i64 = t.next()?.parse().ok()?;
|
|
||||||
let sec: i64 = t.next()?.parse().ok()?;
|
|
||||||
let secs = days_from_civil(year, month, day) * 86_400 + hour * 3_600 + min * 60 + sec;
|
|
||||||
u64::try_from(secs).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Days since the Unix epoch for a proleptic-Gregorian date.
|
|
||||||
/// Howard Hinnant's algorithm: <http://howardhinnant.github.io/date_algorithms.html>
|
|
||||||
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
|
||||||
let y = if m <= 2 { y - 1 } else { y };
|
|
||||||
let era = (if y >= 0 { y } else { y - 399 }) / 400;
|
|
||||||
let yoe = y - era * 400;
|
|
||||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
|
||||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
|
||||||
era * 146_097 + doe - 719_468
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_privmsg_with_prefix() {
|
|
||||||
let m = Message::parse(":alice!a@host PRIVMSG #chan :hi there\r\n").unwrap();
|
|
||||||
assert_eq!(m.command, "PRIVMSG");
|
|
||||||
assert_eq!(m.nick(), Some("alice"));
|
|
||||||
assert_eq!(m.param(0), Some("#chan"));
|
|
||||||
assert_eq!(m.trailing(), Some("hi there"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_ping_without_prefix() {
|
|
||||||
let m = Message::parse("PING :LAG1234").unwrap();
|
|
||||||
assert_eq!(m.command, "PING");
|
|
||||||
assert!(m.prefix.is_none());
|
|
||||||
assert_eq!(m.trailing(), Some("LAG1234"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_and_unescapes_tags() {
|
|
||||||
let m = Message::parse("@id=123;key=a\\sb\\:c :n!u@h PRIVMSG #c :yo").unwrap();
|
|
||||||
assert_eq!(m.tags.get("id").map(String::as_str), Some("123"));
|
|
||||||
assert_eq!(m.tags.get("key").map(String::as_str), Some("a b;c"));
|
|
||||||
assert_eq!(m.command, "PRIVMSG");
|
|
||||||
assert_eq!(m.trailing(), Some("yo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_numeric_reply() {
|
|
||||||
let m = Message::parse(":srv 001 me :Welcome to the network").unwrap();
|
|
||||||
assert_eq!(m.command, "001");
|
|
||||||
assert_eq!(m.param(0), Some("me"));
|
|
||||||
assert_eq!(m.trailing(), Some("Welcome to the network"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn full_prefix_is_split() {
|
|
||||||
let p = Prefix::parse("nick!user@host.example");
|
|
||||||
assert_eq!(p.nick.as_deref(), Some("nick"));
|
|
||||||
assert_eq!(p.user.as_deref(), Some("user"));
|
|
||||||
assert_eq!(p.host.as_deref(), Some("host.example"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blank_line_is_none() {
|
|
||||||
assert!(Message::parse(" \r\n").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn base64_matches_known_vectors() {
|
|
||||||
assert_eq!(base64_encode(b""), "");
|
|
||||||
assert_eq!(base64_encode(b"f"), "Zg==");
|
|
||||||
assert_eq!(base64_encode(b"fo"), "Zm8=");
|
|
||||||
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
|
|
||||||
// A SASL PLAIN payload: authzid="" authcid="me" passwd="pw"
|
|
||||||
assert_eq!(base64_encode(b"\0me\0pw"), "AG1lAHB3");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_time_parses_to_unix_seconds() {
|
|
||||||
assert_eq!(parse_server_time("1970-01-01T00:00:00.000Z"), Some(0));
|
|
||||||
assert_eq!(parse_server_time("2020-01-01T00:00:00Z"), Some(1_577_836_800));
|
|
||||||
assert_eq!(parse_server_time("2021-01-01T00:00:00.123Z"), Some(1_609_459_200));
|
|
||||||
assert_eq!(parse_server_time("not-a-timestamp"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reads_message_tags_via_accessor() {
|
|
||||||
let m = Message::parse("@time=2021-01-01T00:00:00Z;batch=42 :s PRIVMSG #c :hi").unwrap();
|
|
||||||
assert_eq!(m.tag("batch"), Some("42"));
|
|
||||||
assert_eq!(m.tag("time"), Some("2021-01-01T00:00:00Z"));
|
|
||||||
assert_eq!(m.tag("missing"), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
161
src/irc/connection.rs
Normal file
161
src/irc/connection.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
use std::io::{self, BufRead, BufReader, Read, Write};
|
||||||
|
use std::net::TcpStream;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use native_tls::TlsConnector;
|
||||||
|
|
||||||
|
use super::Message;
|
||||||
|
|
||||||
|
const MAX_LINE: usize = 500;
|
||||||
|
const SEND_THROTTLE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
|
trait Stream: Read + Write {}
|
||||||
|
impl<T: Read + Write> Stream for T {}
|
||||||
|
|
||||||
|
pub struct Connection {
|
||||||
|
inner: BufReader<Box<dyn Stream>>,
|
||||||
|
log_prefix: String,
|
||||||
|
verbose: bool,
|
||||||
|
throttle: Duration,
|
||||||
|
last_send: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Connection {
|
||||||
|
pub fn connect(host: &str, port: u16, use_tls: bool, verbose: bool) -> io::Result<Connection> {
|
||||||
|
let tcp = TcpStream::connect((host, port))?;
|
||||||
|
let stream: Box<dyn Stream> = if use_tls {
|
||||||
|
let connector =
|
||||||
|
TlsConnector::new().map_err(|e| io::Error::other(format!("TLS init: {e}")))?;
|
||||||
|
let tls = connector
|
||||||
|
.connect(host, tcp)
|
||||||
|
.map_err(|e| io::Error::other(format!("TLS handshake: {e}")))?;
|
||||||
|
Box::new(tls)
|
||||||
|
} else {
|
||||||
|
Box::new(tcp)
|
||||||
|
};
|
||||||
|
Ok(Connection {
|
||||||
|
inner: BufReader::new(stream),
|
||||||
|
log_prefix: String::new(),
|
||||||
|
verbose,
|
||||||
|
throttle: SEND_THROTTLE,
|
||||||
|
last_send: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_stream<S: Read + Write + 'static>(stream: S) -> Connection {
|
||||||
|
let boxed: Box<dyn Stream> = Box::new(stream);
|
||||||
|
Connection {
|
||||||
|
inner: BufReader::new(boxed),
|
||||||
|
log_prefix: String::new(),
|
||||||
|
verbose: false,
|
||||||
|
throttle: Duration::ZERO,
|
||||||
|
last_send: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_label(&mut self, label: &str) {
|
||||||
|
self.log_prefix = if label.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("[{label}] ")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_message(&mut self) -> io::Result<Option<Message>> {
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
if self.inner.read_line(&mut line)? == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let trimmed = line.trim_end_matches(['\r', '\n']);
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.verbose {
|
||||||
|
eprintln!("{}<< {trimmed}", self.log_prefix);
|
||||||
|
}
|
||||||
|
if let Some(msg) = Message::parse(trimmed) {
|
||||||
|
return Ok(Some(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_raw(&mut self, line: &str) -> io::Result<()> {
|
||||||
|
if !self.throttle.is_zero() {
|
||||||
|
if let Some(last) = self.last_send {
|
||||||
|
let elapsed = last.elapsed();
|
||||||
|
if elapsed < self.throttle {
|
||||||
|
std::thread::sleep(self.throttle - elapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_send = Some(Instant::now());
|
||||||
|
}
|
||||||
|
let clean = line.replace(['\r', '\n'], " ");
|
||||||
|
let clean = truncate_to(&clean, MAX_LINE);
|
||||||
|
if self.verbose {
|
||||||
|
eprintln!("{}>> {clean}", self.log_prefix);
|
||||||
|
}
|
||||||
|
let writer = self.inner.get_mut();
|
||||||
|
write!(writer, "{clean}\r\n")?;
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pass(&mut self, password: &str) -> io::Result<()> {
|
||||||
|
self.send_raw(&format!("PASS {password}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn nick(&mut self, nick: &str) -> io::Result<()> {
|
||||||
|
self.send_raw(&format!("NICK {nick}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user(&mut self, user: &str, realname: &str) -> io::Result<()> {
|
||||||
|
self.send_raw(&format!("USER {user} 0 * :{realname}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn join(&mut self, channel: &str) -> io::Result<()> {
|
||||||
|
self.send_raw(&format!("JOIN {channel}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pong(&mut self, token: &str) -> io::Result<()> {
|
||||||
|
self.send_raw(&format!("PONG :{token}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn privmsg(&mut self, target: &str, text: &str) -> io::Result<()> {
|
||||||
|
self.send_raw(&format!("PRIVMSG {target} :{text}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn notice(&mut self, target: &str, text: &str) -> io::Result<()> {
|
||||||
|
self.send_raw(&format!("NOTICE {target} :{text}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn quit(&mut self, reason: &str) -> io::Result<()> {
|
||||||
|
self.send_raw(&format!("QUIT :{reason}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cap_ls(&mut self) -> io::Result<()> {
|
||||||
|
self.send_raw("CAP LS 302")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cap_req(&mut self, caps: &str) -> io::Result<()> {
|
||||||
|
self.send_raw(&format!("CAP REQ :{caps}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cap_end(&mut self) -> io::Result<()> {
|
||||||
|
self.send_raw("CAP END")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authenticate(&mut self, payload: &str) -> io::Result<()> {
|
||||||
|
self.send_raw(&format!("AUTHENTICATE {payload}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_to(s: &str, max: usize) -> &str {
|
||||||
|
if s.len() <= max {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
let mut end = max;
|
||||||
|
while end > 0 && !s.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
&s[..end]
|
||||||
|
}
|
||||||
49
src/irc/encoding.rs
Normal file
49
src/irc/encoding.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
pub fn base64_encode(input: &[u8]) -> String {
|
||||||
|
const ALPHABET: &[u8; 64] =
|
||||||
|
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
|
||||||
|
for chunk in input.chunks(3) {
|
||||||
|
let b0 = chunk[0] as u32;
|
||||||
|
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
|
||||||
|
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
|
||||||
|
let n = (b0 << 16) | (b1 << 8) | b2;
|
||||||
|
out.push(ALPHABET[(n >> 18 & 63) as usize] as char);
|
||||||
|
out.push(ALPHABET[(n >> 12 & 63) as usize] as char);
|
||||||
|
out.push(if chunk.len() > 1 { ALPHABET[(n >> 6 & 63) as usize] as char } else { '=' });
|
||||||
|
out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' });
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_unix() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_server_time(s: &str) -> Option<u64> {
|
||||||
|
let (date, time) = s.split_once('T')?;
|
||||||
|
let time = time.trim_end_matches('Z').split('.').next()?;
|
||||||
|
let mut d = date.splitn(3, '-');
|
||||||
|
let year: i64 = d.next()?.parse().ok()?;
|
||||||
|
let month: i64 = d.next()?.parse().ok()?;
|
||||||
|
let day: i64 = d.next()?.parse().ok()?;
|
||||||
|
let mut t = time.splitn(3, ':');
|
||||||
|
let hour: i64 = t.next()?.parse().ok()?;
|
||||||
|
let min: i64 = t.next()?.parse().ok()?;
|
||||||
|
let sec: i64 = t.next()?.parse().ok()?;
|
||||||
|
let secs = days_from_civil(year, month, day) * 86_400 + hour * 3_600 + min * 60 + sec;
|
||||||
|
u64::try_from(secs).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||||
|
let y = if m <= 2 { y - 1 } else { y };
|
||||||
|
let era = (if y >= 0 { y } else { y - 399 }) / 400;
|
||||||
|
let yoe = y - era * 400;
|
||||||
|
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||||
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||||
|
era * 146_097 + doe - 719_468
|
||||||
|
}
|
||||||
143
src/irc/message.rs
Normal file
143
src/irc/message.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Prefix {
|
||||||
|
pub raw: String,
|
||||||
|
pub nick: Option<String>,
|
||||||
|
pub user: Option<String>,
|
||||||
|
pub host: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Prefix {
|
||||||
|
fn parse(raw: &str) -> Prefix {
|
||||||
|
let (nick, user, host) = if let Some((n, rest)) = raw.split_once('!') {
|
||||||
|
match rest.split_once('@') {
|
||||||
|
Some((u, h)) => (Some(n), Some(u), Some(h)),
|
||||||
|
None => (Some(n), Some(rest), None),
|
||||||
|
}
|
||||||
|
} else if let Some((n, h)) = raw.split_once('@') {
|
||||||
|
(Some(n), None, Some(h))
|
||||||
|
} else {
|
||||||
|
(Some(raw), None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
Prefix {
|
||||||
|
raw: raw.to_string(),
|
||||||
|
nick: nick.map(str::to_string),
|
||||||
|
user: user.map(str::to_string),
|
||||||
|
host: host.map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
self.nick.as_deref().unwrap_or(&self.raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Message {
|
||||||
|
pub tags: HashMap<String, String>,
|
||||||
|
pub prefix: Option<Prefix>,
|
||||||
|
pub command: String,
|
||||||
|
pub params: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Message {
|
||||||
|
pub fn parse(line: &str) -> Option<Message> {
|
||||||
|
let mut rest = line.trim_end_matches(['\r', '\n']).trim_start();
|
||||||
|
if rest.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tags = HashMap::new();
|
||||||
|
if let Some(stripped) = rest.strip_prefix('@') {
|
||||||
|
let (tag_str, r) = stripped.split_once(' ')?;
|
||||||
|
rest = r.trim_start();
|
||||||
|
for item in tag_str.split(';').filter(|s| !s.is_empty()) {
|
||||||
|
match item.split_once('=') {
|
||||||
|
Some((k, v)) => tags.insert(k.to_string(), unescape_tag_value(v)),
|
||||||
|
None => tags.insert(item.to_string(), String::new()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut prefix = None;
|
||||||
|
if let Some(stripped) = rest.strip_prefix(':') {
|
||||||
|
let (p, r) = stripped.split_once(' ')?;
|
||||||
|
prefix = Some(Prefix::parse(p));
|
||||||
|
rest = r.trim_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (command, mut rest) = match rest.split_once(' ') {
|
||||||
|
Some((c, r)) => (c, r.trim_start()),
|
||||||
|
None => (rest, ""),
|
||||||
|
};
|
||||||
|
if command.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut params = Vec::new();
|
||||||
|
while !rest.is_empty() {
|
||||||
|
if let Some(trailing) = rest.strip_prefix(':') {
|
||||||
|
params.push(trailing.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match rest.split_once(' ') {
|
||||||
|
Some((p, r)) => {
|
||||||
|
if !p.is_empty() {
|
||||||
|
params.push(p.to_string());
|
||||||
|
}
|
||||||
|
rest = r.trim_start();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
params.push(rest.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Message {
|
||||||
|
tags,
|
||||||
|
prefix,
|
||||||
|
command: command.to_uppercase(),
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn param(&self, index: usize) -> Option<&str> {
|
||||||
|
self.params.get(index).map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn trailing(&self) -> Option<&str> {
|
||||||
|
self.params.last().map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn nick(&self) -> Option<&str> {
|
||||||
|
self.prefix.as_ref().and_then(|p| p.nick.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tag(&self, key: &str) -> Option<&str> {
|
||||||
|
self.tags.get(key).map(String::as_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unescape_tag_value(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len());
|
||||||
|
let mut chars = value.chars();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c != '\\' {
|
||||||
|
out.push(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match chars.next() {
|
||||||
|
Some(':') => out.push(';'),
|
||||||
|
Some('s') => out.push(' '),
|
||||||
|
Some('\\') => out.push('\\'),
|
||||||
|
Some('r') => out.push('\r'),
|
||||||
|
Some('n') => out.push('\n'),
|
||||||
|
Some(other) => out.push(other),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
9
src/irc/mod.rs
Normal file
9
src/irc/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
mod connection;
|
||||||
|
mod encoding;
|
||||||
|
mod message;
|
||||||
|
|
||||||
|
pub use connection::Connection;
|
||||||
|
pub use encoding::{base64_encode, now_unix, parse_server_time};
|
||||||
|
pub use message::{Message, Prefix};
|
||||||
9
src/lib.rs
Normal file
9
src/lib.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub mod bot;
|
||||||
|
pub mod config;
|
||||||
|
pub mod irc;
|
||||||
|
|
||||||
|
pub fn log(msg: &str) {
|
||||||
|
eprintln!("[rustbot] {msg}");
|
||||||
|
}
|
||||||
196
src/main.rs
196
src/main.rs
|
|
@ -1,166 +1,70 @@
|
||||||
//! rustbot — a small, modular IRC bot.
|
use std::thread;
|
||||||
//!
|
|
||||||
//! Layers:
|
|
||||||
//! * `irc` — protocol + transport (the "IRC library")
|
|
||||||
//! * `bot` — behavior: registration, event loop, commands
|
|
||||||
//! * `main` — configuration and the connect/reconnect supervisor
|
|
||||||
//!
|
|
||||||
//! Configuration is layered (see [`load_config`]): built-in defaults, then a
|
|
||||||
//! `key = value` config file, then environment variables — so the same binary
|
|
||||||
//! can target any network without a rebuild.
|
|
||||||
|
|
||||||
mod bot;
|
|
||||||
mod irc;
|
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
use std::io;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use bot::{Bot, Config};
|
use rustbot::bot::Bot;
|
||||||
use irc::Connection;
|
use rustbot::config::{self, Config};
|
||||||
|
use rustbot::irc::Connection;
|
||||||
|
use rustbot::log;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let config = load_config();
|
let configs = config::load();
|
||||||
|
if configs.is_empty() {
|
||||||
|
log("no networks configured; nothing to do");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let names: Vec<&str> = configs.iter().map(|c| c.name.as_str()).collect();
|
||||||
log(&format!(
|
log(&format!(
|
||||||
"starting rustbot as {} -> {}:{}",
|
"starting rustbot across {} network(s): {}",
|
||||||
config.nick, config.server, config.port
|
configs.len(),
|
||||||
|
names.join(", ")
|
||||||
|
));
|
||||||
|
|
||||||
|
let labelled = configs.len() > 1;
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for config in configs {
|
||||||
|
let name = config.name.clone();
|
||||||
|
let handle = thread::Builder::new()
|
||||||
|
.name(name.clone())
|
||||||
|
.spawn(move || supervise(config, labelled))
|
||||||
|
.unwrap_or_else(|e| panic!("failed to spawn thread for {name}: {e}"));
|
||||||
|
handles.push(handle);
|
||||||
|
}
|
||||||
|
for handle in handles {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supervise(config: Config, labelled: bool) {
|
||||||
|
let tag = if labelled {
|
||||||
|
format!("[{}] ", config.name)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
log(&format!(
|
||||||
|
"{tag}connecting as {} -> {}:{} (tls={})",
|
||||||
|
config.nick, config.server, config.port, config.tls
|
||||||
));
|
));
|
||||||
|
|
||||||
// Supervisor: keep the bot alive across disconnects with capped backoff.
|
|
||||||
let mut backoff = 1u64;
|
let mut backoff = 1u64;
|
||||||
loop {
|
loop {
|
||||||
match run_once(&config) {
|
match run_once(&config, labelled) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
log("server closed the connection; reconnecting shortly");
|
log(&format!("{tag}server closed the connection; reconnecting shortly"));
|
||||||
backoff = 1;
|
backoff = 1;
|
||||||
}
|
}
|
||||||
Err(e) => log(&format!("session error: {e}; retrying in {backoff}s")),
|
Err(e) => log(&format!("{tag}session error: {e}; retrying in {backoff}s")),
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_secs(backoff));
|
thread::sleep(Duration::from_secs(backoff));
|
||||||
backoff = (backoff * 2).min(60);
|
backoff = (backoff * 2).min(60);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a single connection lifecycle: connect, register, loop until disconnect.
|
fn run_once(config: &Config, labelled: bool) -> std::io::Result<()> {
|
||||||
fn run_once(config: &Config) -> std::io::Result<()> {
|
let mut conn = Connection::connect(&config.server, config.port, config.tls, config.verbose)?;
|
||||||
let conn = Connection::connect(&config.server, config.port)?;
|
if labelled {
|
||||||
|
conn.set_label(&config.name);
|
||||||
|
}
|
||||||
let mut bot = Bot::new(config.clone(), conn);
|
let mut bot = Bot::new(config.clone(), conn);
|
||||||
bot.run()
|
bot.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the [`Config`] by layering sources in increasing priority:
|
|
||||||
/// built-in defaults, then a config file, then environment variables.
|
|
||||||
fn load_config() -> Config {
|
|
||||||
let mut c = Config::default();
|
|
||||||
|
|
||||||
if let Some(path) = config_path() {
|
|
||||||
match apply_config_file(&mut c, &path) {
|
|
||||||
Ok(()) => log(&format!("loaded config from {}", path.display())),
|
|
||||||
Err(e) => log(&format!("could not read config {}: {e}", path.display())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
apply_env(&mut c);
|
|
||||||
|
|
||||||
c
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decide which config file to read: CLI argument, then `RUSTBOT_CONFIG`, then
|
|
||||||
/// `./rustbot.conf` if it happens to exist. Returns `None` when there is none.
|
|
||||||
fn config_path() -> Option<PathBuf> {
|
|
||||||
if let Some(arg) = std::env::args().nth(1) {
|
|
||||||
return Some(PathBuf::from(arg));
|
|
||||||
}
|
|
||||||
if let Ok(env) = std::env::var("RUSTBOT_CONFIG") {
|
|
||||||
return Some(PathBuf::from(env));
|
|
||||||
}
|
|
||||||
let default = PathBuf::from("rustbot.conf");
|
|
||||||
default.exists().then_some(default)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a `key = value` config file onto `c`.
|
|
||||||
///
|
|
||||||
/// Blank lines are ignored. A line whose first non-space character is `#` or
|
|
||||||
/// `;` is a comment — note that inline comments are *not* supported, because
|
|
||||||
/// IRC channel names legitimately contain `#`.
|
|
||||||
fn apply_config_file(c: &mut Config, path: &Path) -> io::Result<()> {
|
|
||||||
let content = fs::read_to_string(path)?;
|
|
||||||
for (i, raw) in content.lines().enumerate() {
|
|
||||||
let line = raw.trim();
|
|
||||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some((key, value)) = line.split_once('=') else {
|
|
||||||
log(&format!("{}:{}: ignoring line without '='", path.display(), i + 1));
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let (key, value) = (key.trim().to_ascii_lowercase(), value.trim());
|
|
||||||
match key.as_str() {
|
|
||||||
"server" => c.server = value.to_string(),
|
|
||||||
"port" => match value.parse() {
|
|
||||||
Ok(p) => c.port = p,
|
|
||||||
Err(_) => log(&format!("{}:{}: invalid port '{value}'", path.display(), i + 1)),
|
|
||||||
},
|
|
||||||
"nick" => c.nick = value.to_string(),
|
|
||||||
"user" => c.user = value.to_string(),
|
|
||||||
"realname" => c.realname = value.to_string(),
|
|
||||||
"channels" => c.channels = split_list(value),
|
|
||||||
"prefix" | "command_prefix" => c.command_prefix = value.to_string(),
|
|
||||||
"password" => c.password = (!value.is_empty()).then(|| value.to_string()),
|
|
||||||
"sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()),
|
|
||||||
"sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()),
|
|
||||||
other => log(&format!("{}:{}: unknown key '{other}'", path.display(), i + 1)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Overlay environment variables onto `c` (highest priority — handy for
|
|
||||||
/// one-off overrides without editing the file).
|
|
||||||
///
|
|
||||||
/// Vars: `IRC_SERVER`, `IRC_PORT`, `IRC_NICK`, `IRC_REALNAME`,
|
|
||||||
/// `IRC_CHANNELS` (comma/space-separated), `IRC_PREFIX`, `IRC_PASSWORD`.
|
|
||||||
fn apply_env(c: &mut Config) {
|
|
||||||
if let Ok(v) = std::env::var("IRC_SERVER") {
|
|
||||||
c.server = v;
|
|
||||||
}
|
|
||||||
if let Some(p) = std::env::var("IRC_PORT").ok().and_then(|v| v.parse().ok()) {
|
|
||||||
c.port = p;
|
|
||||||
}
|
|
||||||
if let Ok(v) = std::env::var("IRC_NICK") {
|
|
||||||
c.user = v.clone();
|
|
||||||
c.nick = v;
|
|
||||||
}
|
|
||||||
if let Ok(v) = std::env::var("IRC_REALNAME") {
|
|
||||||
c.realname = v;
|
|
||||||
}
|
|
||||||
if let Ok(v) = std::env::var("IRC_CHANNELS") {
|
|
||||||
c.channels = split_list(&v);
|
|
||||||
}
|
|
||||||
if let Ok(v) = std::env::var("IRC_PREFIX") {
|
|
||||||
c.command_prefix = v;
|
|
||||||
}
|
|
||||||
if let Ok(v) = std::env::var("IRC_PASSWORD") {
|
|
||||||
c.password = Some(v);
|
|
||||||
}
|
|
||||||
if let Ok(v) = std::env::var("IRC_SASL_USER") {
|
|
||||||
c.sasl_user = Some(v);
|
|
||||||
}
|
|
||||||
if let Ok(v) = std::env::var("IRC_SASL_PASS") {
|
|
||||||
c.sasl_pass = Some(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Split a channel list on commas and/or whitespace, dropping empties.
|
|
||||||
fn split_list(value: &str) -> Vec<String> {
|
|
||||||
value
|
|
||||||
.split(|ch: char| ch == ',' || ch.is_whitespace())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tiny stderr logger. Swap for the `log`/`tracing` crates if you outgrow it.
|
|
||||||
fn log(msg: &str) {
|
|
||||||
eprintln!("[rustbot] {msg}");
|
|
||||||
}
|
|
||||||
|
|
|
||||||
78
tests/bot.rs
Normal file
78
tests/bot.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
use std::io::{self, Cursor, Read, Write};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use rustbot::bot::Bot;
|
||||||
|
use rustbot::config::Config;
|
||||||
|
use rustbot::irc::Connection;
|
||||||
|
|
||||||
|
struct Mock {
|
||||||
|
input: Cursor<Vec<u8>>,
|
||||||
|
output: Arc<Mutex<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Read for Mock {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
self.input.read(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Write for Mock {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
|
self.output.lock().unwrap().extend_from_slice(buf);
|
||||||
|
Ok(buf.len())
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(server: &str) -> String {
|
||||||
|
let output = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let mock = Mock {
|
||||||
|
input: Cursor::new(server.as_bytes().to_vec()),
|
||||||
|
output: output.clone(),
|
||||||
|
};
|
||||||
|
let conn = Connection::from_stream(mock);
|
||||||
|
let mut bot = Bot::new(Config::default(), conn);
|
||||||
|
let _ = bot.run();
|
||||||
|
let bytes = output.lock().unwrap().clone();
|
||||||
|
String::from_utf8_lossy(&bytes).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sends_registration() {
|
||||||
|
let out = run("");
|
||||||
|
assert!(out.contains("CAP LS"), "{out}");
|
||||||
|
assert!(out.contains("NICK rubot"), "{out}");
|
||||||
|
assert!(out.contains("USER rubot"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn answers_ping() {
|
||||||
|
let out = run("PING :tok123\r\n");
|
||||||
|
assert!(out.contains("PONG :tok123"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn joins_configured_channels_on_welcome() {
|
||||||
|
let out = run(":srv 001 rubot :welcome\r\n");
|
||||||
|
assert!(out.contains("JOIN #devs"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn appends_underscore_on_nick_in_use() {
|
||||||
|
let out = run(":srv 433 * rubot :nickname is already in use\r\n");
|
||||||
|
assert!(out.contains("NICK rubot_"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatches_command_to_module() {
|
||||||
|
let out = run(":alice!a@h PRIVMSG #chan :!ping\r\n");
|
||||||
|
assert!(out.contains("PRIVMSG #chan :pong"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_own_echoed_message() {
|
||||||
|
let out = run(":rubot!r@h PRIVMSG #chan :!ping\r\n");
|
||||||
|
assert!(!out.contains(":pong"), "{out}");
|
||||||
|
}
|
||||||
50
tests/config.rs
Normal file
50
tests/config.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
use rustbot::config::parse_str;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flat_config_is_a_single_network() {
|
||||||
|
let cfgs = parse_str("server = irc.example.org\nnick = flatbot\nchannels = #a #b\n");
|
||||||
|
assert_eq!(cfgs.len(), 1);
|
||||||
|
assert_eq!(cfgs[0].server, "irc.example.org");
|
||||||
|
assert_eq!(cfgs[0].nick, "flatbot");
|
||||||
|
assert_eq!(cfgs[0].channels, vec!["#a", "#b"]);
|
||||||
|
assert_eq!(cfgs[0].name, "irc.example.org");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sections_inherit_top_level_defaults_and_override() {
|
||||||
|
let cfgs = parse_str(
|
||||||
|
"nick = shared\nchannels = #lobby\n\
|
||||||
|
[libera]\nserver = irc.libera.chat\ntls = true\n\
|
||||||
|
[oftc]\nserver = irc.oftc.net\nnick = other\nchannels = #oftc\n",
|
||||||
|
);
|
||||||
|
assert_eq!(cfgs.len(), 2);
|
||||||
|
|
||||||
|
let libera = &cfgs[0];
|
||||||
|
assert_eq!(libera.name, "libera");
|
||||||
|
assert_eq!(libera.server, "irc.libera.chat");
|
||||||
|
assert_eq!(libera.nick, "shared");
|
||||||
|
assert_eq!(libera.channels, vec!["#lobby"]);
|
||||||
|
assert!(libera.tls);
|
||||||
|
assert_eq!(libera.port, 6697);
|
||||||
|
|
||||||
|
let oftc = &cfgs[1];
|
||||||
|
assert_eq!(oftc.name, "oftc");
|
||||||
|
assert_eq!(oftc.server, "irc.oftc.net");
|
||||||
|
assert_eq!(oftc.nick, "other");
|
||||||
|
assert_eq!(oftc.channels, vec!["#oftc"]);
|
||||||
|
assert!(!oftc.tls);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_section_may_rename_itself() {
|
||||||
|
let cfgs = parse_str("[net]\nname = pretty\nserver = irc.example.org\n");
|
||||||
|
assert_eq!(cfgs.len(), 1);
|
||||||
|
assert_eq!(cfgs[0].name, "pretty");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comments_and_blanks_are_ignored() {
|
||||||
|
let cfgs = parse_str("# comment\n; also\n\n \n");
|
||||||
|
assert_eq!(cfgs.len(), 1);
|
||||||
|
assert_eq!(cfgs[0].server, "irc.tchatou.fr");
|
||||||
|
}
|
||||||
76
tests/hash.rs
Normal file
76
tests/hash.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
use rustbot::bot::module::{Action, Command, Module};
|
||||||
|
use rustbot::bot::modules::hash::{md5, sha1, sha256, Hash};
|
||||||
|
|
||||||
|
fn cmd<'a>(name: &'a str, args: &'a [&'a str]) -> Command<'a> {
|
||||||
|
Command { sender: "u", reply_to: "#c", name, args, prefix: ">", network: "t" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(actions: &[Action]) -> &str {
|
||||||
|
match actions {
|
||||||
|
[Action::Reply(s)] => s,
|
||||||
|
_ => panic!("expected exactly one Reply"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MULTI: &[u8] = b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn md5_vectors() {
|
||||||
|
assert_eq!(md5(b""), "d41d8cd98f00b204e9800998ecf8427e");
|
||||||
|
assert_eq!(md5(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
|
||||||
|
assert_eq!(
|
||||||
|
md5(b"The quick brown fox jumps over the lazy dog"),
|
||||||
|
"9e107d9d372bb6826bd81d3542a419d6"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
md5(b"12345678901234567890123456789012345678901234567890123456789012345678901234567890"),
|
||||||
|
"57edf4a22be3c955ac49da2e2107b67a"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sha1_vectors() {
|
||||||
|
assert_eq!(sha1(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709");
|
||||||
|
assert_eq!(sha1(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
|
||||||
|
assert_eq!(sha1(MULTI), "84983e441c3bd26ebaae4aa1f95129e5e54670f1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sha256_vectors() {
|
||||||
|
assert_eq!(
|
||||||
|
sha256(b""),
|
||||||
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sha256(b"abc"),
|
||||||
|
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sha256(MULTI),
|
||||||
|
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn module_single_hash() {
|
||||||
|
let mut m = Hash;
|
||||||
|
assert_eq!(
|
||||||
|
reply(&m.on_command(&cmd("sha256", &["abc"]))),
|
||||||
|
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn module_all_hashes() {
|
||||||
|
let mut m = Hash;
|
||||||
|
let r = reply(&m.on_command(&cmd("hash", &["abc"]))).to_string();
|
||||||
|
assert!(r.contains("md5: 900150983cd24fb0d6963f7d28e17f72"), "{r}");
|
||||||
|
assert!(r.contains("sha1: a9993e364706816aba3e25717850c26c9cd0d89d"), "{r}");
|
||||||
|
assert!(r.contains("sha256: ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"), "{r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn module_usage_when_empty() {
|
||||||
|
let mut m = Hash;
|
||||||
|
assert!(reply(&m.on_command(&cmd("md5", &[]))).contains("usage"));
|
||||||
|
}
|
||||||
79
tests/modules.rs
Normal file
79
tests/modules.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
use rustbot::bot::module::{Action, Command, Module};
|
||||||
|
use rustbot::bot::modules::{builtins::Builtins, dice::Dice};
|
||||||
|
|
||||||
|
fn cmd<'a>(name: &'a str, args: &'a [&'a str]) -> Command<'a> {
|
||||||
|
Command { sender: "alice", reply_to: "#chan", name, args, prefix: ">", network: "test" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(actions: &[Action]) -> &str {
|
||||||
|
match actions {
|
||||||
|
[Action::Reply(s)] => s,
|
||||||
|
_ => panic!("expected exactly one Reply action"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtins_ping_pongs() {
|
||||||
|
let mut m = Builtins;
|
||||||
|
assert_eq!(reply(&m.on_command(&cmd("ping", &[]))), "pong");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtins_echo_joins_args() {
|
||||||
|
let mut m = Builtins;
|
||||||
|
assert_eq!(reply(&m.on_command(&cmd("echo", &["hello", "world"]))), "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtins_hello_greets_sender() {
|
||||||
|
let mut m = Builtins;
|
||||||
|
assert_eq!(reply(&m.on_command(&cmd("hello", &[]))), "hello, alice!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dice_declares_roll_command() {
|
||||||
|
let m = Dice::new();
|
||||||
|
assert_eq!(m.name(), "dice");
|
||||||
|
assert!(m.commands().iter().any(|c| c.name == "roll"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dice_2d6_total_is_in_range() {
|
||||||
|
let mut d = Dice::new();
|
||||||
|
for _ in 0..300 {
|
||||||
|
let r = reply(&d.on_command(&cmd("roll", &["2d6"]))).to_string();
|
||||||
|
let total: u64 = r.rsplit('=').next().unwrap().trim().parse().unwrap();
|
||||||
|
assert!((2..=12).contains(&total), "2d6 total {total} out of range: {r:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dice_defaults_to_1d6() {
|
||||||
|
let mut d = Dice::new();
|
||||||
|
let r = reply(&d.on_command(&cmd("roll", &[]))).to_string();
|
||||||
|
assert!(r.starts_with("1d6: "), "unexpected format: {r:?}");
|
||||||
|
let n: u64 = r.rsplit(':').next().unwrap().trim().parse().unwrap();
|
||||||
|
assert!((1..=6).contains(&n), "1d6 {n} out of range: {r:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dice_bare_number_means_one_die() {
|
||||||
|
let mut d = Dice::new();
|
||||||
|
let r = reply(&d.on_command(&cmd("roll", &["20"]))).to_string();
|
||||||
|
assert!(r.starts_with("1d20: "), "expected 1d20, got {r:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dice_rejects_garbage_with_usage() {
|
||||||
|
let mut d = Dice::new();
|
||||||
|
let actions = d.on_command(&cmd("roll", &["banana"]));
|
||||||
|
let r = reply(&actions);
|
||||||
|
assert!(r.contains("usage"), "expected a usage hint, got {r:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dice_rejects_out_of_bounds() {
|
||||||
|
let mut d = Dice::new();
|
||||||
|
assert!(reply(&d.on_command(&cmd("roll", &["999d6"]))).contains("usage"));
|
||||||
|
assert!(reply(&d.on_command(&cmd("roll", &["1d1"]))).contains("usage"));
|
||||||
|
}
|
||||||
75
tests/protocol.rs
Normal file
75
tests/protocol.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
use rustbot::irc::{base64_encode, parse_server_time, Message};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_privmsg_with_prefix() {
|
||||||
|
let m = Message::parse(":alice!a@host PRIVMSG #chan :hi there\r\n").unwrap();
|
||||||
|
assert_eq!(m.command, "PRIVMSG");
|
||||||
|
assert_eq!(m.nick(), Some("alice"));
|
||||||
|
assert_eq!(m.param(0), Some("#chan"));
|
||||||
|
assert_eq!(m.trailing(), Some("hi there"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_ping_without_prefix() {
|
||||||
|
let m = Message::parse("PING :LAG1234").unwrap();
|
||||||
|
assert_eq!(m.command, "PING");
|
||||||
|
assert!(m.prefix.is_none());
|
||||||
|
assert_eq!(m.trailing(), Some("LAG1234"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_and_unescapes_tags() {
|
||||||
|
let m = Message::parse("@id=123;key=a\\sb\\:c :n!u@h PRIVMSG #c :yo").unwrap();
|
||||||
|
assert_eq!(m.tag("id"), Some("123"));
|
||||||
|
assert_eq!(m.tag("key"), Some("a b;c"));
|
||||||
|
assert_eq!(m.command, "PRIVMSG");
|
||||||
|
assert_eq!(m.trailing(), Some("yo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_numeric_reply() {
|
||||||
|
let m = Message::parse(":srv 001 me :Welcome to the network").unwrap();
|
||||||
|
assert_eq!(m.command, "001");
|
||||||
|
assert_eq!(m.param(0), Some("me"));
|
||||||
|
assert_eq!(m.trailing(), Some("Welcome to the network"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_prefix_is_split() {
|
||||||
|
let m = Message::parse(":nick!user@host.example PRIVMSG #c :hi").unwrap();
|
||||||
|
let p = m.prefix.expect("prefix present");
|
||||||
|
assert_eq!(p.nick.as_deref(), Some("nick"));
|
||||||
|
assert_eq!(p.user.as_deref(), Some("user"));
|
||||||
|
assert_eq!(p.host.as_deref(), Some("host.example"));
|
||||||
|
assert_eq!(p.name(), "nick");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blank_line_is_none() {
|
||||||
|
assert!(Message::parse(" \r\n").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base64_matches_known_vectors() {
|
||||||
|
assert_eq!(base64_encode(b""), "");
|
||||||
|
assert_eq!(base64_encode(b"f"), "Zg==");
|
||||||
|
assert_eq!(base64_encode(b"fo"), "Zm8=");
|
||||||
|
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
|
||||||
|
assert_eq!(base64_encode(b"\0me\0pw"), "AG1lAHB3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_time_parses_to_unix_seconds() {
|
||||||
|
assert_eq!(parse_server_time("1970-01-01T00:00:00.000Z"), Some(0));
|
||||||
|
assert_eq!(parse_server_time("2020-01-01T00:00:00Z"), Some(1_577_836_800));
|
||||||
|
assert_eq!(parse_server_time("2021-01-01T00:00:00.123Z"), Some(1_609_459_200));
|
||||||
|
assert_eq!(parse_server_time("not-a-timestamp"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_message_tags_via_accessor() {
|
||||||
|
let m = Message::parse("@time=2021-01-01T00:00:00Z;batch=42 :s PRIVMSG #c :hi").unwrap();
|
||||||
|
assert_eq!(m.tag("batch"), Some("42"));
|
||||||
|
assert_eq!(m.tag("time"), Some("2021-01-01T00:00:00Z"));
|
||||||
|
assert_eq!(m.tag("missing"), None);
|
||||||
|
}
|
||||||
83
tests/weather.rs
Normal file
83
tests/weather.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use rustbot::bot::module::{Action, Command, Module};
|
||||||
|
use rustbot::bot::modules::weather::{format_weather, parse_db, to_json, Weather};
|
||||||
|
|
||||||
|
fn cmd<'a>(sender: &'a str, name: &'a str, args: &'a [&'a str]) -> Command<'a> {
|
||||||
|
Command { sender, reply_to: "#chan", name, args, prefix: ">", network: "test" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(actions: &[Action]) -> &str {
|
||||||
|
match actions {
|
||||||
|
[Action::Reply(s)] => s,
|
||||||
|
_ => panic!("expected exactly one Reply"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp(name: &str) -> PathBuf {
|
||||||
|
let p = std::env::temp_dir().join(format!("rubot-test-{name}.json"));
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_round_trip_handles_special_chars() {
|
||||||
|
let mut m = BTreeMap::new();
|
||||||
|
m.insert("alice".to_string(), "73700".to_string());
|
||||||
|
m.insert("bob".to_string(), "Saint-Étienne, FR".to_string());
|
||||||
|
m.insert("carol".to_string(), "a\"b\\c\nd".to_string());
|
||||||
|
let back = parse_db(&to_json(&m));
|
||||||
|
assert_eq!(m, back);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_db_tolerates_junk() {
|
||||||
|
assert!(parse_db("").is_empty());
|
||||||
|
assert!(parse_db("not json").is_empty());
|
||||||
|
assert_eq!(parse_db("{}").len(), 0);
|
||||||
|
assert_eq!(parse_db("{\"a\":\"b\"}").get("a").map(String::as_str), Some("b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_saves_and_persists_case_insensitively() {
|
||||||
|
let path = temp("weather-add");
|
||||||
|
let mut w = Weather::with_path(path.clone());
|
||||||
|
let r = reply(&w.on_command(&cmd("Alice", "add", &["73700"]))).to_string();
|
||||||
|
assert!(r.contains("73700"), "reply: {r:?}");
|
||||||
|
|
||||||
|
let w2 = Weather::with_path(path.clone());
|
||||||
|
assert_eq!(w2.location_of("alice"), Some("73700"));
|
||||||
|
assert_eq!(w2.location_of("ALICE"), Some("73700"));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_without_arg_shows_usage() {
|
||||||
|
let mut w = Weather::with_path(temp("weather-usage"));
|
||||||
|
let r = reply(&w.on_command(&cmd("alice", "add", &[]))).to_string();
|
||||||
|
assert!(r.contains("usage"), "reply: {r:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn w_without_saved_location_hints_to_add() {
|
||||||
|
let mut w = Weather::with_path(temp("weather-hint"));
|
||||||
|
let r = reply(&w.on_command(&cmd("nobody", "w", &[]))).to_string();
|
||||||
|
assert!(r.contains("add"), "reply: {r:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_weather_builds_friendly_line() {
|
||||||
|
let out = format_weather("73700|☀️ |Sunny|+23°C|+18°C|↘8km/h|45%", "73700");
|
||||||
|
assert!(out.contains("☀️"), "{out}");
|
||||||
|
assert!(out.contains("73700: Sunny"), "{out}");
|
||||||
|
assert!(out.contains("23°C") && !out.contains("+23"), "{out}");
|
||||||
|
assert!(out.contains("feels like 18°C"), "{out}");
|
||||||
|
assert!(out.contains("45%"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_weather_friendly_not_found() {
|
||||||
|
let out = format_weather("location not found: upstream error", "zzz");
|
||||||
|
assert!(out.contains("couldn't find 'zzz'"), "{out}");
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue