# alfred - developer documentation ## Overview Alfred is the owner's personal server management bot. It is not a general-purpose bot. Every command is owner-only. It consolidates server administration tasks (process management, system stats, soju bouncer control, 0x0 file hosting, and coffee logging) into a single IRC interface. Alfred runs as its own Sopel instance using a dedicated config. The other bots it manages are also Sopel instances, each with their own configs. --- ## Module structure Alfred's directory contains multiple .py files, each loaded as an independent Sopel plugin module. Sopel loads them individually (not as a package), so relative imports do not work. Modules that need shared data use a sys.path insertion to import `helpstrings` as a plain module — see the Help system section below. ``` alfred/ helpstrings.py - help string constants shared across all modules help.py - !help command handler coffee.py - coffee intake logger server.py - system stats (!srv / !server) soju.py - soju bouncer management (!irc) files.py - 0x0 file hosting management (!files) bots.py - bot process controller (!bot) __init__.py - empty, marks directory as Sopel package ``` The `enable` list in alfred.cfg controls which modules load. All six above are listed. --- ## Help system ### Design Help strings live in `helpstrings.py` as plain string constants and dicts. No Sopel imports, no decorators. Each plugin imports it at the top with: ```python import os as _os, sys as _sys _alfred_dir = _os.path.dirname(_os.path.abspath(__file__)) if _alfred_dir not in _sys.path: _sys.path.insert(0, _alfred_dir) import helpstrings as h ``` Relative imports (`from . import helpstrings`) do not work because Sopel loads each file as a standalone module, not as part of a package. The sys.path insertion ensures `helpstrings` is findable regardless of how Sopel resolves the module. This central location means help text only needs updating in one place. Each plugin uses its topic string for the bare-call output (e.g. `!srv` with no args) and `help.py` uses the same strings for `!help srv` responses. ### Three-level hierarchy ``` !help → TOPICS (list of topic names) !help → topic line (same as bare !) !help → single command description + usage ``` All responses are sent via `bot.notice()` to the requester — not said publicly in channel. This matches the osterman and jeeves help behaviour. ### Dispatch `help.py` delegates all resolution to `h.lookup(args)` in `helpstrings.py`. `helpstrings.py` exposes `TOPIC_MAP`, `_ALL`, and `lookup()` alongside the existing string constants — the same pattern used by osterman and jeeves. Unknown args fall back to the TOPICS line with a "no help for X" prefix. ### Bare-call consistency Every command plugin calls `bot.say(h._TOPIC)` when invoked with no arguments. This means `!srv` and `!help srv` produce identical output. The USAGE lists that previously existed in each plugin have been removed. ### bot topic special case `!help bot ` where `` is not `list` or `all` falls back to `h.BOT_NAME`, the generic bot-name description. This is handled inside `h.lookup()` — not in `help.py` — since managed bot names are dynamic and not enumerable at help-text definition time. ### coffee +n / -n lookup The `+n` and `-n` keys in `h.COFFEE` are looked up with `args[1].lower()`. Sopel preserves the `+` and `-` characters in the argument string, so `!help coffee +n` correctly maps to the `+n` entry. --- ## coffee.py ### Design The coffee tracker is owner-only and deliberately simple. It stores data as a JSON file with a single `entries` dict mapping ISO date strings to cup counts. No database, no schema migration, easy to inspect and edit manually. ### Pending confirmation flow Some operations trigger a warning before applying: setting a count above `WARN_HIGH` (5 cups) or bringing a count down to 0. The bot stores the pending action in a module-level `_pending` dict keyed by nick, then waits for `!coffee yes` or `!coffee no`. This is in-memory only and does not persist across restarts. That is acceptable since pending actions are ephemeral by nature. The `_pending` dict is module-level (not in `bot.memory`) because Sopel plugins can safely use module-level state when the state is simple and does not need to be shared across plugins. ### Week bar graph `_bar()` maps 7 days of cup counts to Unicode block characters (U+2581 through U+2588) scaled relative to the week's maximum. Zero is always a space. ### Timezone handling All date calculations use `ZoneInfo` from the standard library. The timezone is read from `[coffee] timezone` in the config. This matters for the owner's local midnight boundary: without timezone awareness, a cup logged at 11pm could land on the wrong date if the server is in UTC. --- ## server.py ### Design A thin wrapper around standard Unix commands and /proc files. Each subcommand is a function that takes `(bot, args)` and runs a subprocess or reads a file. The dispatch table `_SUBCOMMANDS` maps subcommand names to functions, making it easy to add new ones without touching the command handler. Commands that pass raw system output directly to IRC without formatting: `!srv disk` (df -h), `!srv mem` (free -h), `!srv conns` (ss -s), `!srv who` (who). These need proper parsing and formatted output. `!server` and `!srv` are both registered as aliases via `@plugin.commands()`. ### top / topmem These parse `top -b -n 1` output. The column indices are hardcoded to match the Alpine Linux `top` format from busybox: ``` PID PPID USER STAT VSZ %VSZ CPU %CPU COMMAND ``` If the system uses a different `top` (procps-ng for example), the column indices may be wrong. This is documented as a known platform dependency. ### logs The `LOG_PATHS` dict maps service names to candidate log file paths. The function tries each path in order and reads from the first that exists. This handles services that log to different locations depending on configuration. Services not in `LOG_PATHS` fall back to `/var/log/.log` and `/var/log//error.log`. --- ## soju.py ### Design A thin wrapper around the `sojuctl` CLI. Every operation shells out to `sojuctl` with the appropriate arguments. This avoids having to speak the soju management protocol directly and stays compatible with future soju versions as long as the CLI interface is stable. All commands except `!irc net presets` pass raw sojuctl output to IRC without formatting. These need proper parsing and formatted output. ### Multiline output `_send()` splits `sojuctl` output on newlines and sends each non-empty line as a separate `bot.say()`. This handles `sojuctl` commands that return tables or multiple status lines. ### Network presets Common IRC networks are hardcoded in `NETWORK_PRESETS`. When adding a network with just a name, the preset address is used. When adding with a full address, the preset is bypassed. This avoids requiring the user to remember server addresses for common networks. --- ## files.py ### Design Direct SQLite access to the 0x0 database rather than going through the HTTP API for most operations. The HTTP API is used only for shorten and mirror because those create new records. Stats, listing, and removal go to the database directly for speed and to avoid HTTP overhead. ### URL encoding 0x0 uses a custom base-N encoding for file IDs. The alphabet is stored in `URL_ALPHABET`. `_enbase()` and `_debase()` implement the encoding used by 0x0 to convert between integer IDs and URL-safe strings. These must match 0x0's implementation exactly or file lookups will fail. ### File removal `_cmd_remove()` parses the filename to extract the base name and extension, converts it back to a database ID via `_debase()`, then deletes the file from disk and marks the record as removed in the database. It handles double extensions like `.tar.gz` by taking the last two suffixes. The pattern `p.name[:-len(sufs) or None]` is intentional: when `sufs` is empty (no extension), `-0 or None` evaluates to `None`, so `p.name[:None]` returns the full name. When `sufs` is non-empty, it removes the extension characters. ### Raw flask output `!files prune` and `!files vscan` pass raw flask CLI stdout to IRC without formatting. These need proper parsing and formatted output. ### Stats labeling The stats query separates files by expiration state: - `expiration IS NOT NULL` = live files with a set expiry - `expiration IS NULL` = permanent files (no expiry set) --- ## bots.py ### Design Bots are managed as plain OS processes, not services. Alfred starts them with `subprocess.Popen`, stops them with `pkill -f`, and checks if they are running with `pgrep -f`. The match pattern is `sopel.*`, which is specific enough to avoid false matches. ### Bot discovery `_discover()` globs `~/.config/sopel/*.cfg` and returns a dict of name to config path. Alfred itself is excluded via the `EXCLUDE` set. This means adding a new bot just requires dropping a config file in that directory. No hardcoded list. ### Restart race `_restart()` stops the bot then polls `_running()` up to 10 times with 0.2s sleeps (2 seconds total) before starting it again. This avoids starting a new process before the old one has fully exited. The poll is necessary because `pkill` returns immediately after sending the signal, not after the process exits. --- ## Config Alfred's config file is `alfred.cfg`. The `extra` key points to the script directory. The `enable` list must include all six module names or they will not load. Each module that uses config reads its own section via a `StaticSection` subclass registered in `setup()`.