diff options
80 files changed, 11749 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bbe7b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +*.pyo @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ahmed + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7b6642 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# irc-bots + +A collection of [Sopel](https://sopel.chat/) IRC bots for self-hosted servers. + +| Bot | Purpose | Doc | +|-----|---------|-----| +| [alfred](alfred/) | Server management, bot control, file hosting, soju bouncer | [docs/alfred.md](docs/alfred.md) | +| [osterman](osterman/) | Channel moderation, auto-modes, idle tracking | [docs/osterman.md](docs/osterman.md) | +| [daffy](daffy/) | Duck hunting game | [docs/daffy.md](docs/daffy.md) | +| [contessa](contessa/) | Recipe suggestions | [docs/contessa.md](docs/contessa.md) | +| [shireen](shireen/) | News headlines and weather | [docs/shireen.md](docs/shireen.md) | +| [daft](daft/) | YouTube playlist manager | [docs/daft.md](docs/daft.md) | +| [herald](herald/) | Owner-follow bot and keyword responder | [docs/herald.md](docs/herald.md) | +| [salvador](salvador/) | Image-to-mIRC-art renderer | [docs/salvador.md](docs/salvador.md) | + +## Requirements + +- Python 3.9+ +- [Sopel](https://sopel.chat/) 8.x + +``` +pip install sopel +``` + +Bot-specific dependencies: + +| Bot | Extra | +|-----|-------| +| alfred | `pip install requests` | +| salvador | `pip install Pillow` | + +All others use only the standard library. + +## Install + +1. Clone or copy this repo. + +2. Copy the example config for the bot you want and fill in your details: + ``` + cp alfred/alfred.cfg ~/.config/sopel/alfred.cfg + $EDITOR ~/.config/sopel/alfred.cfg + ``` + Set `nick`, `host`, `port`, `owner`, and any `[section]` paths. + The `extra` key must point to the bot's directory (e.g. `/path/to/irc-bots/alfred`). + +3. Create any required data directories: + ``` + mkdir -p /var/log/<botname> + # contessa: place recipes.json at the path set in [contessa] recipes_path + # shireen: data.json is created automatically on first run + # osterman: db.json and track.db are created automatically on first run + # daffy: scores.db is created automatically on first run + ``` + +4. Start the bot: + ``` + sopel --config ~/.config/sopel/<botname>.cfg + ``` + +5. (Optional) Register as a service. Example OpenRC init script: + ```sh + #!/sbin/openrc-run + supervisor=supervise-daemon + command="/usr/bin/sopel" + command_args="--config /home/user/.config/sopel/<botname>.cfg" + command_user="user" + output_log="/var/log/<botname>/daemon.log" + error_log="/var/log/<botname>/daemon.log" + depend() { need net; } + ``` + +## Notes + +- All bots use `!` as the command prefix by default (set `prefix` in config). +- All bots share `!join`, `!part`, `!chans`, and `!help` as owner-only admin commands. + When multiple bots are in the same channel and the owner types one of these, all bots act - that's intentional. +- **osterman** uses short-form aliases (`!b`, `!k`, `!v`, etc.) that are unique to it. + No other bot in this collection uses conflicting short forms. + +## License + +MIT - see [LICENSE](LICENSE). diff --git a/alfred/__init__.py b/alfred/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/alfred/__init__.py diff --git a/alfred/alfred.cfg b/alfred/alfred.cfg new file mode 100644 index 0000000..e4fb6f5 --- /dev/null +++ b/alfred/alfred.cfg @@ -0,0 +1,34 @@ +[core] +nick = alfred +host = 127.0.0.1 +port = 6667 +use_ssl = false +verify_ssl = false +owner = yournick +password = REPLACEME +prefix = ! +channels = #yourchannel +extra = /path/to/irc-bots/alfred +enable = + coffee + server + soju + files + bots + help +logdir = /var/log/alfred +logging_level = INFO +timeout = 120 + +[coffee] +data_path = /var/www/coffee.gumx.cc/data.json + +[soju] +config_path = /etc/soju/config + +[files] +url = http://127.0.0.1:5000 +default_domain = wkfo +db_path = /var/0x0/db.sqlite +data_path = /var/0x0/files +app_path = /opt/0x0 diff --git a/alfred/bots.py b/alfred/bots.py new file mode 100644 index 0000000..de0a1d4 --- /dev/null +++ b/alfred/bots.py @@ -0,0 +1,133 @@ +""" +Sopel plugin: bot controller + +Owner-only commands to start, stop, and restart IRC bots directly as +processes. Alfred manages them; no rc-service or sudo required. + +Bots are discovered automatically from ~/.config/sopel/*.cfg - add a new +config file there and it will appear in !bot list on the next command. + + !bot list - show status of all managed bots + !bot all <start|stop|restart> - control all bots at once + !bot <name> <start|stop|restart> - control a bot + !bot <name> status - show status of one bot +""" + +import glob +import os +import subprocess +import time + +from sopel import plugin +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 + + +SOPEL = "/usr/bin/sopel" +CFG_DIR = "/home/ahmed/.config/sopel" +EXCLUDE = {"alfred"} + + +def _discover(): + paths = glob.glob(os.path.join(CFG_DIR, "*.cfg")) + return { + os.path.splitext(os.path.basename(p))[0]: p + for p in sorted(paths) + if os.path.splitext(os.path.basename(p))[0] not in EXCLUDE + } + + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _running(cfg_path): + r = subprocess.run(["pgrep", "-f", f"sopel.*{cfg_path}"], capture_output=True) + return r.returncode == 0 + + +def _start(name, cfg_path): + if _running(cfg_path): + return f"{name}: already running" + subprocess.Popen( + [SOPEL, "--config", cfg_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + ) + return f"{name}: started" + + +def _stop(name, cfg_path): + r = subprocess.run(["pkill", "-f", f"sopel.*{cfg_path}"], capture_output=True) + return f"{name}: stopped" if r.returncode == 0 else f"{name}: not running" + + +def _restart(name, cfg_path): + _stop(name, cfg_path) + for _ in range(10): + time.sleep(0.2) + if not _running(cfg_path): + break + return _start(name, cfg_path) + + +@plugin.command("bot") +def cmd_bot(bot, trigger): + if not _is_owner(bot, trigger): + return + + bots = _discover() + args = (trigger.group(2) or "").split() + + if not args: + bot.say(h.BOT_TOPIC) + return + + if args[0] == "list": + if not bots: + bot.say("no bots found") + return + bot.say(" | ".join( + f"{n}:{'up' if _running(p) else 'down'}" for n, p in bots.items() + )) + return + + if args[0] == "all": + if len(args) < 2 or args[1] not in ("start", "stop", "restart"): + bot.say("usage: !bot all <start|stop|restart>") + return + action = args[1] + if not bots: + bot.say("no bots found") + return + fn = {"start": _start, "stop": _stop, "restart": _restart}[action] + results = [fn(n, p) for n, p in bots.items()] + bot.say(" | ".join(results)) + return + + name = args[0] + if name not in bots: + bot.say(f"unknown bot - known: {', '.join(bots)}") + return + + if len(args) < 2: + bot.say(f"usage: !bot {name} <start|stop|restart|status>") + return + + cfg_path = bots[name] + action = args[1] + + if action == "start": + bot.say(_start(name, cfg_path)) + elif action == "stop": + bot.say(_stop(name, cfg_path)) + elif action == "restart": + bot.say(_restart(name, cfg_path)) + elif action == "status": + bot.say(f"{name}: {'running' if _running(cfg_path) else 'stopped'}") + else: + bot.say(f"usage: !bot {name} <start|stop|restart|status>") diff --git a/alfred/coffee.py b/alfred/coffee.py new file mode 100644 index 0000000..cb4f138 --- /dev/null +++ b/alfred/coffee.py @@ -0,0 +1,212 @@ +""" +Sopel plugin: coffee logger + +Commands (owner only): + !coffee - show usage + !coffee stats - today / week / streak + !coffee n - set today to n cups + !coffee +n - add n cups to today (warns if > 5) + !coffee -n - remove n cups from today (warns if hits 0) + !coffee n yyyy-mm-dd - set a past date to n cups + !coffee +n yyyy-mm-dd - add n cups to a past date + !coffee -n yyyy-mm-dd - remove n cups from a past date + !coffee yes/no - confirm or cancel a pending action + +Data: {"entries": {"YYYY-MM-DD": N, ...}} at coffee.data_path +""" + +import json +import os +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo + +from sopel import plugin +from sopel.config.types import FilenameAttribute, StaticSection, ValidatedAttribute +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 + +WARN_HIGH = 5 +BAR_CHARS = " ▁▂▃▄▅▆▇█" + +_pending = {} + + +class CoffeeSection(StaticSection): + data_path = FilenameAttribute("data_path", relative=False) + timezone = ValidatedAttribute("timezone", default="UTC") + + +def setup(bot): + bot.settings.define_section("coffee", CoffeeSection) + + +def _load(bot): + path = bot.settings.coffee.data_path + if os.path.exists(path): + with open(path) as f: + return json.load(f).get("entries", {}) + return {} + + +def _save(bot, entries): + path = bot.settings.coffee.data_path + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + json.dump({"entries": entries}, f, indent=2, sort_keys=True) + f.write("\n") + + +def _today(bot): + tz = ZoneInfo(bot.settings.coffee.timezone) + return datetime.now(tz).date() + + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _bar(values): + max_v = max(values) if any(values) else 1 + chars = [] + for v in values: + if v == 0: + chars.append(" ") + else: + idx = max(1, round(v / max_v * (len(BAR_CHARS) - 1))) + chars.append(BAR_CHARS[idx]) + return "".join(chars) + + +def _streak(entries, today): + count = 0 + d = today + while True: + if entries.get(d.isoformat(), 0) > 0: + count += 1 + d -= timedelta(days=1) + else: + break + return count + + +def _show_stats(bot, entries, today): + today_key = today.isoformat() + today_count = entries.get(today_key, 0) + + week_days = [(today - timedelta(days=6 - i)) for i in range(7)] + week_values = [entries.get(d.isoformat(), 0) for d in week_days] + week_total = sum(week_values) + bar = _bar(week_values) + + streak = _streak(entries, today) + all_total = sum(entries.values()) + days_logged = sum(1 for v in entries.values() if v > 0) + avg = all_total / days_logged if days_logged else 0 + + bot.say(f"coffee: today {today_count} cups") + bot.say(f"coffee: week {bar} total {week_total} cups") + bot.say(f"coffee: streak {streak} days") + bot.say(f"coffee: total {all_total} cups") + bot.say(f"coffee: average {avg:.1f} cups/day") + + +def _apply(bot, nick, key, new_val): + entries = _load(bot) + entries[key] = new_val + _save(bot, entries) + cups = "cup" if new_val == 1 else "cups" + bot.say(f"coffee: {key}: {new_val} {cups}") + del _pending[nick] + + +@plugin.command("coffee") +def cmd_coffee(bot, trigger): + if not _is_owner(bot, trigger): + return + + arg = (trigger.group(2) or "").strip() + nick = str(trigger.nick) + + # Confirmation flow + if arg.lower() in ("yes", "y"): + if nick not in _pending: + bot.say("coffee: nothing pending") + return + _apply(bot, nick, _pending[nick]["key"], _pending[nick]["value"]) + return + + if arg.lower() in ("no", "n"): + _pending.pop(nick, None) + bot.say("coffee: cancelled") + return + + if not arg: + bot.say(h.COFFEE_TOPIC) + return + + # Stats subcommand + if arg == "stats": + entries = _load(bot) + _show_stats(bot, entries, _today(bot)) + return + + # Parse: <op> [yyyy-mm-dd] + parts = arg.split() + op_str = parts[0] + + target = _today(bot) + if len(parts) == 2: + try: + target = date.fromisoformat(parts[1]) + except ValueError: + bot.say("coffee: invalid date - use yyyy-mm-dd") + return + elif len(parts) > 2: + bot.say("coffee: too many arguments") + return + + target_key = target.isoformat() + entries = _load(bot) + current = entries.get(target_key, 0) + + if op_str.startswith("+"): + try: + delta = int(op_str[1:]) + except ValueError: + bot.say("coffee: usage: !coffee [+/-]n [yyyy-mm-dd]") + return + new_val = current + delta + if new_val > WARN_HIGH: + _pending[nick] = {"key": target_key, "value": new_val} + bot.say(f"coffee: that brings {target_key} to {new_val} cups (>{WARN_HIGH}). confirm? (!coffee yes/no)") + return + + elif op_str.startswith("-"): + try: + delta = int(op_str[1:]) + except ValueError: + bot.say("coffee: usage: !coffee [+/-]n [yyyy-mm-dd]") + return + new_val = max(0, current - delta) + if new_val == 0: + _pending[nick] = {"key": target_key, "value": 0} + bot.say(f"coffee: that brings {target_key} to 0 cups. confirm? (!coffee yes/no)") + return + + else: + try: + new_val = int(op_str) + except ValueError: + bot.say("coffee: usage: !coffee [+/-]n [yyyy-mm-dd]") + return + if new_val < 0: + bot.say("coffee: count can't be negative") + return + + entries[target_key] = new_val + _save(bot, entries) + cups = "cup" if new_val == 1 else "cups" + bot.say(f"coffee: {target_key} set to {new_val} {cups}") diff --git a/alfred/files.py b/alfred/files.py new file mode 100644 index 0000000..5775bdf --- /dev/null +++ b/alfred/files.py @@ -0,0 +1,282 @@ +""" +Sopel plugin: 0x0 file hosting management + +All commands are owner-only and work in channel or PM. + + !files - show usage + + !files stats - file count, total size, disk usage + !files list [n] - last n uploads with name/size/expiry (default 5) + !files shorten <url> [wkfo|gumx] - shorten a URL (default: wkfo) + !files mirror <url> [wkfo|gumx] - mirror a remote URL to this instance + !files prune - delete expired files from disk + !files vscan - run ClamAV scan on stored files + !files remove <filename> - permanently remove a file +""" + +import sqlite3 +import subprocess +import time +from pathlib import Path + +import requests as req + +from sopel import plugin +from sopel.config.types import FilenameAttribute, StaticSection, ValidatedAttribute +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 + + +URL_ALPHABET = "DEQhd2uFteibPwq0SWBInTpA_jcZL5GKz3YCR14Ulk87Jors9vNHgfaOmMXy6Vx-" + +DOMAINS = { + "wkfo": "wk.fo", + "gumx": "files.gumx.cc", +} + + +class FilesSection(StaticSection): + url = ValidatedAttribute("url", default="http://127.0.0.1:5000") + default_domain = ValidatedAttribute("default_domain", default="wkfo") + db_path = FilenameAttribute("db_path", relative=False) + data_path = ValidatedAttribute("data_path") + app_path = ValidatedAttribute("app_path") + + +def setup(bot): + bot.settings.define_section("files", FilesSection) + + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _enbase(x): + n = len(URL_ALPHABET) + s = "" + while x > 0: + s = URL_ALPHABET[int(x % n)] + s + x = int(x // n) + return s or URL_ALPHABET[0] + + +def _debase(s): + n = len(URL_ALPHABET) + result = 0 + for c in s: + result = result * n + URL_ALPHABET.index(c) + return result + + +def _fmt_size(n): + for unit in ("B", "KB", "MB", "GB", "TB"): + if n < 1024: + return f"{n:.1f} {unit}" + n /= 1024 + return f"{n:.1f} TB" + + +def _db(bot): + return sqlite3.connect(bot.settings.files.db_path) + + +def _post(bot, data, domain=None): + host = DOMAINS.get(domain or bot.settings.files.default_domain, "wk.fo") + r = req.post( + bot.settings.files.url, + data=data, + headers={"Host": host}, + timeout=30, + ) + r.raise_for_status() + return r.text.strip() + + +def _flask(bot, cmd, timeout=120): + r = subprocess.run( + ["python3", "-m", "flask", "--app", "fhost", cmd], + capture_output=True, text=True, timeout=timeout, + cwd=bot.settings.files.app_path, + ) + out = r.stdout.strip() + return out[:400] if out else "(no output)" + + +# --------------------------------------------------------------------------- +# !files +# --------------------------------------------------------------------------- + +@plugin.command("files") +def cmd_files(bot, trigger): + if not _is_owner(bot, trigger): + return + + args = (trigger.group(2) or "").split() + + if not args: + bot.say(h.FILES_TOPIC) + return + + sub = args[0] + + if sub == "stats": + _cmd_stats(bot) + + elif sub == "list": + n = 5 + if len(args) > 1: + try: + n = max(1, min(int(args[1]), 20)) + except ValueError: + bot.say("usage: !files list [n]") + return + _cmd_list(bot, n) + + elif sub == "shorten" and len(args) >= 2: + _cmd_shorten(bot, args[1], args[2] if len(args) > 2 else None) + + elif sub == "mirror" and len(args) >= 2: + _cmd_mirror(bot, args[1], args[2] if len(args) > 2 else None) + + elif sub == "prune": + bot.say("pruning expired files...") + bot.say(_flask(bot, "prune")) + + elif sub == "vscan": + bot.say("running vscan - this may take a while...") + bot.say(_flask(bot, "vscan", timeout=300)) + + elif sub == "remove" and len(args) == 2: + _cmd_remove(bot, args[1]) + + else: + bot.say("unknown subcommand - type !files for usage") + + +# --------------------------------------------------------------------------- +# stats +# --------------------------------------------------------------------------- + +def _cmd_stats(bot): + try: + conn = _db(bot) + live = conn.execute( + "SELECT COUNT(*), SUM(size) FROM file " + "WHERE removed = 0 AND expiration IS NOT NULL" + ).fetchone() + permanent = conn.execute( + "SELECT COUNT(*) FROM file WHERE expiration IS NULL AND removed = 0" + ).fetchone()[0] + removed = conn.execute( + "SELECT COUNT(*) FROM file WHERE removed = 1" + ).fetchone()[0] + conn.close() + + count = live[0] or 0 + size = live[1] or 0 + bot.say(f"files: {count} live | {permanent} permanent | {removed} removed | size: {_fmt_size(size)}") + except Exception as e: + bot.say(f"error: {e}") + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + +def _cmd_list(bot, n): + try: + conn = _db(bot) + rows = conn.execute( + "SELECT id, ext, size, expiration FROM file " + "WHERE removed = 0 AND expiration IS NOT NULL " + "ORDER BY id DESC LIMIT ?", + (n,) + ).fetchall() + conn.close() + + if not rows: + bot.say("files: no uploads found") + return + + now_ms = time.time() * 1000 + for fid, ext, size, expiration in rows: + name = _enbase(fid) + (ext or "") + size_str = _fmt_size(size or 0) + hours = max(0, (expiration - now_ms) / 3_600_000) + bot.say(f"{name} | {size_str} | expires in {hours:.0f}h") + except Exception as e: + bot.say(f"error: {e}") + + +# --------------------------------------------------------------------------- +# shorten +# --------------------------------------------------------------------------- + +def _cmd_shorten(bot, url, domain): + if domain and domain not in DOMAINS: + bot.say(f"unknown domain '{domain}' - use wkfo or gumx") + return + try: + bot.say(_post(bot, {"shorten": url}, domain)) + except Exception as e: + bot.say(f"error: {e}") + + +# --------------------------------------------------------------------------- +# mirror +# --------------------------------------------------------------------------- + +def _cmd_mirror(bot, url, domain): + if domain and domain not in DOMAINS: + bot.say(f"unknown domain '{domain}' - use wkfo or gumx") + return + try: + bot.say(f"mirroring {url}...") + bot.say(_post(bot, {"url": url}, domain)) + except Exception as e: + bot.say(f"error: {e}") + + +# --------------------------------------------------------------------------- +# remove +# --------------------------------------------------------------------------- + +def _cmd_remove(bot, filename): + filename = filename.rstrip("/").split("/")[-1] + p = Path(filename) + sufs = "".join(p.suffixes[-2:]) + name = p.name[:-len(sufs) or None] + + try: + file_id = _debase(name) + except (ValueError, IndexError): + bot.say(f"invalid filename: {filename}") + return + + try: + conn = _db(bot) + row = conn.execute( + "SELECT sha256 FROM file WHERE id = ? AND removed = 0", + (file_id,) + ).fetchone() + + if not row: + bot.say(f"file not found or already removed: {filename}") + conn.close() + return + + Path(bot.settings.files.data_path, row[0]).unlink(missing_ok=True) + + conn.execute( + "UPDATE file SET removed = 1, expiration = NULL, mgmt_token = NULL " + "WHERE id = ?", + (file_id,) + ) + conn.commit() + conn.close() + bot.say(f"removed {filename}") + except Exception as e: + bot.say(f"error: {e}") diff --git a/alfred/git.py b/alfred/git.py new file mode 100644 index 0000000..107a3b1 --- /dev/null +++ b/alfred/git.py @@ -0,0 +1,148 @@ +""" +Sopel plugin: bare git repo management + + !git list list all repos + !git desc <repo> <text> set repo description + !git hook <repo> <type> install a named post-receive hook + !git remove <repo> remove a repo (confirms first) +""" + +import os +import shutil +import subprocess + +from sopel import plugin + +REPOS = "/home/git/repos" +HOOKS = "/home/ahmed/scripts/git-hooks" + +_pending_remove = {} + + +def _owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _repos(): + try: + return sorted( + e for e in os.listdir(REPOS) + if os.path.isdir(os.path.join(REPOS, e)) + ) + except OSError: + return [] + + +def _desc(name): + path = os.path.join(REPOS, name, "description") + try: + text = open(path).read().strip() + if text.startswith("Unnamed repository"): + return "" + return text + except OSError: + return "" + + +def _last_commit(name): + try: + r = subprocess.run( + ["git", "-C", os.path.join(REPOS, name), "log", "-1", "--format=%cr"], + capture_output=True, text=True, timeout=5, + ) + return r.stdout.strip() or "no commits" + except Exception: + return "?" + + +@plugin.command("git") +def cmd_git(bot, trigger): + if not _owner(bot, trigger): + return + + args = (trigger.group(2) or "").split() + if not args: + bot.say("usage: !git <list|desc|hook|remove>") + return + + action = args[0] + + if action == "list": + repos = _repos() + if not repos: + bot.say("no repos") + return + for name in repos: + desc = _desc(name) + last = _last_commit(name) + line = f"{name} ({last})" + if desc: + line += f" {desc}" + bot.say(line) + + elif action == "desc": + if len(args) < 3: + bot.say("usage: !git desc <repo> <description>") + return + name, text = args[1], " ".join(args[2:]) + path = os.path.join(REPOS, name) + if not os.path.isdir(path): + bot.say(f"{name}: not found") + return + with open(os.path.join(path, "description"), "w") as f: + f.write(text + "\n") + bot.say(f"{name}: description updated") + + elif action == "hook": + if len(args) < 3: + available = [ + f[len("post-receive-"):-len(".sh")] + for f in os.listdir(HOOKS) + if f.startswith("post-receive-") and f.endswith(".sh") + ] if os.path.isdir(HOOKS) else [] + bot.say(f"usage: !git hook <repo> <type> available: {', '.join(available) or 'none'}") + return + name, hooktype = args[1], args[2] + repo_path = os.path.join(REPOS, name) + if not os.path.isdir(repo_path): + bot.say(f"{name}: not found") + return + src = os.path.join(HOOKS, f"post-receive-{hooktype}.sh") + if not os.path.isfile(src): + bot.say(f"{hooktype}: hook template not found") + return + dst = os.path.join(repo_path, "hooks", "post-receive") + shutil.copy2(src, dst) + os.chmod(dst, 0o755) + bot.say(f"{name}: {hooktype} hook installed") + + elif action == "remove": + if len(args) < 2: + bot.say("usage: !git remove <repo>") + return + name = args[1] + path = os.path.join(REPOS, name) + if not os.path.isdir(path): + bot.say(f"{name}: not found") + return + channel = str(trigger.sender) + _pending_remove[channel] = path + bot.say(f"remove {name}? reply !git yes or !git no") + + elif action == "yes": + channel = str(trigger.sender) + path = _pending_remove.pop(channel, None) + if not path: + bot.say("nothing pending") + return + name = os.path.basename(path) + shutil.rmtree(path) + bot.say(f"{name}: removed") + + elif action == "no": + channel = str(trigger.sender) + _pending_remove.pop(channel, None) + bot.say("cancelled") + + else: + bot.say("usage: !git <list|desc|hook|remove>") diff --git a/alfred/help.py b/alfred/help.py new file mode 100644 index 0000000..606c8dd --- /dev/null +++ b/alfred/help.py @@ -0,0 +1,31 @@ +""" +Sopel plugin: help system for alfred + + !help - list topics + !help <topic> - list commands for a topic + !help <topic> <cmd> - description and usage for a command + +Topics: srv, bot, files, irc, coffee +""" + +from sopel import plugin +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 + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +@plugin.command("help") +def cmd_help(bot, trigger): + if not _is_owner(bot, trigger): + return + args = (trigger.group(2) or "").split() + result = h.lookup(args) + if result: + bot.notice(result, trigger.nick) + else: + bot.notice(f"no help for '{' '.join(args)}'. {h.TOPICS}", trigger.nick) diff --git a/alfred/helpstrings.py b/alfred/helpstrings.py new file mode 100644 index 0000000..6711340 --- /dev/null +++ b/alfred/helpstrings.py @@ -0,0 +1,78 @@ +TOPICS = "topics: srv, bot, files, irc, coffee. type !help <topic> for details" + +SRV_TOPIC = "srv: uptime disk mem load status net top topmem io conns who logs. type !help srv <cmd> for details" +SRV = { + "uptime": "srv uptime: system uptime and load averages. usage: !srv uptime", + "disk": "srv disk: disk usage for root. usage: !srv disk", + "mem": "srv mem: RAM and swap usage. usage: !srv mem", + "load": "srv load: 1 5 15 min load averages and process count. usage: !srv load", + "status": "srv status: rc-service status, all or one. usage: !srv status or !srv status service", + "net": "srv net: RX TX bytes for a network interface. usage: !srv net or !srv net iface", + "top": "srv top: top n processes by CPU. usage: !srv top or !srv top n", + "topmem": "srv topmem: top n processes by memory. usage: !srv topmem or !srv topmem n", + "io": "srv io: disk read write stats for the main block device. usage: !srv io", + "conns": "srv conns: TCP connection summary. usage: !srv conns", + "who": "srv who: currently logged-in users. usage: !srv who", + "logs": "srv logs: last n lines from a service log. usage: !srv logs service or !srv logs service n", +} + +BOT_TOPIC = "bot: list, all, name start stop restart status. type !help bot <cmd> for details" +BOT = { + "list": "bot list: show all managed bots and their running state. usage: !bot list", + "all": "bot all: control all bots at once. usage: !bot all start or stop or restart", +} +BOT_NAME = "bot name: control or check a specific bot. usage: !bot name start or stop or restart or status" + +FILES_TOPIC = "files: stats list shorten mirror prune vscan remove. type !help files <cmd> for details" +FILES = { + "stats": "files stats: live permanent removed count and total size. usage: !files stats", + "list": "files list: last n uploads with name size and expiry. usage: !files list or !files list n", + "shorten": "files shorten: shorten a URL. usage: !files shorten url or !files shorten url wkfo or gumx", + "mirror": "files mirror: mirror a remote URL into this instance. usage: !files mirror url or !files mirror url wkfo or gumx", + "prune": "files prune: delete all expired files from disk. usage: !files prune", + "vscan": "files vscan: run ClamAV scan on all stored files. usage: !files vscan", + "remove": "files remove: permanently delete a file by filename. usage: !files remove filename", +} + +IRC_TOPIC = "irc: user net bouncer notice. type !help irc <cmd> for details" +IRC = { + "user": "irc user: list, add nick pass, remove nick, passwd nick pass, enable nick, disable nick. usage: !irc user sub", + "net": "irc net: list user, status user, add user name, add user name addr, remove user name, quote user net cmd, presets. usage: !irc net sub", + "bouncer": "irc bouncer: show bouncer-wide stats and status. usage: !irc bouncer", + "notice": "irc notice: broadcast a notice to all bouncer users. usage: !irc notice message", +} + +COFFEE_TOPIC = "coffee: stats n +n -n yes no. type !help coffee <cmd> for details" +COFFEE = { + "stats": "coffee stats: today cups, 7-day chart, streak, all-time average. usage: !coffee stats", + "n": "coffee n: set cup count for today or a past date. usage: !coffee n or !coffee n yyyy-mm-dd", + "+n": "coffee +n: add cups, warns if over 5. usage: !coffee +n or !coffee +n yyyy-mm-dd", + "-n": "coffee -n: remove cups, warns if hitting 0. usage: !coffee -n or !coffee -n yyyy-mm-dd", + "yes": "coffee yes or no: confirm or cancel a pending change. usage: !coffee yes or !coffee no", + "no": "coffee yes or no: confirm or cancel a pending change. usage: !coffee yes or !coffee no", +} + +TOPIC_MAP = { + "srv": (SRV_TOPIC, SRV), + "bot": (BOT_TOPIC, BOT), + "files": (FILES_TOPIC, FILES), + "irc": (IRC_TOPIC, IRC), + "coffee": (COFFEE_TOPIC, COFFEE), +} + +_ALL = {**SRV, **BOT, **FILES, **IRC, **COFFEE} + + +def lookup(args): + if not args: + return TOPICS + first = args[0].lower() + if first in TOPIC_MAP: + if len(args) == 1: + return TOPIC_MAP[first][0] + cmd = args[1].lower() + result = TOPIC_MAP[first][1].get(cmd) + if result is None and first == "bot": + return BOT_NAME + return result + return _ALL.get(first) diff --git a/alfred/mlmmj.py b/alfred/mlmmj.py new file mode 100644 index 0000000..5dfbfc3 --- /dev/null +++ b/alfred/mlmmj.py @@ -0,0 +1,97 @@ +""" +Sopel plugin: mlmmj mailing list management + + !list subscribe <email> - subscribe to list@gumx.cc + !list unsubscribe <email> - unsubscribe + !list members - show all subscribers + !list send <message> - send a message to the list as owner +""" + +import email.utils, glob, os, subprocess, tempfile +from sopel import plugin + +LIST_DIR = "/var/spool/mlmmj/list" +LIST_ADDR = "list@gumx.cc" +OWNER_ADDR = "ahmed@gumx.cc" + + +def _owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +@plugin.command("list") +def cmd_list(bot, trigger): + if not _owner(bot, trigger): + return + + args = (trigger.group(2) or "").split(None, 1) + if not args: + bot.say("usage: !list <subscribe|unsubscribe|members|send>") + return + + action = args[0] + + if action == "subscribe": + if len(args) < 2: + bot.say("usage: !list subscribe <email>") + return + email_addr = args[1].strip() + r = subprocess.run( + ["mlmmj-sub", "-L", LIST_DIR, "-a", email_addr, "-s", "-f"], + capture_output=True, text=True + ) + bot.say(f"subscribed: {email_addr}" if r.returncode == 0 + else f"error: {(r.stderr or r.stdout).strip()}") + + elif action == "unsubscribe": + if len(args) < 2: + bot.say("usage: !list unsubscribe <email>") + return + email_addr = args[1].strip() + r = subprocess.run( + ["mlmmj-unsub", "-L", LIST_DIR, "-a", email_addr, "-s", "-f"], + capture_output=True, text=True + ) + bot.say(f"unsubscribed: {email_addr}" if r.returncode == 0 + else f"error: {(r.stderr or r.stdout).strip()}") + + elif action == "members": + subs_dir = os.path.join(LIST_DIR, "subscribers.d") + subs = [] + if os.path.isdir(subs_dir): + for fname in sorted(glob.glob(os.path.join(subs_dir, "*"))): + with open(fname) as f: + subs.extend(line.strip() for line in f if line.strip()) + if subs: + bot.say(f"{len(subs)} subscriber(s): " + ", ".join(subs)) + else: + bot.say("no subscribers") + + elif action == "send": + if len(args) < 2: + bot.say("usage: !list send <message>") + return + message = args[1].strip() + msg = ( + f"From: {OWNER_ADDR}\n" + f"To: {LIST_ADDR}\n" + f"Subject: {message}\n" + f"Date: {email.utils.formatdate(localtime=True)}\n" + f"\n" + f"{message}\n" + ) + with tempfile.NamedTemporaryFile(mode="w", suffix=".eml", delete=False) as f: + f.write(msg) + tmpname = f.name + try: + r = subprocess.run( + ["mlmmj-send", "-L", LIST_DIR, "-l", "1", "-m", tmpname], + capture_output=True, text=True + ) + bot.say("sent" if r.returncode == 0 + else f"error: {(r.stderr or r.stdout).strip()}") + finally: + os.unlink(tmpname) + + else: + bot.say("usage: !list <subscribe|unsubscribe|members|send>") diff --git a/alfred/server.py b/alfred/server.py new file mode 100644 index 0000000..cfb4c69 --- /dev/null +++ b/alfred/server.py @@ -0,0 +1,266 @@ +""" +Sopel plugin: server stats + +Commands (owner only) - use !server or !srv: + !server - show this usage + !server uptime - uptime and load averages + !server disk - disk usage for / + !server mem - RAM and swap usage + !server load - 1/5/15 min load + process count + !server status [service] - rc-service status (all or one) + !server net [iface] - RX/TX for iface (default: first non-lo) + !server top [n] - top n procs by CPU (default 5) + !server topmem [n] - top n procs by memory (default 5) + !server io - disk read/write stats for main block device + !server conns - TCP connection summary + !server who - logged-in users + !server logs <service> [n] - last n lines of service log (default 10) +""" + +import os +import subprocess + +from sopel import plugin +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 + +SERVICES = [ + "nginx", "postfix", "dovecot", "ngircd", "soju", + "opendkim", "fcgiwrap", "alfred", "dcron", "0x0", +] + +LOG_PATHS = { + "nginx": ["/var/log/nginx/error.log", "/var/log/nginx/access.log"], + "postfix": ["/var/log/mail.log", "/var/log/messages"], + "dovecot": ["/var/log/dovecot.log", "/var/log/messages"], + "ngircd": ["/var/log/ngircd.log", "/var/log/messages"], + "soju": ["/var/log/soju.log", "/var/log/messages"], + "alfred": ["/var/log/alfred/sopel.log", "/var/log/alfred/daemon.log"], + "herald": ["/var/log/herald/sopel.log", "/var/log/herald/daemon.log"], +} + + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _run(cmd, timeout=10): + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + out = (r.stdout + r.stderr).strip() + return out[:400] if out else "(no output)" + except subprocess.TimeoutExpired: + return "(timed out)" + except Exception as e: + return f"(error: {e})" + + +def _uptime(bot, _args): + bot.say(_run(["uptime"])) + + +def _disk(bot, _args): + bot.say(_run(["df", "-h", "/"])) + + +def _mem(bot, _args): + bot.say(_run(["free", "-h"])) + + +def _load(bot, _args): + try: + with open("/proc/loadavg") as f: + fields = f.read().split() + load = f"{fields[0]} {fields[1]} {fields[2]}" + procs = fields[3] + bot.say(f"load: {load} | procs: {procs}") + except OSError as e: + bot.say(f"error: {e}") + + +def _status(bot, args): + targets = [args[0]] if args else SERVICES + results = [] + for svc in targets: + r = subprocess.run( + ["rc-service", svc, "status"], + capture_output=True, text=True, timeout=5 + ) + out = (r.stdout + r.stderr).strip() + state = "started" if "started" in out else "stopped" if "stopped" in out else out[:20] + results.append(f"{svc}:{state}") + bot.say(" | ".join(results)) + + +def _net(bot, args): + iface = args[0] if args else None + try: + with open("/proc/net/dev") as f: + lines = f.readlines()[2:] + devs = {} + for line in lines: + parts = line.split() + name = parts[0].rstrip(":") + if name == "lo": + continue + devs[name] = {"rx": int(parts[1]), "tx": int(parts[9])} + + if not devs: + bot.say("net: no interfaces found") + return + + target = iface if iface in devs else next(iter(devs)) + if iface and iface not in devs: + bot.say(f"net: unknown iface '{iface}', using {target}") + + def fmt(n): + for unit in ("B", "KB", "MB", "GB"): + if n < 1024: + return f"{n:.1f}{unit}" + n /= 1024 + return f"{n:.1f}TB" + + d = devs[target] + bot.say(f"{target}: RX {fmt(d['rx'])} | TX {fmt(d['tx'])}") + except OSError as e: + bot.say(f"error: {e}") + + +def _top_procs(bot, args, sort_col, label): + try: + n = int(args[0]) if args else 5 + n = max(1, min(n, 15)) + except ValueError: + bot.say("usage: !srv top [n]") + return + + r = subprocess.run(["top", "-b", "-n", "1"], capture_output=True, text=True) + lines = r.stdout.strip().splitlines() + + proc_lines = [] + in_procs = False + for line in lines: + if not in_procs: + if "PID" in line and "PPID" in line: + in_procs = True + continue + if line.strip(): + proc_lines.append(line) + + if not proc_lines: + bot.say("no processes found") + return + + # columns: PID PPID USER STAT VSZ %VSZ CPU %CPU COMMAND + procs = [] + for line in proc_lines: + parts = line.split(None, 8) + if len(parts) < 9: + continue + try: + val = float(parts[sort_col].rstrip("%m")) + user = parts[2] + cmd = parts[8].split()[0].split("/")[-1][:25] + procs.append((val, user, cmd)) + except (ValueError, IndexError): + continue + + procs.sort(reverse=True) + out = " | ".join(f"{cmd}({user},{v:.1f}%)" for v, user, cmd in procs[:n]) + bot.say(f"{label}: {out}" if out else f"{label}: (no data)") + + +def _top(bot, args): + _top_procs(bot, args, sort_col=7, label="cpu") + + +def _topmem(bot, args): + _top_procs(bot, args, sort_col=5, label="mem") + + +def _io(bot, _args): + try: + with open("/proc/diskstats") as f: + lines = f.readlines() + for line in lines: + parts = line.split() + if len(parts) < 14: + continue + name = parts[2] + if name.startswith(("sd", "vd", "nvme", "hd", "mmcblk")) and not name[-1].isdigit(): + reads = int(parts[5]) + writes = int(parts[9]) + bot.say(f"{name}: reads {reads} | writes {writes}") + return + bot.say("io: no block device found") + except OSError as e: + bot.say(f"error: {e}") + + +def _conns(bot, _args): + bot.say(_run(["ss", "-s"])) + + +def _who(bot, _args): + out = _run(["who"]) + bot.say(out if out.strip() else "who: no users logged in") + + +def _logs(bot, args): + if not args: + bot.say("usage: !srv logs <service> [n]") + return + svc = args[0] + try: + n = int(args[1]) if len(args) > 1 else 10 + n = max(1, min(n, 30)) + except ValueError: + bot.say("usage: !srv logs <service> [n]") + return + + candidates = LOG_PATHS.get(svc, [f"/var/log/{svc}.log", f"/var/log/{svc}/error.log"]) + for path in candidates: + if os.path.exists(path): + out = _run(["tail", "-n", str(n), path]) + for line in out.splitlines()[:n]: + bot.say(line) + return + bot.say(f"logs: no log file found for '{svc}'") + + +_SUBCOMMANDS = { + "uptime": _uptime, + "disk": _disk, + "mem": _mem, + "load": _load, + "status": _status, + "net": _net, + "top": _top, + "topmem": _topmem, + "io": _io, + "conns": _conns, + "who": _who, + "logs": _logs, +} + + +@plugin.commands("server", "srv") +def cmd_server(bot, trigger): + if not _is_owner(bot, trigger): + return + + args = (trigger.group(2) or "").split() + sub = args[0].lower() if args else "" + + if not sub: + bot.say(h.SRV_TOPIC) + return + + fn = _SUBCOMMANDS.get(sub) + if fn: + fn(bot, args[1:]) + else: + bot.say(f"server: unknown subcommand '{sub}' - type !srv for usage") diff --git a/alfred/soju.py b/alfred/soju.py new file mode 100644 index 0000000..02428a5 --- /dev/null +++ b/alfred/soju.py @@ -0,0 +1,193 @@ +""" +Sopel plugin: soju IRC bouncer management + +All commands are owner-only and work in channel or PM. + + !irc - show usage + + !irc user list - list all bouncer users + !irc user add <nick> <pass> - create a new user + !irc user remove <nick> - delete a user + !irc user passwd <nick> <pass> - change a user's password + !irc user enable <nick> - re-enable a disabled user + !irc user disable <nick> - disable a user + + !irc net list <user> - list networks for a user + !irc net status <user> - show network connection status + !irc net add <user> <name> - add a network using a preset + !irc net add <user> <name> <addr> - add a network with a custom address + !irc net remove <user> <name> - remove a network + !irc net quote <user> <net> <cmd> - send raw IRC command to a network + !irc net presets - list available network presets + + !irc bouncer - show bouncer-wide stats + !irc notice <message> - broadcast a notice to all users +""" + +import subprocess + +from sopel import plugin +from sopel.config.types import FilenameAttribute, StaticSection +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 + + +NETWORK_PRESETS = { + "libera": "ircs://irc.libera.chat:6697", + "hackint": "ircs://irc.hackint.org:6697", + "oftc": "ircs://irc.oftc.net:6697", + "rizon": "ircs://irc.rizon.net:6697", + "efnet": "ircs://irc.efnet.org:6697", +} + + +class SojuSection(StaticSection): + config_path = FilenameAttribute("config_path", relative=False) + + +def setup(bot): + bot.settings.define_section("soju", SojuSection) + + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _sojuctl(bot, *args, timeout=15): + cmd = ["sojuctl"] + cfg = bot.settings.soju.config_path + if cfg: + cmd += ["-config", cfg] + cmd += list(args) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + out = (result.stdout + result.stderr).strip() + return out[:400] if out else "(no output)" + except subprocess.TimeoutExpired: + return "(timed out)" + except Exception as e: + return f"(error: {e})" + + +def _send(bot, text): + for line in text.splitlines(): + if line.strip(): + bot.say(line.strip()) + + +# --------------------------------------------------------------------------- +# !irc +# --------------------------------------------------------------------------- + +@plugin.command("irc") +def cmd_irc(bot, trigger): + if not _is_owner(bot, trigger): + return + + args = (trigger.group(2) or "").split() + + if not args: + bot.say(h.IRC_TOPIC) + return + + sub = args[0] + + if sub == "bouncer": + _send(bot, _sojuctl(bot, "server", "status")) + + elif sub == "notice": + if len(args) < 2: + bot.say("usage: !irc notice <message>") + return + _send(bot, _sojuctl(bot, "server", "notice", " ".join(args[1:]))) + + elif sub == "user": + _cmd_user(bot, args[1:]) + + elif sub == "net": + _cmd_net(bot, args[1:]) + + else: + bot.say("unknown subcommand - type !irc for usage") + + +# --------------------------------------------------------------------------- +# user subcommands +# --------------------------------------------------------------------------- + +def _cmd_user(bot, args): + if not args: + bot.say("usage: !irc user <list|add <nick> <pass>|remove <nick>|passwd <nick> <pass>|enable <nick>|disable <nick>>") + return + + sub = args[0] + + if sub == "list": + _send(bot, _sojuctl(bot, "user", "status")) + + elif sub == "add" and len(args) == 3: + _send(bot, _sojuctl(bot, "user", "create", + "-username", args[1], "-password", args[2])) + + elif sub == "remove" and len(args) == 2: + _send(bot, _sojuctl(bot, "user", "delete", args[1])) + + elif sub == "passwd" and len(args) == 3: + _send(bot, _sojuctl(bot, "user", "update", args[1], "-password", args[2])) + + elif sub == "enable" and len(args) == 2: + _send(bot, _sojuctl(bot, "user", "update", args[1], "-enabled", "true")) + + elif sub == "disable" and len(args) == 2: + _send(bot, _sojuctl(bot, "user", "update", args[1], "-enabled", "false")) + + else: + bot.say("usage: !irc user <list|add <nick> <pass>|remove <nick>|passwd <nick> <pass>|enable <nick>|disable <nick>>") + + +# --------------------------------------------------------------------------- +# net subcommands +# --------------------------------------------------------------------------- + +def _cmd_net(bot, args): + if not args: + bot.say("usage: !irc net <list <user>|status <user>|add <user> <name> [addr]|remove <user> <name>|quote <user> <net> <cmd>|presets>") + return + + sub = args[0] + + if sub == "presets": + bot.say("presets: " + ", ".join(f"{k} ({v})" for k, v in NETWORK_PRESETS.items())) + + elif sub == "list" and len(args) == 2: + _send(bot, _sojuctl(bot, "user", "run", args[1], "network", "status")) + + elif sub == "status" and len(args) == 2: + _send(bot, _sojuctl(bot, "user", "run", args[1], "network", "status")) + + elif sub == "add" and len(args) == 3: + name = args[2] + addr = NETWORK_PRESETS.get(name) + if not addr: + bot.say(f"unknown preset '{name}' - use !irc net presets or provide an address") + return + _send(bot, _sojuctl(bot, "user", "run", args[1], + "network", "create", "-name", name, "-addr", addr)) + + elif sub == "add" and len(args) == 4: + _send(bot, _sojuctl(bot, "user", "run", args[1], + "network", "create", "-name", args[2], "-addr", args[3])) + + elif sub == "remove" and len(args) == 3: + _send(bot, _sojuctl(bot, "user", "run", args[1], + "network", "delete", args[2])) + + elif sub == "quote" and len(args) >= 4: + _send(bot, _sojuctl(bot, "user", "run", args[1], + "network", "quote", args[2], " ".join(args[3:]))) + + else: + bot.say("usage: !irc net <list <user>|status <user>|add <user> <name> [addr]|remove <user> <name>|quote <user> <net> <cmd>|presets>") diff --git a/alfred/vpn.py b/alfred/vpn.py new file mode 100644 index 0000000..230aae1 --- /dev/null +++ b/alfred/vpn.py @@ -0,0 +1,170 @@ +""" +Sopel plugin: WireGuard VPN peer management + + !vpn add <name> <pubkey> - add peer, returns client config URL + !vpn remove <name> - remove peer + !vpn list - list all peers with IPs + !vpn pubkey - print server public key +""" + +import json, os, subprocess, tempfile + +import requests +from sopel import plugin + +PEERS = "/etc/wireguard/peers.json" +PUBKEY = "/etc/wireguard/server.pub" +FHOST = "http://127.0.0.1:5000" +SUBNET = "10.0.0." + + +def _owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _peers(): + if os.path.exists(PEERS): + with open(PEERS) as f: + return json.load(f) + return {} + + +def _save(peers): + with open(PEERS, "w") as f: + json.dump(peers, f, indent=2) + + +def _next_ip(peers): + used = {p["ip"] for p in peers.values()} + for i in range(2, 255): + ip = f"{SUBNET}{i}" + if ip not in used: + return ip + return None + + +def _server_pub(): + with open(PUBKEY) as f: + return f.read().strip() + + +def _wg(*args): + subprocess.run(["/usr/bin/wg"] + list(args), check=True) + + +def _rewrite_conf(peers): + conf_path = "/etc/wireguard/wg0.conf" + with open(conf_path) as f: + lines = f.readlines() + new_lines, skip = [], False + for line in lines: + if line.strip() == "[Peer]": + skip = True + elif line.strip().startswith("[") and line.strip() != "[Peer]": + skip = False + if not skip: + new_lines.append(line) + conf = "".join(new_lines).rstrip() + "\n" + for name, p in peers.items(): + conf += f"\n[Peer]\n# {name}\nPublicKey = {p['pubkey']}\nAllowedIPs = {p['ip']}/32\n" + with open(conf_path, "w") as f: + f.write(conf) + + +def _upload(content): + with tempfile.NamedTemporaryFile(mode="w", suffix=".conf", delete=False) as f: + f.write(content) + name = f.name + try: + with open(name, "rb") as fh: + r = requests.post(FHOST, files={"file": fh}, timeout=10) + r.raise_for_status() + return r.text.strip() + finally: + os.unlink(name) + + +@plugin.command("vpn") +def cmd_vpn(bot, trigger): + if not _owner(bot, trigger): + return + + args = (trigger.group(2) or "").split() + if not args: + bot.say("usage: !vpn <add|remove|list|pubkey>") + return + + action = args[0] + peers = _peers() + + if action == "pubkey": + bot.say(_server_pub()) + + elif action == "list": + if not peers: + bot.say("no peers") + return + for name, p in peers.items(): + bot.say(f"{name}: {p['ip']} {p['pubkey'][:24]}...") + + elif action == "add": + if len(args) < 3: + bot.say("usage: !vpn add <name> <pubkey>") + return + name, pubkey = args[1], args[2] + if name in peers: + bot.say(f"{name}: already exists") + return + ip = _next_ip(peers) + if not ip: + bot.say("no IPs available") + return + peers[name] = {"ip": ip, "pubkey": pubkey} + _save(peers) + try: + _wg("set", "wg0", "peer", pubkey, "allowed-ips", f"{ip}/32") + _rewrite_conf(peers) + except subprocess.CalledProcessError as e: + bot.say(f"error: {e}") + return + pub = _server_pub() + cfg = ( + f"[Interface]\n" + f"PrivateKey = <YOUR_PRIVATE_KEY>\n" + f"Address = {ip}/32\n" + f"DNS = 1.1.1.1\n\n" + f"[Peer]\n" + f"PublicKey = {pub}\n" + f"Endpoint = wk.fo:51820\n" + f"AllowedIPs = 0.0.0.0/0\n" + f"PersistentKeepalive = 25\n" + ) + try: + url = _upload(cfg) + bot.say(f"{name}: {ip} — {url}") + except Exception: + bot.say(f"{name}: {ip} — upload failed. server pubkey: {pub}") + + elif action == "remove": + if len(args) < 2: + bot.say("usage: !vpn remove <name>") + return + name = args[1] + if name not in peers: + bot.say(f"{name}: not found") + return + pubkey = peers[name]["pubkey"] + try: + _wg("set", "wg0", "peer", pubkey, "remove") + except subprocess.CalledProcessError as e: + bot.say(f"warning: live removal failed: {e}") + del peers[name] + _save(peers) + try: + _rewrite_conf(peers) + except Exception: + pass + bot.say(f"{name}: removed") + + else: + bot.say("usage: !vpn <add|remove|list|pubkey>") diff --git a/contessa/__init__.py b/contessa/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/contessa/__init__.py diff --git a/contessa/commands.py b/contessa/commands.py new file mode 100644 index 0000000..ac5be72 --- /dev/null +++ b/contessa/commands.py @@ -0,0 +1,191 @@ +""" +Sopel plugin: contessa - recipe bot + +Egyptian, Middle Eastern and Western recipes. + + !breakfast [list] - random breakfast dish or list all + !lunch [list] - random lunch dish or list all + !dinner [list] - random dinner dish or list all + !snack [list] - random snack or list all + !recipe <name> - full recipe (up to 3 lines) + + !join / !part / !chans / !help - owner commands +""" + +import json +import os +import random + +from sopel import plugin +from sopel.config.types import FilenameAttribute, StaticSection + + +class ContessaSection(StaticSection): + recipes_path = FilenameAttribute("recipes_path", relative=False, + default="/var/contessa/recipes.json") + + +def setup(bot): + bot.config.define_section("contessa", ContessaSection, validate=False) + try: + path = bot.config.contessa.recipes_path + except Exception: + path = "/var/contessa/recipes.json" + + try: + with open(path) as f: + bot.memory["ct_recipes"] = json.load(f) + except Exception: + bot.memory["ct_recipes"] = {"breakfast": [], "lunch": [], "dinner": [], "snack": []} + + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _fmt_random(category, dish): + return ( + f"{category.capitalize()} suggestion: " + f"{dish['name']} | {dish['description']}. " + f"Type !recipe {dish['name'].lower()} for the full recipe." + ) + + +def _fmt_list(category, dishes): + names = ", ".join(d["name"] for d in dishes) + return f"{category.capitalize()} dishes ({len(dishes)}): {names}" + + +def _fmt_recipe(dish): + line1 = f"{dish['name']} | {dish['description']}" + line2 = "Ingredients: " + ", ".join(dish["ingredients"]) + steps = " ".join( + f"{i+1}. {s.rstrip('.')}." for i, s in enumerate(dish["steps"]) + ) + line3 = steps + return line1, line2, line3 + + +def _all_dishes(bot): + recipes = bot.memory.get("ct_recipes", {}) + for category, dishes in recipes.items(): + for dish in dishes: + yield category, dish + + +def _find_dish(bot, query): + q = query.strip().lower() + for _, dish in _all_dishes(bot): + if dish["name"].lower() == q: + return dish + for _, dish in _all_dishes(bot): + if q in dish["name"].lower(): + return dish + return None + + +def _meal_cmd(bot, trigger, category): + arg = (trigger.group(2) or "").strip().lower() + recipes = bot.memory.get("ct_recipes", {}) + dishes = recipes.get(category, []) + + if not dishes: + bot.say(f"no {category} recipes loaded", trigger.sender) + return + + if arg == "list": + bot.say(_fmt_list(category, dishes), trigger.sender) + return + + dish = random.choice(dishes) + bot.say(_fmt_random(category, dish), trigger.sender) + + +@plugin.command("breakfast") +@plugin.require_chanmsg +def cmd_breakfast(bot, trigger): + _meal_cmd(bot, trigger, "breakfast") + + +@plugin.command("lunch") +@plugin.require_chanmsg +def cmd_lunch(bot, trigger): + _meal_cmd(bot, trigger, "lunch") + + +@plugin.command("dinner") +@plugin.require_chanmsg +def cmd_dinner(bot, trigger): + _meal_cmd(bot, trigger, "dinner") + + +@plugin.command("snack") +@plugin.require_chanmsg +def cmd_snack(bot, trigger): + _meal_cmd(bot, trigger, "snack") + + +@plugin.command("recipe") +@plugin.require_chanmsg +def cmd_recipe(bot, trigger): + query = (trigger.group(2) or "").strip() + if not query: + bot.say("usage: !recipe <dish name>", trigger.sender) + return + + dish = _find_dish(bot, query) + if not dish: + bot.say(f"no recipe found for '{query}'", trigger.sender) + return + + line1, line2, line3 = _fmt_recipe(dish) + bot.say(line1, trigger.sender) + bot.say(line2, trigger.sender) + bot.say(line3, trigger.sender) + + +@plugin.command("join") +def cmd_join(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() + if not channel.startswith("#"): + bot.say("usage: !join #channel", trigger.nick) + return + bot.join(channel) + + +@plugin.command("part") +def cmd_part(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() or str(trigger.sender) + bot.part(channel) + + +@plugin.command("chans") +def cmd_chans(bot, trigger): + if not _is_owner(bot, trigger): + return + chans = sorted(str(c) for c in bot.channels) + bot.say(("in: " + " ".join(chans)) if chans else "not in any channels", trigger.nick) + + +@plugin.command("help") +def cmd_help(bot, trigger): + lines = [ + "!breakfast [list] - random breakfast dish or list all", + "!lunch [list] - random lunch dish or list all", + "!dinner [list] - random dinner dish or list all", + "!snack [list] - random snack or list all", + "!recipe <name> - full recipe (3 lines)", + ] + if _is_owner(bot, trigger): + lines += [ + "!join #channel - join a channel", + "!part [#channel] - leave a channel", + "!chans - list channels", + ] + lines.append("!help - this message") + for line in lines: + bot.say(line, trigger.nick) diff --git a/contessa/contessa.cfg b/contessa/contessa.cfg new file mode 100644 index 0000000..d68aa22 --- /dev/null +++ b/contessa/contessa.cfg @@ -0,0 +1,19 @@ +[core] +nick = contessa +user = contessa +name = contessa +host = 127.0.0.1 +port = 6667 +owner = yournick +password = REPLACEME +prefix = ! +channels = #yourchannel +extra = /path/to/irc-bots/contessa +enable = + commands +logdir = /var/log/contessa +logging_level = INFO +timeout = 120 + +[contessa] +recipes_path = /var/contessa/recipes.json diff --git a/contessa/recipes.json b/contessa/recipes.json new file mode 100644 index 0000000..0edc99f --- /dev/null +++ b/contessa/recipes.json @@ -0,0 +1,586 @@ +{ + "breakfast": [ + { + "name": "Ful Medames", + "description": "slow-cooked fava beans with garlic, lemon and olive oil", + "ingredients": ["fava beans", "garlic", "lemon juice", "olive oil", "cumin", "parsley", "salt"], + "steps": ["Soak beans overnight", "Boil until tender (45 min)", "Mash lightly with garlic and lemon", "Season with cumin and salt", "Top with olive oil and parsley"] + }, + { + "name": "Tamiya", + "description": "Egyptian falafel made from fava beans and herbs", + "ingredients": ["dried fava beans", "chickpeas", "parsley", "dill", "coriander", "garlic", "onion", "cumin", "sesame seeds", "oil for frying"], + "steps": ["Soak fava beans and chickpeas overnight (do not cook)", "Blend with herbs, garlic and spices into a paste", "Shape into flat patties and coat with sesame seeds", "Fry in hot oil 2-3 min per side until golden", "Serve in bread with tahini and tomato"] + }, + { + "name": "Shakshuka", + "description": "eggs poached in spiced tomato and pepper sauce", + "ingredients": ["eggs", "tomatoes", "red bell pepper", "onion", "garlic", "cumin", "paprika", "chilli flakes", "olive oil", "salt"], + "steps": ["Saute onion and pepper in olive oil until soft", "Add garlic and spices, cook 1 min", "Add tomatoes and simmer 10 min until thick", "Make wells and crack in eggs", "Cover and cook 5-8 min until whites are set"] + }, + { + "name": "Feteer Meshaltet", + "description": "flaky layered Egyptian pastry with ghee", + "ingredients": ["flour", "water", "ghee", "salt", "sugar", "lemon juice", "vinegar", "oil"], + "steps": ["Mix flour, salt, sugar and water into a soft dough", "Rest 10 min then divide into balls", "Roll each ball thin, coat with ghee, fold and roll again", "Layer multiple sheets in a pan with ghee between each", "Bake at 200C for 20 min until golden and crisp"] + }, + { + "name": "Balila", + "description": "warm chickpeas with olive oil, cumin and lemon", + "ingredients": ["chickpeas", "olive oil", "cumin", "lemon juice", "garlic", "parsley", "salt"], + "steps": ["Cook chickpeas until tender or use canned", "Warm in a pan with olive oil and minced garlic", "Season with cumin, lemon juice and salt", "Top with chopped parsley", "Serve warm with bread"] + }, + { + "name": "Bissara", + "description": "Egyptian dried fava bean dip with herbs and spices", + "ingredients": ["dried split fava beans", "garlic", "cumin", "coriander", "lemon juice", "olive oil", "parsley", "paprika", "salt"], + "steps": ["Boil fava beans with garlic until very soft (45 min)", "Blend with cumin, coriander, lemon and olive oil until smooth", "Season with salt, adjust consistency with water", "Top with a drizzle of olive oil and paprika", "Serve warm with flatbread"] + }, + { + "name": "Hawawshi", + "description": "crispy Egyptian flatbread stuffed with spiced minced meat", + "ingredients": ["minced beef", "onion", "parsley", "green pepper", "cumin", "coriander", "salt", "black pepper", "chilli", "flatbread dough"], + "steps": ["Mix beef with finely chopped onion, parsley, pepper and spices", "Split dough into balls and roll flat", "Place meat filling on half and fold dough over, sealing edges", "Bake at 220C for 20-25 min until crispy and golden", "Serve hot"] + }, + { + "name": "Eggah", + "description": "Egyptian baked herb omelette", + "ingredients": ["eggs", "parsley", "dill", "onion", "garlic", "flour", "baking powder", "olive oil", "salt", "black pepper"], + "steps": ["Beat eggs with chopped herbs, onion and garlic", "Mix in flour and baking powder, season well", "Heat olive oil in an oven-safe pan", "Pour in egg mixture and cook 3 min on stovetop", "Bake at 180C for 15-20 min until set and golden"] + }, + { + "name": "Eggs with Pastrami", + "description": "sauteed beef pastrami with scrambled eggs", + "ingredients": ["eggs", "beef pastrami", "tomato", "onion", "olive oil", "salt", "black pepper", "cumin"], + "steps": ["Slice pastrami and fry in olive oil until edges crisp", "Add diced onion and tomato, cook 3 min", "Beat eggs with salt, pepper and cumin", "Pour eggs over pastrami and scramble gently", "Serve immediately with bread"] + }, + { + "name": "Om Ali", + "description": "Egyptian bread pudding with milk, nuts and cream", + "ingredients": ["puff pastry", "full-fat milk", "heavy cream", "sugar", "mixed nuts", "raisins", "desiccated coconut", "vanilla"], + "steps": ["Bake puff pastry until golden then break into pieces", "Bring milk, cream and sugar to a gentle boil", "Layer pastry pieces in a baking dish", "Pour hot milk mixture over pastry", "Top with nuts, raisins and coconut, bake at 200C for 15 min until golden"] + }, + { + "name": "Ful Akhdar", + "description": "fresh fava beans sauteed with olive oil and garlic", + "ingredients": ["fresh fava beans", "garlic", "olive oil", "lemon juice", "dill", "salt"], + "steps": ["Shell and peel fresh fava beans", "Saute garlic in olive oil until fragrant", "Add beans and cook 5-7 min until tender", "Squeeze lemon juice and toss with dill", "Serve warm as a side or with bread"] + }, + { + "name": "Fatteh with Yogurt", + "description": "layers of toasted bread, chickpeas and garlicky yogurt", + "ingredients": ["flatbread", "chickpeas", "yogurt", "tahini", "garlic", "lemon juice", "olive oil", "paprika", "parsley", "pine nuts"], + "steps": ["Toast or fry flatbread pieces until crisp", "Layer bread in a dish with warm chickpeas", "Mix yogurt with garlic, tahini and lemon juice", "Pour yogurt sauce generously over layers", "Top with olive oil, paprika and toasted pine nuts"] + }, + { + "name": "French Toast", + "description": "thick bread slices dipped in egg and pan-fried until golden", + "ingredients": ["thick white bread", "eggs", "milk", "vanilla extract", "cinnamon", "butter", "maple syrup", "powdered sugar"], + "steps": ["Beat eggs with milk, vanilla and cinnamon", "Dip bread slices in egg mixture for 30 seconds each side", "Melt butter in a pan over medium heat", "Fry bread 2-3 min per side until golden", "Dust with powdered sugar and serve with maple syrup"] + }, + { + "name": "Pancakes", + "description": "fluffy American-style pancakes with maple syrup", + "ingredients": ["flour", "eggs", "milk", "butter", "baking powder", "sugar", "salt", "vanilla", "maple syrup"], + "steps": ["Whisk dry ingredients together", "Mix wet ingredients separately then combine gently (lumps are fine)", "Heat buttered pan over medium heat", "Pour ladle-size portions and cook until bubbles form, flip once", "Stack and serve with butter and maple syrup"] + }, + { + "name": "Full English Breakfast", + "description": "a hearty plate of eggs, beef sausages, beans and grilled vegetables", + "ingredients": ["beef sausages", "eggs", "baked beans", "mushrooms", "tomatoes", "bread", "butter", "oil", "salt", "black pepper"], + "steps": ["Grill sausages turning occasionally until cooked through (15 min)", "Fry mushrooms and halved tomatoes in butter", "Warm beans in a small saucepan", "Fry or poach eggs to your liking", "Toast bread and arrange everything on a large plate"] + }, + { + "name": "Avocado Toast with Poached Eggs", + "description": "creamy smashed avocado on sourdough with runny poached eggs", + "ingredients": ["sourdough bread", "avocados", "eggs", "lemon juice", "chilli flakes", "salt", "black pepper", "olive oil", "white vinegar"], + "steps": ["Toast sourdough slices until crisp", "Mash avocado with lemon juice, salt and chilli flakes", "Bring water to a gentle simmer, add a splash of vinegar", "Crack eggs into water and poach 3-4 min", "Spread avocado on toast, top with poached eggs and black pepper"] + }, + { + "name": "Crepes with Honey and Lemon", + "description": "thin French-style crepes served with honey and lemon juice", + "ingredients": ["flour", "eggs", "milk", "butter", "salt", "sugar", "honey", "lemon"], + "steps": ["Blend flour, eggs, milk, melted butter and salt into a smooth thin batter", "Rest batter 30 min", "Heat a non-stick pan, add tiny knob of butter", "Pour thin layer of batter, swirl to cover pan, cook 1 min per side", "Fold and serve drizzled with honey and fresh lemon juice"] + }, + { + "name": "Belgian Waffles", + "description": "deep crispy waffles with fresh cream and fruit", + "ingredients": ["flour", "eggs", "milk", "butter", "sugar", "baking powder", "vanilla", "salt", "whipped cream", "strawberries"], + "steps": ["Separate eggs and beat whites to stiff peaks", "Mix yolks with melted butter, milk, sugar and vanilla", "Fold in flour and baking powder then gently fold in egg whites", "Preheat waffle iron and grease lightly", "Cook waffles until golden and crispy, serve with cream and fruit"] + }, + { + "name": "Bircher Muesli", + "description": "oats soaked overnight in apple juice with yogurt and fruit", + "ingredients": ["rolled oats", "apple juice", "yogurt", "grated apple", "lemon juice", "honey", "mixed berries", "toasted almonds"], + "steps": ["Mix oats with apple juice, cover and refrigerate overnight", "In the morning stir in yogurt and grated apple", "Add a squeeze of lemon and drizzle of honey", "Top with mixed berries and toasted almonds", "Serve cold"] + }, + { + "name": "Smoked Salmon Bagel", + "description": "toasted bagel with cream cheese and smoked salmon", + "ingredients": ["bagel", "cream cheese", "smoked salmon", "capers", "red onion", "cucumber", "lemon", "dill", "black pepper"], + "steps": ["Slice and toast bagel until lightly golden", "Spread generously with cream cheese", "Layer with smoked salmon slices", "Top with thin red onion, capers and cucumber", "Finish with dill, black pepper and a squeeze of lemon"] + }, + { + "name": "Spanish Omelette", + "description": "thick Spanish tortilla with potato and onion", + "ingredients": ["eggs", "potatoes", "onion", "olive oil", "salt"], + "steps": ["Peel and thinly slice potatoes, slice onion", "Cook potatoes and onion slowly in olive oil 20 min until tender (not browned)", "Beat eggs with salt, add potato mixture", "Pour into a lightly oiled pan over medium heat", "Cook 5 min, flip onto a plate then slide back in and cook 3 min more"] + }, + { + "name": "Dutch Baby Pancake", + "description": "oven-puffed German-style pancake served with lemon and sugar", + "ingredients": ["eggs", "flour", "milk", "butter", "salt", "vanilla", "lemon", "powdered sugar"], + "steps": ["Preheat oven to 220C with an oven-safe skillet inside", "Blend eggs, flour, milk, salt and vanilla until smooth", "Add butter to hot skillet and swirl to coat", "Pour in batter and immediately return to oven", "Bake 18-20 min until puffed and golden, serve with lemon and powdered sugar"] + }, + { + "name": "Eggs Florentine", + "description": "poached eggs on wilted spinach and toasted muffins with hollandaise", + "ingredients": ["eggs", "baby spinach", "English muffins", "butter", "garlic", "egg yolks", "lemon juice", "white vinegar", "salt", "cayenne"], + "steps": ["Toast muffins and keep warm", "Wilt spinach with garlic and butter, season and drain", "Make hollandaise by whisking egg yolks over hot water then slowly adding melted butter and lemon", "Poach eggs in simmering water with vinegar for 3-4 min", "Build: muffin, spinach, egg, hollandaise, pinch of cayenne"] + }, + { + "name": "Cinnamon Porridge", + "description": "creamy oat porridge with cinnamon, banana and mixed berries", + "ingredients": ["rolled oats", "milk", "water", "cinnamon", "honey", "banana", "mixed berries", "salt"], + "steps": ["Combine oats, milk, water and a pinch of salt in a saucepan", "Cook over medium heat stirring frequently for 5 min", "Stir in cinnamon and honey to taste", "Pour into bowl and top with sliced banana", "Add mixed berries and an extra drizzle of honey"] + } + ], + "lunch": [ + { + "name": "Koshari", + "description": "Egypt's national street food of rice, lentils, pasta and spiced tomato sauce", + "ingredients": ["rice", "brown lentils", "ditalini pasta", "chickpeas", "tomatoes", "garlic", "vinegar", "cumin", "coriander", "fried onions", "tomato paste"], + "steps": ["Cook lentils and rice together until tender", "Boil pasta separately until al dente", "Fry sliced onions until dark and crispy, set aside", "Simmer tomato sauce with garlic, vinegar and cumin 15 min", "Layer rice mix, pasta and chickpeas, top with sauce and crispy onions"] + }, + { + "name": "Molokhia with Chicken", + "description": "silky jute leaf soup served over rice with roasted chicken", + "ingredients": ["whole chicken", "frozen minced molokhia", "garlic", "coriander", "ghee", "bay leaves", "cardamom", "rice", "onion", "salt"], + "steps": ["Simmer chicken with onion, bay leaves and cardamom 90 min to make broth", "Roast or grill the cooked chicken to brown the skin", "Bring 4 cups broth to boil, add molokhia and cook 2 min stirring", "Fry garlic with coriander in ghee until golden, add to molokhia", "Serve over white rice with chicken on the side"] + }, + { + "name": "Mahshi", + "description": "vegetables stuffed with herbed rice and beef in tomato sauce", + "ingredients": ["courgettes", "peppers", "aubergine", "short-grain rice", "minced beef", "tomatoes", "dill", "parsley", "allspice", "cinnamon", "tomato paste", "olive oil"], + "steps": ["Hollow out vegetables using a corer", "Mix rice with beef, chopped herbs, spices and tomato paste", "Fill vegetables three-quarters full (rice expands)", "Arrange in a pot, pour over tomato broth to cover", "Simmer covered 40 min until rice is cooked and vegetables are tender"] + }, + { + "name": "Macarona Bechamel", + "description": "Egyptian baked pasta with spiced beef and creamy bechamel", + "ingredients": ["penne pasta", "minced beef", "onion", "tomato paste", "bechamel sauce", "butter", "flour", "milk", "nutmeg", "cinnamon", "allspice", "salt"], + "steps": ["Cook pasta al dente and drain", "Brown beef with onion, add tomato paste, cinnamon, allspice and simmer", "Make bechamel: melt butter, whisk in flour, add milk slowly until thick, season with nutmeg", "Mix pasta with some bechamel, layer: pasta, beef, pasta, bechamel in a baking dish", "Bake at 180C for 40-45 min until golden brown on top"] + }, + { + "name": "Bamia", + "description": "slow-cooked okra and beef stew in rich tomato sauce", + "ingredients": ["baby okra", "beef chunks", "tomatoes", "tomato paste", "onion", "garlic", "coriander", "cumin", "lemon juice", "olive oil", "salt"], + "steps": ["Brown beef in olive oil, remove and set aside", "Fry onion and garlic until golden, add spices", "Add tomatoes and tomato paste, return beef and simmer 30 min", "Trim okra stems, add whole and cook 20 min (do not stir to avoid sliminess)", "Finish with lemon juice and serve over rice"] + }, + { + "name": "Daoud Basha", + "description": "Lebanese-Egyptian spiced meatballs in tangy tomato and pine nut sauce", + "ingredients": ["minced beef", "onion", "pine nuts", "tomatoes", "tomato paste", "allspice", "cinnamon", "nutmeg", "pomegranate molasses", "ghee", "parsley"], + "steps": ["Mix beef with grated onion and spices, form into small balls", "Fry pine nuts in ghee until golden, set aside", "Brown meatballs on all sides then remove", "Make sauce with onion, tomatoes, tomato paste, molasses and simmer 10 min", "Add meatballs and pine nuts, simmer 20 min, serve over rice"] + }, + { + "name": "Kabsa", + "description": "Saudi spiced rice with chicken, tomatoes and dried fruits", + "ingredients": ["chicken pieces", "basmati rice", "tomatoes", "onion", "garlic", "dried limes", "cardamom", "cinnamon", "cloves", "raisins", "almonds", "ghee"], + "steps": ["Brown chicken in ghee, add onion and garlic until softened", "Add tomatoes, spices, dried limes and water, simmer chicken 30 min", "Remove chicken and add washed rice to broth, cook covered 20 min", "Fry raisins and almonds in ghee for garnish", "Plate rice, top with chicken and garnish"] + }, + { + "name": "Musakhan", + "description": "Palestinian roasted chicken on flatbread with caramelised onions and sumac", + "ingredients": ["chicken thighs", "onions", "sumac", "taboon flatbread", "olive oil", "allspice", "cinnamon", "toasted pine nuts", "salt"], + "steps": ["Rub chicken with sumac, allspice, cinnamon and salt, roast at 200C for 40 min", "Slowly caramelise onions in generous olive oil with more sumac for 40 min", "Arrange flatbread on a baking tray, spread onions over it", "Place chicken on top, return to oven 10 min to crisp bread", "Top with toasted pine nuts and serve"] + }, + { + "name": "Maqluba", + "description": "Palestinian upside-down rice dish with chicken and vegetables", + "ingredients": ["chicken pieces", "aubergine", "cauliflower", "basmati rice", "onion", "turmeric", "allspice", "cinnamon", "tomatoes", "oil", "toasted almonds"], + "steps": ["Fry aubergine and cauliflower until golden, drain on paper", "Brown chicken with onion and spices, add water and simmer 30 min", "Drain and layer in a deep pot: tomatoes, fried vegetables, chicken, rice", "Pour strained broth over to just cover, cook covered on low 30 min", "Rest 10 min then flip onto a platter, top with almonds"] + }, + { + "name": "Kebda Eskandarani", + "description": "Alexandria-style liver sandwich with peppers and chilli", + "ingredients": ["beef liver", "green peppers", "chilli peppers", "garlic", "cumin", "coriander", "lemon juice", "olive oil", "salt", "flatbread"], + "steps": ["Slice liver thin, season well with cumin, coriander and salt", "Heat oil in a very hot pan until smoking", "Flash-fry liver in batches 1-2 min, do not overcook", "Add sliced peppers and chilli in the last minute", "Stuff into flatbread with a squeeze of lemon"] + }, + { + "name": "Fattet Hummus", + "description": "layered dish of crispy bread, chickpeas and garlicky yogurt", + "ingredients": ["flatbread", "chickpeas", "yogurt", "tahini", "lemon juice", "garlic", "pine nuts", "olive oil", "paprika", "cumin", "parsley"], + "steps": ["Fry or toast flatbread into crispy pieces", "Warm chickpeas in broth or water, season with cumin", "Blend yogurt with tahini, garlic, lemon juice and salt", "Layer in a deep dish: bread, then chickpeas, then yogurt sauce", "Top with toasted pine nuts, drizzle of olive oil and paprika"] + }, + { + "name": "Chicken Tagine", + "description": "Moroccan slow-cooked chicken with preserved lemon, olives and spices", + "ingredients": ["chicken pieces", "preserved lemon", "green olives", "onion", "garlic", "ginger", "turmeric", "cumin", "coriander", "saffron", "olive oil", "parsley"], + "steps": ["Marinate chicken with garlic, ginger, turmeric, cumin and oil for 1 hour", "Brown chicken in a tagine or heavy pot", "Add onions, saffron, stock and cook covered on low 45 min", "Add preserved lemon strips and olives in last 10 min", "Garnish with parsley and serve with couscous or bread"] + }, + { + "name": "Chicken Caesar Salad", + "description": "grilled chicken on romaine with Caesar dressing and croutons", + "ingredients": ["chicken breast", "romaine lettuce", "parmesan", "croutons", "egg yolk", "garlic", "lemon juice", "Worcestershire sauce", "mustard", "olive oil", "black pepper"], + "steps": ["Make dressing: blend garlic, egg yolk, lemon, Worcestershire and mustard then whisk in olive oil", "Season and grill chicken 5-6 min per side until cooked, slice", "Tear romaine, toss with dressing and half the parmesan", "Top with chicken, croutons and remaining parmesan", "Finish with cracked black pepper"] + }, + { + "name": "French Onion Soup", + "description": "deeply caramelised onion broth with a cheese-topped crouton", + "ingredients": ["onions", "beef stock", "dry bread slices", "gruyere cheese", "butter", "olive oil", "thyme", "bay leaf", "salt", "black pepper"], + "steps": ["Slice onions thinly, cook in butter and oil on low heat 45 min until deep golden", "Add thyme, bay leaf and stock, simmer 20 min, season", "Ladle into oven-safe bowls and float toasted bread on top", "Cover bread with grated gruyere", "Grill under broiler until cheese is bubbling and golden"] + }, + { + "name": "Beef Burger", + "description": "juicy beef patty with lettuce, tomato and pickles in a toasted bun", + "ingredients": ["minced beef", "burger buns", "lettuce", "tomato", "red onion", "pickles", "cheddar", "mustard", "ketchup", "salt", "black pepper"], + "steps": ["Season beef with salt and pepper, form into patties, make a thumb indent in centre", "Grill or pan-fry 3-4 min per side for medium, add cheese in last minute", "Toast buns cut-side down in the same pan", "Spread mustard and ketchup on bun", "Layer lettuce, tomato, onion, patty and pickles"] + }, + { + "name": "Greek Salad with Grilled Chicken", + "description": "classic horiatiki salad topped with herb-marinated grilled chicken", + "ingredients": ["chicken breast", "tomatoes", "cucumber", "red onion", "feta cheese", "kalamata olives", "olive oil", "oregano", "lemon juice", "salt", "black pepper"], + "steps": ["Marinate chicken in olive oil, lemon, oregano and garlic 30 min", "Grill chicken 5-6 min per side until cooked through, rest and slice", "Chop tomatoes, cucumber and onion into chunky pieces", "Toss vegetables with olives, olive oil and seasoning", "Top with feta, sliced chicken and a pinch of oregano"] + }, + { + "name": "Minestrone Soup", + "description": "hearty Italian vegetable soup with pasta and beans", + "ingredients": ["cannellini beans", "tomatoes", "courgette", "carrot", "celery", "onion", "garlic", "small pasta", "vegetable stock", "basil", "parmesan", "olive oil"], + "steps": ["Saute onion, celery and carrot in olive oil until soft", "Add garlic, tomatoes and stock, bring to a boil", "Add courgette and beans, simmer 15 min", "Add pasta and cook until al dente", "Stir in torn basil, serve with parmesan"] + }, + { + "name": "Chicken Pesto Pasta", + "description": "penne with basil pesto and grilled chicken", + "ingredients": ["penne", "chicken breast", "basil pesto", "cherry tomatoes", "parmesan", "pine nuts", "olive oil", "garlic", "salt", "black pepper"], + "steps": ["Cook penne until al dente, reserve 1 cup pasta water before draining", "Season and grill chicken 5-6 min per side, rest and slice", "Halve tomatoes and saute in olive oil with garlic 3 min", "Toss hot pasta with pesto, add pasta water to loosen as needed", "Top with chicken, tomatoes, pine nuts and parmesan"] + }, + { + "name": "Smoked Turkey Club Sandwich", + "description": "triple-layer sandwich with smoked turkey, lettuce and tomato", + "ingredients": ["smoked turkey breast", "bread", "lettuce", "tomato", "cucumber", "mayonnaise", "mustard", "avocado", "salt", "black pepper"], + "steps": ["Toast three bread slices per sandwich", "Spread mayonnaise on two slices and mustard on one", "Layer first: lettuce, tomato, turkey", "Add middle toast, then avocado, cucumber and more turkey", "Top with final toast, press gently and cut into triangles with skewers"] + }, + { + "name": "Caprese Salad with Grilled Chicken", + "description": "fresh mozzarella and tomatoes with grilled chicken and basil", + "ingredients": ["chicken breast", "fresh mozzarella", "beef tomatoes", "fresh basil", "olive oil", "balsamic glaze", "salt", "black pepper"], + "steps": ["Season chicken, grill 5-6 min per side until cooked through, rest and slice", "Slice tomatoes and mozzarella to same thickness", "Alternate tomato and mozzarella slices on a plate", "Lay chicken alongside or on top", "Tear basil over, drizzle with olive oil and balsamic glaze, season"] + }, + { + "name": "Leek and Potato Soup", + "description": "velvety smooth British-style leek and potato soup", + "ingredients": ["leeks", "potatoes", "onion", "garlic", "vegetable stock", "butter", "double cream", "thyme", "salt", "black pepper"], + "steps": ["Slice leeks and onion, saute in butter until soft (10 min)", "Add diced potatoes, garlic and thyme", "Pour in stock and simmer 20 min until potatoes are tender", "Blend until smooth, stir in cream and season well", "Serve with crusty bread and a swirl of cream"] + }, + { + "name": "Chicken Souvlaki Wrap", + "description": "Greek marinated chicken skewers wrapped in pita with tzatziki", + "ingredients": ["chicken thighs", "pita bread", "yogurt", "cucumber", "garlic", "lemon juice", "olive oil", "oregano", "tomato", "red onion", "paprika"], + "steps": ["Marinate chicken in olive oil, lemon, oregano and garlic for 1 hour", "Thread onto skewers and grill 4-5 min per side", "Make tzatziki: grate and squeeze cucumber, mix with yogurt, garlic and lemon", "Warm pita on the grill", "Fill pita with chicken, tzatziki, tomato and red onion"] + }, + { + "name": "Tuna Nicoise Salad", + "description": "French composed salad with tuna, green beans, eggs and olives", + "ingredients": ["tuna in olive oil", "green beans", "cherry tomatoes", "eggs", "black olives", "red onion", "capers", "lettuce", "Dijon mustard", "lemon juice", "olive oil"], + "steps": ["Hard-boil eggs 8 min then cool and halve", "Blanch green beans in salted water 3 min, drain and refresh in cold water", "Make dressing: whisk mustard, lemon juice and olive oil", "Arrange lettuce, green beans, tomatoes, olives and onion on plates", "Top with tuna, eggs and capers, drizzle dressing over"] + }, + { + "name": "Roasted Vegetable Flatbread", + "description": "grilled flatbread topped with roasted Mediterranean vegetables and hummus", + "ingredients": ["flatbread", "courgette", "red pepper", "red onion", "cherry tomatoes", "hummus", "feta", "olive oil", "oregano", "balsamic glaze", "salt"], + "steps": ["Slice vegetables and toss with olive oil, oregano and salt, roast at 200C for 25 min", "Grill or toast flatbreads until lightly charred", "Spread hummus generously over each flatbread", "Top with warm roasted vegetables and crumbled feta", "Drizzle with balsamic glaze and serve immediately"] + } + ], + "dinner": [ + { + "name": "Kofta Mashwia", + "description": "grilled Egyptian minced beef skewers with herbs and spices", + "ingredients": ["minced beef", "minced lamb", "onion", "parsley", "garlic", "cumin", "coriander", "allspice", "paprika", "breadcrumbs", "salt"], + "steps": ["Blend onion, garlic and parsley in a food processor", "Mix with beef, lamb, spices and breadcrumbs into a smooth paste", "Refrigerate 30 min then mould onto flat skewers", "Grill on high heat 4 min per side", "Serve with flatbread, tahini and a tomato salad"] + }, + { + "name": "Fatta", + "description": "Egyptian celebratory dish of bread, rice and lamb in spiced tomato broth", + "ingredients": ["lamb pieces", "short-grain rice", "flatbread", "tomatoes", "garlic", "vinegar", "ghee", "cinnamon", "allspice", "parsley", "salt"], + "steps": ["Simmer lamb with cinnamon and allspice until very tender (90 min)", "Cook rice in the lamb broth", "Fry flatbread in ghee until crispy", "Make a quick tomato sauce with garlic and vinegar", "Layer fried bread, rice then lamb, pour sauce and broth over and garnish with parsley"] + }, + { + "name": "Sayadieh", + "description": "Lebanese spiced fish with caramelised onion rice", + "ingredients": ["white fish fillets", "basmati rice", "onions", "cumin", "turmeric", "coriander", "allspice", "lemon juice", "olive oil", "pine nuts", "parsley"], + "steps": ["Fry sliced onions slowly in olive oil 30 min until dark caramel brown", "Add spices to onions then add water and simmer for rice broth", "Fry fish fillets in olive oil until golden on both sides", "Cook rice in the spiced onion broth", "Plate rice topped with fish, fried onions and toasted pine nuts"] + }, + { + "name": "Shish Tawook", + "description": "Lebanese marinated chicken skewers with garlic sauce", + "ingredients": ["chicken breast", "yogurt", "lemon juice", "garlic", "tomato paste", "paprika", "cumin", "olive oil", "salt", "flatbread", "garlic sauce"], + "steps": ["Cube chicken and marinate in yogurt, lemon, garlic, tomato paste and spices for 4 hours", "Thread onto skewers", "Grill on high heat 4-5 min per side until charred and cooked through", "Make garlic sauce by blending garlic with lemon juice and oil until creamy white", "Serve in bread with garlic sauce and pickles"] + }, + { + "name": "Chicken Shawarma", + "description": "spiced rotisserie-style chicken wrapped in bread with tahini and vegetables", + "ingredients": ["chicken thighs", "yogurt", "lemon juice", "garlic", "cumin", "turmeric", "cinnamon", "paprika", "flatbread", "tahini", "pickles", "tomato", "parsley"], + "steps": ["Marinate chicken in yogurt, lemon and spices overnight", "Grill or roast at 220C for 35-40 min until charred", "Rest 5 min then slice thin", "Spread tahini on warmed flatbread, add chicken slices", "Top with pickles, tomato and parsley, roll tightly"] + }, + { + "name": "Freekeh Soup", + "description": "smoky whole freekeh grain soup with chicken and warm spices", + "ingredients": ["chicken pieces", "whole freekeh", "onion", "garlic", "cinnamon", "allspice", "cumin", "ghee", "lemon juice", "parsley", "salt"], + "steps": ["Simmer chicken with onion, cinnamon and allspice 45 min to make broth", "Remove chicken, shred the meat, discard bones and skin", "Wash freekeh and add to strained broth with more spices", "Cook covered 40 min until freekeh is tender", "Return chicken, adjust seasoning, serve with lemon and parsley"] + }, + { + "name": "Ouzi", + "description": "slow-cooked whole lamb stuffed with spiced rice and nuts on a platter", + "ingredients": ["lamb shoulder", "basmati rice", "onion", "mixed nuts", "raisins", "cinnamon", "allspice", "cardamom", "ghee", "yogurt", "salt"], + "steps": ["Marinate lamb in yogurt, garlic and spices overnight", "Slow roast lamb covered at 160C for 4-5 hours until falling apart", "Cook spiced rice in lamb broth with nuts and raisins", "Arrange rice on a large platter", "Place lamb on top and pour pan juices over"] + }, + { + "name": "Hamam Mahshi", + "description": "Egyptian stuffed pigeon with green wheat and caramelised onions", + "ingredients": ["pigeons", "freekeh or rice", "onion", "ghee", "liver (from pigeons)", "cinnamon", "allspice", "salt", "black pepper"], + "steps": ["Caramelise diced onion in ghee until golden", "Add chopped liver and freekeh, season with cinnamon and allspice", "Cook filling 5 min then stuff loosely into cavities", "Truss birds and rub with spiced ghee", "Roast at 200C for 45 min until golden, basting halfway"] + }, + { + "name": "Grilled Sea Bass with Chermoula", + "description": "whole sea bass marinated in North African herb and spice sauce", + "ingredients": ["whole sea bass", "parsley", "coriander", "garlic", "cumin", "paprika", "lemon juice", "olive oil", "salt", "chilli"], + "steps": ["Blend parsley, coriander, garlic, cumin, paprika, lemon, chilli and oil into chermoula", "Score fish 3-4 times on each side", "Rub chermoula inside and over fish, marinate 1 hour", "Grill on high heat 5-6 min per side until skin is charred", "Serve with lemon wedges and a simple salad"] + }, + { + "name": "Kebab Halla", + "description": "Egyptian beef kebabs braised in caramelised onion sauce", + "ingredients": ["minced beef", "onions", "garlic", "cumin", "allspice", "tomato paste", "ghee", "parsley", "salt", "black pepper"], + "steps": ["Form seasoned minced beef into long finger-shaped kebabs", "Brown kebabs on all sides in ghee, remove", "In same pot slowly caramelise sliced onions 30 min", "Add garlic, tomato paste and return kebabs", "Braise covered on low heat 20 min, serve over rice with parsley"] + }, + { + "name": "Arayes", + "description": "Lebanese grilled flatbread stuffed with spiced minced beef", + "ingredients": ["minced beef", "onion", "parsley", "tomato", "cumin", "cinnamon", "allspice", "chilli", "flatbread", "olive oil", "salt"], + "steps": ["Mix beef with finely grated onion, chopped parsley, tomato and spices", "Cut flatbread in half and open like a pocket", "Spread meat filling inside each half and press closed", "Brush outsides with olive oil", "Grill on high heat 3-4 min per side until charred and filling is cooked"] + }, + { + "name": "Mixed Grill Platter", + "description": "assortment of grilled lamb chops, kofta and shish tawook", + "ingredients": ["lamb chops", "minced beef", "chicken thighs", "yogurt", "garlic", "lemon juice", "cumin", "paprika", "allspice", "parsley", "flatbread"], + "steps": ["Marinate chicken in yogurt, lemon and garlic for 2 hours", "Season lamb chops with cumin, allspice, salt and pepper", "Mix beef with grated onion, parsley and spices, form into kofta on skewers", "Grill all meats over high heat: chicken 10 min, lamb 4 min, kofta 6 min", "Arrange on a platter with flatbread, salad and dips"] + }, + { + "name": "Spaghetti Bolognese", + "description": "slow-cooked Italian beef ragu on spaghetti with parmesan", + "ingredients": ["spaghetti", "minced beef", "onion", "carrot", "celery", "garlic", "tomatoes", "tomato paste", "beef stock", "olive oil", "bay leaf", "parmesan"], + "steps": ["Saute onion, carrot and celery in olive oil until soft", "Add beef and brown on high heat, breaking up lumps", "Stir in garlic, tomato paste, tomatoes, stock and bay leaf", "Simmer on low heat 90 min stirring occasionally", "Cook spaghetti al dente, toss with ragu and parmesan"] + }, + { + "name": "Roast Chicken with Herbs", + "description": "crispy roasted whole chicken with garlic, lemon and fresh herbs", + "ingredients": ["whole chicken", "butter", "garlic", "lemon", "thyme", "rosemary", "olive oil", "salt", "black pepper", "onion"], + "steps": ["Mix softened butter with garlic, thyme, lemon zest, salt and pepper", "Loosen skin and rub butter under and over skin", "Stuff cavity with lemon halves, garlic and rosemary", "Roast on a bed of onions at 200C for 1h 20min", "Rest 15 min before carving, use pan juices as gravy"] + }, + { + "name": "Beef Stew with Root Vegetables", + "description": "rich slow-cooked beef stew with carrots, parsnip and potatoes", + "ingredients": ["beef chuck", "potatoes", "carrots", "parsnip", "onion", "garlic", "tomatoes", "beef stock", "thyme", "bay leaf", "flour", "olive oil"], + "steps": ["Cube beef and dust with flour, brown in batches in olive oil", "Saute onion and garlic until soft", "Add stock, tomatoes, thyme and bay leaf, return beef and bring to simmer", "Cook covered on low heat 1.5 hours", "Add root vegetables and cook 30 min more until tender"] + }, + { + "name": "Lamb Chops with Roasted Vegetables", + "description": "herb-marinated lamb chops served with mixed roasted Mediterranean vegetables", + "ingredients": ["lamb chops", "courgette", "red pepper", "aubergine", "cherry tomatoes", "garlic", "rosemary", "thyme", "olive oil", "lemon", "salt"], + "steps": ["Marinate lamb in olive oil, garlic, rosemary and lemon for 1 hour", "Chop vegetables, toss with olive oil, thyme, salt and roast at 200C for 35 min", "Sear lamb chops in a hot pan 3-4 min per side for medium-rare", "Rest lamb 5 min before serving", "Arrange vegetables on plate and place chops on top with pan juices"] + }, + { + "name": "Chicken Parmesan", + "description": "breaded chicken breast baked with tomato sauce and melted mozzarella", + "ingredients": ["chicken breasts", "breadcrumbs", "parmesan", "eggs", "tomato sauce", "mozzarella", "basil", "olive oil", "garlic", "salt", "black pepper"], + "steps": ["Pound chicken breasts to even thickness, season", "Coat in beaten egg then seasoned breadcrumbs mixed with parmesan", "Fry in olive oil 3-4 min per side until golden", "Transfer to baking dish, spoon tomato sauce over each piece", "Top with mozzarella and bake at 200C for 15 min, garnish with basil"] + }, + { + "name": "Beef Lasagne", + "description": "layered pasta sheets with beef ragu and creamy bechamel", + "ingredients": ["lasagne sheets", "minced beef", "onion", "garlic", "tomatoes", "tomato paste", "bechamel sauce", "mozzarella", "parmesan", "olive oil", "basil"], + "steps": ["Brown beef with onion and garlic, add tomatoes and paste, simmer 30 min", "Make bechamel: melt butter, whisk in flour, gradually add milk, season with nutmeg", "Layer in a deep baking dish: bechamel, pasta, beef, pasta, beef, bechamel, mozzarella", "Repeat layers ending with bechamel and parmesan on top", "Bake at 180C for 40 min until bubbling and golden"] + }, + { + "name": "Grilled Salmon with Lemon Butter", + "description": "salmon fillet grilled with lemon butter sauce and capers", + "ingredients": ["salmon fillets", "butter", "lemon", "capers", "garlic", "dill", "olive oil", "salt", "black pepper"], + "steps": ["Pat salmon dry and season with salt and pepper", "Brush with olive oil and grill skin-side down 4-5 min until skin is crispy", "Flip and cook 2-3 min more until just cooked through", "Make sauce: melt butter with garlic, lemon juice and capers", "Drizzle sauce over salmon and finish with fresh dill"] + }, + { + "name": "Shepherd's Pie", + "description": "minced lamb with vegetables under a creamy mashed potato crust", + "ingredients": ["minced lamb", "potatoes", "onion", "carrot", "frozen peas", "garlic", "tomato paste", "lamb stock", "butter", "milk", "thyme", "rosemary"], + "steps": ["Brown lamb with onion and carrot, drain excess fat", "Add garlic, tomato paste, stock, thyme and rosemary, simmer 20 min, stir in peas", "Boil potatoes until tender, mash with butter and milk until smooth", "Transfer lamb to a baking dish, top with mashed potato", "Fork the surface and bake at 200C for 25 min until golden"] + }, + { + "name": "Chicken and Seafood Paella", + "description": "Spanish saffron rice with chicken, prawns and mussels", + "ingredients": ["chicken thighs", "prawns", "mussels", "paella rice", "onion", "red pepper", "garlic", "tomatoes", "saffron", "paprika", "olive oil", "chicken stock"], + "steps": ["Brown chicken in olive oil, set aside", "Saute onion, pepper and garlic until soft, add paprika and tomatoes", "Add rice and stir to coat, pour in hot saffron-infused stock", "Nestle chicken back in and cook on medium heat 15 min without stirring", "Add prawns and mussels on top, cook 8-10 min until seafood opens and rice is done"] + }, + { + "name": "Moussaka", + "description": "Greek layered baked dish of aubergine, beef ragu and bechamel", + "ingredients": ["aubergine", "minced beef", "onion", "garlic", "tomatoes", "cinnamon", "allspice", "red wine vinegar", "bechamel sauce", "parmesan", "olive oil"], + "steps": ["Slice aubergine, brush with oil and roast at 200C for 25 min until golden", "Brown beef with onion, garlic, cinnamon and tomatoes, simmer 20 min", "Make thick bechamel with parmesan", "Layer in a baking dish: aubergine, beef, aubergine, thick bechamel", "Bake at 180C for 45 min until golden brown on top"] + }, + { + "name": "Chicken and Mushroom Pie", + "description": "creamy chicken and mushroom filling under a golden shortcrust pastry lid", + "ingredients": ["chicken thighs", "mixed mushrooms", "onion", "garlic", "double cream", "chicken stock", "thyme", "butter", "flour", "shortcrust pastry", "egg", "salt"], + "steps": ["Cook chicken in stock until tender, shred into pieces", "Saute mushrooms, onion and garlic in butter until soft", "Make a sauce with flour, stock and cream, add chicken, mushrooms and thyme", "Pour filling into a pie dish, top with pastry and crimp edges", "Brush with egg and bake at 200C for 30-35 min until golden"] + }, + { + "name": "Greek Lamb Stew", + "description": "slow-cooked Greek lamb with tomatoes, cinnamon and pearl onions", + "ingredients": ["lamb shoulder", "pearl onions", "tomatoes", "tomato paste", "garlic", "cinnamon stick", "allspice", "bay leaf", "olive oil", "red wine vinegar", "parsley", "salt"], + "steps": ["Brown lamb pieces in olive oil on all sides, remove", "Add pearl onions and cook until coloured, add garlic", "Stir in tomatoes, tomato paste, vinegar, cinnamon, allspice and bay leaf", "Return lamb, add water to cover and simmer covered on low heat 90 min", "Uncover and simmer 20 min more to thicken, serve with bread or orzo"] + } + ], + "snack": [ + { + "name": "Basbousa", + "description": "Egyptian semolina cake soaked in rose water syrup", + "ingredients": ["semolina", "yogurt", "sugar", "butter", "coconut", "baking powder", "vanilla", "rose water", "almonds", "syrup"], + "steps": ["Mix semolina, yogurt, sugar, melted butter, coconut and baking powder", "Spread in a greased tray, score into diamond shapes and press an almond on each", "Bake at 180C for 30 min until golden", "Make syrup: boil sugar, water, lemon and rose water 5 min then cool", "Pour cold syrup over hot cake and rest 1 hour before serving"] + }, + { + "name": "Konafa bil Ashta", + "description": "shredded kataifi pastry with milk cream filling and simple syrup", + "ingredients": ["kataifi pastry", "ghee", "milk", "heavy cream", "cornstarch", "sugar", "rose water", "orange blossom water", "pistachios"], + "steps": ["Shred kataifi, toss well with melted ghee", "Make ashta: heat milk and cream, whisk in cornstarch until thick, add rose water and cool", "Press half the pastry into a greased pan, spread ashta, cover with remaining pastry", "Bake at 200C for 40-45 min until deep golden", "Pour cool syrup over hot konafa, top with crushed pistachios"] + }, + { + "name": "Kahk", + "description": "Egyptian eid butter cookies dusted with powdered sugar", + "ingredients": ["flour", "ghee", "semolina", "sugar", "instant yeast", "warm water", "sesame seeds", "anise seeds", "mahlab", "powdered sugar"], + "steps": ["Mix flour, semolina, sugar, yeast and spices, rub in ghee until like breadcrumbs", "Add warm water gradually and knead to a soft dough, rest 1 hour", "Shape into small rounds or rings, coat lightly in sesame seeds", "Bake at 180C for 15-18 min until pale golden (they harden as they cool)", "Dust generously with powdered sugar when completely cool"] + }, + { + "name": "Balah El Sham", + "description": "Egyptian crispy choux pastry fingers soaked in syrup", + "ingredients": ["flour", "water", "butter", "eggs", "salt", "oil for frying", "sugar", "lemon juice", "rose water"], + "steps": ["Make choux: boil water and butter, add flour and stir until dough pulls from pan", "Beat in eggs one at a time until smooth and glossy", "Pipe 8cm lengths into hot oil using a star nozzle, fry until deep golden", "Make syrup: boil sugar and water 5 min with lemon, add rose water", "Soak hot pastry in syrup for 2-3 min and drain"] + }, + { + "name": "Luqaimat", + "description": "Gulf-style fried dough balls drizzled with date syrup and sesame", + "ingredients": ["flour", "yeast", "sugar", "salt", "warm water", "oil for frying", "date syrup", "sesame seeds"], + "steps": ["Dissolve yeast in warm water with sugar, rest 10 min", "Mix with flour and salt to a thick batter, rest 1 hour until bubbly", "Drop spoonfuls into hot oil and fry until golden all over, about 3-4 min", "Drain on paper towels", "Drizzle generously with date syrup and sprinkle with sesame seeds, serve immediately"] + }, + { + "name": "Meshabek", + "description": "Egyptian funnel cakes in flower shapes soaked in syrup", + "ingredients": ["flour", "yeast", "sugar", "saffron", "warm water", "oil for frying", "sugar syrup", "lemon juice", "rose water"], + "steps": ["Make a thin batter with flour, yeast, sugar, saffron and water, rest 1 hour", "Heat oil in a deep pan, fill a squeeze bottle or piping bag with batter", "Pipe swirling flower shapes into hot oil, fry 2 min per side until golden", "Make syrup with sugar, water and lemon, bring to a boil and add rose water", "Immediately dip hot meshabek into warm syrup, drain and serve"] + }, + { + "name": "Ghorayeba", + "description": "Egyptian melt-in-the-mouth shortbread cookies", + "ingredients": ["ghee", "powdered sugar", "flour", "cornstarch", "vanilla", "pistachios"], + "steps": ["Beat softened ghee with powdered sugar until very light and fluffy", "Sift in flour and cornstarch, mix to a soft dough - do not overwork", "Roll into small balls, press a pistachio on each and place on a tray", "Bake at 160C for 12-15 min - they should be white, not golden", "Cool completely before moving as they are very fragile"] + }, + { + "name": "Zalabia", + "description": "crispy Middle Eastern fried pastry spirals in syrup", + "ingredients": ["flour", "yeast", "yogurt", "warm water", "oil for frying", "sugar", "lemon juice", "rose water", "saffron"], + "steps": ["Mix flour, yeast, yogurt and water to a smooth batter, rest 1 hour", "Heat oil to 180C", "Pipe thin spirals or small fritters into the oil, fry until golden and crispy", "Prepare warm syrup with saffron, lemon and rose water", "Dip hot zalabia in syrup for 30 seconds, drain and serve warm"] + }, + { + "name": "Shaabiyat", + "description": "crispy phyllo pastry squares filled with cream and drenched in syrup", + "ingredients": ["phyllo pastry", "butter", "whole milk", "heavy cream", "sugar", "cornstarch", "rose water", "orange blossom water", "pistachios"], + "steps": ["Make ashta cream: heat milk and cream, whisk in cornstarch, sweeten and add rose water, cool", "Layer 4 sheets phyllo brushed with butter, add a line of cream, fold into a square", "Brush tops with butter", "Bake at 200C for 20 min until deep golden", "Drench in simple syrup immediately, top with crushed pistachios"] + }, + { + "name": "Asabeya bil Ashta", + "description": "phyllo pastry fingers rolled with cream and soaked in syrup", + "ingredients": ["phyllo pastry", "butter", "ashta cream", "whole milk", "cornstarch", "heavy cream", "rose water", "sugar syrup", "pistachios"], + "steps": ["Make ashta cream: thicken milk and cream with cornstarch, add rose water and cool", "Lay a phyllo sheet, brush with butter, place a line of cream at one end", "Roll into a tight finger shape and seal edge with butter", "Bake at 200C for 18-20 min until golden and crispy", "Pour syrup over hot pastry, let absorb and top with crushed pistachios"] + }, + { + "name": "Harissa Cake", + "description": "North African moist semolina and almond cake with honey syrup", + "ingredients": ["semolina", "ground almonds", "yogurt", "ghee", "sugar", "baking powder", "vanilla", "honey", "orange blossom water", "whole almonds"], + "steps": ["Mix semolina, ground almonds, sugar, baking powder and vanilla", "Stir in melted ghee and yogurt until combined", "Pour into a greased tin and press whole almonds across the top", "Bake at 175C for 25-30 min until golden", "Warm honey with orange blossom water and pour over immediately after baking"] + }, + { + "name": "Om Ali", + "description": "warm Egyptian bread pudding with nuts and cream", + "ingredients": ["puff pastry", "full-fat milk", "heavy cream", "sugar", "walnuts", "almonds", "pistachios", "raisins", "desiccated coconut", "vanilla"], + "steps": ["Bake puff pastry until golden then tear into rough pieces", "Bring milk, cream and sugar to a gentle simmer", "Place pastry pieces in a baking dish with nuts, raisins and coconut", "Pour hot milk mixture over to soak everything", "Bake at 200C for 15-20 min until bubbling and top is golden"] + }, + { + "name": "Chocolate Chip Cookies", + "description": "classic American chewy cookies with dark chocolate chips", + "ingredients": ["flour", "butter", "brown sugar", "caster sugar", "eggs", "vanilla", "baking soda", "salt", "dark chocolate chips"], + "steps": ["Beat butter with both sugars until light and fluffy", "Add eggs one at a time with vanilla, mix well", "Fold in sifted flour, baking soda and salt", "Stir in chocolate chips, chill dough 30 min", "Scoop balls onto lined trays, bake at 180C for 10-12 min until edges are set but centres look soft"] + }, + { + "name": "Banana Bread", + "description": "moist loaf cake made with overripe bananas and walnuts", + "ingredients": ["overripe bananas", "flour", "butter", "sugar", "eggs", "baking soda", "salt", "vanilla", "walnuts", "cinnamon"], + "steps": ["Mash very ripe bananas with a fork until smooth", "Melt butter and mix with sugar, eggs and vanilla", "Stir in mashed bananas then fold in flour, baking soda, salt and cinnamon", "Fold in chopped walnuts and pour into a greased loaf tin", "Bake at 180C for 55-65 min until a skewer comes out clean"] + }, + { + "name": "Brownies", + "description": "dense fudgy dark chocolate brownies with a crinkly top", + "ingredients": ["dark chocolate", "butter", "sugar", "eggs", "flour", "cocoa powder", "vanilla", "salt"], + "steps": ["Melt dark chocolate with butter, cool slightly", "Whisk sugar and eggs together vigorously until pale and thick", "Stir in chocolate mixture and vanilla", "Fold in flour, cocoa and salt until just combined - do not overmix", "Pour into a greased square tin and bake at 180C for 20-22 min until top is set but centre wobbles slightly"] + }, + { + "name": "Classic Cheesecake", + "description": "baked New York-style cheesecake on a digestive biscuit base", + "ingredients": ["cream cheese", "sour cream", "sugar", "eggs", "vanilla", "lemon zest", "digestive biscuits", "butter", "cornstarch"], + "steps": ["Crush biscuits, mix with melted butter and press into a springform tin, chill", "Beat cream cheese, sugar and cornstarch until smooth, add eggs one at a time", "Mix in sour cream, vanilla and lemon zest", "Pour over base and bake at 160C for 55 min until just set with a slight wobble", "Cool in oven with door ajar then refrigerate overnight"] + }, + { + "name": "Apple Crumble", + "description": "spiced stewed apples under a buttery oat crumble topping", + "ingredients": ["cooking apples", "sugar", "cinnamon", "flour", "rolled oats", "butter", "brown sugar", "salt"], + "steps": ["Peel, core and slice apples, toss with sugar and cinnamon, spread in a baking dish", "Rub cold butter into flour until like breadcrumbs, stir in oats and brown sugar", "Spread crumble evenly over apples without pressing down", "Bake at 190C for 35-40 min until crumble is golden and apples are bubbling", "Serve warm with custard or vanilla ice cream"] + }, + { + "name": "Victoria Sponge", + "description": "classic British sandwich cake with jam and fresh cream", + "ingredients": ["flour", "butter", "sugar", "eggs", "baking powder", "vanilla", "jam", "double cream", "powdered sugar"], + "steps": ["Beat butter and sugar until very pale and fluffy", "Add eggs one at a time with a spoonful of flour each time", "Fold in remaining sifted flour and baking powder", "Divide between two 20cm tins, bake at 180C for 25 min until springy", "Sandwich cooled cakes with jam and whipped cream, dust top with powdered sugar"] + }, + { + "name": "Lemon Drizzle Cake", + "description": "light lemon sponge soaked with sharp lemon sugar syrup", + "ingredients": ["flour", "butter", "sugar", "eggs", "lemon zest", "baking powder", "yogurt", "lemon juice", "icing sugar"], + "steps": ["Beat butter and sugar until fluffy, add eggs with lemon zest", "Fold in flour, baking powder and yogurt", "Pour into a greased loaf tin and bake at 180C for 45-50 min", "Mix lemon juice with granulated sugar to make the drizzle", "Pierce hot cake all over with a skewer, pour drizzle over and leave to cool in tin"] + }, + { + "name": "Profiteroles", + "description": "light choux pastry balls filled with cream and topped with chocolate sauce", + "ingredients": ["flour", "butter", "water", "eggs", "salt", "double cream", "vanilla", "dark chocolate", "butter for sauce"], + "steps": ["Make choux: boil water and butter, beat in flour then cool, beat in eggs until smooth and glossy", "Pipe golf-ball-sized rounds onto lined trays, bake at 200C for 25 min until puffed and golden", "Do not open the oven! Cool completely before filling", "Whip cream with vanilla to soft peaks, pipe into split profiteroles", "Melt chocolate with butter and cream, drizzle over profiteroles and serve"] + }, + { + "name": "Fresh Fruit Tart", + "description": "sweet shortcrust pastry shell filled with vanilla cream and seasonal fruit", + "ingredients": ["shortcrust pastry", "butter", "sugar", "egg", "flour", "milk", "egg yolks", "cornstarch", "vanilla", "mixed fruit", "apricot jam"], + "steps": ["Line a tart tin with pastry, blind bake at 190C for 20 min until golden", "Make creme patissiere: heat milk, whisk yolks with sugar and cornstarch, pour milk in slowly, cook stirring until thick, cool", "Fill cooled tart shell with cream patissiere", "Arrange sliced fresh fruit decoratively on top", "Warm and sieve apricot jam, brush over fruit to glaze"] + }, + { + "name": "Cinnamon Rolls", + "description": "soft fluffy rolls with a cinnamon sugar filling and vanilla icing", + "ingredients": ["flour", "yeast", "milk", "butter", "sugar", "eggs", "salt", "brown sugar", "cinnamon", "cream cheese", "vanilla", "powdered sugar"], + "steps": ["Mix flour, yeast, warm milk, butter, sugar and eggs into a soft dough, knead 10 min, rest 1 hour", "Roll into a large rectangle, spread with softened butter and cinnamon sugar", "Roll tightly into a log and cut into 12 rounds", "Place in a greased tin, prove 40 min until doubled", "Bake at 180C for 20-25 min, cool slightly then ice with cream cheese frosting"] + }, + { + "name": "Madeleine Cookies", + "description": "small French shell-shaped sponge cakes with lemon zest", + "ingredients": ["flour", "sugar", "butter", "eggs", "lemon zest", "vanilla", "baking powder", "salt", "honey"], + "steps": ["Melt butter and cool to room temperature", "Whisk eggs, sugar, honey and vanilla until pale, fold in sifted flour, baking powder and salt", "Stir in melted butter and lemon zest", "Rest batter in the fridge 1 hour (essential for the hump)", "Fill greased madeleine moulds to three-quarters, bake at 200C for 10-12 min until golden and springy"] + }, + { + "name": "Churros with Chocolate Sauce", + "description": "crispy Spanish fried dough sticks with a thick hot chocolate dip", + "ingredients": ["flour", "water", "butter", "salt", "sugar", "oil for frying", "cinnamon sugar", "dark chocolate", "double cream", "milk"], + "steps": ["Boil water with butter and salt, stir in flour and cook until dough pulls from pan", "Pipe long strips into hot oil using a star nozzle, fry 3-4 min until golden and crispy", "Drain and roll immediately in cinnamon sugar", "Make chocolate sauce: heat cream and milk, pour over chopped chocolate, stir smooth", "Serve churros with warm chocolate sauce for dipping"] + } + ] +} diff --git a/daffy/__init__.py b/daffy/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/daffy/__init__.py diff --git a/daffy/daffy.cfg b/daffy/daffy.cfg new file mode 100644 index 0000000..3908c58 --- /dev/null +++ b/daffy/daffy.cfg @@ -0,0 +1,23 @@ +[core] +nick = daffy +user = daffy +name = daffy +host = 127.0.0.1 +port = 6667 +owner = yournick +password = REPLACEME +prefix = ! +channels = #yourchannel +extra = /path/to/irc-bots/daffy +enable = + daffy +logdir = /var/log/daffy +logging_level = INFO +timeout = 120 + +[daffy] +db_path = /var/daffy/scores.db +idle_rounds = 3 +spawn_min = 60 +spawn_max = 300 +flee_time = 45 diff --git a/daffy/daffy.py b/daffy/daffy.py new file mode 100644 index 0000000..dc7388f --- /dev/null +++ b/daffy/daffy.py @@ -0,0 +1,473 @@ +""" +Sopel plugin: daffy - duck hunting game + +Spawns ducks at random intervals, tracks ammo in memory, and stores scores +in SQLite with per-channel differentiation. Auto-stops after a configurable +number of idle rounds. + + !bang - fire at the current duck + !reload - refill ammo (3s cooldown) + !score - top 5 for this channel + !score clear - clear scores for this channel (owner/op) + !hunt - show hunt state for this channel + !hunt start - start the hunt (owner/op) + !hunt stop - stop the hunt (owner/op) + !hunt idle <N> - set empty-round auto-stop threshold (owner/op) + !join / !part / !chans / !help - owner commands +""" + +import os +import random +import sqlite3 +import threading +import time + +from sopel import plugin +from sopel.config.types import FilenameAttribute, StaticSection, ValidatedAttribute + + +SPAWN_MSG = "\x02** A duck appears! **\x02 Quick, type !bang to shoot it!" +FLEE_MSG = "\x02** The duck got away! **\x02 Too slow." +EMPTY_MSG = "*click* -- chamber is empty. Use !reload first." +MOCK_MSG = "BLAM! ...at what? There's no duck, {nick}. You waste your shot." +KILL_MSG = "\x02BLAM!\x02 {nick} shot the duck in \x02{elapsed:.2f}s\x02! (+1 pt)" + +SPAWN_MIN = 60 +SPAWN_MAX = 300 +FLEE_TIMEOUT = 45 +RELOAD_CD = 3 + + +class DaffySection(StaticSection): + db_path = FilenameAttribute("db_path", relative=False, default=None) + idle_rounds = ValidatedAttribute("idle_rounds", default="3") + spawn_min = ValidatedAttribute("spawn_min", default="60") + spawn_max = ValidatedAttribute("spawn_max", default="300") + flee_time = ValidatedAttribute("flee_time", default="45") + + +def _next_spawn_time(bot=None): + lo = bot.memory.get("df_spawn_min", SPAWN_MIN) if bot else SPAWN_MIN + hi = bot.memory.get("df_spawn_max", SPAWN_MAX) if bot else SPAWN_MAX + return time.time() + random.uniform(lo, hi) + + +def _connect(bot): + return sqlite3.connect(bot.memory["df_db_path"]) + + +def _add_score(bot, nick, channel): + with _connect(bot) as conn: + conn.execute( + "INSERT INTO scores (nick, channel, score) VALUES (?, ?, 1)" + " ON CONFLICT(nick, channel) DO UPDATE SET score = score + 1", + (nick, channel), + ) + conn.commit() + + +def _chan_state(bot, channel): + """Return per-channel game state dict, creating it on first access.""" + states = bot.memory.setdefault("df_channels", {}) + key = str(channel).lower() + if key not in states: + idle = bot.memory.get("df_idle_rounds", 3) + states[key] = { + "active": False, + "spawn_time": 0.0, + "next_spawn": _next_spawn_time(), + "empty_rounds": 0, + "enabled": True, + "idle_rounds": idle, + } + return states[key] + + +def setup(bot): + bot.config.define_section("daffy", DaffySection, validate=False) + + try: + configured = bot.config.daffy.db_path + except Exception: + configured = None + + path = configured or os.path.join(bot.config.core.homedir, "daffy_scores.db") + try: + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + except OSError: + path = os.path.join(bot.config.core.homedir, "daffy_scores.db") + + bot.memory["df_db_path"] = path + + with sqlite3.connect(path) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS scores ( + nick TEXT NOT NULL, + channel TEXT NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (nick, channel) + ) + """) + conn.commit() + + try: + idle_rounds = int(bot.config.daffy.idle_rounds) + except Exception: + idle_rounds = 3 + + try: + spawn_min = float(bot.config.daffy.spawn_min) + except Exception: + spawn_min = float(SPAWN_MIN) + + try: + spawn_max = float(bot.config.daffy.spawn_max) + except Exception: + spawn_max = float(SPAWN_MAX) + + try: + flee_time = float(bot.config.daffy.flee_time) + except Exception: + flee_time = float(FLEE_TIMEOUT) + + bot.memory["df_idle_rounds"] = idle_rounds + bot.memory["df_spawn_min"] = spawn_min + bot.memory["df_spawn_max"] = spawn_max + bot.memory["df_flee_time"] = flee_time + bot.memory["df_channels"] = {} + bot.memory["df_reload_cd"] = {} + bot.memory["df_game_lock"] = threading.Lock() + bot.memory["df_players"] = {} + + +def _player(bot, nick): + if nick not in bot.memory["df_players"]: + bot.memory["df_players"][nick] = {"ammo": 1} + return bot.memory["df_players"][nick] + + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _is_op(bot, trigger): + chan = bot.channels.get(str(trigger.sender)) + if not chan: + return False + return chan.privileges.get(trigger.nick, 0) >= plugin.OP + + +def _is_authorized(bot, trigger): + return _is_owner(bot, trigger) or _is_op(bot, trigger) + + +# --- Game tick --- + +@plugin.interval(5) +def _tick(bot): + now = time.time() + + with bot.memory["df_game_lock"]: + for chan in list(bot.channels): + st = _chan_state(bot, chan) + if not st["enabled"]: + continue + + if st["active"]: + flee = bot.memory.get("df_flee_time", FLEE_TIMEOUT) + if now - st["spawn_time"] >= flee: + st["active"] = False + st["next_spawn"] = _next_spawn_time(bot) + st["empty_rounds"] += 1 + bot.say(FLEE_MSG, chan) + + idle = st["idle_rounds"] + if idle > 0 and st["empty_rounds"] >= idle: + st["enabled"] = False + st["next_spawn"] = float("inf") + bot.say( + f"Hunt over - no players for {idle} round(s). " + f"Type !hunt start to begin again.", + chan, + ) + else: + if now >= st["next_spawn"]: + st["active"] = True + st["spawn_time"] = now + bot.say(SPAWN_MSG, chan) + + +# --- Game commands --- + +@plugin.command("bang") +@plugin.require_chanmsg +def cmd_bang(bot, trigger): + nick = trigger.nick + channel = str(trigger.sender) + + with bot.memory["df_game_lock"]: + if not _chan_state(bot, channel)["enabled"]: + return + + p = _player(bot, nick) + + if p["ammo"] < 1: + bot.say(EMPTY_MSG, channel) + return + + with bot.memory["df_game_lock"]: + st = _chan_state(bot, channel) + if not st["active"]: + p["ammo"] = 0 + bot.say(MOCK_MSG.format(nick=nick), channel) + return + + elapsed = time.time() - st["spawn_time"] + st["active"] = False + st["next_spawn"] = _next_spawn_time(bot) + st["empty_rounds"] = 0 + + p["ammo"] = 0 + bot.say(KILL_MSG.format(nick=nick, elapsed=elapsed), channel) + _add_score(bot, nick, channel) + + +@plugin.command("reload") +@plugin.require_chanmsg +def cmd_reload(bot, trigger): + with bot.memory["df_game_lock"]: + if not _chan_state(bot, str(trigger.sender))["enabled"]: + return + + nick = trigger.nick + now = time.time() + cd = bot.memory["df_reload_cd"].get(nick, 0) + + if now - cd < RELOAD_CD: + remaining = RELOAD_CD - (now - cd) + bot.say(f"{nick}: reload on cooldown ({remaining:.1f}s remaining)", trigger.sender) + return + + bot.memory["df_reload_cd"][nick] = now + _player(bot, nick)["ammo"] = 1 + bot.say(f"{nick}: reloaded. One shell in the chamber.", trigger.sender) + + +@plugin.command("score") +def cmd_score(bot, trigger): + arg = (trigger.group(2) or "").strip() + in_channel = str(trigger.sender).startswith("#") + is_owner = _is_owner(bot, trigger) + + parts = arg.split(None, 1) + if parts and parts[0].lower() == "clear": + if not is_owner and not (in_channel and _is_op(bot, trigger)): + return + target = parts[1].strip() if len(parts) > 1 else None + + if in_channel and not target: + channel = str(trigger.sender) + with _connect(bot) as conn: + conn.execute( + "DELETE FROM scores WHERE channel = ? COLLATE NOCASE", + (channel,), + ) + conn.commit() + bot.say(f"scores cleared for {channel}", trigger.sender) + + elif not in_channel and is_owner: + if not target: + bot.say("usage: !score clear all or !score clear #channel", trigger.nick) + elif target.lower() == "all": + with _connect(bot) as conn: + conn.execute("DELETE FROM scores") + conn.commit() + bot.say("all scores cleared", trigger.nick) + else: + with _connect(bot) as conn: + conn.execute( + "DELETE FROM scores WHERE channel = ? COLLATE NOCASE", + (target,), + ) + conn.commit() + bot.say(f"scores cleared for {target}", trigger.nick) + return + + if in_channel: + channel = str(trigger.sender) + with _connect(bot) as conn: + rows = conn.execute( + "SELECT nick, score FROM scores" + " WHERE channel = ? COLLATE NOCASE" + " ORDER BY score DESC LIMIT 5", + (channel,), + ).fetchall() + if not rows: + bot.say("no scores yet", trigger.sender) + return + line = " | ".join(f"{i+1}. {nick} ({score})" for i, (nick, score) in enumerate(rows)) + bot.say(line, trigger.sender) + + elif is_owner: + with _connect(bot) as conn: + rows = conn.execute( + "SELECT channel, nick, score FROM scores ORDER BY channel, score DESC" + ).fetchall() + if not rows: + bot.say("no scores yet", trigger.nick) + return + by_channel = {} + for channel, nick, score in rows: + by_channel.setdefault(channel, []).append((nick, score)) + for channel, players in sorted(by_channel.items()): + total = sum(s for _, s in players) + player_str = " | ".join(f"{n} ({s})" for n, s in players[:10]) + bot.say(f"{channel}: {player_str} [total: {total} duck(s)]", trigger.nick) + + else: + bot.say("use !score in a channel", trigger.nick) + + +# --- Hunt control --- + +@plugin.command("hunt") +@plugin.require_chanmsg +def cmd_hunt(bot, trigger): + channel = str(trigger.sender) + arg = (trigger.group(2) or "").strip().lower() + args = arg.split(None, 1) + sub = args[0] if args else "" + + if sub in ("start", "on"): + if not _is_authorized(bot, trigger): + return + with bot.memory["df_game_lock"]: + st = _chan_state(bot, channel) + st["enabled"] = True + st["empty_rounds"] = 0 + st["next_spawn"] = _next_spawn_time(bot) + st["active"] = False + idle = st["idle_rounds"] + idle_str = f"{idle} idle round(s)" if idle > 0 else "no idle limit" + bot.say(f"the hunt is on! ({idle_str})") + + elif sub in ("stop", "off"): + if not _is_authorized(bot, trigger): + return + with bot.memory["df_game_lock"]: + st = _chan_state(bot, channel) + st["enabled"] = False + st["active"] = False + st["next_spawn"] = float("inf") + bot.say("the hunt is over.") + + elif sub == "idle": + if not _is_authorized(bot, trigger): + return + if len(args) < 2: + with bot.memory["df_game_lock"]: + current = _chan_state(bot, channel)["idle_rounds"] + bot.say(f"idle rounds: {current} (0 = never auto-stop). usage: !hunt idle <N>") + return + try: + n = max(0, int(args[1])) + except ValueError: + bot.say("usage: !hunt idle <N> (N = 0 to disable auto-stop)") + return + with bot.memory["df_game_lock"]: + st = _chan_state(bot, channel) + st["idle_rounds"] = n + if n > 0 and st["empty_rounds"] >= n and not st["active"]: + st["enabled"] = False + st["next_spawn"] = float("inf") + bot.say( + f"Hunt over - idle limit now {n}, already at " + f"{st['empty_rounds']} empty round(s). " + f"Type !hunt start to begin again.", + channel, + ) + return + if n == 0: + bot.say("idle auto-stop disabled") + else: + bot.say(f"hunt will auto-stop after {n} round(s) with no players") + + else: + with bot.memory["df_game_lock"]: + st = _chan_state(bot, channel) + enabled = st["enabled"] + active = st["active"] + idle = st["idle_rounds"] + empty = st["empty_rounds"] + + if not enabled: + state = "off" + elif active: + state = "active (duck is up!)" + else: + state = "on (waiting for next duck)" + + if enabled: + idle_str = f"{empty}/{idle}" if idle > 0 else f"{empty}/off" + bot.say(f"hunt: {state} | empty rounds: {idle_str}") + else: + bot.say(f"hunt: {state}") + if _is_authorized(bot, trigger): + bot.say("usage: !hunt on/start | !hunt off/stop | !hunt idle <N>") + + +# --- Admin --- + +@plugin.command("join") +def cmd_join(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() + if not channel.startswith("#"): + bot.say("usage: !join #channel", trigger.nick) + return + bot.join(channel) + + +@plugin.command("part") +def cmd_part(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() or str(trigger.sender) + bot.part(channel) + + +@plugin.command("chans") +def cmd_chans(bot, trigger): + if not _is_owner(bot, trigger): + return + chans = sorted(str(c) for c in bot.channels) + bot.say(("in: " + " ".join(chans)) if chans else "not in any channels", trigger.nick) + + +@plugin.command("help") +def cmd_help(bot, trigger): + lines = [ + "!bang - fire at the current duck", + "!reload - refill ammo (3s cooldown)", + "!score - top 5 players for this channel", + "!score clear - clear scores for this channel (owner/op)", + ] + if _is_authorized(bot, trigger): + lines += [ + "!hunt - show hunt state for this channel", + "!hunt start - start the hunt", + "!hunt stop - stop the hunt", + "!hunt idle <N> - set empty-round auto-stop (0 = never)", + ] + if _is_owner(bot, trigger): + lines += [ + "!join #channel - join a channel", + "!part [#channel] - leave a channel", + "!chans - list channels", + "!score clear all - clear all scores (owner PM)", + "!score clear #channel - clear a specific channel (owner PM)", + ] + lines.append("!help - this message") + for line in lines: + bot.say(line, trigger.nick) diff --git a/daft/__init__.py b/daft/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/daft/__init__.py diff --git a/daft/daft.cfg b/daft/daft.cfg new file mode 100644 index 0000000..1a22761 --- /dev/null +++ b/daft/daft.cfg @@ -0,0 +1,16 @@ +[core] +nick = daft +user = daft +name = daft +host = 127.0.0.1 +port = 6667 +owner = yournick +password = REPLACEME +prefix = ! +channels = #yourchannel +extra = /path/to/irc-bots/daft +enable = + dj +logdir = /var/log/daft +logging_level = INFO +timeout = 120 diff --git a/daft/dj.py b/daft/dj.py new file mode 100644 index 0000000..5a0ec27 --- /dev/null +++ b/daft/dj.py @@ -0,0 +1,332 @@ +""" +Sopel plugin: dj +Playlist manager with admin playback controls. + + !dj <url> - queue a YouTube track (anyone) + !np - show current track (anyone) + !queue - show upcoming tracks (anyone) + !play - start/resume playback (op) + !stop - pause playback (op) + !skip - skip to next track (op) + !remove - remove current song from queue (op) + !loop <on|off> - loop the queue circularly (op) + !clear - clear queue and stop playback (op) + !join #chan - join a channel (owner) + !part [#chan] - leave a channel (owner) + !chans - list joined channels (owner) + !help - show usage +""" +import re +import threading +from urllib.parse import urlparse, parse_qs + +from sopel import plugin + +QUEUE_CAP = 50 +TRACK_DURATION = 120 # seconds + +_YOUTUBE_HOSTS = frozenset({ + "youtube.com", "www.youtube.com", "m.youtube.com", + "music.youtube.com", "youtu.be", +}) +_REJECT_PATHS = ("/shorts/", "/embed/", "/live/") +_VIDEO_ID_RE = re.compile(r'^[A-Za-z0-9_-]{11}$') + + +def _extract_video_id(url): + """Return the 11-char video ID from a YouTube URL, or None if invalid/rejected.""" + if "://" not in url: + url = "https://" + url + try: + p = urlparse(url) + except Exception: + return None + if p.scheme not in ("http", "https"): + return None + if p.netloc.lower() not in _YOUTUBE_HOSTS: + return None + for bad in _REJECT_PATHS: + if p.path.startswith(bad): + return None + if p.netloc.lower() == "youtu.be": + vid = p.path.strip("/") + return vid if _VIDEO_ID_RE.match(vid) else None + # youtube.com/watch?v=ID or /playlist?v=ID&list=... (take v= only) + qs = parse_qs(p.query) + vid = qs.get("v", [None])[0] + return vid if vid and _VIDEO_ID_RE.match(vid) else None + + +def _normalize(video_id): + return f"https://youtu.be/{video_id}" + + +def setup(bot): + bot.memory.setdefault("dj_queue", []) + bot.memory.setdefault("dj_pos", -1) + bot.memory.setdefault("dj_playing", False) + bot.memory.setdefault("dj_timer", None) + bot.memory.setdefault("dj_loop", False) + bot.memory.setdefault("dj_channel", None) + bot.memory.setdefault("dj_lock", threading.RLock()) + + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _cancel_timer(bot): + t = bot.memory.get("dj_timer") + if t: + t.cancel() + bot.memory["dj_timer"] = None + + +def _play_at(bot, sender, pos): + queue = bot.memory["dj_queue"] + bot.memory["dj_pos"] = pos + bot.memory["dj_channel"] = str(sender) + bot.say(f"now playing: {queue[pos]}", sender) + t = threading.Timer(TRACK_DURATION, _on_track_end, args=(bot, sender)) + t.daemon = True + t.start() + bot.memory["dj_timer"] = t + + +def _on_track_end(bot, sender): + with bot.memory["dj_lock"]: + if not bot.memory["dj_playing"]: + return + queue = bot.memory["dj_queue"] + pos = bot.memory["dj_pos"] + next_pos = pos + 1 + if next_pos >= len(queue): + if bot.memory["dj_loop"] and len(queue) > 0: + next_pos = 0 + else: + bot.say("queue finished, playback stopped", sender) + bot.memory["dj_playing"] = False + bot.memory["dj_pos"] = -1 + bot.memory["dj_timer"] = None + return + _play_at(bot, sender, next_pos) + + +@plugin.command("dj") +def cmd_dj(bot, trigger): + url = (trigger.group(2) or "").strip() + if not url: + bot.reply("usage: !dj <youtube url>") + return + vid = _extract_video_id(url) + if not vid: + bot.reply("invalid YouTube URL (shorts, embeds, and live links are not accepted)") + return + normalized = _normalize(vid) + with bot.memory["dj_lock"]: + queue = bot.memory["dj_queue"] + if normalized in queue: + return # duplicate, skip silently + if len(queue) >= QUEUE_CAP: + bot.reply(f"queue is full ({QUEUE_CAP} tracks max)") + return + queue.append(normalized) + pos = len(queue) + bot.say(f"queued at position {pos}") + + +@plugin.command("np") +def cmd_np(bot, trigger): + with bot.memory["dj_lock"]: + queue = bot.memory["dj_queue"] + pos = bot.memory["dj_pos"] + if bot.memory["dj_playing"] and 0 <= pos < len(queue): + bot.say(f"now playing: {queue[pos]}") + else: + bot.say("nothing playing") + + +@plugin.command("queue") +def cmd_queue(bot, trigger): + with bot.memory["dj_lock"]: + queue = list(bot.memory["dj_queue"]) + pos = bot.memory["dj_pos"] + loop = bot.memory["dj_loop"] + + if not queue: + bot.say("queue is empty") + return + + start = pos + 1 if pos >= 0 else 0 + + if loop: + n = len(queue) + upcoming = [] + for i in range(min(4, n - (1 if pos >= 0 else 0))): + idx = (start + i) % n + if idx == pos: + break + upcoming.append(queue[idx]) + label = f"{n} in queue (looping)" + else: + upcoming = queue[start:start + 4] + remaining = max(0, len(queue) - start) + label = f"{remaining} remaining" + + if not upcoming: + bot.say("no upcoming songs in queue") + return + + bot.say(f"up next ({label}):") + for i, song in enumerate(upcoming, 1): + bot.say(f" {i}. {song}") + if not loop and remaining > 4: + bot.say(f" ... and {remaining - 4} more") + + +@plugin.command("play") +@plugin.require_privilege(plugin.OP, "ops only") +def cmd_play(bot, trigger): + with bot.memory["dj_lock"]: + if bot.memory["dj_playing"]: + bot.say("already playing") + return + queue = bot.memory["dj_queue"] + if not queue: + bot.say("queue is empty") + return + pos = bot.memory["dj_pos"] + start = pos if 0 <= pos < len(queue) else 0 + bot.memory["dj_playing"] = True + _play_at(bot, trigger.sender, start) + + +@plugin.command("stop") +@plugin.require_privilege(plugin.OP, "ops only") +def cmd_stop(bot, trigger): + with bot.memory["dj_lock"]: + if not bot.memory["dj_playing"]: + bot.say("already stopped") + return + bot.memory["dj_playing"] = False + _cancel_timer(bot) + bot.say("playback stopped") + + +@plugin.command("skip") +@plugin.require_privilege(plugin.OP, "ops only") +def cmd_skip(bot, trigger): + with bot.memory["dj_lock"]: + if not bot.memory["dj_playing"]: + bot.say("nothing playing") + return + _cancel_timer(bot) + _on_track_end(bot, trigger.sender) + + +@plugin.command("remove") +@plugin.require_privilege(plugin.OP, "ops only") +def cmd_remove(bot, trigger): + with bot.memory["dj_lock"]: + queue = bot.memory["dj_queue"] + pos = bot.memory["dj_pos"] + if not queue or pos < 0 or pos >= len(queue): + bot.say("nothing to remove") + return + removed = queue.pop(pos) + bot.say(f"removed: {removed}") + if bot.memory["dj_playing"]: + # pos now points to the next song; step back so _on_track_end advances to it + bot.memory["dj_pos"] = pos - 1 + _cancel_timer(bot) + _on_track_end(bot, bot.memory.get("dj_channel") or str(trigger.sender)) + else: + bot.memory["dj_pos"] = max(-1, pos - 1) + + +@plugin.command("loop") +@plugin.require_privilege(plugin.OP, "ops only") +def cmd_loop(bot, trigger): + arg = (trigger.group(2) or "").strip().lower() + if arg == "on": + bot.memory["dj_loop"] = True + bot.say("loop on, queue will repeat") + elif arg == "off": + bot.memory["dj_loop"] = False + bot.say("loop off") + else: + state = "on" if bot.memory["dj_loop"] else "off" + bot.say(f"loop is {state}, usage: !loop <on|off>") + + +@plugin.command("clear") +@plugin.require_privilege(plugin.OP, "ops only") +def cmd_clear(bot, trigger): + with bot.memory["dj_lock"]: + bot.memory["dj_playing"] = False + _cancel_timer(bot) + bot.memory["dj_queue"].clear() + bot.memory["dj_pos"] = -1 + bot.memory["dj_loop"] = False + bot.memory["dj_channel"] = None + bot.say("queue cleared, playback stopped") + + +@plugin.command("join") +def cmd_join(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() + if not channel.startswith("#"): + bot.say("usage: !join #channel") + return + bot.join(channel) + + +@plugin.command("part") +def cmd_part(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() or trigger.sender + bot.part(channel) + + +@plugin.command("chans") +def cmd_chans(bot, trigger): + if not _is_owner(bot, trigger): + return + chans = sorted(str(c) for c in bot.channels) + bot.say("in: " + " ".join(chans) if chans else "not in any channels") + + +@plugin.command("help") +def cmd_help(bot, trigger): + channel = bot.channels.get(trigger.sender) + is_op = False + if channel: + priv = channel.privileges.get(trigger.nick, 0) + is_op = priv >= plugin.OP + + lines = [ + "!dj <url> - queue a YouTube track", + "!np - show current track", + "!queue - show upcoming tracks", + ] + if is_op or _is_owner(bot, trigger): + lines += [ + "!play - start/resume playback (op)", + "!stop - pause playback (op)", + "!skip - skip current track (op)", + "!remove - remove current song from queue (op)", + "!loop <on|off> - loop the queue (op)", + "!clear - clear queue and stop (op)", + ] + if _is_owner(bot, trigger): + lines += [ + "!join #channel - join a channel (owner)", + "!part [#channel] - leave a channel (owner)", + "!chans - list joined channels (owner)", + ] + lines.append("!help - this message") + for line in lines: + bot.say(line, trigger.nick) 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()`. diff --git a/dev-docs/contessa.md b/dev-docs/contessa.md new file mode 100644 index 0000000..07b4140 --- /dev/null +++ b/dev-docs/contessa.md @@ -0,0 +1,92 @@ +# contessa - developer documentation + +## Overview + +Contessa is a recipe suggestion bot. It loads a JSON recipe file at startup +and serves random suggestions or full recipes on demand. There is no external +API, no database, and no persistent state beyond what is in memory. + +--- + +## Module structure + +``` +contessa/ + commands.py - all commands and recipe logic + recipes.json - bundled recipe data + __init__.py - empty +``` + +--- + +## Data loading + +Recipes are loaded once in `setup()` and stored in `bot.memory["ct_recipes"]`. +The structure is a dict with meal-type keys mapping to lists of dish objects: + +```json +{ + "breakfast": [{"name": "...", "description": "...", "ingredients": [...], "steps": [...]}], + "lunch": [...], + "dinner": [...], + "snack": [...] +} +``` + +If the file is missing or malformed, `setup()` catches the exception and +initializes all categories to empty lists. The bot will respond with "no +recipes loaded" rather than crashing. + +There is no hot-reload. A restart is required to pick up changes to the +recipe file. + +--- + +## Recipe lookup + +`_find_dish()` does two passes over all categories: +1. Exact name match (case-insensitive) +2. Substring match (case-insensitive) + +This order matters. If someone has a dish named "Rice" and another named +"Rice Pudding", searching for "rice" returns "Rice" not "Rice Pudding". The +first exact match wins. + +The function is a generator consumer over `_all_dishes()`, which yields +`(category, dish)` tuples from all categories. The double traversal (once +for exact, once for partial) is acceptable given that recipe sets are small. + +--- + +## Output format + +`_fmt_recipe()` returns three strings meant to be sent as three separate IRC +messages: +1. Name and description +2. Ingredients joined by comma +3. All steps joined into one line, numbered + +The steps line can get long for recipes with many steps. IRC servers typically +truncate or drop messages over ~512 bytes. This is a known limitation. The +decision to keep steps on one line was made to minimize flood (three messages +per recipe is already pushing it in a shared channel). + +--- + +## Channel requirement + +All meal and recipe commands require channel context (`@plugin.require_chanmsg`). +The admin commands (join, part, chans, help) do not, since the owner needs to +manage the bot from PM. + +--- + +## Config + +| Key | Default | Description | +|-----|---------|-------------| +| `[contessa] recipes_path` | `/var/contessa/recipes.json` | Path to recipe JSON | + +The bundled `recipes.json` contains 24 recipes per category (96 total) covering +Egyptian, Middle Eastern, and Western dishes. It is the default data file and +can be replaced or extended without code changes. diff --git a/dev-docs/daffy.md b/dev-docs/daffy.md new file mode 100644 index 0000000..5ac7815 --- /dev/null +++ b/dev-docs/daffy.md @@ -0,0 +1,157 @@ +# daffy - developer documentation + +## Overview + +Daffy is a duck hunting game. Ducks spawn at random intervals in each channel. +Players fire with `!bang`, score points, and compete on a per-channel leaderboard. +Scores are persistent in SQLite. Game state is in memory only and resets on restart. + +--- + +## Module structure + +``` +daffy/ + daffy.py - all game logic, commands, and admin + __init__.py - empty +``` + +--- + +## Per-channel state + +The original design used global bot.memory keys for game state. This meant all +channels shared one duck, one timer, and one game toggle. A duck spawning would +broadcast to every channel the bot was in simultaneously. That was wrong. + +The fix was a per-channel state dict stored in `bot.memory["df_channels"]`, +keyed by the lowercased channel name. `_chan_state(bot, channel)` handles lazy +initialization and key normalization: + +```python +key = str(channel).lower() +bot.memory["df_channels"][key] = { + "active": False, # duck is currently up + "spawn_time": 0.0, # when the current duck spawned + "next_spawn": ..., # when the next duck will spawn + "empty_rounds": 0, # consecutive rounds with no shooter + "enabled": True, # hunt is running for this channel + "idle_rounds": 3, # auto-stop threshold +} +``` + +The key **must** be lowercased. Sopel's `bot.channels` yields `Identifier` objects +whose `__hash__` returns `hash(irc_lower(name))`, while command handlers produce +plain `str` from `trigger.sender` whose hash is case-sensitive. Without +normalization, `_tick` and command handlers key into different dict entries: +the tick sets `active=True` under `Identifier("#Chan")`, but `cmd_bang` looks up +`"#Chan"` (different hash), finds no entry, creates a fresh one with +`active=False`, and fires MOCK_MSG instead of registering the shot. The same +split caused `!hunt start` to never affect the state the tick was reading. + +State is created on first access (first tick or first command). Channels the +bot is in before the first tick may not have state yet. This is fine because +the tick runs every 5 seconds and creates state on its first pass. + +--- + +## Game tick + +`_tick()` runs every 5 seconds via `@plugin.interval(5)`. It holds +`df_game_lock` for its entire run and iterates all joined channels. For each: + +1. If a duck is active and the flee timeout has passed, the duck flees. +2. If no duck is active and the spawn timer has fired, a new duck spawns. + +The flee check increments `empty_rounds`. When `empty_rounds` reaches +`idle_rounds`, the hunt auto-stops for that channel. Each channel manages this +independently. + +--- + +## Threading + +A single `threading.Lock()` (`df_game_lock`) protects all channel states. +Per-channel locks were considered but rejected: the tick iterates all channels +under one lock anyway, so per-channel locks would not improve concurrency and +would add complexity. + +`cmd_bang` acquires the lock to check and clear `active`. The ammo check is +outside the lock since ammo is per-nick and Sopel's event handlers run in a +single thread. Two concurrent bangs from the same nick cannot happen under +normal Sopel operation. + +--- + +## Ammo + +Ammo is tracked in `bot.memory["df_players"]`, keyed by nick, and is not +per-channel. A nick carries their loaded/unloaded state across all channels. +This is intentional: if you fire in one channel you are empty in all channels +until you reload. Reload has a 3-second cooldown tracked by `df_reload_cd`. + +--- + +## Scoring + +Scores are stored in SQLite with `(nick, channel)` as the primary key. The +table uses `ON CONFLICT DO UPDATE` (upsert) so there is no separate select +before incrementing. The database path comes from config, defaulting to +`~/daffy_scores.db`. The directory is created automatically if it does not exist. + +--- + +## Hunt control + +`!hunt` requires channel context (`@plugin.require_chanmsg`) after the +per-channel refactor. It no longer makes sense in PM since each hunt is +tied to a specific channel. + +`!hunt idle 0` disables auto-stop for that channel. The idle counter still +increments but is never compared to a threshold. + +`!hunt idle N` checks the current `empty_rounds` immediately on set. If the +counter already meets the new threshold and no duck is active, the hunt stops +right away with an announcement — same behaviour as if the flee had just +triggered it. This prevents a silent bypass where setting a lower threshold +while `empty_rounds` was already at or above it would have no effect until +the next flee, and a single kill in between would reset the counter and escape +the threshold entirely. + +--- + +## !score from PM (owner only) + +The owner can use `!score` from PM to see scores across all channels, or +`!score clear all` / `!score clear #channel` to manage them. Regular users +get "use !score in a channel" from PM. + +--- + +## Known issues and tradeoffs + +**No persistence for game state.** On restart, all channels start fresh with +`enabled=True` and a new random spawn timer. There is no way to stop the +hunt, restart the bot, and have it stay stopped. This is acceptable since +the hunt auto-starts silently without bothering anyone until a duck actually +appears. + +**Idle auto-stop is per-restart.** `empty_rounds` resets to 0 on restart +because it is in-memory. A channel that was about to auto-stop will get +a fresh counter. + +--- + +## Config + +| Key | Default | Description | +|-----|---------|-------------| +| `[daffy] db_path` | `~/daffy_scores.db` | SQLite scores database | +| `[daffy] idle_rounds` | 3 | Empty rounds before auto-stop (0 = disabled) | +| `[daffy] spawn_min` | 60 | Lower bound of spawn interval (seconds) | +| `[daffy] spawn_max` | 300 | Upper bound of spawn interval (seconds) | +| `[daffy] flee_time` | 45 | Seconds before an unshot duck flees | + +All three timing values are loaded into `bot.memory` as floats (`df_spawn_min`, +`df_spawn_max`, `df_flee_time`) at startup and read from there at runtime, so +they fall back to the module-level constants if the config section is missing. diff --git a/dev-docs/daft.md b/dev-docs/daft.md new file mode 100644 index 0000000..880b95c --- /dev/null +++ b/dev-docs/daft.md @@ -0,0 +1,132 @@ +# daft - developer documentation + +## Overview + +Daft is a YouTube playlist manager for IRC. Anyone can queue tracks. Ops control +playback. The bot announces tracks and advances the queue on a fixed timer. +There is no actual audio playback. The timer simulates track duration for +queue progression purposes. + +--- + +## Module structure + +``` +daft/ + dj.py - all commands and queue logic + __init__.py - empty +``` + +--- + +## Queue model + +The queue is a plain list stored in `bot.memory["dj_queue"]`. There is no +persistence. The queue is lost on restart. This is intentional: a music queue +is a live social activity, not something that needs to survive a restart. + +`dj_pos` is the index of the currently playing track. `-1` means nothing is +playing. When a track ends, `_on_track_end()` advances `dj_pos` by 1 and +calls `_play_at()` for the next position. + +--- + +## Track duration + +`TRACK_DURATION = 120` seconds. This is a fixed constant, not the actual +duration of the YouTube video. The bot has no way to query video duration +without an API key. The timer is just for queue advancement in the IRC +session. If you want a different advancement interval, change the constant. + +--- + +## URL validation + +`_extract_video_id()` validates and normalizes YouTube URLs: + +1. Ensures the scheme is http or https. +2. Checks the domain against a whitelist of known YouTube hosts. +3. Rejects shorts, embeds, and live stream paths. +4. Extracts the 11-character video ID. + +All queued URLs are normalized to `https://youtu.be/<id>`. This means the +same video added with different URL formats (watch?v=, youtu.be/, m.youtube.com) +is detected as a duplicate. + +The regex `^[A-Za-z0-9_-]{11}$` validates the video ID format. This is the +format YouTube has used since the beginning. If YouTube ever changes their +ID format, this regex needs to be updated. + +--- + +## Threading + +`_on_track_end()` is called from a `threading.Timer` callback, which runs +in a separate thread from Sopel's main event loop. It reads and writes shared +state in `bot.memory`. This requires locking. + +A `threading.RLock()` (`dj_lock`) is used instead of a regular `Lock()` because +`cmd_skip` calls `_on_track_end()` directly from the main thread while holding +the lock. An RLock allows the same thread to acquire it multiple times without +deadlocking. + +The lock is acquired in: +- `_on_track_end()` (timer thread) +- `cmd_play`, `cmd_stop`, `cmd_skip`, `cmd_remove`, `cmd_clear` (main thread) +- `cmd_dj`, `cmd_np`, `cmd_queue` (main thread, for read consistency) + +`_play_at()` and `_cancel_timer()` do not acquire the lock themselves because +they are always called by code that already holds it. + +--- + +## Removing a playing track + +`cmd_remove()` removes the current track and advances to the next one. +The logic: + +1. `queue.pop(pos)` removes the current track. The element that was at + `pos+1` is now at `pos`. +2. `dj_pos` is set to `pos - 1`. +3. `_on_track_end()` is called, which computes `next_pos = (pos-1) + 1 = pos`, + which points to the track that was previously next. + +This correctly advances to the next track without skipping it. + +If the removed track was the last one, `next_pos` will be out of bounds and +playback stops normally. + +--- + +## Loop mode + +In loop mode, `_on_track_end()` wraps around to index 0 when it reaches the +end of the queue. The `!queue` display in loop mode shows up to 4 upcoming +tracks wrapping around the current position, excluding the current track +itself. + +--- + +## dj_channel + +`bot.memory["dj_channel"]` stores the channel where playback was started. +This is used when `_on_track_end()` fires from the timer thread and needs to +know where to send "now playing" messages. It is also used as a fallback in +`cmd_remove()` when `trigger.sender` might differ from the original playback +channel. + +--- + +## Known issues and tradeoffs + +**No actual playback.** The bot announces tracks but does not play audio. +It was designed as a queue coordinator for a separate external player. The +TRACK_DURATION timer is a rough approximation. + +**No duration info.** Without the YouTube Data API, there is no way to know +the actual length of a video. Using a fixed 120s timer means long videos get +cut short and short videos have dead air. + +**Queue position display.** `!queue` shows position 1-indexed from the next +track. The currently playing track is not shown in the queue list, only in +`!np`. diff --git a/dev-docs/herald.md b/dev-docs/herald.md new file mode 100644 index 0000000..c23cf44 --- /dev/null +++ b/dev-docs/herald.md @@ -0,0 +1,140 @@ +# herald - developer documentation + +## Overview + +Herald (also deployed as bikabot) does two things: it follows the owner around +IRC channels automatically, and it responds to configured keywords in channel +messages. These are implemented in two independent plugin files. + +--- + +## Module structure + +``` +herald/ + commands.py - follow logic, admin commands, owner presence tracking + respond.py - keyword-based auto-responder + __init__.py - empty +``` + +--- + +## commands.py: follow logic + +### How follow works + +When `!follow on` is active, the bot sends a WHOIS for the owner every 30 +seconds via `@plugin.interval(30)`. The server responds with one or more IRC +numeric replies: + +- **319 RPL_WHOISCHANNELS**: lists the channels the owner is in. +- **318 RPL_ENDOFWHOIS**: signals the end of the WHOIS response. + +If 319 arrives, `_whois_channels()` parses the channel list and joins any +channel the owner is in that the bot is not already in. Joined channels are +tracked in `bot.memory["follow_joined"]`. + +If 318 arrives with no preceding 319 (the flag `whois_got_channels` is still +False), the owner is offline. The bot leaves all follow-joined channels. + +### Preconfigured channels + +Channels listed under `channels` in the bot config are never auto-left, even +if the owner leaves or goes offline. `_configured_channels()` reads +`bot.settings.core.channels` and returns a set of lowercase channel names. + +This matters for the PART and QUIT event handlers: if the owner leaves a +channel, the bot only follows if the channel is not preconfigured. + +### PART and QUIT handling + +`on_owner_part()` fires on any PART. If the parting nick is the owner and the +channel is not preconfigured, the bot parts too. + +`on_owner_quit()` fires on QUIT and leaves all non-preconfigured channels +immediately without waiting for the next WHOIS cycle. + +### Why WHOIS instead of tracking JOIN/PART directly + +Tracking JOIN/PART directly would miss channels the owner was already in before +the bot connected. WHOIS gives a complete current picture regardless of when +the bot joined the network. + +### RPL_WHOISCHANNELS parsing + +The 319 reply contains channel names prefixed with membership status characters +(`@`, `+`, `~`, `&`, `%`). The parser strips these prefixes before comparing +channel names: + +```python +chan = part.lstrip("@+~&%") +``` + +--- + +## commands.py: !pause / !resume + +`!pause` sets `bot.memory["herald_paused"] = True`; `!resume` clears it. Both +are owner-only and work in any context (channel or PM). The `respond()` handler +in `respond.py` checks this flag before processing any message. + +--- + +## commands.py: !unfollow + +`!unfollow` leaves all channels in `follow_joined` immediately and clears the +set. It does not disable `follow_enabled`. After `!unfollow`, the bot stops +following for that session but `!follow on` can restart it. + +--- + +## respond.py: keyword responder + +### How it works + +`respond()` fires on every channel message. It loads `keywords` and `responses` +from the `[respond]` config section (both are `ListAttribute`, supporting +newline-separated values in the config file). If any keyword appears anywhere +in the message (case-insensitive using `casefold()`), a random response is sent. + +### Why casefold over lower + +`casefold()` is more aggressive than `lower()` for Unicode. For a bot that +might see messages in Arabic or other scripts, casefold handles more edge +cases correctly. + +### Error handling + +The match is wrapped in a try/except for `UnicodeError` and `AttributeError`. +This guards against malformed trigger data from the IRC layer. + +--- + +## State + +``` +bot.memory["follow_enabled"] - bool, follow mode on/off +bot.memory["follow_joined"] - set of channel names joined via follow +bot.memory["whois_got_channels"] - bool, reset each WHOIS cycle +bot.memory["herald_paused"] - bool, keyword responses paused on/off +``` + +None of this persists across restarts. Both `follow_enabled` and `herald_paused` +are off by default on startup. + +`herald_paused` is set by `!pause` / `!resume` in `commands.py` and read by +`respond()` in `respond.py`. The two plugins share `bot.memory`, so no import +or coupling between files is needed. + +--- + +## Known issues and tradeoffs + +**WHOIS interval is fixed at 30 seconds.** If the owner moves channels quickly, +the bot may lag behind by up to 30 seconds. Shortening the interval increases +WHOIS traffic on the server. + +**Follow only joins, never leaves proactively.** With `!follow on`, the bot +joins channels the owner is in but only leaves them on QUIT/PART events or +when the owner goes offline. It does not automatically leave a channel if the +owner leaves without the bot noticing (network split, for example). diff --git a/dev-docs/jeeves.md b/dev-docs/jeeves.md new file mode 100644 index 0000000..27ba163 --- /dev/null +++ b/dev-docs/jeeves.md @@ -0,0 +1,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. diff --git a/dev-docs/osterman.md b/dev-docs/osterman.md new file mode 100644 index 0000000..7676def --- /dev/null +++ b/dev-docs/osterman.md @@ -0,0 +1,274 @@ +# osterman - developer documentation + +## Overview + +Osterman is a full channel moderation bot. It handles automatic protection +(flood, caps, repeat, badword, clone, join flood, nick flood), manual +moderation commands, auto-modes on join, persistent ban tracking, user +event logging, and idle detection. + +It is split across five plugin files that share state through `bot.memory`. + +--- + +## Module structure + +``` +osterman/ + protect.py - setup, passive protection, on_join, on_message, MODE/NICK/PART/QUIT handlers + commands.py - all user-facing moderation and config commands + tracking.py - SQLite event log, greeting, !seen, !info, !log + idle.py - idle detection tick, !afk, !idle + help.py - three-level help system (!help) + helpstrings.py - help string constants shared across modules + __init__.py - empty +``` + +--- + +## Help system + +Help strings live in `helpstrings.py` as plain string constants and dicts. +No Sopel imports, no decorators. `help.py` imports it via sys.path insertion +(same pattern as alfred): + +```python +import os as _os, sys as _sys +_osterman_dir = _os.path.dirname(_os.path.abspath(__file__)) +if _osterman_dir not in _sys.path: + _sys.path.insert(0, _osterman_dir) +import helpstrings as h +``` + +Three-level hierarchy: +``` +!help → TOPICS (list of topic names and commands) +!help <topic> → topic line listing commands +!help <cmd> → single command description + usage +!help <topic> <cmd> → same as above +``` + +`helpstrings.lookup(args)` handles all dispatch. Unknown args fall back to +the TOPICS line with an "no help for X" prefix. + +--- + +## Shared state + +`protect.py`'s `setup()` initializes all shared state in `bot.memory`: + +``` +bot.memory["os_db"] - full database dict (loaded from JSON) +bot.memory["os_db_path"] - path to the JSON db file +bot.memory["os_lock"] - threading.Lock() for db writes +bot.memory["os_flood"] - {(channel, nick): [timestamps]} flood tracking +bot.memory["os_repeat"] - {(channel, nick): [(hash, timestamp)]} repeat tracking +bot.memory["os_join_flood"] - {(channel, host): [timestamps]} join flood (per channel) +bot.memory["os_nick_flood"] - {"user@host": [timestamps]} nick flood (network-wide) +bot.memory["os_last"] - {(channel, nick): timestamp} last message time +``` + +`os_join_flood` is keyed by `(channel, host)` so join flood counts are isolated +per channel. A user joining multiple channels quickly does not bleed the flood +counter across channels. `os_nick_flood` is network-wide by design — NICK changes +apply across all channels. + +The other modules access these keys with safe fallbacks in case of load order +differences. Sopel does not guarantee plugin load order, so every module that +touches `os_db` uses `bot.memory.get("os_db", {})` rather than a direct dict +access. + +`tracking.py` derives its SQLite database path from `os_db_path` by replacing +the filename with `track.db`. This keeps both files in the same directory +without requiring a separate config key. + +--- + +## Database structure + +### JSON database (os_db) + +```json +{ + "config": {"flood_threshold": 5, ...}, + "acl": ["nick1"], + "whitelist": ["*!*@services.dal.net"], + "blacklist": [], + "exceptions": [], + "channels": { + "#example": { + "config": {"flood_threshold": 3}, + "bans": [{"mask": "...", "added_by": "...", "timestamp": 0}], + "filters": ["regex1"], + "badwords": ["word1"], + "exceptions": [], + "autoop": [], + "autovoice": [], + "autohalfop": [] + } + } +} +``` + +Config resolution is a three-level merge: hardcoded defaults → global config +override → channel config override. `_cfg(bot, channel)` returns the merged +result. `!set` in a channel writes to channel config; `!set` in PM writes to +global config (owner only). + +### SQLite database (track.db) + +Single `events` table: `id`, `nick`, `type`, `channel`, `detail`, `timestamp`. +Indexed on `nick` (NOCASE) and `timestamp DESC`. + +Event types: `join`, `part`, `quit`, `kick`, `ban`, `nick`, `mute`, `unmute`, +`lock+m`, `lock+i`, `unlock-m`, `unlock-i`. + +--- + +## Message routing + +| Output | Method | Destination | +|--------|--------|-------------| +| `!seen`, `!idle <nick>` results | `bot.say` | channel (`trigger.sender`) | +| `!info`, `!log` results | `bot.say` | PM (`trigger.nick`) | +| `!ban list`, `!config`, `!filter list`, `!badword list` | `bot.notice` | nick | +| All other command feedback | `bot.notice` | nick | +| Greetings, idle warns, bad word warn | `bot.notice` | nick | + +`_notice(bot, nick, text)` in `commands.py` and `help.py` calls +`bot.notice(text, nick)` — a true IRC NOTICE, not a PRIVMSG. This is the +standard approach for automated bot feedback that should not trigger other +bots or appear as a regular chat message. + +--- + +## protect.py: passive protection + +### on_join + +Runs in order: +1. Blacklist check (global instant-ban, bypasses whitelist). +2. Whitelist/exception check. If exempt, still applies auto-modes. +3. Persistent ban reapply. +4. Clone detection. +5. Join flood detection (keyed per channel+host). +6. Auto-modes (`_apply_auto_modes()`). + +Each step returns early if it takes action. + +### on_message + +Runs in order under `os_lock`: +1. Regex content filters. +2. Badword list. +3. Caps filter. +4. Repeat filter. +5. Flood control. + +Each filter returns early on action. Flood is last because it is the least +specific. + +### Privilege degradation + +- Op: full enforcement (kick, ban, +b mode). +- Halfop: kick only (no +b, no re-op). +- None: tracking only (`os_last` updates, no enforcement). + +### Takeover mitigation + +`on_mode()` watches for `-o` events on ACL nicks. If the bot has op, it +immediately re-ops them. + +--- + +## commands.py + +### ban vs bankick + +`!ban` and `!bankick` are functionally identical. They both ban and kick. +`!ban` also handles the `!ban list` subcommand. `!bankick` exists as an +explicit alias without the list subcommand ambiguity. + +### tempban + +Auto-unban is implemented with `threading.Timer`. The timer is not persisted. +If the bot restarts before the timer fires, the ban is never lifted. The expiry +is stored in the JSON db (`"expires"` key), so the data is there; it is just +not acted on at startup. + +### !filter del / !badword del + +Deleted by index, not by value. Use `!filter list` / `!badword list` to see +indices, then `del N`. This avoids ambiguity with regex special characters. + +### !unban db cleanup + +`cmd_unban` removes matching entries from the internal ban database using +`fnmatch.fnmatch(b["mask"], mask)` — tests whether the stored ban mask matches +the target being unbanned. The argument order matters: `fnmatch(pattern, string)` +where the stored mask is the pattern and the unban target is the string being +tested against it. + +--- + +## tracking.py + +### Greeting + +On JOIN, checks for prior events before logging the join itself. If the nick +has any prior events: "Welcome back, nick!". If not: "Welcome to #channel, nick!". +Sent as IRC NOTICE to the joining user. Controlled by the `greet` config key. + +### !seen scoping + +In channel: checks only the current channel — live presence check against +`bot.channels[channel]`, then DB query with `AND channel = ?`. Reports in channel. + +In PM: owner/ACL only. Cross-channel lookup across all joined channels and +the full event log. Reports as IRC NOTICE to nick. All other callers get +"use !seen in a channel". + +### !info scoping + +In channel: all DB queries scoped to `channel = ?`. Alias lookups (nick-change +events) are global because NICK events have no channel. Reports in PM. + +In PM: owner/ACL only. All queries are global. Reports in PM. All other +callers get "use !info in a channel". + +--- + +## idle.py + +### Idle tick + +`@plugin.interval(60)` checks all users in all channels against +`idle_warn_min` and `idle_kick_min` (minutes). Uses `os_last` timestamps. +Users with no `os_last` entry are treated as active. + +--- + +## Known issues and tradeoffs + +**tempban does not survive restarts.** The expiry timestamp is stored in the +JSON db but not checked on startup. Tempbans that expire while the bot is down +are never lifted. + +**Clone detection is host-only.** Checks `trigger.host`, not `user@host`. +Users behind a BNC share the same host. Either raise `clone_limit` or whitelist +the BNC host. + +**ACL management requires a channel.** `!acl` has `@plugin.require_chanmsg`. +Cannot manage the ACL from PM. Workaround: call from any channel the bot is in. + +**`!ban list` only shows bot-tracked bans.** Bans set manually by channel ops +(not through osterman) do not appear. The list reflects the internal JSON db, +not the IRC +b list. + +--- + +## Config + +| Key | Description | +|-----|-------------| +| `[osterman] db_path` | Path to JSON database (auto-created if missing) | diff --git a/dev-docs/salvador.md b/dev-docs/salvador.md new file mode 100644 index 0000000..fa6e75f --- /dev/null +++ b/dev-docs/salvador.md @@ -0,0 +1,143 @@ +# salvador - developer documentation + +## Overview + +Salvador converts an image URL to mIRC colour art using half-block characters +and Floyd-Steinberg dithering. It uses Pillow for image loading and resizing +and implements its own color matching and dithering against the 99-color mIRC +palette. + +--- + +## Module structure + +``` +salvador/ + salvador.py - image fetching, rendering, !draw command + commands.py - admin commands (!join, !part, !chans, !help) + __init__.py - empty +``` + +--- + +## Half-block rendering technique + +Each IRC character represents two vertical pixels. The character used is +U+2584 (LOWER HALF BLOCK). The foreground color represents the lower pixel +and the background color represents the upper pixel. IRC color codes are +written as `\x03fg,bg`. + +To render an image at N character lines, the image is resized to N*2 pixels +tall. Pairs of rows are then combined: row 0 and row 1 become character line 0, +row 2 and row 3 become character line 1, and so on. + +This doubles the effective vertical resolution compared to full-block +approaches where each character is one pixel. + +--- + +## Color matching + +`_nearest(r, g, b)` finds the closest mIRC palette color using a +perceptually-weighted RGB distance formula: + +```python +rmean = (r + 128) / 2 +dist = (2 + rmean/256) * dr*dr + 4*dg*dg + (2 + (255-rmean)/256) * db*db +``` + +This is a standard approximation of human color perception. Green is weighted +highest (coefficient 4). Red and blue are weighted by the red channel mean. +Pure Euclidean distance produces noticeably worse results especially in +saturated reds and blues. + +The full 99-color mIRC palette is hardcoded in `MIRC`. The first 16 are the +original mIRC colors. Colors 16-98 are the extended palette added in later +mIRC versions. Colors 88-98 are grayscale. + +--- + +## Floyd-Steinberg dithering + +`_dither()` is a manual implementation of Floyd-Steinberg dithering. +PIL's built-in dithering only targets the web palette or a custom palette +using PIL's quantize methods, which do not map cleanly to the mIRC palette +index scheme. Implementing it directly gives full control over the palette +and error diffusion. + +The algorithm quantizes each pixel to the nearest palette color, computes +the quantization error (difference between original and quantized RGB), and +distributes that error to neighboring pixels: + +``` +(x+1, y ): 7/16 of error +(x-1, y+1): 3/16 of error +(x , y+1): 5/16 of error +(x+1, y+1): 1/16 of error +``` + +The buffer is floats to accumulate fractional errors. Final values are clamped +to 0-255 before color matching. + +--- + +## Fetch and size limits + +`_fetch()` sends a request with a custom User-Agent and reads up to `MAX_BYTES` +(5MB). It checks the Content-Type header before reading the body. If the +response is not an image type, it raises a ValueError immediately rather than +reading the full body. + +The size limit prevents memory exhaustion from large images. 5MB is generous +for web images and strict enough to catch accidental links to large files. + +--- + +## Aspect ratio fitting + +`_fit()` computes the output dimensions to fit within `MAX_WIDTH x height` +while preserving the aspect ratio. IRC monospace character cells are +approximately twice as tall as they are wide, so a naïve pixel aspect ratio +would produce output that is 2× stretched vertically. + +`CHAR_RATIO = 2.0` corrects for this: the image's pixel aspect ratio is +multiplied by `CHAR_RATIO` before the fit comparison, so the renderer allocates +twice as many character columns as the raw pixel ratio would suggest, cancelling +the cell height distortion. + +--- + +## Flood protection + +A `FLOOD_DELAY` of 0.4 seconds is inserted between output lines via +`time.sleep()`. Without this, sending 6-12 lines in rapid succession triggers +flood protection on most IRC servers and the lines get dropped. + +`MAX_HEIGHT = 12` lines. Sending more than 12 lines would be disruptive in +any channel. + +--- + +## URL handling + +The command strips trailing punctuation from the URL (`.`, `,`, `)`, `>`). +This handles the common case where someone pastes a URL at the end of a +sentence and the punctuation gets included in the copied text. + +Only `http://` and `https://` URLs are accepted. This blocks `file://` and +other schemes that could access local resources. + +--- + +## Known issues and tradeoffs + +**Color accuracy degrades on complex gradients.** The 99-color mIRC palette +is coarse. Dithering helps significantly but cannot fully compensate for the +palette limitation. + +**No image caching.** Every `!draw` fetches the image fresh. Repeated calls +with the same URL re-download every time. + +**Blocking fetch.** `_fetch()` runs in the command handler (main Sopel thread) +and blocks while downloading. Large images or slow servers will block the bot +for the duration of the download (up to 10 seconds timeout). diff --git a/dev-docs/shireen.md b/dev-docs/shireen.md new file mode 100644 index 0000000..85563a5 --- /dev/null +++ b/dev-docs/shireen.md @@ -0,0 +1,173 @@ +# shireen - developer documentation + +## Overview + +Shireen is a news and weather bot. It fetches weather from wttr.in (no API +key required) and headlines from RSS/Atom feeds. It posts fresh headlines +automatically at a configurable per-channel interval and deduplicates across +restarts using a persisted GUID list. + +--- + +## Module structure + +``` +shireen/ + commands.py - all logic, commands, broadcast tick + __init__.py - empty +``` + +--- + +## Data persistence + +All state is stored in a single JSON file at `data_path`. The structure is: + +```json +{ + "channels": { + "#example": { + "news_enabled": true, + "broadcast_interval": 90, + "broadcast_count": 1, + "last_broadcast": 0 + } + }, + "feeds": [...], + "seen": ["guid1", "guid2"] +} +``` + +The file is read once at startup into `bot.memory["sh_data"]` and written +back on every change. This means the in-memory dict is the live state and +the file is the persistence layer. Changes made to the file while the bot +is running will be overwritten on the next save. + +### _DEFAULT_CHANNEL mutation + +`setup()` mutates the module-level `_DEFAULT_CHANNEL` dict to apply the +config-specified defaults. New channels added to `data["channels"]` after +startup will use these mutated defaults. This is intentional: the config +file controls what a "fresh" channel looks like. + +--- + +## Feed parsing + +`_fetch_feed()` parses both RSS 2.0 and Atom feeds: + +- RSS: looks for `<item>` elements with `<title>`, `<link>`, and `<guid>`. +- Atom: falls back if no items found, looks for `<entry>` elements with the + Atom namespace. + +The fallback order means Atom feeds are only tried if RSS parsing finds +nothing. Feeds that mix both formats will be parsed as RSS. + +GUIDs are used as unique identifiers. If a feed does not provide a GUID, +the link URL is used instead. This is less stable but still prevents +immediate re-posting. + +--- + +## URL cleaning + +`_clean_url()` strips query strings and fragments from feed item links before +storing and posting them. RSS feeds from major outlets routinely include +tracking parameters in article links (`utm_source`, `ref`, etc.). Stripping +them produces cleaner, shorter URLs. + +--- + +## Deduplication + +`data["seen"]` is a list of GUIDs that have already been posted. Before +posting an item, its GUID is checked against this list. After posting, +the GUID is added. The list is pruned to 1000 entries (oldest removed) on +every save. This prevents the file from growing unbounded. + +The seen list persists across restarts so the same article is never reposted +even if the bot is restarted between broadcast cycles. + +--- + +## Broadcast tick and locking + +`@plugin.interval(60)` runs every minute and checks each channel. + +The original implementation held `sh_lock` for the entire tick including all +HTTP fetches. This blocked `!headlines` and `!news` commands for the full +duration of the RSS fetches (multiple feeds, multiple channels). On a slow +network this could mean several seconds of blocked commands. + +The fix splits the tick into three phases: + +1. Under lock: read config, determine which channels need a broadcast, + snapshot `feeds` and `seen`. +2. Outside lock: fetch headlines using the snapshot. HTTP happens here. +3. Under lock: write seen GUIDs, update `last_broadcast`, save to disk. + +The `seen` set is updated between channels in step 3 to prevent the same +article from being posted to multiple channels in the same tick cycle. + +`!headlines` still holds the lock during its fetch since it is a single +user-triggered request. The wait is acceptable for interactive commands. + +--- + +## Weather + +Weather data comes from `wttr.in` using the JSON format `?format=j1`. No +API key is required. The response includes current conditions, hourly data +for 3 days, and nearest area information. + +`_format_location()` builds a clean location string using a country code +lookup. "United States of America" becomes "US", etc. If the country is not +in the lookup dict, the full country name is used as-is. + +`!forecast` uses `day["hourly"][4]` for the weather description. Index 4 +corresponds to roughly midday (wttr.in hourly data is in 3-hour intervals +starting at midnight, so index 4 = 12:00). This gives a more representative +daily description than midnight or early morning conditions. + +--- + +## Feed defaults + +Nine feeds are hardcoded in `_DEFAULT_DATA`. They are written to the data +file on first run. After that, the feeds in the data file are used. To +add, remove, or disable a feed, edit the `feeds` array in the data file +while the bot is stopped. + +Each feed has an `enabled` field. Setting it to `false` excludes it from +all headline fetches without removing it from the file. + +--- + +## Per-channel configuration + +Each channel's config is stored separately in `data["channels"]`. `_chan()` +creates a new channel entry with `_DEFAULT_CHANNEL` defaults if it does not +exist. Channel settings are modified via `!news interval` and `!news count`. +These write directly to the data dict and save immediately. + +--- + +## Known issues and tradeoffs + +**No feed management via IRC.** Feeds can only be added or modified by +editing the JSON file directly. There are no IRC commands for feed management. +This was a deliberate simplicity choice. + +**RSS fetches can fail silently.** If a feed is unreachable or returns bad +XML, the error is logged via `log.warning()` but not reported in IRC. The +tick continues with the remaining feeds. This prevents a single broken feed +from affecting all others. + +**Seen list is global, not per-channel.** An article posted in one channel +will never be posted in another channel either. If you want different channels +to receive the same articles independently, this would require reworking the +seen list to be per-channel. + +**last_broadcast is stored in UTC epoch seconds.** The interval check is +`now - last_broadcast < interval_sec`. This is wall-clock time, not adjusted +for timezones. It works correctly as long as the bot's system clock is stable. diff --git a/docs/alfred.md b/docs/alfred.md new file mode 100644 index 0000000..c2e6a7d --- /dev/null +++ b/docs/alfred.md @@ -0,0 +1,132 @@ +# alfred + +Server management bot. All commands are owner-only and work in channel or PM. + +## Dependencies + +``` +pip install requests +``` + +## Help system + +``` +!help list topics +!help <topic> list commands for a topic +!help <topic> <cmd> description and usage for a command +``` + +All help responses are sent as IRC NOTICE to the requester, not said publicly in channel. + +Running any command with no arguments shows the same output as `!help <topic>`. + +## Commands + +### Bot management + +``` +!bot list show all managed bots and their running state +!bot all start|stop|restart control all bots at once +!bot <name> start|stop|restart control a bot +!bot <name> status check if a bot is running +``` + +Bots are discovered automatically from `~/.config/sopel/*.cfg`. + +### Server stats + +``` +!srv uptime uptime and load averages +!srv disk disk usage for / [raw df output - needs formatting] +!srv mem RAM and swap usage [raw free output - needs formatting] +!srv load load averages and process count +!srv status [service] rc-service status, all or one +!srv net [iface] RX TX bytes for an interface +!srv top [n] top n processes by CPU (default 5) +!srv topmem [n] top n processes by memory (default 5) +!srv io disk read write stats +!srv conns TCP connection summary [raw ss -s output - needs formatting] +!srv who logged-in users [raw who output - needs formatting] +!srv logs <service> [n] last n log lines for a service (default 10) +``` + +`!server` is an alias for `!srv`. + +### Soju bouncer + +``` +!irc user list list all bouncer users [raw sojuctl output - needs formatting] +!irc user add <nick> <pass> create a new user [raw sojuctl output - needs formatting] +!irc user remove <nick> delete a user [raw sojuctl output - needs formatting] +!irc user passwd <nick> <pass> change a user's password [raw sojuctl output - needs formatting] +!irc user enable <nick> re-enable a disabled user [raw sojuctl output - needs formatting] +!irc user disable <nick> disable a user [raw sojuctl output - needs formatting] +!irc net list <user> list networks for a user [raw sojuctl output - needs formatting] +!irc net status <user> show network connection status [raw sojuctl output - needs formatting] +!irc net add <user> <name> [addr] add a network (preset or custom) [raw sojuctl output - needs formatting] +!irc net remove <user> <name> remove a network [raw sojuctl output - needs formatting] +!irc net quote <user> <net> <cmd> send a raw IRC command to a network [raw sojuctl output - needs formatting] +!irc net presets show available network presets +!irc bouncer bouncer-wide stats [raw sojuctl output - needs formatting] +!irc notice <message> broadcast a notice to all users [raw sojuctl output - needs formatting] +``` + +### File hosting (0x0) + +``` +!files stats file count and total size +!files list [n] last n uploads (default 5) +!files shorten <url> [wkfo|gumx] shorten a URL +!files mirror <url> [wkfo|gumx] mirror a remote URL +!files prune delete expired files from disk [raw flask output - needs formatting] +!files vscan run ClamAV scan on stored files [raw flask output - needs formatting] +!files remove <filename> permanently remove a file +``` + +### Coffee tracker + +``` +!coffee stats today count, week bar graph, streak, all-time average +!coffee +<n> add n cups to today +!coffee -<n> remove n cups from today +!coffee <n> set today's count to n +!coffee +<n> yyyy-mm-dd add n cups to a past date +!coffee <n> yyyy-mm-dd set a past date to n cups +!coffee yes confirm a pending action +!coffee no cancel a pending action +``` + +### WireGuard VPN + +``` +!vpn pubkey print server public key +!vpn list list peers: name, IP, pubkey (first 24 chars) +!vpn add <name> <pubkey> add peer, returns assigned IP and client config URL +!vpn remove <name> remove peer live and from wg0.conf +``` + +`!vpn add` assigns the next free `10.0.0.x`, calls `wg set wg0 peer` live (no restart needed), rewrites `wg0.conf`, uploads a ready-to-use client `.conf` to wk.fo, and replies with the IP and URL. + +### Mailing list + +``` +!list members show all subscribers +!list subscribe <email> subscribe an address +!list unsubscribe <email> unsubscribe an address +!list send <message> send a message to the list +``` + +Manages the mlmmj list at `list@gumx.cc`. Web archive at `mail.gumx.cc/list/archive/`. + +## Config keys + +| Key | Description | +|-----|-------------| +| `[coffee] data_path` | Path to coffee data JSON | +| `[coffee] timezone` | Timezone for date calculations (default UTC) | +| `[soju] config_path` | Path to soju config file | +| `[files] url` | 0x0 API URL | +| `[files] default_domain` | Default short domain (wkfo or gumx) | +| `[files] db_path` | Path to 0x0 SQLite database | +| `[files] data_path` | Path to 0x0 file storage directory | +| `[files] app_path` | Path to 0x0 Flask app directory | diff --git a/docs/contessa.md b/docs/contessa.md new file mode 100644 index 0000000..b1d2e27 --- /dev/null +++ b/docs/contessa.md @@ -0,0 +1,59 @@ +# contessa + +Recipe suggestion bot. Suggests random recipes by meal type, lists available +dishes per category, and gives full step-by-step recipes on request. + +## Dependencies + +No external libraries required. + +## Commands + +All commands require channel context (`@plugin.require_chanmsg`). + +| Command | Description | +|---------|-------------| +| `!breakfast [list]` | Random breakfast suggestion, or list all breakfast dishes | +| `!lunch [list]` | Random lunch suggestion, or list all lunch dishes | +| `!dinner [list]` | Random dinner suggestion, or list all dinner dishes | +| `!snack [list]` | Random snack suggestion, or list all snacks | +| `!recipe <name>` | Full recipe: description, ingredients, steps (3 lines) | + +### Admin (owner) +| Command | Description | +|---------|-------------| +| `!join #channel` | Join a channel | +| `!part [#channel]` | Leave a channel | +| `!chans` | List channels | +| `!help` | Show usage (PM) | + +## Recipe data + +Recipes are loaded from a JSON file at startup. Format: + +```json +{ + "breakfast": [ + { + "name": "Dish Name", + "description": "One-line description.", + "ingredients": ["item 1", "item 2"], + "steps": ["Step one.", "Step two.", "Step three."] + } + ], + "lunch": [...], + "dinner": [...], + "snack": [...] +} +``` + +The included `recipes.json` has 24 recipes per category (96 total), covering +both Egyptian/Middle Eastern and Western/European dishes. + +`!recipe` matches by exact name first, then by substring across all categories. + +## Config keys + +| Key | Default | Description | +|-----|---------|-------------| +| `[contessa] recipes_path` | `/var/contessa/recipes.json` | Path to recipes JSON file | diff --git a/docs/daffy.md b/docs/daffy.md new file mode 100644 index 0000000..d9a8311 --- /dev/null +++ b/docs/daffy.md @@ -0,0 +1,55 @@ +# daffy + +Duck hunting game bot. Ducks appear at random intervals; players race to shoot +them. Scores are stored in SQLite. The hunt auto-stops after a configurable +number of rounds with no participants. + +## Dependencies + +No external libraries required. + +## Commands + +### Game (anyone in channel) +| Command | Description | +|---------|-------------| +| `!bang` | Fire at the current duck | +| `!reload` | Refill ammo (3-second cooldown) | +| `!score` | Top 5 players for this channel | +| `!score clear` | Clear scores for this channel (owner/op) | + +Owner can also use `!score clear all` or `!score clear #channel` from PM. + +### Hunt control (owner / op) +| Command | Description | +|---------|-------------| +| `!hunt` | Show hunt state (on/off/active, idle counter) | +| `!hunt start` | Start the hunt | +| `!hunt stop` | Stop the hunt | +| `!hunt idle <N>` | Set empty-round auto-stop threshold (0 = never); takes effect immediately if threshold is already met | + +### Admin (owner) +| Command | Description | +|---------|-------------| +| `!join #channel` | Join a channel | +| `!part [#channel]` | Leave a channel | +| `!chans` | List channels | +| `!help` | Show usage (PM) | + +## How it works + +- A duck spawns every `spawn_min` to `spawn_max` seconds (random, default 1-5 minutes). +- First player to `!bang` scores a point. Each player has one shell; use `!reload` to reload. +- `!bang` and `!reload` are ignored while the hunt is off. +- If no one shoots within `flee_time` seconds (default 45), the duck flees and the round is marked empty. +- After `idle_rounds` consecutive empty rounds the hunt auto-stops. + +## Config keys + +| Key | Default | Description | +|-----|---------|-------------| +| `[daffy] db_path` | `~/daffy_scores.db` | Path to SQLite scores database | +| `[daffy] idle_rounds` | 3 | Empty rounds before auto-stop (0 = never) | +| `[daffy] spawn_min` | 60 | Minimum seconds between duck spawns | +| `[daffy] spawn_max` | 300 | Maximum seconds between duck spawns | +| `[daffy] flee_time` | 45 | Seconds before an unshot duck flees | diff --git a/docs/daft.md b/docs/daft.md new file mode 100644 index 0000000..f3595ee --- /dev/null +++ b/docs/daft.md @@ -0,0 +1,39 @@ +# daft + +YouTube playlist manager. Anyone can queue tracks; ops control playback. +Tracks are played via an external player (managed outside the bot). + +## Dependencies + +No external libraries required. + +## Commands + +### Queue (anyone in channel) +| Command | Description | +|---------|-------------| +| `!dj <youtube-url>` | Add a YouTube video to the queue | +| `!np` | Show the currently playing track | +| `!queue` | Show upcoming tracks | + +Accepted URL formats: `youtube.com/watch?v=...`, `youtu.be/...`. +Shorts, embeds, and live streams are rejected. +Queue cap: 50 tracks. + +### Playback (owner / op) +| Command | Description | +|---------|-------------| +| `!play` | Start or resume playback | +| `!stop` | Pause playback | +| `!skip` | Skip to the next track | +| `!remove` | Remove the current track from the queue | +| `!loop on\|off` | Toggle circular queue looping | +| `!clear` | Clear queue and stop playback | + +### Admin (owner) +| Command | Description | +|---------|-------------| +| `!join #channel` | Join a channel | +| `!part [#channel]` | Leave a channel | +| `!chans` | List channels | +| `!help` | Show usage (PM) | diff --git a/docs/herald.md b/docs/herald.md new file mode 100644 index 0000000..d651d4f --- /dev/null +++ b/docs/herald.md @@ -0,0 +1,63 @@ +# herald + +Owner-follow bot with a configurable keyword responder. + +Herald tracks the owner's IRC presence and mirrors their channel movements. +It can also respond to keywords in channel messages with random replies. + +## Dependencies + +No external libraries required. + +## Plugins + +| File | Role | +|------|------| +| `commands.py` | Follow logic and admin commands | +| `respond.py` | Keyword-based auto-responder | + +## Commands (owner only) + +| Command | Description | +|---------|-------------| +| `!pause` | Pause keyword responses | +| `!resume` | Resume keyword responses | +| `!follow on` | Start tracking owner's channels (checks every 30s via WHOIS) | +| `!follow off` | Stop tracking | +| `!unfollow` | Immediately leave all follow-joined channels | +| `!join #channel` | Join a channel manually | +| `!part [#channel]` | Leave a channel | +| `!chans` | List channels | +| `!help` | Show usage (PM) | + +## Automatic behaviour + +- When the owner PARTs a channel, herald leaves too (unless it's a preconfigured channel). +- When the owner QUITs, herald leaves all non-preconfigured channels. +- With `!follow on`, herald also joins any channel the owner is in that herald isn't. + +Preconfigured channels are the ones listed under `channels` in the config. + +## Keyword responder (`respond.py`) + +Configure in `[respond]`: + +```ini +[respond] +keywords = + hello + hi there +responses = + Hey! + What's up? +``` + +When any `keyword` appears (case-insensitive) in a channel message, herald +replies with a randomly chosen response. + +## Config keys + +| Key | Description | +|-----|-------------| +| `[respond] keywords` | Newline-separated list of trigger words | +| `[respond] responses` | Newline-separated list of possible replies | diff --git a/docs/jeeves.md b/docs/jeeves.md new file mode 100644 index 0000000..77bd5e7 --- /dev/null +++ b/docs/jeeves.md @@ -0,0 +1,122 @@ +# jeeves + +Combined-features IRC bot. Brings together DJ queue management, duck hunting, +recipes, news and weather, quote bank, image rendering, and keyword responses +in a single bot with a shared runtime-configurable settings store. + +## Dependencies + +- Sopel IRC framework +- Pillow (`pip install Pillow`) - only needed for `!draw`; all other features + work without it + +## Commands + +### DJ (anyone / op for controls) +| Command | Description | +|---------|-------------| +| `!dj <url>` | Queue a YouTube URL (shorts, embeds, live not accepted) | +| `!np` | Show currently playing track | +| `!queue` | List up to 4 upcoming tracks | +| `!loop` | Show loop state | +| `!play` | Start or resume playback (op) | +| `!stop` | Pause playback (op) | +| `!skip` | Skip to next track (op) | +| `!remove` | Remove current track from queue (op) | +| `!loop <on\|off>` | Toggle queue looping (op) | +| `!clear` | Clear queue and stop playback (op) | + +### Duck hunting (anyone in channel / op for control) +| Command | Description | +|---------|-------------| +| `!bang` | Fire at the current duck | +| `!reload` | Refill ammo (3-second cooldown) | +| `!score` | Top 5 players for this channel | +| `!score clear` | Clear scores for this channel (op/owner) | +| `!hunt` | Show hunt state (includes current spawn/flee times) | +| `!hunt start` | Start the hunt (op/owner) | +| `!hunt stop` | Stop the hunt (op/owner) | + +### Recipes (anyone in channel) +| Command | Description | +|---------|-------------| +| `!breakfast [list]` | Random breakfast suggestion, or list all | +| `!lunch [list]` | Random lunch suggestion, or list all | +| `!dinner [list]` | Random dinner suggestion, or list all | +| `!snack [list]` | Random snack suggestion, or list all | +| `!recipe <name>` | Full recipe: name, ingredients, steps | + +### News and weather (anyone) +| Command | Description | +|---------|-------------| +| `!weather <city>` | Current weather conditions | +| `!forecast <city>` | 3-day weather forecast | +| `!headlines [global\|mena\|egypt] [count]` | Latest RSS headlines | +| `!news` | Show auto-broadcast state | +| `!news on\|off` | Enable/disable auto-broadcast (op/owner) | + +### Quotes (anyone / op for management) +| Command | Description | +|---------|-------------| +| `!quote` | Post a random quote | +| `!quote add <text>` | Add a quote (op/owner) | +| `!quote del <N>` | Delete quote by index (op/owner) | +| `!quote list` | Show total quote count | +| `!quotes` | Show auto-post state | +| `!quotes on\|off` | Enable/disable auto-posting (op/owner) | + +### Art (anyone in channel) +| Command | Description | +|---------|-------------| +| `!draw [<lines>] <url>` | Render image as mIRC colour art (lines 4-12) | + +### Config (owner / op) +| Command | Description | +|---------|-------------| +| `!set <key> <value>` | Set a config value (in channel: per-channel; in PM: global, owner only) | +| `!config [#channel]` | Show all config values for a channel | + +### Admin (owner) +| Command | Description | +|---------|-------------| +| `!join #channel` | Join a channel | +| `!part [#channel]` | Leave a channel | +| `!chans` | List joined channels | +| `!pause` | Pause keyword responses | +| `!resume` | Resume keyword responses | + +### Help +| Command | Description | +|---------|-------------| +| `!help` | Topics overview | +| `!help <topic>` | Commands in that topic | +| `!help <cmd>` | Command description | + +## Runtime config keys + +All keys are set via `!set <key> <value>`. Per-channel keys can be set in a +channel (affecting that channel only) or in PM (setting the global default). +Global-only keys must be set in PM by the owner. + +| Key | Default | Scope | Description | +|-----|---------|-------|-------------| +| `dj_track_duration` | 120 | global | Seconds per track before auto-advance | +| `dj_queue_cap` | 50 | global | Max tracks in queue | +| `art_height` | 6 | global | Default lines for `!draw` (4-12) | +| `duck_spawn_min` | 60 | per-channel | Min seconds between duck spawns | +| `duck_spawn_max` | 300 | per-channel | Max seconds between duck spawns | +| `duck_flee_time` | 45 | per-channel | Seconds before duck flees | +| `duck_idle_rounds` | 3 | per-channel | Empty rounds before auto-stop (0=off) | +| `news_interval` | 90 | per-channel | Minutes between news broadcasts | +| `news_count` | 1 | per-channel | Headlines per broadcast cycle | +| `quotes_interval` | 60 | per-channel | Minutes between quote auto-posts | + +## Config file keys + +| Key | Description | +|-----|-------------| +| `[jeeves] db_path` | Path to main JSON database | +| `[jeeves] duck_db_path` | Path to duck scores SQLite database | +| `[jeeves] recipes_path` | Path to recipes JSON file | +| `[respond] keywords` | Newline-separated trigger words for keyword responses | +| `[respond] responses` | Newline-separated response lines (random choice) | diff --git a/docs/osterman.md b/docs/osterman.md new file mode 100644 index 0000000..2e81629 --- /dev/null +++ b/docs/osterman.md @@ -0,0 +1,134 @@ +# osterman + +Channel moderation bot. Manages bans, kicks, mutes, auto-modes, content filters, +and idle tracking. + +## Dependencies + +No external libraries required. + +## Plugins + +| File | Role | +|------|------| +| `protect.py` | Setup, passive protection: flood, caps, repeat, badword, clone, join flood | +| `commands.py` | All user-facing moderation and config commands | +| `tracking.py` | SQLite event log, greeting, `!seen`, `!info`, `!log` | +| `idle.py` | Idle detection tick, `!afk`, `!idle` | +| `help.py` | Three-level help system | + +## Message routing + +| Output type | Delivery | +|-------------|----------| +| `!seen`, `!idle <nick>` results | Channel message | +| `!info`, `!log` results | PM to nick | +| `!ban list`, `!config`, `!filter list`, `!badword list` | PM to nick | +| All other command feedback (errors, confirmations) | IRC NOTICE to nick | +| Greetings, idle warns, bad word warn | IRC NOTICE to nick | + +## Help system + +``` +!help list topics +!help <topic> list commands for a topic +!help <topic> <cmd> description and usage for a command +!help <cmd> description and usage (topic not required) +``` + +Topics: `mod`, `config`, `track`, `idle`, `admin` + +## Commands + +### Moderation (owner / ACL / op) + +| Command | Short | Description | +|---------|-------|-------------| +| `!kick <nick> [reason]` | `!k` | Kick | +| `!ban <nick\|mask> [reason]` | `!b` | Ban + kick | +| `!ban list` | `!b list` | View channel ban list (PM) | +| `!bankick <nick\|mask> [reason]` | `!bk` | Ban + kick (explicit) | +| `!unban <nick\|mask>` | `!ub` | Remove ban | +| `!tempban <nick\|mask> <min> [reason]` | `!tb` | Timed ban (auto-lifts) | +| `!mute <nick\|mask>` | `!mu` | Quiet (+q) | +| `!unmute <nick\|mask>` | `!umu` | Unquiet (-q) | +| `!voice <nick>` | `!v` | Give voice (+v) | +| `!devoice <nick>` | `!dv` | Remove voice | +| `!op <nick>` | `!o` | Give op (+o) | +| `!deop <nick>` | `!do` | Remove op | +| `!halfop <nick>` | `!hop` | Give halfop (+h) | +| `!dehalfop <nick>` | `!dhop` | Remove halfop | +| `!lock [m\|i]` | `!lk` | Lock channel (+mi, +m, or +i) | +| `!unlock [m\|i]` | `!ulk` | Unlock channel | + +### Auto-modes (owner / ACL / op, per channel) + +| Command | Short | Description | +|---------|-------|-------------| +| `!autoop <add\|del\|list> [nick\|mask]` | `!ao` | Auto +o on join | +| `!autovoice <add\|del\|list> [nick\|mask]` | `!av` | Auto +v on join | +| `!autohalfop <add\|del\|list> [nick\|mask]` | `!ahop` | Auto +h on join | + +### Config / lists (owner / ACL) + +| Command | Short | Description | +|---------|-------|-------------| +| `!blacklist <add\|del\|list> [mask]` | `!bl` | Global ban list (all channels) | +| `!whitelist <add\|del\|list> [mask]` | `!wl` | Global bypass list | +| `!except <add\|del\|list> [mask]` | `!ex` | Per-channel bypass list | +| `!acl <add\|del\|list> [nick]` | (none) | Trusted nicks (can use mod commands) | +| `!filter <add\|del\|list> [pattern]` | `!f` | Per-channel regex content filter | +| `!badword <add\|del\|list> [word]` | `!bw` | Per-channel word block list | +| `!set <key> <value>` | `!s` | Set a config value (in channel: per-channel; in PM: global) | +| `!config [#channel]` | `!cfg` | Show channel config (PM) | +| `!idle <on\|off>` | `!id` | Toggle idle detection | + +### Tracking + +| Command | Auth | Description | +|---------|------|-------------| +| `!seen <nick>` | anyone | Last time nick was seen in this channel. Responds in channel. Owner/ACL may call from PM for cross-channel lookup. | +| `!info <nick>` | anyone | Nick summary scoped to this channel (PM). Owner/ACL may call from PM for global lookup. | +| `!idle <nick>` | anyone | How long nick has been idle in this channel. Responds in channel. | +| `!log <nick> [n]` | owner / ACL | Full event log for nick (PM) | +| `!afk` | anyone | Reset your idle timer | + +### Admin (owner) + +| Command | Short | Description | +|---------|-------|-------------| +| `!join <#channel>` | `!j` | Join a channel | +| `!part [#channel]` | `!p` | Leave a channel | +| `!chans` | `!c` | List channels | + +## Configurable settings (`!set <key> <value>`) + +| Key | Default | Description | +|-----|---------|-------------| +| `flood_threshold` | 5 | Messages before flood action | +| `flood_window_sec` | 10 | Flood detection window (seconds) | +| `caps_filter` | 1 | Enable caps filter (0/1) | +| `caps_percent` | 70 | Caps percentage threshold | +| `caps_min_len` | 10 | Minimum message length to check | +| `repeat_filter` | 1 | Enable repeat filter (0/1) | +| `repeat_threshold` | 3 | Repeated messages before action | +| `repeat_window_sec` | 30 | Repeat detection window (seconds) | +| `clone_limit` | 2 | Max connections from same host | +| `join_flood_filter` | 1 | Enable join flood filter (0/1) | +| `join_flood_count` | 5 | Joins before action | +| `join_flood_window` | 10 | Join flood window (seconds) | +| `nick_flood_filter` | 1 | Enable nick flood filter (0/1) | +| `nick_flood_count` | 3 | Nick changes before action | +| `nick_flood_window` | 60 | Nick flood window (seconds) | +| `badword_action` | kick | Action on badword: `kick` or `warn` | +| `tempban_max_min` | 60 | Max duration for `!tempban` (minutes) | +| `idle_warn_min` | 45 | Minutes idle before warning | +| `idle_kick_min` | 60 | Minutes idle before kick | +| `log_limit` | 6 | Default number of events shown by `!log` | +| `greet` | 1 | Send join greeting (0/1) | + +## Config keys + +| Key | Description | +|-----|-------------| +| `[osterman] db_path` | Path to JSON database file | diff --git a/docs/salvador.md b/docs/salvador.md new file mode 100644 index 0000000..c1206b0 --- /dev/null +++ b/docs/salvador.md @@ -0,0 +1,30 @@ +# salvador + +Image-to-IRC art bot. Converts an image URL into mIRC colour art using +half-block characters and Floyd-Steinberg dithering against the 99-colour +mIRC palette. + +## Dependencies + +``` +pip install Pillow +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `!draw <url>` | Render image at default height (6 lines) | +| `!draw <lines> <url>` | Render at custom height (4-12 lines) | + +The image is fetched, resized to fit within 60 chars wide, dithered to the +mIRC palette, and output line by line with a 0.4s flood delay between lines. +Images up to 5 MB are accepted; the Content-Type must be `image/*`. + +### Admin (owner) +| Command | Description | +|---------|-------------| +| `!join #channel` | Join a channel | +| `!part [#channel]` | Leave a channel | +| `!chans` | List channels | +| `!help` | Show usage (PM) | diff --git a/docs/shireen.md b/docs/shireen.md new file mode 100644 index 0000000..71cbaf7 --- /dev/null +++ b/docs/shireen.md @@ -0,0 +1,88 @@ +# shireen + +News and weather bot. Fetches current weather and forecasts from wttr.in, +and streams headlines from configurable RSS feeds. Posts fresh headlines +automatically at a per-channel interval and deduplicates across restarts. + +## Dependencies + +No external libraries required (uses `urllib` and `xml.etree.ElementTree`). + +## Commands + +| Command | Description | +|---------|-------------| +| `!weather <city>` | Current weather conditions | +| `!forecast <city>` | Brief forecast (today + next 2 days) | +| `!headlines [global\|mena\|egypt] [n]` | Fresh headlines, optionally filtered by label or count | +| `!news` | Show auto-broadcast state for this channel | +| `!news on` | Enable auto-broadcast (owner/op) | +| `!news off` | Disable auto-broadcast (owner/op) | +| `!news interval <min>` | Set broadcast interval in minutes (owner/op) | +| `!news count <n>` | Set headlines per broadcast cycle (owner/op) | + +### Admin (owner) +| Command | Description | +|---------|-------------| +| `!join #channel` | Join a channel | +| `!part [#channel]` | Leave a channel | +| `!chans` | List channels | +| `!help` | Show usage (PM) | + +## Auto-broadcast + +Every 90 minutes (default, per channel) shireen posts 1 headline (default) it +hasn't posted before. GUIDs are persisted to the data JSON so the same article +is never repeated across restarts. The cap is 1000 seen GUIDs (oldest pruned). +Interval and count are configurable per channel with `!news interval` and `!news count`. + +## URL cleaning + +All query strings and fragments are stripped from feed links before posting. +News article URLs are fully identified by their path; query parameters from +feeds are always tracking noise. + +## Default RSS feeds + +| ID | Name | Label | +|----|------|-------| +| `bbc_world` | BBC World | global | +| `reuters_world` | Reuters World | global | +| `ap_world` | AP News | global | +| `guardian_world` | The Guardian | global | +| `arab_news` | Arab News | mena | +| `aljazeera` | Al Jazeera | mena | +| `al_monitor` | Al-Monitor | mena | +| `egypt_indep` | Egypt Independent | egypt | +| `daily_news_egypt` | Daily News Egypt | egypt | + +Feeds can be modified directly in the data JSON (`feeds` array) while the bot +is stopped. Each feed has: `id`, `name`, `url`, `label`, `enabled`. + +## Data file format + +Created automatically at `data_path` on first run: + +```json +{ + "channels": { + "#example": { + "news_enabled": true, + "broadcast_interval": 90, + "broadcast_count": 1, + "last_broadcast": 0 + } + }, + "feeds": [...], + "seen": ["guid1", "guid2"] +} +``` + +## Config keys + +| Key | Default | Description | +|-----|---------|-------------| +| `[shireen] data_path` | `/var/shireen/data.json` | Path to state/config JSON (auto-created) | +| `[shireen] broadcast_interval` | `90` | Default minutes between auto-broadcast cycles | +| `[shireen] broadcast_count` | `1` | Default headlines posted per broadcast cycle | +| `[shireen] headlines_count` | `1` | Default headlines returned by `!headlines` | diff --git a/fonts/KawkabMono-Bold.woff2 b/fonts/KawkabMono-Bold.woff2 Binary files differnew file mode 100644 index 0000000..f8f61f7 --- /dev/null +++ b/fonts/KawkabMono-Bold.woff2 diff --git a/fonts/KawkabMono-Regular.woff2 b/fonts/KawkabMono-Regular.woff2 Binary files differnew file mode 100644 index 0000000..92d4d15 --- /dev/null +++ b/fonts/KawkabMono-Regular.woff2 diff --git a/herald/__init__.py b/herald/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/herald/__init__.py diff --git a/herald/commands.py b/herald/commands.py new file mode 100644 index 0000000..9930902 --- /dev/null +++ b/herald/commands.py @@ -0,0 +1,220 @@ +""" +Sopel plugin: herald/bikabot admin commands + +Owner-only commands for managing the bot at runtime. + + !pause - pause keyword responses + !resume - resume keyword responses + !join #channel - join a channel + !part [#channel] - leave a channel (defaults to current) + !follow <on|off> - periodically join/leave channels to match owner's presence + !unfollow - leave all follow-joined channels immediately + !chans - list channels the bot is in + !help - show usage + +Automatic behaviour (always active, no commands needed): + - When the owner PARTs a channel, the bot leaves too (unless it's a preconfigured channel). + - When the owner QUITs, the bot leaves all channels except preconfigured ones. +""" + +from sopel import plugin +from sopel.tools import events + + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _configured_channels(bot): + """Return a set of lowercased channel names from the bot's static config.""" + chans = bot.settings.core.channels or [] + return {c.split()[0].lower() for c in chans} + + +def setup(bot): + bot.memory.setdefault("follow_enabled", False) + bot.memory.setdefault("follow_joined", set()) + bot.memory.setdefault("whois_got_channels", False) + bot.memory.setdefault("herald_paused", False) + + +# --- Owner presence tracking --- + +@plugin.event("PART") +def on_owner_part(bot, trigger): + """Leave any channel the owner leaves, unless it's a preconfigured channel.""" + if not _is_owner(bot, trigger): + return + channel = str(trigger.sender) + if channel.lower() in _configured_channels(bot): + return + if channel in {str(c) for c in bot.channels}: + bot.part(channel) + bot.memory.get("follow_joined", set()).discard(channel.lower()) + + +@plugin.event("QUIT") +def on_owner_quit(bot, trigger): + """Leave all non-preconfigured channels when the owner disconnects.""" + if not _is_owner(bot, trigger): + return + configured = _configured_channels(bot) + for chan in list(bot.channels): + if str(chan).lower() not in configured: + bot.part(chan) + bot.memory["follow_joined"] = set() + + +# --- Manual commands --- + +@plugin.command("pause") +def cmd_pause(bot, trigger): + if not _is_owner(bot, trigger): + return + bot.memory["herald_paused"] = True + bot.say("herald paused - keyword responses inactive") + + +@plugin.command("resume") +def cmd_resume(bot, trigger): + if not _is_owner(bot, trigger): + return + bot.memory["herald_paused"] = False + bot.say("herald resumed - keyword responses active") + + +@plugin.command("join") +def cmd_join(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() + if not channel.startswith("#"): + bot.say("usage: !join #channel") + return + bot.join(channel) + + +@plugin.command("part") +def cmd_part(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() + if not channel: + channel = trigger.sender + bot.part(channel) + + +@plugin.command("follow") +def cmd_follow(bot, trigger): + if not _is_owner(bot, trigger): + return + arg = (trigger.group(2) or "").strip().lower() + if arg == "on": + bot.memory["follow_enabled"] = True + bot.say("follow on - tracking your channels every 30s") + elif arg == "off": + bot.memory["follow_enabled"] = False + bot.say("follow off") + else: + state = "on" if bot.memory.get("follow_enabled") else "off" + bot.say(f"follow is {state} - usage: !follow <on|off>") + + +@plugin.command("unfollow") +def cmd_unfollow(bot, trigger): + if not _is_owner(bot, trigger): + return + joined = set(bot.memory.get("follow_joined", set())) + if not joined: + bot.say("not in any follow-joined channels") + return + for chan in joined: + bot.part(chan) + bot.memory["follow_joined"] = set() + bot.say(f"left {len(joined)} channel(s)") + + +@plugin.command("chans") +def cmd_chans(bot, trigger): + if not _is_owner(bot, trigger): + return + chans = sorted(str(c) for c in bot.channels) + if chans: + bot.say("in: " + " ".join(chans)) + else: + bot.say("not in any channels") + + +@plugin.command("help") +def cmd_help(bot, trigger): + if not _is_owner(bot, trigger): + return + for line in [ + "!pause - pause keyword responses", + "!resume - resume keyword responses", + "!join #channel - join a channel", + "!part [#channel] - leave a channel (defaults to current)", + "!follow <on|off> - follow owner: join channels to match owner's presence (checks every 30s)", + "!unfollow - immediately leave all follow-joined channels", + "!chans - list channels the bot is currently in", + "!help - this message", + ]: + bot.say(line, trigger.nick) + + +# --- Follow tick (joining only) --- + +@plugin.interval(30) +def _follow_tick(bot): + if not bot.memory.get("follow_enabled"): + return + bot.memory["whois_got_channels"] = False + owner = bot.settings.core.owner + bot.write(("WHOIS", owner)) + + +@plugin.rule(r"(.*)") +@plugin.event(events.RPL_WHOISCHANNELS) # 319 +@plugin.priority("high") +def _whois_channels(bot, trigger): + if not bot.memory.get("follow_enabled"): + return + owner = bot.settings.core.owner + if len(trigger.args) < 2 or trigger.args[1] != owner: + return + + bot.memory["whois_got_channels"] = True + raw = (trigger.group(1) or "").strip() + owner_chans = set() + for part in raw.split(): + chan = part.lstrip("@+~&%") + if chan.startswith("#"): + owner_chans.add(chan.lower()) + + current_chans = {str(c).lower() for c in bot.channels} + follow_joined = bot.memory.get("follow_joined", set()) + + for chan in owner_chans: + if chan not in current_chans: + bot.join(chan) + follow_joined.add(chan) + + bot.memory["follow_joined"] = follow_joined + + +@plugin.rule(r"(.*)") +@plugin.event(events.RPL_ENDOFWHOIS) # 318 +@plugin.priority("high") +def _whois_end(bot, trigger): + if not bot.memory.get("follow_enabled"): + return + owner = bot.settings.core.owner + if len(trigger.args) < 2 or trigger.args[1] != owner: + return + if bot.memory.get("whois_got_channels"): + return + # owner is offline, no 319 received; leave all follow-joined channels + follow_joined = set(bot.memory.get("follow_joined", set())) + for chan in follow_joined: + bot.part(chan) + bot.memory["follow_joined"] = set() diff --git a/herald/herald.cfg b/herald/herald.cfg new file mode 100644 index 0000000..d99f9b4 --- /dev/null +++ b/herald/herald.cfg @@ -0,0 +1,23 @@ +[core] +nick = herald +user = herald +name = herald +host = 127.0.0.1 +port = 6667 +owner = yournick +password = REPLACEME +prefix = ! +channels = #yourchannel +extra = /path/to/irc-bots/herald +enable = + respond + commands +logdir = /var/log/herald +logging_level = INFO +timeout = 120 + +[respond] +keywords = + REPLACEME +responses = + REPLACEME diff --git a/herald/respond.py b/herald/respond.py new file mode 100644 index 0000000..066ea81 --- /dev/null +++ b/herald/respond.py @@ -0,0 +1,49 @@ +""" +Sopel plugin: keyword responder + +Responds to any channel message containing one of the configured keywords +with a randomly chosen response line. Matching is case-insensitive. +Can be paused/resumed by the owner via !pause / !resume. + + [respond] + keywords = + word1 + word2 + responses = + reply one + reply two +""" + +import random + +from sopel import plugin +from sopel.config.types import ListAttribute, StaticSection + + +class RespondSection(StaticSection): + keywords = ListAttribute("keywords") + responses = ListAttribute("responses") + + +def setup(bot): + bot.settings.define_section("respond", RespondSection) + + +@plugin.rule(r"(?s:.*)") +@plugin.require_chanmsg +def respond(bot, trigger): + if bot.memory.get("herald_paused"): + return + + keywords = bot.settings.respond.keywords + responses = bot.settings.respond.responses + + if not keywords or not responses: + return + + try: + msg = trigger.group(0).casefold() + if any(kw.casefold() in msg for kw in keywords): + bot.say(random.choice(responses)) + except (UnicodeError, AttributeError): + pass diff --git a/jeeves/__init__.py b/jeeves/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/jeeves/__init__.py diff --git a/jeeves/admin.py b/jeeves/admin.py new file mode 100644 index 0000000..8738025 --- /dev/null +++ b/jeeves/admin.py @@ -0,0 +1,185 @@ +""" +Sopel plugin: jeeves admin + +Owner commands for bot management and follow mode. + + !join #channel - join a channel + !part [#channel] - leave a channel (defaults to current) + !chans - list joined channels + !follow <on|off> - follow owner into channels + !unfollow - leave all follow-joined channels immediately + !pause - pause keyword responses + !resume - resume keyword responses +""" + +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 + +from sopel import plugin +from sopel.tools import events + + +def setup(bot): + jv.ensure_setup(bot) + bot.memory.setdefault("jv_follow_enabled", False) + bot.memory.setdefault("jv_follow_joined", set()) + bot.memory.setdefault("jv_whois_got_channels", False) + bot.memory.setdefault("jv_paused", False) + + +def _configured_channels(bot): + chans = bot.settings.core.channels or [] + return {c.split()[0].lower() for c in chans} + + +@plugin.event("PART") +def on_owner_part(bot, trigger): + if not jv.is_owner(bot, trigger): + return + channel = str(trigger.sender) + if channel.lower() in _configured_channels(bot): + return + if channel in {str(c) for c in bot.channels}: + bot.part(channel) + bot.memory.get("jv_follow_joined", set()).discard(channel.lower()) + + +@plugin.event("QUIT") +def on_owner_quit(bot, trigger): + if not jv.is_owner(bot, trigger): + return + configured = _configured_channels(bot) + for chan in list(bot.channels): + if str(chan).lower() not in configured: + bot.part(chan) + bot.memory["jv_follow_joined"] = set() + + +@plugin.command("join") +def cmd_join(bot, trigger): + if not jv.is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() + if not channel.startswith("#"): + bot.notice("usage: !join #channel", trigger.nick) + return + bot.join(channel) + + +@plugin.command("part") +def cmd_part(bot, trigger): + if not jv.is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() or str(trigger.sender) + bot.part(channel) + + +@plugin.command("chans") +def cmd_chans(bot, trigger): + if not jv.is_owner(bot, trigger): + return + chans = sorted(str(c) for c in bot.channels) + bot.notice(("in: " + " ".join(chans)) if chans else "not in any channels", trigger.nick) + + +@plugin.command("follow") +def cmd_follow(bot, trigger): + if not jv.is_owner(bot, trigger): + return + arg = (trigger.group(2) or "").strip().lower() + if arg == "on": + bot.memory["jv_follow_enabled"] = True + bot.notice("follow on - tracking your channels every 30s", trigger.nick) + elif arg == "off": + bot.memory["jv_follow_enabled"] = False + bot.notice("follow off", trigger.nick) + else: + state = "on" if bot.memory.get("jv_follow_enabled") else "off" + bot.notice(f"follow is {state} — usage: !follow <on|off>", trigger.nick) + + +@plugin.command("unfollow") +def cmd_unfollow(bot, trigger): + if not jv.is_owner(bot, trigger): + return + joined = set(bot.memory.get("jv_follow_joined", set())) + if not joined: + bot.notice("not in any follow-joined channels", trigger.nick) + return + for chan in joined: + bot.part(chan) + bot.memory["jv_follow_joined"] = set() + bot.notice(f"left {len(joined)} channel(s)", trigger.nick) + + +@plugin.command("pause") +def cmd_pause(bot, trigger): + if not jv.is_owner(bot, trigger): + return + bot.memory["jv_paused"] = True + bot.notice("keyword responses paused", trigger.nick) + + +@plugin.command("resume") +def cmd_resume(bot, trigger): + if not jv.is_owner(bot, trigger): + return + bot.memory["jv_paused"] = False + bot.notice("keyword responses resumed", trigger.nick) + + +@plugin.interval(30) +def _follow_tick(bot): + if not bot.memory.get("jv_follow_enabled"): + return + bot.memory["jv_whois_got_channels"] = False + bot.write(("WHOIS", bot.settings.core.owner)) + + +@plugin.rule(r"(.*)") +@plugin.event(events.RPL_WHOISCHANNELS) +@plugin.priority("high") +def _whois_channels(bot, trigger): + if not bot.memory.get("jv_follow_enabled"): + return + owner = bot.settings.core.owner + if len(trigger.args) < 2 or trigger.args[1] != owner: + return + + bot.memory["jv_whois_got_channels"] = True + raw = (trigger.group(1) or "").strip() + owner_chans = set() + for part in raw.split(): + chan = part.lstrip("@+~&%") + if chan.startswith("#"): + owner_chans.add(chan.lower()) + + current_chans = {str(c).lower() for c in bot.channels} + follow_joined = bot.memory.get("jv_follow_joined", set()) + + for chan in owner_chans: + if chan not in current_chans: + bot.join(chan) + follow_joined.add(chan) + + bot.memory["jv_follow_joined"] = follow_joined + + +@plugin.rule(r"(.*)") +@plugin.event(events.RPL_ENDOFWHOIS) +@plugin.priority("high") +def _whois_end(bot, trigger): + if not bot.memory.get("jv_follow_enabled"): + return + owner = bot.settings.core.owner + if len(trigger.args) < 2 or trigger.args[1] != owner: + return + if bot.memory.get("jv_whois_got_channels"): + return + follow_joined = set(bot.memory.get("jv_follow_joined", set())) + for chan in follow_joined: + bot.part(chan) + bot.memory["jv_follow_joined"] = set() diff --git a/jeeves/art.py b/jeeves/art.py new file mode 100644 index 0000000..274bac5 --- /dev/null +++ b/jeeves/art.py @@ -0,0 +1,188 @@ +""" +Sopel plugin: jeeves art + +Converts an image URL to mIRC colour art using half-block characters. + + !draw [<lines>] <url> - render image (lines: 4-12, default from !set art_height) + +Default height is configurable via !set art_height <N>. +Requires Pillow (pip install Pillow). Gracefully disabled if not installed. +""" + +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 + +import time +import urllib.request + +from sopel import plugin + +try: + from io import BytesIO + from PIL import Image, ImageFile + ImageFile.LOAD_TRUNCATED_IMAGES = True + _PILLOW = True +except ImportError: + _PILLOW = False + + +MAX_WIDTH = 60 +MIN_HEIGHT = 4 +MAX_HEIGHT = 12 +FLOOD_DELAY = 0.4 +MAX_BYTES = 5 * 1024 * 1024 + +# IRC monospace character cells are approximately twice as tall as they are wide. +# _fit multiplies the image aspect ratio by this factor so the rendered output +# uses enough character columns to compensate for the tall cells. +CHAR_RATIO = 2.0 + + +MIRC = [ + (0xff,0xff,0xff),(0x00,0x00,0x00),(0x00,0x00,0x7f),(0x00,0x93,0x00), + (0xff,0x00,0x00),(0x7f,0x00,0x00),(0x9c,0x00,0x9c),(0xfc,0x7f,0x00), + (0xff,0xff,0x00),(0x00,0xfc,0x00),(0x00,0x93,0x93),(0x00,0xff,0xff), + (0x00,0x00,0xfc),(0xff,0x00,0xff),(0x55,0x55,0x55),(0xaa,0xaa,0xaa), + (0x47,0x00,0x00),(0x47,0x21,0x00),(0x47,0x47,0x00),(0x32,0x47,0x00), + (0x00,0x47,0x00),(0x00,0x47,0x2c),(0x00,0x47,0x47),(0x00,0x27,0x47), + (0x00,0x00,0x47),(0x2e,0x00,0x47),(0x47,0x00,0x47),(0x47,0x00,0x2a), + (0x74,0x00,0x00),(0x74,0x3a,0x00),(0x74,0x74,0x00),(0x51,0x74,0x00), + (0x00,0x74,0x00),(0x00,0x74,0x49),(0x00,0x74,0x74),(0x00,0x40,0x74), + (0x00,0x00,0x74),(0x4b,0x00,0x74),(0x74,0x00,0x74),(0x74,0x00,0x45), + (0xb5,0x00,0x00),(0xb5,0x63,0x00),(0xb5,0xb5,0x00),(0x7d,0xb5,0x00), + (0x00,0xb5,0x00),(0x00,0xb5,0x71),(0x00,0xb5,0xb5),(0x00,0x63,0xb5), + (0x00,0x00,0xb5),(0x75,0x00,0xb5),(0xb5,0x00,0xb5),(0xb5,0x00,0x6b), + (0xff,0x00,0x00),(0xff,0x8c,0x00),(0xff,0xff,0x00),(0xb2,0xff,0x00), + (0x00,0xff,0x00),(0x00,0xff,0xa0),(0x00,0xff,0xff),(0x00,0x8c,0xff), + (0x00,0x00,0xff),(0xa5,0x00,0xff),(0xff,0x00,0xff),(0xff,0x00,0x98), + (0xff,0x59,0x59),(0xff,0xb4,0x59),(0xff,0xff,0x71),(0xcf,0xff,0x60), + (0x6f,0xff,0x6f),(0x65,0xff,0xc9),(0x6d,0xff,0xff),(0x59,0xb4,0xff), + (0x59,0x59,0xff),(0xc4,0x59,0xff),(0xff,0x66,0xff),(0xff,0x59,0xbc), + (0xff,0x9c,0x9c),(0xff,0xd3,0x9c),(0xff,0xff,0x9c),(0xe2,0xff,0x9c), + (0x9c,0xff,0x9c),(0x9c,0xff,0xdb),(0x9c,0xff,0xff),(0x9c,0xd3,0xff), + (0x9c,0x9c,0xff),(0xdc,0x9c,0xff),(0xff,0x9c,0xff),(0xff,0x94,0xd3), + (0x00,0x00,0x00),(0x13,0x13,0x13),(0x28,0x28,0x28),(0x36,0x36,0x36), + (0x4d,0x4d,0x4d),(0x65,0x65,0x65),(0x81,0x81,0x81),(0x9f,0x9f,0x9f), + (0xbc,0xbc,0xbc),(0xe2,0xe2,0xe2),(0xff,0xff,0xff), +] + + +def setup(bot): + jv.ensure_setup(bot) + + +def _nearest(r, g, b): + rmean = (r + 128) / 2 + best, best_dist = 0, float("inf") + for i, (cr, cg, cb) in enumerate(MIRC): + dr, dg, db = r - cr, g - cg, b - cb + dist = (2 + rmean / 256) * dr*dr + 4*dg*dg + (2 + (255 - rmean) / 256) * db*db + if dist < best_dist: + best_dist = dist + best = i + return best + + +def _fetch(url): + req = urllib.request.Request(url, headers={"User-Agent": "jeeves/1.0"}) + with urllib.request.urlopen(req, timeout=10) as resp: + content_type = resp.headers.get("Content-Type", "") + if "image" not in content_type: + raise ValueError(f"not an image ({content_type})") + return resp.read(MAX_BYTES) + + +def _fit(img_w, img_h, max_w, max_h): + aspect = (img_w / img_h) * CHAR_RATIO + if aspect >= max_w / max_h: + w = max_w + h = max(1, round(max_w / aspect)) + else: + h = max_h + w = max(1, round(max_h * aspect)) + return w, h + + +def _dither(img, w, h): + buf = [[list(map(float, img.getpixel((x, y)))) for x in range(w)] for y in range(h)] + idx = [[0] * w for _ in range(h)] + for y in range(h): + for x in range(w): + r, g, b = (max(0, min(255, int(v))) for v in buf[y][x]) + c = _nearest(r, g, b) + idx[y][x] = c + cr, cg, cb = MIRC[c] + er, eg, eb = r - cr, g - cg, b - cb + for dx, dy, f in ((1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)): + nx, ny = x + dx, y + dy + if 0 <= nx < w and 0 <= ny < h: + buf[ny][nx][0] += er * f / 16 + buf[ny][nx][1] += eg * f / 16 + buf[ny][nx][2] += eb * f / 16 + return idx + + +def _img_to_irc(url, height): + data = _fetch(url) + img = Image.open(BytesIO(data)).convert("RGB") + char_w, char_h = _fit(img.width, img.height, MAX_WIDTH, height) + img = img.resize((char_w, char_h * 2), Image.LANCZOS) + idx = _dither(img, char_w, char_h * 2) + lines = [] + for row in range(char_h): + line = "" + last_fg = last_bg = None + for col in range(char_w): + bg = idx[row * 2][col] + fg = idx[row * 2 + 1][col] + if fg != last_fg or bg != last_bg: + line += f"\x03{fg:02d},{bg:02d}" + last_fg, last_bg = fg, bg + line += "▄" + line += "\x0f" + lines.append(line) + return lines + + +@plugin.command("draw") +@plugin.require_chanmsg +def cmd_draw(bot, trigger): + if not _PILLOW: + bot.notice("!draw requires Pillow (pip install Pillow) — not installed", trigger.nick) + return + + args = (trigger.group(2) or "").split(None, 1) + default_height = jv.cfg_val(bot, "art_height") + + if not args: + bot.notice(f"usage: !draw [<lines>] <url> (lines: {MIN_HEIGHT}-{MAX_HEIGHT}, default {default_height})", trigger.nick) + return + + height = default_height + if len(args) == 2 and args[0].isdigit(): + n = int(args[0]) + if not (MIN_HEIGHT <= n <= MAX_HEIGHT): + bot.notice(f"lines must be between {MIN_HEIGHT} and {MAX_HEIGHT}", trigger.nick) + return + height = n + url = args[1] + else: + url = args[0] + + url = url.rstrip(".,)>") + if not url.startswith(("http://", "https://")): + bot.notice(f"usage: !draw [<lines>] <url> (lines: {MIN_HEIGHT}-{MAX_HEIGHT}, default {default_height})", trigger.nick) + return + + try: + lines = _img_to_irc(url, height=height) + except Exception as e: + bot.say(str(e), trigger.sender) + return + + for line in lines: + bot.say(line, trigger.sender) + time.sleep(FLOOD_DELAY) diff --git a/jeeves/config.py b/jeeves/config.py new file mode 100644 index 0000000..58d6484 --- /dev/null +++ b/jeeves/config.py @@ -0,0 +1,98 @@ +""" +Sopel plugin: jeeves config + + !set <key> <value> - set a config value (in channel: per-channel; in PM: global) + !config [#channel] - show config for a channel +""" + +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 + +from sopel import plugin + + +def setup(bot): + jv.ensure_setup(bot) + + +@plugin.command("set", "s") +def cmd_set(bot, trigger): + if not jv.is_owner(bot, trigger) and not jv.is_op(bot, trigger): + return + + args = (trigger.group(2) or "").split() + in_chan = str(trigger.sender).startswith("#") + channel = str(trigger.sender) if in_chan else None + + if len(args) < 2: + keys = ", ".join(sorted(jv.CONFIG_KEYS)) + bot.notice(f"usage: !set <key> <value> — keys: {keys}", trigger.nick) + return + + key, raw = args[0].lower(), args[1] + + if key not in jv.CONFIG_KEYS: + bot.notice(f"unknown key '{key}' — use !config to see valid keys", trigger.nick) + return + + typ, _, global_only, _ = jv.CONFIG_KEYS[key] + + if global_only and in_chan: + bot.notice(f"{key} is a global setting — use in PM to change it", trigger.nick) + return + + if global_only and not jv.is_owner(bot, trigger): + return + + try: + value = typ(raw) + except ValueError: + bot.notice(f"invalid value for {key}: expected {typ.__name__}", trigger.nick) + return + + with bot.memory["jv_lock"]: + db = bot.memory["jv_db"] + if in_chan: + jv.chan_db(bot, channel).setdefault("config", {})[key] = value + bot.notice(f"set {key} = {value} for {channel}", trigger.nick) + else: + db.setdefault("config", {})[key] = value + bot.notice(f"set global {key} = {value}", trigger.nick) + jv.save(bot) + + +@plugin.command("config", "cfg") +def cmd_config(bot, trigger): + if not jv.is_owner(bot, trigger) and not jv.is_op(bot, trigger): + return + + in_chan = str(trigger.sender).startswith("#") + arg = (trigger.group(2) or "").strip() + + if arg.startswith("#"): + channel = arg + elif in_chan: + channel = str(trigger.sender) + else: + bot.notice("usage: !config [#channel]", trigger.nick) + return + + db = bot.memory.get("jv_db", {}) + global_cfg = db.get("config", {}) + chan_cfg = db.get("channels", {}).get(channel.lower(), {}).get("config", {}) + + bot.say(f"config for {channel}:", trigger.nick) + for key in sorted(jv.CONFIG_KEYS): + _, default, global_only, desc = jv.CONFIG_KEYS[key] + g_val = global_cfg.get(key) + c_val = chan_cfg.get(key) + scope = "global-only" if global_only else "per-channel" + if c_val is not None: + bot.say(f"{key} = {c_val} (channel; global: {g_val if g_val is not None else default}) [{scope}]", trigger.nick) + elif g_val is not None: + bot.say(f"{key} = {g_val} (global; default: {default}) [{scope}]", trigger.nick) + else: + bot.say(f"{key} = {default} (default) [{scope}]", trigger.nick) diff --git a/jeeves/dj.py b/jeeves/dj.py new file mode 100644 index 0000000..c270c64 --- /dev/null +++ b/jeeves/dj.py @@ -0,0 +1,275 @@ +""" +Sopel plugin: jeeves dj + +Playlist manager with admin playback controls. + + !dj <url> - queue a YouTube track (anyone) + !np - show current track (anyone) + !queue - show upcoming tracks (anyone) + !loop - show loop state (anyone) + !play - start/resume playback (op) + !stop - pause playback (op) + !skip - skip to next track (op) + !remove - remove current song from queue (op) + !loop <on|off> - toggle queue looping (op) + !clear - clear queue and stop playback (op) + +Track duration and queue cap are configurable via !set. +""" +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 + +import re +import threading +from urllib.parse import urlparse, parse_qs + +from sopel import plugin + + +_YOUTUBE_HOSTS = frozenset({ + "youtube.com", "www.youtube.com", "m.youtube.com", + "music.youtube.com", "youtu.be", +}) +_REJECT_PATHS = ("/shorts/", "/embed/", "/live/") +_VIDEO_ID_RE = re.compile(r'^[A-Za-z0-9_-]{11}$') + + +def setup(bot): + jv.ensure_setup(bot) + bot.memory.setdefault("dj_queue", []) + bot.memory.setdefault("dj_pos", -1) + bot.memory.setdefault("dj_playing", False) + bot.memory.setdefault("dj_timer", None) + bot.memory.setdefault("dj_loop", False) + bot.memory.setdefault("dj_channel", None) + bot.memory.setdefault("dj_lock", threading.RLock()) + + +def _extract_video_id(url): + if "://" not in url: + url = "https://" + url + try: + p = urlparse(url) + except Exception: + return None + if p.scheme not in ("http", "https"): + return None + if p.netloc.lower() not in _YOUTUBE_HOSTS: + return None + for bad in _REJECT_PATHS: + if p.path.startswith(bad): + return None + if p.netloc.lower() == "youtu.be": + vid = p.path.strip("/") + return vid if _VIDEO_ID_RE.match(vid) else None + qs = parse_qs(p.query) + vid = qs.get("v", [None])[0] + return vid if vid and _VIDEO_ID_RE.match(vid) else None + + +def _normalize(video_id): + return f"https://youtu.be/{video_id}" + + +def _cancel_timer(bot): + t = bot.memory.get("dj_timer") + if t: + t.cancel() + bot.memory["dj_timer"] = None + + +def _play_at(bot, sender, pos): + queue = bot.memory["dj_queue"] + duration = jv.cfg_val(bot, "dj_track_duration") + bot.memory["dj_pos"] = pos + bot.memory["dj_channel"] = str(sender) + bot.say(f"now playing: {queue[pos]}", sender) + t = threading.Timer(duration, _on_track_end, args=(bot, sender)) + t.daemon = True + t.start() + bot.memory["dj_timer"] = t + + +def _on_track_end(bot, sender): + with bot.memory["dj_lock"]: + if not bot.memory["dj_playing"]: + return + queue = bot.memory["dj_queue"] + pos = bot.memory["dj_pos"] + next_pos = pos + 1 + if next_pos >= len(queue): + if bot.memory["dj_loop"] and len(queue) > 0: + next_pos = 0 + else: + bot.say("queue finished, playback stopped", sender) + bot.memory["dj_playing"] = False + bot.memory["dj_pos"] = -1 + bot.memory["dj_timer"] = None + return + _play_at(bot, sender, next_pos) + + +@plugin.command("dj") +def cmd_dj(bot, trigger): + url = (trigger.group(2) or "").strip() + if not url: + bot.notice("usage: !dj <youtube url>", trigger.nick) + return + vid = _extract_video_id(url) + if not vid: + bot.notice("invalid YouTube URL (shorts, embeds, and live links are not accepted)", trigger.nick) + return + normalized = _normalize(vid) + cap = jv.cfg_val(bot, "dj_queue_cap") + with bot.memory["dj_lock"]: + queue = bot.memory["dj_queue"] + if normalized in queue: + return + if len(queue) >= cap: + bot.notice(f"queue is full ({cap} tracks max)", trigger.nick) + return + queue.append(normalized) + pos = len(queue) + bot.say(f"queued at position {pos}", trigger.sender) + + +@plugin.command("np") +def cmd_np(bot, trigger): + with bot.memory["dj_lock"]: + queue = bot.memory["dj_queue"] + pos = bot.memory["dj_pos"] + if bot.memory["dj_playing"] and 0 <= pos < len(queue): + bot.say(f"now playing: {queue[pos]}", trigger.sender) + else: + bot.say("nothing playing", trigger.sender) + + +@plugin.command("queue", "q") +def cmd_queue(bot, trigger): + with bot.memory["dj_lock"]: + queue = list(bot.memory["dj_queue"]) + pos = bot.memory["dj_pos"] + loop = bot.memory["dj_loop"] + + if not queue: + bot.say("queue is empty", trigger.sender) + return + + start = pos + 1 if pos >= 0 else 0 + + if loop: + n = len(queue) + upcoming = [] + for i in range(min(4, n - (1 if pos >= 0 else 0))): + idx = (start + i) % n + if idx == pos: + break + upcoming.append(queue[idx]) + label = f"{n} in queue (looping)" + else: + upcoming = queue[start:start + 4] + remaining = max(0, len(queue) - start) + label = f"{remaining} remaining" + + if not upcoming: + bot.say("no upcoming tracks in queue", trigger.sender) + return + + bot.say(f"up next ({label}):", trigger.sender) + for i, song in enumerate(upcoming, 1): + bot.say(f"{i}. {song}", trigger.sender) + if not loop and remaining > 4: + bot.say(f"... and {remaining - 4} more", trigger.sender) + + +@plugin.command("play") +def cmd_play(bot, trigger): + if not jv.is_authorized(bot, trigger): + return + with bot.memory["dj_lock"]: + if bot.memory["dj_playing"]: + bot.say("already playing", trigger.sender) + return + queue = bot.memory["dj_queue"] + if not queue: + bot.say("queue is empty", trigger.sender) + return + pos = bot.memory["dj_pos"] + start = pos if 0 <= pos < len(queue) else 0 + bot.memory["dj_playing"] = True + _play_at(bot, trigger.sender, start) + + +@plugin.command("stop") +def cmd_stop(bot, trigger): + if not jv.is_authorized(bot, trigger): + return + with bot.memory["dj_lock"]: + if not bot.memory["dj_playing"]: + bot.say("already stopped", trigger.sender) + return + bot.memory["dj_playing"] = False + _cancel_timer(bot) + bot.say("playback stopped", trigger.sender) + + +@plugin.command("skip") +def cmd_skip(bot, trigger): + if not jv.is_authorized(bot, trigger): + return + with bot.memory["dj_lock"]: + if not bot.memory["dj_playing"]: + bot.say("nothing playing", trigger.sender) + return + _cancel_timer(bot) + _on_track_end(bot, trigger.sender) + + +@plugin.command("remove") +def cmd_remove(bot, trigger): + if not jv.is_authorized(bot, trigger): + return + with bot.memory["dj_lock"]: + queue = bot.memory["dj_queue"] + pos = bot.memory["dj_pos"] + if not queue or pos < 0 or pos >= len(queue): + bot.say("nothing to remove", trigger.sender) + return + removed = queue.pop(pos) + bot.say(f"removed: {removed}", trigger.sender) + if bot.memory["dj_playing"]: + bot.memory["dj_pos"] = pos - 1 + _cancel_timer(bot) + _on_track_end(bot, bot.memory.get("dj_channel") or str(trigger.sender)) + else: + bot.memory["dj_pos"] = max(-1, pos - 1) + + +@plugin.command("loop") +def cmd_loop(bot, trigger): + arg = (trigger.group(2) or "").strip().lower() + if arg in ("on", "off"): + if not jv.is_authorized(bot, trigger): + return + bot.memory["dj_loop"] = (arg == "on") + bot.say(f"loop {arg}", trigger.sender) + else: + state = "on" if bot.memory.get("dj_loop") else "off" + bot.say(f"loop is {state} — use !loop <on|off> to change (op)", trigger.sender) + + +@plugin.command("clear") +def cmd_clear(bot, trigger): + if not jv.is_authorized(bot, trigger): + return + with bot.memory["dj_lock"]: + bot.memory["dj_playing"] = False + _cancel_timer(bot) + bot.memory["dj_queue"].clear() + bot.memory["dj_pos"] = -1 + bot.memory["dj_loop"] = False + bot.memory["dj_channel"] = None + bot.say("queue cleared, playback stopped", trigger.sender) diff --git a/jeeves/duck.py b/jeeves/duck.py new file mode 100644 index 0000000..be998b1 --- /dev/null +++ b/jeeves/duck.py @@ -0,0 +1,314 @@ +""" +Sopel plugin: jeeves duck hunting game + +Spawns ducks at random intervals, tracks ammo in memory, stores scores in SQLite. +Spawn times, flee timeout, and idle threshold are runtime-configurable via !set. + + !bang - fire at the current duck + !reload - refill ammo (3s cooldown) + !score - top 5 for this channel + !score clear - clear scores for this channel (op/owner) + !hunt - show hunt state + !hunt start - start the hunt (op/owner) + !hunt stop - stop the hunt (op/owner) +""" + +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 + +import sqlite3 +import random +import threading +import time + +from sopel import plugin + + +SPAWN_MSG = "\x02** A duck appears! **\x02 Quick, type !bang to shoot it!" +FLEE_MSG = "\x02** The duck got away! **\x02 Too slow." +EMPTY_MSG = "*click* — chamber is empty. Use !reload first." +MOCK_MSG = "BLAM! ...at what? There's no duck, {nick}. You waste your shot." +KILL_MSG = "\x02BLAM!\x02 {nick} shot the duck in \x02{elapsed:.2f}s\x02! (+1 pt)" + +RELOAD_CD = 3 + + +def setup(bot): + jv.ensure_setup(bot) + path = bot.memory["jv_duck_db_path"] + try: + _os.makedirs(_os.path.dirname(_os.path.abspath(path)), exist_ok=True) + except OSError: + pass + with sqlite3.connect(path) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS scores ( + nick TEXT NOT NULL, + channel TEXT NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (nick, channel) + ) + """) + conn.commit() + bot.memory.setdefault("dk_channels", {}) + bot.memory.setdefault("dk_reload_cd", {}) + bot.memory.setdefault("dk_players", {}) + bot.memory.setdefault("dk_lock", threading.Lock()) + + +def _connect(bot): + return sqlite3.connect(bot.memory["jv_duck_db_path"]) + + +def _add_score(bot, nick, channel): + with _connect(bot) as conn: + conn.execute( + "INSERT INTO scores (nick, channel, score) VALUES (?, ?, 1)" + " ON CONFLICT(nick, channel) DO UPDATE SET score = score + 1", + (nick, channel), + ) + conn.commit() + + +def _next_spawn(bot, channel): + lo = jv.cfg_val(bot, "duck_spawn_min", channel) + hi = jv.cfg_val(bot, "duck_spawn_max", channel) + return time.time() + random.uniform(lo, hi) + + +def _chan_state(bot, channel): + states = bot.memory.setdefault("dk_channels", {}) + key = str(channel).lower() + if key not in states: + states[key] = { + "active": False, + "spawn_time": 0.0, + "next_spawn": _next_spawn(bot, channel), + "empty_rounds": 0, + "enabled": False, + } + return states[key] + + +def _player(bot, nick): + if nick not in bot.memory["dk_players"]: + bot.memory["dk_players"][nick] = {"ammo": 1} + return bot.memory["dk_players"][nick] + + +@plugin.interval(5) +def _tick(bot): + now = time.time() + with bot.memory["dk_lock"]: + for chan in list(bot.channels): + channel = str(chan) + st = _chan_state(bot, channel) + if not st["enabled"]: + continue + + if st["active"]: + flee = jv.cfg_val(bot, "duck_flee_time", channel) + if now - st["spawn_time"] >= flee: + st["active"] = False + st["next_spawn"] = _next_spawn(bot, channel) + st["empty_rounds"] += 1 + bot.say(FLEE_MSG, channel) + + idle = jv.cfg_val(bot, "duck_idle_rounds", channel) + if idle > 0 and st["empty_rounds"] >= idle: + st["enabled"] = False + st["next_spawn"] = float("inf") + bot.say( + f"Hunt over — no players for {idle} round(s). " + f"Type !hunt start to begin again.", + channel, + ) + else: + if now >= st["next_spawn"]: + st["active"] = True + st["spawn_time"] = now + bot.say(SPAWN_MSG, channel) + + +@plugin.command("bang") +@plugin.require_chanmsg +def cmd_bang(bot, trigger): + nick = trigger.nick + channel = str(trigger.sender) + + with bot.memory["dk_lock"]: + if not _chan_state(bot, channel)["enabled"]: + return + + p = _player(bot, nick) + + if p["ammo"] < 1: + bot.say(EMPTY_MSG, channel) + return + + with bot.memory["dk_lock"]: + st = _chan_state(bot, channel) + if not st["active"]: + p["ammo"] = 0 + bot.say(MOCK_MSG.format(nick=nick), channel) + return + + elapsed = time.time() - st["spawn_time"] + st["active"] = False + st["next_spawn"] = _next_spawn(bot, channel) + st["empty_rounds"] = 0 + + p["ammo"] = 0 + bot.say(KILL_MSG.format(nick=nick, elapsed=elapsed), channel) + _add_score(bot, nick, channel) + + +@plugin.command("reload") +@plugin.require_chanmsg +def cmd_reload(bot, trigger): + with bot.memory["dk_lock"]: + if not _chan_state(bot, str(trigger.sender))["enabled"]: + return + + nick = trigger.nick + now = time.time() + cd = bot.memory["dk_reload_cd"].get(nick, 0) + + if now - cd < RELOAD_CD: + remaining = RELOAD_CD - (now - cd) + bot.say(f"{nick}: reload on cooldown ({remaining:.1f}s remaining)", trigger.sender) + return + + bot.memory["dk_reload_cd"][nick] = now + _player(bot, nick)["ammo"] = 1 + bot.say(f"{nick}: reloaded. One shell in the chamber.", trigger.sender) + + +@plugin.command("score") +def cmd_score(bot, trigger): + arg = (trigger.group(2) or "").strip() + in_chan = str(trigger.sender).startswith("#") + is_owner = jv.is_owner(bot, trigger) + + parts = arg.split(None, 1) + if parts and parts[0].lower() == "clear": + if not is_owner and not (in_chan and jv.is_op(bot, trigger)): + return + target = parts[1].strip() if len(parts) > 1 else None + + if in_chan and not target: + channel = str(trigger.sender) + with _connect(bot) as conn: + conn.execute("DELETE FROM scores WHERE channel = ? COLLATE NOCASE", (channel,)) + conn.commit() + bot.say(f"scores cleared for {channel}", trigger.sender) + elif not in_chan and is_owner: + if not target: + bot.notice("usage: !score clear all or !score clear #channel", trigger.nick) + elif target.lower() == "all": + with _connect(bot) as conn: + conn.execute("DELETE FROM scores") + conn.commit() + bot.notice("all scores cleared", trigger.nick) + else: + with _connect(bot) as conn: + conn.execute("DELETE FROM scores WHERE channel = ? COLLATE NOCASE", (target,)) + conn.commit() + bot.notice(f"scores cleared for {target}", trigger.nick) + return + + if in_chan: + channel = str(trigger.sender) + with _connect(bot) as conn: + rows = conn.execute( + "SELECT nick, score FROM scores" + " WHERE channel = ? COLLATE NOCASE" + " ORDER BY score DESC LIMIT 5", + (channel,), + ).fetchall() + if not rows: + bot.say("no scores yet", trigger.sender) + return + line = " | ".join(f"{i+1}. {nick} ({score})" for i, (nick, score) in enumerate(rows)) + bot.say(line, trigger.sender) + elif is_owner: + with _connect(bot) as conn: + rows = conn.execute( + "SELECT channel, nick, score FROM scores ORDER BY channel, score DESC" + ).fetchall() + if not rows: + bot.notice("no scores yet", trigger.nick) + return + by_channel = {} + for channel, nick, score in rows: + by_channel.setdefault(channel, []).append((nick, score)) + for channel, players in sorted(by_channel.items()): + total = sum(s for _, s in players) + player_str = " | ".join(f"{n} ({s})" for n, s in players[:10]) + bot.notice(f"{channel}: {player_str} [total: {total}]", trigger.nick) + else: + bot.say("use !score in a channel", trigger.nick) + + +@plugin.command("hunt") +@plugin.require_chanmsg +def cmd_hunt(bot, trigger): + channel = str(trigger.sender) + arg = (trigger.group(2) or "").strip().lower() + sub = arg.split()[0] if arg else "" + + if sub in ("start", "on"): + if not jv.is_authorized(bot, trigger): + return + with bot.memory["dk_lock"]: + st = _chan_state(bot, channel) + st["enabled"] = True + st["empty_rounds"] = 0 + st["next_spawn"] = _next_spawn(bot, channel) + st["active"] = False + idle = jv.cfg_val(bot, "duck_idle_rounds", channel) + idle_str = f"{idle} idle round(s)" if idle > 0 else "no idle limit" + bot.say(f"the hunt is on! ({idle_str})", channel) + + elif sub in ("stop", "off"): + if not jv.is_authorized(bot, trigger): + return + with bot.memory["dk_lock"]: + st = _chan_state(bot, channel) + st["enabled"] = False + st["active"] = False + st["next_spawn"] = float("inf") + bot.say("the hunt is over.", channel) + + else: + with bot.memory["dk_lock"]: + st = _chan_state(bot, channel) + enabled = st["enabled"] + active = st["active"] + empty = st["empty_rounds"] + idle = jv.cfg_val(bot, "duck_idle_rounds", channel) + spawn_lo = jv.cfg_val(bot, "duck_spawn_min", channel) + spawn_hi = jv.cfg_val(bot, "duck_spawn_max", channel) + flee_time = jv.cfg_val(bot, "duck_flee_time", channel) + + if not enabled: + state = "off" + elif active: + state = "active (duck is up!)" + else: + state = "on (waiting for next duck)" + + if enabled: + idle_str = f"{empty}/{idle}" if idle > 0 else f"{empty}/off" + bot.say( + f"hunt: {state} | empty rounds: {idle_str} | " + f"spawn: {spawn_lo}-{spawn_hi}s | flee: {flee_time}s", + channel, + ) + else: + bot.say(f"hunt: {state}", channel) + if jv.is_authorized(bot, trigger): + bot.notice("use !hunt start | !hunt stop to control | timing via !set duck_spawn_min/max/flee_time", trigger.nick) diff --git a/jeeves/feeds.json b/jeeves/feeds.json new file mode 100644 index 0000000..75fe4da --- /dev/null +++ b/jeeves/feeds.json @@ -0,0 +1,11 @@ +[ + {"id": "bbc_world", "name": "BBC World", "url": "https://feeds.bbci.co.uk/news/world/rss.xml", "label": "global", "enabled": true}, + {"id": "reuters_world", "name": "Reuters World", "url": "https://feeds.reuters.com/reuters/worldNews", "label": "global", "enabled": true}, + {"id": "ap_world", "name": "AP News", "url": "https://feeds.apnews.com/rss/apf-topnews", "label": "global", "enabled": true}, + {"id": "guardian_world", "name": "The Guardian", "url": "https://www.theguardian.com/world/rss", "label": "global", "enabled": true}, + {"id": "arab_news", "name": "Arab News", "url": "https://www.arabnews.com/rss.xml", "label": "mena", "enabled": true}, + {"id": "aljazeera", "name": "Al Jazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml", "label": "mena", "enabled": true}, + {"id": "al_monitor", "name": "Al-Monitor", "url": "https://www.al-monitor.com/rss.xml", "label": "mena", "enabled": true}, + {"id": "egypt_indep", "name": "Egypt Independent", "url": "https://egyptindependent.com/feed/", "label": "egypt", "enabled": true}, + {"id": "daily_news_egypt", "name": "Daily News Egypt", "url": "https://dailynewsegypt.com/feed/", "label": "egypt", "enabled": true} +] diff --git a/jeeves/help.py b/jeeves/help.py new file mode 100644 index 0000000..ac953ff --- /dev/null +++ b/jeeves/help.py @@ -0,0 +1,32 @@ +""" +Sopel plugin: jeeves help + +Three-level help system: + !help - topics overview + !help <topic> - commands in that topic + !help <cmd> - command description + !help <topic> <cmd> - same as above +""" + +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 +import helpstrings as h + +from sopel import plugin + + +def setup(bot): + jv.ensure_setup(bot) + + +@plugin.command("help") +def cmd_help(bot, trigger): + args = (trigger.group(2) or "").split() + result = h.lookup(args) + if result: + bot.notice(result, trigger.nick) + else: + bot.notice(f"no help for '{' '.join(args)}'. {h.TOPICS}", trigger.nick) diff --git a/jeeves/helpstrings.py b/jeeves/helpstrings.py new file mode 100644 index 0000000..bf4b27e --- /dev/null +++ b/jeeves/helpstrings.py @@ -0,0 +1,100 @@ +""" +Jeeves help string constants. Not loaded directly by Sopel. +Imported via sys.path insertion by help.py. +""" + +_ADMIN = { + "join": "!join #channel - join a channel (owner)", + "part": "!part [#channel] - leave a channel, defaults to current (owner)", + "chans": "!chans - list channels the bot is currently in (owner)", + "follow": "!follow <on/off> - follow owner into channels every 30s (owner)", + "unfollow": "!unfollow - leave all follow-joined channels immediately (owner)", + "pause": "!pause - pause keyword responses (owner)", + "resume": "!resume - resume keyword responses (owner)", +} + +_DJ = { + "dj": "!dj <url> - queue a YouTube URL (shorts, embeds, live not accepted)", + "np": "!np - show currently playing track", + "queue": "!queue - list up to 4 upcoming tracks", + "loop": "!loop - show loop state; !loop <on/off> to change (op)", + "play": "!play - start or resume playback (op)", + "stop": "!stop - pause playback (op)", + "skip": "!skip - skip to next track (op)", + "remove": "!remove - remove current track from queue (op)", + "clear": "!clear - clear queue and stop playback (op)", +} + +_DUCK = { + "bang": "!bang - fire at the current duck", + "reload": "!reload - refill ammo, 3s cooldown", + "score": "!score - top 5 for this channel; !score clear to reset (op/owner)", + "hunt": "!hunt - show hunt state (includes spawn/flee times); !hunt start|stop to control (op); timing via !set duck_spawn_min/max/flee_time", +} + +_RECIPES = { + "breakfast": "!breakfast [list] - random breakfast suggestion or list all", + "lunch": "!lunch [list] - random lunch suggestion or list all", + "dinner": "!dinner [list] - random dinner suggestion or list all", + "snack": "!snack [list] - random snack suggestion or list all", + "recipe": "!recipe <name> - full recipe: name, ingredients, steps", +} + +_NEWS = { + "weather": "!weather <city> - current weather conditions", + "forecast": "!forecast <city> - 3-day weather forecast", + "headlines": "!headlines [global/mena/egypt] [count] - latest headlines", + "news": "!news - show broadcast state; !news on/off to toggle (op)", +} + +_QUOTES = { + "quote": "!quote - random quote; !quote add <text> (op); !quote del <N> (op); !quote list", + "quotes": "!quotes - show auto-post state; !quotes on/off to toggle (op)", +} + +_ART = { + "draw": "!draw [<lines>] <url> - render image as mIRC colour art (lines 4-12, default from !set art_height)", +} + +_CONFIG = { + "set": "!set <key> <value> - set a config value (in channel: per-channel; in PM: global, owner only)", + "config": "!config [#channel] - show all config values for a channel", +} + +TOPIC_MAP = { + "admin": ("admin: join, part, chans, follow, unfollow, pause, resume. type !help <cmd> for usage", _ADMIN), + "dj": ("dj: dj, np, queue, loop, play, stop, skip, remove, clear. type !help <cmd> for usage", _DJ), + "duck": ("duck: bang, reload, score, hunt. type !help <cmd> for usage", _DUCK), + "recipes": ("recipes: breakfast, lunch, dinner, snack, recipe. type !help <cmd> for usage", _RECIPES), + "news": ("news: weather, forecast, headlines, news. type !help <cmd> for usage", _NEWS), + "quotes": ("quotes: quote, quotes. type !help <cmd> for usage", _QUOTES), + "art": ("art: draw. type !help <cmd> for usage", _ART), + "config": ("config: set, config. type !help <cmd> for usage", _CONFIG), +} + +TOPICS = ( + "topics (commands): " + "admin (join, part, chans, follow, unfollow, pause, resume), " + "dj (dj, np, queue, loop, play, stop, skip, remove, clear), " + "duck (bang, reload, score, hunt), " + "recipes (breakfast, lunch, dinner, snack, recipe), " + "news (weather, forecast, headlines, news), " + "quotes (quote, quotes), " + "art (draw), " + "config (set, config). " + "type !help <topic> or !help <cmd> to know more" +) + +_ALL = {**_ADMIN, **_DJ, **_DUCK, **_RECIPES, **_NEWS, **_QUOTES, **_ART, **_CONFIG} + + +def lookup(args): + if not args: + return TOPICS + first = args[0].lower() + if first in TOPIC_MAP: + if len(args) == 1: + return TOPIC_MAP[first][0] + cmd = args[1].lower() + return TOPIC_MAP[first][1].get(cmd) + return _ALL.get(first) diff --git a/jeeves/jv_core.py b/jeeves/jv_core.py new file mode 100644 index 0000000..b8bba33 --- /dev/null +++ b/jeeves/jv_core.py @@ -0,0 +1,147 @@ +""" +Jeeves core helpers. Not loaded directly by Sopel (not in enable list). +All jeeves plugins import this via sys.path insertion. + +Provides: ensure_setup, save, cfg, cfg_val, chan_db, CONFIG_KEYS. +""" + +import json +import os +import threading + +from sopel.config.types import FilenameAttribute, StaticSection + +# (type, default, global_only, description) +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"), +} + +_SEEN_MAX = 1000 + + +class JeevesSection(StaticSection): + db_path = FilenameAttribute("db_path", relative=False, default="/var/jeeves/db.json") + duck_db_path = FilenameAttribute("duck_db_path", relative=False, default="/var/jeeves/scores.db") + recipes_path = FilenameAttribute("recipes_path", relative=False, default="/var/jeeves/recipes.json") + feeds_path = FilenameAttribute("feeds_path", relative=False, default="/var/jeeves/feeds.json") + + +def _load_feeds(path): + try: + with open(path) as f: + return json.load(f) + except Exception: + return [] + + +def _load(path, feeds_path): + feeds = _load_feeds(feeds_path) + if os.path.exists(path): + try: + with open(path) as f: + data = json.load(f) + data.setdefault("config", {}) + data.setdefault("channels", {}) + data.setdefault("quotes", []) + data.setdefault("news_feeds", feeds) + data.setdefault("news_seen", []) + return data + except Exception: + pass + data = { + "config": {}, + "channels": {}, + "quotes": [], + "news_feeds": feeds, + "news_seen": [], + } + _save_raw(path, data) + return data + + +def _save_raw(path, data): + os.makedirs(os.path.dirname(path), exist_ok=True) + seen = data.get("news_seen", []) + if len(seen) > _SEEN_MAX: + data["news_seen"] = seen[-_SEEN_MAX:] + with open(path, "w") as f: + json.dump(data, f, indent=2) + + +def save(bot): + _save_raw(bot.memory["jv_db_path"], bot.memory["jv_db"]) + + +def ensure_setup(bot): + if "jv_db" in bot.memory: + return + try: + bot.config.define_section("jeeves", JeevesSection, validate=False) + db_path = bot.config.jeeves.db_path + duck_db_path = bot.config.jeeves.duck_db_path + recipes_path = bot.config.jeeves.recipes_path + feeds_path = bot.config.jeeves.feeds_path + except Exception: + db_path = "/var/jeeves/db.json" + duck_db_path = "/var/jeeves/scores.db" + recipes_path = "/var/jeeves/recipes.json" + feeds_path = "/var/jeeves/feeds.json" + + bot.memory["jv_db_path"] = db_path + bot.memory["jv_duck_db_path"] = duck_db_path + bot.memory["jv_recipes_path"] = recipes_path + bot.memory["jv_feeds_path"] = feeds_path + bot.memory["jv_db"] = _load(db_path, feeds_path) + bot.memory["jv_lock"] = threading.Lock() + + +def cfg(bot, channel=None): + db = bot.memory.get("jv_db", {}) + global_cfg = db.get("config", {}) + if channel: + chan_cfg = (db.get("channels", {}) + .get(channel.lower(), {}) + .get("config", {})) + return {**global_cfg, **chan_cfg} + return dict(global_cfg) + + +def cfg_val(bot, key, channel=None): + merged = cfg(bot, channel) + default = CONFIG_KEYS[key][1] + return type(CONFIG_KEYS[key][0])(merged.get(key, default)) + + +def chan_db(bot, channel): + db = bot.memory["jv_db"] + db.setdefault("channels", {}) + ch = db["channels"].setdefault(channel.lower(), {}) + ch.setdefault("config", {}) + ch.setdefault("news", {"enabled": True, "last_broadcast": 0}) + ch.setdefault("quotes", {"enabled": False, "last_broadcast": 0}) + return ch + + +def is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def is_op(bot, trigger): + chan = bot.channels.get(str(trigger.sender)) + if not chan: + return False + from sopel import plugin as _p + return chan.privileges.get(trigger.nick, 0) >= _p.OP + + +def is_authorized(bot, trigger): + return is_owner(bot, trigger) or is_op(bot, trigger) diff --git a/jeeves/news.py b/jeeves/news.py new file mode 100644 index 0000000..27f78af --- /dev/null +++ b/jeeves/news.py @@ -0,0 +1,340 @@ +""" +Sopel plugin: jeeves news and weather + +Weather (via wttr.in - no API key required): + !weather <city> - current conditions + !forecast <city> - 3-day forecast + +News (via RSS feeds): + !headlines [label] [n] - latest headlines (label: global / mena / egypt) + !news - show auto-broadcast state for this channel + !news on|off - enable/disable auto-broadcast (op/owner) + +Broadcast interval and count are configured globally/per-channel via !set: + !set news_interval <min> - minutes between broadcasts (default 90) + !set news_count <n> - headlines per broadcast cycle (default 1) +""" + +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 + +import json +import logging +import time +import urllib.error +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET + +from sopel import plugin + +log = logging.getLogger(__name__) + +_MAX_COUNT = 10 + + +def setup(bot): + jv.ensure_setup(bot) + + +def _fetch_url(url, timeout=10): + req = urllib.request.Request(url, headers={"User-Agent": "jeeves-irc-bot/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", errors="replace") + + +def _city_param(city): + return urllib.parse.quote(city.replace(" ", "+")) + + +def _clean_url(url): + try: + p = urllib.parse.urlparse(url) + return urllib.parse.urlunparse(p._replace(query="", fragment="")) + except Exception: + return url + + +_COUNTRY_CODES = { + "Afghanistan": "AF", "Albania": "AL", "Algeria": "DZ", "Argentina": "AR", + "Australia": "AU", "Austria": "AT", "Bahrain": "BH", "Bangladesh": "BD", + "Belgium": "BE", "Brazil": "BR", "Canada": "CA", "Chile": "CL", + "China": "CN", "Colombia": "CO", "Croatia": "HR", "Czech Republic": "CZ", + "Denmark": "DK", "Egypt": "EG", "Ethiopia": "ET", "Finland": "FI", + "France": "FR", "Germany": "DE", "Ghana": "GH", "Greece": "GR", + "Hungary": "HU", "India": "IN", "Indonesia": "ID", "Iran": "IR", + "Iraq": "IQ", "Ireland": "IE", "Israel": "IL", "Italy": "IT", + "Japan": "JP", "Jordan": "JO", "Kenya": "KE", "Kuwait": "KW", + "Lebanon": "LB", "Libya": "LY", "Malaysia": "MY", "Mexico": "MX", + "Morocco": "MA", "Netherlands": "NL", "New Zealand": "NZ", "Nigeria": "NG", + "Norway": "NO", "Oman": "OM", "Pakistan": "PK", "Palestine": "PS", + "Peru": "PE", "Philippines": "PH", "Poland": "PL", "Portugal": "PT", + "Qatar": "QA", "Romania": "RO", "Russia": "RU", "Saudi Arabia": "SA", + "Senegal": "SN", "Serbia": "RS", "Singapore": "SG", "South Africa": "ZA", + "South Korea": "KR", "Spain": "ES", "Sudan": "SD", "Sweden": "SE", + "Switzerland": "CH", "Syria": "SY", "Thailand": "TH", "Tunisia": "TN", + "Turkey": "TR", "Ukraine": "UA", "United Arab Emirates": "AE", + "United Kingdom": "GB", "United States of America": "US", + "United States": "US", "Venezuela": "VE", "Vietnam": "VN", "Yemen": "YE", +} + + +def _format_location(city_name, region, country): + code = _COUNTRY_CODES.get(country, country) + if region and region.lower() not in city_name.lower(): + return f"{city_name}, {region}, {code}" + return f"{city_name}, {code}" + + +def _fetch_weather_json(city): + url = f"https://wttr.in/{_city_param(city)}?format=j1" + try: + raw = _fetch_url(url) + except urllib.error.HTTPError as exc: + if exc.code in (404, 500): + return None, f"city not found: '{city}'" + return None, f"weather service error ({exc.code})" + except Exception as exc: + return None, f"weather lookup failed: {exc}" + try: + return json.loads(raw), None + except Exception: + return None, f"city not found: '{city}'" + + +@plugin.command("weather") +def cmd_weather(bot, trigger): + city = (trigger.group(2) or "").strip() + if not city: + bot.say("usage: !weather <city>", trigger.sender) + return + data, err = _fetch_weather_json(city) + if err: + bot.say(err, trigger.sender) + return + try: + area = data["nearest_area"][0] + city_name = area["areaName"][0]["value"] + region = area["region"][0]["value"] + country = area["country"][0]["value"] + cur = data["current_condition"][0] + desc = cur["weatherDesc"][0]["value"] + temp_c = cur["temp_C"] + feels_c = cur["FeelsLikeC"] + humidity = cur["humidity"] + location = _format_location(city_name, region, country) + bot.say( + f"[Weather] {location}: {desc}, {temp_c}°C (feels like {feels_c}°C), humidity {humidity}%", + trigger.sender, + ) + except (KeyError, IndexError): + bot.say(f"city not found: '{city}'", trigger.sender) + + +@plugin.command("forecast") +def cmd_forecast(bot, trigger): + city = (trigger.group(2) or "").strip() + if not city: + bot.say("usage: !forecast <city>", trigger.sender) + return + data, err = _fetch_weather_json(city) + if err: + bot.say(err, trigger.sender) + return + try: + area = data["nearest_area"][0] + city_name = area["areaName"][0]["value"] + region = area["region"][0]["value"] + country = area["country"][0]["value"] + location = _format_location(city_name, region, country) + days = data["weather"] + labels = ["Today", "Tomorrow", "Day after"] + parts = [] + for day, label in zip(days[:3], labels): + desc = day["hourly"][4]["weatherDesc"][0]["value"] + max_c = day["maxtempC"] + min_c = day["mintempC"] + parts.append(f"{label}: {desc} {min_c}–{max_c}°C") + bot.say(f"[Forecast] {location}: {' | '.join(parts)}", trigger.sender) + except (KeyError, IndexError): + bot.say(f"city not found: '{city}'", trigger.sender) + + +# --- RSS --- + +def _fetch_feed(url): + raw = _fetch_url(url, timeout=15) + root = ET.fromstring(raw) + items = [] + + for item in root.iter("item"): + title = (item.findtext("title") or "").strip() + link = _clean_url((item.findtext("link") or "").strip()) + guid = (item.findtext("guid") or link).strip() + if title and guid: + items.append({"title": title, "link": link, "guid": guid}) + + if not items: + ns = {"a": "http://www.w3.org/2005/Atom"} + for entry in root.findall("a:entry", ns): + title_el = entry.find("a:title", ns) + link_el = entry.find("a:link", ns) + id_el = entry.find("a:id", ns) + title = (title_el.text or "").strip() if title_el is not None else "" + link = _clean_url((link_el.get("href") or "").strip()) if link_el is not None else "" + guid = (id_el.text or "").strip() if id_el is not None else link + if title and guid: + items.append({"title": title, "link": link, "guid": guid}) + + return items + + +def _fresh_headlines(data, label=None, max_items=3): + seen = set(data.get("news_seen", [])) + feeds = [f for f in data.get("news_feeds", []) if f.get("enabled", True)] + if label: + feeds = [f for f in feeds if f.get("label", "").lower() == label.lower()] + + results = [] + for feed in feeds: + if len(results) >= max_items: + break + try: + for item in _fetch_feed(feed["url"]): + if item["guid"] not in seen and len(results) < max_items: + results.append({"feed": feed["name"], **item}) + except Exception as exc: + log.warning("jeeves/news: failed to fetch %s: %s", feed.get("id", "?"), exc) + + return results + + +def _mark_seen(data, items): + for item in items: + if item["guid"] not in data["news_seen"]: + data["news_seen"].append(item["guid"]) + + +@plugin.command("headlines") +def cmd_headlines(bot, trigger): + args = (trigger.group(2) or "").strip().split() + label = None + count = jv.cfg_val(bot, "news_count") + + for arg in args: + if arg.lower() in ("global", "mena", "egypt"): + label = arg.lower() + else: + try: + count = min(int(arg), _MAX_COUNT) + if count < 1: + raise ValueError + except ValueError: + bot.say("usage: !headlines [global|mena|egypt] [count]", trigger.sender) + return + + with bot.memory["jv_lock"]: + data = bot.memory["jv_db"] + feeds = list(data.get("news_feeds", [])) + seen = set(data.get("news_seen", [])) + + items = _fresh_headlines({"news_feeds": feeds, "news_seen": list(seen)}, label=label, max_items=count) + if not items: + bot.say("no fresh headlines available right now", trigger.sender) + return + + with bot.memory["jv_lock"]: + _mark_seen(bot.memory["jv_db"], items) + jv.save(bot) + + for item in items: + bot.say(f"[{item['feed']}] {item['title']} | {item['link']}", trigger.sender) + + +@plugin.command("news") +def cmd_news(bot, trigger): + args = (trigger.group(2) or "").strip().split() + arg = args[0].lower() if args else "" + in_chan = str(trigger.sender).startswith("#") + channel = str(trigger.sender) if in_chan else None + + if arg in ("on", "off"): + if not jv.is_authorized(bot, trigger): + return + if not in_chan: + bot.notice("use !news on|off in a channel", trigger.nick) + return + with bot.memory["jv_lock"]: + jv.chan_db(bot, channel)["news"]["enabled"] = (arg == "on") + jv.save(bot) + bot.say(f"auto-broadcast turned {arg} for {channel}", channel) + return + + if in_chan: + ch = jv.chan_db(bot, channel) + enabled = ch["news"]["enabled"] + last = ch["news"]["last_broadcast"] + interval = jv.cfg_val(bot, "news_interval", channel) + count = jv.cfg_val(bot, "news_count", channel) + if last: + ago = int(time.time() - last) + last_str = f"{ago // 60}m ago" if ago >= 60 else f"{ago}s ago" + else: + last_str = "never" + state = "on" if enabled else "off" + bot.say( + f"auto-broadcast: {state} | every {interval} min | {count} headline(s)/cycle | last: {last_str}", + channel, + ) + else: + bot.notice("usage: !news [on|off] (use in a channel)", trigger.nick) + + +@plugin.interval(60) +def _broadcast_tick(bot): + if "jv_db" not in bot.memory: + return + now = time.time() + + with bot.memory["jv_lock"]: + data = bot.memory["jv_db"] + feeds = list(data.get("news_feeds", [])) + seen = set(data.get("news_seen", [])) + pending = [] + for chan_name in list(bot.channels): + channel = str(chan_name) + ch = jv.chan_db(bot, channel) + if not ch["news"]["enabled"]: + continue + interval_sec = jv.cfg_val(bot, "news_interval", channel) * 60 + if now - ch["news"]["last_broadcast"] < interval_sec: + continue + pending.append((channel, jv.cfg_val(bot, "news_count", channel))) + + for channel, b_count in pending: + active_feeds = [f for f in feeds if f.get("enabled", True)] + results = [] + for feed in active_feeds: + if len(results) >= b_count: + break + try: + for item in _fetch_feed(feed["url"]): + if item["guid"] not in seen and len(results) < b_count: + results.append({"feed": feed["name"], **item}) + except Exception as exc: + log.warning("jeeves/news tick: %s: %s", feed.get("id", "?"), exc) + + if not results: + continue + + with bot.memory["jv_lock"]: + _mark_seen(bot.memory["jv_db"], results) + jv.chan_db(bot, channel)["news"]["last_broadcast"] = now + jv.save(bot) + seen.update(item["guid"] for item in results) + + for item in results: + bot.say(f"[{item['feed']}] {item['title']} | {item['link']}", channel) diff --git a/jeeves/quotes.py b/jeeves/quotes.py new file mode 100644 index 0000000..c840a6d --- /dev/null +++ b/jeeves/quotes.py @@ -0,0 +1,150 @@ +""" +Sopel plugin: jeeves quotes + +Maintains a shared quote bank with automatic periodic posting. + + !quote - post a random quote + !quote add <text> - add a quote (op/owner) + !quote del <N> - delete quote by index (op/owner) + !quote list - show total count + !quotes - show auto-post state for this channel + !quotes on|off - enable/disable auto-posting (op/owner) + +Auto-post interval is configured via: + !set quotes_interval <min> - minutes between auto-posts (default 60) +""" + +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 + +import random +import time + +from sopel import plugin + + +def setup(bot): + jv.ensure_setup(bot) + + +@plugin.command("quote") +def cmd_quote(bot, trigger): + args = (trigger.group(2) or "").strip().split(None, 1) + sub = args[0].lower() if args else "" + + if sub == "add": + if not jv.is_authorized(bot, trigger): + return + text = args[1].strip() if len(args) > 1 else "" + if not text: + bot.notice("usage: !quote add <text>", trigger.nick) + return + with bot.memory["jv_lock"]: + bot.memory["jv_db"].setdefault("quotes", []).append(text) + n = len(bot.memory["jv_db"]["quotes"]) + jv.save(bot) + bot.notice(f"quote #{n} added", trigger.nick) + return + + if sub == "del": + if not jv.is_authorized(bot, trigger): + return + if len(args) < 2: + bot.notice("usage: !quote del <N>", trigger.nick) + return + try: + idx = int(args[1]) - 1 + except ValueError: + bot.notice("usage: !quote del <N>", trigger.nick) + return + with bot.memory["jv_lock"]: + quotes = bot.memory["jv_db"].get("quotes", []) + if idx < 0 or idx >= len(quotes): + bot.notice(f"no quote #{idx + 1}", trigger.nick) + return + quotes.pop(idx) + jv.save(bot) + bot.notice(f"quote #{idx + 1} deleted", trigger.nick) + return + + if sub == "list": + quotes = bot.memory.get("jv_db", {}).get("quotes", []) + count = len(quotes) + dest = trigger.sender if str(trigger.sender).startswith("#") else trigger.nick + bot.say(f"{count} quote(s) in the bank", dest) + return + + quotes = bot.memory.get("jv_db", {}).get("quotes", []) + if not quotes: + dest = trigger.sender if str(trigger.sender).startswith("#") else trigger.nick + bot.say("no quotes in the bank yet", dest) + return + dest = trigger.sender if str(trigger.sender).startswith("#") else trigger.nick + bot.say(random.choice(quotes), dest) + + +@plugin.command("quotes") +def cmd_quotes(bot, trigger): + args = (trigger.group(2) or "").strip().split() + arg = args[0].lower() if args else "" + in_chan = str(trigger.sender).startswith("#") + channel = str(trigger.sender) if in_chan else None + + if arg in ("on", "off"): + if not jv.is_authorized(bot, trigger): + return + if not in_chan: + bot.notice("use !quotes on|off in a channel", trigger.nick) + return + with bot.memory["jv_lock"]: + jv.chan_db(bot, channel)["quotes"]["enabled"] = (arg == "on") + jv.save(bot) + bot.say(f"quote auto-post turned {arg} for {channel}", channel) + return + + if in_chan: + ch = jv.chan_db(bot, channel) + enabled = ch["quotes"]["enabled"] + last = ch["quotes"]["last_broadcast"] + interval = jv.cfg_val(bot, "quotes_interval", channel) + if last: + ago = int(time.time() - last) + last_str = f"{ago // 60}m ago" if ago >= 60 else f"{ago}s ago" + else: + last_str = "never" + state = "on" if enabled else "off" + count = len(bot.memory.get("jv_db", {}).get("quotes", [])) + bot.say( + f"quote auto-post: {state} | every {interval} min | {count} quote(s) | last: {last_str}", + channel, + ) + else: + bot.notice("usage: !quotes [on|off] (use in a channel)", trigger.nick) + + +@plugin.interval(60) +def _quotes_tick(bot): + if "jv_db" not in bot.memory: + return + now = time.time() + quotes = bot.memory["jv_db"].get("quotes", []) + if not quotes: + return + + for chan_name in list(bot.channels): + channel = str(chan_name) + ch = jv.chan_db(bot, channel) + if not ch["quotes"]["enabled"]: + continue + interval_sec = jv.cfg_val(bot, "quotes_interval", channel) * 60 + if now - ch["quotes"]["last_broadcast"] < interval_sec: + continue + + quote = random.choice(quotes) + with bot.memory["jv_lock"]: + jv.chan_db(bot, channel)["quotes"]["last_broadcast"] = now + jv.save(bot) + bot.say(quote, channel) diff --git a/jeeves/recipes.json b/jeeves/recipes.json new file mode 100644 index 0000000..0edc99f --- /dev/null +++ b/jeeves/recipes.json @@ -0,0 +1,586 @@ +{ + "breakfast": [ + { + "name": "Ful Medames", + "description": "slow-cooked fava beans with garlic, lemon and olive oil", + "ingredients": ["fava beans", "garlic", "lemon juice", "olive oil", "cumin", "parsley", "salt"], + "steps": ["Soak beans overnight", "Boil until tender (45 min)", "Mash lightly with garlic and lemon", "Season with cumin and salt", "Top with olive oil and parsley"] + }, + { + "name": "Tamiya", + "description": "Egyptian falafel made from fava beans and herbs", + "ingredients": ["dried fava beans", "chickpeas", "parsley", "dill", "coriander", "garlic", "onion", "cumin", "sesame seeds", "oil for frying"], + "steps": ["Soak fava beans and chickpeas overnight (do not cook)", "Blend with herbs, garlic and spices into a paste", "Shape into flat patties and coat with sesame seeds", "Fry in hot oil 2-3 min per side until golden", "Serve in bread with tahini and tomato"] + }, + { + "name": "Shakshuka", + "description": "eggs poached in spiced tomato and pepper sauce", + "ingredients": ["eggs", "tomatoes", "red bell pepper", "onion", "garlic", "cumin", "paprika", "chilli flakes", "olive oil", "salt"], + "steps": ["Saute onion and pepper in olive oil until soft", "Add garlic and spices, cook 1 min", "Add tomatoes and simmer 10 min until thick", "Make wells and crack in eggs", "Cover and cook 5-8 min until whites are set"] + }, + { + "name": "Feteer Meshaltet", + "description": "flaky layered Egyptian pastry with ghee", + "ingredients": ["flour", "water", "ghee", "salt", "sugar", "lemon juice", "vinegar", "oil"], + "steps": ["Mix flour, salt, sugar and water into a soft dough", "Rest 10 min then divide into balls", "Roll each ball thin, coat with ghee, fold and roll again", "Layer multiple sheets in a pan with ghee between each", "Bake at 200C for 20 min until golden and crisp"] + }, + { + "name": "Balila", + "description": "warm chickpeas with olive oil, cumin and lemon", + "ingredients": ["chickpeas", "olive oil", "cumin", "lemon juice", "garlic", "parsley", "salt"], + "steps": ["Cook chickpeas until tender or use canned", "Warm in a pan with olive oil and minced garlic", "Season with cumin, lemon juice and salt", "Top with chopped parsley", "Serve warm with bread"] + }, + { + "name": "Bissara", + "description": "Egyptian dried fava bean dip with herbs and spices", + "ingredients": ["dried split fava beans", "garlic", "cumin", "coriander", "lemon juice", "olive oil", "parsley", "paprika", "salt"], + "steps": ["Boil fava beans with garlic until very soft (45 min)", "Blend with cumin, coriander, lemon and olive oil until smooth", "Season with salt, adjust consistency with water", "Top with a drizzle of olive oil and paprika", "Serve warm with flatbread"] + }, + { + "name": "Hawawshi", + "description": "crispy Egyptian flatbread stuffed with spiced minced meat", + "ingredients": ["minced beef", "onion", "parsley", "green pepper", "cumin", "coriander", "salt", "black pepper", "chilli", "flatbread dough"], + "steps": ["Mix beef with finely chopped onion, parsley, pepper and spices", "Split dough into balls and roll flat", "Place meat filling on half and fold dough over, sealing edges", "Bake at 220C for 20-25 min until crispy and golden", "Serve hot"] + }, + { + "name": "Eggah", + "description": "Egyptian baked herb omelette", + "ingredients": ["eggs", "parsley", "dill", "onion", "garlic", "flour", "baking powder", "olive oil", "salt", "black pepper"], + "steps": ["Beat eggs with chopped herbs, onion and garlic", "Mix in flour and baking powder, season well", "Heat olive oil in an oven-safe pan", "Pour in egg mixture and cook 3 min on stovetop", "Bake at 180C for 15-20 min until set and golden"] + }, + { + "name": "Eggs with Pastrami", + "description": "sauteed beef pastrami with scrambled eggs", + "ingredients": ["eggs", "beef pastrami", "tomato", "onion", "olive oil", "salt", "black pepper", "cumin"], + "steps": ["Slice pastrami and fry in olive oil until edges crisp", "Add diced onion and tomato, cook 3 min", "Beat eggs with salt, pepper and cumin", "Pour eggs over pastrami and scramble gently", "Serve immediately with bread"] + }, + { + "name": "Om Ali", + "description": "Egyptian bread pudding with milk, nuts and cream", + "ingredients": ["puff pastry", "full-fat milk", "heavy cream", "sugar", "mixed nuts", "raisins", "desiccated coconut", "vanilla"], + "steps": ["Bake puff pastry until golden then break into pieces", "Bring milk, cream and sugar to a gentle boil", "Layer pastry pieces in a baking dish", "Pour hot milk mixture over pastry", "Top with nuts, raisins and coconut, bake at 200C for 15 min until golden"] + }, + { + "name": "Ful Akhdar", + "description": "fresh fava beans sauteed with olive oil and garlic", + "ingredients": ["fresh fava beans", "garlic", "olive oil", "lemon juice", "dill", "salt"], + "steps": ["Shell and peel fresh fava beans", "Saute garlic in olive oil until fragrant", "Add beans and cook 5-7 min until tender", "Squeeze lemon juice and toss with dill", "Serve warm as a side or with bread"] + }, + { + "name": "Fatteh with Yogurt", + "description": "layers of toasted bread, chickpeas and garlicky yogurt", + "ingredients": ["flatbread", "chickpeas", "yogurt", "tahini", "garlic", "lemon juice", "olive oil", "paprika", "parsley", "pine nuts"], + "steps": ["Toast or fry flatbread pieces until crisp", "Layer bread in a dish with warm chickpeas", "Mix yogurt with garlic, tahini and lemon juice", "Pour yogurt sauce generously over layers", "Top with olive oil, paprika and toasted pine nuts"] + }, + { + "name": "French Toast", + "description": "thick bread slices dipped in egg and pan-fried until golden", + "ingredients": ["thick white bread", "eggs", "milk", "vanilla extract", "cinnamon", "butter", "maple syrup", "powdered sugar"], + "steps": ["Beat eggs with milk, vanilla and cinnamon", "Dip bread slices in egg mixture for 30 seconds each side", "Melt butter in a pan over medium heat", "Fry bread 2-3 min per side until golden", "Dust with powdered sugar and serve with maple syrup"] + }, + { + "name": "Pancakes", + "description": "fluffy American-style pancakes with maple syrup", + "ingredients": ["flour", "eggs", "milk", "butter", "baking powder", "sugar", "salt", "vanilla", "maple syrup"], + "steps": ["Whisk dry ingredients together", "Mix wet ingredients separately then combine gently (lumps are fine)", "Heat buttered pan over medium heat", "Pour ladle-size portions and cook until bubbles form, flip once", "Stack and serve with butter and maple syrup"] + }, + { + "name": "Full English Breakfast", + "description": "a hearty plate of eggs, beef sausages, beans and grilled vegetables", + "ingredients": ["beef sausages", "eggs", "baked beans", "mushrooms", "tomatoes", "bread", "butter", "oil", "salt", "black pepper"], + "steps": ["Grill sausages turning occasionally until cooked through (15 min)", "Fry mushrooms and halved tomatoes in butter", "Warm beans in a small saucepan", "Fry or poach eggs to your liking", "Toast bread and arrange everything on a large plate"] + }, + { + "name": "Avocado Toast with Poached Eggs", + "description": "creamy smashed avocado on sourdough with runny poached eggs", + "ingredients": ["sourdough bread", "avocados", "eggs", "lemon juice", "chilli flakes", "salt", "black pepper", "olive oil", "white vinegar"], + "steps": ["Toast sourdough slices until crisp", "Mash avocado with lemon juice, salt and chilli flakes", "Bring water to a gentle simmer, add a splash of vinegar", "Crack eggs into water and poach 3-4 min", "Spread avocado on toast, top with poached eggs and black pepper"] + }, + { + "name": "Crepes with Honey and Lemon", + "description": "thin French-style crepes served with honey and lemon juice", + "ingredients": ["flour", "eggs", "milk", "butter", "salt", "sugar", "honey", "lemon"], + "steps": ["Blend flour, eggs, milk, melted butter and salt into a smooth thin batter", "Rest batter 30 min", "Heat a non-stick pan, add tiny knob of butter", "Pour thin layer of batter, swirl to cover pan, cook 1 min per side", "Fold and serve drizzled with honey and fresh lemon juice"] + }, + { + "name": "Belgian Waffles", + "description": "deep crispy waffles with fresh cream and fruit", + "ingredients": ["flour", "eggs", "milk", "butter", "sugar", "baking powder", "vanilla", "salt", "whipped cream", "strawberries"], + "steps": ["Separate eggs and beat whites to stiff peaks", "Mix yolks with melted butter, milk, sugar and vanilla", "Fold in flour and baking powder then gently fold in egg whites", "Preheat waffle iron and grease lightly", "Cook waffles until golden and crispy, serve with cream and fruit"] + }, + { + "name": "Bircher Muesli", + "description": "oats soaked overnight in apple juice with yogurt and fruit", + "ingredients": ["rolled oats", "apple juice", "yogurt", "grated apple", "lemon juice", "honey", "mixed berries", "toasted almonds"], + "steps": ["Mix oats with apple juice, cover and refrigerate overnight", "In the morning stir in yogurt and grated apple", "Add a squeeze of lemon and drizzle of honey", "Top with mixed berries and toasted almonds", "Serve cold"] + }, + { + "name": "Smoked Salmon Bagel", + "description": "toasted bagel with cream cheese and smoked salmon", + "ingredients": ["bagel", "cream cheese", "smoked salmon", "capers", "red onion", "cucumber", "lemon", "dill", "black pepper"], + "steps": ["Slice and toast bagel until lightly golden", "Spread generously with cream cheese", "Layer with smoked salmon slices", "Top with thin red onion, capers and cucumber", "Finish with dill, black pepper and a squeeze of lemon"] + }, + { + "name": "Spanish Omelette", + "description": "thick Spanish tortilla with potato and onion", + "ingredients": ["eggs", "potatoes", "onion", "olive oil", "salt"], + "steps": ["Peel and thinly slice potatoes, slice onion", "Cook potatoes and onion slowly in olive oil 20 min until tender (not browned)", "Beat eggs with salt, add potato mixture", "Pour into a lightly oiled pan over medium heat", "Cook 5 min, flip onto a plate then slide back in and cook 3 min more"] + }, + { + "name": "Dutch Baby Pancake", + "description": "oven-puffed German-style pancake served with lemon and sugar", + "ingredients": ["eggs", "flour", "milk", "butter", "salt", "vanilla", "lemon", "powdered sugar"], + "steps": ["Preheat oven to 220C with an oven-safe skillet inside", "Blend eggs, flour, milk, salt and vanilla until smooth", "Add butter to hot skillet and swirl to coat", "Pour in batter and immediately return to oven", "Bake 18-20 min until puffed and golden, serve with lemon and powdered sugar"] + }, + { + "name": "Eggs Florentine", + "description": "poached eggs on wilted spinach and toasted muffins with hollandaise", + "ingredients": ["eggs", "baby spinach", "English muffins", "butter", "garlic", "egg yolks", "lemon juice", "white vinegar", "salt", "cayenne"], + "steps": ["Toast muffins and keep warm", "Wilt spinach with garlic and butter, season and drain", "Make hollandaise by whisking egg yolks over hot water then slowly adding melted butter and lemon", "Poach eggs in simmering water with vinegar for 3-4 min", "Build: muffin, spinach, egg, hollandaise, pinch of cayenne"] + }, + { + "name": "Cinnamon Porridge", + "description": "creamy oat porridge with cinnamon, banana and mixed berries", + "ingredients": ["rolled oats", "milk", "water", "cinnamon", "honey", "banana", "mixed berries", "salt"], + "steps": ["Combine oats, milk, water and a pinch of salt in a saucepan", "Cook over medium heat stirring frequently for 5 min", "Stir in cinnamon and honey to taste", "Pour into bowl and top with sliced banana", "Add mixed berries and an extra drizzle of honey"] + } + ], + "lunch": [ + { + "name": "Koshari", + "description": "Egypt's national street food of rice, lentils, pasta and spiced tomato sauce", + "ingredients": ["rice", "brown lentils", "ditalini pasta", "chickpeas", "tomatoes", "garlic", "vinegar", "cumin", "coriander", "fried onions", "tomato paste"], + "steps": ["Cook lentils and rice together until tender", "Boil pasta separately until al dente", "Fry sliced onions until dark and crispy, set aside", "Simmer tomato sauce with garlic, vinegar and cumin 15 min", "Layer rice mix, pasta and chickpeas, top with sauce and crispy onions"] + }, + { + "name": "Molokhia with Chicken", + "description": "silky jute leaf soup served over rice with roasted chicken", + "ingredients": ["whole chicken", "frozen minced molokhia", "garlic", "coriander", "ghee", "bay leaves", "cardamom", "rice", "onion", "salt"], + "steps": ["Simmer chicken with onion, bay leaves and cardamom 90 min to make broth", "Roast or grill the cooked chicken to brown the skin", "Bring 4 cups broth to boil, add molokhia and cook 2 min stirring", "Fry garlic with coriander in ghee until golden, add to molokhia", "Serve over white rice with chicken on the side"] + }, + { + "name": "Mahshi", + "description": "vegetables stuffed with herbed rice and beef in tomato sauce", + "ingredients": ["courgettes", "peppers", "aubergine", "short-grain rice", "minced beef", "tomatoes", "dill", "parsley", "allspice", "cinnamon", "tomato paste", "olive oil"], + "steps": ["Hollow out vegetables using a corer", "Mix rice with beef, chopped herbs, spices and tomato paste", "Fill vegetables three-quarters full (rice expands)", "Arrange in a pot, pour over tomato broth to cover", "Simmer covered 40 min until rice is cooked and vegetables are tender"] + }, + { + "name": "Macarona Bechamel", + "description": "Egyptian baked pasta with spiced beef and creamy bechamel", + "ingredients": ["penne pasta", "minced beef", "onion", "tomato paste", "bechamel sauce", "butter", "flour", "milk", "nutmeg", "cinnamon", "allspice", "salt"], + "steps": ["Cook pasta al dente and drain", "Brown beef with onion, add tomato paste, cinnamon, allspice and simmer", "Make bechamel: melt butter, whisk in flour, add milk slowly until thick, season with nutmeg", "Mix pasta with some bechamel, layer: pasta, beef, pasta, bechamel in a baking dish", "Bake at 180C for 40-45 min until golden brown on top"] + }, + { + "name": "Bamia", + "description": "slow-cooked okra and beef stew in rich tomato sauce", + "ingredients": ["baby okra", "beef chunks", "tomatoes", "tomato paste", "onion", "garlic", "coriander", "cumin", "lemon juice", "olive oil", "salt"], + "steps": ["Brown beef in olive oil, remove and set aside", "Fry onion and garlic until golden, add spices", "Add tomatoes and tomato paste, return beef and simmer 30 min", "Trim okra stems, add whole and cook 20 min (do not stir to avoid sliminess)", "Finish with lemon juice and serve over rice"] + }, + { + "name": "Daoud Basha", + "description": "Lebanese-Egyptian spiced meatballs in tangy tomato and pine nut sauce", + "ingredients": ["minced beef", "onion", "pine nuts", "tomatoes", "tomato paste", "allspice", "cinnamon", "nutmeg", "pomegranate molasses", "ghee", "parsley"], + "steps": ["Mix beef with grated onion and spices, form into small balls", "Fry pine nuts in ghee until golden, set aside", "Brown meatballs on all sides then remove", "Make sauce with onion, tomatoes, tomato paste, molasses and simmer 10 min", "Add meatballs and pine nuts, simmer 20 min, serve over rice"] + }, + { + "name": "Kabsa", + "description": "Saudi spiced rice with chicken, tomatoes and dried fruits", + "ingredients": ["chicken pieces", "basmati rice", "tomatoes", "onion", "garlic", "dried limes", "cardamom", "cinnamon", "cloves", "raisins", "almonds", "ghee"], + "steps": ["Brown chicken in ghee, add onion and garlic until softened", "Add tomatoes, spices, dried limes and water, simmer chicken 30 min", "Remove chicken and add washed rice to broth, cook covered 20 min", "Fry raisins and almonds in ghee for garnish", "Plate rice, top with chicken and garnish"] + }, + { + "name": "Musakhan", + "description": "Palestinian roasted chicken on flatbread with caramelised onions and sumac", + "ingredients": ["chicken thighs", "onions", "sumac", "taboon flatbread", "olive oil", "allspice", "cinnamon", "toasted pine nuts", "salt"], + "steps": ["Rub chicken with sumac, allspice, cinnamon and salt, roast at 200C for 40 min", "Slowly caramelise onions in generous olive oil with more sumac for 40 min", "Arrange flatbread on a baking tray, spread onions over it", "Place chicken on top, return to oven 10 min to crisp bread", "Top with toasted pine nuts and serve"] + }, + { + "name": "Maqluba", + "description": "Palestinian upside-down rice dish with chicken and vegetables", + "ingredients": ["chicken pieces", "aubergine", "cauliflower", "basmati rice", "onion", "turmeric", "allspice", "cinnamon", "tomatoes", "oil", "toasted almonds"], + "steps": ["Fry aubergine and cauliflower until golden, drain on paper", "Brown chicken with onion and spices, add water and simmer 30 min", "Drain and layer in a deep pot: tomatoes, fried vegetables, chicken, rice", "Pour strained broth over to just cover, cook covered on low 30 min", "Rest 10 min then flip onto a platter, top with almonds"] + }, + { + "name": "Kebda Eskandarani", + "description": "Alexandria-style liver sandwich with peppers and chilli", + "ingredients": ["beef liver", "green peppers", "chilli peppers", "garlic", "cumin", "coriander", "lemon juice", "olive oil", "salt", "flatbread"], + "steps": ["Slice liver thin, season well with cumin, coriander and salt", "Heat oil in a very hot pan until smoking", "Flash-fry liver in batches 1-2 min, do not overcook", "Add sliced peppers and chilli in the last minute", "Stuff into flatbread with a squeeze of lemon"] + }, + { + "name": "Fattet Hummus", + "description": "layered dish of crispy bread, chickpeas and garlicky yogurt", + "ingredients": ["flatbread", "chickpeas", "yogurt", "tahini", "lemon juice", "garlic", "pine nuts", "olive oil", "paprika", "cumin", "parsley"], + "steps": ["Fry or toast flatbread into crispy pieces", "Warm chickpeas in broth or water, season with cumin", "Blend yogurt with tahini, garlic, lemon juice and salt", "Layer in a deep dish: bread, then chickpeas, then yogurt sauce", "Top with toasted pine nuts, drizzle of olive oil and paprika"] + }, + { + "name": "Chicken Tagine", + "description": "Moroccan slow-cooked chicken with preserved lemon, olives and spices", + "ingredients": ["chicken pieces", "preserved lemon", "green olives", "onion", "garlic", "ginger", "turmeric", "cumin", "coriander", "saffron", "olive oil", "parsley"], + "steps": ["Marinate chicken with garlic, ginger, turmeric, cumin and oil for 1 hour", "Brown chicken in a tagine or heavy pot", "Add onions, saffron, stock and cook covered on low 45 min", "Add preserved lemon strips and olives in last 10 min", "Garnish with parsley and serve with couscous or bread"] + }, + { + "name": "Chicken Caesar Salad", + "description": "grilled chicken on romaine with Caesar dressing and croutons", + "ingredients": ["chicken breast", "romaine lettuce", "parmesan", "croutons", "egg yolk", "garlic", "lemon juice", "Worcestershire sauce", "mustard", "olive oil", "black pepper"], + "steps": ["Make dressing: blend garlic, egg yolk, lemon, Worcestershire and mustard then whisk in olive oil", "Season and grill chicken 5-6 min per side until cooked, slice", "Tear romaine, toss with dressing and half the parmesan", "Top with chicken, croutons and remaining parmesan", "Finish with cracked black pepper"] + }, + { + "name": "French Onion Soup", + "description": "deeply caramelised onion broth with a cheese-topped crouton", + "ingredients": ["onions", "beef stock", "dry bread slices", "gruyere cheese", "butter", "olive oil", "thyme", "bay leaf", "salt", "black pepper"], + "steps": ["Slice onions thinly, cook in butter and oil on low heat 45 min until deep golden", "Add thyme, bay leaf and stock, simmer 20 min, season", "Ladle into oven-safe bowls and float toasted bread on top", "Cover bread with grated gruyere", "Grill under broiler until cheese is bubbling and golden"] + }, + { + "name": "Beef Burger", + "description": "juicy beef patty with lettuce, tomato and pickles in a toasted bun", + "ingredients": ["minced beef", "burger buns", "lettuce", "tomato", "red onion", "pickles", "cheddar", "mustard", "ketchup", "salt", "black pepper"], + "steps": ["Season beef with salt and pepper, form into patties, make a thumb indent in centre", "Grill or pan-fry 3-4 min per side for medium, add cheese in last minute", "Toast buns cut-side down in the same pan", "Spread mustard and ketchup on bun", "Layer lettuce, tomato, onion, patty and pickles"] + }, + { + "name": "Greek Salad with Grilled Chicken", + "description": "classic horiatiki salad topped with herb-marinated grilled chicken", + "ingredients": ["chicken breast", "tomatoes", "cucumber", "red onion", "feta cheese", "kalamata olives", "olive oil", "oregano", "lemon juice", "salt", "black pepper"], + "steps": ["Marinate chicken in olive oil, lemon, oregano and garlic 30 min", "Grill chicken 5-6 min per side until cooked through, rest and slice", "Chop tomatoes, cucumber and onion into chunky pieces", "Toss vegetables with olives, olive oil and seasoning", "Top with feta, sliced chicken and a pinch of oregano"] + }, + { + "name": "Minestrone Soup", + "description": "hearty Italian vegetable soup with pasta and beans", + "ingredients": ["cannellini beans", "tomatoes", "courgette", "carrot", "celery", "onion", "garlic", "small pasta", "vegetable stock", "basil", "parmesan", "olive oil"], + "steps": ["Saute onion, celery and carrot in olive oil until soft", "Add garlic, tomatoes and stock, bring to a boil", "Add courgette and beans, simmer 15 min", "Add pasta and cook until al dente", "Stir in torn basil, serve with parmesan"] + }, + { + "name": "Chicken Pesto Pasta", + "description": "penne with basil pesto and grilled chicken", + "ingredients": ["penne", "chicken breast", "basil pesto", "cherry tomatoes", "parmesan", "pine nuts", "olive oil", "garlic", "salt", "black pepper"], + "steps": ["Cook penne until al dente, reserve 1 cup pasta water before draining", "Season and grill chicken 5-6 min per side, rest and slice", "Halve tomatoes and saute in olive oil with garlic 3 min", "Toss hot pasta with pesto, add pasta water to loosen as needed", "Top with chicken, tomatoes, pine nuts and parmesan"] + }, + { + "name": "Smoked Turkey Club Sandwich", + "description": "triple-layer sandwich with smoked turkey, lettuce and tomato", + "ingredients": ["smoked turkey breast", "bread", "lettuce", "tomato", "cucumber", "mayonnaise", "mustard", "avocado", "salt", "black pepper"], + "steps": ["Toast three bread slices per sandwich", "Spread mayonnaise on two slices and mustard on one", "Layer first: lettuce, tomato, turkey", "Add middle toast, then avocado, cucumber and more turkey", "Top with final toast, press gently and cut into triangles with skewers"] + }, + { + "name": "Caprese Salad with Grilled Chicken", + "description": "fresh mozzarella and tomatoes with grilled chicken and basil", + "ingredients": ["chicken breast", "fresh mozzarella", "beef tomatoes", "fresh basil", "olive oil", "balsamic glaze", "salt", "black pepper"], + "steps": ["Season chicken, grill 5-6 min per side until cooked through, rest and slice", "Slice tomatoes and mozzarella to same thickness", "Alternate tomato and mozzarella slices on a plate", "Lay chicken alongside or on top", "Tear basil over, drizzle with olive oil and balsamic glaze, season"] + }, + { + "name": "Leek and Potato Soup", + "description": "velvety smooth British-style leek and potato soup", + "ingredients": ["leeks", "potatoes", "onion", "garlic", "vegetable stock", "butter", "double cream", "thyme", "salt", "black pepper"], + "steps": ["Slice leeks and onion, saute in butter until soft (10 min)", "Add diced potatoes, garlic and thyme", "Pour in stock and simmer 20 min until potatoes are tender", "Blend until smooth, stir in cream and season well", "Serve with crusty bread and a swirl of cream"] + }, + { + "name": "Chicken Souvlaki Wrap", + "description": "Greek marinated chicken skewers wrapped in pita with tzatziki", + "ingredients": ["chicken thighs", "pita bread", "yogurt", "cucumber", "garlic", "lemon juice", "olive oil", "oregano", "tomato", "red onion", "paprika"], + "steps": ["Marinate chicken in olive oil, lemon, oregano and garlic for 1 hour", "Thread onto skewers and grill 4-5 min per side", "Make tzatziki: grate and squeeze cucumber, mix with yogurt, garlic and lemon", "Warm pita on the grill", "Fill pita with chicken, tzatziki, tomato and red onion"] + }, + { + "name": "Tuna Nicoise Salad", + "description": "French composed salad with tuna, green beans, eggs and olives", + "ingredients": ["tuna in olive oil", "green beans", "cherry tomatoes", "eggs", "black olives", "red onion", "capers", "lettuce", "Dijon mustard", "lemon juice", "olive oil"], + "steps": ["Hard-boil eggs 8 min then cool and halve", "Blanch green beans in salted water 3 min, drain and refresh in cold water", "Make dressing: whisk mustard, lemon juice and olive oil", "Arrange lettuce, green beans, tomatoes, olives and onion on plates", "Top with tuna, eggs and capers, drizzle dressing over"] + }, + { + "name": "Roasted Vegetable Flatbread", + "description": "grilled flatbread topped with roasted Mediterranean vegetables and hummus", + "ingredients": ["flatbread", "courgette", "red pepper", "red onion", "cherry tomatoes", "hummus", "feta", "olive oil", "oregano", "balsamic glaze", "salt"], + "steps": ["Slice vegetables and toss with olive oil, oregano and salt, roast at 200C for 25 min", "Grill or toast flatbreads until lightly charred", "Spread hummus generously over each flatbread", "Top with warm roasted vegetables and crumbled feta", "Drizzle with balsamic glaze and serve immediately"] + } + ], + "dinner": [ + { + "name": "Kofta Mashwia", + "description": "grilled Egyptian minced beef skewers with herbs and spices", + "ingredients": ["minced beef", "minced lamb", "onion", "parsley", "garlic", "cumin", "coriander", "allspice", "paprika", "breadcrumbs", "salt"], + "steps": ["Blend onion, garlic and parsley in a food processor", "Mix with beef, lamb, spices and breadcrumbs into a smooth paste", "Refrigerate 30 min then mould onto flat skewers", "Grill on high heat 4 min per side", "Serve with flatbread, tahini and a tomato salad"] + }, + { + "name": "Fatta", + "description": "Egyptian celebratory dish of bread, rice and lamb in spiced tomato broth", + "ingredients": ["lamb pieces", "short-grain rice", "flatbread", "tomatoes", "garlic", "vinegar", "ghee", "cinnamon", "allspice", "parsley", "salt"], + "steps": ["Simmer lamb with cinnamon and allspice until very tender (90 min)", "Cook rice in the lamb broth", "Fry flatbread in ghee until crispy", "Make a quick tomato sauce with garlic and vinegar", "Layer fried bread, rice then lamb, pour sauce and broth over and garnish with parsley"] + }, + { + "name": "Sayadieh", + "description": "Lebanese spiced fish with caramelised onion rice", + "ingredients": ["white fish fillets", "basmati rice", "onions", "cumin", "turmeric", "coriander", "allspice", "lemon juice", "olive oil", "pine nuts", "parsley"], + "steps": ["Fry sliced onions slowly in olive oil 30 min until dark caramel brown", "Add spices to onions then add water and simmer for rice broth", "Fry fish fillets in olive oil until golden on both sides", "Cook rice in the spiced onion broth", "Plate rice topped with fish, fried onions and toasted pine nuts"] + }, + { + "name": "Shish Tawook", + "description": "Lebanese marinated chicken skewers with garlic sauce", + "ingredients": ["chicken breast", "yogurt", "lemon juice", "garlic", "tomato paste", "paprika", "cumin", "olive oil", "salt", "flatbread", "garlic sauce"], + "steps": ["Cube chicken and marinate in yogurt, lemon, garlic, tomato paste and spices for 4 hours", "Thread onto skewers", "Grill on high heat 4-5 min per side until charred and cooked through", "Make garlic sauce by blending garlic with lemon juice and oil until creamy white", "Serve in bread with garlic sauce and pickles"] + }, + { + "name": "Chicken Shawarma", + "description": "spiced rotisserie-style chicken wrapped in bread with tahini and vegetables", + "ingredients": ["chicken thighs", "yogurt", "lemon juice", "garlic", "cumin", "turmeric", "cinnamon", "paprika", "flatbread", "tahini", "pickles", "tomato", "parsley"], + "steps": ["Marinate chicken in yogurt, lemon and spices overnight", "Grill or roast at 220C for 35-40 min until charred", "Rest 5 min then slice thin", "Spread tahini on warmed flatbread, add chicken slices", "Top with pickles, tomato and parsley, roll tightly"] + }, + { + "name": "Freekeh Soup", + "description": "smoky whole freekeh grain soup with chicken and warm spices", + "ingredients": ["chicken pieces", "whole freekeh", "onion", "garlic", "cinnamon", "allspice", "cumin", "ghee", "lemon juice", "parsley", "salt"], + "steps": ["Simmer chicken with onion, cinnamon and allspice 45 min to make broth", "Remove chicken, shred the meat, discard bones and skin", "Wash freekeh and add to strained broth with more spices", "Cook covered 40 min until freekeh is tender", "Return chicken, adjust seasoning, serve with lemon and parsley"] + }, + { + "name": "Ouzi", + "description": "slow-cooked whole lamb stuffed with spiced rice and nuts on a platter", + "ingredients": ["lamb shoulder", "basmati rice", "onion", "mixed nuts", "raisins", "cinnamon", "allspice", "cardamom", "ghee", "yogurt", "salt"], + "steps": ["Marinate lamb in yogurt, garlic and spices overnight", "Slow roast lamb covered at 160C for 4-5 hours until falling apart", "Cook spiced rice in lamb broth with nuts and raisins", "Arrange rice on a large platter", "Place lamb on top and pour pan juices over"] + }, + { + "name": "Hamam Mahshi", + "description": "Egyptian stuffed pigeon with green wheat and caramelised onions", + "ingredients": ["pigeons", "freekeh or rice", "onion", "ghee", "liver (from pigeons)", "cinnamon", "allspice", "salt", "black pepper"], + "steps": ["Caramelise diced onion in ghee until golden", "Add chopped liver and freekeh, season with cinnamon and allspice", "Cook filling 5 min then stuff loosely into cavities", "Truss birds and rub with spiced ghee", "Roast at 200C for 45 min until golden, basting halfway"] + }, + { + "name": "Grilled Sea Bass with Chermoula", + "description": "whole sea bass marinated in North African herb and spice sauce", + "ingredients": ["whole sea bass", "parsley", "coriander", "garlic", "cumin", "paprika", "lemon juice", "olive oil", "salt", "chilli"], + "steps": ["Blend parsley, coriander, garlic, cumin, paprika, lemon, chilli and oil into chermoula", "Score fish 3-4 times on each side", "Rub chermoula inside and over fish, marinate 1 hour", "Grill on high heat 5-6 min per side until skin is charred", "Serve with lemon wedges and a simple salad"] + }, + { + "name": "Kebab Halla", + "description": "Egyptian beef kebabs braised in caramelised onion sauce", + "ingredients": ["minced beef", "onions", "garlic", "cumin", "allspice", "tomato paste", "ghee", "parsley", "salt", "black pepper"], + "steps": ["Form seasoned minced beef into long finger-shaped kebabs", "Brown kebabs on all sides in ghee, remove", "In same pot slowly caramelise sliced onions 30 min", "Add garlic, tomato paste and return kebabs", "Braise covered on low heat 20 min, serve over rice with parsley"] + }, + { + "name": "Arayes", + "description": "Lebanese grilled flatbread stuffed with spiced minced beef", + "ingredients": ["minced beef", "onion", "parsley", "tomato", "cumin", "cinnamon", "allspice", "chilli", "flatbread", "olive oil", "salt"], + "steps": ["Mix beef with finely grated onion, chopped parsley, tomato and spices", "Cut flatbread in half and open like a pocket", "Spread meat filling inside each half and press closed", "Brush outsides with olive oil", "Grill on high heat 3-4 min per side until charred and filling is cooked"] + }, + { + "name": "Mixed Grill Platter", + "description": "assortment of grilled lamb chops, kofta and shish tawook", + "ingredients": ["lamb chops", "minced beef", "chicken thighs", "yogurt", "garlic", "lemon juice", "cumin", "paprika", "allspice", "parsley", "flatbread"], + "steps": ["Marinate chicken in yogurt, lemon and garlic for 2 hours", "Season lamb chops with cumin, allspice, salt and pepper", "Mix beef with grated onion, parsley and spices, form into kofta on skewers", "Grill all meats over high heat: chicken 10 min, lamb 4 min, kofta 6 min", "Arrange on a platter with flatbread, salad and dips"] + }, + { + "name": "Spaghetti Bolognese", + "description": "slow-cooked Italian beef ragu on spaghetti with parmesan", + "ingredients": ["spaghetti", "minced beef", "onion", "carrot", "celery", "garlic", "tomatoes", "tomato paste", "beef stock", "olive oil", "bay leaf", "parmesan"], + "steps": ["Saute onion, carrot and celery in olive oil until soft", "Add beef and brown on high heat, breaking up lumps", "Stir in garlic, tomato paste, tomatoes, stock and bay leaf", "Simmer on low heat 90 min stirring occasionally", "Cook spaghetti al dente, toss with ragu and parmesan"] + }, + { + "name": "Roast Chicken with Herbs", + "description": "crispy roasted whole chicken with garlic, lemon and fresh herbs", + "ingredients": ["whole chicken", "butter", "garlic", "lemon", "thyme", "rosemary", "olive oil", "salt", "black pepper", "onion"], + "steps": ["Mix softened butter with garlic, thyme, lemon zest, salt and pepper", "Loosen skin and rub butter under and over skin", "Stuff cavity with lemon halves, garlic and rosemary", "Roast on a bed of onions at 200C for 1h 20min", "Rest 15 min before carving, use pan juices as gravy"] + }, + { + "name": "Beef Stew with Root Vegetables", + "description": "rich slow-cooked beef stew with carrots, parsnip and potatoes", + "ingredients": ["beef chuck", "potatoes", "carrots", "parsnip", "onion", "garlic", "tomatoes", "beef stock", "thyme", "bay leaf", "flour", "olive oil"], + "steps": ["Cube beef and dust with flour, brown in batches in olive oil", "Saute onion and garlic until soft", "Add stock, tomatoes, thyme and bay leaf, return beef and bring to simmer", "Cook covered on low heat 1.5 hours", "Add root vegetables and cook 30 min more until tender"] + }, + { + "name": "Lamb Chops with Roasted Vegetables", + "description": "herb-marinated lamb chops served with mixed roasted Mediterranean vegetables", + "ingredients": ["lamb chops", "courgette", "red pepper", "aubergine", "cherry tomatoes", "garlic", "rosemary", "thyme", "olive oil", "lemon", "salt"], + "steps": ["Marinate lamb in olive oil, garlic, rosemary and lemon for 1 hour", "Chop vegetables, toss with olive oil, thyme, salt and roast at 200C for 35 min", "Sear lamb chops in a hot pan 3-4 min per side for medium-rare", "Rest lamb 5 min before serving", "Arrange vegetables on plate and place chops on top with pan juices"] + }, + { + "name": "Chicken Parmesan", + "description": "breaded chicken breast baked with tomato sauce and melted mozzarella", + "ingredients": ["chicken breasts", "breadcrumbs", "parmesan", "eggs", "tomato sauce", "mozzarella", "basil", "olive oil", "garlic", "salt", "black pepper"], + "steps": ["Pound chicken breasts to even thickness, season", "Coat in beaten egg then seasoned breadcrumbs mixed with parmesan", "Fry in olive oil 3-4 min per side until golden", "Transfer to baking dish, spoon tomato sauce over each piece", "Top with mozzarella and bake at 200C for 15 min, garnish with basil"] + }, + { + "name": "Beef Lasagne", + "description": "layered pasta sheets with beef ragu and creamy bechamel", + "ingredients": ["lasagne sheets", "minced beef", "onion", "garlic", "tomatoes", "tomato paste", "bechamel sauce", "mozzarella", "parmesan", "olive oil", "basil"], + "steps": ["Brown beef with onion and garlic, add tomatoes and paste, simmer 30 min", "Make bechamel: melt butter, whisk in flour, gradually add milk, season with nutmeg", "Layer in a deep baking dish: bechamel, pasta, beef, pasta, beef, bechamel, mozzarella", "Repeat layers ending with bechamel and parmesan on top", "Bake at 180C for 40 min until bubbling and golden"] + }, + { + "name": "Grilled Salmon with Lemon Butter", + "description": "salmon fillet grilled with lemon butter sauce and capers", + "ingredients": ["salmon fillets", "butter", "lemon", "capers", "garlic", "dill", "olive oil", "salt", "black pepper"], + "steps": ["Pat salmon dry and season with salt and pepper", "Brush with olive oil and grill skin-side down 4-5 min until skin is crispy", "Flip and cook 2-3 min more until just cooked through", "Make sauce: melt butter with garlic, lemon juice and capers", "Drizzle sauce over salmon and finish with fresh dill"] + }, + { + "name": "Shepherd's Pie", + "description": "minced lamb with vegetables under a creamy mashed potato crust", + "ingredients": ["minced lamb", "potatoes", "onion", "carrot", "frozen peas", "garlic", "tomato paste", "lamb stock", "butter", "milk", "thyme", "rosemary"], + "steps": ["Brown lamb with onion and carrot, drain excess fat", "Add garlic, tomato paste, stock, thyme and rosemary, simmer 20 min, stir in peas", "Boil potatoes until tender, mash with butter and milk until smooth", "Transfer lamb to a baking dish, top with mashed potato", "Fork the surface and bake at 200C for 25 min until golden"] + }, + { + "name": "Chicken and Seafood Paella", + "description": "Spanish saffron rice with chicken, prawns and mussels", + "ingredients": ["chicken thighs", "prawns", "mussels", "paella rice", "onion", "red pepper", "garlic", "tomatoes", "saffron", "paprika", "olive oil", "chicken stock"], + "steps": ["Brown chicken in olive oil, set aside", "Saute onion, pepper and garlic until soft, add paprika and tomatoes", "Add rice and stir to coat, pour in hot saffron-infused stock", "Nestle chicken back in and cook on medium heat 15 min without stirring", "Add prawns and mussels on top, cook 8-10 min until seafood opens and rice is done"] + }, + { + "name": "Moussaka", + "description": "Greek layered baked dish of aubergine, beef ragu and bechamel", + "ingredients": ["aubergine", "minced beef", "onion", "garlic", "tomatoes", "cinnamon", "allspice", "red wine vinegar", "bechamel sauce", "parmesan", "olive oil"], + "steps": ["Slice aubergine, brush with oil and roast at 200C for 25 min until golden", "Brown beef with onion, garlic, cinnamon and tomatoes, simmer 20 min", "Make thick bechamel with parmesan", "Layer in a baking dish: aubergine, beef, aubergine, thick bechamel", "Bake at 180C for 45 min until golden brown on top"] + }, + { + "name": "Chicken and Mushroom Pie", + "description": "creamy chicken and mushroom filling under a golden shortcrust pastry lid", + "ingredients": ["chicken thighs", "mixed mushrooms", "onion", "garlic", "double cream", "chicken stock", "thyme", "butter", "flour", "shortcrust pastry", "egg", "salt"], + "steps": ["Cook chicken in stock until tender, shred into pieces", "Saute mushrooms, onion and garlic in butter until soft", "Make a sauce with flour, stock and cream, add chicken, mushrooms and thyme", "Pour filling into a pie dish, top with pastry and crimp edges", "Brush with egg and bake at 200C for 30-35 min until golden"] + }, + { + "name": "Greek Lamb Stew", + "description": "slow-cooked Greek lamb with tomatoes, cinnamon and pearl onions", + "ingredients": ["lamb shoulder", "pearl onions", "tomatoes", "tomato paste", "garlic", "cinnamon stick", "allspice", "bay leaf", "olive oil", "red wine vinegar", "parsley", "salt"], + "steps": ["Brown lamb pieces in olive oil on all sides, remove", "Add pearl onions and cook until coloured, add garlic", "Stir in tomatoes, tomato paste, vinegar, cinnamon, allspice and bay leaf", "Return lamb, add water to cover and simmer covered on low heat 90 min", "Uncover and simmer 20 min more to thicken, serve with bread or orzo"] + } + ], + "snack": [ + { + "name": "Basbousa", + "description": "Egyptian semolina cake soaked in rose water syrup", + "ingredients": ["semolina", "yogurt", "sugar", "butter", "coconut", "baking powder", "vanilla", "rose water", "almonds", "syrup"], + "steps": ["Mix semolina, yogurt, sugar, melted butter, coconut and baking powder", "Spread in a greased tray, score into diamond shapes and press an almond on each", "Bake at 180C for 30 min until golden", "Make syrup: boil sugar, water, lemon and rose water 5 min then cool", "Pour cold syrup over hot cake and rest 1 hour before serving"] + }, + { + "name": "Konafa bil Ashta", + "description": "shredded kataifi pastry with milk cream filling and simple syrup", + "ingredients": ["kataifi pastry", "ghee", "milk", "heavy cream", "cornstarch", "sugar", "rose water", "orange blossom water", "pistachios"], + "steps": ["Shred kataifi, toss well with melted ghee", "Make ashta: heat milk and cream, whisk in cornstarch until thick, add rose water and cool", "Press half the pastry into a greased pan, spread ashta, cover with remaining pastry", "Bake at 200C for 40-45 min until deep golden", "Pour cool syrup over hot konafa, top with crushed pistachios"] + }, + { + "name": "Kahk", + "description": "Egyptian eid butter cookies dusted with powdered sugar", + "ingredients": ["flour", "ghee", "semolina", "sugar", "instant yeast", "warm water", "sesame seeds", "anise seeds", "mahlab", "powdered sugar"], + "steps": ["Mix flour, semolina, sugar, yeast and spices, rub in ghee until like breadcrumbs", "Add warm water gradually and knead to a soft dough, rest 1 hour", "Shape into small rounds or rings, coat lightly in sesame seeds", "Bake at 180C for 15-18 min until pale golden (they harden as they cool)", "Dust generously with powdered sugar when completely cool"] + }, + { + "name": "Balah El Sham", + "description": "Egyptian crispy choux pastry fingers soaked in syrup", + "ingredients": ["flour", "water", "butter", "eggs", "salt", "oil for frying", "sugar", "lemon juice", "rose water"], + "steps": ["Make choux: boil water and butter, add flour and stir until dough pulls from pan", "Beat in eggs one at a time until smooth and glossy", "Pipe 8cm lengths into hot oil using a star nozzle, fry until deep golden", "Make syrup: boil sugar and water 5 min with lemon, add rose water", "Soak hot pastry in syrup for 2-3 min and drain"] + }, + { + "name": "Luqaimat", + "description": "Gulf-style fried dough balls drizzled with date syrup and sesame", + "ingredients": ["flour", "yeast", "sugar", "salt", "warm water", "oil for frying", "date syrup", "sesame seeds"], + "steps": ["Dissolve yeast in warm water with sugar, rest 10 min", "Mix with flour and salt to a thick batter, rest 1 hour until bubbly", "Drop spoonfuls into hot oil and fry until golden all over, about 3-4 min", "Drain on paper towels", "Drizzle generously with date syrup and sprinkle with sesame seeds, serve immediately"] + }, + { + "name": "Meshabek", + "description": "Egyptian funnel cakes in flower shapes soaked in syrup", + "ingredients": ["flour", "yeast", "sugar", "saffron", "warm water", "oil for frying", "sugar syrup", "lemon juice", "rose water"], + "steps": ["Make a thin batter with flour, yeast, sugar, saffron and water, rest 1 hour", "Heat oil in a deep pan, fill a squeeze bottle or piping bag with batter", "Pipe swirling flower shapes into hot oil, fry 2 min per side until golden", "Make syrup with sugar, water and lemon, bring to a boil and add rose water", "Immediately dip hot meshabek into warm syrup, drain and serve"] + }, + { + "name": "Ghorayeba", + "description": "Egyptian melt-in-the-mouth shortbread cookies", + "ingredients": ["ghee", "powdered sugar", "flour", "cornstarch", "vanilla", "pistachios"], + "steps": ["Beat softened ghee with powdered sugar until very light and fluffy", "Sift in flour and cornstarch, mix to a soft dough - do not overwork", "Roll into small balls, press a pistachio on each and place on a tray", "Bake at 160C for 12-15 min - they should be white, not golden", "Cool completely before moving as they are very fragile"] + }, + { + "name": "Zalabia", + "description": "crispy Middle Eastern fried pastry spirals in syrup", + "ingredients": ["flour", "yeast", "yogurt", "warm water", "oil for frying", "sugar", "lemon juice", "rose water", "saffron"], + "steps": ["Mix flour, yeast, yogurt and water to a smooth batter, rest 1 hour", "Heat oil to 180C", "Pipe thin spirals or small fritters into the oil, fry until golden and crispy", "Prepare warm syrup with saffron, lemon and rose water", "Dip hot zalabia in syrup for 30 seconds, drain and serve warm"] + }, + { + "name": "Shaabiyat", + "description": "crispy phyllo pastry squares filled with cream and drenched in syrup", + "ingredients": ["phyllo pastry", "butter", "whole milk", "heavy cream", "sugar", "cornstarch", "rose water", "orange blossom water", "pistachios"], + "steps": ["Make ashta cream: heat milk and cream, whisk in cornstarch, sweeten and add rose water, cool", "Layer 4 sheets phyllo brushed with butter, add a line of cream, fold into a square", "Brush tops with butter", "Bake at 200C for 20 min until deep golden", "Drench in simple syrup immediately, top with crushed pistachios"] + }, + { + "name": "Asabeya bil Ashta", + "description": "phyllo pastry fingers rolled with cream and soaked in syrup", + "ingredients": ["phyllo pastry", "butter", "ashta cream", "whole milk", "cornstarch", "heavy cream", "rose water", "sugar syrup", "pistachios"], + "steps": ["Make ashta cream: thicken milk and cream with cornstarch, add rose water and cool", "Lay a phyllo sheet, brush with butter, place a line of cream at one end", "Roll into a tight finger shape and seal edge with butter", "Bake at 200C for 18-20 min until golden and crispy", "Pour syrup over hot pastry, let absorb and top with crushed pistachios"] + }, + { + "name": "Harissa Cake", + "description": "North African moist semolina and almond cake with honey syrup", + "ingredients": ["semolina", "ground almonds", "yogurt", "ghee", "sugar", "baking powder", "vanilla", "honey", "orange blossom water", "whole almonds"], + "steps": ["Mix semolina, ground almonds, sugar, baking powder and vanilla", "Stir in melted ghee and yogurt until combined", "Pour into a greased tin and press whole almonds across the top", "Bake at 175C for 25-30 min until golden", "Warm honey with orange blossom water and pour over immediately after baking"] + }, + { + "name": "Om Ali", + "description": "warm Egyptian bread pudding with nuts and cream", + "ingredients": ["puff pastry", "full-fat milk", "heavy cream", "sugar", "walnuts", "almonds", "pistachios", "raisins", "desiccated coconut", "vanilla"], + "steps": ["Bake puff pastry until golden then tear into rough pieces", "Bring milk, cream and sugar to a gentle simmer", "Place pastry pieces in a baking dish with nuts, raisins and coconut", "Pour hot milk mixture over to soak everything", "Bake at 200C for 15-20 min until bubbling and top is golden"] + }, + { + "name": "Chocolate Chip Cookies", + "description": "classic American chewy cookies with dark chocolate chips", + "ingredients": ["flour", "butter", "brown sugar", "caster sugar", "eggs", "vanilla", "baking soda", "salt", "dark chocolate chips"], + "steps": ["Beat butter with both sugars until light and fluffy", "Add eggs one at a time with vanilla, mix well", "Fold in sifted flour, baking soda and salt", "Stir in chocolate chips, chill dough 30 min", "Scoop balls onto lined trays, bake at 180C for 10-12 min until edges are set but centres look soft"] + }, + { + "name": "Banana Bread", + "description": "moist loaf cake made with overripe bananas and walnuts", + "ingredients": ["overripe bananas", "flour", "butter", "sugar", "eggs", "baking soda", "salt", "vanilla", "walnuts", "cinnamon"], + "steps": ["Mash very ripe bananas with a fork until smooth", "Melt butter and mix with sugar, eggs and vanilla", "Stir in mashed bananas then fold in flour, baking soda, salt and cinnamon", "Fold in chopped walnuts and pour into a greased loaf tin", "Bake at 180C for 55-65 min until a skewer comes out clean"] + }, + { + "name": "Brownies", + "description": "dense fudgy dark chocolate brownies with a crinkly top", + "ingredients": ["dark chocolate", "butter", "sugar", "eggs", "flour", "cocoa powder", "vanilla", "salt"], + "steps": ["Melt dark chocolate with butter, cool slightly", "Whisk sugar and eggs together vigorously until pale and thick", "Stir in chocolate mixture and vanilla", "Fold in flour, cocoa and salt until just combined - do not overmix", "Pour into a greased square tin and bake at 180C for 20-22 min until top is set but centre wobbles slightly"] + }, + { + "name": "Classic Cheesecake", + "description": "baked New York-style cheesecake on a digestive biscuit base", + "ingredients": ["cream cheese", "sour cream", "sugar", "eggs", "vanilla", "lemon zest", "digestive biscuits", "butter", "cornstarch"], + "steps": ["Crush biscuits, mix with melted butter and press into a springform tin, chill", "Beat cream cheese, sugar and cornstarch until smooth, add eggs one at a time", "Mix in sour cream, vanilla and lemon zest", "Pour over base and bake at 160C for 55 min until just set with a slight wobble", "Cool in oven with door ajar then refrigerate overnight"] + }, + { + "name": "Apple Crumble", + "description": "spiced stewed apples under a buttery oat crumble topping", + "ingredients": ["cooking apples", "sugar", "cinnamon", "flour", "rolled oats", "butter", "brown sugar", "salt"], + "steps": ["Peel, core and slice apples, toss with sugar and cinnamon, spread in a baking dish", "Rub cold butter into flour until like breadcrumbs, stir in oats and brown sugar", "Spread crumble evenly over apples without pressing down", "Bake at 190C for 35-40 min until crumble is golden and apples are bubbling", "Serve warm with custard or vanilla ice cream"] + }, + { + "name": "Victoria Sponge", + "description": "classic British sandwich cake with jam and fresh cream", + "ingredients": ["flour", "butter", "sugar", "eggs", "baking powder", "vanilla", "jam", "double cream", "powdered sugar"], + "steps": ["Beat butter and sugar until very pale and fluffy", "Add eggs one at a time with a spoonful of flour each time", "Fold in remaining sifted flour and baking powder", "Divide between two 20cm tins, bake at 180C for 25 min until springy", "Sandwich cooled cakes with jam and whipped cream, dust top with powdered sugar"] + }, + { + "name": "Lemon Drizzle Cake", + "description": "light lemon sponge soaked with sharp lemon sugar syrup", + "ingredients": ["flour", "butter", "sugar", "eggs", "lemon zest", "baking powder", "yogurt", "lemon juice", "icing sugar"], + "steps": ["Beat butter and sugar until fluffy, add eggs with lemon zest", "Fold in flour, baking powder and yogurt", "Pour into a greased loaf tin and bake at 180C for 45-50 min", "Mix lemon juice with granulated sugar to make the drizzle", "Pierce hot cake all over with a skewer, pour drizzle over and leave to cool in tin"] + }, + { + "name": "Profiteroles", + "description": "light choux pastry balls filled with cream and topped with chocolate sauce", + "ingredients": ["flour", "butter", "water", "eggs", "salt", "double cream", "vanilla", "dark chocolate", "butter for sauce"], + "steps": ["Make choux: boil water and butter, beat in flour then cool, beat in eggs until smooth and glossy", "Pipe golf-ball-sized rounds onto lined trays, bake at 200C for 25 min until puffed and golden", "Do not open the oven! Cool completely before filling", "Whip cream with vanilla to soft peaks, pipe into split profiteroles", "Melt chocolate with butter and cream, drizzle over profiteroles and serve"] + }, + { + "name": "Fresh Fruit Tart", + "description": "sweet shortcrust pastry shell filled with vanilla cream and seasonal fruit", + "ingredients": ["shortcrust pastry", "butter", "sugar", "egg", "flour", "milk", "egg yolks", "cornstarch", "vanilla", "mixed fruit", "apricot jam"], + "steps": ["Line a tart tin with pastry, blind bake at 190C for 20 min until golden", "Make creme patissiere: heat milk, whisk yolks with sugar and cornstarch, pour milk in slowly, cook stirring until thick, cool", "Fill cooled tart shell with cream patissiere", "Arrange sliced fresh fruit decoratively on top", "Warm and sieve apricot jam, brush over fruit to glaze"] + }, + { + "name": "Cinnamon Rolls", + "description": "soft fluffy rolls with a cinnamon sugar filling and vanilla icing", + "ingredients": ["flour", "yeast", "milk", "butter", "sugar", "eggs", "salt", "brown sugar", "cinnamon", "cream cheese", "vanilla", "powdered sugar"], + "steps": ["Mix flour, yeast, warm milk, butter, sugar and eggs into a soft dough, knead 10 min, rest 1 hour", "Roll into a large rectangle, spread with softened butter and cinnamon sugar", "Roll tightly into a log and cut into 12 rounds", "Place in a greased tin, prove 40 min until doubled", "Bake at 180C for 20-25 min, cool slightly then ice with cream cheese frosting"] + }, + { + "name": "Madeleine Cookies", + "description": "small French shell-shaped sponge cakes with lemon zest", + "ingredients": ["flour", "sugar", "butter", "eggs", "lemon zest", "vanilla", "baking powder", "salt", "honey"], + "steps": ["Melt butter and cool to room temperature", "Whisk eggs, sugar, honey and vanilla until pale, fold in sifted flour, baking powder and salt", "Stir in melted butter and lemon zest", "Rest batter in the fridge 1 hour (essential for the hump)", "Fill greased madeleine moulds to three-quarters, bake at 200C for 10-12 min until golden and springy"] + }, + { + "name": "Churros with Chocolate Sauce", + "description": "crispy Spanish fried dough sticks with a thick hot chocolate dip", + "ingredients": ["flour", "water", "butter", "salt", "sugar", "oil for frying", "cinnamon sugar", "dark chocolate", "double cream", "milk"], + "steps": ["Boil water with butter and salt, stir in flour and cook until dough pulls from pan", "Pipe long strips into hot oil using a star nozzle, fry 3-4 min until golden and crispy", "Drain and roll immediately in cinnamon sugar", "Make chocolate sauce: heat cream and milk, pour over chopped chocolate, stir smooth", "Serve churros with warm chocolate sauce for dipping"] + } + ] +} diff --git a/jeeves/recipes.py b/jeeves/recipes.py new file mode 100644 index 0000000..ae0efbf --- /dev/null +++ b/jeeves/recipes.py @@ -0,0 +1,123 @@ +""" +Sopel plugin: jeeves recipes + +Egyptian, Middle Eastern and Western recipes. + + !breakfast [list] - random breakfast dish or list all + !lunch [list] - random lunch dish or list all + !dinner [list] - random dinner dish or list all + !snack [list] - random snack or list all + !recipe <name> - full recipe (up to 3 lines) +""" + +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 + +import json +import random + +from sopel import plugin + + +def setup(bot): + jv.ensure_setup(bot) + path = bot.memory["jv_recipes_path"] + try: + with open(path) as f: + bot.memory["jv_recipes"] = json.load(f) + except Exception: + bot.memory["jv_recipes"] = {"breakfast": [], "lunch": [], "dinner": [], "snack": []} + + +def _fmt_random(category, dish): + return ( + f"{category.capitalize()} suggestion: " + f"{dish['name']} | {dish['description']}. " + f"Type !recipe {dish['name'].lower()} for the full recipe." + ) + + +def _fmt_list(category, dishes): + names = ", ".join(d["name"] for d in dishes) + return f"{category.capitalize()} dishes ({len(dishes)}): {names}" + + +def _fmt_recipe(dish): + line1 = f"{dish['name']} | {dish['description']}" + line2 = "Ingredients: " + ", ".join(dish["ingredients"]) + steps = " ".join(f"{i+1}. {s.rstrip('.')}." for i, s in enumerate(dish["steps"])) + return line1, line2, steps + + +def _all_dishes(bot): + for category, dishes in bot.memory.get("jv_recipes", {}).items(): + for dish in dishes: + yield category, dish + + +def _find_dish(bot, query): + q = query.strip().lower() + for _, dish in _all_dishes(bot): + if dish["name"].lower() == q: + return dish + for _, dish in _all_dishes(bot): + if q in dish["name"].lower(): + return dish + return None + + +def _meal_cmd(bot, trigger, category): + arg = (trigger.group(2) or "").strip().lower() + dishes = bot.memory.get("jv_recipes", {}).get(category, []) + + if not dishes: + bot.say(f"no {category} recipes loaded", trigger.sender) + return + + if arg == "list": + bot.say(_fmt_list(category, dishes), trigger.sender) + return + + bot.say(_fmt_random(category, random.choice(dishes)), trigger.sender) + + +@plugin.command("breakfast") +@plugin.require_chanmsg +def cmd_breakfast(bot, trigger): + _meal_cmd(bot, trigger, "breakfast") + + +@plugin.command("lunch") +@plugin.require_chanmsg +def cmd_lunch(bot, trigger): + _meal_cmd(bot, trigger, "lunch") + + +@plugin.command("dinner") +@plugin.require_chanmsg +def cmd_dinner(bot, trigger): + _meal_cmd(bot, trigger, "dinner") + + +@plugin.command("snack") +@plugin.require_chanmsg +def cmd_snack(bot, trigger): + _meal_cmd(bot, trigger, "snack") + + +@plugin.command("recipe") +@plugin.require_chanmsg +def cmd_recipe(bot, trigger): + query = (trigger.group(2) or "").strip() + if not query: + bot.say("usage: !recipe <dish name>", trigger.sender) + return + dish = _find_dish(bot, query) + if not dish: + bot.say(f"no recipe found for '{query}'", trigger.sender) + return + for line in _fmt_recipe(dish): + bot.say(line, trigger.sender) diff --git a/jeeves/respond.py b/jeeves/respond.py new file mode 100644 index 0000000..c39883b --- /dev/null +++ b/jeeves/respond.py @@ -0,0 +1,52 @@ +""" +Sopel plugin: jeeves keyword responder + +Responds to channel messages containing configured keywords with a random reply. +Can be paused/resumed via !pause / !resume (admin.py). + +Config (in jeeves.cfg): + [respond] + keywords = + word1 + word2 + responses = + reply one + reply two +""" + +import random + +from sopel import plugin +from sopel.config.types import ListAttribute, StaticSection + + +class RespondSection(StaticSection): + keywords = ListAttribute("keywords") + responses = ListAttribute("responses") + + +def setup(bot): + bot.config.define_section("respond", RespondSection, validate=False) + + +@plugin.rule(r"(?s:.*)") +@plugin.require_chanmsg +def on_message(bot, trigger): + if bot.memory.get("jv_paused"): + return + + try: + keywords = bot.config.respond.keywords or [] + responses = bot.config.respond.responses or [] + except Exception: + return + + if not keywords or not responses: + return + + try: + msg = trigger.group(0).casefold() + if any(kw.casefold() in msg for kw in keywords): + bot.say(random.choice(responses), trigger.sender) + except (UnicodeError, AttributeError): + pass diff --git a/osterman/__init__.py b/osterman/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/osterman/__init__.py diff --git a/osterman/commands.py b/osterman/commands.py new file mode 100644 index 0000000..4ee2037 --- /dev/null +++ b/osterman/commands.py @@ -0,0 +1,875 @@ +""" +Sopel plugin: osterman admin & moderation commands + +Moderation (ACL / op / owner): + !kick / !k <nick> [reason] - kick + !ban / !b <nick|mask> [reason] - ban + kick + !ban list / !b list - view channel ban list (NOTICE) + !bankick / !bk <nick|mask> [reason] - ban + kick (explicit) + !unban / !ub <nick|mask> - unban + !tempban / !tb <nick|mask> <min> [reason] - timed ban + !mute / !mu <nick|mask> - quiet (+q) + !unmute / !umu <nick|mask> - unquiet (-q) + !voice / !v <nick> - give voice + !devoice / !dv <nick> - remove voice + !op / !o <nick> - give op + !deop / !do <nick> - remove op + !halfop / !hop <nick> - give halfop + !dehalfop / !dhop <nick> - remove halfop + !lock / !lk [m|i] - lock channel + !unlock / !ulk [m|i] - unlock channel + +Auto-mode (ACL / op / owner - per channel): + !autoop / !ao <add|del|list> [nick|mask] - auto +o on join + !autovoice / !av <add|del|list> [nick|mask] - auto +v on join + !autohalfop / !ahop <add|del|list> [nick|mask] - auto +h on join + +Config (ACL / owner): + !blacklist / !bl <add|del|list> [nick|mask] - global ban list (all channels) + !acl <add|del|list> [nick] - trusted nicks (global) + !whitelist / !wl <add|del|list> [mask] - bypass all protection (global) + !except / !ex <add|del|list> [mask] - bypass protection (per channel) + !filter / !f <add|del|list> [pattern] - regex content filter (per channel) + !badword / !bw <add|del|list> [word] - word block list (per channel) + !set / !s <key> <value> - set a config value + !config / !cfg [#channel] - show channel config (NOTICE) + !idle / !id on|off - toggle idle checking + !idle / !id <nick> - show idle time + +Tracking: + !seen <nick> - last seen time + !info / !i <nick> - nick summary + !log / !l <nick> [N] - event log (NOTICE, ACL/owner only) + !afk - reset your idle timer + +Admin (owner): + !join / !j #channel - join a channel + !part / !p [#channel] - leave a channel + !chans / !c - list channels + !help / !h - this list (NOTICE) +""" + +import fnmatch +import json +import os +import threading +import time + +from sopel import plugin + + +_CONFIG_KEYS = { + "flood_threshold": int, + "flood_window_sec": int, + "caps_filter": int, + "caps_percent": int, + "caps_min_len": int, + "repeat_filter": int, + "repeat_threshold": int, + "repeat_window_sec": int, + "clone_limit": int, + "join_flood_filter": int, + "join_flood_count": int, + "join_flood_window": int, + "nick_flood_filter": int, + "nick_flood_count": int, + "nick_flood_window": int, + "badword_action": str, + "tempban_max_min": int, + "idle_warn_min": int, + "idle_kick_min": int, + "log_limit": int, + "greet": int, +} + +_CONFIG_DEFAULTS = { + "flood_threshold": 5, + "flood_window_sec": 10, + "caps_filter": 1, + "caps_percent": 70, + "caps_min_len": 10, + "repeat_filter": 1, + "repeat_threshold": 3, + "repeat_window_sec": 30, + "clone_limit": 2, + "join_flood_filter": 1, + "join_flood_count": 5, + "join_flood_window": 10, + "nick_flood_filter": 1, + "nick_flood_count": 3, + "nick_flood_window": 60, + "badword_action": "kick", + "tempban_max_min": 60, + "idle_warn_min": 45, + "idle_kick_min": 60, + "log_limit": 6, + "greet": 1, +} + + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _is_acl(bot, trigger): + return trigger.nick in bot.memory.get("os_db", {}).get("acl", []) + + +def _is_op(bot, trigger): + chan = bot.channels.get(str(trigger.sender)) + if not chan: + return False + return chan.privileges.get(trigger.nick, 0) >= plugin.OP + + +def _is_authorized(bot, trigger): + return _is_owner(bot, trigger) or _is_acl(bot, trigger) or _is_op(bot, trigger) + + +def _global_db(bot): + return bot.memory.get("os_db", {}) + + +def _chan_db(bot, channel): + db = bot.memory["os_db"] + db.setdefault("channels", {}) + return db["channels"].setdefault(channel.lower(), {}) + + +def _save(bot): + path = bot.memory["os_db_path"] + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + json.dump(bot.memory["os_db"], f, indent=2) + + +def _bot_privs(bot, channel): + chan = bot.channels.get(channel) + if not chan: + return 0 + return chan.privileges.get(bot.nick, 0) + + +def _bot_has_op(bot, channel): + return _bot_privs(bot, channel) >= plugin.OP + + +def _bot_has_halfop(bot, channel): + return _bot_privs(bot, channel) >= plugin.HALFOP + + +def _resolve_mask(bot, channel, target): + if "!" not in target and "@" not in target: + chan = bot.channels.get(channel) + if chan: + u = chan.users.get(target) + if u: + return f"*!{u.user}@{u.host}" + return target + + +def _notice(bot, nick, text): + bot.notice(text, nick) + + +# --- Moderation --- + +@plugin.command("kick", "k") +@plugin.require_chanmsg +def cmd_kick(bot, trigger): + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + if not _bot_has_halfop(bot, channel): + _notice(bot, trigger.nick, "need at least halfop to kick") + return + args = (trigger.group(2) or "").split(None, 1) + if not args or not args[0]: + _notice(bot, trigger.nick, "usage: !kick <nick> [reason]") + return + bot.write(["KICK", channel, args[0], args[1] if len(args) > 1 else "kicked"]) + + +@plugin.command("ban", "b") +@plugin.require_chanmsg +def cmd_ban(bot, trigger): + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + args = (trigger.group(2) or "").split(None, 1) + + if args and args[0].lower() == "list": + cdb = _chan_db(bot, channel) + bans = cdb.get("bans", []) + if not bans: + _notice(bot, trigger.nick, f"ban list for {channel}: (empty)") + return + _notice(bot, trigger.nick, f"-- ban list for {channel} ({len(bans)}) --") + for b in bans: + ts = time.strftime("%m-%d %H:%M", time.localtime(b.get("timestamp", 0))) + exp = f" expires {time.strftime('%m-%d %H:%M', time.localtime(b['expires']))}" if b.get("expires") else "" + _notice(bot, trigger.nick, f"{b['mask']} set by {b.get('added_by','?')} on {ts}{exp}") + return + + if not _bot_has_halfop(bot, channel): + _notice(bot, trigger.nick, "need at least halfop to ban") + return + if not args or not args[0]: + _notice(bot, trigger.nick, "usage: !ban <nick|mask> [reason] or !ban list") + return + target = args[0] + reason = args[1] if len(args) > 1 else "banned" + mask = _resolve_mask(bot, channel, target) + has_op = _bot_has_op(bot, channel) + + if has_op: + bot.write(["MODE", channel, "+b", mask]) + else: + _notice(bot, trigger.nick, f"(halfop only: kicking {target}, cannot set +b)") + + bot.write(["KICK", channel, target.split("!")[0] if "!" in target else target, reason]) + + if has_op: + with bot.memory["os_lock"]: + _chan_db(bot, channel).setdefault("bans", []).append({ + "mask": mask, "added_by": trigger.nick, + "timestamp": int(time.time()), + }) + _save(bot) + + +@plugin.command("bankick", "bk") +@plugin.require_chanmsg +def cmd_bankick(bot, trigger): + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + if not _bot_has_halfop(bot, channel): + _notice(bot, trigger.nick, "need at least halfop to bankick") + return + args = (trigger.group(2) or "").split(None, 1) + if not args or not args[0]: + _notice(bot, trigger.nick, "usage: !bankick <nick|mask> [reason]") + return + target = args[0] + reason = args[1] if len(args) > 1 else "banned" + mask = _resolve_mask(bot, channel, target) + has_op = _bot_has_op(bot, channel) + + if has_op: + bot.write(["MODE", channel, "+b", mask]) + else: + _notice(bot, trigger.nick, f"(halfop only: kicking {target}, cannot set +b)") + + bot.write(["KICK", channel, target.split("!")[0] if "!" in target else target, reason]) + + if has_op: + with bot.memory["os_lock"]: + _chan_db(bot, channel).setdefault("bans", []).append({ + "mask": mask, "added_by": trigger.nick, + "timestamp": int(time.time()), + }) + _save(bot) + + +@plugin.command("unban", "ub") +@plugin.require_chanmsg +def cmd_unban(bot, trigger): + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + if not _bot_has_op(bot, channel): + _notice(bot, trigger.nick, "need op to unban") + return + args = (trigger.group(2) or "").split() + if not args: + _notice(bot, trigger.nick, "usage: !unban <nick|mask>") + return + mask = _resolve_mask(bot, channel, args[0]) + bot.write(["MODE", channel, "-b", mask]) + + with bot.memory["os_lock"]: + cdb = _chan_db(bot, channel) + before = len(cdb.get("bans", [])) + cdb["bans"] = [ + b for b in cdb.get("bans", []) + if not fnmatch.fnmatch(b["mask"], mask) and b["mask"] != mask + ] + removed = before - len(cdb["bans"]) + _save(bot) + + msg = f"unbanned {mask}" + if removed: + msg += f" ({removed} removed from db)" + _notice(bot, trigger.nick, msg) + + +@plugin.command("tempban", "tb") +@plugin.require_chanmsg +def cmd_tempban(bot, trigger): + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + if not _bot_has_halfop(bot, channel): + _notice(bot, trigger.nick, "need at least halfop to tempban") + return + args = (trigger.group(2) or "").split(None, 2) + if len(args) < 2: + _notice(bot, trigger.nick, "usage: !tempban <nick|mask> <minutes> [reason]") + return + target = args[0] + global_cfg = bot.memory.get("os_db", {}).get("config", {}) + chan_cfg = bot.memory.get("os_db", {}).get("channels", {}).get(channel.lower(), {}).get("config", {}) + max_min = {**global_cfg, **chan_cfg}.get("tempban_max_min", 60) + try: + minutes = max(1, min(int(args[1]), max_min)) + except ValueError: + _notice(bot, trigger.nick, "minutes must be a number") + return + reason = args[2] if len(args) > 2 else f"tempban ({minutes}m)" + mask = _resolve_mask(bot, channel, target) + has_op = _bot_has_op(bot, channel) + + if has_op: + bot.write(["MODE", channel, "+b", mask]) + + bot.write(["KICK", channel, target.split("!")[0] if "!" in target else target, reason]) + + if has_op: + expires = int(time.time()) + minutes * 60 + + def unban(): + bot.write(["MODE", channel, "-b", mask]) + with bot.memory["os_lock"]: + cdb = _chan_db(bot, channel) + cdb["bans"] = [b for b in cdb.get("bans", []) if b.get("mask") != mask] + _save(bot) + + t = threading.Timer(minutes * 60, unban) + t.daemon = True + t.start() + + with bot.memory["os_lock"]: + _chan_db(bot, channel).setdefault("bans", []).append({ + "mask": mask, "added_by": trigger.nick, + "timestamp": int(time.time()), "expires": expires, + "channel": channel, + }) + _save(bot) + + _notice(bot, trigger.nick, f"tempbanned {mask} for {minutes}m") + else: + _notice(bot, trigger.nick, f"(halfop only: kicked {target}, cannot set timed ban)") + + +@plugin.command("mute", "mu") +@plugin.require_chanmsg +def cmd_mute(bot, trigger): + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + if not _bot_has_halfop(bot, channel): + _notice(bot, trigger.nick, "need at least halfop to mute") + return + args = (trigger.group(2) or "").split() + if not args: + _notice(bot, trigger.nick, "usage: !mute <nick|mask>") + return + mask = _resolve_mask(bot, channel, args[0]) + bot.write(["MODE", channel, "+q", mask]) + + +@plugin.command("unmute", "umu") +@plugin.require_chanmsg +def cmd_unmute(bot, trigger): + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + if not _bot_has_halfop(bot, channel): + _notice(bot, trigger.nick, "need at least halfop to unmute") + return + args = (trigger.group(2) or "").split() + if not args: + _notice(bot, trigger.nick, "usage: !unmute <nick|mask>") + return + mask = _resolve_mask(bot, channel, args[0]) + bot.write(["MODE", channel, "-q", mask]) + + +@plugin.command("voice", "v") +@plugin.require_chanmsg +def cmd_voice(bot, trigger): + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + if not _bot_has_halfop(bot, channel): + _notice(bot, trigger.nick, "need at least halfop to set voice") + return + args = (trigger.group(2) or "").split() + if not args: + _notice(bot, trigger.nick, "usage: !voice <nick>") + return + bot.write(["MODE", channel, "+v", args[0]]) + + +@plugin.command("devoice", "dv") +@plugin.require_chanmsg +def cmd_devoice(bot, trigger): + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + if not _bot_has_halfop(bot, channel): + _notice(bot, trigger.nick, "need at least halfop to remove voice") + return + args = (trigger.group(2) or "").split() + if not args: + _notice(bot, trigger.nick, "usage: !devoice <nick>") + return + bot.write(["MODE", channel, "-v", args[0]]) + + +@plugin.command("op", "o") +@plugin.require_chanmsg +def cmd_op(bot, trigger): + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + if not _bot_has_op(bot, channel): + _notice(bot, trigger.nick, "need op to give op") + return + args = (trigger.group(2) or "").split() + if not args: + _notice(bot, trigger.nick, "usage: !op <nick>") + return + bot.write(["MODE", channel, "+o", args[0]]) + + +@plugin.command("deop", "do") +@plugin.require_chanmsg +def cmd_deop(bot, trigger): + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + if not _bot_has_op(bot, channel): + _notice(bot, trigger.nick, "need op to remove op") + return + args = (trigger.group(2) or "").split() + if not args: + _notice(bot, trigger.nick, "usage: !deop <nick>") + return + bot.write(["MODE", channel, "-o", args[0]]) + + +@plugin.command("halfop", "hop") +@plugin.require_chanmsg +def cmd_halfop(bot, trigger): + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + if not _bot_has_op(bot, channel): + _notice(bot, trigger.nick, "need op to give halfop") + return + args = (trigger.group(2) or "").split() + if not args: + _notice(bot, trigger.nick, "usage: !halfop <nick>") + return + bot.write(["MODE", channel, "+h", args[0]]) + + +@plugin.command("dehalfop", "dhop") +@plugin.require_chanmsg +def cmd_dehalfop(bot, trigger): + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + if not _bot_has_op(bot, channel): + _notice(bot, trigger.nick, "need op to remove halfop") + return + args = (trigger.group(2) or "").split() + if not args: + _notice(bot, trigger.nick, "usage: !dehalfop <nick>") + return + bot.write(["MODE", channel, "-h", args[0]]) + + +@plugin.command("lock", "lk") +@plugin.require_chanmsg +def cmd_lock(bot, trigger): + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + if not _bot_has_op(bot, channel): + _notice(bot, trigger.nick, "need op to lock channel") + return + arg = (trigger.group(2) or "").strip().lower() + if arg == "m": + bot.write(["MODE", channel, "+m"]) + _notice(bot, trigger.nick, "channel locked (+m moderated)") + elif arg == "i": + bot.write(["MODE", channel, "+i"]) + _notice(bot, trigger.nick, "channel locked (+i invite-only)") + else: + bot.write(["MODE", channel, "+mi"]) + _notice(bot, trigger.nick, "channel locked (+mi moderated + invite-only)") + + +@plugin.command("unlock", "ulk") +@plugin.require_chanmsg +def cmd_unlock(bot, trigger): + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + if not _bot_has_op(bot, channel): + _notice(bot, trigger.nick, "need op to unlock channel") + return + arg = (trigger.group(2) or "").strip().lower() + if arg == "m": + bot.write(["MODE", channel, "-m"]) + _notice(bot, trigger.nick, "channel unlocked (-m moderated)") + elif arg == "i": + bot.write(["MODE", channel, "-i"]) + _notice(bot, trigger.nick, "channel unlocked (-i invite-only)") + else: + bot.write(["MODE", channel, "-mi"]) + _notice(bot, trigger.nick, "channel unlocked (-mi moderated + invite-only)") + + +# --- Auto-mode --- + +def _automode_cmd(bot, trigger, key, mode_char, label): + """Generic handler for !autoop, !autovoice, !autohalfop.""" + if not _is_authorized(bot, trigger): + return + channel = str(trigger.sender) + args = (trigger.group(2) or "").split() + if not args: + _notice(bot, trigger.nick, f"usage: !{label} <add|del|list> [nick|mask]") + return + sub = args[0].lower() + cdb = _chan_db(bot, channel) + if sub == "list": + entries = cdb.get(key, []) + _notice(bot, trigger.nick, + f"{label} for {channel}: " + (", ".join(entries) if entries else "(empty)")) + elif sub == "add" and len(args) > 1: + mask = args[1] + if mask not in cdb.setdefault(key, []): + cdb[key].append(mask) + _save(bot) + _notice(bot, trigger.nick, f"added {mask} to {label} for {channel}") + elif sub == "del" and len(args) > 1: + mask = args[1] + cdb[key] = [m for m in cdb.get(key, []) if m != mask] + _save(bot) + _notice(bot, trigger.nick, f"removed {mask} from {label} for {channel}") + else: + _notice(bot, trigger.nick, f"usage: !{label} <add|del|list> [nick|mask]") + + +@plugin.command("autoop", "ao") +@plugin.require_chanmsg +def cmd_autoop(bot, trigger): + _automode_cmd(bot, trigger, "autoop", "o", "autoop") + + +@plugin.command("autovoice", "av") +@plugin.require_chanmsg +def cmd_autovoice(bot, trigger): + _automode_cmd(bot, trigger, "autovoice", "v", "autovoice") + + +@plugin.command("autohalfop", "ahop") +@plugin.require_chanmsg +def cmd_autohalfop(bot, trigger): + _automode_cmd(bot, trigger, "autohalfop", "h", "autohalfop") + + +# --- Config --- + +@plugin.command("blacklist", "bl") +def cmd_blacklist(bot, trigger): + if not _is_owner(bot, trigger) and not _is_acl(bot, trigger): + return + args = (trigger.group(2) or "").split() + dest = str(trigger.sender) if str(trigger.sender).startswith("#") else trigger.nick + if not args: + _notice(bot, trigger.nick, "usage: !blacklist <add|del|list> [nick|mask]") + return + sub = args[0].lower() + db = _global_db(bot) + if sub == "list": + bl = db.get("blacklist", []) + _notice(bot, trigger.nick, + "blacklist: " + (", ".join(bl) if bl else "(empty)")) + elif sub == "add" and len(args) > 1: + mask = args[1] + if mask not in db.setdefault("blacklist", []): + db["blacklist"].append(mask) + _save(bot) + _notice(bot, trigger.nick, f"added {mask} to blacklist") + elif sub == "del" and len(args) > 1: + mask = args[1] + db["blacklist"] = [m for m in db.get("blacklist", []) if m != mask] + _save(bot) + _notice(bot, trigger.nick, f"removed {mask} from blacklist") + else: + _notice(bot, trigger.nick, "usage: !blacklist <add|del|list> [nick|mask]") + + +@plugin.command("acl") +@plugin.require_chanmsg +def cmd_acl(bot, trigger): + if not _is_owner(bot, trigger) and not _is_acl(bot, trigger): + return + args = (trigger.group(2) or "").split() + channel = str(trigger.sender) + if not args: + _notice(bot, trigger.nick, "usage: !acl <add|del|list> [nick]") + return + sub = args[0].lower() + db = _global_db(bot) + if sub == "list": + acl = db.get("acl", []) + _notice(bot, trigger.nick, "acl: " + (", ".join(acl) if acl else "(empty)")) + elif sub == "add" and len(args) > 1: + nick = args[1] + if nick not in db.setdefault("acl", []): + db["acl"].append(nick) + _save(bot) + _notice(bot, trigger.nick, f"added {nick} to acl") + elif sub == "del" and len(args) > 1: + nick = args[1] + db["acl"] = [n for n in db.get("acl", []) if n != nick] + _save(bot) + _notice(bot, trigger.nick, f"removed {nick} from acl") + else: + _notice(bot, trigger.nick, "usage: !acl <add|del|list> [nick]") + + +@plugin.command("whitelist", "wl") +@plugin.require_chanmsg +def cmd_wl(bot, trigger): + if not _is_owner(bot, trigger) and not _is_acl(bot, trigger): + return + args = (trigger.group(2) or "").split() + channel = str(trigger.sender) + if not args: + _notice(bot, trigger.nick, "usage: !wl <add|del|list> [mask]") + return + sub = args[0].lower() + db = _global_db(bot) + if sub == "list": + wl = db.get("whitelist", []) + _notice(bot, trigger.nick, "whitelist: " + (", ".join(wl) if wl else "(empty)")) + elif sub == "add" and len(args) > 1: + mask = args[1] + if mask not in db.setdefault("whitelist", []): + db["whitelist"].append(mask) + _save(bot) + _notice(bot, trigger.nick, f"added {mask} to whitelist") + elif sub == "del" and len(args) > 1: + mask = args[1] + db["whitelist"] = [m for m in db.get("whitelist", []) if m != mask] + _save(bot) + _notice(bot, trigger.nick, f"removed {mask} from whitelist") + else: + _notice(bot, trigger.nick, "usage: !wl <add|del|list> [mask]") + + +@plugin.command("except", "ex") +@plugin.require_chanmsg +def cmd_except(bot, trigger): + if not _is_owner(bot, trigger) and not _is_acl(bot, trigger): + return + args = (trigger.group(2) or "").split() + channel = str(trigger.sender) + if not args: + _notice(bot, trigger.nick, "usage: !except <add|del|list> [mask]") + return + sub = args[0].lower() + cdb = _chan_db(bot, channel) + if sub == "list": + exc = cdb.get("exceptions", []) + _notice(bot, trigger.nick, + f"exceptions for {channel}: " + (", ".join(exc) if exc else "(empty)")) + elif sub == "add" and len(args) > 1: + mask = args[1] + if mask not in cdb.setdefault("exceptions", []): + cdb["exceptions"].append(mask) + _save(bot) + _notice(bot, trigger.nick, f"added {mask} to exceptions for {channel}") + elif sub == "del" and len(args) > 1: + mask = args[1] + cdb["exceptions"] = [m for m in cdb.get("exceptions", []) if m != mask] + _save(bot) + _notice(bot, trigger.nick, f"removed {mask} from exceptions for {channel}") + else: + _notice(bot, trigger.nick, "usage: !except <add|del|list> [mask]") + + +@plugin.command("filter", "f") +@plugin.require_chanmsg +def cmd_filter(bot, trigger): + if not _is_owner(bot, trigger) and not _is_acl(bot, trigger): + return + args = (trigger.group(2) or "").split(None, 1) + channel = str(trigger.sender) + if not args: + _notice(bot, trigger.nick, "usage: !filter <add|del|list> [pattern]") + return + sub = args[0].lower() + cdb = _chan_db(bot, channel) + if sub == "list": + filters = cdb.get("filters", []) + if not filters: + _notice(bot, trigger.nick, f"filters for {channel}: (none)") + else: + for i, f in enumerate(filters): + _notice(bot, trigger.nick, f"[{i}] {f}") + elif sub == "add" and len(args) > 1: + cdb.setdefault("filters", []).append(args[1]) + _save(bot) + _notice(bot, trigger.nick, f"filter added: {args[1]}") + elif sub == "del" and len(args) > 1: + try: + removed = cdb["filters"].pop(int(args[1])) + _save(bot) + _notice(bot, trigger.nick, f"filter removed: {removed}") + except (ValueError, IndexError): + _notice(bot, trigger.nick, "usage: !filter del <index> (use !filter list for indices)") + else: + _notice(bot, trigger.nick, "usage: !filter <add|del|list> [pattern]") + + +@plugin.command("badword", "bw") +@plugin.require_chanmsg +def cmd_badword(bot, trigger): + if not _is_owner(bot, trigger) and not _is_acl(bot, trigger): + return + args = (trigger.group(2) or "").split(None, 1) + channel = str(trigger.sender) + if not args: + _notice(bot, trigger.nick, "usage: !badword <add|del|list> [word]") + return + sub = args[0].lower() + cdb = _chan_db(bot, channel) + if sub == "list": + words = cdb.get("badwords", []) + if not words: + _notice(bot, trigger.nick, f"badwords for {channel}: (none)") + else: + for i, w in enumerate(words): + _notice(bot, trigger.nick, f"[{i}] {w}") + elif sub == "add" and len(args) > 1: + word = args[1].lower() + cdb.setdefault("badwords", []).append(word) + _save(bot) + _notice(bot, trigger.nick, f"badword added: {word}") + elif sub == "del" and len(args) > 1: + try: + removed = cdb["badwords"].pop(int(args[1])) + _save(bot) + _notice(bot, trigger.nick, f"badword removed: {removed}") + except (ValueError, IndexError): + _notice(bot, trigger.nick, "usage: !badword del <index> (use !badword list for indices)") + else: + _notice(bot, trigger.nick, "usage: !badword <add|del|list> [word]") + + +@plugin.command("set", "s") +def cmd_set(bot, trigger): + if not _is_owner(bot, trigger) and not _is_acl(bot, trigger): + return + args = (trigger.group(2) or "").split() + in_chan = str(trigger.sender).startswith("#") + channel = str(trigger.sender) if in_chan else None + dest = trigger.nick + if len(args) < 2: + keys = ", ".join(_CONFIG_KEYS) + _notice(bot, dest, f"usage: !set <key> <value> - keys: {keys}") + return + key, raw = args[0], args[1] + if key not in _CONFIG_KEYS: + _notice(bot, dest, f"unknown key '{key}' - valid keys: {', '.join(_CONFIG_KEYS)}") + return + try: + value = _CONFIG_KEYS[key](raw) + except ValueError: + _notice(bot, dest, f"invalid value for {key}: expected {_CONFIG_KEYS[key].__name__}") + return + + if in_chan: + _chan_db(bot, channel).setdefault("config", {})[key] = value + _notice(bot, dest, f"set {key} = {value} for {channel}") + else: + if not _is_owner(bot, trigger): + return + _global_db(bot).setdefault("config", {})[key] = value + _notice(bot, dest, f"set global default {key} = {value}") + _save(bot) + + +@plugin.command("config", "cfg") +def cmd_config(bot, trigger): + if not _is_owner(bot, trigger) and not _is_acl(bot, trigger): + return + + in_chan = str(trigger.sender).startswith("#") + arg = (trigger.group(2) or "").strip() + + if arg.startswith("#"): + channel = arg + elif in_chan: + channel = str(trigger.sender) + else: + _notice(bot, trigger.nick, "usage: !config [#channel]") + return + + global_cfg = _global_db(bot).get("config", {}) + channel_cfg = _chan_db(bot, channel).get("config", {}) + + _notice(bot, trigger.nick, f"-- config for {channel} --") + for key in sorted(_CONFIG_KEYS): + hardcoded = _CONFIG_DEFAULTS.get(key, "?") + global_val = global_cfg.get(key) + channel_val = channel_cfg.get(key) + if channel_val is not None: + _notice(bot, trigger.nick, + f"{key} = {channel_val} (channel override; global: {global_val if global_val is not None else hardcoded})") + elif global_val is not None: + _notice(bot, trigger.nick, + f"{key} = {global_val} (global; default: {hardcoded})") + else: + _notice(bot, trigger.nick, f"{key} = {hardcoded} (default)") + + +# --- Admin --- + +@plugin.command("join", "j") +def cmd_join(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() + if not channel.startswith("#"): + _notice(bot, trigger.nick, "usage: !join #channel") + return + bot.join(channel) + + +@plugin.command("part", "p") +def cmd_part(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() or str(trigger.sender) + bot.part(channel) + + +@plugin.command("chans", "c") +def cmd_chans(bot, trigger): + if not _is_owner(bot, trigger): + return + chans = sorted(str(c) for c in bot.channels) + _notice(bot, trigger.nick, + ("in: " + " ".join(chans)) if chans else "not in any channels") + + diff --git a/osterman/help.py b/osterman/help.py new file mode 100644 index 0000000..fdc94c0 --- /dev/null +++ b/osterman/help.py @@ -0,0 +1,30 @@ +""" +Sopel plugin: osterman help system + + !help list topics + !help <topic> list commands for a topic + !help <topic> <cmd> description and usage for a command + !help <cmd> description and usage (topic not required) +""" + +import os as _os, sys as _sys +_osterman_dir = _os.path.dirname(_os.path.abspath(__file__)) +if _osterman_dir not in _sys.path: + _sys.path.insert(0, _osterman_dir) +import helpstrings as h + +from sopel import plugin + + +def _notice(bot, nick, text): + bot.notice(text, nick) + + +@plugin.command("help", "h") +def cmd_help(bot, trigger): + args = (trigger.group(2) or "").split() + result = h.lookup(args) + if result: + _notice(bot, trigger.nick, result) + else: + _notice(bot, trigger.nick, f"no help for '{' '.join(args)}'. {h.TOPICS}") diff --git a/osterman/helpstrings.py b/osterman/helpstrings.py new file mode 100644 index 0000000..31ee7d1 --- /dev/null +++ b/osterman/helpstrings.py @@ -0,0 +1,93 @@ +TOPICS = ( + "topics (commands): " + "mod (kick, bankick, tempban, ban, unban, mute, unmute, voice, devoice, op, deop, halfop, dehalfop, lock, unlock), " + "config (autoop, autovoice, autohalfop, acl, whitelist, blacklist, except, filter, badword, set, config), " + "track (seen, info, log), " + "idle (idle, afk), " + "admin (join, part, chans). " + "type !help <topic> or !help <cmd> to know more" +) + +MOD_TOPIC = "mod: kick, bankick, tempban, ban, unban, mute, unmute, voice, devoice, op, deop, halfop, dehalfop, lock, unlock. type !help <cmd> for usage" +CONFIG_TOPIC = "config: autoop, autovoice, autohalfop, acl, whitelist, blacklist, except, filter, badword, set, config. type !help <cmd> for usage" +TRACK_TOPIC = "track: seen, info, log. type !help <cmd> for usage" +IDLE_TOPIC = "idle: idle, afk. type !help <cmd> for usage" +ADMIN_TOPIC = "admin: join, part, chans. type !help <cmd> for usage" + +MOD = { + "kick": "kick a user. usage: !kick <nick> [reason]", + "bankick": "ban and kick a user. usage: !bankick <nick|mask> [reason]", + "tempban": "timed ban and kick. usage: !tempban <nick|mask> <minutes> [reason]", + "ban": "ban and kick, or list bans. usage: !ban <nick|mask> [reason] | !ban list", + "unban": "remove a ban. usage: !unban <nick|mask>", + "mute": "silence a user (+q). usage: !mute <nick|mask>", + "unmute": "remove silence (-q). usage: !unmute <nick|mask>", + "voice": "give voice. usage: !voice <nick>", + "devoice": "remove voice. usage: !devoice <nick>", + "op": "give op. usage: !op <nick>", + "deop": "remove op. usage: !deop <nick>", + "halfop": "give halfop. usage: !halfop <nick>", + "dehalfop": "remove halfop. usage: !dehalfop <nick>", + "lock": "lock the channel. usage: !lock [m|i] (default: +mi)", + "unlock": "unlock the channel. usage: !unlock [m|i] (default: -mi)", +} + +CONFIG = { + "autoop": "manage auto-op list for this channel. usage: !autoop <add|del|list> [nick|mask]", + "autovoice": "manage auto-voice list for this channel. usage: !autovoice <add|del|list> [nick|mask]", + "autohalfop": "manage auto-halfop list for this channel. usage: !autohalfop <add|del|list> [nick|mask]", + "acl": "manage trusted nicks (global). usage: !acl <add|del|list> [nick]", + "whitelist": "manage global protection bypass list. usage: !whitelist <add|del|list> [mask]", + "blacklist": "manage global ban list. usage: !blacklist <add|del|list> [nick|mask]", + "except": "manage per-channel protection bypass list. usage: !except <add|del|list> [mask]", + "filter": "manage regex content filters. usage: !filter <add|del|list> [pattern] (del uses index from list)", + "badword": "manage word block list. usage: !badword <add|del|list> [word] (del uses index from list)", + "set": "set a config key. usage: !set <key> <value> (in channel: per-channel; in PM: global)", + "config": "show channel config. usage: !config [#channel]", +} + +TRACK = { + "seen": "show when a nick was last seen. usage: !seen <nick>", + "info": "show nick summary. usage: !info <nick>", + "log": "show recent events for a nick (ACL/owner only). usage: !log <nick> [limit]", +} + +IDLE = { + "idle": "show idle time or toggle idle checking. usage: !idle <nick> | !idle <on|off>", + "afk": "reset your idle timer. usage: !afk", +} + +ADMIN = { + "join": "join a channel. usage: !join <#channel>", + "part": "leave a channel. usage: !part [#channel]", + "chans": "list channels the bot is in. usage: !chans", +} + +_ALL = {**MOD, **CONFIG, **TRACK, **IDLE, **ADMIN} + +TOPIC_MAP = { + "mod": (MOD_TOPIC, MOD), + "config": (CONFIG_TOPIC, CONFIG), + "track": (TRACK_TOPIC, TRACK), + "idle": (IDLE_TOPIC, IDLE), + "admin": (ADMIN_TOPIC, ADMIN), +} + + +def lookup(args): + """Return a help string for args (list of strings) or None if not found. + + lookup([]) -> TOPICS + lookup(["mod"]) -> MOD_TOPIC + lookup(["kick"]) -> MOD["kick"] + lookup(["mod","kick"]) -> MOD["kick"] + """ + if not args: + return TOPICS + first = args[0].lower() + if first in TOPIC_MAP: + if len(args) == 1: + return TOPIC_MAP[first][0] + cmd = args[1].lower() + return TOPIC_MAP[first][1].get(cmd) + return _ALL.get(first) diff --git a/osterman/idle.py b/osterman/idle.py new file mode 100644 index 0000000..c80d0d0 --- /dev/null +++ b/osterman/idle.py @@ -0,0 +1,137 @@ +""" +Sopel plugin: osterman - anti-idle + +Warns idle users via NOTICE, kicks if they stay idle past the kick threshold. +Idle state is tracked per channel. Thresholds are in minutes; set to 0 +to disable warn or kick. + +Degrades gracefully by privilege level: + halfop or op - warn and kick + none - warn only (cannot kick) + + !afk - reset your own idle timer + !idle on|off - enable/disable idle checking (ACL/owner; per channel if in channel, global if PM) + !idle <nick> - show how long a nick has been idle in this channel +""" + +import json +import os +import time + +from sopel import plugin + + +def _bot_has_halfop(bot, channel): + chan = bot.channels.get(channel) + if not chan: + return False + return chan.privileges.get(bot.nick, 0) >= plugin.HALFOP + + +def _chan_cfg(bot, channel): + global_cfg = bot.memory.get("os_db", {}).get("config", {}) + chan_cfg = (bot.memory.get("os_db", {}) + .get("channels", {}) + .get(channel.lower(), {}) + .get("config", {})) + return {**global_cfg, **chan_cfg} + + +@plugin.interval(60) +def _idle_tick(bot): + now = time.time() + last = bot.memory.get("os_last", {}) + + for channel_name, channel in list(bot.channels.items()): + cfg = _chan_cfg(bot, channel_name) + warn_min = cfg.get("idle_warn_min", 45) + kick_min = cfg.get("idle_kick_min", 60) + + if warn_min <= 0 and kick_min <= 0: + continue + + can_kick = _bot_has_halfop(bot, channel_name) + + for nick in list(channel.users): + if nick == bot.nick: + continue + idle_sec = now - last.get((channel_name, nick), now) + idle_min = idle_sec / 60 + + if kick_min > 0 and idle_min >= kick_min: + if can_kick: + bot.kick(channel_name, nick, f"idle for {int(idle_min)}m") + last.pop((channel_name, nick), None) + else: + bot.notice(f"you have been idle for {int(idle_min)}m in {channel_name} (cannot kick without halfop)", nick) + elif warn_min > 0 and idle_min >= warn_min: + bot.notice(f"you have been idle for {int(idle_min)}m in {channel_name} - say something or get kicked", nick) + + +@plugin.command("afk") +@plugin.require_chanmsg +def cmd_afk(bot, trigger): + channel = str(trigger.sender) + bot.memory.setdefault("os_last", {})[(channel, trigger.nick)] = time.time() + bot.notice("idle timer reset", trigger.nick) + + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _is_acl(bot, trigger): + return trigger.nick in bot.memory.get("os_db", {}).get("acl", []) + + +@plugin.command("idle", "id") +def cmd_idle(bot, trigger): + arg = (trigger.group(2) or "").strip().lower() + in_chan = str(trigger.sender).startswith("#") + + if arg in ("on", "off"): + if not _is_owner(bot, trigger) and not _is_acl(bot, trigger): + return + db = bot.memory.get("os_db", {}) + if in_chan: + channel = str(trigger.sender) + cfg = (db.setdefault("channels", {}) + .setdefault(channel.lower(), {}) + .setdefault("config", {})) + else: + cfg = db.setdefault("config", {}) + + if arg == "off": + cfg["idle_warn_min"] = 0 + cfg["idle_kick_min"] = 0 + bot.notice("idle checking disabled", trigger.nick) + else: + cfg["idle_warn_min"] = 45 + cfg["idle_kick_min"] = 60 + bot.notice("idle checking enabled (warn 45m, kick 60m)", trigger.nick) + + path = bot.memory.get("os_db_path", "/var/osterman/db.json") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as fp: + json.dump(db, fp, indent=2) + return + + target = arg or trigger.nick + if not in_chan: + bot.notice("use !idle in a channel to check idle time", trigger.nick) + return + channel = str(trigger.sender) + last = bot.memory.get("os_last", {}).get((channel, target)) + if last is None: + bot.say(f"{target}: no idle data", trigger.sender) + return + diff = int(time.time() - last) + if diff < 60: + idle_str = f"{diff}s" + elif diff < 3600: + idle_str = f"{diff // 60}m" + elif diff < 86400: + idle_str = f"{diff // 3600}h {(diff % 3600) // 60}m" + else: + idle_str = f"{diff // 86400}d {(diff % 86400) // 3600}h" + bot.say(f"{target} idle: {idle_str}", trigger.sender) diff --git a/osterman/osterman.cfg b/osterman/osterman.cfg new file mode 100644 index 0000000..9288194 --- /dev/null +++ b/osterman/osterman.cfg @@ -0,0 +1,23 @@ +[core] +nick = osterman +host = 127.0.0.1 +port = 6667 +use_ssl = false +verify_ssl = false +owner = gumx +password = REPLACEME +prefix = ! +channels = #gumx +extra = /home/ahmed/scripts/osterman +logdir = /var/log/osterman +logging_level = INFO +timeout = 120 +enable = + protect + idle + commands + tracking + help + +[osterman] +db_path = /var/osterman/db.json diff --git a/osterman/protect.py b/osterman/protect.py new file mode 100644 index 0000000..d677173 --- /dev/null +++ b/osterman/protect.py @@ -0,0 +1,486 @@ +""" +Sopel plugin: osterman - channel protection core + +Automatic enforcement (all configurable at runtime via !set): + - Flood control flood_threshold, flood_window_sec + - Caps filter caps_filter, caps_percent, caps_min_len + - Repeat filter repeat_filter, repeat_threshold, repeat_window_sec + - Content filter regex list via !filter + - Bad word list word list via !badword + - Clone detection clone_limit + - Join flood join_flood_filter, join_flood_count, join_flood_window + - Nick flood nick_flood_filter, nick_flood_count, nick_flood_window + - Persistent ban reapply + - Blacklist global instant-ban on join + - Auto-mode auto +o/+h/+v on join (per channel) + - Takeover mitigation re-op ACL nicks that get deopped + - Greet greet + +Degrades gracefully by privilege level: + op - full: kick, ban, re-op, flood ban + halfop - partial: kick only (no bans, no re-op) + none - passive: last_seen tracking only + +Config is per-channel with global defaults as fallback. + Global: acl, whitelist, blacklist, config defaults + Per-channel: config overrides, bans, filters, badwords, exceptions, auto-modes +""" + +import fnmatch +import json +import logging +import os +import re +import threading +import time + +from sopel import plugin +from sopel.config.types import FilenameAttribute, StaticSection + +log = logging.getLogger(__name__) + +_DB_DEFAULTS = { + "config": { + "flood_threshold": 5, + "flood_window_sec": 10, + "caps_filter": 1, + "caps_percent": 70, + "caps_min_len": 10, + "repeat_filter": 1, + "repeat_threshold": 3, + "repeat_window_sec": 30, + "clone_limit": 2, + "join_flood_filter": 1, + "join_flood_count": 5, + "join_flood_window": 10, + "nick_flood_filter": 1, + "nick_flood_count": 3, + "nick_flood_window": 60, + "badword_action": "kick", + "tempban_max_min": 60, + "idle_warn_min": 45, + "idle_kick_min": 60, + "log_limit": 6, + "greet": 1, + }, + "acl": [], + "whitelist": ["*!*@services.dal.net"], + "blacklist": [], + "exceptions": [], + "channels": {}, +} + + +class OstermanSection(StaticSection): + db_path = FilenameAttribute("db_path", relative=False, + default="/var/osterman/db.json") + + +def _load_db(path): + if os.path.exists(path): + with open(path) as f: + data = json.load(f) + data.setdefault("channels", {}) + data.setdefault("blacklist", []) + return data + db = { + k: (dict(v) if isinstance(v, dict) else list(v)) + for k, v in _DB_DEFAULTS.items() + } + _save_db(path, db) + return db + + +def _save_db(path, db): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + json.dump(db, f, indent=2) + + +def setup(bot): + bot.config.define_section("osterman", OstermanSection, validate=False) + path = bot.config.osterman.db_path + + bot.memory["os_db_path"] = path + bot.memory["os_lock"] = threading.Lock() + bot.memory["os_flood"] = {} + bot.memory["os_repeat"] = {} + bot.memory["os_join_flood"] = {} + bot.memory["os_nick_flood"] = {} + bot.memory["os_last"] = {} + + try: + bot.memory["os_db"] = _load_db(path) + except Exception as exc: + log.error("osterman: failed to load db from %s: %s - using defaults", path, exc) + bot.memory["os_db"] = { + k: (dict(v) if isinstance(v, dict) else list(v)) + for k, v in _DB_DEFAULTS.items() + } + + +# --- DB helpers --- + +def _global_db(bot): + return bot.memory["os_db"] + + +def _chan_db(bot, channel): + return bot.memory["os_db"]["channels"].setdefault(channel.lower(), {}) + + +def _cfg(bot, channel): + global_cfg = bot.memory["os_db"].get("config", {}) + channel_cfg = _chan_db(bot, channel).get("config", {}) + return {**global_cfg, **channel_cfg} + + +def _save(bot): + _save_db(bot.memory["os_db_path"], bot.memory["os_db"]) + + +# --- Privilege helpers --- + +def _hostmask(trigger): + return f"{trigger.nick}!{trigger.user}@{trigger.host}" + + +def _bot_privs(bot, channel): + chan = bot.channels.get(channel) + if not chan: + return 0 + return chan.privileges.get(bot.nick, 0) + + +def _bot_has_op(bot, channel): + return _bot_privs(bot, channel) >= plugin.OP + + +def _bot_has_halfop(bot, channel): + return _bot_privs(bot, channel) >= plugin.HALFOP + + +# --- Policy helpers --- + +def _is_whitelisted(bot, hostmask): + return any(fnmatch.fnmatch(hostmask, p) + for p in bot.memory["os_db"].get("whitelist", [])) + + +def _is_blacklisted(bot, hostmask): + return any(fnmatch.fnmatch(hostmask, p) + for p in bot.memory["os_db"].get("blacklist", [])) + + +def _is_excepted(bot, hostmask, channel=None): + # Check per-channel exceptions + if channel: + per_chan = _chan_db(bot, channel).get("exceptions", []) + if any(fnmatch.fnmatch(hostmask, p) for p in per_chan): + return True + # Legacy: check global exceptions list for backward compat + global_exc = bot.memory["os_db"].get("exceptions", []) + return any(fnmatch.fnmatch(hostmask, p) for p in global_exc) + + +def _is_exempt(bot, hostmask, channel=None): + return _is_whitelisted(bot, hostmask) or _is_excepted(bot, hostmask, channel) + + +def _is_banned(bot, hostmask, channel): + global_bans = bot.memory["os_db"].get("bans", []) + channel_bans = _chan_db(bot, channel).get("bans", []) + return any(fnmatch.fnmatch(hostmask, b["mask"]) + for b in global_bans + channel_bans) + + +def _matches_list(hostmask, entries): + """Return True if hostmask matches any nick or hostmask pattern in entries.""" + for entry in entries: + if "!" in entry or "@" in entry or "*" in entry or "?" in entry: + if fnmatch.fnmatch(hostmask, entry): + return True + else: + nick = hostmask.split("!")[0] + if nick.lower() == entry.lower(): + return True + return False + + +# --- Event handlers --- + +@plugin.event("JOIN") +@plugin.require_chanmsg +def on_bot_join(bot, trigger): + if trigger.nick != bot.nick: + return + channel = str(trigger.sender) + if channel.lower() not in bot.memory["os_db"].get("channels", {}): + _chan_db(bot, channel) + _save(bot) + + +@plugin.event("JOIN") +@plugin.require_chanmsg +def on_join(bot, trigger): + if trigger.nick == bot.nick: + return + + channel = str(trigger.sender) + hm = _hostmask(trigger) + has_op = _bot_has_op(bot, channel) + has_halfop = _bot_has_halfop(bot, channel) + + # Blacklist: global instant-ban, checked before whitelist + if _is_blacklisted(bot, hm): + if has_op: + bot.write(["MODE", channel, "+b", hm]) + bot.write(["KICK", channel, trigger.nick, "blacklisted"]) + elif has_halfop: + bot.write(["KICK", channel, trigger.nick, "blacklisted"]) + return + + if _is_exempt(bot, hm, channel): + # Still apply auto-modes for whitelisted/excepted users + _apply_auto_modes(bot, trigger, channel, hm, has_op, has_halfop) + return + + if _is_banned(bot, hm, channel): + if has_op: + bot.write(["MODE", channel, "+b", hm]) + bot.write(["KICK", channel, trigger.nick, "banned"]) + elif has_halfop: + bot.write(["KICK", channel, trigger.nick, "banned"]) + return + + if not has_halfop: + return + + cfg = _cfg(bot, channel) + + # Clone detection + limit = cfg.get("clone_limit", 2) + host = trigger.host + chan = bot.channels.get(channel) + if chan: + clones = [u for u in chan.users + if u != trigger.nick and chan.users[u].host == host] + if len(clones) >= limit: + bot.write(["KICK", channel, trigger.nick, f"clone limit ({limit}) exceeded"]) + return + + # Join flood detection + if cfg.get("join_flood_filter", 1): + jf_count = cfg.get("join_flood_count", 5) + jf_window = cfg.get("join_flood_window", 10) + now = time.time() + jf_key = (channel, host) + times = bot.memory["os_join_flood"].setdefault(jf_key, []) + times.append(now) + bot.memory["os_join_flood"][jf_key] = [t for t in times if now - t <= jf_window] + if len(bot.memory["os_join_flood"][jf_key]) >= jf_count: + mask = f"*!*@{host}" + bot.write(["KICK", channel, trigger.nick, "join flood"]) + if has_op: + bot.write(["MODE", channel, "+b", mask]) + bot.memory["os_join_flood"][jf_key] = [] + return + + # Auto-modes applied after all checks pass + _apply_auto_modes(bot, trigger, channel, hm, has_op, has_halfop) + + +def _apply_auto_modes(bot, trigger, channel, hm, has_op, has_halfop): + cdb = _chan_db(bot, channel) + + if has_op: + autoop = cdb.get("autoop", []) + if _matches_list(hm, autoop): + bot.write(["MODE", channel, "+o", trigger.nick]) + return + + autohalfop = cdb.get("autohalfop", []) + if _matches_list(hm, autohalfop): + bot.write(["MODE", channel, "+h", trigger.nick]) + return + + if has_halfop or has_op: + autovoice = cdb.get("autovoice", []) + if _matches_list(hm, autovoice): + bot.write(["MODE", channel, "+v", trigger.nick]) + + +@plugin.event("MODE") +def on_mode(bot, trigger): + if not trigger.args or len(trigger.args) < 2: + return + + channel = trigger.args[0] + mode_str = trigger.args[1] + targets = list(trigger.args[2:]) + + if channel not in bot.channels: + return + if not _bot_has_op(bot, channel): + return + + acl = _global_db(bot).get("acl", []) + adding = True + t_idx = 0 + + for ch in mode_str: + if ch == "+": + adding = True + elif ch == "-": + adding = False + elif ch == "o": + if t_idx < len(targets): + target = targets[t_idx] + t_idx += 1 + if not adding and target in acl: + bot.write(["MODE", channel, "+o", target]) + + +@plugin.event("NICK") +def on_nick_flood(bot, trigger): + if trigger.nick == bot.nick: + return + + hm = _hostmask(trigger) + if _is_exempt(bot, hm): + return + + now = time.time() + host_key = f"{trigger.user}@{trigger.host}" + + global_cfg = _global_db(bot).get("config", {}) + if not global_cfg.get("nick_flood_filter", 1): + return + + nf_count = global_cfg.get("nick_flood_count", 3) + nf_window = global_cfg.get("nick_flood_window", 60) + + times = bot.memory["os_nick_flood"].setdefault(host_key, []) + times.append(now) + bot.memory["os_nick_flood"][host_key] = [t for t in times if now - t <= nf_window] + + if len(bot.memory["os_nick_flood"][host_key]) >= nf_count: + new_nick = trigger.args[0] if trigger.args else None + if not new_nick: + return + for chan_name in list(bot.channels): + if new_nick in bot.channels[chan_name].users: + if _bot_has_halfop(bot, chan_name): + bot.write(["KICK", chan_name, new_nick, "nick flood"]) + if _bot_has_op(bot, chan_name): + bot.write(["MODE", chan_name, "+b", hm]) + bot.memory["os_nick_flood"][host_key] = [] + + +@plugin.rule(".*") +@plugin.require_chanmsg +def on_message(bot, trigger): + nick = trigger.nick + if nick == bot.nick: + return + + channel = str(trigger.sender) + hm = _hostmask(trigger) + now = time.time() + + bot.memory["os_last"][(channel, nick)] = now + + if _is_exempt(bot, hm, channel): + return + + has_op = _bot_has_op(bot, channel) + has_halfop = _bot_has_halfop(bot, channel) + + if not has_halfop: + return + + with bot.memory["os_lock"]: + cfg = _cfg(bot, channel) + cdb = _chan_db(bot, channel) + text = trigger.group(0) or "" + + # Regex content filter + for pattern in cdb.get("filters", []): + try: + if re.search(pattern, text, re.IGNORECASE): + bot.write(["KICK", channel, nick, "content filter"]) + return + except re.error: + pass + + # Bad word list + text_lower = text.lower() + for word in cdb.get("badwords", []): + if word in text_lower: + action = cfg.get("badword_action", "kick") + if action == "warn": + bot.notice("watch your language", nick) + else: + bot.write(["KICK", channel, nick, "bad word"]) + return + + # Caps filter + if cfg.get("caps_filter", 1): + letters = [c for c in text if c.isalpha()] + if len(letters) >= cfg.get("caps_min_len", 10): + upper_pct = sum(1 for c in letters if c.isupper()) / len(letters) * 100 + if upper_pct >= cfg.get("caps_percent", 70): + bot.write(["KICK", channel, nick, "caps"]) + return + + # Repeat filter + if cfg.get("repeat_filter", 1): + rep_threshold = cfg.get("repeat_threshold", 3) + rep_window = cfg.get("repeat_window_sec", 30) + msg_hash = hash(text.strip().lower()) + history = bot.memory["os_repeat"].setdefault((channel, nick), []) + history.append((msg_hash, now)) + bot.memory["os_repeat"][(channel, nick)] = [ + (h, t) for h, t in history if now - t <= rep_window + ] + same = sum(1 for h, _ in bot.memory["os_repeat"][(channel, nick)] + if h == msg_hash) + if same >= rep_threshold: + bot.write(["KICK", channel, nick, "repeat"]) + bot.memory["os_repeat"][(channel, nick)] = [] + return + + # Flood control + threshold = cfg.get("flood_threshold", 5) + window = cfg.get("flood_window_sec", 10) + times = bot.memory["os_flood"].setdefault((channel, nick), []) + times.append(now) + bot.memory["os_flood"][(channel, nick)] = [ + t for t in times if now - t <= window + ] + if len(bot.memory["os_flood"][(channel, nick)]) > threshold: + bot.write(["KICK", channel, nick, "flood"]) + if has_op: + bot.write(["MODE", channel, "+b", hm]) + bot.memory["os_flood"][(channel, nick)] = [] + + +@plugin.event("PART") +@plugin.event("QUIT") +@plugin.event("NICK") +def on_gone(bot, trigger): + nick = trigger.nick + host_key = f"{trigger.user}@{trigger.host}" + + if trigger.event == "PART": + channel = str(trigger.sender) + for cache in ("os_last", "os_flood", "os_repeat"): + bot.memory.get(cache, {}).pop((channel, nick), None) + else: + for cache in ("os_last", "os_flood", "os_repeat"): + mem = bot.memory.get(cache, {}) + keys = [k for k in mem if isinstance(k, tuple) and k[1] == nick] + for k in keys: + del mem[k] + + bot.memory.get("os_nick_flood", {}).pop(host_key, None) diff --git a/osterman/tracking.py b/osterman/tracking.py new file mode 100644 index 0000000..fa5696d --- /dev/null +++ b/osterman/tracking.py @@ -0,0 +1,414 @@ +""" +Sopel plugin: osterman - user tracking + +Records join, part, quit, kick, ban, nick events to SQLite. +Derives db path from os_db_path set by protect.py. + +On JOIN: sends a NOTICE greeting to the joining user. + - First-time joiner: "Welcome to #channel, nick!" + - Returning user: "Welcome back, nick!" + Controlled per channel via !set greet 1|0 + + !seen <nick> - when was nick last seen + !info / !i <nick> - summary: first seen, known nicks, kick/ban count + !log / !l <nick> - last N events (ACL/owner, PMed) +""" + +import datetime +import os +import sqlite3 +import time + +from sopel import plugin + + +def _track_path(bot): + base = bot.memory.get("os_db_path", "/var/osterman/db.json") + return os.path.join(os.path.dirname(base), "track.db") + + +def _connect(bot): + return sqlite3.connect(_track_path(bot)) + + +def setup(bot): + path = _track_path(bot) + os.makedirs(os.path.dirname(path), exist_ok=True) + with sqlite3.connect(path) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nick TEXT NOT NULL, + type TEXT NOT NULL, + channel TEXT, + detail TEXT, + timestamp INTEGER NOT NULL + ) + """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_nick ON events(nick COLLATE NOCASE)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_ts ON events(timestamp DESC)" + ) + conn.commit() + + +def _log(bot, nick, type_, channel, detail): + with _connect(bot) as conn: + conn.execute( + "INSERT INTO events (nick, type, channel, detail, timestamp)" + " VALUES (?,?,?,?,?)", + (nick, type_, channel, detail, int(time.time())) + ) + conn.commit() + + +def _fmt_ts(ts): + return datetime.datetime.fromtimestamp(ts).strftime("%m-%d %H:%M") + + +def _fmt_ago(ts): + diff = int(time.time() - ts) + if diff < 60: + return "just now" + elif diff < 3600: + return f"{diff // 60}m ago" + elif diff < 86400: + return f"{diff // 3600}h ago" + elif diff < 604800: + return f"{diff // 86400}d ago" + else: + return f"{diff // 604800}w ago" + + +def _fmt_idle(seconds): + seconds = int(seconds) + if seconds < 60: + return f"{seconds}s" + elif seconds < 3600: + return f"{seconds // 60}m" + elif seconds < 86400: + return f"{seconds // 3600}h" + else: + return f"{seconds // 86400}d" + + +def _greet_enabled(bot, channel): + global_cfg = bot.memory.get("os_db", {}).get("config", {}) + chan_cfg = (bot.memory.get("os_db", {}) + .get("channels", {}) + .get(channel.lower(), {}) + .get("config", {})) + return bool({**global_cfg, **chan_cfg}.get("greet", 1)) + + +# --- Event handlers --- + +@plugin.event("JOIN") +@plugin.require_chanmsg +def on_join(bot, trigger): + if trigger.nick == bot.nick: + return + + nick = trigger.nick + channel = str(trigger.sender) + + # Greeting check BEFORE logging so first-timers get the right message + if _greet_enabled(bot, channel): + try: + with _connect(bot) as conn: + seen = conn.execute( + "SELECT 1 FROM events WHERE nick = ? COLLATE NOCASE LIMIT 1", + (nick,) + ).fetchone() + if seen: + bot.notice(f"Welcome back, {nick}!", nick) + else: + bot.notice(f"Welcome to {channel}, {nick}!", nick) + except Exception: + pass + + _log(bot, nick, "join", channel, None) + + +@plugin.event("PART") +@plugin.require_chanmsg +def on_part(bot, trigger): + if trigger.nick == bot.nick: + return + reason = trigger.args[1] if len(trigger.args) > 1 else None + _log(bot, trigger.nick, "part", str(trigger.sender), reason) + + +@plugin.event("QUIT") +def on_quit(bot, trigger): + if trigger.nick == bot.nick: + return + reason = trigger.args[0] if trigger.args else None + _log(bot, trigger.nick, "quit", None, reason) + + +@plugin.event("KICK") +def on_kick(bot, trigger): + channel = str(trigger.sender) + kicker = trigger.nick + victim = trigger.args[1] if len(trigger.args) > 1 else None + reason = trigger.args[2] if len(trigger.args) > 2 else None + if not victim: + return + detail = f"by {kicker}" + if reason: + detail += f": {reason}" + _log(bot, victim, "kick", channel, detail) + + +@plugin.event("NICK") +def on_nick(bot, trigger): + old_nick = trigger.nick + new_nick = trigger.args[0] if trigger.args else None + if not new_nick or old_nick == bot.nick: + return + _log(bot, old_nick, "nick", None, new_nick) + + +@plugin.event("MODE") +def on_mode_ban(bot, trigger): + if not trigger.args or len(trigger.args) < 2: + return + channel = trigger.args[0] + mode_str = trigger.args[1] + targets = list(trigger.args[2:]) + actor = trigger.nick + adding = True + t_idx = 0 + + if not channel.startswith("#"): + return + + for ch in mode_str: + if ch == "+": + adding = True + elif ch == "-": + adding = False + elif ch == "b": + if t_idx < len(targets): + mask = targets[t_idx] + t_idx += 1 + if adding: + nick_key = mask.split("!")[0] if "!" in mask and not mask.startswith("*") else mask + _log(bot, nick_key, "ban", channel, f"{mask} by {actor}") + elif ch == "q": + if t_idx < len(targets): + mask = targets[t_idx] + t_idx += 1 + action = "mute" if adding else "unmute" + nick_key = mask.split("!")[0] if "!" in mask and not mask.startswith("*") else mask + _log(bot, nick_key, action, channel, f"{mask} by {actor}") + elif ch in ("m", "i"): + action = f"lock+{ch}" if adding else f"unlock-{ch}" + _log(bot, actor, action, channel, None) + + +# --- Helpers shared by commands --- + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _is_acl(bot, trigger): + return trigger.nick in bot.memory.get("os_db", {}).get("acl", []) + + +# --- Commands --- + +@plugin.command("seen") +def cmd_seen(bot, trigger): + args = (trigger.group(2) or "").split() + in_chan = str(trigger.sender).startswith("#") + authed = _is_owner(bot, trigger) or _is_acl(bot, trigger) + + if not args: + bot.notice("usage: !seen <nick>", trigger.nick) + return + + target = args[0] + + if in_chan: + channel = str(trigger.sender) + chan = bot.channels.get(channel) + if chan and target in chan.users: + last = bot.memory.get("os_last", {}).get((channel, target)) + idle_str = f", idle {_fmt_idle(time.time() - last)}" if last else "" + bot.say(f"{target} is here{idle_str}", trigger.sender) + return + with _connect(bot) as conn: + row = conn.execute( + "SELECT type, timestamp FROM events" + " WHERE nick = ? COLLATE NOCASE AND channel = ?" + " ORDER BY timestamp DESC LIMIT 1", + (target, channel) + ).fetchone() + if not row: + bot.say(f"{target}: not seen in {channel}", trigger.sender) + return + type_, ts = row + bot.say(f"{target} last seen {_fmt_ago(ts)} ({type_})", trigger.sender) + + elif authed: + for chan_name, chan in bot.channels.items(): + if target in chan.users: + last = bot.memory.get("os_last", {}).get((chan_name, target)) + idle_str = f", idle {_fmt_idle(time.time() - last)}" if last else "" + bot.notice(f"{target} is in {chan_name}{idle_str}", trigger.nick) + return + with _connect(bot) as conn: + row = conn.execute( + "SELECT type, channel, timestamp FROM events" + " WHERE nick = ? COLLATE NOCASE ORDER BY timestamp DESC LIMIT 1", + (target,) + ).fetchone() + if not row: + bot.notice(f"{target}: never seen", trigger.nick) + return + type_, chan_name, ts = row + parts = [f"{target} last seen {_fmt_ago(ts)}"] + if chan_name: + parts.append(f"in {chan_name}") + parts.append(f"({type_})") + bot.notice(" ".join(parts), trigger.nick) + + else: + bot.notice("use !seen in a channel", trigger.nick) + + +@plugin.command("info", "i") +def cmd_info(bot, trigger): + args = (trigger.group(2) or "").split() + in_chan = str(trigger.sender).startswith("#") + authed = _is_owner(bot, trigger) or _is_acl(bot, trigger) + + if not args: + bot.notice("usage: !info <nick>", trigger.nick) + return + + if not in_chan and not authed: + bot.notice("use !info in a channel", trigger.nick) + return + + target = args[0] + channel = str(trigger.sender) if in_chan else None + + with _connect(bot) as conn: + if channel: + span = conn.execute( + "SELECT MIN(timestamp), MAX(timestamp), COUNT(*) FROM events" + " WHERE nick = ? COLLATE NOCASE AND channel = ?", + (target, channel) + ).fetchone() + else: + span = conn.execute( + "SELECT MIN(timestamp), MAX(timestamp), COUNT(*) FROM events" + " WHERE nick = ? COLLATE NOCASE", + (target,) + ).fetchone() + + if not span or span[0] is None: + bot.notice(f"{target}: no records", trigger.nick) + return + + first_ts, last_ts, total = span + + if channel: + last_row = conn.execute( + "SELECT type, channel, detail FROM events" + " WHERE nick = ? COLLATE NOCASE AND channel = ?" + " ORDER BY timestamp DESC LIMIT 1", + (target, channel) + ).fetchone() + kb_count = conn.execute( + "SELECT COUNT(*) FROM events" + " WHERE nick = ? COLLATE NOCASE AND channel = ? AND type IN ('kick','ban')", + (target, channel) + ).fetchone()[0] + else: + last_row = conn.execute( + "SELECT type, channel, detail FROM events" + " WHERE nick = ? COLLATE NOCASE ORDER BY timestamp DESC LIMIT 1", + (target,) + ).fetchone() + kb_count = conn.execute( + "SELECT COUNT(*) FROM events" + " WHERE nick = ? COLLATE NOCASE AND type IN ('kick','ban')", + (target,) + ).fetchone()[0] + + newnicks = [r[0] for r in conn.execute( + "SELECT DISTINCT detail FROM events" + " WHERE nick = ? COLLATE NOCASE AND type = 'nick' AND detail IS NOT NULL", + (target,) + ).fetchall()] + + oldnicks = [r[0] for r in conn.execute( + "SELECT DISTINCT nick FROM events" + " WHERE detail = ? COLLATE NOCASE AND type = 'nick'", + (target,) + ).fetchall()] + + bot.say( + f"{target}: first seen {_fmt_ago(first_ts)}, last {_fmt_ago(last_ts)}, {total} events", + trigger.nick + ) + + if last_row: + type_, chan_name, detail = last_row + last_str = type_ + if chan_name: + last_str += f" in {chan_name}" + if detail: + last_str += f" ({detail})" + bot.say(f"last: {last_str}", trigger.nick) + + all_nicks = sorted(set(newnicks + oldnicks) - {target}) + if all_nicks: + bot.say(f"also known as: {', '.join(all_nicks[:6])}", trigger.nick) + + if kb_count: + bot.say(f"kicked/banned: {kb_count}x", trigger.nick) + + +@plugin.command("log", "l") +def cmd_log(bot, trigger): + if not _is_owner(bot, trigger) and not _is_acl(bot, trigger): + return + args = (trigger.group(2) or "").split() + if not args: + bot.say("usage: !log <nick> [limit]", trigger.nick) + return + target = args[0] + + cfg = bot.memory.get("os_db", {}).get("config", {}) + limit = cfg.get("log_limit", 6) + if len(args) > 1: + try: + limit = max(1, min(int(args[1]), 50)) + except ValueError: + pass + + with _connect(bot) as conn: + rows = conn.execute( + "SELECT type, channel, detail, timestamp FROM events" + " WHERE nick = ? COLLATE NOCASE ORDER BY timestamp DESC LIMIT ?", + (target, limit) + ).fetchall() + + if not rows: + bot.say(f"{target}: no records", trigger.nick) + return + + bot.say(f"-- {target} ({len(rows)} events) --", trigger.nick) + for type_, channel, detail, ts in rows: + content = channel or "" + if detail: + content = f"{content} {detail}".strip() + bot.say(f"[{_fmt_ts(ts)}] {type_:<6} {content}", trigger.nick) diff --git a/salvador/__init__.py b/salvador/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/salvador/__init__.py diff --git a/salvador/commands.py b/salvador/commands.py new file mode 100644 index 0000000..833a953 --- /dev/null +++ b/salvador/commands.py @@ -0,0 +1,64 @@ +""" +Sopel plugin: salvador admin commands + +Owner-only commands for managing the bot at runtime. + + !join #channel - join a channel + !part [#channel] - leave a channel (defaults to current) + !chans - list channels the bot is in + !help - show usage +""" + +from sopel import plugin + + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +@plugin.command("join") +def cmd_join(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() + if not channel.startswith("#"): + bot.say("usage: !join #channel") + return + bot.join(channel) + + +@plugin.command("part") +def cmd_part(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() + if not channel: + channel = trigger.sender + bot.part(channel) + + +@plugin.command("chans") +def cmd_chans(bot, trigger): + if not _is_owner(bot, trigger): + return + chans = sorted(str(c) for c in bot.channels) + if chans: + bot.say("in: " + " ".join(chans)) + else: + bot.say("not in any channels") + + +@plugin.command("help") +def cmd_help(bot, trigger): + lines = [ + "!draw [<lines>] <url> - render image as mIRC colour art (lines: 4-12, default 6)", + ] + if _is_owner(bot, trigger): + lines += [ + "!join #channel - join a channel (owner)", + "!part [#channel] - leave a channel (owner)", + "!chans - list channels the bot is in (owner)", + ] + lines.append("!help - this message") + for line in lines: + bot.say(line, trigger.nick) diff --git a/salvador/salvador.cfg b/salvador/salvador.cfg new file mode 100644 index 0000000..6295525 --- /dev/null +++ b/salvador/salvador.cfg @@ -0,0 +1,19 @@ +[core] +nick = salvador +user = salvador +name = salvador +host = 127.0.0.1 +port = 6667 +use_ssl = false +verify_ssl = false +owner = yournick +password = REPLACEME +prefix = ! +channels = #yourchannel +extra = /path/to/irc-bots/salvador +enable = + salvador + commands +logdir = /var/log/salvador +logging_level = INFO +timeout = 120 diff --git a/salvador/salvador.py b/salvador/salvador.py new file mode 100644 index 0000000..f346e2a --- /dev/null +++ b/salvador/salvador.py @@ -0,0 +1,172 @@ +""" +Sopel plugin: salvador + +Converts an image URL to mIRC colour art using half-block characters. + + !draw <url> - render at default height (6 lines) + !draw <lines> <url> - render at custom height (4-12 lines) +""" + +import time +import urllib.request +from io import BytesIO + +from PIL import Image, ImageFile + +ImageFile.LOAD_TRUNCATED_IMAGES = True +from sopel import plugin + + +MAX_WIDTH = 60 +DEFAULT_HEIGHT = 6 +MIN_HEIGHT = 4 +MAX_HEIGHT = 12 +FLOOD_DELAY = 0.4 +MAX_BYTES = 5 * 1024 * 1024 # 5 MB + +# IRC monospace character cells are approximately twice as tall as they are wide. +# _fit multiplies the image aspect ratio by this factor so the rendered output +# uses enough character columns to compensate for the tall cells. +CHAR_RATIO = 2.0 + +USAGE = f'usage: salvador [<lines>] <url> (lines: {MIN_HEIGHT}-{MAX_HEIGHT}, default {DEFAULT_HEIGHT})' + +# Full mIRC 99-colour palette (indices 0-98) +MIRC = [ + (0xff,0xff,0xff),(0x00,0x00,0x00),(0x00,0x00,0x7f),(0x00,0x93,0x00), + (0xff,0x00,0x00),(0x7f,0x00,0x00),(0x9c,0x00,0x9c),(0xfc,0x7f,0x00), + (0xff,0xff,0x00),(0x00,0xfc,0x00),(0x00,0x93,0x93),(0x00,0xff,0xff), + (0x00,0x00,0xfc),(0xff,0x00,0xff),(0x55,0x55,0x55),(0xaa,0xaa,0xaa), + (0x47,0x00,0x00),(0x47,0x21,0x00),(0x47,0x47,0x00),(0x32,0x47,0x00), + (0x00,0x47,0x00),(0x00,0x47,0x2c),(0x00,0x47,0x47),(0x00,0x27,0x47), + (0x00,0x00,0x47),(0x2e,0x00,0x47),(0x47,0x00,0x47),(0x47,0x00,0x2a), + (0x74,0x00,0x00),(0x74,0x3a,0x00),(0x74,0x74,0x00),(0x51,0x74,0x00), + (0x00,0x74,0x00),(0x00,0x74,0x49),(0x00,0x74,0x74),(0x00,0x40,0x74), + (0x00,0x00,0x74),(0x4b,0x00,0x74),(0x74,0x00,0x74),(0x74,0x00,0x45), + (0xb5,0x00,0x00),(0xb5,0x63,0x00),(0xb5,0xb5,0x00),(0x7d,0xb5,0x00), + (0x00,0xb5,0x00),(0x00,0xb5,0x71),(0x00,0xb5,0xb5),(0x00,0x63,0xb5), + (0x00,0x00,0xb5),(0x75,0x00,0xb5),(0xb5,0x00,0xb5),(0xb5,0x00,0x6b), + (0xff,0x00,0x00),(0xff,0x8c,0x00),(0xff,0xff,0x00),(0xb2,0xff,0x00), + (0x00,0xff,0x00),(0x00,0xff,0xa0),(0x00,0xff,0xff),(0x00,0x8c,0xff), + (0x00,0x00,0xff),(0xa5,0x00,0xff),(0xff,0x00,0xff),(0xff,0x00,0x98), + (0xff,0x59,0x59),(0xff,0xb4,0x59),(0xff,0xff,0x71),(0xcf,0xff,0x60), + (0x6f,0xff,0x6f),(0x65,0xff,0xc9),(0x6d,0xff,0xff),(0x59,0xb4,0xff), + (0x59,0x59,0xff),(0xc4,0x59,0xff),(0xff,0x66,0xff),(0xff,0x59,0xbc), + (0xff,0x9c,0x9c),(0xff,0xd3,0x9c),(0xff,0xff,0x9c),(0xe2,0xff,0x9c), + (0x9c,0xff,0x9c),(0x9c,0xff,0xdb),(0x9c,0xff,0xff),(0x9c,0xd3,0xff), + (0x9c,0x9c,0xff),(0xdc,0x9c,0xff),(0xff,0x9c,0xff),(0xff,0x94,0xd3), + (0x00,0x00,0x00),(0x13,0x13,0x13),(0x28,0x28,0x28),(0x36,0x36,0x36), + (0x4d,0x4d,0x4d),(0x65,0x65,0x65),(0x81,0x81,0x81),(0x9f,0x9f,0x9f), + (0xbc,0xbc,0xbc),(0xe2,0xe2,0xe2),(0xff,0xff,0xff), +] + + +def _nearest(r, g, b): + # Perceptually-weighted RGB distance + rmean = (r + 128) / 2 + best, best_dist = 0, float('inf') + for i, (cr, cg, cb) in enumerate(MIRC): + dr, dg, db = r - cr, g - cg, b - cb + dist = (2 + rmean / 256) * dr*dr + 4*dg*dg + (2 + (255 - rmean) / 256) * db*db + if dist < best_dist: + best_dist = dist + best = i + return best + + +def _fetch(url): + req = urllib.request.Request(url, headers={'User-Agent': 'salvador/1.0'}) + with urllib.request.urlopen(req, timeout=10) as resp: + content_type = resp.headers.get('Content-Type', '') + if 'image' not in content_type: + raise ValueError(f'not an image ({content_type})') + return resp.read(MAX_BYTES) + + +def _fit(img_w, img_h, max_w, max_h): + aspect = (img_w / img_h) * CHAR_RATIO + char_aspect = max_w / max_h + if aspect >= char_aspect: + w = max_w + h = max(1, round(max_w / aspect)) + else: + h = max_h + w = max(1, round(max_h * aspect)) + return w, h + + +def _dither(img, w, h): + buf = [[list(map(float, img.getpixel((x, y)))) for x in range(w)] + for y in range(h)] + idx = [[0] * w for _ in range(h)] + for y in range(h): + for x in range(w): + r, g, b = (max(0, min(255, int(v))) for v in buf[y][x]) + c = _nearest(r, g, b) + idx[y][x] = c + cr, cg, cb = MIRC[c] + er, eg, eb = r - cr, g - cg, b - cb + for dx, dy, f in ((1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)): + nx, ny = x + dx, y + dy + if 0 <= nx < w and 0 <= ny < h: + buf[ny][nx][0] += er * f / 16 + buf[ny][nx][1] += eg * f / 16 + buf[ny][nx][2] += eb * f / 16 + return idx + + +def _img_to_irc(url, height=DEFAULT_HEIGHT): + data = _fetch(url) + img = Image.open(BytesIO(data)).convert('RGB') + char_w, char_h = _fit(img.width, img.height, MAX_WIDTH, height) + img = img.resize((char_w, char_h * 2), Image.LANCZOS) + idx = _dither(img, char_w, char_h * 2) + + lines = [] + for row in range(char_h): + line = '' + last_fg = last_bg = None + for col in range(char_w): + bg = idx[row * 2][col] + fg = idx[row * 2 + 1][col] + if fg != last_fg or bg != last_bg: + line += f'\x03{fg:02d},{bg:02d}' + last_fg, last_bg = fg, bg + line += '▄' + line += '\x0f' + lines.append(line) + return lines + + +@plugin.command("draw") +@plugin.require_chanmsg +def cmd_draw(bot, trigger): + args = (trigger.group(2) or "").split(None, 1) + if not args: + bot.say(USAGE, trigger.nick) + return + + height = DEFAULT_HEIGHT + if len(args) == 2 and args[0].isdigit(): + n = int(args[0]) + if not (MIN_HEIGHT <= n <= MAX_HEIGHT): + bot.say(USAGE, trigger.nick) + return + height = n + url = args[1] + else: + url = args[0] + + url = url.rstrip('.,)>') + if not url.startswith(('http://', 'https://')): + bot.say(USAGE, trigger.nick) + return + + try: + lines = _img_to_irc(url, height=height) + except Exception as e: + bot.say(str(e)) + return + for line in lines: + bot.say(line) + time.sleep(FLOOD_DELAY) diff --git a/scripts/gen_bots.py b/scripts/gen_bots.py new file mode 100644 index 0000000..ddb2d54 --- /dev/null +++ b/scripts/gen_bots.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Generate bot documentation HTML pages from docs/*.md.""" + +import os +import re +import sys + +BOTS = [ + ("alfred", "Server management, bot control, file hosting, soju bouncer"), + ("osterman", "Channel moderation, auto-modes, idle tracking"), + ("daffy", "Duck hunting game"), + ("contessa", "Recipe suggestions"), + ("shireen", "News headlines and weather"), + ("daft", "YouTube playlist manager"), + ("herald", "Owner-follow bot and keyword responder"), + ("salvador", "Image-to-mIRC-art renderer"), +] + +STYLE = """\ +@font-face { font-family: "Kawkab Mono"; src: url(/fonts/KawkabMono-Regular.woff2); font-weight: normal; } +@font-face { font-family: "Kawkab Mono"; src: url(/fonts/KawkabMono-Bold.woff2); font-weight: bold; } +* { unicode-bidi: plaintext; box-sizing: border-box; } +html { color: black; background-color: white; } +body { font-family: "Kawkab Mono"; font-size: 16px; line-height: 1.4; margin: 0; padding: 4rem 0; min-height: 100%; overflow-wrap: break-word; } +main, header, footer { max-width: 800px; margin-inline: auto; padding: 0 2rem; } +h1, header, footer { text-align: center; } +main { text-align: left; } +p, h2, h3, h4 { margin: 1em 0 0 0; } +table { margin: auto; border-collapse: collapse; } +th, td { border: 1px solid; padding: 0.3em 0.8em; } +pre { background: rgba(128,128,128,0.08); padding: 1em; overflow-x: auto; margin: 1em 0; } +code { font-size: 85%; } +header { margin-bottom: 1em; } +footer { margin-top: 3em; } +a { color: inherit; } +@media (max-width: 600px) { body { font-size: 0.9em; } h1 { font-size: 1.8em; } } +@media (max-width: 400px) { body { font-size: 0.8em; } h1 { font-size: 1.6em; } } +@media (prefers-color-scheme: dark) { html { filter: invert(1); } img { filter: invert(1); } }""" + +FOOTER = """\ +<footer> +<hr> +<a href="https://gumx.cc">gumx.cc</a> / +<a href="https://git.gumx.cc">git</a> / +<a href="https://mail.gumx.cc">mail</a> / +<a href="https://irc.gumx.cc">irc</a> / +<a href="https://vpn.gumx.cc">vpn</a> / +<a href="https://pgp.gumx.cc">pgp</a> / +<a href="https://wk.fo">wk.fo</a> +</footer>""" + + +def page(title, header_html, content_html): + return f"""<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width,initial-scale=1"> +<title>{title}</title> +<style> +{STYLE} +</style> +</head> +<body> +<header> +{header_html} +</header> +<main> +{content_html} +</main> +{FOOTER} +</body> +</html> +""" + + +def md_to_html(text): + """Minimal markdown → HTML: headers, code blocks, inline code, bold, links, paragraphs.""" + lines = text.splitlines() + out = [] + i = 0 + while i < len(lines): + line = lines[i] + + # fenced code block + if line.strip().startswith("```"): + lang = line.strip()[3:].strip() + i += 1 + code_lines = [] + while i < len(lines) and not lines[i].strip().startswith("```"): + code_lines.append(_esc(lines[i])) + i += 1 + out.append(f"<pre><code>{chr(10).join(code_lines)}</code></pre>") + i += 1 + continue + + # ATX headers + m = re.match(r'^(#{1,4})\s+(.*)', line) + if m: + level = len(m.group(1)) + out.append(f"<h{level}>{_inline(m.group(2))}</h{level}>") + i += 1 + continue + + # table row + if '|' in line and line.strip().startswith('|'): + table_lines = [] + while i < len(lines) and '|' in lines[i] and lines[i].strip().startswith('|'): + table_lines.append(lines[i]) + i += 1 + out.append(_table(table_lines)) + continue + + # blank line + if not line.strip(): + i += 1 + continue + + # paragraph + para = [line] + i += 1 + while i < len(lines) and lines[i].strip() and not lines[i].startswith('#') and not lines[i].strip().startswith('```') and not ('|' in lines[i] and lines[i].strip().startswith('|')): + para.append(lines[i]) + i += 1 + out.append(f"<p>{_inline(' '.join(para))}</p>") + + return "\n".join(out) + + +def _table(rows): + html = ["<table>"] + for j, row in enumerate(rows): + cells = [c.strip() for c in row.strip().strip('|').split('|')] + if all(re.match(r'^[-: ]+$', c) for c in cells): + continue + tag = "th" if j == 0 else "td" + html.append("<tr>" + "".join(f"<{tag}>{_inline(c)}</{tag}>" for c in cells) + "</tr>") + html.append("</table>") + return "\n".join(html) + + +def _inline(text): + text = _esc(text) + # bold + text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text) + # inline code + text = re.sub(r'`([^`]+)`', r'<code>\1</code>', text) + # links + text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text) + return text + + +def _esc(text): + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +def build(src_dir, out_dir): + os.makedirs(out_dir, exist_ok=True) + + # Copy fonts + fonts_src = os.path.join(src_dir, "fonts") + fonts_dst = os.path.join(out_dir, "fonts") + if os.path.isdir(fonts_src): + os.makedirs(fonts_dst, exist_ok=True) + for f in os.listdir(fonts_src): + src = os.path.join(fonts_src, f) + dst = os.path.join(fonts_dst, f) + with open(src, "rb") as fh: + data = fh.read() + with open(dst, "wb") as fh: + fh.write(data) + + # Per-bot pages + for name, desc in BOTS: + doc_path = os.path.join(src_dir, "docs", f"{name}.md") + if not os.path.exists(doc_path): + continue + with open(doc_path) as f: + md = f.read() + content = md_to_html(md) + html = page( + f"{name} — irc.gumx.cc", + f'<h1><a href="https://irc.gumx.cc">irc.gumx.cc</a> / bots / {name}</h1>', + content, + ) + bot_dir = os.path.join(out_dir, name) + os.makedirs(bot_dir, exist_ok=True) + with open(os.path.join(bot_dir, "index.html"), "w") as f: + f.write(html) + + # Index page + rows = "\n".join( + f'<tr><td><a href="{name}/">{name}</a></td><td>{desc}</td></tr>' + for name, desc in BOTS + if os.path.exists(os.path.join(src_dir, "docs", f"{name}.md")) + ) + index_content = f"""<h2>bots</h2> +<table> +<tr><th>bot</th><th>purpose</th></tr> +{rows} +</table> +<p>All bots run <a href="https://sopel.chat/">Sopel</a>. Source: <a href="https://git.gumx.cc/irc-bots">git.gumx.cc/irc-bots</a>.</p>""" + + index_html = page( + "irc bots — irc.gumx.cc", + '<h1><a href="https://irc.gumx.cc">irc.gumx.cc</a> / bots</h1>', + index_content, + ) + with open(os.path.join(out_dir, "index.html"), "w") as f: + f.write(index_html) + + print(f"Generated {len(BOTS)} bot pages + index in {out_dir}") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} <src_dir> <out_dir>", file=sys.stderr) + sys.exit(1) + build(sys.argv[1], sys.argv[2]) diff --git a/shireen/__init__.py b/shireen/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/shireen/__init__.py diff --git a/shireen/commands.py b/shireen/commands.py new file mode 100644 index 0000000..d5ddbda --- /dev/null +++ b/shireen/commands.py @@ -0,0 +1,567 @@ +""" +Sopel plugin: shireen - news and weather bot + +Weather (via wttr.in - no API key required): + !weather <city> - current conditions + !forecast <city> - 3-day forecast + +News (via RSS feeds): + !headlines [label] - latest headlines (label: global / mena / egypt) + !news - show broadcast state for this channel + !news on|off - enable/disable auto-broadcast (owner/op) + !news interval <min> - set broadcast interval in minutes (owner/op) + !news count <n> - set headlines per broadcast cycle (owner/op) + +Auto-broadcast: posts fresh headlines on a configurable interval per channel. +Seen article GUIDs are stored so the same story is never repeated. +Config and seen-list are persisted to a JSON file on disk. + + !join / !part / !chans / !help - owner commands + +Config defaults (sopel cfg [shireen] section): + broadcast_interval = 90 ; minutes between auto-broadcast cycles + broadcast_count = 1 ; headlines posted per cycle + headlines_count = 1 ; headlines returned by !headlines + +Default RSS feeds (configurable in data JSON): + Global: BBC World, Reuters, AP News, The Guardian + MENA: Arab News, Al Jazeera, Al-Monitor + Egypt: Egypt Independent, Daily News Egypt +""" + +import json +import logging +import os +import threading +import time +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET + +from sopel import plugin +from sopel.config.types import FilenameAttribute, StaticSection, ValidatedAttribute + +log = logging.getLogger(__name__) + +_DEFAULT_DATA = { + "channels": {}, + "feeds": [ + {"id": "bbc_world", "name": "BBC World", "url": "https://feeds.bbci.co.uk/news/world/rss.xml", "label": "global", "enabled": True}, + {"id": "reuters_world", "name": "Reuters World", "url": "https://feeds.reuters.com/reuters/worldNews", "label": "global", "enabled": True}, + {"id": "ap_world", "name": "AP News", "url": "https://feeds.apnews.com/rss/apf-topnews", "label": "global", "enabled": True}, + {"id": "guardian_world", "name": "The Guardian", "url": "https://www.theguardian.com/world/rss", "label": "global", "enabled": True}, + {"id": "arab_news", "name": "Arab News", "url": "https://www.arabnews.com/rss.xml", "label": "mena", "enabled": True}, + {"id": "aljazeera", "name": "Al Jazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml", "label": "mena", "enabled": True}, + {"id": "al_monitor", "name": "Al-Monitor", "url": "https://www.al-monitor.com/rss.xml", "label": "mena", "enabled": True}, + {"id": "egypt_indep", "name": "Egypt Independent","url": "https://egyptindependent.com/feed/", "label": "egypt", "enabled": True}, + {"id": "daily_news_egypt","name": "Daily News Egypt", "url": "https://dailynewsegypt.com/feed/", "label": "egypt", "enabled": True} + ], + "seen": [] +} + +_DEFAULT_CHANNEL = { + "news_enabled": True, + "broadcast_interval": 90, + "broadcast_count": 1, + "last_broadcast": 0, +} + +_SEEN_MAX = 1000 +_MAX_COUNT = 10 + + +class ShireenSection(StaticSection): + data_path = FilenameAttribute("data_path", relative=False, + default="/var/shireen/data.json") + broadcast_interval = ValidatedAttribute("broadcast_interval", default="90") + broadcast_count = ValidatedAttribute("broadcast_count", default="1") + headlines_count = ValidatedAttribute("headlines_count", default="1") + + +def _load_data(path): + if os.path.exists(path): + try: + with open(path) as f: + data = json.load(f) + data.setdefault("channels", {}) + data.setdefault("feeds", _DEFAULT_DATA["feeds"]) + data.setdefault("seen", []) + return data + except Exception as exc: + log.error("shireen: failed to load data from %s: %s", path, exc) + data = json.loads(json.dumps(_DEFAULT_DATA)) + _save_data(path, data) + return data + + +def _save_data(path, data): + os.makedirs(os.path.dirname(path), exist_ok=True) + seen = data.get("seen", []) + if len(seen) > _SEEN_MAX: + data["seen"] = seen[-_SEEN_MAX:] + with open(path, "w") as f: + json.dump(data, f, indent=2) + + +def _chan(data, channel): + return data["channels"].setdefault(channel.lower(), dict(_DEFAULT_CHANNEL)) + + +def _cfg_int(bot, attr, default): + try: + return int(getattr(bot.config.shireen, attr) or default) + except Exception: + return default + + +def setup(bot): + bot.config.define_section("shireen", ShireenSection, validate=False) + try: + path = bot.config.shireen.data_path + except Exception: + path = "/var/shireen/data.json" + + interval = _cfg_int(bot, "broadcast_interval", 90) + b_count = min(_cfg_int(bot, "broadcast_count", 1), _MAX_COUNT) + h_count = min(_cfg_int(bot, "headlines_count", 1), _MAX_COUNT) + + _DEFAULT_CHANNEL["broadcast_interval"] = interval + _DEFAULT_CHANNEL["broadcast_count"] = b_count + + bot.memory["sh_data_path"] = path + bot.memory["sh_data"] = _load_data(path) + bot.memory["sh_lock"] = threading.Lock() + bot.memory["sh_broadcast_interval"] = interval + bot.memory["sh_broadcast_count"] = b_count + bot.memory["sh_headlines_count"] = h_count + + +# --- Weather --- + +import urllib.parse + + +def _fetch_url(url, timeout=10): + req = urllib.request.Request(url, headers={"User-Agent": "shireen-irc-bot/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", errors="replace") + + +_COUNTRY_CODES = { + "Afghanistan": "AF", "Albania": "AL", "Algeria": "DZ", "Argentina": "AR", + "Australia": "AU", "Austria": "AT", "Bahrain": "BH", "Bangladesh": "BD", + "Belgium": "BE", "Brazil": "BR", "Canada": "CA", "Chile": "CL", + "China": "CN", "Colombia": "CO", "Croatia": "HR", "Czech Republic": "CZ", + "Denmark": "DK", "Egypt": "EG", "Ethiopia": "ET", "Finland": "FI", + "France": "FR", "Germany": "DE", "Ghana": "GH", "Greece": "GR", + "Hungary": "HU", "India": "IN", "Indonesia": "ID", "Iran": "IR", + "Iraq": "IQ", "Ireland": "IE", "Israel": "IL", "Italy": "IT", + "Japan": "JP", "Jordan": "JO", "Kenya": "KE", "Kuwait": "KW", + "Lebanon": "LB", "Libya": "LY", "Malaysia": "MY", "Mexico": "MX", + "Morocco": "MA", "Netherlands": "NL", "New Zealand": "NZ", "Nigeria": "NG", + "Norway": "NO", "Oman": "OM", "Pakistan": "PK", "Palestine": "PS", + "Peru": "PE", "Philippines": "PH", "Poland": "PL", "Portugal": "PT", + "Qatar": "QA", "Romania": "RO", "Russia": "RU", "Saudi Arabia": "SA", + "Senegal": "SN", "Serbia": "RS", "Singapore": "SG", "South Africa": "ZA", + "South Korea": "KR", "Spain": "ES", "Sudan": "SD", "Sweden": "SE", + "Switzerland": "CH", "Syria": "SY", "Thailand": "TH", "Tunisia": "TN", + "Turkey": "TR", "Ukraine": "UA", "United Arab Emirates": "AE", + "United Kingdom": "GB", "United States of America": "US", + "United States": "US", "Venezuela": "VE", "Vietnam": "VN", "Yemen": "YE", +} + + +def _format_location(city_name, region, country): + code = _COUNTRY_CODES.get(country, country) + if region and region.lower() not in city_name.lower(): + return f"{city_name}, {region}, {code}" + return f"{city_name}, {code}" + + +def _fetch_weather_json(city): + url = f"https://wttr.in/{_city_param(city)}?format=j1" + try: + raw = _fetch_url(url) + except urllib.error.HTTPError as exc: + if exc.code in (404, 500): + return None, f"city not found: '{city}'" + return None, f"weather service error ({exc.code})" + except Exception as exc: + return None, f"weather lookup failed: {exc}" + try: + return json.loads(raw), None + except Exception: + return None, f"city not found: '{city}'" + + +def _city_param(city): + return urllib.parse.quote(city.replace(" ", "+")) + + +def _clean_url(url): + try: + parsed = urllib.parse.urlparse(url) + return urllib.parse.urlunparse(parsed._replace(query="", fragment="")) + except Exception: + return url + + +@plugin.command("weather") +def cmd_weather(bot, trigger): + city = (trigger.group(2) or "").strip() + if not city: + bot.say("usage: !weather <city>", trigger.sender) + return + data, err = _fetch_weather_json(city) + if err: + bot.say(err, trigger.sender) + return + try: + area = data["nearest_area"][0] + city_name = area["areaName"][0]["value"] + region = area["region"][0]["value"] + country = area["country"][0]["value"] + cur = data["current_condition"][0] + desc = cur["weatherDesc"][0]["value"] + temp_c = cur["temp_C"] + feels_c = cur["FeelsLikeC"] + humidity = cur["humidity"] + location = _format_location(city_name, region, country) + bot.say( + f"[Weather] {location}: {desc}, {temp_c}°C (feels like {feels_c}°C), humidity {humidity}%", + trigger.sender, + ) + except (KeyError, IndexError): + bot.say(f"city not found: '{city}'", trigger.sender) + + +@plugin.command("forecast") +def cmd_forecast(bot, trigger): + city = (trigger.group(2) or "").strip() + if not city: + bot.say("usage: !forecast <city>", trigger.sender) + return + data, err = _fetch_weather_json(city) + if err: + bot.say(err, trigger.sender) + return + try: + area = data["nearest_area"][0] + city_name = area["areaName"][0]["value"] + region = area["region"][0]["value"] + country = area["country"][0]["value"] + location = _format_location(city_name, region, country) + days = data["weather"] + parts = [] + labels = ["Today", "Tomorrow", "Day after"] + for i, (day, label) in enumerate(zip(days[:3], labels)): + desc = day["hourly"][4]["weatherDesc"][0]["value"] + max_c = day["maxtempC"] + min_c = day["mintempC"] + parts.append(f"{label}: {desc} {min_c}–{max_c}°C") + bot.say(f"[Forecast] {location}: {' | '.join(parts)}", trigger.sender) + except (KeyError, IndexError): + bot.say(f"city not found: '{city}'", trigger.sender) + + +# --- RSS --- + +def _fetch_feed(url): + raw = _fetch_url(url, timeout=15) + root = ET.fromstring(raw) + + items = [] + + # RSS 2.0 + for item in root.iter("item"): + title = item.findtext("title") or "" + link = item.findtext("link") or "" + guid = item.findtext("guid") or link + title = title.strip() + link = _clean_url(link.strip()) + guid = guid.strip() + if title and guid: + items.append({"title": title, "link": link, "guid": guid}) + + # Atom fallback + if not items: + ns = {"a": "http://www.w3.org/2005/Atom"} + for entry in root.findall("a:entry", ns): + title_el = entry.find("a:title", ns) + link_el = entry.find("a:link", ns) + id_el = entry.find("a:id", ns) + title = (title_el.text or "").strip() if title_el is not None else "" + link = _clean_url((link_el.get("href") or "").strip()) if link_el is not None else "" + guid = (id_el.text or "").strip() if id_el is not None else link + if title and guid: + items.append({"title": title, "link": link, "guid": guid}) + + return items + + +def _fresh_headlines(data, label=None, max_items=3): + seen = set(data.get("seen", [])) + feeds = [f for f in data.get("feeds", []) if f.get("enabled", True)] + if label: + feeds = [f for f in feeds if f.get("label", "").lower() == label.lower()] + + results = [] + for feed in feeds: + if len(results) >= max_items: + break + try: + items = _fetch_feed(feed["url"]) + for item in items: + if item["guid"] not in seen and len(results) < max_items: + results.append({"feed": feed["name"], **item}) + except Exception as exc: + log.warning("shireen: failed to fetch %s: %s", feed["id"], exc) + + return results + + +def _mark_seen(data, items): + for item in items: + if item["guid"] not in data["seen"]: + data["seen"].append(item["guid"]) + + +@plugin.command("headlines") +def cmd_headlines(bot, trigger): + args = (trigger.group(2) or "").strip().split() + label = None + count = bot.memory["sh_headlines_count"] + + for arg in args: + if arg.lower() in ("global", "mena", "egypt"): + label = arg.lower() + else: + try: + count = min(int(arg), _MAX_COUNT) + if count < 1: + raise ValueError + except ValueError: + bot.say("usage: !headlines [global|mena|egypt] [count]", trigger.sender) + return + + with bot.memory["sh_lock"]: + data = bot.memory["sh_data"] + items = _fresh_headlines(data, label=label, max_items=count) + if not items: + bot.say("no fresh headlines available right now", trigger.sender) + return + _mark_seen(data, items) + _save_data(bot.memory["sh_data_path"], data) + + for item in items: + bot.say(f"[{item['feed']}] {item['title']} | {item['link']}", trigger.sender) + + +# --- News toggle --- + +def _is_owner(bot, trigger): + return trigger.nick == bot.settings.core.owner + + +def _is_op(bot, trigger): + chan = bot.channels.get(str(trigger.sender)) + if not chan: + return False + return chan.privileges.get(trigger.nick, 0) >= plugin.OP + + +def _is_authorized(bot, trigger): + return _is_owner(bot, trigger) or _is_op(bot, trigger) + + +@plugin.command("news") +def cmd_news(bot, trigger): + args = (trigger.group(2) or "").strip().split() + arg = args[0].lower() if args else "" + in_chan = str(trigger.sender).startswith("#") + channel = str(trigger.sender) if in_chan else None + + if arg in ("on", "off"): + if not _is_authorized(bot, trigger): + return + if not in_chan: + bot.say("use !news on|off in a channel", trigger.nick) + return + with bot.memory["sh_lock"]: + data = bot.memory["sh_data"] + ch = _chan(data, channel) + ch["news_enabled"] = (arg == "on") + _save_data(bot.memory["sh_data_path"], data) + bot.say(f"auto-broadcast turned {arg} for {channel}", channel) + return + + if arg == "interval": + if not _is_authorized(bot, trigger): + return + if not in_chan: + bot.say("use !news interval <minutes> in a channel", trigger.nick) + return + try: + minutes = int(args[1]) + if minutes < 1: + raise ValueError + except (IndexError, ValueError): + bot.say("usage: !news interval <minutes>", trigger.sender) + return + with bot.memory["sh_lock"]: + data = bot.memory["sh_data"] + ch = _chan(data, channel) + ch["broadcast_interval"] = minutes + _save_data(bot.memory["sh_data_path"], data) + bot.say(f"broadcast interval set to {minutes} min for {channel}", channel) + return + + if arg == "count": + if not _is_authorized(bot, trigger): + return + if not in_chan: + bot.say("use !news count <n> in a channel", trigger.nick) + return + try: + count = int(args[1]) + if count < 1: + raise ValueError + except (IndexError, ValueError): + bot.say("usage: !news count <n>", trigger.sender) + return + warn = "" + if count > _MAX_COUNT: + count = _MAX_COUNT + warn = f" (capped at {_MAX_COUNT})" + with bot.memory["sh_lock"]: + data = bot.memory["sh_data"] + ch = _chan(data, channel) + ch["broadcast_count"] = count + _save_data(bot.memory["sh_data_path"], data) + bot.say(f"broadcast count set to {count} per cycle for {channel}{warn}", channel) + return + + # Show state + if in_chan: + data = bot.memory["sh_data"] + ch = _chan(data, channel) + enabled = ch.get("news_enabled", True) + every = ch.get("broadcast_interval", bot.memory["sh_broadcast_interval"]) + count = ch.get("broadcast_count", bot.memory["sh_broadcast_count"]) + last = ch.get("last_broadcast", 0) + if last: + ago = int(time.time() - last) + last_str = f"{ago // 60}m ago" if ago >= 60 else f"{ago}s ago" + else: + last_str = "never" + state = "on" if enabled else "off" + bot.say( + f"auto-broadcast: {state} | every {every} min | {count} headline(s)/cycle | last: {last_str}", + channel, + ) + else: + bot.say("usage: !news [on|off|interval <min>|count <n>] (use in a channel)", trigger.nick) + + +# --- Auto-broadcast tick --- + +@plugin.interval(60) +def _broadcast_tick(bot): + now = time.time() + + # Snapshot config and decide which channels need a broadcast — release lock before HTTP + with bot.memory["sh_lock"]: + data = bot.memory["sh_data"] + feeds = list(data.get("feeds", [])) + seen = set(data.get("seen", [])) + pending = [] + for chan_name in list(bot.channels): + ch = _chan(data, chan_name) + if not ch.get("news_enabled", True): + continue + interval_sec = ch.get("broadcast_interval", + bot.memory["sh_broadcast_interval"]) * 60 + if now - ch.get("last_broadcast", 0) < interval_sec: + continue + pending.append((chan_name, ch.get("broadcast_count", bot.memory["sh_broadcast_count"]))) + + # Fetch headlines outside the lock so HTTP latency doesn't block commands + for chan_name, b_count in pending: + active_feeds = [f for f in feeds if f.get("enabled", True)] + results = [] + for feed in active_feeds: + if len(results) >= b_count: + break + try: + for item in _fetch_feed(feed["url"]): + if item["guid"] not in seen and len(results) < b_count: + results.append({"feed": feed["name"], **item}) + except Exception as exc: + log.warning("shireen: failed to fetch %s: %s", feed.get("id", "?"), exc) + + if not results: + continue + + # Update seen + timestamp under lock, then post + with bot.memory["sh_lock"]: + _mark_seen(bot.memory["sh_data"], results) + _chan(bot.memory["sh_data"], chan_name)["last_broadcast"] = now + _save_data(bot.memory["sh_data_path"], bot.memory["sh_data"]) + seen.update(item["guid"] for item in results) + + for item in results: + bot.say(f"[{item['feed']}] {item['title']} | {item['link']}", chan_name) + + +# --- Admin --- + +@plugin.command("join") +def cmd_join(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() + if not channel.startswith("#"): + bot.say("usage: !join #channel", trigger.nick) + return + bot.join(channel) + + +@plugin.command("part") +def cmd_part(bot, trigger): + if not _is_owner(bot, trigger): + return + channel = (trigger.group(2) or "").strip() or str(trigger.sender) + bot.part(channel) + + +@plugin.command("chans") +def cmd_chans(bot, trigger): + if not _is_owner(bot, trigger): + return + chans = sorted(str(c) for c in bot.channels) + bot.say(("in: " + " ".join(chans)) if chans else "not in any channels", trigger.nick) + + +@plugin.command("help") +def cmd_help(bot, trigger): + lines = [ + "!weather <city> - current weather conditions", + "!forecast <city> - 3-day weather forecast", + "!headlines [label] [n] - latest headlines (label: global/mena/egypt, n: count)", + "!news - show auto-broadcast state for this channel", + ] + if _is_authorized(bot, trigger): + lines += [ + "!news on|off - toggle auto-broadcast (op/owner)", + "!news interval <min> - set broadcast interval in minutes (op/owner)", + "!news count <n> - set headlines per broadcast cycle (op/owner)", + ] + if _is_owner(bot, trigger): + lines += [ + "!join #channel - join a channel", + "!part [#channel] - leave a channel", + "!chans - list channels", + ] + lines.append("!help - this message") + for line in lines: + bot.say(line, trigger.nick) diff --git a/shireen/shireen.cfg b/shireen/shireen.cfg new file mode 100644 index 0000000..a444ccf --- /dev/null +++ b/shireen/shireen.cfg @@ -0,0 +1,22 @@ +[core] +nick = shireen +user = shireen +name = shireen +host = 127.0.0.1 +port = 6667 +owner = yournick +password = REPLACEME +prefix = ! +channels = #yourchannel +extra = /path/to/irc-bots/shireen +enable = + commands +logdir = /var/log/shireen +logging_level = INFO +timeout = 120 + +[shireen] +data_path = /var/shireen/data.json +broadcast_interval = 90 +broadcast_count = 1 +headlines_count = 1 |
