From 5a8d568931d9b23ce0df1265d05259a7012081c9 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Sun, 14 Jun 2026 01:46:29 +0300 Subject: init: mostly vibed --- alfred/__init__.py | 0 alfred/alfred.cfg | 34 ++++++ alfred/bots.py | 133 ++++++++++++++++++++++++ alfred/coffee.py | 212 +++++++++++++++++++++++++++++++++++++ alfred/files.py | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++ alfred/git.py | 148 ++++++++++++++++++++++++++ alfred/help.py | 31 ++++++ alfred/helpstrings.py | 78 ++++++++++++++ alfred/mlmmj.py | 97 +++++++++++++++++ alfred/server.py | 266 +++++++++++++++++++++++++++++++++++++++++++++++ alfred/soju.py | 193 ++++++++++++++++++++++++++++++++++ alfred/vpn.py | 170 ++++++++++++++++++++++++++++++ 12 files changed, 1644 insertions(+) create mode 100644 alfred/__init__.py create mode 100644 alfred/alfred.cfg create mode 100644 alfred/bots.py create mode 100644 alfred/coffee.py create mode 100644 alfred/files.py create mode 100644 alfred/git.py create mode 100644 alfred/help.py create mode 100644 alfred/helpstrings.py create mode 100644 alfred/mlmmj.py create mode 100644 alfred/server.py create mode 100644 alfred/soju.py create mode 100644 alfred/vpn.py (limited to 'alfred') diff --git a/alfred/__init__.py b/alfred/__init__.py new file mode 100644 index 0000000..e69de29 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 - control all bots at once + !bot - control a bot + !bot 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 ") + 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} ") + 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} ") 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: [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 [wkfo|gumx] - shorten a URL (default: wkfo) + !files mirror [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 - 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 set repo description + !git hook install a named post-receive hook + !git remove 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 ") + 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 ") + 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 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 ") + 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 ") 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 - list commands for a topic + !help - 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 for details" + +SRV_TOPIC = "srv: uptime disk mem load status net top topmem io conns who logs. type !help srv 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 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 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 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 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 - subscribe to list@gumx.cc + !list unsubscribe - unsubscribe + !list members - show all subscribers + !list send - 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 ") + return + + action = args[0] + + if action == "subscribe": + if len(args) < 2: + bot.say("usage: !list subscribe ") + 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 ") + 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 ") + 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 ") 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 [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 [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 [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 - create a new user + !irc user remove - delete a user + !irc user passwd - change a user's password + !irc user enable - re-enable a disabled user + !irc user disable - disable a user + + !irc net list - list networks for a user + !irc net status - show network connection status + !irc net add - add a network using a preset + !irc net add - add a network with a custom address + !irc net remove - remove a network + !irc net quote - send raw IRC command to a network + !irc net presets - list available network presets + + !irc bouncer - show bouncer-wide stats + !irc notice - 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 ") + 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 |remove |passwd |enable |disable >") + 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 |remove |passwd |enable |disable >") + + +# --------------------------------------------------------------------------- +# net subcommands +# --------------------------------------------------------------------------- + +def _cmd_net(bot, args): + if not args: + bot.say("usage: !irc net |status |add [addr]|remove |quote |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 |status |add [addr]|remove |quote |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 - add peer, returns client config URL + !vpn remove - 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 ") + 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 ") + 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 = \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 ") + 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 ") -- cgit v1.2.3