mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-06-23 07:03:54 -07:00
Add Discord account registration monitor (#7013)
Some checks failed
Build Desktop / Configure (push) Has been cancelled
Build Docker Image / amd64 & arm64 (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 15 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Some checks failed
Build Desktop / Configure (push) Has been cancelled
Build Docker Image / amd64 & arm64 (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 15 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
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:
parent
e99a55ccab
commit
80426d77bc
4 changed files with 519 additions and 0 deletions
5
servatrice/scripts/account_monitor/.gitignore
vendored
Normal file
5
servatrice/scripts/account_monitor/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Local state - never commit these
|
||||||
|
state.json
|
||||||
|
state.json.tmp
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
163
servatrice/scripts/account_monitor/README.md
Normal file
163
servatrice/scripts/account_monitor/README.md
Normal 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) |
|
||||||
350
servatrice/scripts/account_monitor/account_monitor.py
Executable file
350
servatrice/scripts/account_monitor/account_monitor.py
Executable 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()
|
||||||
1
servatrice/scripts/account_monitor/requirements.txt
Normal file
1
servatrice/scripts/account_monitor/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
PyMySQL==1.2.0
|
||||||
Loading…
Add table
Add a link
Reference in a new issue