about summary refs log tree commit diff
path: root/app/lib/activitypub
diff options
context:
space:
mode:
authorStarfall <us@starfall.systems>2022-01-31 12:50:14 -0600
committerStarfall <us@starfall.systems>2022-01-31 12:50:14 -0600
commit17265f47f8f931e70699088dd8bd2a1c7b78112b (patch)
treea1dde2630cd8e481cc4c5d047c4af241a251def0 /app/lib/activitypub
parent129962006c2ebcd195561ac556887dc87d32081c (diff)
parentd6f3261c6cb810ea4eb6f74b9ee62af0d94cbd52 (diff)
Merge branch 'glitchsoc'
Diffstat (limited to 'app/lib/activitypub')
-rw-r--r--app/lib/activitypub/activity.rb45
-rw-r--r--app/lib/activitypub/activity/accept.rb13
-rw-r--r--app/lib/activitypub/activity/add.rb3
-rw-r--r--app/lib/activitypub/activity/announce.rb23
-rw-r--r--app/lib/activitypub/activity/create.rb266
-rw-r--r--app/lib/activitypub/activity/update.rb17
-rw-r--r--app/lib/activitypub/adapter.rb1
-rw-r--r--app/lib/activitypub/parser/custom_emoji_parser.rb27
-rw-r--r--app/lib/activitypub/parser/media_attachment_parser.rb58
-rw-r--r--app/lib/activitypub/parser/poll_parser.rb53
-rw-r--r--app/lib/activitypub/parser/status_parser.rb124
-rw-r--r--app/lib/activitypub/tag_manager.rb26
12 files changed, 410 insertions, 246 deletions
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index d2ec122a4..706960f92 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -94,51 +94,6 @@ class ActivityPub::Activity
     equals_or_includes_any?(@object['type'], CONVERTED_TYPES)
   end
 
-  def distribute(status)
-    crawl_links(status)
-
-    notify_about_reblog(status) if reblog_of_local_account?(status) && !reblog_by_following_group_account?(status)
-    notify_about_mentions(status)
-
-    # Only continue if the status is supposed to have arrived in real-time.
-    # Note that if @options[:override_timestamps] isn't set, the status
-    # may have a lower snowflake id than other existing statuses, potentially
-    # "hiding" it from paginated API calls
-    return unless @options[:override_timestamps] || status.within_realtime_window?
-
-    distribute_to_followers(status)
-  end
-
-  def reblog_of_local_account?(status)
-    status.reblog? && status.reblog.account.local?
-  end
-
-  def reblog_by_following_group_account?(status)
-    status.reblog? && status.account.group? && status.reblog.account.following?(status.account)
-  end
-
-  def notify_about_reblog(status)
-    NotifyService.new.call(status.reblog.account, :reblog, status)
-  end
-
-  def notify_about_mentions(status)
-    status.active_mentions.includes(:account).each do |mention|
-      next unless mention.account.local? && audience_includes?(mention.account)
-      NotifyService.new.call(mention.account, :mention, mention)
-    end
-  end
-
-  def crawl_links(status)
-    return if status.spoiler_text?
-
-    # Spread out crawling randomly to avoid DDoSing the link
-    LinkCrawlWorker.perform_in(rand(1..59).seconds, status.id)
-  end
-
-  def distribute_to_followers(status)
-    ::DistributionWorker.perform_async(status.id)
-  end
-
   def delete_arrived_first?(uri)
     redis.exists?("delete_upon_arrival:#{@account.id}:#{uri}")
   end
diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb
index 7010ff43e..5126e23c6 100644
--- a/app/lib/activitypub/activity/accept.rb
+++ b/app/lib/activitypub/activity/accept.rb
@@ -3,7 +3,7 @@
 class ActivityPub::Activity::Accept < ActivityPub::Activity
   def perform
     return accept_follow_for_relay if relay_follow?
-    return follow_request_from_object.authorize! unless follow_request_from_object.nil?
+    return accept_follow!(follow_request_from_object) unless follow_request_from_object.nil?
 
     case @object['type']
     when 'Follow'
@@ -19,7 +19,16 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity
     return if target_account.nil? || !target_account.local?
 
     follow_request = FollowRequest.find_by(account: target_account, target_account: @account)
