1
0
Fork 0
forked from gitea/nas

Merge commit 'df2611a10f' into kbtopic-remove-quote

This commit is contained in:
KMY 2025-04-26 07:48:46 +09:00
commit e4c72836a3
36 changed files with 1660 additions and 87 deletions

View file

@ -54,10 +54,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@silenced_account_ids = []
@params = {}
@raw_mention_uris = []
@quote = nil
@quote_uri = nil
process_status_params
process_sensitive_words
process_tags
process_quote
process_audience
return nil unless valid_status?
@ -68,6 +71,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
attach_tags(@status)
attach_mentions(@status)
attach_counts(@status)
attach_quote(@status)
end
resolve_thread(@status)
@ -258,6 +262,16 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def attach_quote(status)
return if @quote.nil?
@quote.status = status
@quote.save
ActivityPub::VerifyQuoteService.new.call(@quote, fetchable_quoted_uri: @quote_uri, request_id: @options[:request_id])
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, @quote.id, @quote_uri, { 'request_id' => @options[:request_id] })
end
def process_tags
return if @object['tag'].nil?
@ -272,6 +286,17 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def process_quote
return unless Mastodon::Feature.inbound_quotes_enabled?
@quote_uri = @status_parser.quote_uri
return if @quote_uri.blank?
approval_uri = @status_parser.quote_approval_uri
approval_uri = nil if unsupported_uri_scheme?(approval_uri)
@quote = Quote.new(account: @account, approval_uri: approval_uri)
end
def process_hashtag(tag)
return if tag['name'].blank? || ignore_hashtags?

View file

@ -7,7 +7,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
elsif object_uri == ActivityPub::TagManager::COLLECTIONS[:public]
delete_friend
else
delete_note
delete_object
end
end
@ -19,7 +19,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
end
end
def delete_note
def delete_object
return if object_uri.nil?
with_redis_lock("delete_status_in_progress:#{object_uri}", raise_on_failure: false) do
@ -34,17 +34,38 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
Tombstone.find_or_create_by(uri: object_uri, account: @account)
end
@status = Status.find_by(uri: object_uri, account: @account)
@status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
return if @status.nil?
forwarder.forward! if forwarder.forwardable?
forward_for_conversation
delete_now!
case @object['type']
when 'QuoteAuthorization'
revoke_quote
when 'Note', 'Question'
delete_status
else
delete_status || revoke_quote
end
end
end
def delete_status
@status = Status.find_by(uri: object_uri, account: @account)
@status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
return if @status.nil?
forwarder.forward! if forwarder.forwardable?
forward_for_conversation
RemoveStatusService.new.call(@status, redraft: false)
true
end
def revoke_quote
@quote = Quote.find_by(approval_uri: object_uri, quoted_account: @account)
return if @quote.nil?
ActivityPub::Forwarder.new(@account, @json, @quote.status).forward!
@quote.reject!
end
def forward_for_conversation
return unless @status.conversation.present? && @status.conversation.local? && @json['signature'].present?
@ -59,8 +80,4 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
def forwarder
@forwarder ||= ActivityPub::Forwarder.new(@account, @json, @status)
end
def delete_now!
RemoveStatusService.new.call(@status, redraft: false)
end
end

View file

@ -135,6 +135,16 @@ class ActivityPub::Parser::StatusParser
@object.dig(:shares, :totalItems)
end
def quote_uri
%w(quote _misskey_quote quoteUrl quoteUri).filter_map do |key|
value_or_id(as_array(@object[key]).first)
end.first
end
def quote_approval_uri
as_array(@object['quoteAuthorization']).first
end
private
def raw_language_code

View file

@ -74,6 +74,23 @@ class StatusCacheHydrator
payload[:pinned] = StatusPin.exists?(account_id: account_id, status_id: status.id) if status.account_id == account_id
payload[:filtered] = mapped_applied_custom_filter(account_id, status)
payload[:emoji_reactions] = status.emoji_reactions_grouped_by_name(account)
payload[:quote] = hydrate_quote_payload(payload[:quote], status.quote, account_id) if payload[:quote]
end
def hydrate_quote_payload(empty_payload, quote, account_id)
# TODO: properly handle quotes, including visibility and access control
empty_payload.tap do |payload|
# Nothing to do if we're in the shallow (depth limit) case
next unless payload.key?(:quoted_status)
# TODO: handle hiding a rendered status or showing a non-rendered status according to visibility
if quote&.quoted_status.nil?
payload[:quoted_status] = nil
elsif payload[:quoted_status].present?
payload[:quoted_status] = StatusCacheHydrator.new(quote.quoted_status).hydrate(account_id)
end
end
end
def mapped_applied_custom_filter(account_id, status)

