1. Posts/

run your own welcome-bot: a step-by-step guide for other instances

I’ve written before about yttrx’s anti-abuse stack in bits and pieces, so here’s the whole thing in one place: what it does, why I built it, and a step-by-step guide if you want to run it on your own instance.

What it is, in one paragraph

yttrx-welcomebot is a small FastAPI webhook server that sits behind one of Mastodon’s admin webhooks. It reacts to three events — account.created, account.approved, report.created — and does two jobs: welcome-bot DMs every new local signup so they land somewhere friendly instead of a blank timeline, and abuse-bot watches reports and reversibly silences accounts once enough distinct people report them. On top of that sit two signup-time reputation checks (IP and email domain) and a scheduled sweep that catches spammy signups nobody ever reported. Everything defaults to reversible actions, dry-run-first rollout, and a human always stays in the loop.

Code’s here: git.blairhaus.net/pmb/yttrx-welcomebot .

Why I bothered, for a small instance

This started as a method to send new users a friendly message once they sign up, with helpful links etc., but then was extended to handle abusive new users and reports.

New users get a friendly DM pointing them at the help site instead of silence, obvious spam signups from datacenter IPs get flagged and dealt with before they ever post, and reported accounts get triaged by a consistent rule instead of whoever happens to be at their desk. There are only two mods on the site, and it’s helpful having this handler take care of reports etc, as well as helping to idendity the really terrible signups.

The pieces

new signup / new report on your instance
   └─> Mastodon fires an admin webhook
         account.created | account.approved | report.created
         └─> POST https://your-hooks-domain/webhook  (HMAC-signed)
               └─> welcomebot container
                     ├─ account.created/approved → welcome DM
                     └─ report.created → classify target, count distinct
                                         reporters, maybe silence + DM mod

Welcome-bot — DMs a visibility:direct welcome to every new local account, using its own low-privilege bot token (write:statuses only). It listens to both account events because some instances require signup approval and some don’t; either way, the account gets welcomed exactly once (deduped in a local sqlite file).

Abuse-bot — on a new report against a local, non-staff account, it classifies the target by posting history (young / dormant / no-posts / active), counts distinct reporters (so one person filing five reports doesn’t do anything a single report wouldn’t), and silences — reversibly, never suspends by default — once distinct reporters cross a threshold that’s lower for young/dormant/no-post accounts. The report itself is left open for a human to actually review, and both a moderator and the affected user get DMed (the user gets a link to your appeals page).

IP-reputation scrutiny — every signup already carries the account’s IP for free. The bot classifies it via ipapi.is (free tier: 1,000 lookups/day, no key needed) as some combination of datacenter/vpn/proxy/tor/abuser, or clean. A flagged signup gets its welcome held, gets registered as a native Mastodon Admin::IpBlock (soft, by default — just forces future signups from that address into your approval queue, doesn’t touch the flagged account itself), and needs far fewer reporters to get silenced later if it’s ever reported.

Email-domain scrutiny — same idea, different signal: the signup’s email domain only (never the full address) gets checked against check-mail.org (free tier: 1,000 req/month) for disposable/high-risk providers. A flagged domain gets hard-blocked from future signups (Admin::EmailDomainBlock), and — the one place this stack goes further than “silence” — a report against an account with a flagged email domain auto-suspends on the very first report instead of waiting for multiple reporters. Disposable email plus an actual report is a strong enough combined signal that I was comfortable with a harder action there.

Suspicious-signup sweep — a scheduled job (cron, hourly is fine) that watches every IP- or email-flagged signup and auto-suspends any of them with zero posts and zero follows after a grace period (yttrx runs a 1-hour grace window). Zero organic activity for that long on an already-flagged signup is a stronger bot-signal than a report ever gets.

Every single one of these is env-var tunable, and every one of the “acting” layers ships with a dry-run switch so you can watch what it would do before it touches a real account.

Step by step: standing up your own copy

1. Clone it and read the real docs

git clone https://git.blairhaus.net/pmb/yttrx-welcomebot.git
cd yttrx-welcomebot

The README has the full config table and deploy runbook — what’s below is the shape of it, not a substitute.

2. Create the bot accounts

You need two separate bot accounts on your instance, with two separate tokens, because they hold very different privilege levels:

  • Welcome bot — a plain account (mark it “This is a bot account” in profile settings), with an application token scoped to just write:statuses. This is the one that DMs new users; keep it unprivileged.
  • Moderator bot — a second account, given a role with Manage Users + Manage Reports permissions (Administration → Roles), with an application token scoped to admin:write:accounts admin:read:reports read:statuses write:statuses at minimum. This is the one that silences/suspends accounts and writes IP/domain blocks, so keep its token off anything public-facing.

If you want the IP-block or email-domain-block writes to work, that same moderator token needs the extra scopes for those specifically (admin:write:ip_blocks, admin:write:email_domain_blocks) — easy to miss on the first pass, and the failure mode is just a quiet 403 in the logs rather than anything breaking.