-    follow_request&.authorize!
+    accept_follow!(follow_request)
+  end
+
+  def accept_follow!(request)
+    return if request.nil?
+
+    is_first_follow = !request.target_account.followers.local.exists?
+    request.authorize!
+
+    RemoteAccountRefreshWorker.perform_async(request.target_account_id) if is_first_follow
   end
 
   def accept_follow_for_relay
diff --git a/app/lib/activitypub/activity/add.rb b/app/lib/activitypub/activity/add.rb
index 688ab00b3..845eeaef7 100644
--- a/app/lib/activitypub/activity/add.rb
+++ b/app/lib/activitypub/activity/add.rb
@@ -4,8 +4,7 @@ class ActivityPub::Activity::Add < ActivityPub::Activity
   def perform
     return unless @json['target'].present? && value_or_id(@json['target']) == @account.featured_collection_url
 
-    status   = status_from_uri(object_uri)
-    status ||= fetch_remote_original_status
+    status = status_from_object
 
     return unless !status.nil? && status.account_id == @account.id && !@account.pinned?(status)
 
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index 9f778ffb9..1f9319290 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -22,11 +22,10 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
         visibility: visibility_from_audience
       )
 
-      original_status.tags.each do |tag|
-        tag.use!(@account)
-      end
+      Trends.tags.register(@status)
+      Trends.links.register(@status)
 
-      distribute(@status)
+      distribute
     end
 
     @status
@@ -34,6 +33,22 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
 
   private
 
+  def distribute
+    # Notify the author of the original status if that status is local
+    NotifyService.new.call(@status.reblog.account, :reblog, @status) if reblog_of_local_account?(@status) && !reblog_by_following_group_account?(@status)
+
+    # Distribute into home and list feeds
+    ::DistributionWorker.perform_async(@status.id) if @options[:override_timestamps] || @status.within_realtime_window?
+  end
+
+  def reblog_of_local_account?(status)
+    status.reblog? && status.reblog.account.local?
+  end
+
+  def reblog_by_following_group_account?(status)
+    status.reblog? && status.account.group? && status.reblog.account.following?(status.account)
+  end
+
   def audience_to
     as_array(@json['to']).map { |x| value_or_id(x) }
   end
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index cc2d391fb..ad273c20b 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -69,9 +69,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def process_status
-    @tags     = []
-    @mentions = []
-    @params   = {}
+    @tags                 = []
+    @mentions             = []
+    @silenced_account_ids = []
+    @params               = {}
 
     process_status_params
     process_tags
@@ -84,10 +85,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
     resolve_thread(@status)
     fetch_replies(@status)
-    distribute(@status)
+    distribute
     forward_for_reply
   end
 
+  def distribute
+    # Spread out crawling randomly to avoid DDoSing the link
+    LinkCrawlWorker.perform_in(rand(1..59).seconds, @status.id)
+
+    # Distribute into home and list feeds and notify mentioned accounts
+    ::DistributionWorker.perform_async(@status.id, { 'silenced_account_ids' => @silenced_account_ids }) if @options[:override_timestamps] || @status.within_realtime_window?
+  end
+
   def find_existing_status
     status   = status_from_uri(object_uri)
     status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present?
