194 lines
7.7 KiB
Ruby
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
|