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