@@ -95,19 +104,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def process_status_params
+    @status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: @account.followers_url)
+
     @params = begin
       {
-        uri: object_uri,
-        url: object_url || object_uri,
+        uri: @status_parser.uri,
+        url: @status_parser.url || @status_parser.uri,
         account: @account,
-        text: text_from_content || '',
-        language: detected_language,
-        spoiler_text: converted_object_type? ? '' : (text_from_summary || ''),
-        created_at: @object['published'],
+        text: converted_object_type? ? converted_text : (@status_parser.text || ''),
+        language: @status_parser.language || detected_language,
+        spoiler_text: converted_object_type? ? '' : (@status_parser.spoiler_text || ''),
+        created_at: @status_parser.created_at,
+        edited_at: @status_parser.edited_at,
         override_timestamps: @options[:override_timestamps],
-        reply: @object['inReplyTo'].present?,
-        sensitive: @account.sensitized? || @object['sensitive'] || false,
-        visibility: visibility_from_audience,
+        reply: @status_parser.reply,
+        sensitive: @account.sensitized? || @status_parser.sensitive || false,
+        visibility: @status_parser.visibility,
         thread: replied_to_status,
         conversation: conversation_from_uri(@object['conversation']),
         media_attachment_ids: process_attachments.take(4).map(&:id),
@@ -117,56 +129,63 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def process_audience
-    (audience_to + audience_cc).uniq.each do |audience|
-      next if ActivityPub::TagManager.instance.public_collection?(audience)
+    # Unlike with tags, there is no point in resolving accounts we don't already
+    # know here, because silent mentions would only be used for local access control anyway
+    accounts_in_audience = (audience_to + audience_cc).uniq.filter_map do |audience|
+      account_from_uri(audience) unless ActivityPub::TagManager.instance.public_collection?(audience)
+    end
 
-      # Unlike with tags, there is no point in resolving accounts we don't already
-      # know here, because silent mentions would only be used for local access
-      # control anyway
-      account = account_from_uri(audience)
+    # If the payload was delivered to a specific inbox, the inbox owner must have
+    # access to it, unless they already have access to it anyway
+    if @options[:delivered_to_account_id]
+      accounts_in_audience << delivered_to_account
+      accounts_in_audience.uniq!
+    end
 
-      next if account.nil? || @mentions.any? { |mention| mention.account_id == account.id }
+    accounts_in_audience.each do |account|
+      # This runs after tags are processed, and those translate into non-silent
+      # mentions, which take precedence
+      next if @mentions.any? { |mention| mention.account_id == account.id }
 
       @mentions << Mention.new(account: account, silent: true)
 
       # If there is at least one silent mention, then the status can be considered
       # as a limited-audience status, and not strictly a direct message, but only
       # if we considered a direct message in the first place
-      next unless @params[:visibility] == :direct && direct_message.nil?
-
-      @params[:visibility] = :limited
+      @params[:visibility] = :limited if @params[:visibility] == :direct && !@object['directMessage']
     end
 
-    # If the payload was delivered to a specific inbox, the inbox owner must have
-    # access to it, unless they already have access to it anyway
-    return if @options[:delivered_to_account_id].nil? || @mentions.any? { |mention| mention.account_id == @options[:delivered_to_account_id] }
-
-    @mentions << Mention.new(account_id: @options[:delivered_to_account_id], silent: true)
-
-    return unless @params[:visibility] == :direct && direct_message.nil?
-
-    @params[:visibility] = :limited
+    # Accounts that are tagged but are not in the audience are not
+    # supposed to be notified explicitly
+    @silenced_account_ids = @mentions.map(&:account_id) - accounts_in_audience.map(&:id)
   end
 
   def postprocess_audience_and_deliver
     return if @status.mentions.find_by(account_id: @options[:delivered_to_account_id])
 
-    delivered_to_account = Account.find(@options[:delivered_to_account_id])
-
     @status.mentions.create(account: delivered_to_account, silent: true)
-    @status.update(visibility: :limited) if @status.direct_visibility? && direct_message.nil?
+    @status.update(visibility: :limited) if @status.direct_visibility? && !@object['directMessage']
 
     return unless delivered_to_account.following?(@account)
 
-    FeedInsertWorker.perform_async(@status.id, delivered_to_account.id, :home)
+    FeedInsertWorker.perform_async(@status.id, delivered_to_account.id, 'home')
+  end
+
+  def delivered_to_account
+    @delivered_to_account ||= Account.find(@options[:delivered_to_account_id])
   end
 
   def attach_tags(status)
     @tags.each do |tag|
       status.tags << tag
-      tag.use!(@account, status: status, at_time: status.created_at) if status.public_visibility?
+      tag.update(last_status_at: status.created_at) if tag.last_status_at.nil? || (tag.last_status_at < status.created_at && tag.last_status_at < 12.hours.ago)
     end
 
+    # If we're processing an old status, this may register tags as being used now
+    # as opposed to when the status was really published, but this is probably
+    # not a big deal
+    Trends.tags.register(status)
+
     @mentions.each do |mention|
       mention.status = status
       mention.save
@@ -210,21 +229,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
   def process_emoji(tag)
     return if skip_download?
-    return if tag['name'].blank? || tag['icon'].blank? || tag['icon']['url'].blank?
 
-    shortcode = tag['name'].delete(':')
-    image_url = tag['icon']['url']
-    uri       = tag['id']
-    updated   = tag['updated']
-    emoji     = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain)
+    custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(tag)
 
