# 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 line (list of commands) !help → command description !help → 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.