Initial commit: welcome-bot, abuse-bot, dormant-sweep, IP signup scrutiny
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.example
|
||||
*.db
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
README.md
|
||||
nginx/
|
||||
+119
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
data/
|
||||
.venv/
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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}
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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.
|
||||
@@ -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. 💜
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
httpx==0.28.1
|
||||
ipwhois==1.3.0
|
||||
+300
@@ -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")
|
||||
Reference in New Issue
Block a user