-    return unless emoji.nil? || image_url != emoji.image_remote_url || (updated && updated >= emoji.updated_at)
+    return if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank?
 
-    emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri)
-    emoji.image_remote_url = image_url
-    emoji.save
-  rescue Seahorse::Client::NetworkingError
-    nil
+    emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain)
+
+    return unless emoji.nil? || custom_emoji_parser.image_remote_url != emoji.image_remote_url || (custom_emoji_parser.updated_at && custom_emoji_parser.updated_at >= emoji.updated_at)
+
+    begin
+      emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: custom_emoji_parser.shortcode, uri: custom_emoji_parser.uri)
+      emoji.image_remote_url = custom_emoji_parser.image_remote_url
+      emoji.save
+    rescue Seahorse::Client::NetworkingError => e
+      Rails.logger.warn "Error storing emoji: #{e}"
+    end
   end
 
   def process_attachments
@@ -233,22 +253,31 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     media_attachments = []
 
     as_array(@object['attachment']).each do |attachment|
-      next if attachment['url'].blank? || media_attachments.size >= 4
+      media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment)
+
+      next if media_attachment_parser.remote_url.blank? || media_attachments.size >= 4
 
       begin
-        href             = Addressable::URI.parse(attachment['url']).normalize.to_s
-        media_attachment = MediaAttachment.create(account: @account, remote_url: href, thumbnail_remote_url: icon_url_from_attachment(attachment), description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
+        media_attachment = MediaAttachment.create(
+          account: @account,
+          remote_url: media_attachment_parser.remote_url,
+          thumbnail_remote_url: media_attachment_parser.thumbnail_remote_url,
+          description: media_attachment_parser.description,
+          focus: media_attachment_parser.focus,
+          blurhash: media_attachment_parser.blurhash
+        )
+
         media_attachments << media_attachment
 
-        next if unsupported_media_type?(attachment['mediaType']) || skip_download?
+        next if unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download?
 
         media_attachment.download_file!
         media_attachment.download_thumbnail!
         media_attachment.save
       rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
         RedownloadMediaWorker.perform_in(rand(30..600).seconds, media_attachment.id)
-      rescue Seahorse::Client::NetworkingError
-        nil
+      rescue Seahorse::Client::NetworkingError => e
+        Rails.logger.warn "Error storing media attachment: #{e}"
       end
     end
 
@@ -258,42 +287,17 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     media_attachments
   end
 
-  def icon_url_from_attachment(attachment)
-    url = attachment['icon'].is_a?(Hash) ? attachment['icon']['url'] : attachment['icon']
-    Addressable::URI.parse(url).normalize.to_s if url.present?
-  rescue Addressable::URI::InvalidURIError
-    nil
-  end
-
   def process_poll
-    return unless @object['type'] == 'Question' && (@object['anyOf'].is_a?(Array) || @object['oneOf'].is_a?(Array))
-
-    expires_at = begin
-      if @object['closed'].is_a?(String)
-        @object['closed']
-      elsif !@object['closed'].nil? && !@object['closed'].is_a?(FalseClass)
-        Time.now.utc
-      else
-        @object['endTime']
-      end
-    end
-
-    if @object['anyOf'].is_a?(Array)
-      multiple = true
-      items    = @object['anyOf']
-    else
-      multiple = false
-      items    = @object['oneOf']
-    end
+    poll_parser = ActivityPub::Parser::PollParser.new(@object)
 
-    voters_count = @object['votersCount']
+    return unless poll_parser.valid?
 
     @account.polls.new(
-      multiple: multiple,
-      expires_at: expires_at,
-      options: items.map { |item| item['name'].presence || item['content'] }.compact,
-      cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 },
-      voters_count: voters_count
+      multiple: poll_parser.multiple,
+      expires_at: poll_parser.expires_at,
+      options: poll_parser.options,
+      cached_tallies: poll_parser.cached_tallies,
+      voters_count: poll_parser.voters_count
     )
   end
 
@@ -346,29 +350,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     end
   end
 
-  def visibility_from_audience
-    if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) }
-      :public
-    elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) }
-      :unlisted
-    elsif audience_to.include?(@account.followers_url)
-      :private
-    elsif direct_message == false
-      :limited
-    else
-      :direct
-    end
-  end
-
-  def audience_includes?(account)
-    uri = ActivityPub::TagManager.instance.uri_for(account)
-    audience_to.include?(uri) || audience_cc.include?(uri)
-  end
-
-  def direct_message
-    @object['directMessage']
-  end
-
   def replied_to_status
     return @replied_to_status if defined?(@replied_to_status)
 
