Initial commit: welcome-bot, abuse-bot, dormant-sweep, IP signup scrutiny
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user