Files

194 lines
7.7 KiB
Ruby

# 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