1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
|
# 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()`.
|