diff options
Diffstat (limited to 'app/lib/command_tag')
-rw-r--r-- | app/lib/command_tag/commands.rb | 10 | ||||
-rw-r--r-- | app/lib/command_tag/commands/account_tools.rb | 37 | ||||
-rw-r--r-- | app/lib/command_tag/commands/footer_tools.rb | 50 | ||||
-rw-r--r-- | app/lib/command_tag/commands/hello_world.rb | 11 | ||||
-rw-r--r-- | app/lib/command_tag/commands/parent_status_tools.rb | 80 | ||||
-rw-r--r-- | app/lib/command_tag/commands/status_tools.rb | 239 | ||||
-rw-r--r-- | app/lib/command_tag/commands/text_tools.rb | 89 | ||||
-rw-r--r-- | app/lib/command_tag/commands/variables.rb | 40 | ||||
-rw-r--r-- | app/lib/command_tag/processor.rb | 343 |
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 |