- Python 99.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| imgs | ||
| k8s | ||
| tests | ||
| .gitignore | ||
| bot.example.yaml | ||
| bot.py | ||
| CHANGELOG.md | ||
| Dockerfile | ||
| LICENSE | ||
| LLM-NOTICE.md | ||
| README.md | ||
| requirements-dev.txt | ||
| requirements.txt | ||
| SKILLS.md | ||
Mastodon Signup Review Bot — Setup Guide
This bot sits between Mastodon's account-approval workflow and your moderation team. When someone signs up on an instance that requires manual approval, the bot:
- waits until the new user has confirmed their email address,
- looks up the signup IP's country, city, and ASN,
- optionally validates the signup email (UserCheck / self-hosted Reacher),
- surfaces the review to one or both of:
- a web UI — a minimal HTML page at
/webuilisting pending signups with ✅ Accept / ❌ Reject buttons (plus an opt-in ⛔ Reject + block domain button; see Review integrations), or - a Matrix room — a review card that moderators resolve by replying
accept/rejectin the thread, or by reacting ✅ Accept, ❌ Reject, or ⛔ Reject + block domain,
- a web UI — a minimal HTML page at
- calls the Mastodon Admin API to approve or reject the account, and
- records the decision (web UI list update and/or Matrix thread confirmation).
The web UI has two extra views: IP and email check (/webui?view=check) runs an IP lookup and/or the configured email checks on any address on demand, and System status (/webui?view=status), a read-only dashboard of stuck or failed work — rate-limit state, signups still waiting for confirmation, and actions awaiting retry.
Both integrations are optional — enable the web UI only, the Matrix room only, or both, via the integrations block in bot.yaml. At least one must be enabled; with both off the bot exits at startup. When both are on they are equivalent: the first decision wins and the signup disappears from the other surface.
It supports any number of Mastodon instances feeding into a single web UI and/or Matrix room.
![]() |
![]() |
![]() |
![]() |
![]() |
Click any screenshot to enlarge.
Contents
- Requirements
- Installation and running the bot
- Setting up the Matrix integration
- Setting up the Mastodon application and webhook
- Configure the bot
- Review integrations (Matrix / web UI)
- Reverse proxy setup
- Daily operation
- Understanding account status polling
- Troubleshooting
- Developer notes
Requirements
- Python 3.10 or newer
- Network access from the bot host to:
- each Mastodon instance's API
ip-api.com(HTTPS, port 443) for IP geolocation- your Matrix homeserver — only if the Matrix integration is enabled
api.usercheck.com(HTTPS) — only if theusercheck.comemail check provider is enabled- your self-hosted Reacher instance — only if the
reacheremail check provider is enabled
- A dedicated Matrix account for the bot — only if the Matrix integration is enabled
- An admin-level API token on each Mastodon instance you want to manage (Setting up the Mastodon application and webhook)
Installation and running the bot
Install dependencies (exact pins in requirements.txt):
pip install -r requirements.txt
Directly with Python
python bot.py
# with debug logging:
LOG_LEVEL=DEBUG python bot.py
As a systemd service
# /etc/systemd/system/mastodon-signup-bot.service
[Unit]
Description=Mastodon signup review bot
After=network.target
[Service]
User=mastodon-bot
WorkingDirectory=/opt/mastodon-signup-bot
ExecStart=/opt/mastodon-signup-bot/venv/bin/python bot.py
Restart=on-failure
RestartSec=5
Environment=BOT_CONFIG=/opt/mastodon-signup-bot/bot.yaml
# Environment=LOG_LEVEL=DEBUG # uncomment while debugging
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now mastodon-signup-bot
journalctl -u mastodon-signup-bot -f
In Docker
Pull and run from the registry (or build locally — a Dockerfile is included):
docker pull git.fediverse.foundation/ff_pub/fedi-signup-bot:latest
docker run -d \
--name fedi-signup-bot \
-p 8080:8080 \
-v /path/to/your/bot.yaml:/app/bot.yaml:ro \
-v /path/to/bot-cache:/app/cache \
-e WEBHOOK_CACHE_FILE=/app/cache/webhook_cache.json \
--restart unless-stopped \
git.fediverse.foundation/ff_pub/fedi-signup-bot:latest
The cache volume persists the webhook replay-dedup cache across restarts — without it, a captured signed webhook could be replayed within its TTL window after every restart.
Debug environment variables:
| Variable | Effect |
|---|---|
LOG_LEVEL=DEBUG |
Log every sync event and full API responses — the main setup-verification tool |
LOG_PII=1 |
Log full email addresses instead of fingerprints (incident debugging only) |
Verifying the setup
curl https://bot.example.com/health
# {"status": "ok"}
To see which version is running:
python bot.py --version
# mastodon-signup-bot 3.0.0
/health returns only that one line and is IP-restricted (private networks by default — see health: in Configure the bot). With the web UI enabled:
curl -s https://bot.example.com/webui | head
# <!doctype html> … <h1>📬 Mastodon signup review — 0 pending</h1> …
Confirm Matrix connectivity (if enabled) in the startup logs (Joined review room !roomid, Matrix sync loop started as @signup-bot:example.org). For an end-to-end test, sign up for a test account with a real working email address — after you click the confirmation link, a review appears within one poll cycle (default 60 s).
Setting up the Matrix integration
Only needed if integrations.matrix: true.
Create the bot account
Use any Matrix homeserver (Synapse, Dendrite, Conduit, …). Register a dedicated account — do not reuse a personal one. The simplest way to get an access token is a password login via curl:
curl -s -XPOST 'https://matrix.example.org/_matrix/client/v3/login' \
-H 'Content-Type: application/json' \
-d '{
"type": "m.login.password",
"identifier": { "type": "m.id.user", "user": "signup-bot" },
"password": "REPLACE_ME"
}'
Copy the returned access_token into bot.yaml. Treat it like a password — it grants full access to the bot account. Do not log out the device this token belongs to, or the token is invalidated and the bot stops working.
Create the review room
- Create a Matrix room for signup review. A private room restricted to your moderation team is strongly recommended — approval/rejection decisions and signup IPs are visible to everyone in it.
- Invite the bot account to the room; the bot auto-joins on startup, but the invite must already exist.
- Get the room's internal ID (starts with
!, not the human-readable#alias). In Element: Room Settings → Advanced → "Internal room ID".
Setting up the Mastodon application and webhook
Required for every Mastodon instance you want to manage, regardless of which review integration(s) you use. Do both steps as an administrator, once per instance.
Create the Admin API token
The token is what lets the bot query account status and approve/reject accounts through the Mastodon Admin API — every moderation decision ultimately goes through it, so store it carefully.
- Log in with an account that has administrator privileges.
- Go to Preferences → Development → New Application, name it (e.g.
signup-review-bot), and grant scopes:- mandatory:
admin:read:accounts,admin:write:accounts - optional — only for the reject + block email domain feature (Review integrations):
admin:write:email_domain_blocks(required) andadmin:read:email_domain_blocks(optional)
- mandatory:
- Submit and copy the access token from the application's detail page.
The token is instance-specific — repeat for every instance. Adding scopes later does not require a new token: edit the existing application, tick the extra scope(s), save; the token stays valid.
Register the webhook
Mastodon pushes an account.created event to the bot the moment someone submits the signup form — this is what starts every review.
- Go to Administration → Webhooks → Add new endpoint. 2. URL:
https://<your-bot-host>/webhook/<mastodon-domain>— e.g.https://bot.example.com/webhook/mastodon.example.org. The<mastodon-domain>segment must exactly match thedomain:value inbot.yaml; the bot uses it to route the webhook to the right API credentials. 3. Events: selectaccount.created. 4. Save. Mastodon shows a signing secret — copy it intobot.yamlaswebhook_secretfor this instance. It is required: without it the bot rejects every webhook for that instance with 401, and there is no opt-out.
The endpoint must be reachable from the Mastodon server over HTTPS — put the bot behind a TLS-terminating reverse proxy (Reverse proxy setup).
Configure the bot
cp bot.example.yaml bot.yaml
Edit bot.yaml (config is read once at startup — restart the bot after editing; use the BOT_CONFIG env var for a different path):
# 127.0.0.1 (loopback) is the default and the safe choice — put a reverse
# proxy in front. "0.0.0.0" binds every interface (loud startup warning).
webhook_host: "127.0.0.1"
webhook_port: 8080
# Optional server settings (defaults shown — uncomment to override):
# trust_proxy: false # use the first X-Forwarded-For hop for the /webui
# # audit IP, access log and /health allowlist —
# # only behind a proxy you control
# webui_secure_cookie: true # Secure flag on the theme cookie; keep true behind TLS
# health: true # /health access: true (private-only, default) |
# # false (allow all) | ["10.0.0.0/8", …] (exactly
# # these IP/CIDRs); [] allows nothing
integrations:
matrix: true # post review cards to the Matrix room (default true)
webui: true # serve the HTML review page at /webui (default false)
webui_refresh: 15 # seconds between automatic web UI refreshes (0 disables)
matrix:
homeserver: "https://matrix.example.org"
user_id: "@signup-bot:example.org"
access_token: "syt_..." # see Setting up the Matrix integration
review_room: "!roomid" # internal room ID only, no domain suffix
# allowed_users: # optional: restrict who may moderate from
# - "@alice:example.org" # the room (full MXIDs); absent/empty =
# anyone in the room may act
email_confirmation:
poll_interval: 60 # seconds between email-confirmation checks
timeout: 86400 # give up after 24 h if never confirmed
on_timeout: notify # notify | reject | silent — see Understanding account status polling
# Pending-list polling (DEFAULT MODE — the single source of truth for
# account state; one sweep of the pending list per instance).
# enabled: false restores legacy per-account polling.
# pending_list:
# enabled: true # off = legacy per-account polling (see below)
# poll_interval: 60 # seconds between pending-list sweeps
manual_action_poll_interval: 300 # post-card Mastodon polls (legacy mode only)
# Optional DoS limits (defaults shown — uncomment to override):
# max_concurrent_reviews: 50 # in-flight review pollers
# review_queue_wait: 10 # seconds a webhook waits for a slot before 429
# max_pending_reviews: 500 # cap on the in-memory pending queue
# on_pending_cap: evict # evict oldest | reject new webhooks with 429
# Optional: rate-limit the bot's OWN admin API traffic per instance
# (see "Rate-limiting the bot's own API traffic")
# api_rate_limit:
# requests_per_minute: 50 # steady-state cap for background polls (0 = no cap)
# burst: 10 # short bursts allowed
# backoff_base: 60 # cooldown after the first HTTP 429, seconds
# backoff_multiplier: 2.0 # growth per consecutive 429
# backoff_max: 900 # cooldown cap, seconds
# Optional: adaptive confirmation polling + failed-action retry tuning
# (defaults shown — uncomment to override)
# adaptive_polling:
# enabled: true # off = always poll at the fixed base interval
# threshold: 10 # in-flight reviews at/above which ALL polls widen
# extended_interval: 300 # widened cadence (seconds) while the load is high
# action_retry:
# max_attempts: 20 # failed-action retry cap before EXHAUSTED
# interval: 30 # seconds between retry passes
# file: /var/lib/fedi-signup-bot/action_outbox.json # where the outbox
# # JSON lives (default: next to bot.yaml; must be
# # absolute) — env ACTION_OUTBOX_FILE wins over this
# Optional: persist pending reviews + in-flight signups across restarts
# (default OFF — a restart loses them; see "Persisting pending signups")
# persistence:
# enabled: true
# type: disk
# path: /var/lib/fedi-signup-bot/signup_cache.json # must be absolute
# Optional: relocate the webhook replay-dedup cache (default: next to
# bot.yaml as webhook_cache.json). --webhook-cache and env
# WEBHOOK_CACHE_FILE win over this; an unusable path warns at startup,
# it never blocks (the cache must not break webhook handling).
# webhook_cache:
# file: /var/lib/fedi-signup-bot/webhook_cache.json
# Optional: User-Agent sent with every outgoing API request (Mastodon admin
# API, ip-api lookups, email checks) — what your instance sees in its logs.
# Default: fedi-signup-bot/<version of the running bot>.
# user_agent: "my-mastodon-bot/1.2.3"
# Reject + block signup email domain (opt-in, default OFF) — see Review
# integrations. Requires the admin:write:email_domain_blocks token scope;
# a per-instance value overrides the global key.
# email_domain_block: true
# IP-API configuration. Leave empty to use the free tier (HTTP only — data might leak).
#ip_api:
# key: "YOUR_IP_API_PRO_KEY" # enables HTTPS via pro.ip-api.com
email_check:
- provider: usercheck.com
enabled: false
token: "" # leave empty for free tier (100 lookups/day); paid key optional
- provider: reacher
enabled: false
url: "http://reacher.internal:8080"
token: "" # optional auth token
# timeout: 20 # Reacher can be slower due to SMTP probing
mastodon_instances:
- domain: "mastodon.example.org"
access_token: "..." # see Setting up the Mastodon application and webhook
webhook_secret: "..." # required — no secret, all webhooks 401
- domain: "another.instance.social"
access_token: "..."
webhook_secret: "..."
email_poll_interval: 30 # optional per-instance overrides
email_timeout: 43200
on_timeout: reject
email_domain_block: true
# Per-instance overrides (win key-by-key): api_rate_limit, adaptive_polling
Reference for the less obvious keys:
| Key | Default | Meaning |
|---|---|---|
trust_proxy |
false |
Use the first X-Forwarded-For hop for the web-UI audit IP, access log and /health allowlist. Only behind a proxy you control — the header is spoofable otherwise. |
webui_secure_cookie |
false |
Secure flag on the theme cookie; set true behind TLS. |
health |
private-only | IP allowlist for /health: true/absent = private networks + loopback; false = allow all; list = exactly those networks ([] allows nothing). A bad CIDR fails startup. |
integrations.webui_refresh |
15 |
Web UI auto-refresh interval in seconds; 0 disables. |
matrix.allowed_users |
(absent) | Moderator allowlist (full MXIDs); absent/empty = room membership is the only gate. |
pending_list (enabled / poll_interval) |
on / 60 |
Default mode. One sweep of the instance's pending list every poll_interval seconds is the single source of truth for confirmation + manual-action detection. enabled: false restores legacy per-account polling — see below. |
email_confirmation.* / per-instance email_poll_interval, email_timeout, on_timeout |
60 / 86400 / notify |
Confirmation-wait tuning; per-instance values win over the global block. |
manual_action_poll_interval |
300 |
Post-card watcher cadence for manual moderator actions in Mastodon. (legacy mode only — with pending_list on, the watcher rides the shared sweeps) |
email_domain_block (global + per-instance) |
off | Opt-in reject + block email domain; see Review integrations. The per-instance value wins over the global one. |
ip_api.key |
(empty) | ip-api.com Pro key → HTTPS lookups. Free tier uses plain HTTP. |
DoS limits (max_concurrent_reviews, review_queue_wait, max_pending_reviews, on_pending_cap) |
50 / 10 / 500 / evict |
Bound the in-memory resources a signup burst can consume. |
api_rate_limit (requests_per_minute / burst / backoff_base / backoff_multiplier / backoff_max) |
50 / 10 / 60 / 2.0 / 900 | Per-instance rate gate for the bot's own admin API traffic — 429 cooldown + steady-state cap; see below. |
adaptive_polling (enabled / threshold / extended_interval, plus a per-instance override) |
on / 10 / 300 |
While an instance has ≥ threshold in-flight (pre-card) reviews, ALL its confirmation polls widen to extended_interval seconds until the load drops. (legacy mode only — config validation warns if it is set while pending_list is on) |
action_retry (max_attempts / interval / file) |
20 / 30 / <config dir>/action_outbox.json |
Failed-action (outbox) retry cap, cadence and file location (file must be absolute — same rules as persistence.path). Env ACTION_OUTBOX_MAX_ATTEMPTS / ACTION_OUTBOX_TICK / ACTION_OUTBOX_FILE win. |
persistence (enabled / type / path) |
off | Opt-in disk store — pending reviews + in-flight signups survive a restart (see below). |
webhook_cache.file |
<config dir>/webhook_cache.json |
Where the replay-dedup cache is persisted so the dedup window survives restarts. Must be absolute (same rules as persistence.path); --webhook-cache / env WEBHOOK_CACHE_FILE win. An unusable path warns at startup instead of failing — the cache must never break webhook handling. |
degraded_summary_interval |
600 |
While an instance is rate-limited or unreachable, one WARNING per affected instance repeats every N seconds; silent when healthy. 0 disables. The System status view shows the same state live. |
Persisting pending signups across restarts (opt-in). By default the pending queue lives in memory: a restart loses every pending review (web UI list empties, Matrix cards silently stop being actionable) and abandons every signup still waiting for email confirmation. To make them survive, enable the disk store:
persistence:
enabled: true
type: disk # currently the only option
path: /var/lib/fedi-signup-bot/signup_cache.json # must be absolute
The state is written after every change and restored at startup before the webhook listener comes up: after a restart the web UI list is populated again, Matrix cards are actionable again, and in-flight signups resume with their remaining confirmation deadline (an account that confirmed during the downtime is picked up on the first poll). An unusable path (missing directory, not writable, path is a directory) exits startup with an error describing the problem — it never silently falls back to in-memory-only. Runtime disk errors are logged and the in-memory state stays authoritative, so a full or read-only disk can never break webhook handling or moderation; a corrupt state file degrades to an empty start with a warning. For Docker, mount a volume at the file's directory so the state survives container rebuilds.
Each option can also be set from the environment or command line (PERSISTENCE_ENABLED / PERSISTENCE_TYPE / PERSISTENCE_PATH, --persistence-enabled / --persistence-type / --persistence-path) with the usual precedence: command line > environment > bot.yaml; an override is re-validated at startup exactly like the config file.
Rate-limiting the bot's own API traffic (per-instance rate gate). A burst of unconfirmable signups can push the instance's admin API into its rate limit on its own. The bot therefore routes every admin API call (confirmation polls, post-card watcher, approve/reject, domain blocks) through one gate per Mastodon instance:
- Any HTTP 429 puts that whole instance into a cooldown (honouring the server's
Retry-After, growing with each consecutive 429 up tobackoff_max). During it every admin call for that instance waits; any other response resets the escalation. - A steady-state cap (
requests_per_minute+burst) limits background polls even before the API starts 429-ing;requests_per_minute: 0disables it. - Human-paced actions (Accept/Reject clicks, reject+block) are exempt from the cap — a moderator click never queues behind pollers — but they wait out a cooldown.
- Poll sleeps carry an independent ±25% jitter so pollers that start together don't fire in lockstep.
Log hygiene during a storm: exactly one aggregate WARNING per backoff transition (episode start or escalation) instead of one line per account, a periodic summary every degraded_summary_interval seconds while degraded, and one INFO line on recovery. Failed pending-list sweeps close the same way: one WARNING when the streak starts (429 or API/network error) and one INFO pending-list sweep recovered line when the next complete sweep lands — a 429 storm therefore ends with two lines, the gate's "no longer rate-limited" and the sweep's recovery. Tune api_rate_limit if your instance's admin API is stricter — or lower requests_per_minute proactively.
Adaptive confirmation polling (widen the cadence under load — legacy mode only). With pending_list enabled (the default) there are no per-account polls to widen. In legacy mode every unconfirmed signup is its own poller, so a signup wave multiplies the bot's request rate on its own: when the in-flight (pre-card) review count reaches adaptive_polling.threshold (default 10), all confirmation polls for that instance widen to extended_interval seconds (default 300) and return to the base cadence automatically as the load drops — the confirmation wait is minutes-to-hours long, so this costs almost nothing in detection latency. One INFO line per transition; live state in the System status view. Per-instance override: repeat the block inside the instance entry (wins key-by-key). While pending_list is on the setting is dead weight at both levels — config validation warns about it.
Failed action retries (pending-actions outbox). The rate gate stops the bot from causing 429 storms; the outbox makes sure an action that gets caught in one is never lost. When an approve/reject fails with a transient error (HTTP 429, another 5xx, or a network timeout) it is recorded in a durable on-disk outbox and retried by a background pass — paced by the rate gate like everything else — until it succeeds, the account turns out to be gone, or the attempt cap (action_retry.max_attempts, default 20) is reached. This covers both on_timeout: reject auto-rejects (the spam-wave case) and a moderator's click that hit a 429. Exhaustion is loud: a CRITICAL log line plus a ❌ Matrix notice (when enabled), and the entry stays in the file for visibility. The outbox lives in an on-disk JSON file whose location you can set with action_retry.file (default: action_outbox.json next to your config; the env var ACTION_OUTBOX_FILE still wins over the config key). The path must be absolute and is probed strictly at startup — an unusable location exits with an error, never a silent in-memory fallback — and the outbox is restored before the webhook listener comes up, so pending retries survive restarts: a failed action is never silently dropped.
Validating your config
Before you point a webhook (or a reverse proxy) at the bot, validate the config file on its own — the same checks as a real startup, but no server and no Matrix login:
python bot.py --check # validates bot.yaml (or $BOT_CONFIG)
python bot.py --check /path/to/bot.yaml # or an explicit file
It prints every problem it finds — missing required keys, wrong types or out-of-range values, unknown/typo'd keys (with a "did you mean …" hint), unfilled placeholder values, env-var issues — and exits 0 if the config is valid, 1 on errors. Unknown keys are warnings by default; add --strict to make them fail too:
python bot.py --check --strict
Use it in CI and as a dry run before deploying.
Command-line options
Every environment variable above can also be set on the command line, and command-line options take precedence over environment variables, which take precedence over bot.yaml:
| Option | Env equivalent | Meaning |
|---|---|---|
-h, --help |
— | print a description of the bot and all options, then exit |
--version |
— | print the bot version, then exit |
-c PATH, --config PATH |
BOT_CONFIG |
path to the config file (default bot.yaml) |
-l LEVEL, --log-level LEVEL |
LOG_LEVEL |
DEBUG / INFO / WARNING / ERROR / CRITICAL (default INFO; DEBUG also logs full API responses) |
--pii, --no-pii |
LOG_PII |
log full email addresses instead of fingerprints (incident debugging only) |
--webhook-cache PATH |
WEBHOOK_CACHE_FILE |
where the webhook replay cache is persisted |
--persistence-enabled, --no-persistence-enabled |
PERSISTENCE_ENABLED |
enable/disable persisting pending signups to disk (default: bot.yaml) |
--persistence-type TYPE |
PERSISTENCE_TYPE |
persistence store type — currently only disk (default: bot.yaml) |
--persistence-path PATH |
PERSISTENCE_PATH |
where the JSON persistence state file is written (default: bot.yaml) |
--check [PATH], --strict |
— | validate the config and exit without starting any server (see above) |
Short and long forms are equivalent; an unknown or invalid option is a hard error (exit 2), not silently ignored. Examples:
python bot.py -c prod.yaml -l DEBUG # use prod.yaml with debug logging
python bot.py --pii # log full email addresses
python bot.py --check # dry-run the config, start nothing
WEBHOOK_DEDUP_TTL, MAX_PENDING_REVIEWS and the ACTION_OUTBOX_* variables have no command-line flag. The only one with a bot.yaml equivalent is ACTION_OUTBOX_FILE (action_retry.file) — the environment variable still wins.
Email validation
Optionally check each signup's email address with one or both providers — disabled by default. Enabled providers run concurrently with the IP lookup (no extra latency) and produce a single card line:
🔍 Email check: usercheck.com → ✅ ok | reacher → ⚠️ risky
Each enabled provider also posts a detailed thread message under the Matrix card (disposable flag, MX validity, SMTP probe result, …); if all are disabled a short thread notice says so.
| Provider | What it does | Notes |
|---|---|---|
usercheck.com |
Cloud check: disposable flag, MX validity, known public/free provider | Free tier: no key, 100 lookups/day per IP. Privacy: each applicant's email is sent to usercheck.com — review your instance's privacy policy first. |
reacher |
Self-hosted Reacher: adds a live SMTP probe | Emails stay in your infrastructure. Many major providers (Google, Microsoft, Fastmail) block SMTP probing, so unknown is expected — use it alongside UserCheck. |
Enable by setting enabled: true in the email_check list in bot.yaml. Either, both, or neither can be active.
Environment variables
Each of these can also be set with a command-line flag (see Command-line options), which takes precedence over the environment variable.
| Variable | Default | Meaning |
|---|---|---|
BOT_CONFIG |
bot.yaml |
Path to the config file. |
LOG_LEVEL |
INFO |
Log level; DEBUG logs every sync event and full API responses. |
LOG_PII |
off | 1/true/yes logs full email addresses instead of fingerprints. Incident debugging only — do not leave on in a deployment. |
BOT_VERSION |
built-in version | Version string shown at startup and by --version (injected by the published Docker image; overrides the version built into bot.py). |
WEBHOOK_CACHE_FILE |
<config dir>/webhook_cache.json |
Where the webhook replay-dedup cache is persisted so the dedup window survives restarts. Wins over webhook_cache.file in bot.yaml. |
PERSISTENCE_ENABLED |
off | Enables opt-in persistence of pending signups across restarts even when bot.yaml has no persistence: section (see Persisting pending signups). |
PERSISTENCE_TYPE |
disk |
Persistence store type; only disk is implemented — any other value exits startup with an error. |
PERSISTENCE_PATH |
(from bot.yaml) |
Where the JSON persistence state file is written; must be absolute. Wins over persistence.path. |
WEBHOOK_DEDUP_TTL |
600 |
Replay-dedup window in seconds. Primarily a test-suite override. |
MAX_PENDING_REVIEWS |
500 |
Pending-queue cap; the max_pending_reviews config key wins. Primarily a test-suite override. |
ACTION_OUTBOX_FILE |
<config dir>/action_outbox.json |
Where the pending-actions outbox is stored; it survives restarts. Wins over action_retry.file in bot.yaml (see Failed action retries). |
ACTION_OUTBOX_MAX_ATTEMPTS |
20 |
Retry cap per failed action before it is marked exhausted and alerted. Wins over action_retry.max_attempts. |
ACTION_OUTBOX_TICK |
30 |
Seconds between outbox retry passes. Wins over action_retry.interval; primarily a test-suite override. |
DEGRADED_SUMMARY_TICK |
(from bot.yaml) |
Cadence of the degraded-state summary in seconds. Wins over degraded_summary_interval; primarily a test-suite override. |
Review integrations (Matrix / web UI)
⚠️ Secure the web UI before enabling it.
/webuiis unauthenticated by design: anyone who can reach it can approve or reject signups — equivalent to holding a Mastodon admin token. Restrict access at the reverse proxy (basic auth, IP allow-list, VPN-only routing, or an auth proxy) and never expose/webuito the open internet (Reverse proxy setup). If you only need Matrix review, keepwebui: falseso the endpoint is not registered at all.
The integrations block in bot.yaml selects the review surfaces — either one or both:
matrix |
webui |
Behaviour |
|---|---|---|
true |
false |
Matrix only |
false |
true |
Web UI only — no Matrix account, room, or sync loop needed at runtime |
true |
true |
Both — a decision in either place is final; the other surface stops showing the signup |
false |
false |
Rejected at startup — at least one integration must be enabled |
Web UI
With webui: true, open http://<your-bot-host>:<webhook_port>/webui. Each pending signup is a card with the same fields as the Matrix card — user (+locale), email, email-check result, IP, location, city, registration time, invite reason — plus ✅ Accept and ❌ Reject buttons (and ⛔ Reject + block domain when enabled for that instance).
- Every feature has a no-JavaScript fallback, so it works with screen readers, no-JS browsers, and proxies that strip scripts.
- Theme: light / dark / system picker (follows the OS live for "system"); both palettes meet WCAG-AA contrast.
- Third view — System status (
/webui?view=status): read-only dashboard of stuck and failed work — each instance's rate-limit state, every signup still waiting for e-mail confirmation (age, time left until its deadline, failures), and the pending-action retries (attempts, last error, next retry or EXHAUSTED). Pre-card signups appear on no other surface; while any exist the pending list shows a one-line hint linking here. - Auto-refresh: re-checks every
webui_refreshseconds, reloading only when the pending list actually changed (preserving scroll position and focus). - Forms are CSRF-protected with a sticky per-browser token.
- Actions are logged with the client IP (
Moderation: approve … by webui (203.0.113.42)) — behind a proxy, configure real-IP header handling if you rely on that for auditing.
Matrix room
Each pending signup is one card in the review room; details (ip-api link, email-check breakdowns) are posted as thread messages so the timeline stays at one line per signup. Moderators resolve a card by:
- reacting ✅ Accept, ❌ Reject, or — when reject + block is enabled — ⛔ Reject + block domain, or
- replying
accept,reject, orreject+blockin the card's thread.
Confirmations are posted in the same thread and name who acted. Moderation is gated by room membership, plus an optional code-level backstop: matrix.allowed_users (see Configure the bot).
Reject + block signup email domain (opt-in, default off)
Reject the account and block its email domain instance-wide (Mastodon's admin e-mail-domain blocks — blocking foo.com covers subdomains; persists until deleted). Enable with email_domain_block: true; a per-instance value overrides the global one. Requires the token scope admin:write:email_domain_blocks; the bot checks at startup and warns (non-fatally) if it is missing.
Triggers, on every surface:
| Surface | Trigger |
|---|---|
| Matrix thread reply | accept, reject, or reject+block (also reject block) |
| Matrix reaction | ✅ Accept, ❌ Reject, or ⛔ Reject + block domain (:no_entry:) |
| Web UI | third button ⛔ Reject + block domain per card |
The reject always runs first; only after it succeeds does the bot block the domain — outcomes are appended to the confirmation notice (⛔ blocked · ℹ️ already blocked (duplicate = success) · ⚠️ FAILED (<status>), e.g. 403 missing scope — the account stays rejected). When the feature is disabled, the Matrix reject+block / ⛔ triggers still work as a plain reject with an explicit "domain block skipped" notice, and the web-UI button is not rendered (a stale page POSTing it gets 404). Auto-reject on confirmation timeout never blocks domains — domain blocks are strictly human-triggered.
Shared behaviour
- Pending reviews exist only in memory (unless persistence is enabled). If the bot restarts while a signup awaits a decision, acting on the old card or list entry afterward silently does nothing — use the "Pending accounts" admin link to handle it manually in Mastodon.
- Manual handling in Mastodon is detected within one poll cycle (the
pending_listsweep interval by default;manual_action_poll_intervalin legacy mode): the signup is removed from the web UI list and (when Matrix is enabled) the card gets a 📝 reaction plus a thread notice. No moderator action is needed then.
Reverse proxy setup
The bot listens on plain HTTP on webhook_port and binds 127.0.0.1 by default — only reachable from the local host, which is exactly what a local reverse proxy needs. Put a TLS-terminating proxy in front so Mastodon's webhook can reach it over HTTPS.
Setting webhook_host: "0.0.0.0" binds every interface (the bot logs a loud warning at startup) — only do that behind a firewall that already restricts who can reach the port, or the webhook, /health, and the unauthenticated /webui are exposed on all networks.
The bot sends a baseline of security headers (nosniff, Referrer-Policy, frame-ancestors CSP) and HSTS over HTTPS, but TLS and access control belong at the proxy. Recommended split:
/webui— human-facing; protect it here (basic auth below, or IP allow-list / VPN-only routing / SSO check). This is the only line of defence — the bot itself performs no authentication./(webhook +/health) — machine-to-machine traffic; keep off the open internet if you can. Add rate limiting here if you expose the webhook directly.
Example Nginx config with basic auth for /webui:
server {
listen 443 ssl;
server_name bot.example.com;
ssl_certificate /etc/letsencrypt/live/bot.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bot.example.com/privkey.pem;
# HSTS belongs at the edge — set it here (the backend only emits HSTS
# when it itself sees HTTPS).
add_header Strict-Transport-Security "max-age=31536000" always;
# Web UI — human-facing, protected with basic auth. Nginx picks the
# longest matching prefix, so /webui* (list, theme picker, Accept /
# Reject form POSTs) is handled here, not by "/" below.
location /webui {
auth_basic "Restricted — signup review";
auth_basic_user_file /etc/nginx/htpasswd_webui;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
}
# Webhook + health check — machine-to-machine traffic. /health is
# IP-restricted by the bot's `health` block (private-only by default),
# so keep this off the open internet if you can.
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
}
}
Basic auth setup (Debian/Ubuntu shown; dnf install httpd-tools on RHEL/Fedora):
sudo apt install apache2-utils
sudo htpasswd -c /etc/nginx/htpasswd_webui moderator # -c creates the file; add more users without -c
sudo chmod 640 /etc/nginx/htpasswd_webui
sudo chown root:www-data /etc/nginx/htpasswd_webui # RHEL/Fedora: root:nginx
sudo nginx -t && sudo nginx -s reload
Two proxy details that matter for the bot:
- Keep
proxy_set_header X-Real-IP $remote_addr;so the web-UI audit log shows the real client address rather than the proxy's. The bot readsX-Forwarded-For(set it too, as in the example above) and honours it only when you settrust_proxy: true— with that on, the access log, the web-UI audit line and the/healthallowlist all see the real caller instead of the proxy's own address (only behind a proxy you control). - HSTS: in the plain-HTTP-behind-TLS-proxy setup above, the backend does not emit HSTS on its own — configure it at the proxy, as shown.
Daily operation
When a new signup comes in (after the user confirms their email), moderators see:
📬 New signup on mastodon.example.org
👤 User: @alice@mastodon.example.org (Locale: en)
📧 Email: alice@example.com
🔍 Email check: usercheck.com → ✅ ok | reacher → ⚠️ risky
🔌 IP: 203.0.113.42
🌍 Location: Germany (DE) · AS3320 Deutsche Telekom AG
🏙 City: Berlin
🕐 Registered: 2026-06-30T10:00:00Z
🔗 Pending accounts: https://mastodon.example.org/admin/accounts?status=pending
Reply accept / reject — or — react ✅ / ❌ / ⛔
The 🔍 Email check line is omitted when no providers are enabled. Extra detail lives in thread messages (ip-api.com link, per-provider breakdowns); the card itself carries only the pending-accounts link.
Moderators then act:
- Web UI: click ✅ Accept or ❌ Reject on the card at
/webui(or ⛔ Reject + block domain when enabled). The page redirects back to the list without that signup; the decision is logged with the client IP. - Matrix room: react ✅ Accept, ❌ Reject, or ⛔ Reject + block domain, or reply
accept/reject/reject+blockin the thread. The confirmation names who acted — e.g. "✅ Approve of @alice@… by @mod:example.org succeeded." (orwebui (<ip>)for a web-UI click) — and is posted in the same thread.
When both integrations are enabled, the first action wins and the signup is removed from both. If a Mastodon moderator approves or rejects manually via the admin UI, the bot detects it within one poll cycle (pending_list sweep interval by default; manual_action_poll_interval in legacy mode), removes the signup from the web UI list, and (when Matrix is enabled) reacts 📝 with a thread notice:
📝 @alice@mastodon.example.org was manually approved in Mastodon
before the bot could act. Removed from queue.
Understanding account status polling
Before posting a signup to Matrix, the bot waits for the user to confirm their email address. Instead of one API call per unconfirmed signup, each instance is swept once every pending_list.poll_interval seconds (default 60 s): one paginated read of the pending list returns every pending account with its confirmation, approval and suspension state in a single pass, so N stuck signups cost a few requests per cycle instead of N. The sweep is the single source of truth for:
- email confirmation — an account shows as confirmed → the review card is posted;
- early manual actions (pre-card) — an account that drops out of the pending list is verified once: manually rejected/deleted or approved → no card, just a notice;
- manual actions after the card — the post-card watcher rides the same sweeps and removes handled signups with a 📝 thread notice.
Only a complete sweep updates the state: an error on any page keeps the last good snapshot, so a rate-limit storm can never produce a false "gone" verdict. Sweep health (last complete sweep, failed-sweep streak) shows in the web UI's System status view.
pending_list.enabled: false restores legacy per-account polling — one check per unconfirmed signup every email_confirmation.poll_interval (default 60 s), with adaptive widening (adaptive_polling:) and the separate post-card watcher cadence (manual_action_poll_interval) applying in that mode only.
All admin traffic — sweeps, verifications and every approve/reject the bot performs — passes through the per-instance rate gate above.
Timeout behaviour (on_timeout, global or per-instance):
| Value | Behaviour |
|---|---|
notify (default) |
Posts a ⏰ notice to Matrix (when enabled); leaves the account untouched. Non-destructive, visible audit trail for abandoned signups. |
reject |
Auto-rejects via the Admin API, then posts the ⏰ notice. Irreversible — a user who confirms later finds the account gone. |
silent |
Does nothing and logs nothing. |
Troubleshooting
- Reactions/replies don't trigger approval, logs show nothing. Set
LOG_LEVEL=DEBUG— every sync event is logged. Nom.reactionlines at all → the homeserver isn't delivering reactions (check the bot wasn't kicked from the room). - Reactions are logged but nothing happens. A line like
"…matched action=approve but event not in pending…"means the in-memory state lost that event — most likely the bot restarted after the card was posted. Use the "Pending accounts" link to act manually. - Bot posts a "manually approved/rejected" notice for its own actions. Should not happen (own actions are tracked before the API call). Check the logs for a race around that account ID.
- Clicking Accept/Reject says the account "no longer exists". The signup was manually rejected or deleted in Mastodon between the last poll and your click — a clear confirmation, not an error: for a reject the desired end state (account gone) already holds; for an approve, check the admin panel. A genuine failure still says "FAILED" with the HTTP status code.
- Webhook 404. The
<domain>URL segment doesn't exactly match anydomain:entry inbot.yaml(including subdomain). - Webhook 401. Signature verification failed — mandatory for every webhook, including Mastodon's plain-text ping. Check the instance has a non-empty
webhook_secretmatching the secret on the webhook's detail page in Mastodon's admin panel. There is no opt-out. - Webhook 409. The same signed body was already accepted within the dedup window (default 600 s), or the account is already pending/in-flight. Expected no-op — re-delivery or replay.
- Reject succeeded but the notice says domain block FAILED (403). Token lacks
admin:write:email_domain_blocks— add it by editing the existing application (no token re-creation). 422 means Mastodon rejected the domain value itself; the log shows the response body. - No review card ever appears. Most likely the user hasn't confirmed their email yet, or the bot is still inside the
timeoutwindow. "already approved" / "already rejected" log lines mean a manual action suppressed the card. - The bot pauses ALL activity on an instance for minutes at a time; logs say
admin API rate limited … (cooling down ALL admin calls). The instance answered 429 — typically a burst of unconfirmable signups. By design: all admin traffic for that instance waits out an escalating cooldown, then resumes automatically and nothing is lost. You get one aggregate WARNING per backoff transition, a periodic summary (degraded_summary_interval) while it lasts, one INFOpending-list sweep recoveredline when the next complete sweep lands, and the live picture in the System status view. If it happens routinely, lowerapi_rate_limit.requests_per_minute/burstor raise the poll intervals. - Logs say
ACTION OUTBOX EXHAUSTEDand a Matrix ❌ notice was posted. An approve/reject failed on every attempt up to the retry cap (default 20) — usually a long-lived 429 storm or a misconfigured token. The action was NOT performed; the account is still pending in Mastodon, so handle it there manually. - Web UI shows a signup already handled in Mastodon. The post-card watcher removes them within one sweep cycle — wait one cycle.
- Web UI and Matrix room disagree (one is empty). Pending state is in memory — after a restart, pre-restart cards can no longer be actioned from either surface. Act via the "Pending accounts" link.
- UserCheck shows "unavailable" for every signup. Rate limiting (free tier: 100 lookups/day per IP — add a paid
token:) or timeouts. Graceful degradation, not an error. - Reacher returns "unknown" for every address. Expected — many major providers block SMTP probing;
unknownmeans inconclusive, not invalid. - Reacher times out. Default timeout is 20 s (a real connection to the target mail server); increase
timeout:in the provider config if needed. - IP geolocation shows "no IP data". ip-api.com free-tier limits, transient unreachability, or a malformed webhook
ip. Degrades gracefully — the card posts without geo/ASN info.
Developer notes
Project shape
Everything lives in one file: bot.py, organized top-to-bottom in execution order with section banners (logging → config → enrichment → Mastodon Admin API → rate gate → pending-list poller → pending-review state → persistence / outbox → SignupReviewBot class → web UI → webhook server → main()). There is deliberately no package layout — keep it that way. SKILLS.md is the canonical map of the code, its key invariants (do-not-break list) and extension points — read it before modifying bot.py. requirements.txt pins exact versions; after any bump, re-run the regression gate below. The project was substantially developed with generative LLM assistance — see LLM-NOTICE.md.
Test suite
The suite in tests/ is a pytest suite that runs the real bot.main() against a fake Mastodon admin API (fake_mastodon.py) and a stubbed Matrix client — no real homeserver or instance needed:
python -m venv venv && venv/bin/pip install -r requirements.txt -r requirements-dev.txt
venv/bin/pytest tests/ # the FULL suite (~5–6 min)
venv/bin/pytest tests/test_cli_pytest.py # just the CLI options — fast
venv/bin/pytest -k gate # just the admin API rate-gate tests
venv/bin/pytest -k adaptive # just the adaptive-polling tests (unit + e2e)
venv/bin/pytest -k outbox # just the pending-actions outbox tests
venv/bin/pytest -k degraded # just the degraded-state summary tests
venv/bin/pytest tests/test_webui_status_pytest.py # just the System status view
The suites use ports 18080 (bot) and 18443 (fake Mastodon) and write scratch files to temp-can-be-deleted/ — that folder can be wiped after a run. run_bot.py is the shared subprocess harness; test_config.yaml is its fallback config. For a quick syntax/import check without the suite: venv/bin/python -c "import bot".




