Files
yttrx-welcomebot/.env.example
T
pmb 8490048d2d
docker-build-push / build-push (push) Successful in 4s
Only publish blocks that are still in force
ipblock_registered and domain_blocked only ever meant "we posted this
block once" — they said nothing about whether it still exists. When the
API went live it was publishing 24 /32 ranges that had been removed by
hand on 2026-07-21, so ~17% of the IP list didn't block anything. That's
a bad thing to hand another admin an importer for.

app/reconcile_blocks.py fetches Mastodon's live Admin::IpBlock and
Admin::EmailDomainBlock lists and maintains a new block_active column on
both tables: off for rows whose block is gone or whose expires_at has
elapsed, back on for any that reappear. The API serves only active rows.
Re-registering a block reactivates its row, so a range the bot blocks
again after a manual cleanup comes back on its own.

A failed fetch changes nothing — not a 403 from a missing scope, not
Mastodon being down, not a half-failure where one list answers and the
other doesn't. An outage must never be read as "every block was
removed"; the list degrades to over-reporting rather than emptying
itself, and there's a test for each of those paths.

Needs admin:read:ip_blocks + admin:read:email_domain_blocks, added to
the live token in place (scopes updated on the existing Doorkeeper
token, matched by digest) rather than by minting and rotating, so the
token value is unchanged and .env needed no edit.
2026-09-13 01:05:52 -07:00

261 lines
13 KiB
Bash

