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

This commit is contained in:
2026-07-02 16:22:30 -07:00
commit c0e5df3cc4
17 changed files with 2231 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
# dormant-sweep
A **scheduled** sweep that reversibly limits (silences) long-dormant local yttrx
accounts and notifies each one with an appeal/reactivation path. This is the
proactive counterpart to the report-driven `abuse-bot` in this repo — roadmap
item **A** in secondbrain `yttrx-documentation/anti-abuse.md`.
Unlike the rest of the welcomebot (which runs on **admin** over the HTTP API),
this sweep runs **on mammut, inside the Mastodon `live-web-1` container**,
because the "last login" signal (`users.current_sign_in_at`) only exists in the
database — the admin REST API does not expose it. Running inside Rails also
means it acts as the `bot` account directly, so **there is no API token or
secret to manage on the host.**
## What counts as "dormant"
ALL of the following must be older than the cutoff (default **18 months**):
| Signal | Source | Why |
|---|---|---|
| web login | `users.current_sign_in_at` | the headline "hasn't logged in" signal (NULL = never logged in; only counts if the account predates the cutoff) |
| app / API token use | `Doorkeeper::AccessToken.last_used_at` | so we don't limit people active via a mobile app (stale web login, live token) |
| last post | `account_stats.last_status_at` | so we don't limit lurkers who post without web-logging-in |
> **Why all three?** `current_sign_in_at` alone is a trap: it only updates on
> *web* login, so at a 6-month cutoff it flagged **1004 / 1548** accounts (~65%)
> on the live instance. The three-signal definition flagged the same 1004 (the
> instance is just very quiet), but it will not produce false positives as app
> usage grows. Starting policy is an **18-month** cutoff → **906** candidates.
Always excluded: staff (any assigned role), `DORMANT_ALLOWLIST` handles, and
accounts already suspended or silenced.
## Notifications
**DM only by default. We do NOT email dormant users.** Blasting a native strike
email to ~900 years-stale addresses would bounce heavily, and a bounce spike from
`admin@yttrx.com` (which has no Mastodon-side suppression) would damage yttrx's
own outbound mail reputation — the same channel signup confirmations use. So:
- **Bot DM** (`DORMANT_SEND_DM=true`, default) — the primary, bounce-free notice.
A Mastodon DM from the bot; seen if they log back in. (Silence blocks
*outgoing* reach, not incoming, so the DM is delivered fine.)
- **Strike record** — every action still creates an `AccountWarning` (strike)
that is **appealable in the user's account settings**, independent of email.
- **Native strike email** (`DORMANT_EMAIL_NOTIFY=false`, default) — OFF.
Turning it on emails the registered address AND fires the in-app notification
ping (both are gated behind the same `warnable?` check). Only enable with a
small `DORMANT_BATCH_CAP` so bounces trickle, and watch the `admin@yttrx.com`
inbox (bounces return there; Mastodon won't auto-suppress).
- **Mod summary** — a per-run batch summary DM to `DORMANT_MOD_ALERT`.
The user-facing reactivation path is **email admin@yttrx.com** (and/or the native
in-app appeal on the strike) — not "DM @waffles", because a silenced account's
mentions to non-followers are filtered and unreliable until the limit is lifted.
The help page (`account-inactive.md`) explains this.
## Files
| File | Role |
|---|---|
| `dormant_sweep.rb` | the sweep itself; run via `rails runner -` inside `live-web-1` |
| `dormant-sweep.sh` | host cron wrapper (pipes the rb into the container) |
| `dormant-sweep.env.example` | config template → copy to `dormant-sweep.env` on mammut |
| `account-inactive.md` | Hugo content for `welcome.yttrx.com/posts/account-inactive/` |
## Config
All via env (see `dormant-sweep.env.example`). Key knobs:
`DORMANT_MONTHS` (**18**), `DORMANT_DRY_RUN` (**true** by default),
`DORMANT_BATCH_CAP` (50/run), `DORMANT_ALLOWLIST`,
`DORMANT_EMAIL_NOTIFY` (**false** — DM only), `DORMANT_SEND_DM`,
`DORMANT_MOD_ALERT`.
## Deploy (workstation → mammut)
```bash
# 1. Copy the runner + wrapper + config to mammut
ssh mammut 'mkdir -p /root/dormant-sweep'
scp dormant_sweep.rb dormant-sweep.sh dormant-sweep.env.example mammut:/root/dormant-sweep/
ssh mammut 'cd /root/dormant-sweep && cp -n dormant-sweep.env.example dormant-sweep.env && \
chmod +x dormant-sweep.sh'
# 2. Edit /root/dormant-sweep/dormant-sweep.env on mammut (keep DORMANT_DRY_RUN=true)
# 3. DRY RUN — review what it would do (cap raised for a full preview)
ssh mammut 'DORMANT_BATCH_CAP=2000 /root/dormant-sweep/dormant-sweep.sh' | tee /tmp/dormant-dryrun.log
# 4. When happy, go live in controlled batches:
# set DORMANT_DRY_RUN=false in the env, then either run by hand a few times
# or let cron drain it at DORMANT_BATCH_CAP per run.
ssh mammut 'crontab -l; echo "30 4 * * 0 /root/dormant-sweep/dormant-sweep.sh >> /var/log/dormant-sweep.log 2>&1" | crontab -'
```
### Deploy the help page (welcome.yttrx.com, on admin)
```bash
scp account-inactive.md admin.yttrx.com:/var/www/html/welcome/content/posts/
ssh admin.yttrx.com 'cd /var/www/html/welcome && ./build.sh && \
git add -A && git commit -m "welcome: add account-inactive (dormant) page"'
```
## Initial backlog (~906 accounts at the 18-month cutoff)
This is a large one-time batch. Recommended rollout:
1. Dry-run (step 3) and **read the list** — confirm no surprises.
2. Set `DORMANT_DRY_RUN=false`, keep `DORMANT_BATCH_CAP` modest (e.g. 50100).
3. Run a few batches by hand, spot-check the strike emails / appeals queue, then
let cron drain the rest. New accounts crossing 6mo afterward are a trickle.
## Rollback / un-limit
Every action is logged: `limited @handle ...` lines in `/var/log/dormant-sweep.log`,
and the strike note is tagged `[dormant-sweep]`. To lift the whole batch:
```bash
# from the log (exact set this script limited)
ssh mammut 'grep -oP "(?<=limited @)\S+" /var/log/dormant-sweep.log | sort -u' > /tmp/swept.txt
ssh mammut 'docker exec -i $(docker ps -f name=live-web-1 -q) bin/rails runner "
STDIN.read.split.each { |u| a = Account.find_local(u); a.unsilence! if a&.silenced? }
"' < /tmp/swept.txt
# or by the strike tag, regardless of the log:
ssh mammut 'docker exec $(docker ps -f name=live-web-1 -q) bin/rails runner "
AccountWarning.where(\"text LIKE ?\", \"%[dormant-sweep]%\").find_each do |w|
w.target_account.unsilence! if w.target_account&.silenced?
end
"'
```
A single account: in the admin UI (Moderation → the account → Undo limit), or
`Account.find_local(\"handle\").unsilence!`.
## Notes / gotchas
- Runs as `bot` (Admin). `Admin::AccountAction(type: 'silence')` is the exact
call the abuse-bot makes via the API — same reversible, no-`report_id`
behaviour, just invoked in-process.
- mammut uses the **`docker compose` plugin**; `tootctl`/`rails` run inside
`live-web-1` (`docker exec $(docker ps -f name=live-web-1 -q) ...`).
- Verified against Mastodon **4.6.0** (2026-06-18): `Admin::AccountAction`,
`PostStatusService`, `User.confirmed`, and the three signal columns all exist.