View file

@ -26,6 +26,7 @@ module Status::SnapshotConcern
poll_options: preloadable_poll&.options&.dup,
account_id: account_id || self.account_id,
created_at: at_time || edited_at,
quote_id: quote&.id,
rate_limit: rate_limit
)
end

67
app/models/quote.rb Normal file
View file

@ -0,0 +1,67 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: quotes
#
# id :bigint(8) not null, primary key
# activity_uri :string
# approval_uri :string
# state :integer default("pending"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint(8) not null
# quoted_account_id :bigint(8)
# quoted_status_id :bigint(8)
# status_id :bigint(8) not null
#
class Quote < ApplicationRecord
BACKGROUND_REFRESH_INTERVAL = 1.week.freeze
REFRESH_DEADLINE = 6.hours
enum :state,
{ pending: 0, accepted: 1, rejected: 2, revoked: 3 },
validate: true
belongs_to :status
belongs_to :quoted_status, class_name: 'Status', optional: true
belongs_to :account
belongs_to :quoted_account, class_name: 'Account', optional: true
before_validation :set_accounts
validates :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? }
validate :validate_visibility
def accept!
update!(state: :accepted)
end
def reject!
if accepted?
update!(state: :revoked)
elsif !revoked?
update!(state: :rejected)
end
end
def schedule_refresh_if_stale!
return unless quoted_status_id.present? && approval_uri.present? && updated_at <= BACKGROUND_REFRESH_INTERVAL.ago
ActivityPub::QuoteRefreshWorker.perform_in(rand(REFRESH_DEADLINE), id)
end
private
def set_accounts
self.account = status.account
self.quoted_account = quoted_status&.account
end
def validate_visibility
return if account_id == quoted_account_id || quoted_status.nil? || quoted_status.distributable?
errors.add(:quoted_status_id, :visibility_mismatch)
end
end

View file

@ -123,6 +123,7 @@ class Status < ApplicationRecord
has_one :scheduled_expiration_status, inverse_of: :status, dependent: :destroy
has_one :circle_status, inverse_of: :status, dependent: :destroy
has_many :list_status, inverse_of: :status, dependent: :destroy
has_one :quote, inverse_of: :status, dependent: :destroy
validates :uri, uniqueness: true, presence: true, unless: :local?
validates :text, presence: true, unless: -> { with_media? || reblog? }
@ -189,18 +190,20 @@ class Status < ApplicationRecord
:reference_objects,
:references,
:scheduled_expiration_status,
quote: { status: { account: [:account_stat, user: :role] } },
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
account: [:account_stat, user: :role],
active_mentions: :account,
reblog: [
:application,
:tags,
:media_attachments,
:conversation,
:status_stat,
:tags,
:preloadable_poll,
:reference_objects,
:scheduled_expiration_status,
quote: { status: { account: [:account_stat, user: :role] } },
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
account: [:account_stat, user: :role],
active_mentions: { account: :account_stat },
@ -501,14 +504,6 @@ class Status < ApplicationRecord
ConversationMute.select(:conversation_id).where(conversation_id: conversation_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.conversation_id] = true }
end
def blocks_map(account_ids, account_id)
Block.where(account_id: account_id, target_account_id: account_ids).each_with_object({}) { |b, h| h[b.target_account_id] = true }
end
def domain_blocks_map(domains, account_id)
AccountDomainBlock.where(account_id: account_id, domain: domains).each_with_object({}) { |d, h| h[d.domain] = true }
end
def pins_map(status_ids, account_id)
StatusPin.select(:status_id).where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true }
end

View file

@ -16,6 +16,7 @@
# poll_options :string is an Array
# sensitive :boolean
# markdown :boolean default(FALSE)
# quote_id :bigint(8)
#
class StatusEdit < ApplicationRecord

