From 80426d77bc6f5ca0d67e9c3927fcbb51e60db670 Mon Sep 17 00:00:00 2001 From: Zach H Date: Sun, 21 Jun 2026 01:09:57 -0400 Subject: [PATCH] Add Discord account registration monitor (#7013) A read-only cron script that posts new Servatrice account registrations to a Discord channel via webhook. Dedups by an auto-increment id high-water-mark (single-integer state, no duplicates, nothing missed across downtime). Reads DB credentials and the webhook from a servatrice-style ini via --config. --- servatrice/scripts/account_monitor/.gitignore | 5 + servatrice/scripts/account_monitor/README.md | 163 ++++++++ .../account_monitor/account_monitor.py | 350 ++++++++++++++++++ .../scripts/account_monitor/requirements.txt | 1 + 4 files changed, 519 insertions(+) create mode 100644 servatrice/scripts/account_monitor/.gitignore create mode 100644 servatrice/scripts/account_monitor/README.md create mode 100755 servatrice/scripts/account_monitor/account_monitor.py create mode 100644 servatrice/scripts/account_monitor/requirements.txt diff --git a/servatrice/scripts/account_monitor/.gitignore b/servatrice/scripts/account_monitor/.gitignore new file mode 100644 index 000000000..40cd3839a --- /dev/null +++ b/servatrice/scripts/account_monitor/.gitignore @@ -0,0 +1,5 @@ +# Local state - never commit these +state.json +state.json.tmp +venv/ +__pycache__/ diff --git a/servatrice/scripts/account_monitor/README.md b/servatrice/scripts/account_monitor/README.md new file mode 100644 index 000000000..e2c01d0ba --- /dev/null +++ b/servatrice/scripts/account_monitor/README.md @@ -0,0 +1,163 @@ +# Account registration monitor + +Posts a Discord message whenever a new account is registered in Servatrice +(`cockatrice_users`). Each message includes the username, real name (if set), +email, and registration time. + +It runs as a periodic read-only query against the production database. It does +not modify the database and does not touch the running Servatrice process. + +## How it decides what is "new" + +Accounts get an auto-increment `id`, so "new since last time" is just +`id > last_seen_id`. The monitor stores that single high-water-mark id in its +state file. Each run it posts every account above the mark, oldest first, then +advances the mark to the highest id it posted. + +Because the mark only moves forward and an id is posted exactly once, there are +no duplicate messages and nothing is missed, even if the monitor is down for a +while. The state file holds a single number, so it never grows. + +The first run (when no state file exists yet) records the current maximum id as +the baseline and posts nothing. This prevents the entire existing user base from +being dumped into the channel. Only accounts registered after that baseline are +posted. + +If a post to Discord fails, the monitor stops there without advancing the mark +past it, so that account and everything after it are retried on the next run. + +## Privacy note + +Messages contain personal data (real name and email). Discord stores message +content on their servers, so post only to a private channel that the right +people can see, and treat the webhook URL as a secret. It lives in the config +ini alongside the database password, so keep that file readable only by the user +that runs the monitor. + +## Setup + +The monitor reads its database credentials and the webhook from a +servatrice-style ini file passed with `--config` (or the `CONFIG_FILE` env var). +You can point it at your existing `servatrice.ini`, or keep a small separate ini +just for the monitor. + +### 1. Create a read-only database user + +Run as a DB admin. Adjust the host (`'%'` allows any host; restrict it to the +machine running the monitor if you can) and the table prefix if yours is not the +default `cockatrice`. + +```sql +CREATE USER 'account_monitor'@'%' IDENTIFIED BY 'a-strong-password'; +GRANT SELECT (id, name, realname, email, registrationDate) + ON servatrice.cockatrice_users TO 'account_monitor'@'%'; +FLUSH PRIVILEGES; +``` + +Using a read-only user is recommended over pointing `--config` at the real +`servatrice.ini`, because Servatrice's own DB account usually has write access +the monitor does not need. + +### 2. Create the Discord webhook and add it to the config + +In Discord: open the target channel, then Edit Channel -> Integrations -> +Webhooks -> New Webhook. Name it, pick the channel, and copy the webhook URL. + +Add a `[discord]` section with the URL to the ini you will pass to `--config`. +If you want the read-only user above, set the `[database]` section to use it. A +small dedicated `monitor.ini` looks like this: + +```ini +[database] +hostname=127.0.0.1 +database=servatrice +user=account_monitor +password=a-strong-password +prefix=cockatrice + +[discord] +new_user_activation_webhook=https://discord.com/api/webhooks/XXXX/YYYY +``` + +If you would rather use one file, add the `[discord]` section to the real +`servatrice.ini` instead. Servatrice ignores sections it does not use. Note that +Servatrice (a Qt app) rewrites ini values it touches in quoted, backslash-escaped +form, for example `"https\://..."`. The monitor strips that encoding from the +webhook automatically, so either the plain or the escaped form works. + +### 3. Install + +```bash +cd servatrice/scripts/account_monitor +python3 -m venv venv +./venv/bin/pip install -r requirements.txt +``` + +### 4. Verify before scheduling + +```bash +# Confirm the webhook works (sends one test message to the channel) +./venv/bin/python ./account_monitor.py --config /path/to/monitor.ini --test-webhook + +# Confirm DB access and see what it would do, without posting or writing state +./venv/bin/python ./account_monitor.py --config /path/to/monitor.ini --dry-run --verbose +``` + +The first real run seeds the baseline and posts nothing: + +```bash +./venv/bin/python ./account_monitor.py --config /path/to/monitor.ini +``` + +After that, test it end to end by registering a throwaway account and confirming +a message appears on the next run. + +## Run it every 2 minutes with cron + +Edit the crontab of the user that owns the script directory (`crontab -e`) and +add one line. This runs the monitor every 2 minutes, using the venv's Python and +your config ini, and appends output to a log: + +```cron +*/2 * * * * cd /opt/cockatrice/servatrice/scripts/account_monitor && ./venv/bin/python ./account_monitor.py --config /etc/servatrice/servatrice.ini >> /var/log/account_monitor.log 2>&1 +``` + +Adjust the three paths to your install: the script directory after `cd`, and the +`--config` and log paths. The `*/2` field is what makes it run every 2 minutes; +change it to `*/5` for every 5, and so on. + +By default the high-water-mark is stored in `state.json` next to the script, so +the directory must be writable by the cron user. To put it elsewhere, set +`STATE_FILE`: + +```cron +*/2 * * * * STATE_FILE=/var/lib/account_monitor/state.json cd /opt/cockatrice/servatrice/scripts/account_monitor && ./venv/bin/python ./account_monitor.py --config /etc/servatrice/servatrice.ini >> /var/log/account_monitor.log 2>&1 +``` + +The interval only controls how often it checks; it is not a lookback window, so +a longer interval never causes missed accounts. The query is cheap: an indexed +range scan on the primary key for `id > last_seen`. + +## Options + +- `--config PATH` / `-c PATH` — read DB settings from `[database]` and the webhook from `[discord] new_user_activation_webhook` of a servatrice-style ini (falls back to the `CONFIG_FILE` env var). +- `--dry-run` — query and log what would be posted; no Discord posts, no state write. +- `--test-webhook` — send one test message to the webhook and exit (does not need DB credentials). +- `--verbose` — debug logging. + +## Configuration reference + +Settings come from the `--config` ini, with environment variables available as +overrides if you need them (env takes precedence over the ini). + +| Setting | ini (`--config`) | Environment override | +| --- | --- | --- | +| DB host | `[database] hostname` | `DB_HOST` | +| DB port | `[database] port` (optional) | `DB_PORT` | +| DB name | `[database] database` | `DB_NAME` | +| DB user | `[database] user` | `DB_USER` | +| DB password | `[database] password` | `DB_PASSWORD` | +| Table prefix | `[database] prefix` | `DB_TABLE_PREFIX` | +| Webhook URL | `[discord] new_user_activation_webhook` | `DISCORD_WEBHOOK_URL` | +| DB TLS | — | `DB_SSL` / `DB_SSL_CA` | +| State file path | — | `STATE_FILE` (default: `state.json` next to the script) | diff --git a/servatrice/scripts/account_monitor/account_monitor.py b/servatrice/scripts/account_monitor/account_monitor.py new file mode 100755 index 000000000..47cf23fd3 --- /dev/null +++ b/servatrice/scripts/account_monitor/account_monitor.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +"""Post a Discord message when a new Servatrice account is registered. + +Accounts get an auto-increment `id`, so "what is new since last time" is simply +`id > last_seen_id`. The monitor stores that single high-water-mark id in a +small state file. Each run it posts every account above the mark (oldest +first), then advances the mark. This means no duplicate posts, nothing missed +across downtime, and a state file that never grows (it holds one number). + +On the very first run (no state file yet) it records the current maximum id as +the baseline and posts nothing, so existing users are not dumped into the +channel. From then on only newly-registered accounts are posted. + +Intended to be run on a schedule (cron). Pass a servatrice-style ini with +--config (or CONFIG_FILE) for the database credentials and webhook; see +README.md. +""" + +import argparse +import configparser +import json +import logging +import os +import re +import sys +import time +import urllib.error +import urllib.request + +import pymysql + +log = logging.getLogger("account_monitor") + +# Columns we use. `id` drives the high-water-mark; `name` is the login/username, +# `realname` is the optional display name, `registrationDate` is when the account +# row was created. +NEW_ACCOUNTS_QUERY = ( + "SELECT id, name, realname, email, registrationDate " + "FROM `{prefix}_users` WHERE id > %s ORDER BY id ASC" +) + +EMBED_COLOR = 0x5865F2 # discord blurple +DISCORD_MAX_EMBED_FIELD = 1024 +POST_DELAY_SECONDS = 1.0 # gap between webhook posts to stay under rate limits +MAX_RATELIMIT_RETRIES = 5 + + +def _clean_ini_value(value): + """Undo Qt QSettings ini encoding of a value. + + Qt apps (including Servatrice) write ini values that contain special + characters wrapped in double quotes and backslash-escaped, e.g. a webhook + URL stored as "https\\://...". configparser returns that text literally, so + strip the wrapping quotes and remove the backslash escapes. This is applied + to the webhook URL only, where it is safe (URLs contain no quotes or + backslashes); DB values are left untouched so passwords are never altered. + """ + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + return re.sub(r"\\(.)", r"\1", value) + + +def load_config_file(path): + """Read DB and Discord settings from a servatrice-style ini. + + Pulls the [database] section (hostname/database/user/password/prefix/port) + and the Discord webhook from [discord] new_user_activation_webhook. Returns + a dict using this module's internal config keys; only keys actually present + (and non-empty) in the file are returned, so missing values fall back to + defaults or the environment. + """ + # interpolation=None so a '%' in a password is not treated as a token. + parser = configparser.ConfigParser(interpolation=None) + if not parser.read(path): + log.error("Config file not found or unreadable: %s", path) + sys.exit(2) + + result = {} + if parser.has_section("database"): + db = parser["database"] + db_mapping = { + "hostname": "db_host", + "database": "db_name", + "user": "db_user", + "password": "db_password", + "prefix": "db_prefix", + "port": "db_port", # not in stock servatrice.ini, but honored if present + } + result.update({cfg_key: db[ini_key] for ini_key, cfg_key in db_mapping.items() if db.get(ini_key)}) + + if parser.has_section("discord") and parser["discord"].get("new_user_activation_webhook"): + result["webhook_url"] = _clean_ini_value(parser["discord"]["new_user_activation_webhook"]) + + return result + + +def get_config(config_path=None, require_db=True): + """Build configuration from defaults, an optional ini file, then env vars. + + Precedence, highest first: environment variables, the ini file, built-in + defaults. Database credentials and the Discord webhook may come from either + the ini file or the environment; the state file is environment-only. + """ + cfg = { + "db_host": "localhost", + "db_port": 3306, + "db_name": "servatrice", + "db_user": None, + "db_password": None, + "db_prefix": "cockatrice", + "db_ssl": False, + "db_ssl_ca": None, + "webhook_url": None, + "state_file": os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json"), + } + + if config_path: + cfg.update(load_config_file(config_path)) + + env_map = { + "DB_HOST": "db_host", + "DB_PORT": "db_port", + "DB_NAME": "db_name", + "DB_USER": "db_user", + "DB_PASSWORD": "db_password", + "DB_TABLE_PREFIX": "db_prefix", + "DB_SSL_CA": "db_ssl_ca", + "DISCORD_WEBHOOK_URL": "webhook_url", + "STATE_FILE": "state_file", + } + for env_key, cfg_key in env_map.items(): + if os.environ.get(env_key): + cfg[cfg_key] = os.environ[env_key] + if os.environ.get("DB_SSL"): + cfg["db_ssl"] = os.environ["DB_SSL"].lower() in ("1", "true", "yes") + + cfg["db_port"] = int(cfg["db_port"]) + + required = {"webhook_url": "DISCORD_WEBHOOK_URL or [discord] new_user_activation_webhook"} + if require_db: + required["db_user"] = "DB_USER or [database] user" + required["db_password"] = "DB_PASSWORD or [database] password" + missing = [label for key, label in required.items() if not cfg[key]] + if missing: + log.error("Missing required configuration: %s", "; ".join(missing)) + sys.exit(2) + + if cfg["webhook_url"] and not cfg["webhook_url"].lower().startswith(("http://", "https://")): + log.error("Webhook URL does not look like an http(s) URL: %r", cfg["webhook_url"]) + sys.exit(2) + return cfg + + +def connect(cfg): + """Open a read-only connection to the Servatrice database.""" + ssl = None + if cfg["db_ssl"]: + ssl = {"ca": cfg["db_ssl_ca"]} if cfg["db_ssl_ca"] else {} + return pymysql.connect( + host=cfg["db_host"], + port=cfg["db_port"], + user=cfg["db_user"], + password=cfg["db_password"], + database=cfg["db_name"], + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + connect_timeout=15, + read_timeout=30, + ssl=ssl, + ) + + +def fetch_max_id(conn, prefix): + """Return the highest account id currently in the table, or 0 if empty.""" + with conn.cursor() as cur: + cur.execute("SELECT MAX(id) AS max_id FROM `{prefix}_users`".format(prefix=prefix)) + row = cur.fetchone() + return int(row["max_id"]) if row and row["max_id"] is not None else 0 + + +def fetch_new_accounts(conn, prefix, last_id): + """Return detail rows for accounts with id > last_id, oldest first.""" + with conn.cursor() as cur: + cur.execute(NEW_ACCOUNTS_QUERY.format(prefix=prefix), (last_id,)) + return cur.fetchall() + + +def load_state(path): + """Load the high-water-mark id. Returns (last_id, is_first_run).""" + if not os.path.exists(path): + return 0, True + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + return int(data.get("last_id", 0)), False + + +def save_state(path, last_id): + """Atomically persist the high-water-mark id.""" + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump({"version": 2, "last_id": int(last_id)}, fh) + os.replace(tmp, path) + + +def build_embed(row): + """Build a Discord embed dict for one newly-registered account.""" + fields = [ + {"name": "Username", "value": str(row["name"]) or "(none)", "inline": False}, + ] + realname = (row.get("realname") or "").strip() + if realname: + fields.append({"name": "Real name", "value": realname[:DISCORD_MAX_EMBED_FIELD], "inline": False}) + fields.append({"name": "Email", "value": str(row.get("email") or "(none)"), "inline": False}) + reg = row.get("registrationDate") + fields.append({"name": "Reg time", "value": str(reg) if reg is not None else "(unknown)", "inline": False}) + return { + "title": "New account registered", + "color": EMBED_COLOR, + "fields": fields, + } + + +def post_embed(webhook_url, embed): + """POST a single embed to the Discord webhook, honoring 429 rate limits.""" + payload = json.dumps({"embeds": [embed]}).encode("utf-8") + for attempt in range(MAX_RATELIMIT_RETRIES): + req = urllib.request.Request( + webhook_url, + data=payload, + headers={"Content-Type": "application/json", "User-Agent": "servatrice-account-monitor/1.0"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + if resp.status in (200, 204): + return True + log.warning("Unexpected Discord status %s", resp.status) + return False + except urllib.error.HTTPError as err: + if err.code == 429: + retry_after = _retry_after_seconds(err) + log.warning("Rate limited by Discord; sleeping %.2fs", retry_after) + time.sleep(retry_after) + continue + log.error("Discord webhook HTTP %s: %s", err.code, err.read().decode("utf-8", "replace")[:500]) + return False + except urllib.error.URLError as err: + log.error("Discord webhook connection error: %s", err) + return False + log.error("Gave up posting after %d rate-limit retries", MAX_RATELIMIT_RETRIES) + return False + + +def _retry_after_seconds(err): + """Extract the retry delay (seconds) from a Discord 429 response.""" + header = err.headers.get("Retry-After") + if header: + try: + return float(header) + except ValueError: + pass + try: + body = json.loads(err.read().decode("utf-8", "replace")) + return float(body.get("retry_after", 1.0)) + except (ValueError, json.JSONDecodeError): + return 1.0 + + +def main(): + parser = argparse.ArgumentParser(description="Post new Servatrice account registrations to Discord.") + parser.add_argument( + "--config", "-c", + help="Path to a servatrice-style ini; reads DB settings from its [database] " + "section (hostname/database/user/password/prefix) and the webhook from " + "[discord] new_user_activation_webhook. Defaults to the CONFIG_FILE env " + "var if set.", + ) + parser.add_argument("--dry-run", action="store_true", help="Log what would be posted; do not post or write state.") + parser.add_argument("--test-webhook", action="store_true", help="Send a single test message to the webhook and exit.") + parser.add_argument("--verbose", action="store_true", help="Enable debug logging.") + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + ) + + config_path = args.config or os.environ.get("CONFIG_FILE") + + if args.test_webhook: + cfg = get_config(config_path, require_db=False) + ok = post_embed( + cfg["webhook_url"], + {"title": "Account monitor test", "color": EMBED_COLOR, + "description": "If you can see this, the webhook is configured correctly."}, + ) + sys.exit(0 if ok else 1) + + cfg = get_config(config_path) + + try: + conn = connect(cfg) + except pymysql.MySQLError as err: + log.error("Database connection failed: %s", err) + sys.exit(1) + + try: + last_id, first_run = load_state(cfg["state_file"]) + + if first_run: + baseline = fetch_max_id(conn, cfg["db_prefix"]) + log.info("First run: seeding high-water-mark at id=%d; posting nothing.", baseline) + if not args.dry_run: + save_state(cfg["state_file"], baseline) + return + + rows = fetch_new_accounts(conn, cfg["db_prefix"], last_id) + if not rows: + log.info("No new accounts since id=%d.", last_id) + return + + log.info("Found %d new account(s) since id=%d.", len(rows), last_id) + + # Post oldest first. Advance the mark only past accounts we successfully + # posted; on the first failure, stop so nothing after it is posted out of + # order or skipped. The failed account (and the rest) retry next run. + for row in rows: + if args.dry_run: + log.info("[dry-run] would post: id=%s name=%s email=%s reg=%s", + row["id"], row["name"], row.get("email"), row.get("registrationDate")) + continue + if not post_embed(cfg["webhook_url"], build_embed(row)): + log.error("Failed to post account id=%s; stopping. Will retry from here next run.", row["id"]) + break + last_id = row["id"] + log.info("Posted account id=%s (%s)", row["id"], row["name"]) + time.sleep(POST_DELAY_SECONDS) + + if not args.dry_run: + save_state(cfg["state_file"], last_id) + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/servatrice/scripts/account_monitor/requirements.txt b/servatrice/scripts/account_monitor/requirements.txt new file mode 100644 index 000000000..24d93c7a7 --- /dev/null +++ b/servatrice/scripts/account_monitor/requirements.txt @@ -0,0 +1 @@ +PyMySQL==1.2.0