@@ -385,77 +366,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     value_or_id(@object['inReplyTo'])
   end
 
-  def text_from_content
-    return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || object_uri].join(' ')) if converted_object_type?
-
-    if @object['content'].present?
-      @object['content']
-    elsif content_language_map?
-      @object['contentMap'].values.first
-    end
-  end
-
-  def text_from_summary
-    if @object['summary'].present?
-      @object['summary']
-    elsif summary_language_map?
-      @object['summaryMap'].values.first
-    end
-  end
-
-  def text_from_name
-    if @object['name'].present?
-      @object['name']
-    elsif name_language_map?
-      @object['nameMap'].values.first
-    end
+  def converted_text
+    Formatter.instance.linkify([@status_parser.title.presence, @status_parser.spoiler_text.presence, @status_parser.url || @status_parser.uri].compact.join("\n\n"))
   end
 
   def detected_language
-    if content_language_map?
-      @object['contentMap'].keys.first
-    elsif name_language_map?
-      @object['nameMap'].keys.first
-    elsif summary_language_map?
-      @object['summaryMap'].keys.first
-    elsif supported_object_type?
-      LanguageDetector.instance.detect(text_from_content, @account)
-    end
-  end
-
-  def object_url
-    return if @object['url'].blank?
-
-    url_candidate = url_to_href(@object['url'], 'text/html')
-
-    if invalid_origin?(url_candidate)
-      nil
-    else
-      url_candidate
-    end
-  end
-
-  def summary_language_map?
-    @object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty?
-  end
-
-  def content_language_map?
-    @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
-  end
-
-  def name_language_map?
-    @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty?
+    LanguageDetector.instance.detect(@status_parser.text, @account) if supported_object_type?
   end
 
   def unsupported_media_type?(mime_type)
     mime_type.present? && !MediaAttachment.supported_mime_types.include?(mime_type)
   end
 
-  def supported_blurhash?(blurhash)
-    components = blurhash.blank? ? nil : Blurhash.components(blurhash)
-    components.present? && components.none? { |comp| comp > 5 }
-  end
-
   def skip_download?
     return @skip_download if defined?(@skip_download)
 
diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb
index 018e2df54..f04ad321b 100644
--- a/app/lib/activitypub/activity/update.rb
+++ b/app/lib/activitypub/activity/update.rb
@@ -1,32 +1,31 @@
 # frozen_string_literal: true
 
 class ActivityPub::Activity::Update < ActivityPub::Activity
-  SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
-
   def perform
     dereference_object!
 
-    if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
+    if equals_or_includes_any?(@object['type'], %w(Application Group Organization Person Service))
       update_account
-    elsif equals_or_includes_any?(@object['type'], %w(Question))
-      update_poll
+    elsif equals_or_includes_any?(@object['type'], %w(Note Question))
+      update_status
     end
   end
 
   private
 
   def update_account
-    return if @account.uri != object_uri
+    return reject_payload! if @account.uri != object_uri
 
     ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true)
   end
 
-  def update_poll
+  def update_status
     return reject_payload! if invalid_origin?(@object['id'])
 
     status = Status.find_by(uri: object_uri, account_id: @account.id)
-    return if status.nil? || status.preloadable_poll.nil?
 
-    ActivityPub::ProcessPollService.new.call(status.preloadable_poll, @object)
+    return if status.nil?
+
+    ActivityPub::ProcessStatusUpdateService.new.call(status, @object)
   end
 end
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index ef00a4e2e..d8b0c63b2 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -19,7 +19,6 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
     atom_uri: { 'ostatus' => 'http://ostatus.org#', 'atomUri' => 'ostatus:atomUri' },
     conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
     focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
-    identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
     blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
     discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
     voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
