aboutsummaryrefslogtreecommitdiffstats
path: root/alfred
diff options
context:
space:
mode:
authorAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
committerAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
commit5a8d568931d9b23ce0df1265d05259a7012081c9 (patch)
tree4ea19b8bb1763a38246f342f172c285f6579e09b /alfred
init: mostly vibed
Diffstat (limited to 'alfred')
-rw-r--r--alfred/__init__.py0
-rw-r--r--alfred/alfred.cfg34
-rw-r--r--alfred/bots.py133
-rw-r--r--alfred/coffee.py212
-rw-r--r--alfred/files.py282
-rw-r--r--alfred/git.py148
-rw-r--r--alfred/help.py31
-rw-r--r--alfred/helpstrings.py78
-rw-r--r--alfred/mlmmj.py97
-rw-r--r--alfred/server.py266
-rw-r--r--alfred/soju.py193
-rw-r--r--alfred/vpn.py170
12 files changed, 1644 insertions, 0 deletions
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>")