View file

@ -3,7 +3,7 @@
class StatusRelationshipsPresenter
PINNABLE_VISIBILITIES = %w(public public_unlisted unlisted login private).freeze
attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map, :blocks_map, :domain_blocks_map,
attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map,
:bookmarks_map, :filters_map, :attributes_map, :emoji_reaction_allows_map
def initialize(statuses, current_account_id = nil, **options)
@ -14,27 +14,23 @@ class StatusRelationshipsPresenter
@favourites_map = {}
@bookmarks_map = {}
@mutes_map = {}
@blocks_map = {}
@domain_blocks_map = {}
@pins_map = {}
@filters_map = {}
@emoji_reaction_allows_map = nil
else
statuses = statuses.compact
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
conversation_ids = statuses.filter_map(&:conversation_id).uniq
pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && PINNABLE_VISIBILITIES.include?(s.visibility) }
statuses = statuses.compact
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id, s.proper.quote&.quoted_status_id] }.uniq.compact
conversation_ids = statuses.flat_map { |s| [s.proper.conversation_id, s.proper.quote&.quoted_status&.conversation_id] }.uniq.compact
pinnable_status_ids = statuses.flat_map { |s| [s.proper, s.proper.quote&.quoted_status] }.compact.filter_map { |s| s.id if s.account_id == current_account_id && PINNABLE_VISIBILITIES.include?(s.visibility) }
@filters_map = build_filters_map(statuses, current_account_id).merge(options[:filters_map] || {})
@reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
@favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
@bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
@mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {})
@blocks_map = Status.blocks_map(statuses.map(&:account_id), current_account_id).merge(options[:blocks_map] || {})
@domain_blocks_map = Status.domain_blocks_map(statuses.filter_map { |status| status.account.domain }.uniq, current_account_id).merge(options[:domain_blocks_map] || {})
@pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
@filters_map = build_filters_map(statuses.flat_map { |s| [s, s.proper.quote&.quoted_status] }.compact.uniq, current_account_id).merge(options[:filters_map] || {})
@reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
@favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
@bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
@mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {})
@pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
@emoji_reaction_allows_map = Status.emoji_reaction_allows_map(status_ids, current_account_id).merge(options[:emoji_reaction_allows_map] || {})
@attributes_map = options[:attributes_map] || {}
@attributes_map = options[:attributes_map] || {}
end
end

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
class REST::BaseQuoteSerializer < ActiveModel::Serializer
attributes :state
def state
return object.state unless object.accepted?
# Extra states when a status is unavailable
return 'deleted' if object.quoted_status.nil?
return 'unauthorized' if status_filter.filtered?
object.state
end
def quoted_status
object.quoted_status if object.accepted? && object.quoted_status.present? && !status_filter.filtered?
end
private
def status_filter
@status_filter ||= StatusFilter.new(object.quoted_status, current_user&.account, instance_options[:relationships] || {})
end
end

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
class REST::QuoteSerializer < REST::BaseQuoteSerializer
has_one :quoted_status, serializer: REST::ShallowStatusSerializer
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::ShallowQuoteSerializer < REST::BaseQuoteSerializer
attribute :quoted_status_id
def quoted_status_id
quoted_status&.id&.to_s
end
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::ShallowStatusSerializer < REST::StatusSerializer
has_one :quote, key: :quote, serializer: REST::ShallowQuoteSerializer
# It looks like redefining one `has_one` requires redefining all inherited ones
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
end

View file

@ -11,6 +11,8 @@ class REST::StatusEditSerializer < ActiveModel::Serializer
has_many :ordered_media_attachments, key: :media_attachments, serializer: REST::MediaAttachmentSerializer
has_many :emojis, serializer: REST::CustomEmojiSlimSerializer
has_one :quote, serializer: REST::QuoteSerializer, if: -> { object.quote_id.present? }
attribute :poll, if: -> { object.poll_options.present? }
def content
@ -24,4 +26,8 @@ class REST::StatusEditSerializer < ActiveModel::Serializer
def markdown_opt
object.markdown
end
def quote
object.quote_id == status.quote&.id ? status.quote : Quote.new(state: :pending)
end
end

View file

