pmb 1bfbcfdac3
docker-build-push / build-push (push) Successful in 4s
Add welcomebot-reputation CLI and a --reputation log filter
The pre-signup path had no inspection tool. Its activity landed in docker logs
and two sqlite tables with nothing to read them, while the reactive path has
had welcomebot-signups and welcomebot-suspicious all along.

welcomebot-reputation reads reputation_query and reputation_block directly so
it survives log rotation: recent answers, --stats for volume and flag/block
rate by source, --blocks for what is actually in force, and --deferred to see
whether the hourly provider-lookup ceiling is biting. --blocks marks rows GONE
once the reconcile pass finds the block no longer on the Mastodon side, so
"we installed this" and "this is in force" stay distinguishable.

welcomebot-logs --reputation covers the raw lines (pre-blocks, budget
refusals, width-cap narrowings).
2026-09-13 16:35:11 -07:00

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 at https://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.

Both of those are reactive — they run after Mastodon has created the account. The server also exposes a pre-emptive path: Mastodon can ask it about an IP or email domain during signup, before consulting its own block tables, and the answer arrives with the block already installed. See Reputation query API.

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:

  1. Classify the account by its authored posts (reblogs excluded):
    • young — oldest post is newer than ABUSE_YOUNG_MAX_DAYS (30d)
    • dormant — newest post is older than ABUSE_DORMANT_MIN_DAYS (30d)
    • no-posts — reported account with no authored statuses
    • active — everything else (posting for >1mo and posted recently)
  2. Count distinct reporters with open reports against the target (self-reports excluded — one person filing repeatedly counts once).
  3. Silence the account when distinct reporters ≥ the tier threshold:
    • young / dormant / no-posts → ABUSE_SOURCES_NEWDORMANT (2)
    • active → ABUSE_SOURCES_ACTIVE (3)
  4. The action is silence (reversible) and is applied without a report_id, so the report stays open in the moderation queue. The bot then DMs MOD_ALERT_ACCT a 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:

  1. Classifies the IP via ipapi.is (cached in sqlite; free, keyless tier is 1,000 req/day — IP_SCRUTINY_IPAPI_KEY raises the limit if needed) as any combination of datacenter, vpn, proxy, tor, and abuser (ipapi.is's own abuse score), joined with +, or clean if none matched. API failures classify as unknown and are never flagged.
  2. If any signal matched, 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 on account.created and only sent when account.approved fires, 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 network the signup IP belongs to (ipapi.is's asn.route, when it's a valid CIDR that actually contains the IP; otherwise just that one /32//128) is added to Mastodon's native Admin::IpBlock at IP_SCRUTINY_IPBLOCK_SEVERITY (default sign_up_block — a hard reject of future signups from the range at registration, no moderator queue, no appeal via this bot; reversible from the admin UI). Blocking the whole network rather than the single address matters here specifically because a flagged signup is almost always datacenter/VPN/proxy space — a repeat bad actor is far more likely to come back from a different address in the same block than the exact one, unlike a residential IP where that reasoning wouldn't hold.
    • Lowered abuse-bot threshold — if this account is later reported, the usual ABUSE_SOURCES_* distinct-reporter threshold is replaced by IP_SCRUTINY_ABUSE_THRESHOLD (whichever is lower), since a flagged signup IP plus a report is a stronger combined signal than either alone.
  3. If the matched classification includes datacenter, proxy, tor, or abuser — a strong signal, gated by IP_SCRUTINY_STRONG_SUSPEND_ENABLED — the signup is suspended immediately at signup time instead of just getting the held-welcome/ipblock/lowered-threshold treatment above, no matter what the email-domain signal says. vpn alone is deliberately excluded from this list (it also flags plenty of privacy-conscious real users, and is a weaker bulk/bot-signup indicator on its own) — a vpn-only flag still falls through to the normal path, though it's still caught by the combined-signal suspend below if the email domain is also flagged. IP_SCRUTINY_STRONG_SUSPEND_ACTION (default suspend) is the action taken; IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN=true (the shipped default) logs + DMs what would happen without acting. Skips the held welcome and suspicious-watch entry entirely, same shape as the combined-signal suspend (§ below) — the account is already gone.

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:

  1. 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_disposable or its risk score (0-100) is at or above CHECK_MAIL_RISK_THRESHOLD (default 80).
  2. 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 as IP_SCRUTINY_HOLD_WELCOME.
    • Auto-registered email domain block (CHECK_MAIL_AUTO_DOMAIN_BLOCK) — the domain is added to Mastodon's native Admin::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):

  1. Any account flagged by either IP-scrutiny (datacenter/vpn/proxy/tor/ abuser) or email-domain scrutiny (disposable/high-risk) has a grace-period clock started the moment it goes live — account.created if signups are open (or auto-approved), account.approved if 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 of statuses_count/following_count is recorded at that moment (app.main.maybe_start_suspicious_watch, table suspicious_watch).
  2. The sweep looks for watches past SUSPICIOUS_GRACE_HOURS (default 1; lowered from the original 24 on 2026-07-06 — a flagged-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 (default suspend) is applied, the moderator is DMed, and the watch is marked resolved.
  3. Already-suspended/silenced accounts, staff (ABUSE_SKIP_PRIVILEGED), and ABUSE_ALLOWLIST handles 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).

Blocklist API (/api/blocks)

An optional, read-only JSON view of what the bot has auto-blocked. Three endpoints, all GET, all accepting an optional ?limit=N:

Path Returns
/api/blocks both lists in one document
/api/blocks/ip-ranges networks registered as Admin::IpBlock
/api/blocks/email-domains domains registered as Admin::EmailDomainBlock
{
  "generated_at": "2026-09-13T00:00:00+00:00",
  "ip_ranges": {
    "count": 1,
    "items": [
      {"cidr": "198.51.100.0/24", "signups": 3,
       "first_blocked_at": "2026-07-15 09:12:04",
       "last_blocked_at": "2026-08-30 22:41:18"}
    ]
  },
  "email_domains": {
    "count": 1,
    "items": [
      {"domain": "temp-mail.org", "signups": 2,
       "first_blocked_at": "2026-07-03 11:02:55",
       "last_blocked_at": "2026-07-19 16:30:09"}
    ]
  }
}

Plain-text format

Both per-list endpoints also take ?format=txt, which renders one entry per line under a # comment header — the form blocklists are normally traded in, and the form another admin's cron job can consume without a JSON parser:

curl -s 'https://hooks.yttrx.com/api/blocks/ip-ranges?format=txt'   | grep -v '^#'
curl -s 'https://hooks.yttrx.com/api/blocks/email-domains?format=txt' | grep -v '^#'

The combined /api/blocks is JSON-only: a text rendering would put CIDRs and domains in one file, and the point of the text form is that it pipes straight into something expecting one kind of entry. Responses carry Access-Control-Allow-Origin: * so browser-side tooling can read them.

Importing it elsewhere (bin/yttrx-blocklist-import)

Mastodon has no RBL/DNSBL support — nothing in it consults an outside list when deciding whether to accept a signup. (The only outbound lookup in that path is EmailMxValidator's MX resolution, and that feeds the local email_domain_blocks table.) So another instance can't point at this feed live; it has to import, the same way Garden Fence and other community lists propagate.

bin/yttrx-blocklist-import does that — standard library only, no install:

# dry run (the default — nothing is written)
./bin/yttrx-blocklist-import --instance https://your.instance --token "$TOKEN"
# actually write the blocks
./bin/yttrx-blocklist-import --instance https://your.instance --token "$TOKEN" --apply

The token needs admin:write:ip_blocks and admin:write:email_domain_blocks (the script's --help has a Rails-console snippet that mints one). Entries already present come back 422 and are counted as "already blocked", not as errors. --min-signups N skips one-off entries, --ips-only/--domains-only take one list, --limit caps each.

On severity: the IP entries are whole networks, because on yttrx a flagged signup is almost always datacenter/VPN/proxy space where the next attempt comes from a neighbouring address. That's calibrated to yttrx's traffic, not someone else's, so the importer defaults to sign_up_requires_approval — a flagged signup lands in the importer's moderation queue rather than being rejected — even though yttrx runs these at sign_up_block itself. --ip-severity sign_up_block opts into the harder behaviour.

Off by default. These paths are served without authentication, so they 404 unless BLOCKS_API_ENABLED=true is set — publishing which ranges and domains are already blocked also tells a would-be spam signup which ones to avoid, and that's a per-deployment call. yttrx.com opts in; the shipped default doesn't. BLOCKS_API_MAX_ITEMS (default 5000) caps the response size whatever ?limit asks for, and BLOCKS_API_CACHE_SECONDS (default 300) sets the Cache-Control: public, max-age=… on responses.

What the response deliberately does not contain:

  • No account identifiers — no handles, account ids, emails or signup IPs. Repeat signups out of one range collapse into a single entry with a signups count.
  • No classification data. The ipapi.is verdict (datacenter/proxy/…), the org name, and check-mail.org's disposable flag and risk score all stay out of the response. They're what drove each decision, but republishing a paid reputation feed's verdicts on an open endpoint would be redistributing their data rather than ours. An entry asserts only that the block exists.

Entries report the network that was blocked (ipapi.is's asn.route for the signup IP, which is what register_ip_block registers), not the address that signed up. A /32 or /128 entry means a single address really is what was blocked — either the classification returned no usable route CIDR, or the signup predates range-blocking (2026-07-15). The block_cidr column recording this arrived with the API itself, so existing rows were backfilled once from the ipapi range cache by bin/backfill-block-cidr.py; ten rows with no cache entry left are shown as their /32, since a narrower entry is a safer error than claiming a range is blocked when it isn't.

Staying accurate (app/reconcile_blocks.py)

ipblock_registered / domain_blocked only ever meant "we posted this block once" — they say nothing about whether it's still there. A moderator clearing entries by hand, or an expires_at elapsing, would leave the endpoint publishing blocks that no longer block anything. (This was not hypothetical: when the API first went live it was publishing 24 /32 ranges that had been removed on 2026-07-21.)

The reconcile pass fetches Mastodon's live Admin::IpBlock and Admin::EmailDomainBlock lists and flips block_active off for rows whose block is gone or expired, back on for any that reappear. The API serves only active rows, so ?format=txt hands an importer only blocks actually in force.

docker exec yttrx-welcomebot python -m app.reconcile_blocks --dry-run
docker exec yttrx-welcomebot python -m app.reconcile_blocks

Runs hourly by cron on admin.yttrx.com, alongside the suspicious-signup sweep. It needs admin:read:ip_blocks and admin:read:email_domain_blocks on ABUSE_BOT_TOKEN. A failed fetch changes nothing — a 403 from a missing scope, or Mastodon being down, must never be read as "every block was removed", so rows keep their existing state and the list degrades to over-reporting rather than emptying itself.

Scope: this is the bot's own record, not Mastodon's full blocklist — blocks a moderator added by hand in the admin UI are not in it. Entries appear only once the block actually landed on the Mastodon side (a failed POST /api/v1/admin/ip_blocks is logged and not recorded as blocked).

Reputation query API (/api/reputation)

The inversion of everything above. POST /api/reputation classifies an IP and/or email domain, records it, registers the matching Admin::IpBlock / EmailDomainBlock, and only then returns — so a caller that consults its own block tables immediately afterwards finds a block that did not exist when its request began. A bad network gets turned away on first contact instead of cleaned up after the first account out of it.

signup begins on yttrx
   └─> Mastodon (mammut), about to run its own IpBlock lookup
         └─> POST https://hooks.yttrx.com/api/reputation  (X-Reputation-Key: …)
               └─> classify (ipapi.is / check-mail.org, cached)
                   record in reputation_query
                   register Admin::IpBlock / EmailDomainBlock
                   return
         └─> IpBlock.severity_sign_up_block.containing(ip).exists?  ← now true

Unlike the blocklist API this one writes, so it is authenticated and refuses to serve without a key: an unauthenticated endpoint that installs IP blocks is a denial-of-service lever, and there is deliberately no open mode. With REPUTATION_API_ENABLED false or REPUTATION_API_KEY empty the path 404s as if it didn't exist.

curl -s -X POST https://hooks.yttrx.com/api/reputation \
  -H "Content-Type: application/json" \
  -H "X-Reputation-Key: $REPUTATION_API_KEY" \
  -d '{"ip": "198.51.100.20", "email": "[email protected]", "source": "sign_up"}'
{
  "ip": {"ip": "198.51.100.20", "classification": "datacenter+abuser",
         "org": "Example Cloud Inc", "flagged": true, "cidr": "198.51.100.0/24",
         "blocked": true, "cached": false, "deferred": false},
  "email": {"domain": "example.com", "disposable": false, "risk": 12,
            "flagged": false, "blocked": false, "cached": true, "deferred": false},
  "checked_at": "2026-09-13T23:11:58+00:00",
  "flagged": true,
  "blocked": true
}

Both request fields are optional (the caller's IP hook has no email address to offer yet); at least one is required, or the call 400s. Either side may be null if that signal is switched off or had nothing to look at. blocked means "something is blocked on the Mastodon side as a result of this call" — true both for a block installed just now and for one installed earlier that is still in force.

The caller is the call-welcomebot-reputation-api patch on yttrx's Mastodon fork (~/secondbrain/yttrx-documentation/mastodon-fork-patches.md). It is best-effort on that side: if this endpoint is slow, down or disabled, Mastodon logs a warning and proceeds exactly as it would without the patch.

Cost control

The caller hits this when the signup form is merely rendered, not only on submission, so the endpoint is reachable by anyone crawling that page. REPUTATION_API_MAX_LOOKUPS_PER_HOUR (default 500) caps fresh provider lookups per rolling hour; cache hits are free and never counted. Over budget, an uncached value is answered "classification": "unknown", "deferred": true and nothing is installed — never flagged, since the point of the ceiling is to stop paying, and guessing would either block innocents or waste the guard.

The webhook path deliberately never spends from this budget: account.created is low-volume and authoritative, and must not be starved by crawler traffic.

Measured against the live service: ~70ms for a cache hit answered locally, ~700ms from mammut (dominated by TLS setup across the WAN), ~1.3-1.8s on the first sighting of a bad range or domain, where it also registers the block.

Block width and expiry

Two limits apply to every block this bot registers, from the query API and the reactive webhook path alike:

Setting Default Why
IP_SCRUTINY_MAX_BLOCK_PREFIX_V4 / _V6 24 / 48 ipapi.is reports the flagged address's whole ASN route. Right for hosting space, wrong for an ISP aggregate — routes of /8–/11 turn up routinely, and 197.211.32.0/19 was registered against a Benin mobile carrier off one abuser flag. Anything wider falls back to the single address.
IP_SCRUTINY_IPBLOCK_EXPIRES_IN 86400 (24h) An IP block claims who is behind an address now, and VPS addresses are recycled constantly. A day breaks a signup run — which is the shape this abuse takes — without the blocklist accumulating forever. Email domain blocks have no expiry in Mastodon and are unaffected.

The cap applies to what gets blocked, not what gets cached: the wide route is still the right cache key, since it genuinely belongs to one operator and one lookup answers for the whole of it.

Records

Table Holds
reputation_query Audit log of every answer: what was asked, what the providers said, whether it led to a block. Domains only — never a full address.
reputation_block One row per block installed here, keyed by the CIDR/domain (there is no account to key it to). Lets a repeat query skip the Mastodon round trip, and carries expires_at so the dedup record lapses exactly when the block does.

Both feed /api/blocks and the reconcile pass alongside the signup-derived rows, so neither under-reports as blocking shifts to the pre-signup path. The published entry shape is unchanged; signups still counts signups, so a range blocked before anyone signed up from it reads "signups": 0.

Watching it

welcomebot-reputation (installed at /root/bin on admin.yttrx.com) reads both tables directly, so it survives log rotation:

welcomebot-reputation                    # last 20 answers
welcomebot-reputation --stats            # volume, flag/block rate, by source
welcomebot-reputation --blocks           # what it currently has blocked
welcomebot-reputation --blocked --since '-24 hours'
welcomebot-reputation --deferred         # did the hourly budget ceiling bite?
welcomebot-reputation --ip | --email     # one side only

--blocks marks a row GONE once the reconcile pass finds the block no longer on the Mastodon side (moderator cleared it, or it expired), so the difference between "we installed this" and "this is in force" stays visible.

For the raw log lines rather than the tables, welcomebot-logs --reputation filters to pre-blocks, budget refusals and width-cap narrowings:

welcomebot-logs --reputation -f          # follow pre-signup blocking live
welcomebot-logs --reputation --since 24h

On the Mastodon side the patch logs one line per pre-block at info (WelcomebotReputation: pre-blocked signup attempt), plus a warning for every failed or timed-out lookup — docker logs live-web-1 | grep WelcomebotReputation on mammut.

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)
app/reconcile_blocks.py Scheduled pass: marks blocks that no longer exist in Mastodon inactive (cron)
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 (webhook, healthz, blocklist API, reputation API)
bin/welcomebot-logs, bin/welcomebot-signups, bin/welcomebot-suspicious, bin/welcomebot-reputation Ops CLIs installed to /root/bin on admin.yttrx.com
bin/yttrx-blocklist-import Importer other admins can run to pull this list into their own instance
bin/backfill-block-cidr.py One-shot: reconstruct block_cidr for rows predating it (run 2026-09-13)
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_IPAPI_KEY Optional ipapi.is API key for higher rate limits; blank uses the anonymous 1,000 req/day tier
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 signup's network (ipapi.is route, or its own /32//128 if no route) into Mastodon's Admin::IpBlock
IP_SCRUTINY_IPBLOCK_SEVERITY sign_up_block (default) — hard reject, no queue; other options: sign_up_requires_approval (soft, moderator queue), no_access
IP_SCRUTINY_STRONG_SUSPEND_ENABLED Master switch — immediately suspend a signup whose IP is flagged datacenter/proxy/tor/abuser (not vpn alone), regardless of the email signal
IP_SCRUTINY_STRONG_SUSPEND_ACTION suspend (default) or silence, applied immediately on a strong IP signal
IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN true (ships dry-run-first) — set false to act for real once the moderator DMs look right
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 on git push origin main).

1. Create the bot account + token (on yttrx, via the web UI)

  1. Register/choose a bot account, e.g. @welcome (set "This is a bot account" in Preferences → Profile).
  2. Preferences → Development → New application.
    • Scopes: write:statuses (untick the rest).
    • Save, open it, copy "Your access token" → BOT_ACCESS_TOKEN in .env.

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.

  1. Register a dedicated account, e.g. @modbot (mark it a bot account).
  2. 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.
  3. As @modbot: Preferences → Development → New application with scopes admin:write:accounts, admin:read:reports, read:statuses, write:statuses. Copy its access token → ABUSE_BOT_TOKEN in .env.
  4. Set MOD_ALERT_ACCT to the handle that should receive review DMs (e.g. pete). Keep ABUSE_DRY_RUN=true for 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.approved and report.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_SECRET in .env on admin, and docker compose up -d to restart with it.
  • Use the webhook's "Send test" / re-enable it; confirm a 200 in docker compose logs -f welcomebot.

If you'd rather roll the abuse bot out separately, create a second webhook for report.created only — 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-Signature against 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. Adjust app/main.py if 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
S
Description
abuse and signup system for yttrx.com
Readme
1.1 MiB
0 Stars 1 Watchers 0 Forks
Languages
Python 88.3%
Shell 8%
Ruby 3.5%
Dockerfile 0.2%