3. Register the admin webhook

Administration → Webhooks → add one subscribed to account.created, account.approved, and report.created, pointing at wherever you’ll run the container (e.g. https://hooks.example.com/webhook). Mastodon shows you a secret when you create it — that’s your WEBHOOK_SECRET, used to HMAC-verify every delivery.

4. Write your .env

Copy .env.example to .env and fill it in. You genuinely don’t need everything on day one — here’s a minimal version that just gets welcome-bot and reactive abuse-bot running, reputation scrutiny left off until you’re ready:

# core
WEBHOOK_SECRET=<the secret Mastodon showed you>
BOT_ACCESS_TOKEN=<welcome bot's write:statuses token>
MASTODON_BASE_URL=https://your.instance
WELCOME_MESSAGE=👋 Welcome, @{acct}! Check out https://help.example.com if you have questions.
LOCAL_ONLY=true
DB_PATH=/data/welcomed.db

# abuse-bot
ABUSE_BOT_TOKEN=<moderator bot's admin-scoped token>
ABUSE_ENABLED=true
ABUSE_DRY_RUN=true          # start here — log/DM only, no real action
ABUSE_ACTION=silence
ABUSE_LOCAL_ONLY=true
ABUSE_YOUNG_MAX_DAYS=30
ABUSE_DORMANT_MIN_DAYS=30
ABUSE_SOURCES_NEWDORMANT=2
ABUSE_SOURCES_ACTIVE=3
ABUSE_SKIP_PRIVILEGED=true
ABUSE_ALLOWLIST=            # comma-separated handles, no leading @
MOD_ALERT_ACCT=yourmodhandle
ABUSE_HELP_URL=https://help.example.com/posts/account-limited/
ABUSE_USER_DM=Hi @{acct} — your account was temporarily limited while we review some reports. This is reversible: {help_url}

# leave everything below disabled until the above has run for a while
IP_SCRUTINY_ENABLED=false
CHECK_MAIL_ENABLED=false
SUSPICIOUS_SWEEP_ENABLED=false

Once welcome-bot and abuse-bot have run in dry-run for a few days and the DMs/logs look sane, flip ABUSE_DRY_RUN=false. Then, when you’re ready for the signup-time checks, enable them the same cautious way:

IP_SCRUTINY_ENABLED=true
IP_SCRUTINY_DRY_RUN=true            # again: watch it before it acts
IP_SCRUTINY_HOLD_WELCOME=true
IP_SCRUTINY_ABUSE_THRESHOLD=1
IP_SCRUTINY_AUTO_IPBLOCK=true
IP_SCRUTINY_IPBLOCK_SEVERITY=sign_up_requires_approval   # soft — recommended
# IP_SCRUTINY_IPAPI_KEY= only needed past 1,000 lookups/day

CHECK_MAIL_ENABLED=true
CHECK_MAIL_API_KEY=<your check-mail.org key>
CHECK_MAIL_DRY_RUN=true
CHECK_MAIL_RISK_THRESHOLD=80
CHECK_MAIL_HOLD_WELCOME=true
CHECK_MAIL_AUTO_DOMAIN_BLOCK=true

SUSPICIOUS_SWEEP_ENABLED=true
SUSPICIOUS_GRACE_HOURS=24           # yttrx runs 1 now, but start generous
SUSPICIOUS_ACTION=suspend
SUSPICIOUS_DRY_RUN=false            # this one's meant to ship live, but the
                                     # kill switch works the same as the rest

Every _DRY_RUN flag exists for the same reason: so you can watch a new layer’s false-positive rate on real traffic before it’s allowed to touch an account. Don’t skip that step just because it worked fine on yttrx — every instance’s signup mix is different.

5. Run it

docker compose up -d

nginx (or whatever’s in front) proxies /webhook and /healthz to the container. Confirm it’s alive with a GET /healthz, then trigger a real test signup and watch the logs.

6. Watch, then tighten

Tail the logs for the first few real signups and reports. Check that the welcome DM actually looks right, that flagged IPs/domains match what you’d expect, and that nobody legitimate got caught by the reputation checks before you flip any dry-run switch to live. The whole design leans on “reversible and human-reviewed by default” specifically so this rollout can be unhurried.

The honest tradeoffs

This isn’t a moderation team replacement, but it helps with catching the really bad signups when you’re asleep (as well as welcome every signup) so a human only has to look at the cases that actually need judgment. It also adds two third-party dependencies (ipapi.is, check-mail.org) into your signup path if you turn those layers on — both have generous free tiers for a small instance, but they’re now things that can be slow or down, so the code treats any lookup failure as “unknown, never flagged” rather than blocking a signup on a timeout.

If you stand one of these up and hit something confusing, the README in the repo have more detail than fits in a blog post — and I’m always happy to talk shop about it, since honestly it’s been one of the more satisfying pieces of yttrx infra to build.