A modular IRC bot and multi-provider weather aggregator on Python's asyncio and RFC 2812. Worldwide weather (current, forecast, hourly, nowcast, air quality, UV, pollen, astronomy, alerts, wildfire, space weather, marine, tides, historical), stock/crypto/FX prices, movie and music lookups, dictionary and reference tools (Wikipedia, DOI, ISBN, RFC, arXiv, periodic table, tldr-pages), a large developer/encoding/network/security toolkit, IP geolocation and reputation, science/infosec/AI/BSD news feeds, and stateful IRC-native tools (seen, tell, remind, notes). Plugin architecture with hot-reload: modules load, unload, and reload without restarting the bot.
This README is the entry point. Deep dives live in docs/:
docs/architecture.md- core event loop, dispatch, state, the full module catalogdocs/configuration.md- everyconfig.inisection and keydocs/modules.md- writing a moduledocs/providers.md- adding a weather providerdocs/security-model.md- threat model, secret store, audit log, SSRF guarddocs/deployment.md- running headless, process lock, metrics, ops
Platform support: Linux, macOS, FreeBSD, Windows, WSL/WSL2, Cygwin, MinGW, MSYS2. Python: 3.10+ (CI runs 3.10 through 3.14). License: ISC.
The core (internets.py) owns a single asyncio event loop, the IRC state machine, and command dispatch. Everything else is a module under modules/. A module declares a COMMANDS dict mapping command name to async method name; each handler receives (nick, reply_to, arg) and replies through bot.privmsg() / bot.notice() / bot.reply() / bot.preply(). Every command invocation runs as its own asyncio.Task, capped at 50 concurrent.
Weather is served by a capability-based dispatcher over 32 provider packages (weather_providers/). The dispatcher auto-discovers each provider's capabilities by hasattr() on method names (get_weather, get_hourly, get_alerts, ...), then ranks the providers that support a requested capability by: (1) the static per-capability accuracy rank in _dispatch.DEFAULT_RELIABILITY, (2) live health (success rate, latency, rate-limit errors), (3) registration order from provider_priority. All responses normalize to shared dataclasses in weather_providers/base.py, so the command module never touches raw API JSON. Providers needing no key (NWS, Open-Meteo, MET Norway/Yr, SunriseSunset, GDACS, ECCC, NASA POWER, NIFC, NOAA SWPC, NOAA CO-OPS, currentuvindex) always register; keyed providers register only when their key is present in the secret store. For a current request the dispatcher skips an empty or all-N/A result and fills any missing secondary fields (dewpoint, pressure, visibility, humidity) from the next-ranked provider, bounded to 3 contributors and crediting both sources (e.g. NWS + Open-Meteo); see weather_providers/_dispatch.py plus has_gaps / fill_gaps in weather_providers/base.py.
internets.py asyncio event loop, IRC state machine, command dispatch, reconnect
admin_cmds.py AdminCommandsMixin: auth/load/reload/raw/say/stats/audit/shadow-ban/...
console.py interactive stdin console (TTY only)
protocol.py pure protocol helpers (ISUPPORT, MODE, SASL, NAMES parsing)
sender.py async outbound queue, token-bucket flood control, credential scrubbing
store.py in-memory state + periodic disk flush (locations, channels, users, opt-out)
config.py config.ini parsing, __version__, CLI argparse (cli_args)
botlog.py logging setup, _SafeFormatter (log-injection guard), rotation
hashpw.py admin password hashing/verify (scrypt/bcrypt/argon2)
secret_store.py two-tier secret store: INTERNETS_<NAME> env -> config.ini[secrets] (0600)
audit_log.py append-only HMAC-chained tamper-evident log of privileged actions
metrics.py opt-in Prometheus exporter (127.0.0.1 only; off by default)
process_lock.py PID lock with stale-detection; blocks a second instance corrupting state
weather_providers/ 32 provider packages + base dataclasses, _http, _dispatch, _health
modules/ ~70 command modules (see docs/architecture.md for the catalog)
modules/base.py BotModule base class, help_row(), strip_ctrl(), the plugin interface
modules/_netsafe.py SSRF-safe fetch with DNS-TOCTOU pinning (probe, scinews article reader)
tests/ run_tests.py (standalone) + 31 pytest modules
The outbound path goes through Sender, an async drain over asyncio.PriorityQueue implementing a token bucket (5 burst, ~40 msg/min sustained) to stay under flood limits. Protocol messages (PONG, CAP, NICK) bypass the bucket at priority 0. Sender.enqueue() is thread-safe via loop.call_soon_threadsafe(). The queue is bounded at 200 messages.
Key design points (full detail in docs/architecture.md):
- Blocking I/O off the loop. HTTP via
requests, password hashing, and disk writes run underasyncio.to_thread(). Weather provider calls preferaiohttpand fall back torequests+to_threadif aiohttp is absent. - Two-tier rate limiting. A per-nick flood gate (
flood_cooldown) drops fast-repeated commands; a separate API cooldown (api_cooldown) throttles geocode + weather calls. Admins bypass the flood gate but not the API cooldown. - Founder-gated channel control.
.join/.partrequire bot-admin or verified channel founder (WHOIS account + servicesINFO #channel). IRC-native/INVITEis always accepted. Joined channels persist tochannels.jsonand restore on reconnect. - IRCv3 + SASL. Requests
multi-prefix,away-notify,account-notify,chghost,extended-join,server-time,message-tags,sasl. SASL PLAIN runs during cap negotiation when a NickServ password is set, eliminating the IDENTIFY/JOIN race; falls back to NickServ IDENTIFY. All caps degrade gracefully.
Python 3.10+. scrypt is built into hashlib (no install).
pip install -r requirements.txt # full runtime stack
| Package | Required for |
|---|---|
requests (>=2.32.3) |
Core HTTP client for every module hitting a third-party API. Pin closes CVE-2024-35195. |
aiohttp (>=3.14.1) |
Async HTTP transport for weather calls; falls back to requests + to_thread if absent. |
argon2-cffi (>=23.1.0) |
Argon2id admin password hashing (recommended). |
bcrypt (>=4.2.0) |
bcrypt admin password hashing (alternative). |
PyJWT + cryptography |
Apple WeatherKit ES256 JWT signing. Only if WeatherKit is configured. |
defusedxml (>=0.7.1) |
Hardened XML for modules/qdb.py (billion-laughs guard on top of stdlib XXE protection). |
Dev extras (tests, lint, security scans):
pip install -e ".[dev]" # pytest, pytest-asyncio, pytest-cov, coverage, bandit, pip-audit, build
1. Admin password hash:
python hashpw.py # scrypt (default)
python hashpw.py --algo bcrypt
python hashpw.py --algo argon2
Paste the output into config.ini under [admin] password_hash. Plaintext passwords are rejected at startup.
2. Config:
config.ini.example is the committed credential-free template - never edit it with real values. Your gitignored local files:
| File | Perms | Holds |
|---|---|---|
config.ini |
0600 | All settings (server / nick / autoload) plus the [secrets] section (NickServ/SASL/server/oper passwords, every API key, the User-Agent contact identifier) |
config.local.ini |
optional | Non-secret personal overrides, loaded on top of config.ini |
python -m secret_store init # copies config.ini.example -> config.ini (0600)
$EDITOR config.ini # paste real values, including [secrets]
The [secrets] section of config.ini is read only when its perms are exactly 0600; looser perms fail closed (the store returns empty). INTERNETS_<NAME> env vars override the file, for containers/CI:
export INTERNETS_NICKSERV_PASSWORD=...
Secret-store CLI:
python -m secret_store status # backends available
python -m secret_store list # known secrets + which backend holds each (no values)
python -m secret_store get <name> # non-revealing: "(set, N chars, backend=X)"
python -m secret_store set <name> # prompt, store in config.ini[secrets]
python -m secret_store delete <name> # remove from config.ini[secrets]
python -m secret_store migrate # sweep plaintext from other sections into [secrets]
python -c "import secret_store; print(secret_store.get('<name>'))" # extract a value (rotation)
There is intentionally no CLI flag that prints a secret value (closes a scrollback/shell-history surface). OS-keyring support was removed in 3.0.0; the bot targets headless hosts where keyring has no usable backend.
Upgrading from a pre-[secrets] deployment (separate secrets.ini):
{ echo; cat secrets.ini; } >> config.ini # append old secrets
shred -u secrets.ini # securely remove old file
chmod 600 config.ini # required - bot refuses 06443. Run:
python internets.py
CLI flags (defined in config.py):
| Flag | Effect |
|---|---|
--version |
Print Internets <version> and exit |
--debug [SUBSYSTEM ...] |
Global debug (no args) or per-subsystem (--debug weather store) |
--loglevel LEVEL |
Base log level: DEBUG/INFO/WARNING/ERROR |
--debug-file PATH |
Write all DEBUG to a separate file |
--no-console |
Disable the stdin console (for daemonized use) |
A ProcessLock (internets.pid) is taken around the event loop; a second instance against the same state directory refuses to start (prevents JSON state corruption). The interactive console starts only when --no-console is absent and stdin is a TTY; under systemd/pipes it auto-skips.
4. Add to a channel:
/INVITE Internets #yourchannel # anyone; server enforces permissions
.join #yourchannel # registered founder or bot admin
Founder is verified by matching the user's NickServ account (WHOIS 330) against the services INFO #channel founder line (services_nick, default ChanServ). Works across Anope/Atheme/Epona/X2/X3 and forks.
config.ini is read at startup; .rehash reloads it and invalidates all admin sessions. Sections (full key list in docs/configuration.md and config.ini.example):
[irc]- server, SSL (default on),ssl_verify, NickServ, server password, oper credentials,user_modes,oper_modes,oper_snomask.[bot]- command prefix (default.),api_cooldown,flood_cooldown, state file paths,default_location,modules_dir,autoload,services_nick,user_max_age_days(default 90).[admin]-password_hashwithscrypt$/bcrypt$/argon2$prefix.[weather]- User-Agent template, default unit system. Contact identifier lives in[secrets] weather_user_agent.[weather_providers]-provider_priority(registration order + final tie-breaker). All keyed providers read their key from[secrets].[stocks],[imdb],[lastfm],[youtube],[steam],[twitch],[search]- each reads its credential(s) from[secrets]; non-secret paths (e.g.steamids_file) stay in the section.[idlerpg]/[qdb]-api_urlfor the XML endpoint. No key.[logging]- level, file,max_bytes(5 MB),backup_count(3), optionaldebug_file. Runtime control via.loglevel/.debug.[metrics]-enable(default false),host(default 127.0.0.1; 0.0.0.0 is rejected),port(default 9779). See Operational notes.
.help lists commands grouped by category and shows only modules whose is_configured() passes (e.g. imdb is hidden without omdb_key); admin commands appear only when authed. .modules lists every loaded module plus unloaded ones on disk. In PM the . prefix is optional. Command arguments are capped at 400 characters.
| Command | Description |
|---|---|
.weather / .w [-flag] [loc] |
Current conditions, worldwide multi-provider |
.forecast / .f [-flag] [loc] |
Multi-day forecast |
.hourly / .h [loc] |
Next 12 hours |
.nowcast / .nc [loc] |
Precipitation nowcast, next 1-2 hours |
.aqi / .air [loc] |
Air quality index and pollutants |
.uv / .uvi [loc] |
UV index |
.pollen / .allergy [loc] |
Pollen and allergy index |
.astro / .sun [loc] |
Sunrise, sunset, moon phase |
.alerts / .al [loc] |
Active weather alerts |
.wildfire / .fire [loc] |
Wildfire activity |
.space / .aurora [loc] |
Space weather and aurora |
.marine / .sea [loc] |
Ocean waves, swell, water temp |
.tides / .tide [loc] |
Tide predictions |
.history / .hist [YYYY-MM-DD] [loc] |
Weather on a past date |
.providers |
Provider health and capability status (admin only) |
.iss |
ISS location and current crew |
.spacex |
Next scheduled SpaceX launch |
.apod |
NASA Astronomy Picture of the Day |
.solar |
NOAA space weather: X-ray flare class and SSN |
.neo |
NASA near-earth objects today and closest |
.launches [n] |
Next 1-3 rocket launches |
.moon [YYYY-MM-DD] |
Moon phase, illumination, age |
.sky <M#|name> |
Messier catalog lookup |
.passes <sat> <lat,lon> |
Next visible satellite pass (needs n2yo key) |
All weather commands accept city names, zip codes, raw lat,lon, or -n nick to use another user's saved location. Per-provider flags below force a source; the default chain is accuracy-ranked.
| Command | Description |
|---|---|
.sci [topic] |
Science/infosec/AI/BSD headlines (52 feeds; topics: all, ai, cs, sec, pentest, tech, physics, math, bio, astro, space, bsd) |
.sci read <N> |
Read item N from the last list (lead and link) |
.sci sources |
List feed topics and feed count |
.isprime <n> |
Primality test and next prime |
.factor <n> |
Prime factorization |
.gcd <a> <b> [..] |
GCD and LCM |
.base <n> <from> <to> |
Convert between bases 2..36 |
.stats <n1 n2 ...> |
Mean/median/stdev/min/max/sum |
.roman <n|numeral> |
Arabic to/from Roman (1..3999) |
.pct <expr> |
Percentages: 20% of 150, 50 to 75, 30 of 120 |
.bignum <expr> |
Exact n! / fib(n) / 2^n |
.const <name> |
Physical constant value and unit |
.ly <distance> |
Light time to/from distance (ly/au/km/min) |
.sr <v> |
Special-relativity gamma for v as fraction of c |
.escape <body|m r> |
Escape velocity and surface gravity |
.ohm <two of V,I,R,P> |
Ohm-law and power solver |
.rc <bands|ohms> |
Resistor color code to/from value |
.baud <bytes> <bps> |
Serial transfer time (-fmt 8N1) |
.numberfact / .nf [n] [type] |
Number trivia (trivia/math/date/year) |
| Command | Description |
|---|---|
.cc <expression> |
Calculator (AST-whitelisted; math functions, implicit multiplication) |
.cidr <ip/prefix> |
Network/broadcast/mask/hosts/range |
.subnet <ip/prefix> <newlen> |
Split a block into subnets |
.port <number|name> |
Port number to/from service name |
.b64 / .unb64 <text> |
Base64 encode / decode |
.hex <text> |
Hex encode/decode (auto) |
.b32 <text> |
Base32 encode/decode (auto) |
.morse <text> |
Morse encode/decode (auto) |
.hash <algo> <text> |
md5/sha1/sha256/sha512/blake2b digest |
.crc <text> |
CRC32 and Adler-32 |
.unicode <char|U+hex|name> |
Codepoint / name / UTF-8 / block |
.ascii [dec|hex|char] |
ASCII dec/hex/oct/char/name |
.slug <text> |
Slugify text |
.uuid / .ulid |
Random UUID4 / ULID |
.uuid5 <ns> <name> |
Deterministic UUIDv5 |
.ds <value> <unit> |
Data-size convert (decimal and binary) |
.defang <url> |
Defang/refang URL/IP/email (auto) |
.entropy <password> |
Estimate password entropy |
.pw [len] [-s] |
Random password / passphrase |
.lorem [words] |
Lorem ipsum text |
.epoch [arg] |
Epoch to/from ISO 8601 UTC |
.jwt <token> |
Decode JWT header and payload (no sig check) |
.semver <a> <b> |
Compare two semantic versions |
.tz <time> <from> <to> |
Convert a clock time between zones |
.unix <signal|errno> |
Look up a Unix signal or errno |
.color <value> |
hex/rgb/hsl convert and nearest CSS name |
.cron <expr> |
Validate/explain cron and next fire times |
.http <code> |
HTTP status code lookup |
.qr <text> |
QR-code image URL |
.pypi / .npm / .crates <name> |
Package registry lookup |
.gh <owner/repo> |
GitHub repo info |
.dns <host> [type] |
DNS lookup (A/AAAA/MX/TXT/NS/CNAME) |
.rdns <ip> |
Reverse PTR lookup |
.caa <domain> |
CAA records (and SPF/DMARC) |
.whois <domain> |
RDAP domain registration info |
.asn <ip|ASn> |
RDAP network / AS info |
.headers <url> |
HTTP status/server/type/redirect/security headers (SSRF-guarded) |
.ssl <host[:port]> |
TLS cert issuer/CN/days-to-expiry |
.tcp <host> <port> |
TCP connect probe and latency |
.down <host|url> |
Reachability check (up/down; SSRF-guarded) |
.cve <CVE-ID> |
NVD CVSS score, summary, date |
.pwn <password> |
HIBP breach count (PM-only) |
.hashid <hash> |
Identify likely hash type |
.cvss <vector> |
Compute CVSS v3.1 base score |
.cipher <name> |
Cipher reference (size/status) |
.ipinfo <ip/host> |
IP/hostname geolocation (ip-api.com) |
.ip <ip/host> / .rep |
IP reputation: DNSBL / DShield / GreyNoise / Tor / AbuseIPDB |
.ip / .rep (modules/ipintel.py) queries reputation sources and aggregates a verdict; it does not feed or write to any blocklist.
| Command | Description |
|---|---|
.dict <word> [/N] / .dictionary |
English dictionary definition |
.u <word> [/N] / .urbandictionary |
Urban Dictionary lookup |
.wiki <query> |
Wikipedia summary and link |
.doi <doi> |
Crossref work metadata |
.isbn <isbn> |
Open Library book lookup |
.so <query> |
Top Stack Overflow question |
.rfc <number> |
RFC title/status/date |
.rtfm <command> |
tldr-pages command reference (Unix/BSD/Linux) |
.arxiv <id|query> |
arXiv paper lookup |
.element <name|symbol|Z> |
Periodic-table entry (offline) |
.t [src] <tgt> <text> / .translate |
Translate text |
.sw / .g <query> |
Web search (DuckDuckGo) |
.si / .gi <query> |
Image search (Brave API key required) |
.regloc <location> |
Save your default location |
.myloc |
Show your saved location |
.delloc |
Delete your saved location |
| Command | Description |
|---|---|
.imdb <title> |
Movie/TV lookup (rating, genre, cast, plot) |
.lastfm <user> |
Last.fm profile and now-playing |
.yt / .youtube <search> |
YouTube video search |
.xkcd [num] |
xkcd comic (random or specific) |
.mtg <card> |
Magic: the Gathering card (Scryfall) |
.poke / .pokemon <name|id> |
Pokemon info (PokeAPI) |
.dnd <name> |
D&D 5e SRD spell or monster |
.recipe / .meal <name> |
Recipe lookup (TheMealDB) |
.cocktail / .drink <name> |
Cocktail recipe (TheCocktailDB) |
.steam [user/-g/-n nick] / .regsteam <id> |
Steam status / register ID |
.tw [-s|-c|-g] / .twitch |
Twitch streams / channel / game |
.irpg <player> / .idlerpg |
IdleRPG player lookup |
.hn [rank] |
Top Hacker News story (1-30) |
.reddit / .r <sub> [period] |
Top post from a subreddit |
.stock / .s <symbol> |
Stock quote (Finnhub / Alpha Vantage / Twelve Data failover) |
.crypto <symbol> |
Cryptocurrency price in USD (keyed stock providers) |
.gecko / .cg / .coingecko <symbol|name> |
Crypto spot price + 24h change + market cap (CoinGecko, no key) |
.fx <from> <to> [amount] |
FX conversion (frankfurter.dev / ECB) |
.crypto (modules/stocks.py) needs a stock-provider key; .gecko / .cg (modules/crypto.py) is keyless via CoinGecko.
| Command | Description |
|---|---|
.d [X]dN[+/-M] |
Dice roller |
.coin / .8ball / .rps / .choose |
Coin flip, magic 8-ball, RPS, pick one |
.bofh / .excuse |
Random BOFH excuse |
.fml |
Random FMyLife quote |
.qdb [id] |
Random or specific QDB quote |
.advice |
Random piece of advice |
.bored |
Random activity suggestion |
.fact |
Random useless fact |
.catfact / .cat |
Random cat fact |
.chuck |
Random Chuck Norris joke |
.dadjoke / .joke |
Random dad joke |
.cowsay <text> |
ASCII cow speaks your text |
| Command | Description |
|---|---|
.remind <when> <msg> |
Schedule a reminder (30s, 5m, 1h30m, 14:30 UTC, ISO) |
.remind-list / .remind-cancel <N> |
List or cancel your reminders |
.tell <nick> <msg> |
Leave a message for a nick |
.tell-list / .tell-cancel |
List or cancel your pending tells |
.notes <list|add|del|show|clear> |
Personal sticky notes |
.seen <nick> |
When a nick was last seen |
.shorten <url> |
Shorten a URL via is.gd |
.expand <url> / .unshorten <url> |
Expand a shortened URL |
.privacy / .forgetme / .optout / .optin |
See, erase, or opt out of stored data (PM-only) |
.join / .part <#channel> |
Invite or remove the bot (founder or admin) |
.users [#channel] |
Show known users in a channel |
.forgetme is right-to-erasure: it purges the invoking nick from every dataset (saved location + per-channel tracking). .optout sets a persistent opt-out flag (store.set_opt_out, the source of truth) so the bot stops tracking that nick; .optin reverses it.
Force a specific source instead of the dispatcher's choice. Flags work anywhere on the line, before or after the location. Forcing an inactive provider (no key) or one that lacks the requested capability aborts with a message - no silent fallback once you have made an explicit choice.
| Flag | Provider | Notes |
|---|---|---|
-nws |
NWS (Weather.gov) | US only - NDFD + HRRR + WaveWatch III |
-mm / -meteomatics |
Meteomatics | ECMWF/ICON/GFS blend (paid) |
-aw / -wk / -apple / -appleweather / -weatherkit |
Apple WeatherKit | NWS + IBM TWC blend |
-om / -openmeteo |
Open-Meteo | Free; ECMWF/ICON/GFS + CAMS AQ + ERA5 |
-vc / -visualcrossing |
Visual Crossing | ECMWF + ERA5 reanalysis |
-acc / -accuweather |
AccuWeather | Proprietary long-range |
-owm / -openweathermap |
OpenWeatherMap | GFS + ECMWF + CAMS AQ |
-wb / -weatherbit |
WeatherBit | GFS + station obs |
-wapi / -weatherapi |
WeatherAPI.com | GFS-derived |
-pw / -pirate / -pirateweather |
Pirate Weather | Dark Sky compatible; HRRR + MRMS US nowcast |
-sg / -stormglass |
Stormglass | Marine specialist (7-model wave blend) |
-tio / -tomorrow / -tomorrowio |
Tomorrow.io | Proprietary nowcasting |
-wwo / -worldweatheronline |
World Weather Online | Basic single-model |
-ws / -weatherstack |
Weatherstack | Basic; least preferred |
-l |
(list mode) | List active providers ranked by accuracy for that capability |
Specialist capabilities (.aqi, .uv, .pollen, .alerts, .history, .wildfire, .space, .tides) accept their own source flags (e.g. .aqi -an, .uv -om, .tides -coops). See docs/providers.md.
Authenticate in PM first: /MSG Internets AUTH <password>. Auth is restricted to PM. Sessions are keyed by nick and hostmask; a hostmask change invalidates the session. Brute-force lockout after 5 failures (5-minute cooldown). Every privileged action is written to the HMAC-chained audit log.
| Command | Description |
|---|---|
.auth <password> |
Authenticate (PM only) |
.deauth |
End admin session (PM only) |
.load <module> |
Load a module |
.unload <module> |
Unload a module |
.reload <module> |
Reload a module |
.reloadall |
Reload all loaded modules |
.restart |
Full process restart (execv; subprocess on Windows) |
.rehash |
Reload config.ini, re-read password hash, clear admin sessions |
.mode <+/-modes> |
Set bot user modes (e.g. .mode +ix) |
.snomask <+/-flags> |
Set server notice mask (e.g. .snomask +cCkK) |
.raw <line> |
Send a raw IRC line (capped at 510 bytes) |
.nick <newnick> |
Change the bot's nick |
.say <target> <text> |
Send a PRIVMSG as the bot |
.act <target> <text> |
Send a CTCP ACTION (/me) as the bot |
.loglevel [LEVEL] / .loglevel <logger> <LEVEL> |
Show or set global / per-subsystem log level |
.debug [on|off] / .debug <subsystem> [off] |
Toggle global or per-subsystem debug |
.uptime |
Process and current-connection uptime |
.stats |
Runtime counters, send-queue depth, modules, RSS, audit-log size |
.audit [N|grep <pat>|tail|verify] |
View the audit log; verify checks the HMAC chain |
.fingerprint <nick> |
Cross-reference hostmask, channels, last-seen, tells, notes, audit mentions, shadow-ban status |
.shadow-ban <nick> [reason] |
Silently drop all traffic (commands + on_raw) from a nick; audit-logged |
.shadow-unban <nick> |
Lift a shadow-ban |
.shadow-list |
List shadow-banned nicks |
.health |
Per-subsystem health snapshot |
.shutdown [reason] / .die [reason] |
Save state, unload modules, quit cleanly |
When stdin is a TTY (and --no-console is absent) the bot runs a console task. No auth required - it is local-operator-only. Type at the > prompt:
| Console | Equivalent |
|---|---|
debug [on|off] / debug <sub> [off] |
.debug |
loglevel [LEVEL] / loglevel <logger> LEVEL |
.loglevel |
status |
(none) - shows nick, channels, modules, admins, log state |
shutdown [reason] / quit |
.shutdown |
help |
shows console commands |
Copy modules/example.py - a loadable, fully-commented skeleton - to modules/<name>.py, rename the class, set COMMANDS, and write the cmd_* coroutine(s). Add the name to [bot] autoload (or .load it at runtime, admin). Full guide in docs/modules.md.
from __future__ import annotations
from .base import BotModule
class PingModule(BotModule):
COMMANDS: dict[str, str] = {"ping": "cmd_ping"}
async def cmd_ping(self, nick: str, reply_to: str, arg: str | None) -> None:
self.bot.privmsg(reply_to, f"{nick}: pong")
def help_lines(self, prefix: str) -> list[str]:
return [f" {prefix}ping Pong"]
def setup(bot: object) -> PingModule:
return PingModule(bot)nick is the sender, reply_to is the channel or nick to respond to, arg is everything after the command (or None). Handlers are coroutines; run blocking I/O under await asyncio.to_thread(...). Reply via self.bot.privmsg() (public), self.bot.notice() (private), or self.bot.reply() / self.bot.preply() (auto-routed). For user-influenceable URLs use modules/_netsafe.py (SSRF guard). Outbound HTTP should go through modules/base.fetch_json (size-capped); never r.json() or unbounded r.text.
Available on self.bot: cfg, loc_get/set/del(nick), rate_limited(nick), flood_limited(nick), is_admin(nick), channel_users(channel), active_channels, send(raw, priority). Lifecycle hooks: on_load(), on_unload(), on_raw(line) (every incoming line after IRCv3 tag stripping). is_configured() gates whether the module appears in .help and whether its commands run; return False when a required key is absent so the module hides cleanly.
If two modules register the same command name, the second load is rejected with a conflict error.
Create a package in weather_providers/ with one sub-module per API endpoint; the dispatcher auto-discovers capabilities from async def get_* method names. Register a factory in weather_providers/__init__.py that returns None when its key is absent, add the key under [weather_providers], and add the name to provider_priority. Full walkthrough (including WeatherKit ES256 setup) in docs/providers.md.
Full threat model in docs/security-model.md. Highlights:
- Secret store. Lookup order, first hit wins:
INTERNETS_<NAME>env var, then[secrets]inconfig.ini(read only at exactly 0600, else fail-closed). Secrets are stored encrypted-at-rest, never hashed (the bot must send the literal value on the wire). The bot never logs a secret value;sender.pyscrubsPASS/NS IDENTIFY/OPER/AUTHENTICATEbefore logging. - Audit log (
audit_log.py). Append-only, HMAC-SHA-256-chained over each record'sprev_hashplus fields; the HMAC key is a 0600 sidecar (audit.key), so a leaked copy ofaudit.logcannot be used to forge or recompute entries..audit verifywalks the chain. Rotates at a size cap; each segment is independently verifiable. Honest limit: tail-truncation by an attacker holding both files is undetectable from the file alone (needs an external append-only sink). - SSRF guard (
modules/_netsafe.py). For user-influenceable URLs it resolves the host, rejects any private/loopback/link-local/metadata/ULA/IPv4-mapped answer, then pins the connection to the validated IP (thread-local DNS pin) so urllib3 cannot re-resolve to an internal address between check and connect; re-validates every redirect hop. SNI/TLS/Host stay correct. - Authentication. scrypt (default) / bcrypt / argon2; constant-time compare via
hmac.compare_digest; per-nick brute-force lockout; sessions bound to nick+hostmask and cleared on disconnect; auth state under a dedicatedthreading.Lock. - Transport. TLS 1.3-only by default - the entire TLS 1.2 cipher surface is closed; TLS 1.2 is permitted only with
INTERNETS_ALLOW_TLS12=1, never 1.0/1.1; cert verification on by default (ssl_verify). - Input validation. Module names
^[a-z][a-z0-9_]*$; channel-name regex; args capped 400 chars; PRIVMSG/NOTICE targets validated; module loader blocks symlink traversal; noeval/execanywhere (calculator is an AST walker with a strict whitelist). - Resource limits. 50 concurrent command tasks; sender queue bounded at 200; INVITE acceptance rate-limited; state files capped at 10 MB on load; per-nick API + flood limiters.
- Information disclosure. IRC error messages are generic ("see log for details"); no stack traces, paths, or internal state leak to the channel.
- Single-instance safety.
process_lock.py(PID lock with stale-detection) refuses a second instance that would race the JSON state files.
python tests/run_tests.py # standalone, no dependencies
pytest tests/ -v # full suite (pip install -e ".[dev]")
tests/run_tests.py is a self-contained runner. The pytest suite is 31 modules (tests/test_*.py) covering protocol parsing, store CRUD/flush/pruning, calculator sandboxing and DoS guards, dice, weather provider registry and capability dispatch, provider health scoring, config parsing, output formatting, unit conversion, sender injection prevention and line limits, password hashing, rate limiting, the SSRF/netsafe guard, fetch_json size caps, secret store, and per-module behavior (ipintel, scinews, secinfo, devtools, mathx, physcalc, probe, dnsutils, pkginfo, reflookup, satpass, astro2, crypto-cache, stocks, and more). Coverage gate (fail_under = 75 in pyproject.toml) is core-only: modules/* and weather_providers/* are omitted from the measured source, so the headline percentage measures the top-level orchestration modules, not the whole repo.
Full ops detail in docs/deployment.md.
- Reconnect. Exponential backoff 15s/30s/60s/120s/240s (jittered +/-25%) capped at 5 min; counter resets on success. Channels restored from
channels.json; invite-only channels trigger a ChanServ re-invite; channels rejecting with 471/474/475 are logged and dropped from the saved list. - Keepalive.
PINGevery 90s; a 300s read timeout with no data presumes the link dead and hands off to reconnect, and a companion 240s pong-timeout force-reconnects when the server stops answering client PINGs. - User tracking. Per-channel nicks/hostmasks/first-last-seen in memory, flushed to
users.jsonevery 30s, pruned pastuser_max_age_days(default 90). Populated from observed JOIN/PART/QUIT/NICK and channel activity;353NAMES is not used for the general roster, so a user already present when the bot joined will not appear in.usersuntil they trigger an observable event. - Persistence. All JSON loads into memory once at startup; a background thread flushes dirty datasets every 30s, each under its own lock. Worst-case hard-crash loss is ~30s of user-tracking timestamps; location/channel changes also flush on
.shutdown,.restart, and signal handlers.os.replace()is atomic on POSIX, best-effort on NTFS. - Metrics.
metrics.pyis an opt-in Prometheus text exporter. Counters are always collected (cheap); the HTTP exporter starts only when[metrics] enable = true. It binds 127.0.0.1 by default and rejects 0.0.0.0 - expose off-host only behind an authenticating reverse proxy. - Nick collision. If the configured nick is taken, the bot appends
_and retries.
- The translation module uses an undocumented Google Translate endpoint (
translate.googleapis.com): no SLA, may break or rate-limit without notice. .qdbdepends on a working QDB-compatible XML endpoint ([qdb] api_url); qdb.us is defunct, so.qdbis hidden until one is configured.- The roster is event-driven, not NAMES-derived (see User tracking above).
- The audit log detects edit/reorder/delete of any non-tail record but cannot detect tail-truncation by an attacker holding both
audit.logandaudit.keyfrom the file alone. - Coverage's reported percentage is core-only by design; do not read it as repo-wide.
ISC - see LICENSE.md.