diff --git a/app/services/delivery_antenna_service.rb b/app/services/delivery_antenna_service.rb new file mode 100644 index 0000000000..2d53571d87 --- /dev/null +++ b/app/services/delivery_antenna_service.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +class DeliveryAntennaService + def call(status, update, stl_home) + @status = status + @account = @status.account + @update = update || true + + if stl_home + delivery_stl! + else + delivery! + end + end + + private + + def delivery! + tag_ids = @status.tags.pluck(:id) + domain = @account.domain || Rails.configuration.x.local_domain + + antennas = Antenna.availables + antennas = antennas.left_joins(:antenna_domains).where(any_domains: true).or(Antenna.left_joins(:antenna_domains).where(antenna_domains: { name: domain })) + + antennas = Antenna.where(id: antennas.select(:id)) + antennas = antennas.left_joins(:antenna_accounts).where(any_accounts: true).or(Antenna.left_joins(:antenna_accounts).where(antenna_accounts: { account: @account })) + + tag_ids = @status.tags.pluck(:id) + antennas = Antenna.where(id: antennas.select(:id)) + antennas = antennas.left_joins(:antenna_tags).where(any_tags: true).or(Antenna.left_joins(:antenna_tags).where(antenna_tags: { tag_id: tag_ids })) + + antennas = antennas.where(account_id: Account.without_suspended.joins(:user).select('accounts.id').where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)) + antennas = antennas.where(account: @status.account.followers) if [:public, :public_unlisted, :login, :limited].exclude?(@status.visibility.to_sym) + antennas = antennas.where(account: @status.mentioned_accounts) if @status.visibility.to_sym == :limited + antennas = antennas.where(with_media_only: false) unless @status.with_media? + antennas = antennas.where(ignore_reblog: false) if @status.reblog? + antennas = antennas.where(stl: false) + + collection = AntennaCollection.new(@status, @update, false) + + antennas.in_batches do |ans| + ans.each do |antenna| + next unless antenna.enabled? + next if antenna.keywords&.any? && antenna.keywords&.none? { |keyword| @status.text.include?(keyword) } + next if antenna.exclude_keywords&.any? { |keyword| @status.text.include?(keyword) } + next if antenna.exclude_accounts&.include?(@status.account_id) + next if antenna.exclude_domains&.include?(domain) + next if antenna.exclude_tags&.any? { |tag_id| tag_ids.include?(tag_id) } + + collection.push(antenna) + end + end + + collection.deliver! + end + + def delivery_stl! + antennas = Antenna.available_stls + antennas = antennas.where(account_id: Account.without_suspended.joins(:user).select('accounts.id').where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)) + + home_post = !@account.domain.nil? || @status.reblog? || [:public, :public_unlisted, :login].exclude?(@status.visibility.to_sym) + antennas = antennas.where(account: @account.followers).or(antennas.where(account: @account)).where.not(list_id: 0) if home_post + + collection = AntennaCollection.new(@status, @update, home_post) + + antennas.in_batches do |ans| + ans.each do |antenna| + next if antenna.expired? + + collection.push(antenna) + end + end + + collection.deliver! + end + + class AntennaCollection + def initialize(status, update, stl_home = false) # rubocop:disable Style/OptionalBooleanParameter + @status = status + @update = update + @stl_home = stl_home + @home_account_ids = [] + @list_ids = [] + end + + def push(antenna) + if antenna.list_id.zero? + @home_account_ids << { id: antenna.account_id, antenna_id: antenna.id } if @home_account_ids.none? { |id| id[:id] == antenna.account_id } + elsif @list_ids.none? { |id| id[:id] == antenna.list_id } + @list_ids << { id: antenna.list_id, antenna_id: antenna.id } + end + end + + def deliver! + lists = @list_ids + homes = @home_account_ids + + if lists.any? + FeedInsertWorker.push_bulk(lists) do |list| + [@status.id, list[:id], 'list', { 'update' => @update, 'stl_home' => @stl_home || false, 'antenna_id' => list[:antenna_id] }] + end + end + + return unless homes.any? + + FeedInsertWorker.push_bulk(homes) do |home| + [@status.id, home[:id], 'home', { 'update' => @update, 'antenna_id' => home[:antenna_id] }] + end + end + end +end diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 8b6671d31c..94f9612725 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -135,62 +135,11 @@ class FanOutOnWriteService < BaseService end def deliver_to_stl_antennas! - antennas = Antenna.available_stls - antennas = antennas.where(account_id: Account.without_suspended.joins(:user).select('accounts.id').where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)) - - home_post = !@account.domain.nil? || @status.reblog? || [:public, :public_unlisted, :login].exclude?(@status.visibility.to_sym) - antennas = antennas.where(account: @account.followers).or(antennas.where(account: @account)).where.not(list_id: 0) if home_post - - collection = AntennaCollection.new(@status, @options[:update], home_post) - - antennas.in_batches do |ans| - ans.each do |antenna| - next if antenna.expired? - - collection.push(antenna) - end - end - - collection.deliver! + DeliveryAntennaService.new.call(@status, @options[:update], true) end def deliver_to_antennas! - tag_ids = @status.tags.pluck(:id) - domain = @account.domain || Rails.configuration.x.local_domain - - antennas = Antenna.availables - antennas = antennas.left_joins(:antenna_domains).where(any_domains: true).or(Antenna.left_joins(:antenna_domains).where(antenna_domains: { name: domain })) - - antennas = Antenna.where(id: antennas.select(:id)) - antennas = antennas.left_joins(:antenna_accounts).where(any_accounts: true).or(Antenna.left_joins(:antenna_accounts).where(antenna_accounts: { account: @account })) - - tag_ids = @status.tags.pluck(:id) - antennas = Antenna.where(id: antennas.select(:id)) - antennas = antennas.left_joins(:antenna_tags).where(any_tags: true).or(Antenna.left_joins(:antenna_tags).where(antenna_tags: { tag_id: tag_ids })) - - antennas = antennas.where(account_id: Account.without_suspended.joins(:user).select('accounts.id').where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)) - antennas = antennas.where(account: @status.account.followers) if [:public, :public_unlisted, :login, :limited].exclude?(@status.visibility.to_sym) - antennas = antennas.where(account: @status.mentioned_accounts) if @status.visibility.to_sym == :limited - antennas = antennas.where(with_media_only: false) unless @status.with_media? - antennas = antennas.where(ignore_reblog: false) if @status.reblog? - antennas = antennas.where(stl: false) - - collection = AntennaCollection.new(@status, @options[:update], false) - - antennas.in_batches do |ans| - ans.each do |antenna| - next unless antenna.enabled? - next if antenna.keywords&.any? && antenna.keywords&.none? { |keyword| @status.text.include?(keyword) } - next if antenna.exclude_keywords&.any? { |keyword| @status.text.include?(keyword) } - next if antenna.exclude_accounts&.include?(@status.account_id) - next if antenna.exclude_domains&.include?(domain) - next if antenna.exclude_tags&.any? { |tag_id| tag_ids.include?(tag_id) } - - collection.push(antenna) - end - end - - collection.deliver! + DeliveryAntennaService.new.call(@status, @options[:update], false) end def deliver_to_mentioned_followers! @@ -264,39 +213,4 @@ class FanOutOnWriteService < BaseService def broadcastable_unlisted2? @status.unlisted_visibility? && @status.public_searchability? && !@status.reblog? && !@account.silenced? end - - class AntennaCollection - def initialize(status, update, stl_home = false) # rubocop:disable Style/OptionalBooleanParameter - @status = status - @update = update - @stl_home = stl_home - @home_account_ids = [] - @list_ids = [] - end - - def push(antenna) - if antenna.list_id.zero? - @home_account_ids << { id: antenna.account_id, antenna_id: antenna.id } if @home_account_ids.none? { |id| id[:id] == antenna.account_id } - elsif @list_ids.none? { |id| id[:id] == antenna.list_id } - @list_ids << { id: antenna.list_id, antenna_id: antenna.id } - end - end - - def deliver! - lists = @list_ids - homes = @home_account_ids - - if lists.any? - FeedInsertWorker.push_bulk(lists) do |list| - [@status.id, list[:id], 'list', { 'update' => @update, 'stl_home' => @stl_home || false, 'antenna_id' => list[:antenna_id] }] - end - end - - if homes.any? - FeedInsertWorker.push_bulk(homes) do |home| - [@status.id, home[:id], 'home', { 'update' => @update, 'antenna_id' => home[:antenna_id] }] - end - end - end - end end diff --git a/spec/fabricators/antenna_domain_fabricator.rb b/spec/fabricators/antenna_domain_fabricator.rb new file mode 100644 index 0000000000..4299138462 --- /dev/null +++ b/spec/fabricators/antenna_domain_fabricator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Fabricator(:antenna_domain) do + antenna { Fabricate.build(:antenna) } + name 'example.com' + exclude false +end diff --git a/spec/fabricators/antenna_tag_fabricator.rb b/spec/fabricators/antenna_tag_fabricator.rb new file mode 100644 index 0000000000..24e0bc6444 --- /dev/null +++ b/spec/fabricators/antenna_tag_fabricator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Fabricator(:antenna_tag) do + antenna { Fabricate.build(:antenna) } + tag { Fabricate.build(:tag) } + exclude false +end diff --git a/spec/services/delivery_antenna_service_spec.rb b/spec/services/delivery_antenna_service_spec.rb new file mode 100644 index 0000000000..b2df43ac26 --- /dev/null +++ b/spec/services/delivery_antenna_service_spec.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe DeliveryAntennaService, type: :service do + subject { described_class.new } + + let(:last_active_at) { Time.now.utc } + let(:visibility) { 'public' } + let(:searchability) { 'public' } + let(:domain) { nil } + let(:tags) { Tag.find_or_create_by_names(['hoge']) } + let(:status) do + url = domain.present? ? 'https://example.com/status' : nil + status = Fabricate(:status, account: alice, visibility: visibility, searchability: searchability, text: 'Hello my body #hoge', url: url) + status.tags << tags.first if tags.present? + status + end + + let!(:alice) { Fabricate(:account, domain: domain, uri: domain ? "https://#{domain}.com/alice" : '') } + let!(:bob) { Fabricate(:user, current_sign_in_at: last_active_at).account } + let!(:tom) { Fabricate(:user, current_sign_in_at: last_active_at).account } + let!(:ohagi) { Fabricate(:user, current_sign_in_at: last_active_at).account } + + let!(:antenna) { nil } + let!(:empty_antenna) { nil } + + let(:stl_home) { false } + + before do + bob.follow!(alice) + alice.block!(ohagi) + + allow(redis).to receive(:publish) + + subject.call(status, true, stl_home) + end + + def home_feed_of(account) + HomeFeed.new(account).get(10).map(&:id) + end + + def list_feed_of(list) + ListFeed.new(list).get(10).map(&:id) + end + + def antenna_feed_of(antenna) + AntennaFeed.new(antenna).get(10).map(&:id) + end + + def antenna_with_account(owner, target_account, **options) + antenna = Fabricate(:antenna, account: owner, any_accounts: false, **options) + Fabricate(:antenna_account, antenna: antenna, account: target_account) + antenna + end + + def antenna_with_domain(owner, target_domain, **options) + antenna = Fabricate(:antenna, account: owner, any_domains: false, **options) + Fabricate(:antenna_domain, antenna: antenna, name: target_domain) + antenna + end + + def antenna_with_tag(owner, target_tag, **options) + antenna = Fabricate(:antenna, account: owner, any_tags: false, **options) + tag = Tag.find_or_create_by_names([target_tag])[0] + Fabricate(:antenna_tag, antenna: antenna, tag: tag) + antenna + end + + def antenna_with_keyword(owner, target_keyword, **options) + Fabricate(:antenna, account: owner, any_keywords: false, keywords: [target_keyword], **options) + end + + def list(owner) + Fabricate(:list, account: owner) + end + + context 'with account' do + let!(:antenna) { antenna_with_account(bob, alice) } + let!(:empty_antenna) { antenna_with_account(tom, bob) } + + it 'detecting antenna' do + expect(antenna_feed_of(antenna)).to include status.id + end + + it 'not detecting antenna' do + expect(antenna_feed_of(empty_antenna)).to_not include status.id + end + end + + context 'when blocked' do + let!(:antenna) { antenna_with_account(bob, alice) } + let!(:empty_antenna) { antenna_with_account(ohagi, alice) } + + it 'detecting antenna' do + expect(antenna_feed_of(antenna)).to include status.id + end + + it 'not detecting antenna' do + expect(antenna_feed_of(empty_antenna)).to_not include status.id + end + end + + context 'with domain' do + let(:domain) { 'fast.example.com' } + let!(:antenna) { antenna_with_domain(bob, 'fast.example.com') } + let!(:empty_antenna) { antenna_with_domain(tom, 'ohagi.example.com') } + + it 'detecting antenna' do + expect(antenna_feed_of(antenna)).to include status.id + end + + it 'not detecting antenna' do + expect(antenna_feed_of(empty_antenna)).to_not include status.id + end + end + + context 'with tag' do + let!(:antenna) { antenna_with_tag(bob, 'hoge') } + let!(:empty_antenna) { antenna_with_tag(tom, 'hog') } + + it 'detecting antenna' do + expect(antenna_feed_of(antenna)).to include status.id + end + + it 'not detecting antenna' do + expect(antenna_feed_of(empty_antenna)).to_not include status.id + end + end + + context 'with keyword' do + let!(:antenna) { antenna_with_keyword(bob, 'body') } + let!(:empty_antenna) { antenna_with_keyword(tom, 'anime') } + + it 'detecting antenna' do + expect(antenna_feed_of(antenna)).to include status.id + end + + it 'not detecting antenna' do + expect(antenna_feed_of(empty_antenna)).to_not include status.id + end + end + + context 'with domain and excluding account' do + let(:domain) { 'fast.example.com' } + let!(:antenna) { antenna_with_domain(bob, 'fast.example.com', exclude_accounts: [tom.id]) } + let!(:empty_antenna) { antenna_with_domain(tom, 'fast.example.com', exclude_accounts: [alice.id]) } + + it 'detecting antenna' do + expect(antenna_feed_of(antenna)).to include status.id + end + + it 'not detecting antenna' do + expect(antenna_feed_of(empty_antenna)).to_not include status.id + end + end + + context 'with domain and excluding keyword' do + let(:domain) { 'fast.example.com' } + let!(:antenna) { antenna_with_domain(bob, 'fast.example.com', exclude_keywords: ['aaa']) } + let!(:empty_antenna) { antenna_with_domain(tom, 'fast.example.com', exclude_keywords: ['body']) } + + it 'detecting antenna' do + expect(antenna_feed_of(antenna)).to include status.id + end + + it 'not detecting antenna' do + expect(antenna_feed_of(empty_antenna)).to_not include status.id + end + end + + context 'with domain and excluding tag' do + let(:domain) { 'fast.example.com' } + let!(:antenna) { antenna_with_domain(bob, 'fast.example.com') } + let!(:empty_antenna) { antenna_with_domain(tom, 'fast.example.com', exclude_tags: [Tag.find_or_create_by_names(['hoge']).first.id]) } + + it 'detecting antenna' do + expect(antenna_feed_of(antenna)).to include status.id + end + + it 'not detecting antenna' do + expect(antenna_feed_of(empty_antenna)).to_not include status.id + end + end + + context 'with keyword and excluding domain' do + let(:domain) { 'fast.example.com' } + let!(:antenna) { antenna_with_keyword(bob, 'body', exclude_domains: ['ohagi.example.com']) } + let!(:empty_antenna) { antenna_with_keyword(tom, 'body', exclude_domains: ['fast.example.com']) } + + it 'detecting antenna' do + expect(antenna_feed_of(antenna)).to include status.id + end + + it 'not detecting antenna' do + expect(antenna_feed_of(empty_antenna)).to_not include status.id + end + end + + context 'when multiple antennas with keyword' do + let!(:antenna) { antenna_with_keyword(bob, 'body') } + let!(:empty_antenna) { antenna_with_keyword(tom, 'body') } + + it 'detecting antenna' do + expect(antenna_feed_of(antenna)).to include status.id + expect(antenna_feed_of(empty_antenna)).to include status.id + end + end + + context 'when multiple antennas insert home with keyword' do + let!(:antenna) { antenna_with_keyword(bob, 'body', insert_feeds: true) } + let!(:empty_antenna) { antenna_with_keyword(tom, 'body', insert_feeds: true) } + + it 'detecting antenna' do + expect(antenna_feed_of(antenna)).to include status.id + expect(home_feed_of(bob)).to include status.id + expect(antenna_feed_of(empty_antenna)).to include status.id + expect(home_feed_of(tom)).to include status.id + end + end + + context 'when multiple antennas insert list with keyword' do + let!(:antenna) { antenna_with_keyword(bob, 'body', insert_feeds: true, list: list(bob).id) } + let!(:empty_antenna) { antenna_with_keyword(tom, 'body', insert_feeds: true, list: list(tom).id) } + + it 'detecting antenna' do + expect(antenna_feed_of(antenna)).to include status.id + expect(list_feed_of(antenna.list)).to include status.id + expect(antenna_feed_of(empty_antenna)).to include status.id + expect(list_feed_of(empty_antenna.list)).to include status.id + end + end +end diff --git a/spec/services/fan_out_on_write_service_spec.rb b/spec/services/fan_out_on_write_service_spec.rb index 1c81880432..e3d8c952dc 100644 --- a/spec/services/fan_out_on_write_service_spec.rb +++ b/spec/services/fan_out_on_write_service_spec.rb @@ -133,7 +133,7 @@ RSpec.describe FanOutOnWriteService, type: :service do let!(:antenna) { antenna_with_account(bob, alice) } let!(:empty_antenna) { antenna_with_account(tom, alice) } - it 'is added to the list feed of list follower' do + it 'is added to the antenna feed of antenna follower' do expect(antenna_feed_of(antenna)).to include status.id expect(antenna_feed_of(empty_antenna)).to_not include status.id end