diff options
Diffstat (limited to 'dev-docs/alfred.md')
| -rw-r--r-- | dev-docs/alfred.md | 270 |
1 files changed, 270 insertions, 0 deletions
diff --git a/dev-docs/alfred.md b/dev-docs/alfred.md new file mode 100644 index 0000000..74876c5 --- /dev/null +++ b/dev-docs/alfred.md @@ -0,0 +1,270 @@ +# 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> → topic line (same as bare !<topic>) +!help <topic> <cmd> → 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>_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 <name>` where `<name>` 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/<service>.log` and +`/var/log/<service>/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.*<config_path>`, 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()`. |
