With the sweep now running every 10 minutes, the per-run 'N due' summary and per-account skip/clear log lines (already-limited, staff, cleared by activity, not found) were mostly noise drowning out the actual suspend/ silence actions. Those states are still recorded in suspicious_watch.status for anyone querying the DB directly; only the taken action, dry-run simulation, and error paths still log.
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).
Suspicious-signup sweep
A scheduled companion to the two signup-scrutiny signals above (app/suspicious_sweep.py,
run via docker exec yttrx-welcomebot python -m app.suspicious_sweep, e.g. hourly cron on
admin.yttrx.com):
- Any account flagged by either IP-scrutiny (datacenter/hosting) or
email-domain scrutiny (disposable/high-risk) has a grace-period clock
started the moment it goes live —
account.createdif signups are open (or auto-approved),account.approvedif this instance requires moderator approval. Same dual-event handling the welcome flow already uses, so it works in either registration mode without extra config. A baseline snapshot ofstatuses_count/following_countis recorded at that moment (app.main.maybe_start_suspicious_watch, tablesuspicious_watch). - The sweep looks for watches past
SUSPICIOUS_GRACE_HOURS(default 1; lowered from the original 24 on 2026-07-06 — a datacenter/hosting-IP signup is already a strong enough spam signal that a full day of grace was mostly just delaying an inevitable suspend) and compares current counts to the baseline:- Any new post or new follow since the baseline → cleared, never rechecked.
- Zero of either →
SUSPICIOUS_ACTION(defaultsuspend) is applied, the moderator is DMed, and the watch is marked resolved.
- Already-suspended/silenced accounts, staff (
ABUSE_SKIP_PRIVILEGED), andABUSE_ALLOWLISThandles are skipped exactly as elsewhere in this bot — the sweep reuses those same settings rather than defining its own.
Unlike the report-driven abuse-bot (default silence), this sweep defaults
to suspend — the reasoning is that zero organic activity in a full day
is a stronger bulk/bot-signup signal than a report alone, and Mastodon
suspensions have a server-side undo window. It also ships live
(SUSPICIOUS_DRY_RUN=false) rather than with the usual dry-run rollout step;
SUSPICIOUS_DRY_RUN=true remains available as a kill switch if you want to
pause it without redeploying.
Requires ABUSE_BOT_TOKEN to additionally carry the admin:read:accounts
scope (fetches live counts + role/suspended state via
GET /api/v1/admin/accounts/:id) — see CLAUDE.md's moderator-token-gotcha
section for the remint procedure.
Inspect the watch list: welcomebot-suspicious / welcomebot-suspicious --pending
(see bin/welcomebot-suspicious).
Layout
| Path | Purpose |
|---|---|
app/main.py |
The FastAPI app |
app/suspicious_sweep.py |
Scheduled sweep: suspends flagged signups with no activity (run via cron, not the webserver) |
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 |
bin/welcomebot-logs, bin/welcomebot-signups, bin/welcomebot-suspicious |
Ops CLIs installed to /root/bin on 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 |
SUSPICIOUS_SWEEP_ENABLED |
Master switch for the suspicious-signup sweep (app/suspicious_sweep.py) |
SUSPICIOUS_GRACE_HOURS |
Hours a flagged signup has to post or follow someone before it's swept (default 1) |
SUSPICIOUS_ACTION |
suspend (default) or silence, applied to a flagged signup with zero activity in the grace window |
SUSPICIOUS_DRY_RUN |
false (ships live by design) — set true to log/DM only, no real action |
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.
6. Cron for the suspicious-signup sweep (on admin.yttrx.com)
The sweep is not part of the always-running webhook server — it's invoked
on a schedule via docker exec. ABUSE_BOT_TOKEN must additionally carry
the admin:read:accounts scope (remint via the Rails-console snippet in
CLAUDE.md, same procedure as the other scope additions) before installing
this cron entry, or every sweep run 403s per-account:
crontab -l | { cat; echo '*/10 * * * * docker exec yttrx-welcomebot python -m app.suspicious_sweep >> /var/log/welcomebot-suspicious-sweep.log 2>&1'; } | crontab -
Every 10 minutes (tightened from hourly on 2026-07-06 to match
SUSPICIOUS_GRACE_HOURS dropping from 24 to 1) — with a 1h grace window an
hourly cadence could add up to another hour of latency on top of the grace
period itself; a 10-minute cadence keeps the gap between crossing the
deadline and being swept small relative to the window.
Operations
welcomebot-logs -f --abuse # convenience CLI on admin (see bin/welcomebot-logs)
welcomebot-signups --flagged # signup-IP classification history (see bin/welcomebot-signups)
welcomebot-suspicious --pending # suspicious-sweep watch list (see bin/welcomebot-suspicious)
docker exec yttrx-welcomebot python -m app.suspicious_sweep # run the sweep manually
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