Classifies each signup's email domain (domain-only, GDPR-friendly) alongside the existing IP scrutiny signal, with matching held-welcome and auto email_domain_block behavior. A report against an account with a flagged domain suspends immediately (no reporter-count threshold) and blocks the domain, classified live from the report payload rather than any signup-time record so it also covers pre-existing accounts. Ships CHECK_MAIL_DRY_RUN=true by default, independent of ABUSE_DRY_RUN, so the new report-triggered suspend path stays inert until watched.
yttrx welcome-bot + abuse-bot
A small FastAPI webhook server that receives Mastodon admin webhooks for yttrx.com and reacts to two events:
-
account.created/account.approved— direct-messages a welcome toot to every new local signup (the welcome bot). Both events are handled so it works whether registration approval is on or off; dedup welcomes once. -
report.created— evaluates the reported account and, when enough distinct reporters have open reports against a young or dormant account, auto-silences it (reversible) and DMs a moderator for review (the abuse bot). Special case: a report against an account whose email domain is flagged high-risk (see below) auto-suspends it immediately (no reporter-count threshold) and blocks the domain. -
Runs on:
admin.yttrx.com(Docker container, nginx-proxied athttps://hooks.yttrx.com) -
Mastodon side:
mammut— one admin webhook subscribing to both events, a welcome bot account (posts the DMs), and a separate moderator bot account whose token carries the admin scopes used to silence accounts. -
Signatures are HMAC-verified; both flows dedupe in a sqlite store so nobody is welcomed twice and no account is auto-actioned twice.
new signup / new report on yttrx
└─> Mastodon (mammut) fires admin webhook account.created|approved | report.created
└─> POST https://hooks.yttrx.com/webhook (X-Hub-Signature: sha256=…)
└─> nginx (admin) -> 127.0.0.1:8087 -> bot container
├─ account.created/approved -> POST /api/v1/statuses (welcome DM)
└─ report.created -> classify target, count distinct
reporters, maybe silence + DM mod
Abuse-bot policy
On report.created against a local, not-already-limited account that is
not staff (no Admin/Owner/Moderator role — ABUSE_SKIP_PRIVILEGED) and not
on ABUSE_ALLOWLIST:
- Classify the account by its authored posts (reblogs excluded):
young— oldest post is newer thanABUSE_YOUNG_MAX_DAYS(30d)dormant— newest post is older thanABUSE_DORMANT_MIN_DAYS(30d)no-posts— reported account with no authored statusesactive— everything else (posting for >1mo and posted recently)
- Count distinct reporters with open reports against the target (self-reports excluded — one person filing repeatedly counts once).
- Silence the account when distinct reporters ≥ the tier threshold:
- young / dormant / no-posts →
ABUSE_SOURCES_NEWDORMANT(2) - active →
ABUSE_SOURCES_ACTIVE(3)
- young / dormant / no-posts →
- The action is
silence(reversible) and is applied without areport_id, so the report stays open in the moderation queue. The bot then DMsMOD_ALERT_ACCTa summary with a link to the report, and DMs the silenced user a link to the appeals/help page (ABUSE_HELP_URL,https://welcome.yttrx.com/posts/account-limited/).
ABUSE_DRY_RUN=true (the shipped default) logs + DMs what would happen
without touching any account — keep it on until you've watched it for a while.
IP-based signup scrutiny
Every account.created delivery already carries the signup IP for free
(Admin::Account.ip). On each new local signup, the bot:
- Classifies the IP via RDAP org lookup (
ipwhois, cached in sqlite) asdatacenter(hosting/VPN-keyword match),mobile(carrier-keyword match, informational only), orresidential(everything else — never flagged). RDAP failures classify asunknownand are never flagged. - If
datacenter, the signup is treated with more scrutiny:- Moderator DM with the IP, org, and classification.
- Held welcome (
IP_SCRUTINY_HOLD_WELCOME) — the welcome DM is skipped onaccount.createdand only sent whenaccount.approvedfires, i.e. once a human clears yttrx's existing approval-required registration gate. If the signup is rejected instead, no welcome is ever sent. - Auto-registered IP block (
IP_SCRUTINY_AUTO_IPBLOCK) — the IP is added to Mastodon's nativeAdmin::IpBlockatIP_SCRUTINY_IPBLOCK_SEVERITY(defaultsign_up_requires_approval, reversible from the admin UI). - Lowered abuse-bot threshold — if this account is later reported, the
usual
ABUSE_SOURCES_*distinct-reporter threshold is replaced byIP_SCRUTINY_ABUSE_THRESHOLD(whichever is lower), since a flagged signup IP plus a report is a stronger combined signal than either alone.
IP_SCRUTINY_DRY_RUN=true (the shipped default) classifies and DMs a
moderator without holding any welcome or writing any ip_block — keep it on
until you've watched the false-positive rate of the hosting/mobile keyword
regexes for a while. Registering ip_blocks requires the ABUSE_BOT_TOKEN to
carry the admin:write:ip_blocks scope in addition to its existing scopes
(see CLAUDE.md).
Disposable/high-risk email signup scrutiny
Every account.created delivery already carries the signup email for free
(Admin::Account.email). On each new local signup, the bot:
- Classifies the domain only (never the full email address — GDPR-
friendly) via check-mail.org, cached in sqlite.
Flagged if the API marks it
is_disposableor itsriskscore (0-100) is at or aboveCHECK_MAIL_RISK_THRESHOLD(default 80). - If flagged, same scrutiny shape as the IP signal:
- Moderator DM with the domain and risk score.
- Held welcome (
CHECK_MAIL_HOLD_WELCOME) — same semantics asIP_SCRUTINY_HOLD_WELCOME. - Auto-registered email domain block (
CHECK_MAIL_AUTO_DOMAIN_BLOCK) — the domain is added to Mastodon's nativeAdmin::EmailDomainBlock, blocking future signups from it.
A report against an account whose email domain is flagged is a special,
stronger case (not merely a lowered threshold like the IP signal): it
auto-suspends the account on the very first report — no distinct-reporter
threshold — and (re-)registers the email domain block. This is classified
live from the report payload's target_account.email (a full
Admin::Account, always present), not from any record made at signup time —
so it also catches accounts that signed up before this feature existed or
while CHECK_MAIL_ENABLED/CHECK_MAIL_API_KEY was off. Gated by
ABUSE_DRY_RUN or CHECK_MAIL_DRY_RUN (either one holds it) —
deliberately independent of ABUSE_DRY_RUN alone, so this brand-new action
ships inert by CHECK_MAIL_DRY_RUN's own default even once the general
abuse-bot is live.
CHECK_MAIL_DRY_RUN=true (the shipped default) classifies and DMs a
moderator without holding any welcome or writing any email_domain_block —
keep it on until you've watched the false-positive rate for a while.
Registering email_domain_blocks requires the ABUSE_BOT_TOKEN to carry the
admin:write:email_domain_blocks scope in addition to its existing scopes
(see CLAUDE.md).
Layout
| Path | Purpose |
|---|---|
app/main.py |
The FastAPI app |
Dockerfile, docker-compose.yml |
Container build + run |
.env.example |
Config template (copy to .env, never commit) |
nginx/hooks.yttrx.com.conf |
nginx site for admin.yttrx.com |
test_local.py |
Offline smoke test (no network) |
Configuration
Copy .env.example to .env and fill in:
| Var | What |
|---|---|
WEBHOOK_SECRET |
The secret Mastodon shows when you create the webhook |
BOT_ACCESS_TOKEN |
Access token of the bot account (scope write:statuses) |
MASTODON_BASE_URL |
https://yttrx.com |
WELCOME_MESSAGE |
Template; {acct} → new user's handle |
LOCAL_ONLY |
true — only welcome accounts local to yttrx |
DB_PATH |
/data/welcomed.db (matches the compose volume) |
ABUSE_BOT_TOKEN |
Moderator bot token (admin scopes); blank disables abuse handling |
ABUSE_ENABLED |
Master switch for report handling |
ABUSE_DRY_RUN |
true — log/DM only, take no action (rollout safety) |
ABUSE_ACTION |
silence (default, reversible) or suspend |
ABUSE_LOCAL_ONLY |
true — only auto-act on local accounts |
ABUSE_YOUNG_MAX_DAYS / ABUSE_DORMANT_MIN_DAYS |
Tier windows (30 / 30) |
ABUSE_SOURCES_NEWDORMANT / ABUSE_SOURCES_ACTIVE |
Distinct-reporter thresholds (2 / 3) |
ABUSE_MAX_STATUS_PAGES |
Backward status-scan cap (5 × 40 statuses) |
ABUSE_SKIP_PRIVILEGED |
true — never auto-act on staff (Admin/Owner/Moderator), via the payload role |
ABUSE_ALLOWLIST |
Extra handles never to auto-act on (comma-separated backstop) |
MOD_ALERT_ACCT |
Handle to DM after an auto-action; blank disables the DM |
ABUSE_HELP_URL |
Appeals/help page the silenced user is linked to |
ABUSE_USER_DM |
Template DMed to the silenced user ({acct}, {help_url}); blank disables |
IP_SCRUTINY_ENABLED |
Master switch for IP-based signup scrutiny |
IP_SCRUTINY_DRY_RUN |
true — classify + DM only, no held welcome, no ip_block write |
IP_SCRUTINY_HOLD_WELCOME |
true — hold the welcome for a flagged signup until account.approved |
IP_SCRUTINY_ABUSE_THRESHOLD |
Distinct-reporter threshold used (if lower) for accounts with a flagged signup IP |
IP_SCRUTINY_AUTO_IPBLOCK |
Auto-register a flagged IP into Mastodon's Admin::IpBlock |
IP_SCRUTINY_IPBLOCK_SEVERITY |
sign_up_requires_approval (default), sign_up_block, or no_access |
IP_SCRUTINY_HOSTING_RE / IP_SCRUTINY_MOBILE_RE |
Keyword regexes matched against the RDAP org/ASN description |
CHECK_MAIL_ENABLED |
Master switch for disposable/high-risk email signup scrutiny |
CHECK_MAIL_API_KEY |
check-mail.org API key; blank disables the check |
CHECK_MAIL_DRY_RUN |
true — classify + DM only, no held welcome, no email_domain_block write, no report-triggered suspend |
CHECK_MAIL_RISK_THRESHOLD |
Flag if is_disposable or risk ≥ this (default 80) |
CHECK_MAIL_HOLD_WELCOME |
true — hold the welcome for a flagged signup until account.approved |
CHECK_MAIL_AUTO_DOMAIN_BLOCK |
Auto-register a flagged domain into Mastodon's Admin::EmailDomainBlock; also gates the report-triggered override |
Local test
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
WEBHOOK_SECRET=testsecret BOT_ACCESS_TOKEN=x python test_local.py # -> ALL TESTS PASSED
Deploy runbook
Every step that changes the running yttrx system must be logged in
yttrx-documentation/changelog.md(auto-publishes ongit push origin main).
1. Create the bot account + token (on yttrx, via the web UI)
- Register/choose a bot account, e.g.
@welcome(set "This is a bot account" in Preferences → Profile). - Preferences → Development → New application.
- Scopes:
write:statuses(untick the rest). - Save, open it, copy "Your access token" →
BOT_ACCESS_TOKENin.env.
- Scopes:
1b. Create the moderator bot account + token (for the abuse bot)
The abuse bot silences accounts, which needs admin privileges — keep this off the public welcome bot.
- Register a dedicated account, e.g.
@modbot(mark it a bot account). - As an admin, give it a role that includes the Manage Users and Manage Reports permissions (Administration → Roles, then assign it on the account). Without these the API calls 403.
- As
@modbot: Preferences → Development → New application with scopesadmin:write:accounts,admin:read:reports,read:statuses,write:statuses. Copy its access token →ABUSE_BOT_TOKENin.env. - Set
MOD_ALERT_ACCTto the handle that should receive review DMs (e.g.pete). KeepABUSE_DRY_RUN=truefor the initial rollout.
2. Build + run the container (on admin.yttrx.com)
# copy this project to admin (rsync/scp/git clone), then:
cd ~/yttrx-welcomebot # wherever it lands on admin
cp .env.example .env # fill in BOT_ACCESS_TOKEN now; WEBHOOK_SECRET in step 4
docker compose up -d --build
curl -s localhost:8087/healthz # -> {"ok":true}
The container listens on 127.0.0.1:8087 only.
3. nginx + TLS for hooks.yttrx.com (on admin.yttrx.com)
DNS: point hooks.yttrx.com (A/AAAA, or proxied via Cloudflare) at admin first.
sudo cp nginx/hooks.yttrx.com.conf /etc/nginx/sites-available/hooks
# issue the cert (standalone — same pattern as the other admin sites)
sudo systemctl stop nginx
sudo certbot certonly --standalone -d hooks.yttrx.com
sudo systemctl start nginx
sudo ln -s ../sites-available/hooks /etc/nginx/sites-enabled/hooks
sudo nginx -t && sudo systemctl reload nginx
curl -s https://hooks.yttrx.com/healthz # -> {"ok":true}
Renewal piggybacks on the existing 0 2 * * * certbot renew --nginx cron.
4. Create the webhook in Mastodon (on yttrx, admin UI)
Administration → Webhooks → New webhook:
- URL:
https://hooks.yttrx.com/webhook - Events: check
account.created,account.approvedandreport.created(one webhook, all events → one shared secret, one endpoint). Both account events are handled so the welcome fires whether or not registration approval is enabled; the dedup store welcomes once. - Save, then copy the generated secret →
WEBHOOK_SECRETin.envon admin, anddocker compose up -dto restart with it. - Use the webhook's "Send test" / re-enable it; confirm a
200indocker compose logs -f welcomebot.
If you'd rather roll the abuse bot out separately, create a second webhook for
report.createdonly — but then it has its own secret, so you'd need a second endpoint/secret. Subscribing one webhook to both events is simpler.
⚠️ Verify the exact event name and that the signing header is
X-Hub-Signatureagainst this instance (v4.5.11) when you wire it up — the admin UI lists the available events, and the server logs the header it receives. Adjustapp/main.pyif your version differs.
5. Smoke test end to end
Register a throwaway test account on yttrx and confirm the @welcome bot DMs
it. Then delete the test account (tootctl accounts delete on mammut) and the
test toot.
Operations
welcomebot-logs -f --abuse # convenience CLI on admin (see bin/welcomebot-logs)
welcomebot-signups --flagged # signup-IP classification history (see bin/welcomebot-signups)
docker compose logs -f welcomebot # watch deliveries
docker compose restart welcomebot # after editing .env
docker compose down && docker compose up -d --build # redeploy after code change
Dedup store: welcomebot-data volume → /data/welcomed.db. To re-welcome
someone (e.g. after a failed send that got marked), delete their row:
docker compose exec welcomebot \
python -c "import sqlite3; sqlite3.connect('/data/welcomed.db').execute(\
'DELETE FROM welcomed WHERE acct=?', ('alice',)).connection.commit()"
Rollback
# stop the bot
cd ~/yttrx-welcomebot && docker compose down
# disable nginx site
sudo rm /etc/nginx/sites-enabled/hooks && sudo nginx -t && sudo systemctl reload nginx
# in Mastodon admin UI: disable or delete the account.created webhook