about summary refs log tree commit diff
path: root/app/lib/command_tag
diff options
context:
space:
mode:
Diffstat (limited to 'app/lib/command_tag')
-rw-r--r--app/lib/command_tag/commands.rb10
-rw-r--r--app/lib/command_tag/commands/account_tools.rb37
-rw-r--r--app/lib/command_tag/commands/footer_tools.rb50
-rw-r--r--app/lib/command_tag/commands/hello_world.rb11
-rw-r--r--app/lib/command_tag/commands/parent_status_tools.rb80
-rw-r--r--app/lib/command_tag/commands/status_tools.rb239
-rw-r--r--app/lib/command_tag/commands/text_tools.rb89
-rw-r--r--app/lib/command_tag/commands/variables.rb40
-rw-r--r--app/lib/command_tag/processor.rb343
9 files changed, 899 insertions, 0 deletions
diff --git a/app/lib/command_tag/commands.rb b/app/lib/command_tag/commands.rb
new file mode 100644
index 000000000..068c8bb66
--- /dev/null
+++ b/app/lib/command_tag/commands.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module CommandTag::Commands
+  def self.included(base)
+    Dir[File.join(__dir__, 'commands', '*.rb')].sort.each do |file|
+      require file
+      base.include(CommandTag::Commands.const_get(File.basename(file).gsub('.rb', '').split('_').map(&:capitalize).join))
+    end
+  end
+end
diff --git a/app/lib/command_tag/commands/account_tools.rb b/app/lib/command_tag/commands/account_tools.rb
new file mode 100644
index 000000000..d1c652dee
--- /dev/null
+++ b/app/lib/command_tag/commands/account_tools.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+module CommandTag::Commands::AccountTools
+  def handle_account_at_start(args)
+    return if args[0].blank?
+
+    case args[0].downcase
+    when 'set'
+      handle_account_set(args[1..-1])
+    end
+  end
+
+  alias handle_acct_at_start handle_account_at_start
+
+  private
+
+  def handle_account_set(args)
+    return if args[0].blank?
+
+    case args[0].downcase
+    when 'v', 'p', 'visibility', 'privacy', 'default-visibility', 'default-privacy'
+      args[1] = read_visibility_from(args[1])
+      return if args[1].blank?
+
+      if args[2].blank?
+        @account.user.settings.default_privacy = args[1]
+      elsif args[1] == 'public'
+        domains = args[2..-1].map { |domain| normalize_domain(domain) unless domain == '*' }.uniq.compact
+        @account.domain_permissions.where(domain: domains, sticky: false).destroy_all if domains.present?
+      elsif args[1] != 'cc'
+        args[2..-1].flat_map(&:split).uniq.each do |domain|
+          domain = normalize_domain(domain) unless domain == '*'
+          @account.domain_permissions.create_or_update(domain: domain, visibility: args[1]) if domain.present?
+        end
+      end
+    end
+  end
+end
diff --git a/app/lib/command_tag/commands/footer_tools.rb b/app/lib/command_tag/commands/footer_tools.rb
new file mode 100644
index 000000000..b7895d3af
--- /dev/null
+++ b/app/lib/command_tag/commands/footer_tools.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+module CommandTag::Commands::FooterTools
+  def handle_999_footertools_startup
+    @status.footer = var('persist:footer:default')[0]
+  end
+
+  def handle_footer_before_save(args)
+    return if args.blank?
+
+    name = normalize(args.shift)
+    return (@status.footer = nil) if read_falsy_from(name)
+
+    var_name = "persist:footer:#{name}"
+    return @status.footer = var(var_name)[0] if args.blank?
+
+    if read_falsy_from(normalize(args[0]))
+      @status.footer = nil if ['default', var(var_name)[0]].include?(name)
+      @vars.delete(var_name)
+      return
+    end
+
+    if name == 'default'
+      name = normalize(args.shift)
+      var_name = "persist:footer:#{name}"
+      @vars[var_name] = [args.join(' ').strip] if args.present?
+      @vars['persist:footer:default'] = var(var_name)
+    elsif %w(default DEFAULT).include?(args[0])
+      @vars['persist:footer:default'] = var(var_name)
+    else
+      @vars[var_name] = [args.join(' ').strip]
+    end
+
+    @status.footer = var(var_name)[0]
+  end
+
+  # Monsterfork v1 familiarity.
+  def handle_i_before_save(args)
+    return if args.blank?
+
+    handle_footer_before_save(args[1..-1]) if %w(am are).include?(normalize(args[0]))
+  end
+
+  alias handle_we_before_save           handle_i_before_save
+  alias handle_signature_before_save    handle_footer_before_save
+  alias handle_signed_before_save       handle_footer_before_save
+  alias handle_sign_before_save         handle_footer_before_save
+  alias handle_sig_before_save          handle_footer_before_save
+  alias handle_am_before_save           handle_footer_before_save
+  alias handle_are_before_save          handle_footer_before_save
+end
diff --git a/app/lib/command_tag/commands/hello_world.rb b/app/lib/command_tag/commands/hello_world.rb
new file mode 100644
index 000000000..cc770ef80
--- /dev/null
+++ b/app/lib/command_tag/commands/hello_world.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module CommandTag::Commands::HelloWorld
+  def handle_helloworld_startup
+    @vars['hello_world'] = ['Hello, world!']
+  end
+
+  def handle_hello_world_with_return(_)
+    'Hello, world!'
+  end
+end
diff --git a/app/lib/command_tag/commands/parent_status_tools.rb b/app/lib/command_tag/commands/parent_status_tools.rb
new file mode 100644
index 000000000..edaa21b6a
--- /dev/null
+++ b/app/lib/command_tag/commands/parent_status_tools.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+module CommandTag::Commands::ParentStatusTools
+  def handle_publish_once_at_end(_)
+    is_blank = status_text_blank?
+    return PublishStatusService.new.call(@status) if @parent.blank? || !is_blank
+    return unless is_blank && author_of_parent? && !@parent.published?
+
+    PublishStatusService.new.call(@parent)
+  end
+
+  alias handle_publish_post_once_at_end                   handle_publish_once_at_end
+  alias handle_publish_roar_once_at_end                   handle_publish_once_at_end
+  alias handle_publish_toot_once_at_end                   handle_publish_once_at_end
+
+  def handle_edit_once_before_save(_)
+    return unless author_of_parent?
+
+    params = @parent.slice(*UpdateStatusService::ALLOWED_ATTRIBUTES).with_indifferent_access.compact
+    params[:text] = @text
+    UpdateStatusService.new.call(@parent, params)
+    destroy_status!
+  end
+
+  alias handle_edit_post_once_before_save                 handle_edit_once_before_save
+  alias handle_edit_roar_once_before_save                 handle_edit_once_before_save
+  alias handle_edit_toot_once_before_save                 handle_edit_once_before_save
+  alias handle_edit_parent_once_before_save               handle_edit_once_before_save
+
+  def handle_mute_once_at_end(_)
+    return if author_of_parent?
+
+    MuteStatusService.new.call(@account, @parent)
+  end
+
+  alias handle_mute_post_once_at_end                      handle_mute_once_at_end
+  alias handle_mute_roar_once_at_end                      handle_mute_once_at_end
+  alias handle_mute_toot_once_at_end                      handle_mute_once_at_end
+  alias handle_mute_parent_once_at_end                    handle_mute_once_at_end
+  alias handle_hide_once_at_end                           handle_mute_once_at_end
+  alias handle_hide_post_once_at_end                      handle_mute_once_at_end
+  alias handle_hide_roar_once_at_end                      handle_mute_once_at_end
+  alias handle_hide_toot_once_at_end                      handle_mute_once_at_end
+  alias handle_hide_parent_once_at_end                    handle_mute_once_at_end
+
+  def handle_unmute_once_at_end(_)
+    return if author_of_parent?
+
+    @account.unmute_status!(@parent)
+  end
+
+  alias handle_unmute_post_once_at_end                    handle_unmute_once_at_end
+  alias handle_unmute_roar_once_at_end                    handle_unmute_once_at_end
+  alias handle_unmute_toot_once_at_end                    handle_unmute_once_at_end
+  alias handle_unmute_parent_once_at_end                  handle_unmute_once_at_end
+  alias handle_unhide_once_at_end                         handle_unmute_once_at_end
+  alias handle_unhide_post_once_at_end                    handle_unmute_once_at_end
+  alias handle_unhide_roar_once_at_end                    handle_unmute_once_at_end
+  alias handle_unhide_toot_once_at_end                    handle_unmute_once_at_end
+  alias handle_unhide_parent_once_at_end                  handle_unmute_once_at_end
+
+  def handle_mute_thread_once_at_end(_)
+    return if author_of_parent?
+
+    MuteConversationService.new.call(@account, @conversation)
+  end
+
+  alias handle_mute_conversation_once_at_end              handle_mute_thread_once_at_end
+  alias handle_hide_thread_once_at_end                    handle_mute_thread_once_at_end
+  alias handle_hide_conversation_once_at_end              handle_mute_thread_once_at_end
+
+  def handle_unmute_thread_once_at_end(_)
+    return if author_of_parent? || @conversation.blank?
+
+    @account.unmute_conversation!(@conversation)
+  end
+
+  alias handle_unmute_conversation_once_at_end            handle_unmute_thread_once_at_end
+  alias handle_unhide_thread_once_at_end                  handle_unmute_thread_once_at_end
+  alias handle_unhide_conversation_once_at_end            handle_unmute_thread_once_at_end
+end
diff --git a/app/lib/command_tag/commands/status_tools.rb b/app/lib/command_tag/commands/status_tools.rb
new file mode 100644
index 000000000..5b9f52234
--- /dev/null
+++ b/app/lib/command_tag/commands/status_tools.rb
@@ -0,0 +1,239 @@
+# frozen_string_literal: true
+module CommandTag::Commands::StatusTools
+  def handle_boost_once_at_start(args)
+    return unless @parent.present? && StatusPolicy.new(@account, @parent).reblog?
+
+    status = ReblogService.new.call(
+      @account, @parent,
+      visibility: @status.visibility,
+      spoiler_text: args.join(' ').presence || @status.spoiler_text
+    )
+  end
+
+  alias handle_reblog_at_start handle_boost_once_at_start
+  alias handle_rb_at_start handle_boost_once_at_start
+  alias handle_rt_at_start handle_boost_once_at_start
+
+  def handle_article_before_save(args)
+    return unless author_of_status? && args.present?
+
+    case args.shift.downcase
+    when 'title', 'name', 't'
+      status.title = args.join(' ')
+    when 'summary', 'abstract', 'cw', 'cn', 's', 'a'
+      @status.title = @status.spoiler_text if @status.title.blank?
+      @status.spoiler_text = args.join(' ')
+    end
+  end
+
+  def handle_title_before_save(args)
+    args.unshift('title')
+    handle_article_before_save(args)
+  end
+
+  def handle_summary_before_save(args)
+    args.unshift('summary')
+    handle_article_before_save(args)
+  end
+
+  alias handle_abstract_before_save handle_summary_before_save
+
+  def handle_visibility_before_save(args)
+    return unless author_of_status? && args[0].present?
+
+    args[0] = read_visibility_from(args[0])
+    return if args[0].blank?
+
+    if args[1].blank?
+      @status.visibility = args[0].to_sym
+    elsif args[0] == @status.visibility.to_s
+      domains = args[1..-1].map { |domain| normalize_domain(domain) unless domain == '*' }.uniq.compact
+      @status.domain_permissions.where(domain: domains).destroy_all if domains.present?
+    elsif args[0] == 'cc'
+      expect_list = false
+      args[1..-1].uniq.each do |target|
+        if expect_list
+          expect_list = false
+          address_to_list(target)
+        elsif %w(list list:).include?(target.downcase)
+          expect_list = true
+        else
+          mention(resolve_mention(target))
+        end
+      end
+    elsif args[0] == 'community'
+      @status.visibility = :public
+      @status.domain_permissions.create_or_update(domain: '*', visibility: :unlisted)
+    else
+      args[1..-1].flat_map(&:split).uniq.each do |domain|
+        domain = normalize_domain(domain) unless domain == '*'
+        @status.domain_permissions.create_or_update(domain: domain, visibility: args[0]) if domain.present?
+      end
+    end
+  end
+
+  alias handle_v_before_save                      handle_visibility_before_save
+  alias handle_p_before_save                      handle_visibility_before_save
+  alias handle_privacy_before_save                handle_visibility_before_save
+
+  def handle_local_only_before_save(args)
+    @status.local_only = args.present? ? read_boolean_from(args[0]) : true
+    @status.originally_local_only = @status.local_only?
+  end
+
+  def handle_federate_before_save(args)
+    @status.local_only = args.present? ? !read_boolean_from(args[0]) : false
+    @status.originally_local_only = @status.local_only?
+  end
+
+  def handle_notify_before_save(args)
+    return if args[0].blank?
+
+    @status.notify = read_boolean_from(args[0])
+  end
+
+  alias handle_notice_before_save handle_notify_before_save
+
+  def handle_tags_before_save(args)
+    return if args.blank?
+
+    cmd = args.shift.downcase
+    args.select! { |tag| tag =~ /\A(#{Tag::HASHTAG_NAME_RE})\z/i }
+
+    case cmd
+    when 'add', 'a', '+'
+      ProcessHashtagsService.new.call(@status, args)
+    when 'del', 'remove', 'rm', 'r', 'd', '-'
+      RemoveHashtagsService.new.call(@status, args)
+    end
+  end
+
+  def handle_tag_before_save(args)
+    args.unshift('add')
+    handle_tags_before_save(args)
+  end
+
+  def handle_untag_before_save(args)
+    args.unshift('del')
+    handle_tags_before_save(args)
+  end
+
+  def handle_delete_before_save(args)
+    unless args
+      RemovalWorker.perform_async(@parent.id, immediate: true) if author_of_parent? && status_text_blank?
+      return
+    end
+
+    args.flat_map(&:split).uniq.each do |id|
+      if id.match?(/\A\d+\z/)
+        object = @account.statuses.find_by(id: id)
+      elsif id.start_with?('https://')
+        begin
+          object = ActivityPub::TagManager.instance.uri_to_resource(id, Status)
+          if object.blank? && ActivityPub::TagManager.instance.local_uri?(id)
+            id = Addressable::URI.parse(id)&.normalized_path&.sub(/\A.*\/([^\/]*)\/*/, '\1')
+            next unless id.present? && id.match?(/\A\d+\z/)
+
+            object = find_status_or_create_stub(id)
+          end
+        rescue Addressable::URI::InvalidURIError
+          next
+        end
+      end
+
+      next if object.blank? || object.account_id != @account.id
+
+      RemovalWorker.perform_async(object.id, immediate: true, unpublished: true)
+    end
+  end
+
+  alias handle_destroy_before_save handle_delete_before_save
+  alias handle_redraft_before_save handle_delete_before_save
+
+  def handle_expires_before_save(args)
+    return if args.blank?
+
+    @status.expires_at = Time.now.utc + to_datetime(args)
+  end
+
+  alias handle_expires_in_before_save handle_expires_before_save
+  alias handle_delete_in_before_save handle_expires_before_save
+  alias handle_unpublish_in_before_save handle_expires_before_save
+
+  def handle_publish_before_save(args)
+    return if args.blank?
+
+    @status.published = false
+    @status.publish_at = Time.now.utc + to_datetime(args)
+  end
+
+  alias handle_publish_in_before_save handle_publish_before_save
+
+  private
+
+  def resolve_mention(mention_text)
+    return unless (match = mention_text.match(Account::MENTION_RE))
+
+    username, domain  = match[1].split('@')
+    domain            = begin
+                          if TagManager.instance.local_domain?(domain)
+                            nil
+                          else
+                            TagManager.instance.normalize_domain(domain)
+                          end
+                        end
+
+    Account.find_remote(username, domain)
+  end
+
+  def mention(target_account)
+    return if target_account.blank? || target_account.mentions.where(status: @status).exists?
+
+    target_account.mentions.create(status: @status, silent: true)
+  end
+
+  def address_to_list(list_name)
+    return if list_name.blank?
+
+    list_accounts = ListAccount.joins(:list).where(lists: { account: @account }).where('LOWER(lists.title) = ?', list_name.mb_chars.downcase).includes(:account).map(&:account)
+    list_accounts.each { |target_account| mention(target_account) }
+  end
+
+  def find_status_or_create_stub(id)
+    status_params = {
+      id: id,
+      account: @account,
+      text: '(Deleted)',
+      local: true,
+      visibility: :public,
+      local_only: false,
+      published: false,
+    }
+    Status.where(id: id).first_or_create(status_params)
+  end
+
+  def to_datetime(args)
+    total = 0.seconds
+    args.reject { |arg| arg.blank? || %w(in at , and).include?(arg) }.in_groups_of(2) { |i, unit| total += to_duration(i.to_i, unit) }
+    total
+  end
+
+  def to_duration(amount, unit)
+    case unit
+    when nil, 's', 'sec', 'secs', 'second', 'seconds'
+      amount.seconds
+    when 'm', 'min', 'mins', 'minute', 'minutes'
+      amount.minutes
+    when 'h', 'hr', 'hrs', 'hour', 'hours'
+      amount.hours
+    when 'd', 'day', 'days'
+      amount.days
+    when 'w', 'wk', 'wks', 'week', 'weeks'
+      amount.weeks
+    when 'mo', 'mos', 'mn', 'mns', 'month', 'months'
+      amount.months
+    when 'y', 'yr', 'yrs', 'year', 'years'
+      amount.years
+    end
+  end
+end
diff --git a/app/lib/command_tag/commands/text_tools.rb b/app/lib/command_tag/commands/text_tools.rb
new file mode 100644
index 000000000..1a0d53480
--- /dev/null
+++ b/app/lib/command_tag/commands/text_tools.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+module CommandTag::Commands::TextTools
+  def handle_code_at_start(args)
+    return if args.count < 2
+
+    name = normalize(args[0])
+    value = args.last.presence || ''
+    @vars[name] = case @status.content_type
+                  when 'text/markdown'
+                    ["```\n#{value}\n```"]
+                  when 'text/html'
+                    ["<pre><code>#{html_encode(value).gsub("\n", '<br/>')}</code></pre>"]
+                  else
+                    ["----------\n#{value}\n----------"]
+                  end
+  end
+
+  def handle_code_with_return(args)
+    return if args.count > 1
+
+    value = args.last.presence || ''
+    case @status.content_type
+    when 'text/markdown'
+      ["```\n#{value}\n```"]
+    when 'text/html'
+      ["<pre><code>#{html_encode(value).gsub("\n", '<br/>')}</code></pre>"]
+    else
+      ["----------\n#{value}\n----------"]
+    end
+  end
+
+  def handle_prepend_before_save(args)
+    args.each { |arg| @text = "#{arg}\n#{text}" }
+  end
+
+  def handle_append_before_save(args)
+    args.each { |arg| @text << "\n#{arg}" }
+  end
+
+  def handle_replace_before_save(args)
+    @text.gsub!(args[0], args[1] || '')
+  end
+
+  alias handle_sub_before_save handle_replace_before_save
+
+  def handle_regex_replace_before_save(args)
+    flags     = normalize(args[2])
+    re_opts   = (flags.include?('i') ? Regexp::IGNORECASE : 0)
+    re_opts  |= (flags.include?('x') ? Regexp::EXTENDED : 0)
+    re_opts  |= (flags.include?('m') ? Regexp::MULTILINE : 0)
+
+    @text.gsub!(Regexp.new(args[0], re_opts), args[1] || '')
+  end
+
+  alias handle_resub_before_save handle_replace_before_save
+  alias handle_regex_sub_before_save handle_replace_before_save
+
+  def handle_keysmash_with_return(args)
+    keyboard = [
+      'asdf', 'jkl;',
+      'gh', "'",
+      'we', 'io',
+      'r', 'u',
+      'cv', 'nm',
+      't', 'x', ',',
+      'q', 'z',
+      'y', 'b',
+      'p', '.',
+      '[', ']'
+    ]
+
+    min_size = [[5, args[1].to_i].max, 100].min
+    max_size = [args[0].to_i, 100].min
+    max_size = 33 unless max_size.positive?
+
+    min_size, max_size = [max_size, min_size] if min_size > max_size
+
+    chunk = rand(min_size..max_size).times.map do
+      keyboard[(keyboard.size * (rand**3)).floor].split('').sample
+    end
+
+    chunk.join
+  end
+
+  def transform_keysmash_template_return(_, args)
+    handle_keysmash_with_return([args[0], args[2]])
+  end
+end
diff --git a/app/lib/command_tag/commands/variables.rb b/app/lib/command_tag/commands/variables.rb
new file mode 100644
index 000000000..36ba0abd3
--- /dev/null
+++ b/app/lib/command_tag/commands/variables.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module CommandTag::Commands::Variables
+  def handle_000_variables_startup
+    @vars.merge!(persistent_vars_from(@account.metadata.fields)) if @account.metadata.present?
+  end
+
+  def handle_999_variables_shutdown
+    @account.metadata.update!(fields: nonpersistent_vars_from(@account.metadata.fields).merge(persistent_vars_from(@vars)))
+  end
+
+  def handle_set_at_start(args)
+    return if args.blank?
+
+    args[0] = normalize(args[0])
+
+    case args.count
+    when 1
+      @vars.delete(args[0])
+    else
+      @vars[args[0]] = args[1..-1]
+    end
+  end
+
+  def do_unset_at_start(args)
+    args.each do |arg|
+      @vars.delete(normalize(arg))
+    end
+  end
+
+  private
+
+  def persistent_vars_from(vars)
+    vars.select { |key, value| key.start_with?('persist:') && value.present? && value.is_a?(Array) }
+  end
+
+  def nonpersistent_vars_from(vars)
+    vars.reject { |key, value| key.start_with?('persist:') || value.blank? }
+  end
+end
diff --git a/app/lib/command_tag/processor.rb b/app/lib/command_tag/processor.rb
new file mode 100644
index 000000000..77be29eba
--- /dev/null
+++ b/app/lib/command_tag/processor.rb
@@ -0,0 +1,343 @@
+# frozen_string_literal: true
+
+#                  .~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.                  #
+###################              Cthulhu Code!              ###################
+#                  `~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~`                  #
+# - Interprets and executes user input.  THIS CAN BE VERY DANGEROUS!          #
+# - Has a high complexity level and needs tests.                              #
+# - May destroy objects passed to it.                                         #
+# - Incurs a high performance penalty.                                        #
+#                                                                             #
+###############################################################################
+
+require_relative 'commands'
+
+class CommandTag::Break < Mastodon::Error
+  def initialize(msg = 'A handler stopped execution.')
+    super
+  end
+end
+
+class CommandTag::Processor
+  include Redisable
+  include ImgProxyHelper
+  include CommandTag::Commands
+
+  MENTIONS_OR_HASHTAGS_RE = /(?:(?:#{Account::MENTION_RE}|#{Tag::HASHTAG_RE})\s*)+/.freeze
+  PARSEABLE_RE = /^\s*(?:#{MENTIONS_OR_HASHTAGS_RE})?#!|%%.+?%%/.freeze
+  STATEMENT_RE = /^\s*#!\s*[^\n]+ (?:start|begin|do)$.*?\n\s*#!\s*(?:end|stop|done)\s*$|^\s*#!\s*.*?\s*$/im.freeze
+  STATEMENT_PARSE_RE = /'([^']*)'|"([^"]*)"|(\S+)|\s+(?:start|begin|do)\s*$\n+(.*)\n\s*#!\s*(?:end|stop|done)\s*\z/im.freeze
+  TEMPLATE_RE = /%%\s*(\S+.*?)\s*%%/.freeze
+  ESCAPE_MAP = {
+    '\n' => "\n",
+    '\r' => "\r",
+    '\t' => "\t",
+    '\\\\' => '\\',
+    '\%' => '%',
+  }.freeze
+
+  def initialize(account, status)
+    @account      = account
+    @status       = status
+    @parent       = status.thread
+    @conversation = status.conversation
+    @text         = status.text
+    @run_once     = Set[]
+    @vars         = { 'statement_uuid' => [nil] }
+    @statements   = {}
+
+    return unless @account.present? && @account.local? && @status.present?
+  end
+
+  def process!
+    reset_status_caches
+    all_handlers!(:startup)
+
+    unless @text.match?(PARSEABLE_RE)
+      process_inline_images!
+      @status.save!
+      return
+    end
+
+    @text = parse_statements_from!(@text, @statements)
+
+    execute_statements(:at_start)
+    execute_statements(:with_return, true)
+    @text = replace_templates(@text)
+    execute_statements(:before_save)
+
+    if status_text_blank?
+      execute_statements(:when_blank)
+
+      unless (@status.published? && !@status.edited.zero?) || @text.present?
+        execute_statements(:before_destroy)
+        @status.update(published: false)
+        @status.destroy
+        execute_statements(:after_destroy)
+      end
+    elsif @status.destroyed?
+      execute_statements(:after_destroy)
+    else
+      @status.text = @text
+      process_inline_images!
+      if @status.save
+        execute_statements(:after_save)
+      else
+        execute_statements(:after_save_fail)
+      end
+    end
+
+    execute_statements(:at_end)
+    all_handlers!(:shutdown)
+  rescue CommandTag::Break
+    nil
+  rescue StandardError
+    @status.update(published: false)
+    @status.destroy
+    raise
+  ensure
+    reset_status_caches
+  end
+
+  private
+
+  def all_handlers!(affix)
+    self.class.instance_methods.grep(/\Ahandle_\w+_#{affix}\z/).sort.each do |name|
+      public_send(name)
+    end
+  end
+
+  # Calls an arbitary public method (if it exists) on a given value and returns the result.
+  def transform_using(name, value, args = [])
+    respond_to?(name) ? public_send(name, value, args) : value
+  end
+
+  # Moves command tags placed after hashtags and mentions to their own line.
+  def prepare_input(text)
+    text.gsub(/\r\n|\n\r|\r/, "\n").gsub(/^\s*(#{MENTIONS_OR_HASHTAGS_RE})#!/, "\\1\n#!")
+  end
+
+  # Translates %%...%% templates.
+  def replace_templates(text)
+    text.gsub(TEMPLATE_RE) do
+      template = unescape_literals(Regexp.last_match(1))
+      next if template.blank?
+      next template[1..-2] if template.match?(/\A'.*'\z/)
+
+      template = template.match?(/\A".*"\z/) ? template[1..-2] : "\#{#{template}}"
+      template.gsub(/#\{\s*(.*?)\s*\}/) do
+        next if Regexp.last_match(1).blank?
+
+        parts     = Regexp.last_match(1).scan(/'([^']*)'|"([^"]*)"|(\S+)/).flatten.compact
+        name      = normalize(parts[0])
+        separator = "\n"
+
+        if parts.count > 2
+          if %w(: by: with: using: sep: separator: delim: delimiter:).include?(parts[-2].downcase)
+            separator = parts[-1]
+            parts = parts[0..-3]
+          elsif !parts[-1].match?(/\A[-+]?[0-9]+\z/)
+            separator = parts[-1]
+            parts.pop
+          end
+        end
+
+        index       = to_integer(parts[1])
+        str_start   = to_integer(parts[2])
+        str_end     = to_integer(parts[3])
+
+        str_start, str_end = [str_end, str_start] if str_start > str_end
+
+        old_value = (['all', '[]'].include?(parts[1]) ? var(name).join(separator) : var(name)[index].to_s)
+        name      = name.gsub(/[^\w_]+/, '_')
+        new_value = transform_using("transform_#{name}_template_return", old_value, [index, str_start, str_end])
+        next new_value if new_value != old_value
+
+        new_value = transform_using("transform_#{name}_template_value", new_value, [index, str_start, str_end])
+        (str_end - str_start).zero? ? new_value : new_value[str_start..str_end]
+      end
+    end.rstrip
+  end
+
+  # Parses statements from text and merges them into statement queues.
+  # Mutates statement queues hash!
+  def parse_statements_from!(text, statement_queues)
+    @run_once.clear
+
+    text = prepare_input(text)
+    text.gsub!(STATEMENT_RE) do
+      statement = unescape_literals(Regexp.last_match(0).strip[2..-1])
+      next if statement.blank?
+
+      statement_array = statement.scan(STATEMENT_PARSE_RE).flatten.compact.map { |arg| arg.gsub('\#!', '#!') }
+      statement_array[0] = statement_array[0].strip.tr(':.\- ', '_').gsub(/__+/, '_').downcase
+      next unless statement_array[0].match?(/\A[\w_]+\z/)
+
+      statement_array[-1].rstrip! if statement_array.count > 1
+      add_statement_handlers_for!(statement_array, statement_queues)
+    end
+
+    @run_once.clear
+    text
+  end
+
+  # Yields all possible handler names for a command.
+  def potential_handlers_for(name)
+    ['_once', ''].each_with_index do |count_affix, index|
+      %w(at_start with_return when_blank at_end).each do |when_affix|
+        yield ["#{count_affix}_#{when_affix}", "handle_#{name}#{count_affix}_#{when_affix}", index.zero?]
+      end
+
+      %w(destroy save postprocess save_fail).each do |event_affix|
+        %w(before after).each do |when_affix|
+          yield ["#{count_affix}_#{when_affix}_#{event_affix}", "handle_#{name}#{count_affix}_#{when_affix}_#{event_affix}", index.zero?]
+        end
+      end
+    end
+  end
+
+  # Expands a statement to a handler method call, arguments, and template UUID for each handler affix.
+  # Mutates statement queues hash!
+  def add_statement_handlers_for!(statement_array, statement_queues = {})
+    statement_uuid = SecureRandom.uuid
+
+    potential_handlers_for(statement_array[0]) do |when_affix, handler, once|
+      if !(once && @run_once.include?(handler)) && respond_to?(handler)
+        statement_queues[when_affix] ||= []
+        statement_queues[when_affix] << [handler, statement_array[1..-1], statement_uuid]
+        @run_once << handler if once
+      end
+    end
+
+    # Template for statement return value.
+    "%% statement:#{statement_uuid} all %%"
+  end
+
+  # Calls all handlers for a queue of statements in order.
+  def execute_statements(event, with_return = false, statements: nil)
+    statements = @statements if statements.blank?
+
+    ["_#{event}", "_once_#{event}"].each do |when_affix|
+      next if statements[when_affix].blank?
+
+      statements[when_affix].each do |handler, arguments, uuid|
+        @vars['statement_uuid'][0] = uuid
+        if with_return
+          @vars["statement:#{uuid}"] = [public_send(handler, arguments)]
+        else
+          public_send(handler, arguments)
+        end
+      end
+    end
+  end
+
+  # Expire cached statuses after potentially updating them.
+  def reset_status_caches(statuses = nil)
+    statuses = [@status, @parent] if statuses.blank?
+    statuses.each do |status|
+      next unless @account.id == status&.account_id
+
+      Rails.cache.delete_matched("statuses/#{status.id}-*")
+      Rails.cache.delete("statuses/#{status.id}")
+      Rails.cache.delete(status)
+      Rails.cache.delete_matched("format:#{status.id}:*")
+      redis.zremrangebyscore("spam_check:#{status.account.id}", status.id, status.id)
+    end
+  end
+
+  def author_of_status?
+    @account.id == @status.account_id
+  end
+
+  def author_of_parent?
+    @account.id == @parent&.account_id
+  end
+
+  def status_text_blank?
+    @text.blank? || @text.gsub(MENTIONS_OR_HASHTAGS_RE, '').strip.blank?
+  end
+
+  def destroy_status!
+    return if @status.destroyed?
+
+    @status.update(published: false)
+    @status.destroy
+  end
+
+  def replace_status!(new_status)
+    return if new_status.blank?
+
+    destroy_status!
+    @status = new_status
+  end
+
+  def normalize(text)
+    text.to_s.strip.downcase
+  end
+
+  def to_integer(text)
+    text&.strip.to_i
+  end
+
+  def unescape_literals(text)
+    ESCAPE_MAP.each { |escaped, unescaped| text.gsub!(escaped, unescaped) }
+    text
+  end
+
+  def html_encode(text)
+    (@html_entities ||= HTMLEntities.new).encode(text)
+  end
+
+  def var(name)
+    @vars[name].presence || []
+  end
+
+  def read_visibility_from(arg)
+    return if arg.strip.blank?
+
+    arg = case arg.strip
+          when 'p', 'pu', 'all', 'world'
+            'public'
+          when 'u', 'ul'
+            'unlisted'
+          when 'f', 'follower', 'followers', 'packmates', 'follower-only', 'followers-only', 'packmates-only'
+            'private'
+          when 'd', 'dm', 'pm', 'directmessage'
+            'direct'
+          when 'default', 'reset'
+            @account.user.setting_default_privacy
+          when 'to', 'allow', 'allow-from', 'from'
+            'cc'
+          when 'm', 'l', 'mp', 'monsterpit', 'local'
+            'community'
+          else
+            arg.strip
+          end
+
+    %w(public unlisted private limited direct cc community).include?(arg) ? arg : nil
+  end
+
+  def read_falsy_from(arg)
+    %w(f n false no off disable).include?(arg)
+  end
+
+  def read_truthy_from(arg)
+    %w(t y true yes on enable).include?(arg)
+  end
+
+  def read_boolean_from(arg)
+    arg.present? && (read_truthy_from(arg) || !read_falsy_from(arg))
+  end
+
+  def normalize_domain(domain)
+    return if domain&.strip.blank? || !domain.include?('.')
+
+    domain.split('.').map(&:strip).reject(&:blank?).join('.').downcase
+  end
+
+  def federating_with_domain?(domain)
+    return false if domain.blank?
+
+    DomainAllow.where(domain: domain).exists? || Account.where(domain: domain, suspended_at: nil).exists?
+  end
+end