aboutsummaryrefslogtreecommitdiffstats
path: root/dev-docs/alfred.md
diff options
context:
space:
mode:
authorAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
committerAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
commit5a8d568931d9b23ce0df1265d05259a7012081c9 (patch)
tree4ea19b8bb1763a38246f342f172c285f6579e09b /dev-docs/alfred.md
init: mostly vibed
Diffstat (limited to 'dev-docs/alfred.md')
-rw-r--r--dev-docs/alfred.md270
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()`.