From c0e5df3cc443e48b5c39a9a0a147b56ba3ca1096 Mon Sep 17 00:00:00 2001 From: Pete Date: Thu, 2 Jul 2026 16:22:30 -0700 Subject: [PATCH] Initial commit: welcome-bot, abuse-bot, dormant-sweep, IP signup scrutiny --- .dockerignore | 11 + .env.example | 119 ++++ .gitignore | 6 + CLAUDE.md | 176 ++++++ Dockerfile | 16 + README.md | 256 ++++++++ app/main.py | 757 ++++++++++++++++++++++++ bin/welcomebot-logs | 68 +++ docker-compose.yml | 17 + dormant-sweep/README.md | 142 +++++ dormant-sweep/account-inactive.md | 53 ++ dormant-sweep/dormant-sweep.env.example | 44 ++ dormant-sweep/dormant-sweep.sh | 32 + dormant-sweep/dormant_sweep.rb | 193 ++++++ nginx/hooks.yttrx.com.conf | 37 ++ requirements.txt | 4 + test_local.py | 300 ++++++++++ 17 files changed, 2231 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/main.py create mode 100644 bin/welcomebot-logs create mode 100644 docker-compose.yml create mode 100644 dormant-sweep/README.md create mode 100644 dormant-sweep/account-inactive.md create mode 100644 dormant-sweep/dormant-sweep.env.example create mode 100644 dormant-sweep/dormant-sweep.sh create mode 100644 dormant-sweep/dormant_sweep.rb create mode 100644 nginx/hooks.yttrx.com.conf create mode 100644 requirements.txt create mode 100644 test_local.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..265529c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.env +.env.example +*.db +data/ +__pycache__/ +*.pyc +.venv/ +README.md +nginx/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b15fdcd --- /dev/null +++ b/.env.example @@ -0,0 +1,119 @@ +# 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 RDAP org lookup, and treats non-residential/ +# non-mobile ("datacenter"/hosting/VPN) signups with more scrutiny. Master +# switch: +IP_SCRUTINY_ENABLED=true + +# 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 as datacenter/hosting. +IP_SCRUTINY_ABUSE_THRESHOLD=1 + +# Auto-register a flagged IP into Mastodon's native Admin::IpBlock. 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, +# recommended), sign_up_block (hard β€” no queue, cannot appeal via this bot), +# or no_access (blocks all access, not just signups). +IP_SCRUTINY_IPBLOCK_SEVERITY=sign_up_requires_approval + +# Case-insensitive regexes matched against the RDAP org/ASN description. +# Anything matching neither is treated as "residential" (never flagged). +# Only IP_SCRUTINY_HOSTING_RE matches are flagged; IP_SCRUTINY_MOBILE_RE is +# informational only (mobile carriers are never scrutinized). +IP_SCRUTINY_HOSTING_RE=amazon|aws|google|microsoft|azure|digitalocean|ovh|hetzner|linode|vultr|contabo|choopa|m247|scaleway|leaseweb|hostinger|namecheap|godaddy|cloudflare|oracle|alibaba|tencent|akamai|fastly|packet|vpn|proxy|hosting|datacenter|data center|colo(?:cation)?|server +IP_SCRUTINY_MOBILE_RE=mobile|wireless|cellular|verizon|t-mobile|tmobile|at&t|att mobility|vodafone|telstra|sprint|three\b|orange mobile|deutsche telekom|o2\b diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..945f2f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +__pycache__/ +*.pyc +*.db +data/ +.venv/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..98c70ca --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,176 @@ +# CLAUDE.md + +Guidance for Claude Code when working on **yttrx-welcomebot** (the welcome bot ++ abuse auto-silence bot for yttrx.com). + +## What this is / where it runs + +A FastAPI webhook server. One Mastodon admin webhook delivers `account.created`, +`account.approved`, and `report.created` to `POST /webhook`; the app dispatches: + +- **account.created** β†’ classify the signup IP (RDAP org lookup); flagged + (datacenter/hosting) signups get more scrutiny (held welcome, ip_blocks + registration, DM to moderator) before falling through to a normal welcome. +- **account.approved** β†’ always DM the welcome (dedup-safe; this is what + releases a held welcome once a human clears the approval queue). +- **report.created** β†’ classify the reported account, count distinct reporters, + and auto-**silence** young/dormant accounts past the threshold β€” lowered for + accounts with a flagged signup IP (see README). + +| Where | What | +|---|---| +| Workstation | Source of truth: `~/yttrx-welcomebot/` (this repo) | +| `admin.yttrx.com` | Deploy target: `/root/yttrx-welcomebot/`, Docker container `yttrx-welcomebot` on `127.0.0.1:8087`, nginx site `hooks` β†’ `hooks.yttrx.com`. `ssh admin.yttrx.com` logs in as **root**. Compose is **`docker-compose` v1.29.2** (hyphenated, not `docker compose`). | +| `mammut` (`ssh mammut`) | The Mastodon instance. `tootctl`/Rails run inside the `live-web-1` container. Bot accounts + tokens live here. | + +Full ops history is in secondbrain `yttrx-documentation/changelog.md`; the +user-memory note `project_welcomebot` has the current state. + +## HARD RULES + +- **Never overwrite the `.env` on `admin.yttrx.com`.** It holds the live + `WEBHOOK_SECRET`, `BOT_ACCESS_TOKEN`, and `ABUSE_BOT_TOKEN`. The local `.env` + has these blank. **Always rsync code only (`--exclude='.env'`)** and edit the + box's `.env` in place for new vars. +- **Never commit secrets.** `.env` is git- and docker-ignored. Tokens/passwords + must not land in this repo, the changelog, or CLAUDE.md. +- The bot **silences** (reversible), it does not suspend by default + (`ABUSE_ACTION=silence`). It applies the action **without** `report_id` so the + report stays open for human review. + +## Deploy procedure (workstation β†’ admin.yttrx.com) + +```bash +# 1. Test locally (offline, no network) +cd ~/yttrx-welcomebot +python3 -m venv .venv && . .venv/bin/activate && pip install -q -r requirements.txt +WEBHOOK_SECRET=testsecret BOT_ACCESS_TOKEN=x python test_local.py # -> ALL TESTS PASSED + +# 2. Back up the live code + env on the box (date-stamp) +ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && \ + cp app/main.py app/main.py.bak-$(date +%Y%m%d) && cp .env .env.bak-$(date +%Y%m%d)' + +# 3. Rsync CODE ONLY (never the .env) +rsync -az --exclude='.env' --exclude='.venv' --exclude='__pycache__' \ + --exclude='*.pyc' --exclude='*.db' --exclude='*.bak*' --exclude='.git' \ + ~/yttrx-welcomebot/ admin.yttrx.com:/root/yttrx-welcomebot/ + +# 4. Normalise ownership; keep .env root:root 600 +ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && chown -R waffles:waffles . && \ + chown root:root .env .env.bak-* 2>/dev/null; chmod 600 .env .env.bak-* 2>/dev/null' + +# 5. If new env vars were added this release, set them IN PLACE on the box, e.g. +ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && \ + grep -q "^NEW_VAR=" .env || echo "NEW_VAR=value" >> .env' + +# 6. Rebuild + restart (named volume welcomebot-data persists the dedup db) +ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && docker-compose up -d --build' +``` + +### Verify + +```bash +ssh admin.yttrx.com 'curl -s localhost:8087/healthz' # {"ok":true} +ssh admin.yttrx.com 'docker logs --tail 20 yttrx-welcomebot' # clean startup +# confirm the running image + config: +ssh admin.yttrx.com 'docker exec yttrx-welcomebot python -c \ + "import app.main as m; print(m.ABUSE_DRY_RUN, m.ABUSE_ACTION, sorted(m.ABUSE_ALLOWLIST))"' +``` + +### Viewing logs + +A `welcomebot-logs` CLI is installed at `/usr/local/bin/welcomebot-logs` on +admin.yttrx.com (source: `bin/welcomebot-logs` in this repo). It wraps +`docker logs` for the container: + +```bash +ssh admin.yttrx.com welcomebot-logs # last 200 lines +ssh admin.yttrx.com welcomebot-logs -f --abuse # follow abuse activity only +ssh admin.yttrx.com welcomebot-logs -n 1000 --since 24h +ssh admin.yttrx.com 'welcomebot-logs --help' +``` + +To reinstall after editing `bin/welcomebot-logs`: +`scp bin/welcomebot-logs admin.yttrx.com:/tmp/ && ssh admin.yttrx.com 'install -m755 /tmp/welcomebot-logs /usr/local/bin/'` + +### Rollback + +```bash +ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && \ + cp app/main.py.bak-YYYYMMDD app/main.py && cp .env.bak-YYYYMMDD .env && \ + docker-compose up -d --build' +``` + +## Rollout safety: dry-run + +`ABUSE_DRY_RUN=true` makes the abuse handler log + DM what it *would* do without +silencing anyone. The shipped `.env.example` default is `true`; **production is +currently `false` (live and acting)**. To re-enter dry-run: + +```bash +ssh admin.yttrx.com 'cd /root/yttrx-welcomebot && \ + sed -i "s/^ABUSE_DRY_RUN=.*/ABUSE_DRY_RUN=true/" .env && docker-compose up -d' +``` + +## The moderator bot token (gotcha) + +The abuse handler posts to `POST /api/v1/admin/accounts/:id/action`, which needs +the **`admin:write:accounts`** OAuth scope β€” *separate* from `admin:write:reports`. +The bot account (`bot`) is an **Admin** (so it has the role permissions), but a +token created in the UI may omit `admin:write:accounts` β†’ silence returns `403`. + +**IP-scrutiny's `register_ip_block` needs an additional scope, +`admin:write:ip_blocks`**, to `POST /api/v1/admin/ip_blocks` β€” mint (or re-mint) +the token with it included, or `IP_SCRUTINY_AUTO_IPBLOCK` writes will 403 (logged +as an error; nothing else in the bot is affected). + +Mint a correctly-scoped token from the Rails console on mammut: + +```bash +ssh mammut 'docker exec $(docker ps -f name=live-web-1 -q) bin/rails runner " +acct = Account.find_local(%q{bot}) +scopes = %q{read:statuses write:statuses admin:read:reports admin:write:accounts admin:write:ip_blocks} +app = Doorkeeper::Application.create!(name: %q{Abuse handler vN}, scopes: scopes, redirect_uri: %q{urn:ietf:wg:oauth:2.0:oob}) +tok = Doorkeeper::AccessToken.create!(application_id: app.id, resource_owner_id: acct.user.id, scopes: scopes) +puts tok.token +"' +``` + +Put the result in `ABUSE_BOT_TOKEN` on the box's `.env`, restart, and revoke the +old token (`Doorkeeper::AccessToken.find().revoke`). + +**Harmless write-permission probe** (no real account touched β€” `403` = missing +scope, `404` = authorized): + +```bash +curl -s -o /dev/null -w "%{http_code}\n" -X POST \ + -H "Authorization: Bearer $ABUSE_BOT_TOKEN" -d "type=none" \ + https://yttrx.com/api/v1/admin/accounts/0/action +``` + +Same idea for the `admin:write:ip_blocks` scope IP-scrutiny needs (`403` = +missing scope; `422` = authorized, just missing/invalid params β€” no block is +created either way): + +```bash +curl -s -o /dev/null -w "%{http_code}\n" -X POST \ + -H "Authorization: Bearer $ABUSE_BOT_TOKEN" \ + https://yttrx.com/api/v1/admin/ip_blocks +``` + +## Mastodon side (mammut) + +- The webhook (Administration β†’ Webhooks) is already subscribed to + `account.created`, `account.approved`, `report.created` β€” no change needed for + code redeploys. +- Staff are never auto-silenced: `ABUSE_SKIP_PRIVILEGED=true` reads the payload + `target_account.role` (skips any assigned staff role, id > 0), plus the + explicit `ABUSE_ALLOWLIST`. Current staff: `waffles`/`tommertron` (Owner), + `davis`/`bot` (Admin). + +## Appeals page + +Silenced users are DMed `ABUSE_HELP_URL` = +`https://welcome.yttrx.com/posts/account-limited/`. That page is a Hugo +(Compost) content file at `admin.yttrx.com:/var/www/html/welcome/content/posts/`, +rebuilt with `./build.sh` in that dir (see secondbrain `misc-sites.md`). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3d56a6b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +# Persistent dedup store lives here (mounted as a volume). +VOLUME ["/data"] + +EXPOSE 8000 + +# Single worker: the sqlite dedup store and in-process lock assume one process. +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2cfde6 --- /dev/null +++ b/README.md @@ -0,0 +1,256 @@ +# yttrx welcome-bot + abuse-bot + +A small FastAPI webhook server that receives Mastodon **admin webhooks** for +[yttrx.com](https://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). + +- **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. + +``` +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 RDAP org lookup (`ipwhois`, cached in sqlite) as + `datacenter` (hosting/VPN-keyword match), `mobile` (carrier-keyword match, + informational only), or `residential` (everything else β€” never flagged). + RDAP failures classify as `unknown` and are never flagged. +2. 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 + 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 IP is + added to Mastodon's native `Admin::IpBlock` at + `IP_SCRUTINY_IPBLOCK_SEVERITY` (default `sign_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 by + `IP_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`). + +## 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 | + +## Local test + +```bash +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) + +```bash +# 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. + +```bash +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. + +## Operations + +```bash +welcomebot-logs -f --abuse # convenience CLI on admin (see bin/welcomebot-logs) +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: + +```bash +docker compose exec welcomebot \ + python -c "import sqlite3; sqlite3.connect('/data/welcomed.db').execute(\ + 'DELETE FROM welcomed WHERE acct=?', ('alice',)).connection.commit()" +``` + +## Rollback + +```bash +# 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 +``` diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..4e70433 --- /dev/null +++ b/app/main.py @@ -0,0 +1,757 @@ +"""yttrx welcome-bot + abuse-bot webhook server. + +Receives Mastodon admin webhook deliveries, verifies the HMAC signature, +and dispatches by event: + +* ``account.created`` / ``account.approved`` β€” direct-messages each new local + account a welcome toot via the welcome bot's token (scope ``write:statuses``). + Both events are handled so the welcome works whether or not registration + approval is enabled; the dedup store welcomes each account exactly 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. Uses a + separate moderator bot token with admin scopes. + +Mastodon signs every delivery with: + + X-Hub-Signature: sha256= + +One Mastodon webhook subscribing to both events points at /webhook; both +share the single WEBHOOK_SECRET. See README.md for the deploy + Mastodon-side +configuration runbook. +""" + +from __future__ import annotations + +import hashlib +import hmac +import ipaddress +import logging +import os +import re +import sqlite3 +import threading +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone + +import httpx +from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request +from ipwhois import IPWhois + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", +) +log = logging.getLogger("welcomebot") + +# --- Configuration (from environment / .env) ------------------------------- + +WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "") +BOT_ACCESS_TOKEN = os.environ.get("BOT_ACCESS_TOKEN", "") +MASTODON_BASE_URL = os.environ.get("MASTODON_BASE_URL", "https://yttrx.com").rstrip("/") +# {acct} is substituted with the new account's handle (e.g. "alice"). +WELCOME_MESSAGE = os.environ.get( + "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 that are local to this instance (domain is null/empty). +LOCAL_ONLY = os.environ.get("LOCAL_ONLY", "true").lower() in ("1", "true", "yes") +DB_PATH = os.environ.get("DB_PATH", "/data/welcomed.db") + +# --- Abuse-bot configuration ----------------------------------------------- +# Moderator bot token. The account holding it must have a role with the +# "Manage Users" + "Manage Reports" permissions, and the token these scopes: +# admin:write:accounts admin:read:reports read:statuses write:statuses +ABUSE_BOT_TOKEN = os.environ.get("ABUSE_BOT_TOKEN", "") +# Master switch; report handling is also disabled if ABUSE_BOT_TOKEN is empty. +ABUSE_ENABLED = os.environ.get("ABUSE_ENABLED", "true").lower() in ("1", "true", "yes") +# Don't actually silence β€” just log + DM what *would* happen. Safe for rollout. +ABUSE_DRY_RUN = os.environ.get("ABUSE_DRY_RUN", "false").lower() in ("1", "true", "yes") +# Moderation action to take. "silence" is reversible and the agreed default; +# "suspend" is also accepted. +ABUSE_ACTION = os.environ.get("ABUSE_ACTION", "silence").lower() +# Only auto-act on accounts local to this instance. +ABUSE_LOCAL_ONLY = os.environ.get("ABUSE_LOCAL_ONLY", "true").lower() in ("1", "true", "yes") +# An account is "young" if its OLDEST post is newer than this many days. +ABUSE_YOUNG_MAX_DAYS = int(os.environ.get("ABUSE_YOUNG_MAX_DAYS", "30")) +# An account is "dormant" if its NEWEST post is older than this many days. +ABUSE_DORMANT_MIN_DAYS = int(os.environ.get("ABUSE_DORMANT_MIN_DAYS", "30")) +# Distinct-reporter thresholds: young/dormant (or no-posts) vs normally-active. +ABUSE_SOURCES_NEWDORMANT = int(os.environ.get("ABUSE_SOURCES_NEWDORMANT", "2")) +ABUSE_SOURCES_ACTIVE = int(os.environ.get("ABUSE_SOURCES_ACTIVE", "3")) +# Safety cap on the backward status scan (40 statuses/page). +ABUSE_MAX_STATUS_PAGES = int(os.environ.get("ABUSE_MAX_STATUS_PAGES", "5")) +# Comma-separated handles (acct, no leading @) never to auto-act on. +ABUSE_ALLOWLIST = { + h.strip().lstrip("@").lower() + for h in os.environ.get("ABUSE_ALLOWLIST", "").split(",") + if h.strip() +} +# Automatically never auto-act on accounts that hold a staff role +# (Admin/Owner/Moderator β€” any assigned, non-default UserRole). Read from the +# report payload's target_account.role, so new staff are protected with no +# allowlist upkeep. Belt-and-suspenders with ABUSE_ALLOWLIST. +ABUSE_SKIP_PRIVILEGED = os.environ.get( + "ABUSE_SKIP_PRIVILEGED", "true" +).lower() in ("1", "true", "yes") +# Handle (acct, no @) to DM with a review summary. Empty disables the DM. +MOD_ALERT_ACCT = os.environ.get("MOD_ALERT_ACCT", "").strip().lstrip("@") +# Help/appeals page linked in the DM sent to a silenced user. +ABUSE_HELP_URL = os.environ.get( + "ABUSE_HELP_URL", "https://welcome.yttrx.com/posts/account-limited/" +) +# DM sent to the silenced user. {acct} and {help_url} are substituted; the +# message must mention @{acct} for it to reach them. Empty disables the DM. +ABUSE_USER_DM = os.environ.get( + "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 (already delivered free on the +# account.created payload as Admin::Account.ip) via RDAP org lookup, and +# treats non-residential/non-mobile ("datacenter") signups with more scrutiny. +IP_SCRUTINY_ENABLED = os.environ.get("IP_SCRUTINY_ENABLED", "true").lower() in ("1", "true", "yes") +# Log/DM only β€” no held welcome, no ip_blocks write. Rollout safety, same role +# as ABUSE_DRY_RUN. +IP_SCRUTINY_DRY_RUN = os.environ.get("IP_SCRUTINY_DRY_RUN", "true").lower() in ("1", "true", "yes") +# Hold the welcome DM for a flagged signup until account.approved fires +# (i.e. until a human clears the existing approval-required registration +# gate), instead of welcoming immediately on account.created. +IP_SCRUTINY_HOLD_WELCOME = os.environ.get("IP_SCRUTINY_HOLD_WELCOME", "true").lower() in ("1", "true", "yes") +# Distinct-reporter threshold used INSTEAD of the tier's usual threshold (via +# min()) when the reported account's signup IP was flagged. +IP_SCRUTINY_ABUSE_THRESHOLD = int(os.environ.get("IP_SCRUTINY_ABUSE_THRESHOLD", "1")) +# Auto-register flagged IPs into Mastodon's native Admin::IpBlock. +IP_SCRUTINY_AUTO_IPBLOCK = os.environ.get("IP_SCRUTINY_AUTO_IPBLOCK", "true").lower() in ("1", "true", "yes") +# severity: sign_up_requires_approval | sign_up_block | no_access +IP_SCRUTINY_IPBLOCK_SEVERITY = os.environ.get("IP_SCRUTINY_IPBLOCK_SEVERITY", "sign_up_requires_approval") +# Org-name keyword regexes (case-insensitive) used to classify the RDAP +# asn_description/network name. Anything matching neither is "residential". +IP_SCRUTINY_HOSTING_RE = re.compile(os.environ.get( + "IP_SCRUTINY_HOSTING_RE", + r"amazon|aws|google|microsoft|azure|digitalocean|ovh|hetzner|linode|vultr|" + r"contabo|choopa|m247|scaleway|leaseweb|hostinger|namecheap|godaddy|" + r"cloudflare|oracle|alibaba|tencent|akamai|fastly|packet|vpn|proxy|hosting|" + r"datacenter|data center|colo(?:cation)?|server", +), re.I) +IP_SCRUTINY_MOBILE_RE = re.compile(os.environ.get( + "IP_SCRUTINY_MOBILE_RE", + r"mobile|wireless|cellular|verizon|t-mobile|tmobile|at&t|att mobility|" + r"vodafone|telstra|sprint|three\b|orange mobile|deutsche telekom|o2\b", +), re.I) + +if not WEBHOOK_SECRET: + log.warning("WEBHOOK_SECRET is empty β€” signature verification will reject all requests.") +if not BOT_ACCESS_TOKEN: + log.warning("BOT_ACCESS_TOKEN is empty β€” the bot cannot post welcome messages.") +if ABUSE_ENABLED and not ABUSE_BOT_TOKEN: + log.warning("ABUSE_BOT_TOKEN is empty β€” report.created handling is disabled.") + +app = FastAPI(title="yttrx welcome-bot + abuse-bot") + +# --- Dedup / action store -------------------------------------------------- +# Mastodon retries deliveries on any non-2xx response and may deliver more +# than once; persist what we've done so we never act twice. + +_db_lock = threading.Lock() + + +def _init_db() -> None: + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + with sqlite3.connect(DB_PATH) as conn: + conn.execute( + "CREATE TABLE IF NOT EXISTS welcomed (" + " account_id TEXT PRIMARY KEY," + " acct TEXT," + " welcomed_at TEXT DEFAULT CURRENT_TIMESTAMP" + ")" + ) + conn.execute( + "CREATE TABLE IF NOT EXISTS abuse_actions (" + " target_id TEXT PRIMARY KEY," + " acct TEXT," + " action TEXT," + " tier TEXT," + " sources INTEGER," + " report_id TEXT," + " acted_at TEXT DEFAULT CURRENT_TIMESTAMP" + ")" + ) + conn.execute( + "CREATE TABLE IF NOT EXISTS signup_ip (" + " account_id TEXT PRIMARY KEY," + " acct TEXT," + " ip TEXT," + " classification TEXT," + " org TEXT," + " flagged INTEGER," + " ipblock_registered INTEGER DEFAULT 0," + " classified_at TEXT DEFAULT CURRENT_TIMESTAMP" + ")" + ) + conn.execute( + "CREATE TABLE IF NOT EXISTS whois_cache (" + " ip TEXT PRIMARY KEY," + " org TEXT," + " cached_at TEXT DEFAULT CURRENT_TIMESTAMP" + ")" + ) + + +@contextmanager +def _db(): + with _db_lock: + conn = sqlite3.connect(DB_PATH) + try: + yield conn + conn.commit() + finally: + conn.close() + + +def already_welcomed(account_id: str) -> bool: + with _db() as conn: + row = conn.execute( + "SELECT 1 FROM welcomed WHERE account_id = ?", (account_id,) + ).fetchone() + return row is not None + + +def mark_welcomed(account_id: str, acct: str) -> None: + with _db() as conn: + conn.execute( + "INSERT OR IGNORE INTO welcomed (account_id, acct) VALUES (?, ?)", + (account_id, acct), + ) + + +def already_actioned(target_id: str) -> bool: + with _db() as conn: + row = conn.execute( + "SELECT 1 FROM abuse_actions WHERE target_id = ?", (target_id,) + ).fetchone() + return row is not None + + +def record_action(target_id: str, acct: str, action: str, tier: str, + sources: int, report_id: str) -> None: + with _db() as conn: + conn.execute( + "INSERT OR IGNORE INTO abuse_actions " + "(target_id, acct, action, tier, sources, report_id) " + "VALUES (?, ?, ?, ?, ?, ?)", + (target_id, acct, action, tier, sources, report_id), + ) + + +def record_signup_ip(account_id: str, acct: str, ip: str, classification: str, + org: str, flagged: bool) -> None: + with _db() as conn: + conn.execute( + "INSERT OR REPLACE INTO signup_ip " + "(account_id, acct, ip, classification, org, flagged, " + " ipblock_registered) " + "VALUES (?, ?, ?, ?, ?, ?, " + " COALESCE((SELECT ipblock_registered FROM signup_ip WHERE account_id = ?), 0))", + (account_id, acct, ip, classification, org, int(flagged), account_id), + ) + + +def get_signup_flag(account_id: str) -> bool: + with _db() as conn: + row = conn.execute( + "SELECT flagged FROM signup_ip WHERE account_id = ?", (account_id,) + ).fetchone() + return bool(row and row[0]) + + +def mark_ipblock_registered(account_id: str) -> None: + with _db() as conn: + conn.execute( + "UPDATE signup_ip SET ipblock_registered = 1 WHERE account_id = ?", + (account_id,), + ) + + +def cached_whois_org(ip: str) -> str | None: + with _db() as conn: + row = conn.execute( + "SELECT org FROM whois_cache WHERE ip = ?", (ip,) + ).fetchone() + return row[0] if row else None + + +def cache_whois_org(ip: str, org: str) -> None: + with _db() as conn: + conn.execute( + "INSERT OR IGNORE INTO whois_cache (ip, org) VALUES (?, ?)", + (ip, org), + ) + + +_init_db() + +# --- Signature verification ------------------------------------------------ + + +def verify_signature(raw_body: bytes, header_value: str | None) -> bool: + """Constant-time check of the X-Hub-Signature header.""" + if not WEBHOOK_SECRET or not header_value: + return False + if not header_value.startswith("sha256="): + return False + sent = header_value[len("sha256=") :].strip() + expected = hmac.new( + WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(sent, expected) + + +# --- Mastodon API (welcome) ------------------------------------------------ + + +def send_welcome(account_id: str, acct: str) -> None: + """Post a direct-visibility welcome status mentioning the new user.""" + if already_welcomed(account_id): + log.info("already welcomed account_id=%s acct=%s, skipping", account_id, acct) + return + + message = WELCOME_MESSAGE.format(acct=acct) + try: + resp = httpx.post( + f"{MASTODON_BASE_URL}/api/v1/statuses", + headers={"Authorization": f"Bearer {BOT_ACCESS_TOKEN}"}, + data={"status": message, "visibility": "direct"}, + timeout=15.0, + ) + resp.raise_for_status() + except httpx.HTTPError as exc: + # Leave it un-marked so a Mastodon retry (or manual replay) can succeed. + log.error("failed to send welcome to acct=%s: %s", acct, exc) + return + + mark_welcomed(account_id, acct) + log.info("welcomed acct=%s account_id=%s status_id=%s", + acct, account_id, resp.json().get("id")) + + +def process_signup(account_id: str, acct: str, ip: str) -> None: + """Classify a new signup's IP and act on account.created. + + Non-flagged (or IP-scrutiny disabled / no ip in payload) signups are + welcomed immediately, same as before this feature existed. Flagged + (datacenter) signups get a moderator DM, an optional ip_blocks + registration, and β€” unless dry-run β€” have their welcome held until + account.approved fires (see _handle_account_created). + """ + if not (IP_SCRUTINY_ENABLED and ip): + send_welcome(account_id, acct) + return + + classification, org = classify_signup_ip(ip) + flagged = classification == "datacenter" + record_signup_ip(account_id, acct, ip, classification, org, flagged) + + if not flagged: + send_welcome(account_id, acct) + return + + log.warning("flagged signup acct=%s ip=%s classification=%s org=%s", + acct, ip, classification, org) + + if IP_SCRUTINY_AUTO_IPBLOCK and not IP_SCRUTINY_DRY_RUN: + register_ip_block(ip, acct, org) + mark_ipblock_registered(account_id) + + hold = IP_SCRUTINY_HOLD_WELCOME and not IP_SCRUTINY_DRY_RUN + prefix = "[DRY-RUN] " if IP_SCRUTINY_DRY_RUN else "" + dm_moderator( + f"{prefix}⚠️ Flagged signup @{acct} from {ip} ({org or 'unknown org'}, " + f"datacenter/hosting)." + + (" Welcome DM held pending approval." if hold else "") + ) + + if not hold: + send_welcome(account_id, acct) + # else: held β€” sent later if/when account.approved fires (human review). + + +# --- Mastodon API (abuse) -------------------------------------------------- + + +def _parse_ts(value: str | None) -> datetime | None: + """Parse a Mastodon ISO-8601 timestamp into an aware UTC datetime.""" + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + log.warning("could not parse timestamp %r", value) + return None + + +def _admin_headers() -> dict: + return {"Authorization": f"Bearer {ABUSE_BOT_TOKEN}"} + + +def classify_account(target_id: str) -> dict: + """Walk the target's authored statuses (newestβ†’oldest) and classify. + + Returns {has_posts, young, dormant, newest_at, oldest_seen, truncated}. + + * ``young`` β€” the oldest authored post is newer than ABUSE_YOUNG_MAX_DAYS. + Decided by paging backward and bailing out the moment we + see a post older than the window (so established accounts + cost one page). + * ``dormant`` β€” the newest authored post is older than ABUSE_DORMANT_MIN_DAYS. + * ``truncated`` β€” hit the page cap while everything was still inside the + young window, so ``young`` is treated as unknownβ†’False. + """ + now = datetime.now(timezone.utc) + young_cutoff = now - timedelta(days=ABUSE_YOUNG_MAX_DAYS) + dormant_cutoff = now - timedelta(days=ABUSE_DORMANT_MIN_DAYS) + + newest_at: datetime | None = None + oldest_seen: datetime | None = None + found_older_than_young = False + truncated = False + max_id: str | None = None + + for page_num in range(ABUSE_MAX_STATUS_PAGES): + params = {"limit": 40, "exclude_reblogs": "true"} + if max_id: + params["max_id"] = max_id + resp = httpx.get( + f"{MASTODON_BASE_URL}/api/v1/accounts/{target_id}/statuses", + headers=_admin_headers(), params=params, timeout=15.0, + ) + resp.raise_for_status() + page = resp.json() + if not page: + break + + for status in page: + ts = _parse_ts(status.get("created_at")) + if ts is None: + continue + if newest_at is None or ts > newest_at: + newest_at = ts + if oldest_seen is None or ts < oldest_seen: + oldest_seen = ts + if ts < young_cutoff: + found_older_than_young = True + + max_id = str(page[-1].get("id")) + if found_older_than_young or len(page) < 40: + break + if page_num == ABUSE_MAX_STATUS_PAGES - 1 and not found_older_than_young: + # Still all-recent at the cap β€” can't prove the true oldest. + truncated = True + + has_posts = newest_at is not None + young = has_posts and not found_older_than_young and not truncated + dormant = has_posts and newest_at < dormant_cutoff + if truncated: + log.info("status scan truncated at %d pages for target_id=%s; " + "treating young=unknownβ†’False", ABUSE_MAX_STATUS_PAGES, target_id) + return { + "has_posts": has_posts, "young": young, "dormant": dormant, + "newest_at": newest_at, "oldest_seen": oldest_seen, "truncated": truncated, + } + + +def classify_signup_ip(ip: str) -> tuple[str, str]: + """Classify a signup IP as 'datacenter' | 'mobile' | 'residential' | 'unknown'. + + Looks up the owning org via RDAP (cached indefinitely in sqlite β€” an IP's + *owning org* doesn't change on the timescale that matters here) and + keyword-matches it against IP_SCRUTINY_HOSTING_RE / IP_SCRUTINY_MOBILE_RE. + RDAP failure yields 'unknown', which is deliberately never flagged β€” + scrutiny should never trigger on our own lookup errors. + """ + org = cached_whois_org(ip) + if org is None: + try: + result = IPWhois(ip).lookup_rdap(depth=1) + org = result.get("asn_description") or (result.get("network") or {}).get("name") or "" + except Exception as exc: # noqa: BLE001 - any RDAP failure -> unknown, never raise + log.warning("RDAP lookup failed for ip=%s: %s", ip, exc) + return "unknown", "" + cache_whois_org(ip, org) + + if IP_SCRUTINY_HOSTING_RE.search(org): + return "datacenter", org + if IP_SCRUTINY_MOBILE_RE.search(org): + return "mobile", org + return "residential", org + + +def register_ip_block(ip: str, acct: str, org: str) -> None: + """Register a flagged signup IP in Mastodon's native Admin::IpBlock.""" + try: + prefix_len = 32 if ipaddress.ip_address(ip).version == 4 else 128 + except ValueError: + log.warning("skipping ip_block registration for unparseable ip=%r (acct=%s)", ip, acct) + return + cidr = f"{ip}/{prefix_len}" + comment = f"welcomebot: flagged signup @{acct} ({org or 'unknown org'})"[:200] + try: + resp = httpx.post( + f"{MASTODON_BASE_URL}/api/v1/admin/ip_blocks", + headers=_admin_headers(), + data={"ip": cidr, "severity": IP_SCRUTINY_IPBLOCK_SEVERITY, "comment": comment}, + timeout=15.0, + ) + if resp.status_code == 422: + log.info("ip_block for %s already exists, treating as registered", cidr) + else: + resp.raise_for_status() + except httpx.HTTPError as exc: + log.error("failed to register ip_block for %s (acct=%s): %s", cidr, acct, exc) + return + + +def count_distinct_reporters(target_id: str) -> int: + """Count distinct accounts with an OPEN report against the target. + + Self-reports (reporter == target) are excluded. + """ + resp = httpx.get( + f"{MASTODON_BASE_URL}/api/v1/admin/reports", + headers=_admin_headers(), + params={"target_account_id": target_id, "resolved": "false", "limit": 200}, + timeout=15.0, + ) + resp.raise_for_status() + reports = resp.json() + if len(reports) >= 200: + log.warning("hit 200-report cap counting reporters for target_id=%s; " + "count may be undercounted", target_id) + reporters = set() + for rep in reports: + reporter = (rep.get("account") or {}).get("id") + if reporter and str(reporter) != str(target_id): + reporters.add(str(reporter)) + return len(reporters) + + +def apply_action(target_id: str, action: str, text: str) -> None: + """POST the moderation action WITHOUT report_id, so the report stays open.""" + resp = httpx.post( + f"{MASTODON_BASE_URL}/api/v1/admin/accounts/{target_id}/action", + headers=_admin_headers(), + data={"type": action, "text": text}, + timeout=15.0, + ) + resp.raise_for_status() + + +def _post_direct(text: str, *, what: str) -> None: + """Post a direct-visibility status from the moderator bot.""" + try: + resp = httpx.post( + f"{MASTODON_BASE_URL}/api/v1/statuses", + headers=_admin_headers(), + data={"status": text, "visibility": "direct"}, + timeout=15.0, + ) + resp.raise_for_status() + except httpx.HTTPError as exc: + log.error("failed to send %s DM: %s", what, exc) + + +def dm_moderator(message: str) -> None: + """DM the configured moderator account.""" + if not MOD_ALERT_ACCT: + return + _post_direct(f"@{MOD_ALERT_ACCT} {message}", what="moderator") + + +def dm_silenced_user(acct: str) -> None: + """DM the silenced user a link to the appeals/help page.""" + if not ABUSE_USER_DM: + return + _post_direct(ABUSE_USER_DM.format(acct=acct, help_url=ABUSE_HELP_URL), + what="silenced-user") + + +def handle_report(report_id: str, target_id: str, acct: str, is_local: bool, + suspended: bool, silenced: bool, privileged: bool = False) -> None: + """Evaluate a reported account and silence it if the policy is met.""" + if not (ABUSE_ENABLED and ABUSE_BOT_TOKEN): + return + if ABUSE_LOCAL_ONLY and not is_local: + log.info("report %s: target acct=%s is remote, skipping", report_id, acct) + return + if privileged and ABUSE_SKIP_PRIVILEGED: + log.info("report %s: target acct=%s holds a staff role, skipping", report_id, acct) + return + if acct.split("@")[0].lower() in ABUSE_ALLOWLIST or acct.lower() in ABUSE_ALLOWLIST: + log.info("report %s: target acct=%s is allowlisted, skipping", report_id, acct) + return + if suspended or silenced: + log.info("report %s: target acct=%s already limited, skipping", report_id, acct) + return + if already_actioned(target_id): + log.info("report %s: target acct=%s already auto-actioned, skipping", + report_id, acct) + return + + try: + profile = classify_account(target_id) + sources = count_distinct_reporters(target_id) + except httpx.HTTPError as exc: + log.error("report %s: Mastodon API error evaluating acct=%s: %s", + report_id, acct, exc) + return + + if not profile["has_posts"]: + tier, required = "no-posts", ABUSE_SOURCES_NEWDORMANT + elif profile["young"] or profile["dormant"]: + tier = "young" if profile["young"] else "dormant" + required = ABUSE_SOURCES_NEWDORMANT + else: + tier, required = "active", ABUSE_SOURCES_ACTIVE + + flagged_ip = get_signup_flag(target_id) + if flagged_ip: + required = min(required, IP_SCRUTINY_ABUSE_THRESHOLD) + + log.info("report %s: acct=%s tier=%s distinct_sources=%d required=%d%s", + report_id, acct, tier, sources, required, + " (lowered: flagged signup IP)" if flagged_ip else "") + + if sources < required: + log.info("report %s: acct=%s below threshold (%d/%d), leaving for review", + report_id, acct, sources, required) + return + + report_url = f"{MASTODON_BASE_URL}/admin/reports/{report_id}" + ip_note = " Signup IP was flagged (datacenter/hosting) β€” threshold lowered." if flagged_ip else "" + note = (f"Auto-{ABUSE_ACTION} by abuse-bot: {sources} distinct reporters, " + f"account tier={tier}. Pending human review (report left open).{ip_note}") + + if ABUSE_DRY_RUN: + log.warning("[DRY-RUN] would %s acct=%s (tier=%s, sources=%d). %s", + ABUSE_ACTION, acct, tier, sources, report_url) + record_action(target_id, acct, f"dry-run:{ABUSE_ACTION}", tier, sources, report_id) + dm_moderator(f"[DRY-RUN] would {ABUSE_ACTION} @{acct} β€” {sources} distinct " + f"reporters, tier={tier}.{ip_note} Review: {report_url}") + return + + try: + apply_action(target_id, ABUSE_ACTION, note) + except httpx.HTTPError as exc: + log.error("report %s: failed to %s acct=%s: %s", + report_id, ABUSE_ACTION, acct, exc) + return + + record_action(target_id, acct, ABUSE_ACTION, tier, sources, report_id) + log.warning("auto-%sd acct=%s (tier=%s, %d distinct reporters); report left open", + ABUSE_ACTION, acct, tier, sources) + dm_moderator(f"🚨 Auto-{ABUSE_ACTION}d @{acct} β€” {sources} distinct reporters, " + f"account tier={tier}.{ip_note} Report left open for review: {report_url}") + dm_silenced_user(acct) + + +# --- Routes ---------------------------------------------------------------- + + +@app.get("/healthz") +def healthz(): + return {"ok": True} + + +@app.post("/webhook") +async def webhook( + request: Request, + background: BackgroundTasks, + x_hub_signature: str | None = Header(default=None), +): + raw = await request.body() + + if not verify_signature(raw, x_hub_signature): + log.warning("rejected delivery: bad/missing signature") + raise HTTPException(status_code=401, detail="invalid signature") + + try: + payload = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="invalid JSON") + + event = payload.get("event") + obj = payload.get("object") or {} + + # Welcome on either event so it works whether registration approval is on + # (account.created fires at signup, account.approved later) or off. The + # dedup store ensures each account is welcomed exactly once. + if event in ("account.created", "account.approved"): + return _handle_account_created(obj, background, event) + if event == "report.created": + return _handle_report_created(obj, background) + + log.info("ignoring event=%s", event) + return {"ok": True, "ignored": event} + + +def _handle_account_created(obj: dict, background: BackgroundTasks, event: str): + # object is an Admin::Account; the public Account is nested under "account". + nested = obj.get("account") or {} + account_id = str(nested.get("id") or obj.get("id") or "") + acct = nested.get("acct") or obj.get("username") or "" + + if not account_id or not acct: + log.warning("could not extract account from payload: %s", obj) + raise HTTPException(status_code=422, detail="no account in payload") + + if LOCAL_ONLY and ("@" in acct or obj.get("domain")): + log.info("skipping non-local account acct=%s domain=%s", acct, obj.get("domain")) + return {"ok": True, "skipped": "remote account"} + + # Respond 200 immediately; do the API call out-of-band so Mastodon's + # delivery doesn't block on our outbound request. + if event == "account.created": + # First sighting of this account β€” classify its signup IP (Admin::Account.ip) + # and decide whether to welcome now or hold for account.approved. + ip = obj.get("ip") or "" + background.add_task(process_signup, account_id, acct, ip) + else: + # account.approved: a human has cleared the approval-required + # registration gate. Always welcome (dedup makes this safe) β€” this is + # what lifts a held welcome for a previously-flagged signup. + background.add_task(send_welcome, account_id, acct) + return {"ok": True, "queued": acct} + + +def _handle_report_created(obj: dict, background: BackgroundTasks): + # object is an Admin::Report; target_account is the reported Admin::Account. + report_id = str(obj.get("id") or "") + target = obj.get("target_account") or {} + nested = target.get("account") or {} + target_id = str(target.get("id") or nested.get("id") or "") + acct = nested.get("acct") or target.get("username") or "" + is_local = not (target.get("domain") or "@" in acct) + + # Admin::Account.role: regular users get the default "everyone" role + # (id -99); staff hold an assigned role with a positive id. + role = target.get("role") or {} + try: + role_id = int(role.get("id")) if role.get("id") is not None else None + except (TypeError, ValueError): + role_id = None + privileged = role_id is not None and role_id > 0 + + if not report_id or not target_id or not acct: + log.warning("could not extract report/target from payload: %s", obj) + raise HTTPException(status_code=422, detail="no report/target in payload") + + background.add_task( + handle_report, report_id, target_id, acct, is_local, + bool(target.get("suspended")), bool(target.get("silenced")), privileged, + ) + return {"ok": True, "report": report_id, "target": acct} diff --git a/bin/welcomebot-logs b/bin/welcomebot-logs new file mode 100644 index 0000000..5986f80 --- /dev/null +++ b/bin/welcomebot-logs @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# welcomebot-logs β€” print logs for the yttrx-welcomebot container (welcome + abuse bot). +# +# Installed at /usr/local/bin/welcomebot-logs on admin.yttrx.com. +# Source of truth: ~/yttrx-welcomebot/bin/welcomebot-logs (workstation). +# Run as root (how `ssh admin.yttrx.com` logs in); needs Docker access. +set -euo pipefail + +CONTAINER="${WELCOMEBOT_CONTAINER:-yttrx-welcomebot}" +tail_n=200 +follow=0 +since="" +pattern="" + +usage() { + cat <&2; usage >&2; exit 2 ;; + esac + shift +done + +if ! command -v docker >/dev/null 2>&1; then + echo "welcomebot-logs: docker not found in PATH" >&2; exit 1 +fi +if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "welcomebot-logs: container '$CONTAINER' not found (is it deployed/running?)" >&2; exit 1 +fi + +args=(logs --tail "$tail_n") +[ "$follow" -eq 1 ] && args+=(--follow) +[ -n "$since" ] && args+=(--since "$since") + +# docker logs writes app output to stdout+stderr; merge then optionally filter. +if [ -n "$pattern" ]; then + docker "${args[@]}" "$CONTAINER" 2>&1 | grep -E --line-buffered -- "$pattern" || true +else + docker "${args[@]}" "$CONTAINER" 2>&1 +fi diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..74a59b2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +# version pinned for docker-compose v1.29.2 on admin.yttrx.com +version: "3.8" + +services: + welcomebot: + build: . + container_name: yttrx-welcomebot + restart: unless-stopped + env_file: .env + # Bind to localhost only; nginx terminates TLS and proxies to us. + ports: + - "127.0.0.1:8087:8000" + volumes: + - welcomebot-data:/data + +volumes: + welcomebot-data: diff --git a/dormant-sweep/README.md b/dormant-sweep/README.md new file mode 100644 index 0000000..53ff2ee --- /dev/null +++ b/dormant-sweep/README.md @@ -0,0 +1,142 @@ +# dormant-sweep + +A **scheduled** sweep that reversibly limits (silences) long-dormant local yttrx +accounts and notifies each one with an appeal/reactivation path. This is the +proactive counterpart to the report-driven `abuse-bot` in this repo β€” roadmap +item **A** in secondbrain `yttrx-documentation/anti-abuse.md`. + +Unlike the rest of the welcomebot (which runs on **admin** over the HTTP API), +this sweep runs **on mammut, inside the Mastodon `live-web-1` container**, +because the "last login" signal (`users.current_sign_in_at`) only exists in the +database β€” the admin REST API does not expose it. Running inside Rails also +means it acts as the `bot` account directly, so **there is no API token or +secret to manage on the host.** + +## What counts as "dormant" + +ALL of the following must be older than the cutoff (default **18 months**): + +| Signal | Source | Why | +|---|---|---| +| web login | `users.current_sign_in_at` | the headline "hasn't logged in" signal (NULL = never logged in; only counts if the account predates the cutoff) | +| app / API token use | `Doorkeeper::AccessToken.last_used_at` | so we don't limit people active via a mobile app (stale web login, live token) | +| last post | `account_stats.last_status_at` | so we don't limit lurkers who post without web-logging-in | + +> **Why all three?** `current_sign_in_at` alone is a trap: it only updates on +> *web* login, so at a 6-month cutoff it flagged **1004 / 1548** accounts (~65%) +> on the live instance. The three-signal definition flagged the same 1004 (the +> instance is just very quiet), but it will not produce false positives as app +> usage grows. Starting policy is an **18-month** cutoff β†’ **906** candidates. + +Always excluded: staff (any assigned role), `DORMANT_ALLOWLIST` handles, and +accounts already suspended or silenced. + +## Notifications + +**DM only by default. We do NOT email dormant users.** Blasting a native strike +email to ~900 years-stale addresses would bounce heavily, and a bounce spike from +`admin@yttrx.com` (which has no Mastodon-side suppression) would damage yttrx's +own outbound mail reputation β€” the same channel signup confirmations use. So: + +- **Bot DM** (`DORMANT_SEND_DM=true`, default) β€” the primary, bounce-free notice. + A Mastodon DM from the bot; seen if they log back in. (Silence blocks + *outgoing* reach, not incoming, so the DM is delivered fine.) +- **Strike record** β€” every action still creates an `AccountWarning` (strike) + that is **appealable in the user's account settings**, independent of email. +- **Native strike email** (`DORMANT_EMAIL_NOTIFY=false`, default) β€” OFF. + Turning it on emails the registered address AND fires the in-app notification + ping (both are gated behind the same `warnable?` check). Only enable with a + small `DORMANT_BATCH_CAP` so bounces trickle, and watch the `admin@yttrx.com` + inbox (bounces return there; Mastodon won't auto-suppress). +- **Mod summary** β€” a per-run batch summary DM to `DORMANT_MOD_ALERT`. + +The user-facing reactivation path is **email admin@yttrx.com** (and/or the native +in-app appeal on the strike) β€” not "DM @waffles", because a silenced account's +mentions to non-followers are filtered and unreliable until the limit is lifted. +The help page (`account-inactive.md`) explains this. + +## Files + +| File | Role | +|---|---| +| `dormant_sweep.rb` | the sweep itself; run via `rails runner -` inside `live-web-1` | +| `dormant-sweep.sh` | host cron wrapper (pipes the rb into the container) | +| `dormant-sweep.env.example` | config template β†’ copy to `dormant-sweep.env` on mammut | +| `account-inactive.md` | Hugo content for `welcome.yttrx.com/posts/account-inactive/` | + +## Config + +All via env (see `dormant-sweep.env.example`). Key knobs: +`DORMANT_MONTHS` (**18**), `DORMANT_DRY_RUN` (**true** by default), +`DORMANT_BATCH_CAP` (50/run), `DORMANT_ALLOWLIST`, +`DORMANT_EMAIL_NOTIFY` (**false** β€” DM only), `DORMANT_SEND_DM`, +`DORMANT_MOD_ALERT`. + +## Deploy (workstation β†’ mammut) + +```bash +# 1. Copy the runner + wrapper + config to mammut +ssh mammut 'mkdir -p /root/dormant-sweep' +scp dormant_sweep.rb dormant-sweep.sh dormant-sweep.env.example mammut:/root/dormant-sweep/ +ssh mammut 'cd /root/dormant-sweep && cp -n dormant-sweep.env.example dormant-sweep.env && \ + chmod +x dormant-sweep.sh' +# 2. Edit /root/dormant-sweep/dormant-sweep.env on mammut (keep DORMANT_DRY_RUN=true) + +# 3. DRY RUN β€” review what it would do (cap raised for a full preview) +ssh mammut 'DORMANT_BATCH_CAP=2000 /root/dormant-sweep/dormant-sweep.sh' | tee /tmp/dormant-dryrun.log + +# 4. When happy, go live in controlled batches: +# set DORMANT_DRY_RUN=false in the env, then either run by hand a few times +# or let cron drain it at DORMANT_BATCH_CAP per run. +ssh mammut 'crontab -l; echo "30 4 * * 0 /root/dormant-sweep/dormant-sweep.sh >> /var/log/dormant-sweep.log 2>&1" | crontab -' +``` + +### Deploy the help page (welcome.yttrx.com, on admin) + +```bash +scp account-inactive.md admin.yttrx.com:/var/www/html/welcome/content/posts/ +ssh admin.yttrx.com 'cd /var/www/html/welcome && ./build.sh && \ + git add -A && git commit -m "welcome: add account-inactive (dormant) page"' +``` + +## Initial backlog (~906 accounts at the 18-month cutoff) + +This is a large one-time batch. Recommended rollout: + +1. Dry-run (step 3) and **read the list** β€” confirm no surprises. +2. Set `DORMANT_DRY_RUN=false`, keep `DORMANT_BATCH_CAP` modest (e.g. 50–100). +3. Run a few batches by hand, spot-check the strike emails / appeals queue, then + let cron drain the rest. New accounts crossing 6mo afterward are a trickle. + +## Rollback / un-limit + +Every action is logged: `limited @handle ...` lines in `/var/log/dormant-sweep.log`, +and the strike note is tagged `[dormant-sweep]`. To lift the whole batch: + +```bash +# from the log (exact set this script limited) +ssh mammut 'grep -oP "(?<=limited @)\S+" /var/log/dormant-sweep.log | sort -u' > /tmp/swept.txt +ssh mammut 'docker exec -i $(docker ps -f name=live-web-1 -q) bin/rails runner " + STDIN.read.split.each { |u| a = Account.find_local(u); a.unsilence! if a&.silenced? } +"' < /tmp/swept.txt + +# or by the strike tag, regardless of the log: +ssh mammut 'docker exec $(docker ps -f name=live-web-1 -q) bin/rails runner " + AccountWarning.where(\"text LIKE ?\", \"%[dormant-sweep]%\").find_each do |w| + w.target_account.unsilence! if w.target_account&.silenced? + end +"' +``` + +A single account: in the admin UI (Moderation β†’ the account β†’ Undo limit), or +`Account.find_local(\"handle\").unsilence!`. + +## Notes / gotchas + +- Runs as `bot` (Admin). `Admin::AccountAction(type: 'silence')` is the exact + call the abuse-bot makes via the API β€” same reversible, no-`report_id` + behaviour, just invoked in-process. +- mammut uses the **`docker compose` plugin**; `tootctl`/`rails` run inside + `live-web-1` (`docker exec $(docker ps -f name=live-web-1 -q) ...`). +- Verified against Mastodon **4.6.0** (2026-06-18): `Admin::AccountAction`, + `PostStatusService`, `User.confirmed`, and the three signal columns all exist. diff --git a/dormant-sweep/account-inactive.md b/dormant-sweep/account-inactive.md new file mode 100644 index 0000000..8a3452f --- /dev/null +++ b/dormant-sweep/account-inactive.md @@ -0,0 +1,53 @@ +--- +title: "Why was my account limited for inactivity?" +date: 2026-06-18 +description: "yttrx limits accounts that have been inactive for a long time. Here's what that means and how to get yours back." +--- + +If you've landed here, your yttrx account was **automatically limited because it +looked inactive** β€” no logins, app activity, or posts for well over a year. +This is routine housekeeping, **not** a punishment, and **nothing has been +deleted**. Getting it back takes one short email. + +## What "limited" means + +A limited (also called *silenced*) account is **not deleted and not banned**. +Your posts, follows, and data are all still there. While the limit is in place, +your account is just held back from public and federated timelines so a parked, +forgotten account can't quietly be used for spam. It's fully reversible. + +## Why it happens + +yttrx is a small, human-run community. Over the years a lot of accounts sign up +and then go quiet for good. To keep the instance tidy and to shrink the surface +that spammers can hijack, we periodically limit accounts that show **no sign of +life for well over a year** β€” meaning all of: + +- no web login, +- no activity from a mobile app or API client, and +- no posts. + +If you were active by *any* of those, you would not have been caught. If you +were, it just means you've been away for a while β€” welcome back. + +## How to get your account back + +**The reliable way: email [admin@yttrx.com](mailto:admin@yttrx.com)** from, or +mentioning, the address on your account. Include: + +1. **Your full handle** (e.g. `@you@yttrx.com`). +2. A line letting us know you're back and want the limit lifted. + +A real person reads that inbox and will restore you, usually quickly. + +**In-app appeal.** When you log back in, your account settings list this as a +moderation strike with an **Appeal** option β€” submitting it sends your request +straight to our moderators and works just as well. + +**A note on DMs.** You *can* still log in and post while limited, but because a +limited account's mentions are held back from people who don't already follow +you, a direct message to [@waffles](https://yttrx.com/@waffles) may not reach +them reliably until the limit is lifted. **Email is the dependable route** β€” +once you're un-limited, messaging `@waffles` works normally again. + +Thanks for being part of yttrx. We kept your account safe for you. πŸ’œ diff --git a/dormant-sweep/dormant-sweep.env.example b/dormant-sweep/dormant-sweep.env.example new file mode 100644 index 0000000..e4ead69 --- /dev/null +++ b/dormant-sweep/dormant-sweep.env.example @@ -0,0 +1,44 @@ +# Copy to dormant-sweep.env on mammut and tune. No secrets live here β€” the sweep +# runs inside Rails as the bot account, so there is no token to store. + +# Inactivity window, in months. Starting policy: 18 months. +DORMANT_MONTHS=18 + +# SAFETY: start true. Logs what WOULD happen and DMs a [DRY-RUN] mod summary, +# but limits nobody. Flip to false only after reviewing a dry run. +DORMANT_DRY_RUN=true + +# Max accounts to act on per run, so a large backlog drains gradually instead of +# limiting everyone at once. (At launch ~906 accounts qualify at 18mo β€” drain in steps.) +DORMANT_BATCH_CAP=50 + +# Actor account (must hold an Admin role: Manage Users). yttrx uses `bot`. +DORMANT_BOT_ACCT=bot + +# Handle (no @) to DM a per-run batch summary. Empty disables it. +DORMANT_MOD_ALERT=waffles + +# Appeal / reactivation help page (DMed + referenced in the strike note). +DORMANT_HELP_URL=https://welcome.yttrx.com/posts/account-inactive/ + +# Never limited. Includes all current staff as an authoritative backstop on top +# of the automatic "any assigned role = staff" skip. +DORMANT_ALLOWLIST=waffles,tommertron,davis,bot + +# Keep "dormant" honest: also require no API/app token use and no recent post +# within the window. Disabling either widens the net to login-only (NOT advised: +# current_sign_in_at only updates on WEB login, so it flags active app users). +DORMANT_REQUIRE_TOKEN_IDLE=true +DORMANT_REQUIRE_POST_IDLE=true + +# Notification channels. +# EMAIL_NOTIFY -> native Mastodon strike email to the registered address. +# OFF by default: many dormant addresses are dead, and a bounce +# spike from admin@yttrx.com would damage yttrx's own mail +# reputation (Mastodon has no bounce suppression). The strike +# stays appealable in-app without it. If you turn this ON, keep +# DORMANT_BATCH_CAP small so bounces trickle, and watch the +# admin@yttrx.com inbox (bounces return there). +# SEND_DM -> a direct message from the bot (no bounce risk). Primary notice. +DORMANT_EMAIL_NOTIFY=false +DORMANT_SEND_DM=true diff --git a/dormant-sweep/dormant-sweep.sh b/dormant-sweep/dormant-sweep.sh new file mode 100644 index 0000000..3e93f90 --- /dev/null +++ b/dormant-sweep/dormant-sweep.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# dormant-sweep.sh β€” cron entrypoint on mammut. +# +# Pipes dormant_sweep.rb into the Mastodon web container's `rails runner`, +# passing config from an env file. Selection AND actions run inside Rails as the +# `bot` account, so NO API token or secret is needed on the host. +# +# Install (on mammut): /root/dormant-sweep/{dormant-sweep.sh,dormant_sweep.rb,dormant-sweep.env} +# Cron (mammut root): 30 4 * * 0 /root/dormant-sweep/dormant-sweep.sh >> /var/log/dormant-sweep.log 2>&1 +set -eu + +HERE="$(cd "$(dirname "$0")" && pwd)" +ENV_FILE="${DORMANT_ENV_FILE:-$HERE/dormant-sweep.env}" + +# Load KEY=VALUE config and export it so the loop below can forward it. +if [ -f "$ENV_FILE" ]; then + set -a + . "$ENV_FILE" + set +a +fi + +CID="$(docker ps -f name=live-web-1 -q | head -n1)" +[ -n "$CID" ] || { echo "dormant-sweep: live-web-1 container not found" >&2; exit 1; } + +# Forward only DORMANT_* vars into the container. +DOCKER_ENV="" +for v in $(env | sed -n 's/^\(DORMANT_[A-Z_]*\)=.*/\1/p'); do + DOCKER_ENV="$DOCKER_ENV -e $v" +done + +# shellcheck disable=SC2086 +docker exec -i $DOCKER_ENV "$CID" bin/rails runner - < "$HERE/dormant_sweep.rb" diff --git a/dormant-sweep/dormant_sweep.rb b/dormant-sweep/dormant_sweep.rb new file mode 100644 index 0000000..76ba1b4 --- /dev/null +++ b/dormant-sweep/dormant_sweep.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true +# +# dormant_sweep.rb β€” proactively put long-dormant local yttrx accounts into a +# reversible "limited" (silenced) state, notify each one (native strike email + +# optional DM), and point them at an appeal/reactivation page. +# +# Runs INSIDE the Mastodon web container on mammut, as the `bot` account: +# bin/rails runner - < dormant_sweep.rb (config comes from ENV) +# Normally invoked by dormant-sweep.sh from cron. See README.md. +# +# --------------------------------------------------------------------------- +# "Dormant" = ALL of these are older than the cutoff (default 18 months): +# * web/Devise login (users.current_sign_in_at; a NULL login counts as +# dormant only if the account was created before cutoff) +# * API/app token use (Doorkeeper::AccessToken.last_used_at) +# * last authored post (account_stats.last_status_at) +# +# Requiring all three avoids limiting people who are active through a mobile +# app (stale web login but a live OAuth token) or who lurk-and-post without ever +# web-logging-in. current_sign_in_at ALONE is a bad signal: it only updates on +# web login, so on a real instance it flags ~65% of accounts. +# +# Safety: +# * dry-run by default (DORMANT_DRY_RUN=true) β€” logs what it WOULD do +# * batch-capped per run (DORMANT_BATCH_CAP) so a backlog drains gradually +# * never touches staff (any assigned role), allowlisted handles, or accounts +# that are already suspended/silenced +# * silence is REVERSIBLE and is applied with NO report attached +# --------------------------------------------------------------------------- + +require 'time' +require 'set' + +def env(key, default = nil) + v = ENV[key] + v.nil? || v.strip.empty? ? default : v +end + +def env_bool(key, default) + v = ENV[key] + return default if v.nil? || v.strip.empty? + %w[1 true yes on].include?(v.strip.downcase) +end + +MONTHS = (env('DORMANT_MONTHS', '18')).to_i +DRY_RUN = env_bool('DORMANT_DRY_RUN', true) # SAFE DEFAULT +BATCH_CAP = (env('DORMANT_BATCH_CAP', '50')).to_i # max accounts actioned per run +BOT_ACCT = env('DORMANT_BOT_ACCT', 'bot') # actor; must hold an Admin role +MOD_ALERT = (env('DORMANT_MOD_ALERT', '') || '').sub(/\A@/, '') +HELP_URL = env('DORMANT_HELP_URL', 'https://welcome.yttrx.com/posts/account-inactive/') +ALLOWLIST = (env('DORMANT_ALLOWLIST', 'waffles,tommertron,davis,bot') || '') + .split(',').map { |s| s.strip.sub(/\A@/, '').downcase }.reject(&:empty?).to_set + +# Cross-checks that keep "dormant" honest (leave on unless you really mean it). +REQUIRE_TOKEN_IDLE = env_bool('DORMANT_REQUIRE_TOKEN_IDLE', true) +REQUIRE_POST_IDLE = env_bool('DORMANT_REQUIRE_POST_IDLE', true) + +# Notification channels. +# EMAIL_NOTIFY -> Mastodon's native strike email to the user's registered +# address (and the in-app notification ping). OFF BY DEFAULT: +# dormant addresses are often dead, and a bounce spike from +# admin@yttrx.com would hurt yttrx's own sending reputation +# (Mastodon has no bounce suppression). The strike itself is +# still created and appealable in settings without the email. +# SEND_DM -> a direct message from the bot (a Mastodon DM, no bounce +# risk; seen if they log back in β€” silence doesn't block +# INCOMING DMs). This is the primary, safe notice. +EMAIL_NOTIFY = env_bool('DORMANT_EMAIL_NOTIFY', false) +SEND_DM = env_bool('DORMANT_SEND_DM', true) + +# Moderation note stored on the strike. The "dormant-sweep" tag makes the batch +# findable/reversible later (see README rollback). +NOTE = env('DORMANT_NOTE', + "[dormant-sweep] Auto-limited: no login, app/API use, or posts in " \ + "#{MONTHS}+ months. Reversible, no report attached. Reactivate by emailing " \ + "admin@yttrx.com β€” see #{HELP_URL}") + +# Secondary DM template ({acct} + {help_url} substituted; must mention @{acct}). +DM_TEMPLATE = env('DORMANT_DM', + "Hi @{acct} β€” your yttrx account has been put into a limited state because it " \ + "looks inactive (no login or posts in #{MONTHS}+ months). Nothing is deleted " \ + "and it's easy to undo: email admin@yttrx.com (or use the in-app appeal) and " \ + "we'll lift it. Details: {help_url}") + +def log(msg) + puts "#{Time.now.utc.iso8601} #{msg}" +end + +cutoff = MONTHS.months.ago +actor = Account.find_local(BOT_ACCT) +abort("dormant_sweep: actor account '#{BOT_ACCT}' not found") unless actor + +log("start dry_run=#{DRY_RUN} months=#{MONTHS} cutoff=#{cutoff.iso8601} " \ + "batch_cap=#{BATCH_CAP} email=#{EMAIL_NOTIFY} dm=#{SEND_DM} " \ + "token_idle=#{REQUIRE_TOKEN_IDLE} post_idle=#{REQUIRE_POST_IDLE}") + +# --- selection ------------------------------------------------------------- +# User rows exist only for LOCAL accounts, so no domain filter is needed. +base = User.confirmed.where(approved: true).joins(:account) + .where(accounts: { suspended_at: nil, silenced_at: nil }) +candidates = base.where( + 'users.current_sign_in_at < :c OR (users.current_sign_in_at IS NULL AND users.created_at < :c)', + c: cutoff +) + +# Accounts with API/app token activity since the cutoff are NOT dormant. +active_token_uids = + if REQUIRE_TOKEN_IDLE + Doorkeeper::AccessToken.where(resource_owner_id: candidates.pluck(:id)) + .where('last_used_at > ?', cutoff).distinct.pluck(:resource_owner_id).to_set + else + Set.new + end + +acted = 0 +skipped = Hash.new(0) + +candidates.includes(account: :account_stat).find_each do |user| + break if acted >= BATCH_CAP + account = user.account + handle = account.username.downcase + + # --- guards -------------------------------------------------------------- + if ALLOWLIST.include?(handle) + skipped[:allowlist] += 1; next + end + # Normal users have a NULL role_id; any assigned role means staff. The + # ALLOWLIST above is an authoritative backstop for known staff. + if user.role_id.present? + skipped[:staff] += 1; next + end + if REQUIRE_TOKEN_IDLE && active_token_uids.include?(user.id) + skipped[:token_active] += 1; next + end + if REQUIRE_POST_IDLE + last_status = account.account_stat&.last_status_at + if last_status && last_status > cutoff + skipped[:recent_post] += 1; next + end + end + + last_login = user.current_sign_in_at ? user.current_sign_in_at.strftime('%Y-%m-%d') : 'never' + + if DRY_RUN + log("[DRY-RUN] would limit @#{account.username} (last_login=#{last_login})") + acted += 1 + next + end + + begin + Admin::AccountAction.new( + type: 'silence', + current_account: actor, + target_account: account, + text: NOTE, + send_email_notification: EMAIL_NOTIFY, + include_statuses: false + ).save! + rescue => e + log("ERROR limiting @#{account.username}: #{e.class}: #{e.message}") + next + end + + if SEND_DM + begin + text = DM_TEMPLATE.gsub('{acct}', account.username).gsub('{help_url}', HELP_URL) + PostStatusService.new.call(actor, text: text, visibility: 'direct') + rescue => e + log("WARN limited @#{account.username} but DM failed: #{e.class}: #{e.message}") + end + end + + acted += 1 + log("limited @#{account.username} (last_login=#{last_login})" \ + "#{EMAIL_NOTIFY ? '; emailed' : ''}#{SEND_DM ? '; DMed' : ''}") +end + +log("done dry_run=#{DRY_RUN} acted=#{acted} batch_cap=#{BATCH_CAP} " \ + "skipped=#{skipped.map { |k, v| "#{k}=#{v}" }.join(' ')}") + +# Batch summary to the moderator (best-effort). +if !MOD_ALERT.empty? && acted.positive? + begin + verb = DRY_RUN ? 'would limit' : 'limited' + PostStatusService.new.call(actor, + text: "@#{MOD_ALERT} 🧹 Dormant sweep: #{verb} #{acted} inactive account(s) " \ + "(no login/app/posts in #{MONTHS}+ mo, cap #{BATCH_CAP}/run)" \ + "#{DRY_RUN ? ' [DRY-RUN]' : ''}.", + visibility: 'direct') + rescue => e + log("WARN mod-alert DM failed: #{e.class}: #{e.message}") + end +end diff --git a/nginx/hooks.yttrx.com.conf b/nginx/hooks.yttrx.com.conf new file mode 100644 index 0000000..0f4f5d4 --- /dev/null +++ b/nginx/hooks.yttrx.com.conf @@ -0,0 +1,37 @@ +# /etc/nginx/sites-available/hooks on admin.yttrx.com +# Enable with: ln -s ../sites-available/hooks /etc/nginx/sites-enabled/hooks + +server { + listen 80; + listen [::]:80; + server_name hooks.yttrx.com; + # certbot --nginx will manage the redirect / cert; left here for the + # initial standalone issuance flow described in the README. + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name hooks.yttrx.com; + + ssl_certificate /etc/letsencrypt/live/hooks.yttrx.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/hooks.yttrx.com/privkey.pem; + + # Only the webhook endpoint and health check are exposed. + location = /webhook { + proxy_pass http://127.0.0.1:8087; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /healthz { + proxy_pass http://127.0.0.1:8087; + } + + location / { + return 404; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..584941f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +httpx==0.28.1 +ipwhois==1.3.0 diff --git a/test_local.py b/test_local.py new file mode 100644 index 0000000..8a66b5d --- /dev/null +++ b/test_local.py @@ -0,0 +1,300 @@ +"""Local smoke test: signature verification, payload routing, abuse policy. + +Run after `pip install -r requirements.txt`: + WEBHOOK_SECRET=testsecret BOT_ACCESS_TOKEN=x python test_local.py + +It uses FastAPI's TestClient and monkeypatches every outbound Mastodon call, +so it makes no network requests. +""" + +import hashlib +import hmac +import json +import os +from datetime import datetime, timedelta, timezone + +os.environ.setdefault("WEBHOOK_SECRET", "testsecret") +os.environ.setdefault("BOT_ACCESS_TOKEN", "dummy") +os.environ.setdefault("ABUSE_BOT_TOKEN", "dummy-admin") +os.environ.setdefault("ABUSE_DRY_RUN", "false") +os.environ.setdefault("MOD_ALERT_ACCT", "pete") +os.environ.setdefault("DB_PATH", "/tmp/welcomebot-test.db") + +if os.path.exists(os.environ["DB_PATH"]): + os.remove(os.environ["DB_PATH"]) + +from fastapi.testclient import TestClient # noqa: E402 + +import app.main as main # noqa: E402 + +# Capture welcome calls instead of hitting the network. +sent = [] +main.send_welcome = lambda account_id, acct: sent.append((account_id, acct)) + +# Capture report handling at the route boundary for the routing test. +reports = [] +_real_handle_report = main.handle_report +main.handle_report = lambda *a: reports.append(a) + +client = TestClient(main.app) + + +def sign(body: bytes) -> str: + return "sha256=" + hmac.new(b"testsecret", body, hashlib.sha256).hexdigest() + + +def post(payload, secret_ok=True): + body = json.dumps(payload).encode() + sig = sign(body) if secret_ok else "sha256=deadbeef" + return client.post( + "/webhook", content=body, + headers={"X-Hub-Signature": sig, "Content-Type": "application/json"}, + ) + + +def days_ago(n): + return (datetime.now(timezone.utc) - timedelta(days=n)).isoformat() + + +def routing_tests(): + # 1. health + assert client.get("/healthz").json() == {"ok": True} + + # 2. bad signature rejected + r = post({"event": "account.created", "object": {}}, secret_ok=False) + assert r.status_code == 401, r.status_code + + # 3. missing signature rejected + body = json.dumps({"event": "account.created"}).encode() + assert client.post("/webhook", content=body).status_code == 401 + + # 4. valid account.created -> queued + payload = { + "event": "account.created", + "object": { + "id": "1001", "username": "alice", "domain": None, + "account": {"id": "1001", "username": "alice", "acct": "alice", + "url": "https://yttrx.com/@alice"}, + }, + } + r = post(payload) + assert r.status_code == 200, r.text + assert r.json().get("queued") == "alice", r.json() + assert sent == [("1001", "alice")], sent + + # 5. report.created -> routed to handle_report with extracted fields + r = post({ + "event": "report.created", + "object": { + "id": "55", "category": "spam", + "target_account": { + "id": "777", "username": "spammer", "domain": None, + "suspended": False, "silenced": False, + "account": {"id": "777", "acct": "spammer"}, + }, + }, + }) + assert r.status_code == 200, r.text + assert r.json().get("report") == "55", r.json() + # (report_id, target_id, acct, is_local, suspended, silenced, privileged) + assert reports == [("55", "777", "spammer", True, False, False, False)], reports + + # 5b. report against a staff account -> privileged=True extracted from role + r = post({ + "event": "report.created", + "object": { + "id": "56", + "target_account": { + "id": "778", "username": "waffles", "domain": None, + "suspended": False, "silenced": False, + "role": {"id": 3, "name": "Owner", "permissions": "1"}, + "account": {"id": "778", "acct": "waffles"}, + }, + }, + }) + assert reports[-1] == ("56", "778", "waffles", True, False, False, True), reports[-1] + + # 6. account.approved also welcomes β€” but dedup means alice (id 1001, + # already welcomed via account.created above) is not welcomed twice. + r = post({ + "event": "account.approved", + "object": {"id": "1001", "username": "alice", "domain": None, + "account": {"id": "1001", "acct": "alice"}}, + }) + assert r.status_code == 200, r.text + assert r.json().get("queued") == "alice", r.json() + # send_welcome is stubbed to append; real dedup is exercised in policy run. + # A brand-new account via account.approved should also route to welcome. + r = post({ + "event": "account.approved", + "object": {"id": "2002", "username": "carol", "domain": None, + "account": {"id": "2002", "acct": "carol"}}, + }) + assert r.json().get("queued") == "carol", r.json() + assert ("2002", "carol") in sent, sent + + # 7. truly unknown event ignored + r = post({"event": "status.updated", "object": {"id": "5"}}) + assert r.json().get("ignored") == "status.updated", r.json() + + # 8. remote account skipped when LOCAL_ONLY (welcome) + r = post({ + "event": "account.created", + "object": {"id": "9", "username": "bob", "domain": "other.social", + "account": {"id": "9", "acct": "bob@other.social"}}, + }) + assert r.json().get("skipped"), r.json() + + +def policy_tests(): + """Drive handle_report directly with stubbed Mastodon calls.""" + main.handle_report = _real_handle_report + + actions, dms, user_dms = [], [], [] + main.apply_action = lambda target_id, action, text: actions.append((target_id, action)) + main.dm_moderator = lambda message: dms.append(message) + main.dm_silenced_user = lambda acct: user_dms.append(acct) + + def run(target_id, acct, *, profile, sources, **kw): + main.classify_account = lambda tid: profile + main.count_distinct_reporters = lambda tid: sources + defaults = dict(is_local=True, suspended=False, silenced=False, privileged=False) + defaults.update(kw) + main.handle_report("R", target_id, acct, defaults["is_local"], + defaults["suspended"], defaults["silenced"], + defaults["privileged"]) + + young = {"has_posts": True, "young": True, "dormant": False, + "newest_at": None, "oldest_seen": None, "truncated": False} + active = {"has_posts": True, "young": False, "dormant": False, + "newest_at": None, "oldest_seen": None, "truncated": False} + dormant = {"has_posts": True, "young": False, "dormant": True, + "newest_at": None, "oldest_seen": None, "truncated": False} + noposts = {"has_posts": False, "young": False, "dormant": False, + "newest_at": None, "oldest_seen": None, "truncated": False} + + # young, 1 source -> below threshold (needs 2), no action + run("1", "y1", profile=young, sources=1) + assert actions == [], actions + + # young, 2 sources -> silenced; both the mod and the user are DMed + run("2", "y2", profile=young, sources=2) + assert ("2", "silence") in actions, actions + assert any("y2" in d for d in dms), dms + assert "y2" in user_dms, user_dms + + # dormant, 2 sources -> silenced + run("3", "d1", profile=dormant, sources=2) + assert ("3", "silence") in actions, actions + + # active, 2 sources -> below threshold (needs 3), no action + run("4", "a1", profile=active, sources=2) + assert ("4", "silence") not in actions, actions + + # active, 3 sources -> silenced + run("5", "a2", profile=active, sources=3) + assert ("5", "silence") in actions, actions + + # no-posts, 2 sources -> silenced (grouped with young/dormant) + run("6", "n1", profile=noposts, sources=2) + assert ("6", "silence") in actions, actions + + # already silenced -> skipped even with enough sources + run("7", "s1", profile=young, sources=5, silenced=True) + assert ("7", "silence") not in actions, actions + + # remote -> skipped (ABUSE_LOCAL_ONLY) + run("8", "r1@elsewhere", profile=young, sources=5, is_local=False) + assert ("8", "silence") not in actions, actions + + # privileged (staff role) -> skipped even with plenty of sources + run("9", "waffles", profile=young, sources=9, privileged=True) + assert ("9", "silence") not in actions, actions + + # dedup: re-running an already-actioned target takes no new action + before = len(actions) + run("2", "y2", profile=young, sources=9) + assert len(actions) == before, "expected dedup to suppress repeat action" + + +def ip_scrutiny_tests(): + """Drive process_signup + the abuse threshold override, network stubbed out.""" + sent = [] + dms = [] + ipblocks = [] + main.send_welcome = lambda account_id, acct: sent.append((account_id, acct)) + main.dm_moderator = lambda message: dms.append(message) + main.register_ip_block = lambda ip, acct, org: ipblocks.append((ip, acct, org)) + + def classify(ip): + return classifications[ip] + + classifications = { + "203.0.113.10": ("residential", "Example Residential ISP"), + "198.51.100.20": ("datacenter", "Example Cloud Hosting Inc"), + "198.51.100.21": ("datacenter", "Example Cloud Hosting Inc"), + } + main.classify_signup_ip = classify + + main.IP_SCRUTINY_ENABLED = True + + # A. residential IP -> welcomed immediately, no ip-block, no flag recorded. + main.IP_SCRUTINY_DRY_RUN = False + main.IP_SCRUTINY_HOLD_WELCOME = True + main.IP_SCRUTINY_AUTO_IPBLOCK = True + main.process_signup("101", "res1", "203.0.113.10") + assert ("101", "res1") in sent, sent + assert ipblocks == [], ipblocks + assert main.get_signup_flag("101") is False, "residential signup should not be flagged" + + # B. datacenter IP, HOLD_WELCOME + AUTO_IPBLOCK, live (not dry-run) -> + # welcome held, ip-block registered, moderator DM'd; account.approved + # later releases the welcome. + sent.clear(); dms.clear(); ipblocks.clear() + main.process_signup("102", "dc1", "198.51.100.20") + assert ("102", "dc1") not in sent, "welcome should be held for a flagged signup" + assert ipblocks == [("198.51.100.20", "dc1", "Example Cloud Hosting Inc")], ipblocks + assert any("dc1" in d and "held" in d for d in dms), dms + assert main.get_signup_flag("102") is True + + main.send_welcome("102", "dc1") # simulate account.approved's unconditional welcome + assert ("102", "dc1") in sent, sent + + # C. same datacenter IP, but IP_SCRUTINY_DRY_RUN -> welcomed immediately + # (no hold), no ip-block write, DM carries the dry-run prefix. + sent.clear(); dms.clear(); ipblocks.clear() + main.IP_SCRUTINY_DRY_RUN = True + main.process_signup("103", "dc2", "198.51.100.21") + assert ("103", "dc2") in sent, "dry-run must not hold the welcome" + assert ipblocks == [], "dry-run must not write an ip_block" + assert any(d.startswith("[DRY-RUN]") for d in dms), dms + + # D. abuse threshold override: a flagged account needs only + # IP_SCRUTINY_ABUSE_THRESHOLD (1) distinct reporter to silence, vs. the + # normal young-tier threshold of 2 for an unflagged account. + main.IP_SCRUTINY_DRY_RUN = False + main.dm_moderator = lambda message: dms.append(message) + main.handle_report = _real_handle_report + actions = [] + main.apply_action = lambda target_id, action, text: actions.append((target_id, action)) + main.dm_silenced_user = lambda acct: None + young = {"has_posts": True, "young": True, "dormant": False, + "newest_at": None, "oldest_seen": None, "truncated": False} + main.classify_account = lambda tid: young + main.count_distinct_reporters = lambda tid: 1 + + # "102" (dc1) was flagged above -> 1 reporter is enough. + main.handle_report("R2", "102", "dc1", True, False, False, False) + assert ("102", "silence") in actions, actions + + # "101" (res1) was not flagged -> 1 reporter stays below the tier's usual + # threshold of 2. + main.handle_report("R3", "101", "res1", True, False, False, False) + assert ("101", "silence") not in actions, actions + + +if __name__ == "__main__": + routing_tests() + policy_tests() + ip_scrutiny_tests() + print("ALL TESTS PASSED")