diff --git a/app/controllers/api/v2/suggestions_controller.rb b/app/controllers/api/v2/suggestions_controller.rb index 8516796e86..54a0420fc4 100644 --- a/app/controllers/api/v2/suggestions_controller.rb +++ b/app/controllers/api/v2/suggestions_controller.rb @@ -2,11 +2,13 @@ class Api::V2::SuggestionsController < Api::BaseController include Authorization + include AsyncRefreshesConcern before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index before_action :require_user! before_action :set_suggestions + before_action :schedule_fasp_retrieval def index render json: @suggestions.get(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:offset].to_i), each_serializer: REST::SuggestionSerializer @@ -22,4 +24,18 @@ class Api::V2::SuggestionsController < Api::BaseController def set_suggestions @suggestions = AccountSuggestions.new(current_account) end + + def schedule_fasp_retrieval + return unless Mastodon::Feature.fasp_enabled? + # Do not schedule a new retrieval if the request is a follow-up + # to an earlier retrieval + return if request.headers['Mastodon-Async-Refresh-Id'].present? + + refresh_key = "fasp:follow_recommendation:#{current_account.id}" + return if AsyncRefresh.new(refresh_key).running? + + add_async_refresh_header(AsyncRefresh.create(refresh_key)) + + Fasp::FollowRecommendationWorker.perform_async(current_account.id) + end end diff --git a/app/models/async_refresh.rb b/app/models/async_refresh.rb index 9ca6985750..bb179b7639 100644 --- a/app/models/async_refresh.rb +++ b/app/models/async_refresh.rb @@ -22,6 +22,10 @@ class AsyncRefresh new(redis_key) end + def self.exists?(redis_key) + redis.exists?(redis_key) + end + attr_reader :status, :result_count def initialize(redis_key) @@ -49,6 +53,11 @@ class AsyncRefresh @status = 'finished' end + def increment_result_count(by: 1) + redis.hincrby(@redis_key, 'result_count', by) + fetch_data_from_redis + end + def reload fetch_data_from_redis self diff --git a/app/models/fasp/provider.rb b/app/models/fasp/provider.rb index 7926953e6c..37d0b581ca 100644 --- a/app/models/fasp/provider.rb +++ b/app/models/fasp/provider.rb @@ -34,6 +34,10 @@ class Fasp::Provider < ApplicationRecord before_create :create_keypair after_commit :update_remote_capabilities + scope :with_capability, lambda { |capability_name| + where('fasp_providers.capabilities @> ?::jsonb', "[{\"id\": \"#{capability_name}\", \"enabled\": true}]") + } + def capabilities read_attribute(:capabilities).map do |attributes| Fasp::Capability.new(attributes) diff --git a/app/workers/fasp/follow_recommendation_worker.rb b/app/workers/fasp/follow_recommendation_worker.rb new file mode 100644 index 0000000000..4772da404b --- /dev/null +++ b/app/workers/fasp/follow_recommendation_worker.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Fasp::FollowRecommendationWorker + include Sidekiq::Worker + + sidekiq_options queue: 'fasp', retry: 0 + + def perform(account_id) + return unless Mastodon::Feature.fasp_enabled? + + async_refresh = AsyncRefresh.new("fasp:follow_recommendation:#{account_id}") + + account = Account.find(account_id) + + follow_recommendation_providers = Fasp::Provider.with_capability('follow_recommendation') + return if follow_recommendation_providers.none? + + account_uri = ActivityPub::TagManager.instance.uri_for(account) + params = { accountUri: account_uri }.to_query + fetch_service = ActivityPub::FetchRemoteActorService.new + + follow_recommendation_providers.each do |provider| + Fasp::Request.new(provider).get("/follow_recommendation/v0/accounts?#{params}").each do |uri| + next if Account.where(uri:).any? + + account = fetch_service.call(uri) + async_refresh.increment_result_count(by: 1) if account.present? + end + end + rescue ActiveRecord::RecordNotFound + # Nothing to be done + ensure + async_refresh.finish! + end +end diff --git a/spec/fabricators/fasp/provider_fabricator.rb b/spec/fabricators/fasp/provider_fabricator.rb index fd7867402a..8700ebb8cf 100644 --- a/spec/fabricators/fasp/provider_fabricator.rb +++ b/spec/fabricators/fasp/provider_fabricator.rb @@ -29,3 +29,15 @@ Fabricator(:debug_fasp, from: :fasp_provider) do def fasp.update_remote_capabilities = true end end + +Fabricator(:follow_recommendation_fasp, from: :fasp_provider) do + confirmed true + capabilities [ + { id: 'follow_recommendation', version: '0.1', enabled: true }, + ] + + after_build do |fasp| + # Prevent fabrication from attempting an HTTP call to the provider + def fasp.update_remote_capabilities = true + end +end diff --git a/spec/requests/api/v2/suggestions_spec.rb b/spec/requests/api/v2/suggestions_spec.rb index 099d9bc3b2..578bf1b61b 100644 --- a/spec/requests/api/v2/suggestions_spec.rb +++ b/spec/requests/api/v2/suggestions_spec.rb @@ -34,5 +34,14 @@ RSpec.describe 'Suggestions API' do end ) end + + context 'when `follow_recommendation` FASP is enabled', feature: :fasp do + it 'enqueues a retrieval job and adds a header to inform the client' do + get '/api/v2/suggestions', headers: headers + + expect(Fasp::FollowRecommendationWorker).to have_enqueued_sidekiq_job + expect(response.headers['Mastodon-Async-Refresh']).to be_present + end + end end end diff --git a/spec/workers/fasp/follow_recommendation_worker_spec.rb b/spec/workers/fasp/follow_recommendation_worker_spec.rb new file mode 100644 index 0000000000..cd06a63d3c --- /dev/null +++ b/spec/workers/fasp/follow_recommendation_worker_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Fasp::FollowRecommendationWorker, feature: :fasp do + include ProviderRequestHelper + + let(:provider) { Fabricate(:follow_recommendation_fasp) } + let(:account) { Fabricate(:account) } + let(:fetch_service) { instance_double(ActivityPub::FetchRemoteActorService, call: true) } + + let!(:stubbed_request) do + account_uri = ActivityPub::TagManager.instance.uri_for(account) + path = "/follow_recommendation/v0/accounts?accountUri=#{URI.encode_uri_component(account_uri)}" + stub_provider_request(provider, + method: :get, + path:, + response_body: [ + 'https://fedi.example.com/accounts/1', + 'https://fedi.example.com/accounts/7', + ]) + end + + before do + allow(ActivityPub::FetchRemoteActorService).to receive(:new).and_return(fetch_service) + end + + it "sends the requesting account's uri to provider and fetches received account uris" do + described_class.new.perform(account.id) + + expect(stubbed_request).to have_been_made + expect(fetch_service).to have_received(:call).with('https://fedi.example.com/accounts/1') + expect(fetch_service).to have_received(:call).with('https://fedi.example.com/accounts/7') + end + + it 'marks a running async refresh as finished' do + async_refresh = AsyncRefresh.create("fasp:follow_recommendation:#{account.id}", count_results: true) + + described_class.new.perform(account.id) + + expect(async_refresh.reload).to be_finished + end + + it 'tracks the number of fetched accounts in the async refresh' do + async_refresh = AsyncRefresh.create("fasp:follow_recommendation:#{account.id}", count_results: true) + + described_class.new.perform(account.id) + + expect(async_refresh.reload.result_count).to eq 2 + end +end