Initial commit: welcome-bot, abuse-bot, dormant-sweep, IP signup scrutiny

This commit is contained in:
2026-07-02 16:22:30 -07:00
commit c0e5df3cc4
17 changed files with 2231 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.gitignore
.env
.env.example
*.db
data/
__pycache__/
*.pyc
.venv/
README.md
nginx/
+119
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
.env
__pycache__/
*.pyc
*.db
data/
.venv/
+176
View File
@@ -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(<id>).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`).
+16
View File
@@ -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"]
+256
View File
@@ -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
```
+757
View File
@@ -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=<hex hmac-sha256 of the raw body, keyed by the
webhook's secret>
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}
+68
View File
@@ -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 <<EOF
Usage: welcomebot-logs [options]
Print logs for the '$CONTAINER' container (yttrx welcome + abuse bot).
Options:
-f, --follow Stream new log lines (like tail -f); Ctrl-C to stop.
-n, --tail N Show the last N lines (default: $tail_n; 'all' for everything).
--since WHEN Only logs newer than WHEN (e.g. 1h, 30m, 2026-06-17, 2026-06-17T20:00).
--abuse Only abuse-handler lines (reports, silences, dry-run, tiers).
--welcome Only welcome lines.
-g, --grep PATTERN Only lines matching PATTERN (extended regex).
-h, --help Show this help.
Examples:
welcomebot-logs # last $tail_n lines, then exit
welcomebot-logs -f --abuse # follow live abuse activity only
welcomebot-logs -n 1000 --since 24h
welcomebot-logs -g 'acct=spammer'
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
-f|--follow) follow=1 ;;
-n|--tail) shift; tail_n="${1:?--tail needs a value}" ;;
--since) shift; since="${1:?--since needs a value}" ;;
--abuse) pattern='welcomebot (report |auto-|\[DRY-RUN\]|tier=|silenc|holds a staff|allowlisted)' ;;
--welcome) pattern='welcomebot (welcomed|already welcomed|skipping non-local|queued)' ;;
-g|--grep) shift; pattern="${1:?--grep needs a value}" ;;
-h|--help) usage; exit 0 ;;
*) echo "welcomebot-logs: unknown option: $1" >&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
+17
View File
@@ -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:
+142
View File
@@ -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. 50100).
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.
+53
View File
@@ -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. 💜
+44
View File
@@ -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
+32
View File
@@ -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"
+193
View File
@@ -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
+37
View File
@@ -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;
}
}
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
httpx==0.28.1
ipwhois==1.3.0
+300
View File
@@ -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")