diff --git a/app/lib/activitypub/parser/custom_emoji_parser.rb b/app/lib/activitypub/parser/custom_emoji_parser.rb
new file mode 100644
index 000000000..724c60215
--- /dev/null
+++ b/app/lib/activitypub/parser/custom_emoji_parser.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class ActivityPub::Parser::CustomEmojiParser
+  include JsonLdHelper
+
+  def initialize(json)
+    @json = json
+  end
+
+  def uri
+    @json['id']
+  end
+
+  def shortcode
+    @json['name']&.delete(':')
+  end
+
+  def image_remote_url
+    @json.dig('icon', 'url')
+  end
+
+  def updated_at
+    @json['updated']&.to_datetime
+  rescue ArgumentError
+    nil
+  end
+end
diff --git a/app/lib/activitypub/parser/media_attachment_parser.rb b/app/lib/activitypub/parser/media_attachment_parser.rb
new file mode 100644
index 000000000..1798e58a4
--- /dev/null
+++ b/app/lib/activitypub/parser/media_attachment_parser.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+class ActivityPub::Parser::MediaAttachmentParser
+  include JsonLdHelper
+
+  def initialize(json)
+    @json = json
+  end
+
+  # @param [MediaAttachment] previous_record
+  def significantly_changes?(previous_record)
+    remote_url != previous_record.remote_url ||
+      thumbnail_remote_url != previous_record.thumbnail_remote_url ||
+      description != previous_record.description
+  end
+
+  def remote_url
+    Addressable::URI.parse(@json['url'])&.normalize&.to_s
+  rescue Addressable::URI::InvalidURIError
+    nil
+  end
+
+  def thumbnail_remote_url
+    Addressable::URI.parse(@json['icon'].is_a?(Hash) ? @json['icon']['url'] : @json['icon'])&.normalize&.to_s
+  rescue Addressable::URI::InvalidURIError
+    nil
+  end
+
+  def description
+    @json['summary'].presence || @json['name'].presence
+  end
+
+  def focus
+    @json['focalPoint']
+  end
+
+  def blurhash
+    supported_blurhash? ? @json['blurhash'] : nil
+  end
+
+  def file_content_type
+    @json['mediaType']
+  end
+
+  private
+
+  def supported_blurhash?
+    components = begin
+      blurhash = @json['blurhash']
+
+      if blurhash.present? && /^[\w#$%*+-.:;=?@\[\]^{|}~]+$/.match?(blurhash)
+        Blurhash.components(blurhash)
+      end
+    end
+
+    components.present? && components.none? { |comp| comp > 5 }
+  end
+end
diff --git a/app/lib/activitypub/parser/poll_parser.rb b/app/lib/activitypub/parser/poll_parser.rb
new file mode 100644
index 000000000..758c03f07
--- /dev/null
+++ b/app/lib/activitypub/parser/poll_parser.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+class ActivityPub::Parser::PollParser
+  include JsonLdHelper
+
+  def initialize(json)
+    @json = json
+  end
+
+  def valid?
+    equals_or_includes?(@json['type'], 'Question') && items.is_a?(Array)
+  end
+
+  # @param [Poll] previous_record
+  def significantly_changes?(previous_record)
+    options != previous_record.options ||
+      multiple != previous_record.multiple
+  end
+
+  def options
+    items.filter_map { |item| item['name'].presence || item['content'] }
+  end
+
+  def multiple
+    @json['anyOf'].is_a?(Array)
+  end
+
+  def expires_at
+    if @json['closed'].is_a?(String)
+      @json['closed'].to_datetime
+    elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass)
+      Time.now.utc
+    else
+      @json['endTime']&.to_datetime
+    end
+  rescue ArgumentError
+    nil
+  end
+
+  def voters_count
+    @json['votersCount']
+  end
+
+  def cached_tallies
+    items.map { |item| item.dig('replies', 'totalItems') || 0 }
+  end
+
+  private
+
+  def items
+    @json['anyOf'] || @json['oneOf']
+  end
+end
diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb
new file mode 100644
index 000000000..75b8f3d5c
--- /dev/null
+++ b/app/lib/activitypub/parser/status_parser.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+class ActivityPub::Parser::StatusParser
+  include JsonLdHelper
+
+  # @param [Hash] json
+  # @param [Hash] magic_values
+  # @option magic_values [String] :followers_collection
+  def initialize(json, magic_values = {})
+    @json         = json
+    @object       = json['object'] || json
+    @magic_values = magic_values
+  end
+
+  def uri
+    id = @object['id']
+
+    if id&.start_with?('bear:')
+      Addressable::URI.parse(id).query_values['u']
+    else
+      id
+    end
+  rescue Addressable::URI::InvalidURIError
+    id
+  end
+
+  def url
+    url_to_href(@object['url'], 'text/html') if @object['url'].present?
+  end
+
+  def text
+    if @object['content'].present?
+      @object['content']
+    elsif content_language_map?
+      @object['contentMap'].values.first
+    end
+  end
+
+  def spoiler_text
+    if @object['summary'].present?
+      @object['summary']
+    elsif summary_language_map?
+      @object['summaryMap'].values.first
+    end
+  end
+
+  def title
+    if @object['name'].present?
+      @object['name']
+    elsif name_language_map?
+      @object['nameMap'].values.first
+    end
+  end
+
+  def created_at
+    @object['published']&.to_datetime
+  rescue ArgumentError
+    nil
+  end
+
+  def edited_at
+    @object['updated']&.to_datetime
+  rescue ArgumentError
+    nil
+  end
+
+  def reply
+    @object['inReplyTo'].present?
+  end
+
+  def sensitive
+    @object['sensitive']
+  end
+
+  def visibility
+    if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) }
+      :public
+    elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) }
+      :unlisted
+    elsif audience_to.include?(@magic_values[:followers_collection])
+      :private
+    elsif direct_message == false
+      :limited
+    else
+      :direct
+    end
+  end
+
+  def language
+    if content_language_map?
+      @object['contentMap'].keys.first
+    elsif name_language_map?
+      @object['nameMap'].keys.first
+    elsif summary_language_map?
+      @object['summaryMap'].keys.first
+    end
+  end
+
+  def direct_message
+    @object['directMessage']
+  end
+
+  private
+
+  def audience_to
+    as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) }
+  end
+
+  def audience_cc
+    as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
+  end
+
+  def summary_language_map?
+    @object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty?
+  end
+
+  def content_language_map?
+    @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
+  end
+
+  def name_language_map?
+    @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty?
+  end
+end
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index f6b5e10d3..f6b9741fa 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -64,6 +64,10 @@ class ActivityPub::TagManager
     account_status_replies_url(target.account, target, page_params)
   end
 