@ -33,6 +33,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
has_many :tags
has_many :emojis, serializer: REST::CustomEmojiSlimSerializer
has_one :quote, key: :quote, serializer: REST::QuoteSerializer
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer

View file

@ -19,6 +19,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
@account = status.account
@media_attachments_changed = false
@poll_changed = false
@quote_changed = false
@request_id = request_id
# Only native types can be updated at the moment
@ -190,7 +191,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
sensitive: @status.sensitive,
media_count: @next_media_attachments.size,
poll_count: @status.poll&.options&.size || 0,
quote: quote,
quote: quote_url,
reply: @status.reply?,
mention_count: @status.mentions.count,
reference_count: reference_uris.size,
@ -217,7 +218,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
process_sensitive_words
@significant_changes = text_significantly_changed? || @status.spoiler_text_changed? || @media_attachments_changed || @poll_changed
@significant_changes = text_significantly_changed? || @status.spoiler_text_changed? || @media_attachments_changed || @poll_changed || @quote_changed
@status.edited_at = @status_parser.edited_at if significant_changes?
@ -252,6 +253,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
update_tags!
update_mentions!
update_emojis!
update_quote!
end
def update_tags!
@ -340,7 +342,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
def update_references!
references = reference_uris
ProcessReferencesService.call_service_without_error(@status, [], references, [quote].compact)
ProcessReferencesService.call_service_without_error(@status, [], references, [quote_url].compact)
end
def reference_uris
@ -350,7 +352,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
@reference_uris += ProcessReferencesService.extract_uris(@json['content'] || '')
end
def quote
def quote_url
# TODO: quote
nil
end
@ -365,6 +367,45 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
@local_referred_accounts = local_referred_statuses.map(&:account)
end
def update_quote!
return unless Mastodon::Feature.inbound_quotes_enabled?
quote = nil
quote_uri = @status_parser.quote_uri
if quote_uri.present?
approval_uri = @status_parser.quote_approval_uri
approval_uri = nil if unsupported_uri_scheme?(approval_uri)
if @status.quote.present?
# If the quoted post has changed, discard the old object and create a new one
if @status.quote.quoted_status.present? && ActivityPub::TagManager.instance.uri_for(@status.quote.quoted_status) != quote_uri
@status.quote.destroy
quote = Quote.create(status: @status, approval_uri: approval_uri)
@quote_changed = true
else
quote = @status.quote
quote.update(approval_uri: approval_uri, state: :pending) if quote.approval_uri != @status_parser.quote_approval_uri
end
else
quote = Quote.create(status: @status, approval_uri: approval_uri)
@quote_changed = true
end
end
if quote.present?
begin
quote.save
ActivityPub::VerifyQuoteService.new.call(quote, fetchable_quoted_uri: quote_uri, request_id: @request_id)
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, quote.id, quote_uri, { 'request_id' => @request_id })
end
elsif @status.quote.present?
@status.quote.destroy!
@quote_changed = true
end
end
def update_counts!
likes = @status_parser.favourites_count
shares = @status_parser.reblogs_count

View file

