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
|
# jeeves - developer documentation
## Overview
Jeeves is a combined-features IRC bot built on Sopel. It merges the functionality
of herald (admin/follow/keywords), daft (DJ queue), daffy (duck hunting), contessa
(recipes), shireen (news/weather), and salvador (image art), and adds a new quote
bank with auto-posting. All tunable parameters are runtime-configurable via `!set`
and persisted to a single JSON file.
---
## Module structure
```
jeeves/
jv_core.py - shared helpers, CONFIG_KEYS, db I/O, auth utils (not in enable list)
helpstrings.py - help string constants and lookup() (not in enable list)
admin.py - !join, !part, !chans, !pause, !resume
dj.py - YouTube DJ queue
duck.py - duck hunting game
recipes.py - recipe bot
news.py - weather (wttr.in) + RSS news
art.py - mIRC colour art renderer (Pillow)
respond.py - keyword responder
quotes.py - quote bank + auto-post
config.py - !set, !config
help.py - !help
__init__.py - empty
```
---
## Shared state (bot.memory)
`jv_core.ensure_setup()` initializes all persistent state on first call:
```
bot.memory["jv_db"] - main data dict loaded from JSON
bot.memory["jv_db_path"] - path to db.json
bot.memory["jv_duck_db_path"] - path to scores.db
bot.memory["jv_recipes_path"] - path to recipes.json
bot.memory["jv_lock"] - threading.Lock() for db writes
```
Additional per-module memory keys (non-persistent, set in setup()):
```
bot.memory["dj_queue"] - list of normalized YouTube URLs
bot.memory["dj_pos"] - current position (-1 = not playing)
bot.memory["dj_playing"] - bool
bot.memory["dj_timer"] - threading.Timer or None
bot.memory["dj_loop"] - bool
bot.memory["dj_channel"] - str or None
bot.memory["dj_lock"] - threading.RLock() (separate from jv_lock)
bot.memory["dk_channels"] - {channel_lower: state_dict} duck state
bot.memory["dk_reload_cd"] - {nick: timestamp} reload cooldown
bot.memory["dk_players"] - {nick: {"ammo": int}}
bot.memory["dk_lock"] - threading.Lock()
bot.memory["jv_paused"] - bool (keyword responder)
bot.memory["jv_recipes"] - dict loaded from recipes.json
```
---
## Database structure
### JSON database (jv_db)
```json
{
"config": {"dj_track_duration": 90},
"channels": {
"#example": {
"config": {"duck_spawn_min": 30},
"news": {"enabled": true, "last_broadcast": 0},
"quotes": {"enabled": false, "last_broadcast": 0}
}
},
"quotes": ["quote text", ...],
"news_feeds": [...9 feeds...],
"news_seen": ["guid1", ...]
}
```
Config resolution is a two-level merge: global config override → channel config
override, applied on top of hardcoded defaults from `CONFIG_KEYS`.
`cfg(bot, channel)` returns the merged dict.
`cfg_val(bot, key, channel)` returns a single typed value.
### SQLite database (scores.db)
Single `scores` table: `nick`, `channel`, `score`. Primary key `(nick, channel)`.
---
## CONFIG_KEYS
```python
CONFIG_KEYS = {
"dj_track_duration": (int, 120, True, "seconds per track before auto-advance"),
"dj_queue_cap": (int, 50, True, "max tracks in queue"),
"art_height": (int, 6, True, "default lines for !draw (4-12)"),
"duck_spawn_min": (int, 60, False, "min seconds between duck spawns"),
"duck_spawn_max": (int, 300, False, "max seconds between duck spawns"),
"duck_flee_time": (int, 45, False, "seconds before duck flees"),
"duck_idle_rounds": (int, 3, False, "empty rounds before auto-stop (0=off)"),
"news_interval": (int, 90, False, "minutes between news broadcasts"),
"news_count": (int, 1, False, "headlines per broadcast cycle"),
"quotes_interval": (int, 60, False, "minutes between quote auto-posts"),
}
```
The tuple is `(type, default, global_only, description)`.
`global_only=True` means the key can only be changed in PM by the owner. Setting
it in a channel is rejected with a notice. This is used for keys that have no
per-channel meaning (DJ track duration, art height).
---
## Help system
Same three-level pattern as alfred and osterman:
```
!help → TOPICS
!help <topic> → topic line (list of commands)
!help <cmd> → command description
!help <topic> <cmd> → same as above
```
`helpstrings.lookup(args)` in `helpstrings.py` handles all dispatch. `help.py`
imports it via sys.path insertion. Unknown args fall back to the TOPICS line.
---
## Module import pattern
All plugin files use sys.path insertion to import jv_core:
```python
import os as _os, sys as _sys
_d = _os.path.dirname(_os.path.abspath(__file__))
if _d not in _sys.path:
_sys.path.insert(0, _d)
import jv_core as jv
```
`jv_core.py` and `helpstrings.py` are not in the `enable` list and are never
loaded directly by Sopel. They are shared modules, importable via the sys.path
trick above.
---
## Message routing
| Output | Method | Destination |
|--------|--------|-------------|
| Game events (duck spawn/kill/flee) | `bot.say` | channel |
| Score leaderboard | `bot.say` | channel |
| Owner-only score queries | `bot.notice` | nick |
| DJ now-playing, queue | `bot.say` | channel |
| DJ feedback (queued, removed) | `bot.say` | channel |
| DJ error feedback | `bot.notice` | nick |
| News/weather | `bot.say` | channel/sender |
| News broadcast (tick) | `bot.say` | channel |
| Quotes auto-post | `bot.say` | channel |
| Config feedback | `bot.notice` | nick |
| Admin feedback | `bot.notice` | nick |
| Help | `bot.notice` | nick |
---
## DJ notes
- Track duration: read fresh on each play via `jv.cfg_val(bot, "dj_track_duration")` —
changing `dj_track_duration` affects tracks queued after the change, not any
currently running timer.
- `!loop` (no args): shows state to anyone, no privilege check.
- `!loop on|off`: requires op (checked manually via `jv.is_authorized`).
- Queue display: no leading indentation — `{i}. {url}` format.
---
## Duck notes
- Spawn times are read per-tick from `jv.cfg_val(bot, "duck_spawn_min", channel)`.
A config change takes effect on the next spawn scheduling.
- Each channel has independent state in `dk_channels[channel.lower()]`.
- Hunt is off by default; ops must `!hunt start` per channel.
- `!hunt` status output includes current `duck_spawn_min`, `duck_spawn_max`, and
`duck_flee_time` values. Authorized users also receive a notice reminding them
these are adjustable via `!set`.
---
## News notes
- `!news interval` and `!news count` commands from shireen are replaced by
`!set news_interval` / `!set news_count`. The `!news` command only handles
on/off toggle and status display.
- Per-channel news state (`enabled`, `last_broadcast`) lives in
`jv.chan_db(bot, channel)["news"]`.
- Broadcast tick runs every 60s; the interval check is done in-tick against
`cfg_val(bot, "news_interval", channel) * 60`.
---
## Quotes notes
- Quotes are stored globally in `jv_db["quotes"]` — shared across all channels.
- Per-channel auto-post state is in `jv.chan_db(bot, channel)["quotes"]`.
- `!quote del N` uses 1-based indexing for user-facing display.
---
## Pillow dependency (art.py)
`art.py` checks `_PILLOW` at import time. If Pillow is not installed, `!draw`
responds with a notice and returns immediately rather than crashing.
`CHAR_RATIO = 2.0` in `art.py` corrects for IRC monospace character cells
being approximately twice as tall as they are wide. `_fit()` multiplies the
image's pixel aspect ratio by this factor before computing output dimensions,
preventing the 2× vertical stretch that would otherwise occur.
---
## Known issues and tradeoffs
**DJ timer not persisted.** If the bot restarts mid-track the timer is lost.
The queue survives (it's in memory only while the bot runs anyway — not stored
to disk). Reissue `!play` after restart.
**Duck hunt off by default.** Unlike daffy, jeeves does not auto-start the hunt
on any channel. Ops must `!hunt start`. This avoids unwanted duck spam in channels
that don't want the game.
**Global quotes bank.** All channels share the same quote pool. There is no
per-channel quote list.
|