+  def followers_uri_for(target)
+    target.local? ? account_followers_url(target) : target.followers_url.presence
+  end
+
   # Primary audience of a status
   # Public statuses go out to primarily the public collection
   # Unlisted and private statuses go out primarily to the followers collection
@@ -80,17 +84,17 @@ class ActivityPub::TagManager
         account_ids = status.active_mentions.pluck(:account_id)
         to = status.account.followers.where(id: account_ids).each_with_object([]) do |account, result|
           result << uri_for(account)
-          result << account_followers_url(account) if account.group?
+          result << followers_uri_for(account) if account.group?
         end
         to.concat(FollowRequest.where(target_account_id: status.account_id, account_id: account_ids).each_with_object([]) do |request, result|
           result << uri_for(request.account)
-          result << account_followers_url(request.account) if request.account.group?
-        end)
+          result << followers_uri_for(request.account) if request.account.group?
+        end).compact
       else
         status.active_mentions.each_with_object([]) do |mention, result|
           result << uri_for(mention.account)
-          result << account_followers_url(mention.account) if mention.account.group?
-        end
+          result << followers_uri_for(mention.account) if mention.account.group?
+        end.compact
       end
     end
   end
@@ -118,17 +122,17 @@ class ActivityPub::TagManager
         account_ids = status.active_mentions.pluck(:account_id)
         cc.concat(status.account.followers.where(id: account_ids).each_with_object([]) do |account, result|
           result << uri_for(account)
-          result << account_followers_url(account) if account.group?
-        end)
+          result << followers_uri_for(account) if account.group?
+        end.compact)
         cc.concat(FollowRequest.where(target_account_id: status.account_id, account_id: account_ids).each_with_object([]) do |request, result|
           result << uri_for(request.account)
-          result << account_followers_url(request.account) if request.account.group?
-        end)
+          result << followers_uri_for(request.account) if request.account.group?
+        end.compact)
       else
         cc.concat(status.active_mentions.each_with_object([]) do |mention, result|
           result << uri_for(mention.account)
-          result << account_followers_url(mention.account) if mention.account.group?
-        end)
+          result << followers_uri_for(mention.account) if mention.account.group?
+        end.compact)
       end
     end