# Copy to .env and fill in. Do NOT commit .env.
# HMAC secret shown when you create the webhook in Mastodon
# (Administration -> Webhooks). Used to verify X-Hub-Signature.
WEBHOOK_SECRET=
# Access token for the bot account that sends the welcome DMs.
# Create a bot account on yttrx, then Preferences -> Development ->
# New application with scope: write:statuses (copy "Your access token").
BOT_ACCESS_TOKEN=
# Base URL of the Mastodon instance (no trailing slash).
MASTODON_BASE_URL=https://yttrx.com
# Welcome message template. {acct} is replaced with the new user's handle.
# The bot mentions @{acct} so a "direct" status reaches them.
WELCOME_MESSAGE=👋 Welcome to yttrx, @{acct}! Glad to have you here. If you have any questions, check out https://help.yttrx.com or just reply to this message.
# Only welcome accounts local to this instance (recommended: true).
LOCAL_ONLY=true
# Path to the sqlite dedup store inside the container (matches the volume).
DB_PATH=/data/welcomed.db
# --- Abuse-bot (report.created handling) -----------------------------------
# Access token for a DEDICATED MODERATOR bot account. That account must hold a
# role with the "Manage Users" + "Manage Reports" permissions, and the app
# token must request these scopes:
# admin:write:accounts admin:read:reports read:statuses write:statuses
# Leave blank to disable report handling entirely.
ABUSE_BOT_TOKEN=
# Master switch for report handling (also off if ABUSE_BOT_TOKEN is blank).
ABUSE_ENABLED=true
# Rollout safety: when true, log + DM what WOULD happen but take no action.
# Recommended for the first days in production; flip to false once happy.
ABUSE_DRY_RUN=true
# Moderation action: "silence" (reversible, agreed default) or "suspend".
ABUSE_ACTION=silence
# Only auto-act on accounts local to yttrx.
ABUSE_LOCAL_ONLY=true
# Account tiers (in days):
# young = oldest post newer than ABUSE_YOUNG_MAX_DAYS
# dormant = newest post older than ABUSE_DORMANT_MIN_DAYS
ABUSE_YOUNG_MAX_DAYS=30
ABUSE_DORMANT_MIN_DAYS=30
# Required number of DISTINCT reporter accounts (open reports) to auto-act:
# young / dormant / no-posts -> ABUSE_SOURCES_NEWDORMANT
# normally-active -> ABUSE_SOURCES_ACTIVE
ABUSE_SOURCES_NEWDORMANT=2
ABUSE_SOURCES_ACTIVE=3
# Safety cap on the backward status scan (40 statuses per page).
ABUSE_MAX_STATUS_PAGES=5
# Automatically never auto-act on accounts holding a staff role
# (Admin/Owner/Moderator), read from the report payload. Keep this true.
ABUSE_SKIP_PRIVILEGED=true
# Extra comma-separated handles (acct, no leading @) never to auto-act on —
# a backstop on top of ABUSE_SKIP_PRIVILEGED, and for non-staff special
# accounts. e.g. ABUSE_ALLOWLIST=waffles,tommertron,davis,bot
ABUSE_ALLOWLIST=
# Handle (acct, no @) to DM with a review summary after an auto-action.
# Leave blank to disable the DM. The DM is sent from the moderator bot.
MOD_ALERT_ACCT=
# Help/appeals page the silenced user is pointed to.
ABUSE_HELP_URL=https://welcome.yttrx.com/posts/account-limited/
# DM sent to the silenced user ({acct} + {help_url} substituted; must mention
# @{acct}). Leave blank to disable the user DM.
ABUSE_USER_DM=Hi @{acct} — your yttrx account has been temporarily limited while we review some reports. This is reversible and a moderator will take a look. Here's what happened and how to appeal: {help_url}
# --- IP-based signup scrutiny -----------------------------------------------
# Classifies each new signup's IP (Admin::Account.ip, delivered free on the
# account.created webhook) via ipapi.is, and flags it if ipapi.is reports it
# as any of datacenter, vpn, proxy, tor, or an independently-scored abuser.
# Master switch:
IP_SCRUTINY_ENABLED=true
# Optional ipapi.is API key for higher rate limits. Anonymous (blank) is
# capped at 1,000 req/day, which comfortably covers yttrx's signup volume;
# get a key at https://ipapi.is/ only if you expect to exceed that.
IP_SCRUTINY_IPAPI_KEY=
# Rollout safety: when true, classify + DM a moderator but take NO action
# (no held welcome, no ip_blocks write). Recommended for the first days in
# production; flip to false once you've reviewed the false-positive rate.
IP_SCRUTINY_DRY_RUN=true
# Hold the welcome DM for a flagged signup until account.approved fires
# (a human clears the existing approval-required registration gate), instead
# of welcoming immediately like every other signup.
IP_SCRUTINY_HOLD_WELCOME=true
# Distinct-reporter threshold used instead of the tier's usual
# ABUSE_SOURCES_* threshold (whichever is lower) when the reported account's
# signup IP was flagged.
IP_SCRUTINY_ABUSE_THRESHOLD=1
# Auto-register a flagged signup's network into Mastodon's native
# Admin::IpBlock — ipapi.is's asn.route CIDR when it's a valid network that
# actually contains the signup IP, else just that one /32 or /128. Blocking
# the whole network matters here because a flagged signup is almost always
# datacenter/VPN/proxy space, where a repeat bad actor is far more likely to
# come back from a different address in the same block than the exact one.
# Requires the ABUSE_BOT_TOKEN to carry the admin:write:ip_blocks scope (see
# CLAUDE.md's moderator-token-gotcha section) — without it this 403s and is
# logged as an error, but nothing else in the bot is affected.
IP_SCRUTINY_AUTO_IPBLOCK=true
# Severity applied to auto-registered blocks: sign_up_requires_approval (soft,
# sends flagged range's future signups to the moderator queue), sign_up_block
# (hard — flatly rejects future signups from the range at registration, no
# queue, cannot appeal via this bot), or no_access (blocks all access, not
# just signups).
IP_SCRUTINY_IPBLOCK_SEVERITY=sign_up_block
# Strong IP-reputation signals — datacenter, proxy, tor, or an independently-
# scored abuser, but NOT vpn alone (which also flags plenty of privacy-
# conscious real users and is a weaker signal on its own). A signup flagged
# with any of these is suspended immediately at signup time, regardless of
# the email-domain signal, instead of just the held-welcome/auto-ipblock/
# lowered-threshold treatment every other flagged signup gets. Master switch:
IP_SCRUTINY_STRONG_SUSPEND_ENABLED=true
# Moderation action taken immediately on a strong IP signal: "suspend"
# (default) or "silence".
IP_SCRUTINY_STRONG_SUSPEND_ACTION=suspend
# Rollout safety switch — ships "true" (dry-run) here, same reasoning as
# SUSPICIOUS_COMBINED_DRY_RUN, since this is a brand-new action path. Flip to
# "false" once the moderator DMs look right.
IP_SCRUTINY_STRONG_SUSPEND_DRY_RUN=true
# check-mail.org disposable/high-risk email domain scrutiny (roadmap item B,
# anti-abuse.md). Domain-only query (never the full email) against
# POST https://api.check-mail.org/v2/, Authorization: Bearer <key>.
# Free tier: 1,000 req/month.
CHECK_MAIL_ENABLED=true
CHECK_MAIL_API_KEY=
# Log/DM only — no held welcome, no email_domain_block write, no
# report-triggered suspend. Keep true for rollout until you've watched the
# false-positive rate for a while (same role as IP_SCRUTINY_DRY_RUN /
# ABUSE_DRY_RUN).
CHECK_MAIL_DRY_RUN=true
# A domain is flagged if check-mail.org marks it is_disposable OR its risk
# score (0-100) is at or above this threshold.
CHECK_MAIL_RISK_THRESHOLD=80
# Hold the welcome DM for a flagged signup until account.approved fires, same
# semantics as IP_SCRUTINY_HOLD_WELCOME.
CHECK_MAIL_HOLD_WELCOME=true
# Auto-register a flagged signup's email domain into Mastodon's native
# Admin::EmailDomainBlock. Requires ABUSE_BOT_TOKEN to carry the
# admin:write:email_domain_blocks scope (see CLAUDE.md) — without it this
# 403s and is logged as an error, but nothing else in the bot is affected.
# Also gates the report-triggered suspend+block override in handle_report().
CHECK_MAIL_AUTO_DOMAIN_BLOCK=true
# --- Suspicious-signup sweep (app/suspicious_sweep.py, run on a schedule) ---
# Watches every account flagged by EITHER IP-scrutiny or email-domain
# scrutiny at signup. Once the account goes live (account.created if open/
# auto-approved, account.approved if this instance requires moderator
# approval — same dual handling the welcome flow already uses), a baseline
# post/follow count is recorded. A scheduled sweep (cron on admin.yttrx.com,
# `docker exec yttrx-welcomebot python -m app.suspicious_sweep`) then acts on
# any watch past SUSPICIOUS_GRACE_HOURS with zero new posts AND zero new
# follows since that baseline. Master switch:
SUSPICIOUS_SWEEP_ENABLED=true
# Hours of grace given to a flagged signup to show organic activity (a new
# post or a new follow) before it's considered dormant/bot-like. Lowered from
# the original 24 to 1 (2026-07-06) — the datacenter/hosting-IP signal alone
# is a strong enough spam indicator that a full day of grace was mostly just
# delaying an inevitable suspend; paired with a tighter sweep cadence (see
# CLAUDE.md) so real latency stays close to this value.
SUSPICIOUS_GRACE_HOURS=1
# Moderation action taken on a flagged signup with no activity in the grace
# window: "suspend" (agreed default — a stronger signal than the report-
# driven abuse-bot's default "silence", since this is zero organic activity,
# not just reports) or "silence".
SUSPICIOUS_ACTION=suspend
# Rollout safety switch, same role as ABUSE_DRY_RUN — ships "false" (live)
# here by design; flip to "true" to pause without redeploying.
SUSPICIOUS_DRY_RUN=false
# Requires ABUSE_BOT_TOKEN to additionally carry the admin:read:accounts
# scope (fetches current post/follow counts + role/suspended state via
# GET /api/v1/admin/accounts/:id) — see CLAUDE.md's moderator-token-gotcha
# section. Without it the sweep 403s per-account (logged as an error, that
# account is retried next sweep) but nothing else in the bot is affected.
# Reuses ABUSE_SKIP_PRIVILEGED and ABUSE_ALLOWLIST above — no separate vars.
# --- Combined-signal immediate suspend (app/main.py, process_signup) ---
# The sweep above acts on EITHER signal (IP or email) after
# SUSPICIOUS_GRACE_HOURS of inactivity. A signup flagged by BOTH signals at
# once is a stronger indicator, so it's auto-actioned immediately at signup
# time instead of waiting for the hourly sweep. Additive — doesn't change
# the OR-based sweep's handling of single-signal flags. Reuses
# ABUSE_ALLOWLIST above (staff can't be brand-new signups, but checked for
# defense in depth) — no separate allowlist var.
SUSPICIOUS_COMBINED_ENABLED=true
# Moderation action taken immediately on a combined-signal signup: "suspend"
# (agreed default, same reasoning as SUSPICIOUS_ACTION) or "silence".
SUSPICIOUS_COMBINED_ACTION=suspend
# Rollout safety switch — ships "true" (dry-run) here, unlike
# SUSPICIOUS_DRY_RUN's by-design "false" default above, since this is a
# brand-new action path. Flip to "false" once the moderator DMs look right.
SUSPICIOUS_COMBINED_DRY_RUN=true
# --- Blocklist API (app/main.py, GET /api/blocks) ---------------------------
# Read-only JSON of the IP ranges and email domains this bot has auto-blocked:
# GET /api/blocks both lists in one document
# GET /api/blocks/ip-ranges registered Admin::IpBlock networks
# GET /api/blocks/email-domains registered Admin::EmailDomainBlock domains
# Optional ?limit=N on any of them. Responses assert only that a range/domain
# is blocked — no account handles, account ids or email addresses, and none of
# the ipapi.is / check-mail.org classification behind the decision (publishing
# those verdicts would be redistributing a paid reputation feed's data). Note
# this reports the *bot's own* record, not Mastodon's full blocklist: blocks a
# moderator added by hand in the admin UI aren't in it.
#
# SERVED WITHOUT AUTHENTICATION, so it defaults to off and every deployment
# must opt in: publishing which ranges and domains are already blocked also
# tells a would-be spam signup which ones to avoid. With this unset the three
# paths 404 as if they didn't exist. yttrx.com sets this to true.
BLOCKS_API_ENABLED=false
# The list is kept honest by app/reconcile_blocks.py (cron, hourly), which
# drops entries whose block no longer exists in Mastodon. That needs
# admin:read:ip_blocks + admin:read:email_domain_blocks on ABUSE_BOT_TOKEN.
# Hard ceiling on entries per response, whatever ?limit asks for — the
# endpoint is unauthenticated, so response size can't be left to the caller.
BLOCKS_API_MAX_ITEMS=5000
# Public base URL this API is reached at. Used only to print a "source:" line
# in the text format's header comments, so a downloaded list says where it
# came from; it plays no part in routing.
BLOCKS_API_PUBLIC_URL=https://hooks.yttrx.com
# Cache-Control max-age (seconds) on blocklist responses. The data only
# changes when a signup is flagged, so a few minutes of client/intermediate
# caching costs nothing.
BLOCKS_API_CACHE_SECONDS=300