# 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