@ -0,0 +1,112 @@
# frozen_string_literal: true
class ActivityPub::VerifyQuoteService < BaseService
include JsonLdHelper
# Optionally fetch quoted post, and verify the quote is authorized
def call(quote, fetchable_quoted_uri: nil, prefetched_body: nil, request_id: nil)
@request_id = request_id
@quote = quote
@fetching_error = nil
fetch_quoted_post_if_needed!(fetchable_quoted_uri)
return if fast_track_approval! || quote.approval_uri.blank?
@json = fetch_approval_object(quote.approval_uri, prefetched_body:)
return quote.reject! if @json.nil?
return if non_matching_uri_hosts?(quote.approval_uri, value_or_id(@json['attributedTo']))
return unless matching_type? && matching_quote_uri?
# Opportunistically import embedded posts if needed
return if import_quoted_post_if_needed!(fetchable_quoted_uri) && fast_track_approval!
# Raise an error if we failed to fetch the status
raise @fetching_error if @quote.status.nil? && @fetching_error
return unless matching_quoted_post? && matching_quoted_author?
quote.accept!
end
private
# FEP-044f defines rules that don't require the approval flow
def fast_track_approval!
return false if @quote.quoted_status_id.blank?
# Always allow someone to quote themselves
if @quote.account_id == @quote.quoted_account_id
@quote.accept!
true
end
# Always allow someone to quote posts in which they are mentioned
if @quote.quoted_status.active_mentions.exists?(mentions: { account_id: @quote.account_id })
@quote.accept!
true
else
false
end
end
def fetch_approval_object(uri, prefetched_body: nil)
if prefetched_body.nil?
fetch_resource(uri, true, @quote.account.followers.local.first, raise_on_error: :temporary)
else
body_to_json(prefetched_body, compare_id: uri)
end
end
def matching_type?
supported_context?(@json) && equals_or_includes?(@json['type'], 'QuoteAuthorization')
end
def matching_quote_uri?
ActivityPub::TagManager.instance.uri_for(@quote.status) == value_or_id(@json['interactingObject'])
end
def fetch_quoted_post_if_needed!(uri)
return if uri.nil? || @quote.quoted_status.present?
status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
status ||= ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id)
@quote.update(quoted_status: status) if status.present?
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS => e
@fetching_error = e
end
def import_quoted_post_if_needed!(uri)
# No need to fetch if we already have a post
return if uri.nil? || @quote.quoted_status_id.present? || !@json['interactionTarget'].is_a?(Hash)
# NOTE: Replacing the object's context by that of the parent activity is
# not sound, but it's consistent with the rest of the codebase
object = @json['interactionTarget'].merge({ '@context' => @json['@context'] })
# It's not safe to fetch if the inlined object is cross-origin or doesn't match expectations
return if object['id'] != uri || non_matching_uri_hosts?(@quote.approval_uri, object['id'])
status = ActivityPub::FetchRemoteStatusService.new.call(object['id'], prefetched_body: object, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id)
if status.present?
@quote.update(quoted_status: status)
true
else
false
end
end
def matching_quoted_post?
return false if @quote.quoted_status_id.blank?
ActivityPub::TagManager.instance.uri_for(@quote.quoted_status) == value_or_id(@json['interactionTarget'])
end
def matching_quoted_author?
ActivityPub::TagManager.instance.uri_for(@quote.quoted_account) == value_or_id(@json['attributedTo'])
end
end

View file

@ -189,7 +189,7 @@ class ProcessReferencesService < BaseService
@added_objects << @status.reference_objects.new(target_status: status, attribute_type: attribute_type)
# TODO: quote
# @status.update!(quote_of_id: status_id) if quote
# Quote.create(status: @status, approval_uri: approval_uri) if quote
status.increment_count!(:status_referred_by_count)
@references_count += 1
@ -236,14 +236,13 @@ class ProcessReferencesService < BaseService
@status.reference_objects.where(target_status: @changed_items.keys).find_each do |ref|
attribute_type = @changed_items[ref.target_status_id]
quote = quote_attribute?(attribute_type)
quote_change = ref.quote != quote
ref.update!(attribute_type: attribute_type)
next unless quote_change
# TODO: quote
# quote = quote_attribute?(attribute_type)
# quote_change = ref.quote != quote
# next unless quote_change
# if quote
# ref.status.update!(quote_of_id: ref.target_status.id)
# else

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
class ActivityPub::QuoteRefreshWorker
include Sidekiq::Worker
sidekiq_options queue: 'pull', retry: 3, dead: false, lock: :until_executed, lock_ttl: 1.day.to_i
def perform(quote_id)
quote = Quote.find_by(id: quote_id)
return if quote.nil? || quote.updated_at > Quote::BACKGROUND_REFRESH_INTERVAL.ago
quote.touch
ActivityPub::VerifyQuoteService.new.call(quote)
end
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class ActivityPub::RefetchAndVerifyQuoteWorker
include Sidekiq::Worker
include ExponentialBackoff
include JsonLdHelper
sidekiq_options queue: 'pull', retry: 3
def perform(quote_id, quoted_uri, options = {})
quote = Quote.find(quote_id)
ActivityPub::VerifyQuoteService.new.call(quote, fetchable_quoted_uri: quoted_uri, request_id: options[:request_id])
rescue ActiveRecord::RecordNotFound
# Do nothing
true
rescue Mastodon::UnexpectedResponseError => e
raise e unless response_error_unsalvageable?(e.response)
end
end