Add Discord account registration monitor (#7013)
Some checks are pending
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 15 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker Image / amd64 & arm64 (push) Waiting to run

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.
This commit is contained in:
Zach H 2026-06-21 01:09:57 -04:00 committed by GitHub
parent e99a55ccab
commit 80426d77bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 519 additions and 0 deletions

View file

@ -0,0 +1,5 @@
# Local state - never commit these
state.json
state.json.tmp
venv/
__pycache__/

View file

@ -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) |

View file

@ -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()

View file

@ -0,0 +1 @@
PyMySQL==1.2.0