mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-06-21 22:33:54 -07:00
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
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:
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