v1.4.0 — Multi-provider weather release (2026-03-04)
A modular IRC bot built on Python's asyncio and RFC 2812. Handles worldwide weather, calculator, dice, translation, and Urban Dictionary lookups. Designed around a plugin architecture with hot-reload so you never take it offline to ship changes.
Weather queries are served by a multi-provider system with automatic fallback: Open-Meteo (free, no key), WeatherAPI.com, and Tomorrow.io. The first provider to return a result wins. Adding a new provider is one file and one registration line.
Platform support: Linux, macOS, FreeBSD, Windows, WSL/WSL2, Cygwin, MinGW, MSYS2.
Python: 3.10+
Dependencies: requests (single runtime dependency). Optional: aiohttp for true async HTTP, bcrypt, argon2-cffi for stronger password hashing.
internets.py Core: asyncio event loop, IRC state machine, command dispatch
protocol.py Pure protocol helpers (ISUPPORT parsing, MODE parsing, SASL, NAMES)
sender.py Async outbound queue with token-bucket rate limiting
store.py In-memory state with periodic disk flush (locations, channels, user tracking)
hashpw.py Password hashing and verification (scrypt/bcrypt/argon2)
weather_providers/
base.py WeatherResult / ForecastDay dataclasses, WeatherProvider protocol
_http.py Async HTTP helper (aiohttp with requests fallback)
openmeteo.py Open-Meteo provider — free, no API key
weatherapi.py WeatherAPI.com provider — requires API key
tomorrowio.py Tomorrow.io provider — requires API key
__init__.py Provider registry, configure(), get_weather(), get_forecast()
modules/
base.py BotModule base class — the interface every plugin implements
geocode.py Location resolution via Nominatim (supports city names, zip codes, lat/lon)
units.py Temperature, wind, pressure, and distance formatting with dual-unit display
weather.py Weather command handler — calls weather_providers for data
location.py User location registration and lookup
calc.py Expression evaluator
dice.py Dice roller with XdN+M notation
translate.py Translation via Google Translate
urbandictionary.py Urban Dictionary lookups with result pagination
channels.py Join/part management and per-channel user roster queries
tests/
run_tests.py Standalone test suite (no external dependencies)
The core (internets.py) owns the asyncio event loop, IRC state machine, and command dispatch. Everything else is a module. Modules register commands via a COMMANDS dict mapping command names to async method names, receive (nick, reply_to, arg) on invocation, and talk back through bot.privmsg() / bot.notice() / bot.reply(). Every command invocation runs as an asyncio.Task.
The outbound path goes through Sender, an async drain loop over asyncio.PriorityQueue that implements a token-bucket (5 burst, ~40 msg/min sustained) to stay under IRC flood limits. Protocol messages (PONG, CAP, NICK) bypass the bucket at priority 0. Sender.enqueue() is thread-safe via loop.call_soon_threadsafe().
Async architecture. The bot runs on a single asyncio event loop. The connection, line reading, command dispatch, send queue, keepalive, and console all run as async tasks or coroutines. Module command handlers are coroutines too — blocking I/O (HTTP via requests, password hashing) runs via asyncio.to_thread() inside the handler. This keeps the event loop free for protocol processing while still supporting the requests library without requiring aiohttp as an additional dependency.
Founder-gated channel control. .join and .part require the requesting user to be either a bot admin or the registered channel founder. Founder verification is done asynchronously: the bot WHOIS-es the user for their NickServ account and queries IRC services (INFO #channel) for the channel founder, then compares. This works across Anope, Atheme, Epona, X2, X3, and forks — anything that responds with a Founder: or Owner: line. The services bot nick is configurable via services_nick in config.ini (default: ChanServ). Users who aren't the founder can still bring the bot in via IRC's native /INVITE, which is always accepted. Joined channels are persisted to channels.json and restored on reconnect.
Two-tier rate limiting. A global per-nick flood gate drops commands that arrive faster than flood_cooldown seconds. A separate API cooldown rate-limits expensive operations (geocoding + weather API calls). Authed admins bypass the flood gate but not the API cooldown. This is a deliberate split: we don't want a fast-typing admin to trigger provider rate limits, but we also don't want them locked out of .reload during an incident.
Multi-provider weather with automatic fallback. Weather queries go through an ordered provider chain configured in [weather_providers]. The first provider to return a result wins. If one fails (network error, rate limit, invalid key), the system silently tries the next. Open-Meteo requires no key and is always available as the last-resort fallback. Adding a new provider is one Python file implementing the WeatherProvider protocol and one line in the factory registry. All responses are normalized to WeatherResult / ForecastDay dataclasses so the command module never touches raw API responses.
Response routing. Regular output goes to the channel. Help text and admin command responses go as NOTICE to the requesting user (keeps help spam out of channels). Everything in PM stays as PRIVMSG. This is the reply() / preply() split.
IRCv3 capability negotiation. The bot requests multi-prefix, away-notify, account-notify, chghost, extended-join, server-time, message-tags, and sasl. If the server supports SASL and a NickServ password is configured, the bot authenticates via SASL PLAIN during capability negotiation — before registration completes. This eliminates the timing race between NickServ IDENTIFY and channel joins. If SASL fails, the bot falls back to traditional NickServ IDENTIFY. All capabilities degrade gracefully if the server supports none of them.
Python 3.10 or later. One runtime dependency:
pip install requests
For password hashing, scrypt is built into Python's hashlib — no extra packages needed. If you want stronger options:
pip install bcrypt # alternative
pip install argon2-cffi # strongest option
Generate an admin password hash:
python hashpw.py # defaults to scrypt
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.
Configure config.ini:
Set server, port, nickname, and user_agent (required by geocoding services — use a real contact email). Everything else has sane defaults.
Run:
python internets.py
Add to a channel:
Anyone can invite the bot via IRC's native INVITE (the server enforces permissions):
/INVITE Internets #yourchannel
Or the registered channel founder can use .join from any channel or PM:
.join #yourchannel
The bot verifies ownership by checking the user's NickServ account against the channel founder registered with IRC services (ChanServ, X3, etc.). Bot admins bypass this check. The bot remembers channels across restarts.
<alice> .w 10001
<Internets> :: New York, NY :: Conditions Partly Cloudy :: Temperature 18.3C / 64.9F ::
Dew point 12.1C / 53.8F :: Pressure 1018mb / 30.06in :: Humidity 67% ::
Visibility 16.1km / 10.0mi :: Wind from SW at 11.2km/h / 6.9mph ::
Updated March 03, 02:51 PM UTC ::
<alice> .f 10001
<Internets> :: New York, NY :: Monday Partly Cloudy 19.2C / 66.6F / 11.8C / 53.2F ::
Tuesday Rain 14.5C / 58.1F / 9.3C / 48.7F :: Wednesday Mainly Clear
16.7C / 62.1F / 8.1C / 46.6F :: Thursday Overcast 13.4C / 56.1F /
7.2C / 45.0F ::
<bob> .cc sqrt(144) + 2pi
<Internets> [calc] sqrt(144) + 2pi = 18.283185
<carol> .d 3d6+2
<Internets> :: Total 14/20 [71%] :: Rolls [4, 5, 3] ::
<dave> .t es Hello, how are you?
<Internets> [t] [en→es] Hola, ¿cómo estás?
<alice> .regloc 90210
<Internets> alice: location set to Beverly Hills, CA
<alice> .w
<Internets> :: Beverly Hills, CA :: Conditions Clear :: Temperature 22.1C / 71.8F :: ... :: [Open-Meteo] ::
<alice> .w -n bob
<Internets> :: Chicago, IL :: Conditions Overcast :: Temperature 8.4C / 47.1F :: ... :: [WeatherAPI] ::
<alice> .u yolo /2
<Internets> [2/7] An acronym for "you only live once", used to justify doing ...
Admin session (via PM):
-> *Internets* AUTH mypassword
<Internets> Authentication successful.
<alice> .modules
<Internets> Loaded: calc, channels, dice, location, translate, urbandictionary, weather
Available: (none unloaded)
<alice> .reload weather
<Internets> 'weather' unloaded. 'weather' loaded (6 commands).
<alice> .version
<Internets> Internets 1.3.0 — async modular IRC bot https://github.com/brandontroidl/Internets
CLI startup:
$ python internets.py --version
Internets 1.3.0
$ python internets.py
2026-03-03 14:00:01 [INFO] internets: Internets v1.3.0 starting
2026-03-03 14:00:01 [INFO] internets: Loaded calc (1 commands)
2026-03-03 14:00:01 [INFO] internets: Loaded weather (10 commands)
...
2026-03-03 14:00:02 [INFO] internets: Connected to irc.libera.chat:6697 (TLS)
2026-03-03 14:00:03 [INFO] internets: SASL authentication successful
2026-03-03 14:00:03 [INFO] internets: Joined #mychannel
> status
version = 1.3.0
nick = Internets
channels = #mychannel
modules = calc, channels, dice, location, translate, urbandictionary, weather
admins = (none)
>
The bot reads config.ini at startup. Relevant sections:
[irc] — Server connection. Supports SSL (default on), optional certificate verification bypass for self-signed certs, NickServ identification, server password (for bouncers), and IRC operator credentials. Also configurable: user_modes (applied after connect, e.g. +ix), oper_modes (applied after OPER succeeds, e.g. +s), and oper_snomask (server notice mask, e.g. +cCkKoO).
[bot] — Command prefix (default .), rate limiting (api_cooldown, flood_cooldown), file paths for persistent storage, modules directory, and autoload list.
[admin] — Hashed password for admin authentication. Supports scrypt$, bcrypt$, and argon2$ prefixes.
[weather] — User-Agent string (required by geocoding services) and default unit system.
[weather_providers] — Provider priority order and API keys. Open-Meteo requires no key. WeatherAPI.com and Tomorrow.io require API keys configured here.
[logging] — Log level, output file, rotation, and optional debug file. The
main log is rotated at max_bytes (default 5 MB) keeping backup_count old
copies (default 3). Set debug_file to a path to capture ALL output at DEBUG
level regardless of the main level setting — useful for protocol diagnostics.
Runtime control via .loglevel and .debug admin commands (see below).
Config can be reloaded at runtime with .rehash, which also invalidates all active admin sessions.
| Command | Description |
|---|---|
.help |
Show available commands (admin commands visible only when authed) |
.modules |
List loaded and available modules |
.weather / .w [location] |
Current conditions — worldwide (multi-provider) |
.forecast / .f [location] |
Multi-day forecast — worldwide (multi-provider) |
.regloc <location> |
Save your default location |
.myloc |
Show your saved location |
.delloc |
Delete your saved location |
.cc <expression> |
Calculator (supports math functions, implicit multiplication) |
.d [X]dN[+/-M] |
Dice roller |
.t [src] <tgt> <text> |
Translate text |
.u <word> [/N] |
Urban Dictionary lookup |
.join <#channel> |
Invite the bot — requires channel founder or admin |
.part <#channel> |
Remove the bot — requires channel founder or admin |
.users [#channel] |
Show known users in a channel |
All weather commands accept city names, zip codes, raw lat,lon pairs, or -n nick to look up another user's registered location.
In PM, the . prefix is optional — weather 10001 works the same as .weather 10001.
Authenticate first: /MSG Internets AUTH <password>
| 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 via execv |
.rehash |
Reload config.ini and clear admin sessions |
.mode <+/-modes> |
Set bot user modes (e.g. .mode +ix) |
.snomask <+/-flags> |
Set server notice mask (e.g. .snomask +cCkK) |
.loglevel [LEVEL] |
Show or set log output level (DEBUG/INFO/WARNING/ERROR) |
.loglevel <logger> <LEVEL> |
Set level for a specific subsystem (e.g. .loglevel internets.weather DEBUG) |
.debug [on|off] |
Toggle global debug output |
.debug <subsystem> [off] |
Debug a single subsystem (e.g. .debug weather) |
.shutdown [reason] / .die [reason] |
Save state, unload modules, quit cleanly |
When running interactively (stdin is a TTY), the bot starts a console task.
Type commands at the > prompt — no auth required. Disable with --no-console
or when running under a process manager (auto-detected: console is skipped when
stdin is not a TTY).
| Console Command | IRC Equivalent |
|---|---|
debug [on|off] |
.debug [on|off] |
debug <sub> [off] |
.debug <sub> [off] |
loglevel [LEVEL] |
.loglevel [LEVEL] |
loglevel <logger> LEVEL |
.loglevel <logger> LEVEL |
status |
(no equivalent — shows nick, channels, modules, admins, log state) |
shutdown [reason] |
.shutdown [reason] |
help |
(shows console commands) |
Create a Python file in modules/. Implement setup(bot) returning a BotModule subclass. Define commands in the COMMANDS dict.
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)The bot passes nick (who sent the command), reply_to (the channel or nick to respond to), and arg (everything after the command, or None). All command handlers are coroutines (async def). Use self.bot.privmsg() for public responses, self.bot.notice() for private ones, or self.bot.reply() / self.bot.preply() for automatic routing.
For blocking I/O (HTTP, disk, CPU-heavy work), use await asyncio.to_thread(...):
import asyncio, requests
async def cmd_fetch(self, nick, reply_to, arg):
resp = await asyncio.to_thread(requests.get, "https://api.example.com/data", timeout=10)
self.bot.privmsg(reply_to, f"Got: {resp.json()}")Available from self.bot: cfg (ConfigParser), loc_get(nick), loc_set(nick, raw), loc_del(nick), rate_limited(nick), flood_limited(nick), is_admin(nick), channel_users(channel), active_channels, send(raw_irc, priority).
Lifecycle hooks: on_load() runs after the module is registered. on_unload() runs before it's removed. on_raw(line) is called for every incoming IRC line (after IRCv3 tag stripping) and lets modules react to server numerics, NOTICEs, or any other traffic the core doesn't dispatch as a command. Use these for setup, cleanup, and advanced protocol integration.
Create a new file in weather_providers/. Implement a class with name, requires_key, get_weather(), and get_forecast():
from weather_providers.base import WeatherResult, ForecastDay
from weather_providers._http import get_json
class MyProvider:
name = "MyWeather"
requires_key = True
def __init__(self, api_key: str) -> None:
self._key = api_key
async def get_weather(self, lat, lon, location, **kwargs) -> WeatherResult:
data = await get_json("https://api.myweather.com/current",
params={"key": self._key, "lat": lat, "lon": lon})
return WeatherResult(
source=self.name,
temperature=data["temp_c"],
description=data["condition"],
location=location,
)
async def get_forecast(self, lat, lon, location, days=4, **kwargs) -> WeatherResult:
data = await get_json("https://api.myweather.com/forecast",
params={"key": self._key, "lat": lat, "lon": lon, "days": days})
fc = [ForecastDay(d["name"], d["high"], d["low"], d["desc"]) for d in data["days"]]
return WeatherResult(source=self.name, temperature=None, description="",
location=location, forecast=fc)Then register it in weather_providers/__init__.py:
from .myprovider import MyProvider
def _make_myprovider(cfg):
key = cfg.get("weather_providers", "myprovider_key", fallback="").strip()
return MyProvider(key) if key else None
_register("myprovider", _make_myprovider)Add myprovider_key = <your-key> to config.ini under [weather_providers] and optionally add it to the priority list. The system handles errors, fallback, and response normalization automatically.
Nick collision recovery: If the configured nick is taken, the bot appends _ and retries.
Auto-reconnect with exponential backoff. On disconnect, the bot reconnects with exponential backoff: 15s, 30s, 60s, 120s, 240s, capped at 5 minutes. The attempt counter resets on successful connection. Channel list is restored from channels.json. If SASL is available, identification happens during registration. Otherwise, if a NickServ password is configured, the bot waits for identification confirmation (up to 10 seconds) before sending JOINs so that +R channels and ChanServ access lists work. If a saved channel is invite-only (+i), the bot asks ChanServ to re-invite it. Channels that reject with 471 (full), 474 (banned), or 475 (bad key) are logged and removed from the saved list.
Keepalive: An async task sends PING every 90 seconds. If the read times out after 300 seconds with no data, the connection is presumed dead and the reconnect logic takes over.
User tracking. The bot maintains a per-channel registry of nicks, hostmasks, and first/last seen timestamps in memory, flushed to users.json every 30 seconds. Populated from observed JOINs, PARTs, QUITs, NICKs, and channel activity — it is not a complete roster (NAMES replies are not used for the general roster). Entries older than 90 days (configurable via user_max_age_days in config.ini) are automatically pruned during flushes.
Channel ownership verification: When a non-admin user runs .join or .part, the bot verifies they are the channel founder by WHOIS-ing them for their NickServ account (330 numeric) and querying the configured services bot (services_nick, default ChanServ) with INFO #channel for the founder name. If the account matches the founder (case-insensitive), the action proceeds. Verification times out after 15 seconds. This covers Anope, Atheme, Epona, X2, X3, and compatible forks. The services bot name is the only thing that varies — set services_nick = X3 (or Q, etc.) in config.ini for non-ChanServ networks.
Module conflicts: If two modules try to register the same command, the second load is rejected with a conflict error.
The bot has been through seven audit passes with 84 findings, all resolved. See AUDIT.md for the complete finding inventory.
Authentication: Admin passwords are hashed with scrypt (default), bcrypt, or argon2. Constant-time comparison via hmac.compare_digest. Brute-force lockout after 5 failures (5-minute cooldown). Sessions cleared on disconnect. Auth commands restricted to PM.
Transport: TLS 1.2 minimum enforced. No fallback to TLS 1.0/1.1. Certificate verification enabled by default (configurable for self-signed certs).
Input validation: Module names validated against ^[a-z][a-z0-9_]*$. Channel names validated against IRC format regex. Command arguments capped at 400 characters. PRIVMSG/NOTICE targets validated (no spaces). All user input treated as untrusted.
Protocol compliance: Outgoing lines capped at 512 bytes (RFC 2812). Incoming lines limited to 8KB buffer. CRLF/NUL stripped from all outgoing messages. PING payload reflection capped at 400 bytes.
Injection prevention: Log injection prevented via _SafeFormatter (sanitizes msg and args). No eval()/exec() anywhere — calculator uses AST walker with strict whitelist. Module loader blocks symlink traversal. Config path resolved to absolute at startup.
Resource limits: Concurrent command tasks capped at 50. Sender queue bounded at 200 messages. INVITE acceptance rate-limited (5s cooldown). Store data files capped at 10MB on load. API and flood rate limiters per-nick.
Information disclosure: All error messages sent to IRC are generic ("see log for details"). No stack traces, file paths, or internal state exposed. Outgoing credentials (PASS, IDENTIFY, OPER, AUTHENTICATE) redacted in logs.
Cross-platform: Config permission check guarded for POSIX. Store I/O uses explicit UTF-8 encoding. Temp file cleanup is exception-safe on Windows. Restart uses subprocess on Windows (os.execv doesn't replace the process). math.cbrt fallback for Python < 3.11.
154 automated tests in tests/run_tests.py. No external test framework required:
python tests/run_tests.py
Covers protocol parsing, store operations (CRUD, flush, atomic writes, pruning, type validation), calculator sandboxing and DoS guards, dice, weather provider registry and output formatting, unit conversions, sender injection prevention and line limits, password hashing, thread-safe containers, async architecture verification, and all security hardening fixes from audit passes six and seven. Compatible with pytest.
The translation module uses an undocumented Google Translate endpoint (translate.googleapis.com). It has no SLA and may break or be rate-limited without notice.
Persistence: The store loads all JSON files into memory once at startup. Mutations happen in-memory; a background thread flushes dirty data to disk every 30 seconds. Each dataset (locations, channels, users) has its own lock, so weather lookups never block on user-tracking writes. Worst-case data loss on a hard crash is 30 seconds of user-tracking timestamps — channel list and location changes are also flushed on .shutdown, .restart, and signal handlers.
The bot does not parse 353 (NAMES reply) for user roster purposes. Users who were already in the channel when the bot joined will not appear in .users output until they trigger an observable event (JOIN, PART, QUIT, NICK, or sending a message).
Atomic writes on Windows: os.replace() is atomic on POSIX but not guaranteed atomic on NTFS. It is the best Python offers cross-platform. Data loss from a crash during the brief write window is unlikely but theoretically possible on Windows.
MIT — see LICENSE.