Add policy to filter notifications from bots (#38494) (#38809)

This commit is contained in:
Evan Prodromou 2026-06-09 08:28:53 -04:00 committed by GitHub
parent 4fcb28e081
commit 9c493c20b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 229 additions and 4 deletions

View File

@ -31,7 +31,8 @@ class Api::V1::Notifications::PoliciesController < Api::BaseController
:filter_not_following,
:filter_not_followers,
:filter_new_accounts,
:filter_private_mentions
:filter_private_mentions,
:filter_bots
)
end
end

View File

@ -32,7 +32,8 @@ class Api::V2::Notifications::PoliciesController < Api::BaseController
:for_not_followers,
:for_new_accounts,
:for_private_mentions,
:for_limited_accounts
:for_limited_accounts,
:for_bots
)
end
end

View File

@ -8,6 +8,7 @@ export interface NotificationPolicyJSON {
for_new_accounts: NotificationPolicyValue;
for_private_mentions: NotificationPolicyValue;
for_limited_accounts: NotificationPolicyValue;
for_bots: NotificationPolicyValue;
summary: {
pending_requests_count: number;
pending_notifications_count: number;

View File

@ -88,6 +88,13 @@ export const PolicyControls: React.FC = () => {
[dispatch],
);
const handleFilterBots = useCallback(
(value: string) => {
changeFilter(dispatch, 'for_bots', value);
},
[dispatch],
);
if (!notificationPolicy) return null;
const options = [
@ -209,6 +216,24 @@ export const PolicyControls: React.FC = () => {
/>
}
/>
<SelectWithLabel
value={notificationPolicy.for_bots}
onChange={handleFilterBots}
options={options}
label={
<FormattedMessage
id='notifications.policy.filter_bots_title'
defaultMessage='Bots'
/>
}
hint={
<FormattedMessage
id='notifications.policy.filter_bots_hint'
defaultMessage='Accounts marked as automated'
/>
}
/>
</div>
</section>
);

View File

@ -48,6 +48,9 @@ export const IgnoreNotificationsModal = ({ filterType }) => {
case 'for_limited_accounts':
title = <FormattedMessage id='ignore_notifications_modal.limited_accounts_title' defaultMessage='Ignore notifications from moderated accounts?' />;
break;
case 'for_bots':
title = <FormattedMessage id='ignore_notifications_modal.bots_title' defaultMessage='Ignore notifications from bots?' />;
break;
}
return (

View File

@ -780,6 +780,7 @@
"home.pending_critical_update.link": "See updates",
"home.pending_critical_update.title": "Critical security update available!",
"home.show_announcements": "Show announcements",
"ignore_notifications_modal.bots_title": "Ignore notifications from bots?",
"ignore_notifications_modal.disclaimer": "Mastodon cannot inform users that you've ignored their notifications. Ignoring notifications will not stop the messages themselves from being sent.",
"ignore_notifications_modal.filter_instead": "Filter instead",
"ignore_notifications_modal.filter_to_act_users": "You'll still be able to accept, reject, or report users",
@ -1042,6 +1043,8 @@
"notifications.policy.drop": "Ignore",
"notifications.policy.drop_hint": "Send to the void, never to be seen again",
"notifications.policy.filter": "Filter",
"notifications.policy.filter_bots_hint": "Accounts marked as automated",
"notifications.policy.filter_bots_title": "Bots",
"notifications.policy.filter_hint": "Send to filtered notifications inbox",
"notifications.policy.filter_limited_accounts_hint": "Limited by server moderators",
"notifications.policy.filter_limited_accounts_title": "Moderated accounts",

View File

@ -5,6 +5,7 @@
# Table name: notification_policies
#
# id :bigint(8) not null, primary key
# for_bots :integer default("accept"), not null
# for_limited_accounts :integer default("filter"), not null
# for_new_accounts :integer default("accept"), not null
# for_not_followers :integer default("accept"), not null
@ -36,6 +37,7 @@ class NotificationPolicy < ApplicationRecord
enum :for_new_accounts, { accept: 0, filter: 1, drop: 2 }, suffix: :new_accounts
enum :for_private_mentions, { accept: 0, filter: 1, drop: 2 }, suffix: :private_mentions
enum :for_limited_accounts, { accept: 0, filter: 1, drop: 2 }, suffix: :limited_accounts
enum :for_bots, { accept: 0, filter: 1, drop: 2 }, suffix: :bots
def summarize!
@pending_requests_count = pending_notification_requests.first
@ -59,6 +61,10 @@ class NotificationPolicy < ApplicationRecord
self.for_private_mentions = value ? :filter : :accept
end
def filter_bots=(value)
self.for_bots = value ? :filter : :accept
end
private
def pending_notification_requests

View File

@ -8,6 +8,7 @@ class REST::NotificationPolicySerializer < ActiveModel::Serializer
:for_new_accounts,
:for_private_mentions,
:for_limited_accounts,
:for_bots,
:summary
def summary

View File

@ -5,6 +5,7 @@ class REST::V1::NotificationPolicySerializer < ActiveModel::Serializer
:filter_not_followers,
:filter_new_accounts,
:filter_private_mentions,
:filter_bots,
:summary
def summary
@ -29,4 +30,8 @@ class REST::V1::NotificationPolicySerializer < ActiveModel::Serializer
def filter_private_mentions
!object.accept_private_mentions?
end
def filter_bots
!object.accept_bots?
end
end

View File

@ -97,6 +97,10 @@ class NotifyService < BaseService
WHERE ancestors.mention_id IS NOT NULL AND s.account_id = :recipient_id AND s.visibility = 3
SQL
end
def from_bot?
@sender.bot?
end
end
class DropCondition < BaseCondition
@ -120,7 +124,8 @@ class NotifyService < BaseService
blocked_by_not_following_policy? ||
blocked_by_not_followers_policy? ||
blocked_by_new_accounts_policy? ||
blocked_by_private_mentions_policy?
blocked_by_private_mentions_policy? ||
blocked_by_bots_policy?
end
private
@ -160,6 +165,10 @@ class NotifyService < BaseService
def blocked_by_limited_accounts_policy?
@policy.drop_limited_accounts? && (@options[:silenced] || @sender.silenced?) && not_following?
end
def blocked_by_bots_policy?
@policy.drop_bots? && from_bot?
end
end
class FilterCondition < BaseCondition
@ -172,7 +181,8 @@ class NotifyService < BaseService
filtered_by_not_following_policy? ||
filtered_by_not_followers_policy? ||
filtered_by_new_accounts_policy? ||
filtered_by_private_mentions_policy?
filtered_by_private_mentions_policy? ||
filtered_by_bots_policy?
end
private
@ -196,6 +206,10 @@ class NotifyService < BaseService
def filtered_by_limited_accounts_policy?
@policy.filter_limited_accounts? && (@options[:silenced] || @sender.silenced?) && not_following?
end
def filtered_by_bots_policy?
@policy.filter_bots? && from_bot?
end
end
def call(recipient, type, activity, **options)

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddForBotsToNotificationPolicies < ActiveRecord::Migration[8.1]
def change
add_column :notification_policies, :for_bots, :integer, default: 0, null: false
end
end

View File

@ -818,6 +818,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_05_05_155103) do
create_table "notification_policies", force: :cascade do |t|
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.integer "for_bots", default: 0, null: false
t.integer "for_limited_accounts", default: 1, null: false
t.integer "for_new_accounts", default: 0, null: false
t.integer "for_not_followers", default: 0, null: false

View File

@ -28,4 +28,33 @@ RSpec.describe NotificationPolicy do
)
end
end
describe 'for_bots attribute' do
subject { Fabricate(:notification_policy) }
it 'defaults to accept' do
expect(subject.for_bots).to eq 'accept'
end
it 'can be set to filter' do
subject.update!(for_bots: 'filter')
expect(subject.reload.for_bots).to eq 'filter'
end
it 'can be set to drop' do
subject.update!(for_bots: 'drop')
expect(subject.reload.for_bots).to eq 'drop'
end
it 'can be set to accept' do
subject.update!(for_bots: 'accept')
expect(subject.reload.for_bots).to eq 'accept'
end
it 'validates input' do
expect do
subject.update!(for_bots: 'block')
end.to raise_error(ArgumentError, "'block' is not a valid for_bots")
end
end
end

View File

@ -33,6 +33,7 @@ RSpec.describe 'Policies' do
filter_not_followers: false,
filter_new_accounts: false,
filter_private_mentions: true,
filter_bots: false,
summary: a_hash_including(
pending_requests_count: 1,
pending_notifications_count: 0
@ -63,11 +64,17 @@ RSpec.describe 'Policies' do
filter_not_followers: false,
filter_new_accounts: false,
filter_private_mentions: true,
filter_bots: false,
summary: a_hash_including(
pending_requests_count: 0,
pending_notifications_count: 0
)
)
end
it 'updates filter_bots' do
put '/api/v1/notifications/policy', headers: headers, params: { filter_bots: true }
expect(response.parsed_body).to include(filter_bots: true)
end
end
end

View File

@ -34,6 +34,7 @@ RSpec.describe 'Policies' do
for_new_accounts: 'accept',
for_private_mentions: 'filter',
for_limited_accounts: 'filter',
for_bots: 'accept',
summary: a_hash_including(
pending_requests_count: 1,
pending_notifications_count: 0
@ -66,6 +67,7 @@ RSpec.describe 'Policies' do
for_new_accounts: 'accept',
for_private_mentions: 'filter',
for_limited_accounts: 'drop',
for_bots: 'accept',
summary: a_hash_including(
pending_requests_count: 0,
pending_notifications_count: 0
@ -73,4 +75,13 @@ RSpec.describe 'Policies' do
)
end
end
describe 'updating bots policy' do
it 'accepts for_bots parameter' do
put '/api/v2/notifications/policy', headers: headers, params: { for_bots: 'filter' }
expect(response).to have_http_status(200)
expect(response.parsed_body).to include(for_bots: 'filter')
end
end
end

View File

@ -272,6 +272,61 @@ RSpec.describe NotifyService do
expect(subject.drop?).to be true
end
end
context 'with bot policies' do
let(:bot_sender) { Fabricate(:account, bot: true) }
let(:human_sender) { Fabricate(:account, bot: false) }
let(:original_status) { Fabricate(:status) }
let(:recipient) { Fabricate(:account) }
def reblog_notification(from)
activity = Fabricate(:status, account: from, reblog: original_status)
Fabricate(:notification, type: :reblog, activity: activity, from_account: from, account: recipient)
end
before do
recipient.create_notification_policy!(
for_not_following: :accept,
for_not_followers: :accept,
for_new_accounts: :accept,
for_private_mentions: :accept,
for_limited_accounts: :accept,
for_bots: bots_policy
)
end
context 'when recipient is dropping bots' do
let(:bots_policy) { :drop }
it 'drops bot reblogs' do
notification = reblog_notification(bot_sender)
expect(described_class.new(notification).drop?).to be true
end
it 'keeps human reblogs' do
notification = reblog_notification(human_sender)
expect(described_class.new(notification).drop?).to be false
end
end
context 'when recipient is filtering bots' do
let(:bots_policy) { :filter }
it 'does not drop bot reblogs' do
notification = reblog_notification(bot_sender)
expect(described_class.new(notification).drop?).to be false
end
end
context 'when recipient is accepting bots' do
let(:bots_policy) { :accept }
it 'does not drop bot reblogs' do
notification = reblog_notification(bot_sender)
expect(described_class.new(notification).drop?).to be false
end
end
end
end
end
@ -518,6 +573,61 @@ RSpec.describe NotifyService do
end
end
end
context 'with bot policies' do
let(:bot_sender) { Fabricate(:account, bot: true) }
let(:human_sender) { Fabricate(:account, bot: false) }
let(:original_status) { Fabricate(:status) }
let(:recipient) { Fabricate(:account) }
def reblog_notification(from)
activity = Fabricate(:status, account: from, reblog: original_status)
Fabricate(:notification, type: :reblog, activity: activity, from_account: from, account: recipient)
end
before do
recipient.create_notification_policy!(
for_not_following: :accept,
for_not_followers: :accept,
for_new_accounts: :accept,
for_private_mentions: :accept,
for_limited_accounts: :accept,
for_bots: bots_policy
)
end
context 'when recipient is dropping bots' do
let(:bots_policy) { :drop }
it 'does not filter bot reblogs' do
notification = reblog_notification(bot_sender)
expect(described_class.new(notification).filter?).to be false
end
end
context 'when recipient is filtering bots' do
let(:bots_policy) { :filter }
it 'filters bot reblogs' do
notification = reblog_notification(bot_sender)
expect(described_class.new(notification).filter?).to be true
end
it 'keeps human reblogs' do
notification = reblog_notification(human_sender)
expect(described_class.new(notification).filter?).to be false
end
end
context 'when recipient is accepting bots' do
let(:bots_policy) { :accept }
it 'does not filter bot reblogs' do
notification = reblog_notification(bot_sender)
expect(described_class.new(notification).filter?).to be false
end
end
end
end
end
end