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.
+53
View File
@@ -0,0 +1,53 @@
---
title: "Why was my account limited for inactivity?"
date: 2026-06-18
description: "yttrx limits accounts that have been inactive for a long time. Here's what that means and how to get yours back."
---
If you've landed here, your yttrx account was **automatically limited because it
looked inactive** — no logins, app activity, or posts for well over a year.
This is routine housekeeping, **not** a punishment, and **nothing has been
deleted**. Getting it back takes one short email.
## What "limited" means
A limited (also called *silenced*) account is **not deleted and not banned**.
Your posts, follows, and data are all still there. While the limit is in place,
your account is just held back from public and federated timelines so a parked,
forgotten account can't quietly be used for spam. It's fully reversible.
## Why it happens
yttrx is a small, human-run community. Over the years a lot of accounts sign up
and then go quiet for good. To keep the instance tidy and to shrink the surface
that spammers can hijack, we periodically limit accounts that show **no sign of
life for well over a year** — meaning all of:
- no web login,
- no activity from a mobile app or API client, and
- no posts.
If you were active by *any* of those, you would not have been caught. If you
were, it just means you've been away for a while — welcome back.
## How to get your account back
**The reliable way: email [admin@yttrx.com](mailto:admin@yttrx.com)** from, or
mentioning, the address on your account. Include:
1. **Your full handle** (e.g. `@you@yttrx.com`).
2. A line letting us know you're back and want the limit lifted.
A real person reads that inbox and will restore you, usually quickly.
**In-app appeal.** When you log back in, your account settings list this as a
moderation strike with an **Appeal** option — submitting it sends your request
straight to our moderators and works just as well.
**A note on DMs.** You *can* still log in and post while limited, but because a
limited account's mentions are held back from people who don't already follow
you, a direct message to [@waffles](https://yttrx.com/@waffles) may not reach
them reliably until the limit is lifted. **Email is the dependable route**
once you're un-limited, messaging `@waffles` works normally again.
Thanks for being part of yttrx. We kept your account safe for you. 💜
+44
View File
@@ -0,0 +1,44 @@
# Copy to dormant-sweep.env on mammut and tune. No secrets live here — the sweep
# runs inside Rails as the bot account, so there is no token to store.
# Inactivity window, in months. Starting policy: 18 months.
DORMANT_MONTHS=18
# SAFETY: start true. Logs what WOULD happen and DMs a [DRY-RUN] mod summary,
# but limits nobody. Flip to false only after reviewing a dry run.
DORMANT_DRY_RUN=true
# Max accounts to act on per run, so a large backlog drains gradually instead of
# limiting everyone at once. (At launch ~906 accounts qualify at 18mo — drain in steps.)
DORMANT_BATCH_CAP=50
# Actor account (must hold an Admin role: Manage Users). yttrx uses `bot`.
DORMANT_BOT_ACCT=bot
# Handle (no @) to DM a per-run batch summary. Empty disables it.
DORMANT_MOD_ALERT=waffles
# Appeal / reactivation help page (DMed + referenced in the strike note).
DORMANT_HELP_URL=https://welcome.yttrx.com/posts/account-inactive/
# Never limited. Includes all current staff as an authoritative backstop on top
# of the automatic "any assigned role = staff" skip.
DORMANT_ALLOWLIST=waffles,tommertron,davis,bot
# Keep "dormant" honest: also require no API/app token use and no recent post
# within the window. Disabling either widens the net to login-only (NOT advised:
# current_sign_in_at only updates on WEB login, so it flags active app users).
DORMANT_REQUIRE_TOKEN_IDLE=true
DORMANT_REQUIRE_POST_IDLE=true
# Notification channels.
# EMAIL_NOTIFY -> native Mastodon strike email to the registered address.
# OFF by default: many dormant addresses are dead, and a bounce
# spike from admin@yttrx.com would damage yttrx's own mail
# reputation (Mastodon has no bounce suppression). The strike
# stays appealable in-app without it. If you turn this ON, keep
# DORMANT_BATCH_CAP small so bounces trickle, and watch the
# admin@yttrx.com inbox (bounces return there).
# SEND_DM -> a direct message from the bot (no bounce risk). Primary notice.
DORMANT_EMAIL_NOTIFY=false
DORMANT_SEND_DM=true
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
# dormant-sweep.sh — cron entrypoint on mammut.
#
# Pipes dormant_sweep.rb into the Mastodon web container's `rails runner`,
# passing config from an env file. Selection AND actions run inside Rails as the
# `bot` account, so NO API token or secret is needed on the host.
#
# Install (on mammut): /root/dormant-sweep/{dormant-sweep.sh,dormant_sweep.rb,dormant-sweep.env}
# Cron (mammut root): 30 4 * * 0 /root/dormant-sweep/dormant-sweep.sh >> /var/log/dormant-sweep.log 2>&1
set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="${DORMANT_ENV_FILE:-$HERE/dormant-sweep.env}"
# Load KEY=VALUE config and export it so the loop below can forward it.
if [ -f "$ENV_FILE" ]; then
set -a
. "$ENV_FILE"
set +a
fi
CID="$(docker ps -f name=live-web-1 -q | head -n1)"
[ -n "$CID" ] || { echo "dormant-sweep: live-web-1 container not found" >&2; exit 1; }
# Forward only DORMANT_* vars into the container.
DOCKER_ENV=""
for v in $(env | sed -n 's/^\(DORMANT_[A-Z_]*\)=.*/\1/p'); do
DOCKER_ENV="$DOCKER_ENV -e $v"
done
# shellcheck disable=SC2086
docker exec -i $DOCKER_ENV "$CID" bin/rails runner - < "$HERE/dormant_sweep.rb"
+193
View File
@@ -0,0 +1,193 @@
# frozen_string_literal: true
#
# dormant_sweep.rb — proactively put long-dormant local yttrx accounts into a
# reversible "limited" (silenced) state, notify each one (native strike email +
# optional DM), and point them at an appeal/reactivation page.
#
# Runs INSIDE the Mastodon web container on mammut, as the `bot` account:
# bin/rails runner - < dormant_sweep.rb (config comes from ENV)
# Normally invoked by dormant-sweep.sh from cron. See README.md.
#
# ---------------------------------------------------------------------------
# "Dormant" = ALL of these are older than the cutoff (default 18 months):
# * web/Devise login (users.current_sign_in_at; a NULL login counts as
# dormant only if the account was created before cutoff)
# * API/app token use (Doorkeeper::AccessToken.last_used_at)
# * last authored post (account_stats.last_status_at)
#
# Requiring all three avoids limiting people who are active through a mobile
# app (stale web login but a live OAuth token) or who lurk-and-post without ever
# web-logging-in. current_sign_in_at ALONE is a bad signal: it only updates on
# web login, so on a real instance it flags ~65% of accounts.
#
# Safety:
# * dry-run by default (DORMANT_DRY_RUN=true) — logs what it WOULD do
# * batch-capped per run (DORMANT_BATCH_CAP) so a backlog drains gradually
# * never touches staff (any assigned role), allowlisted handles, or accounts
# that are already suspended/silenced
# * silence is REVERSIBLE and is applied with NO report attached
# ---------------------------------------------------------------------------
require 'time'
require 'set'
def env(key, default = nil)
v = ENV[key]
v.nil? || v.strip.empty? ? default : v
end
def env_bool(key, default)
v = ENV[key]
return default if v.nil? || v.strip.empty?
%w[1 true yes on].include?(v.strip.downcase)
end
MONTHS = (env('DORMANT_MONTHS', '18')).to_i
DRY_RUN = env_bool('DORMANT_DRY_RUN', true) # SAFE DEFAULT
BATCH_CAP = (env('DORMANT_BATCH_CAP', '50')).to_i # max accounts actioned per run
BOT_ACCT = env('DORMANT_BOT_ACCT', 'bot') # actor; must hold an Admin role
MOD_ALERT = (env('DORMANT_MOD_ALERT', '') || '').sub(/\A@/, '')
HELP_URL = env('DORMANT_HELP_URL', 'https://welcome.yttrx.com/posts/account-inactive/')
ALLOWLIST = (env('DORMANT_ALLOWLIST', 'waffles,tommertron,davis,bot') || '')
.split(',').map { |s| s.strip.sub(/\A@/, '').downcase }.reject(&:empty?).to_set
# Cross-checks that keep "dormant" honest (leave on unless you really mean it).
REQUIRE_TOKEN_IDLE = env_bool('DORMANT_REQUIRE_TOKEN_IDLE', true)
REQUIRE_POST_IDLE = env_bool('DORMANT_REQUIRE_POST_IDLE', true)
# Notification channels.
# EMAIL_NOTIFY -> Mastodon's native strike email to the user's registered
# address (and the in-app notification ping). OFF BY DEFAULT:
# dormant addresses are often dead, and a bounce spike from
# admin@yttrx.com would hurt yttrx's own sending reputation
# (Mastodon has no bounce suppression). The strike itself is
# still created and appealable in settings without the email.
# SEND_DM -> a direct message from the bot (a Mastodon DM, no bounce
# risk; seen if they log back in — silence doesn't block
# INCOMING DMs). This is the primary, safe notice.
EMAIL_NOTIFY = env_bool('DORMANT_EMAIL_NOTIFY', false)
SEND_DM = env_bool('DORMANT_SEND_DM', true)
# Moderation note stored on the strike. The "dormant-sweep" tag makes the batch
# findable/reversible later (see README rollback).
NOTE = env('DORMANT_NOTE',
"[dormant-sweep] Auto-limited: no login, app/API use, or posts in " \
"#{MONTHS}+ months. Reversible, no report attached. Reactivate by emailing " \
"admin@yttrx.com — see #{HELP_URL}")
# Secondary DM template ({acct} + {help_url} substituted; must mention @{acct}).
DM_TEMPLATE = env('DORMANT_DM',
"Hi @{acct} — your yttrx account has been put into a limited state because it " \
"looks inactive (no login or posts in #{MONTHS}+ months). Nothing is deleted " \
"and it's easy to undo: email admin@yttrx.com (or use the in-app appeal) and " \
"we'll lift it. Details: {help_url}")
def log(msg)
puts "#{Time.now.utc.iso8601} #{msg}"
end
cutoff = MONTHS.months.ago
actor = Account.find_local(BOT_ACCT)
abort("dormant_sweep: actor account '#{BOT_ACCT}' not found") unless actor
log("start dry_run=#{DRY_RUN} months=#{MONTHS} cutoff=#{cutoff.iso8601} " \
"batch_cap=#{BATCH_CAP} email=#{EMAIL_NOTIFY} dm=#{SEND_DM} " \
"token_idle=#{REQUIRE_TOKEN_IDLE} post_idle=#{REQUIRE_POST_IDLE}")
# --- selection -------------------------------------------------------------
# User rows exist only for LOCAL accounts, so no domain filter is needed.
base = User.confirmed.where(approved: true).joins(:account)
.where(accounts: { suspended_at: nil, silenced_at: nil })
candidates = base.where(
'users.current_sign_in_at < :c OR (users.current_sign_in_at IS NULL AND users.created_at < :c)',
c: cutoff
)
# Accounts with API/app token activity since the cutoff are NOT dormant.
active_token_uids =
if REQUIRE_TOKEN_IDLE
Doorkeeper::AccessToken.where(resource_owner_id: candidates.pluck(:id))
.where('last_used_at > ?', cutoff).distinct.pluck(:resource_owner_id).to_set
else
Set.new
end
acted = 0
skipped = Hash.new(0)
candidates.includes(account: :account_stat).find_each do |user|
break if acted >= BATCH_CAP
account = user.account
handle = account.username.downcase
# --- guards --------------------------------------------------------------
if ALLOWLIST.include?(handle)
skipped[:allowlist] += 1; next
end
# Normal users have a NULL role_id; any assigned role means staff. The
# ALLOWLIST above is an authoritative backstop for known staff.
if user.role_id.present?
skipped[:staff] += 1; next
end
if REQUIRE_TOKEN_IDLE && active_token_uids.include?(user.id)
skipped[:token_active] += 1; next
end
if REQUIRE_POST_IDLE
last_status = account.account_stat&.last_status_at
if last_status && last_status > cutoff
skipped[:recent_post] += 1; next
end
end
last_login = user.current_sign_in_at ? user.current_sign_in_at.strftime('%Y-%m-%d') : 'never'
if DRY_RUN
log("[DRY-RUN] would limit @#{account.username} (last_login=#{last_login})")
acted += 1
next
end
begin
Admin::AccountAction.new(
type: 'silence',
current_account: actor,
target_account: account,
text: NOTE,
send_email_notification: EMAIL_NOTIFY,
include_statuses: false
).save!
rescue => e
log("ERROR limiting @#{account.username}: #{e.class}: #{e.message}")
next
end
if SEND_DM
begin
text = DM_TEMPLATE.gsub('{acct}', account.username).gsub('{help_url}', HELP_URL)
PostStatusService.new.call(actor, text: text, visibility: 'direct')
rescue => e
log("WARN limited @#{account.username} but DM failed: #{e.class}: #{e.message}")
end
end
acted += 1
log("limited @#{account.username} (last_login=#{last_login})" \
"#{EMAIL_NOTIFY ? '; emailed' : ''}#{SEND_DM ? '; DMed' : ''}")
end
log("done dry_run=#{DRY_RUN} acted=#{acted} batch_cap=#{BATCH_CAP} " \
"skipped=#{skipped.map { |k, v| "#{k}=#{v}" }.join(' ')}")
# Batch summary to the moderator (best-effort).
if !MOD_ALERT.empty? && acted.positive?
begin
verb = DRY_RUN ? 'would limit' : 'limited'
PostStatusService.new.call(actor,
text: "@#{MOD_ALERT} 🧹 Dormant sweep: #{verb} #{acted} inactive account(s) " \
"(no login/app/posts in #{MONTHS}+ mo, cap #{BATCH_CAP}/run)" \
"#{DRY_RUN ? ' [DRY-RUN]' : ''}.",
visibility: 'direct')
rescue => e
log("WARN mod-alert DM failed: #{e.class}: #{e.message}")
end
end