diff --git a/app/controllers/api/v1/notifications/policies_controller.rb b/app/controllers/api/v1/notifications/policies_controller.rb index 9d70c283be..0cad5fe0b8 100644 --- a/app/controllers/api/v1/notifications/policies_controller.rb +++ b/app/controllers/api/v1/notifications/policies_controller.rb @@ -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 diff --git a/app/controllers/api/v2/notifications/policies_controller.rb b/app/controllers/api/v2/notifications/policies_controller.rb index 637587967f..de20dd071e 100644 --- a/app/controllers/api/v2/notifications/policies_controller.rb +++ b/app/controllers/api/v2/notifications/policies_controller.rb @@ -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 diff --git a/app/javascript/mastodon/api_types/notification_policies.ts b/app/javascript/mastodon/api_types/notification_policies.ts index 1c3970782c..40ca89e78f 100644 --- a/app/javascript/mastodon/api_types/notification_policies.ts +++ b/app/javascript/mastodon/api_types/notification_policies.ts @@ -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; diff --git a/app/javascript/mastodon/features/notifications/components/policy_controls.tsx b/app/javascript/mastodon/features/notifications/components/policy_controls.tsx index a4743b0c22..03fb3530ca 100644 --- a/app/javascript/mastodon/features/notifications/components/policy_controls.tsx +++ b/app/javascript/mastodon/features/notifications/components/policy_controls.tsx @@ -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 = () => { /> } /> + + + } + hint={ + + } + /> ); diff --git a/app/javascript/mastodon/features/ui/components/ignore_notifications_modal.jsx b/app/javascript/mastodon/features/ui/components/ignore_notifications_modal.jsx index 3ef771ed76..d6962152c5 100644 --- a/app/javascript/mastodon/features/ui/components/ignore_notifications_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/ignore_notifications_modal.jsx @@ -48,6 +48,9 @@ export const IgnoreNotificationsModal = ({ filterType }) => { case 'for_limited_accounts': title = ; break; + case 'for_bots': + title = ; + break; } return ( diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 511ae3503c..b6ccc679f1 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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", diff --git a/app/models/notification_policy.rb b/app/models/notification_policy.rb index 73a13b92a8..cf850999f9 100644 --- a/app/models/notification_policy.rb +++ b/app/models/notification_policy.rb @@ -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 diff --git a/app/serializers/rest/notification_policy_serializer.rb b/app/serializers/rest/notification_policy_serializer.rb index 3902c1a04a..d3256e49a9 100644 --- a/app/serializers/rest/notification_policy_serializer.rb +++ b/app/serializers/rest/notification_policy_serializer.rb @@ -8,6 +8,7 @@ class REST::NotificationPolicySerializer < ActiveModel::Serializer :for_new_accounts, :for_private_mentions, :for_limited_accounts, + :for_bots, :summary def summary diff --git a/app/serializers/rest/v1/notification_policy_serializer.rb b/app/serializers/rest/v1/notification_policy_serializer.rb index e1bbdc44ff..8e06af4558 100644 --- a/app/serializers/rest/v1/notification_policy_serializer.rb +++ b/app/serializers/rest/v1/notification_policy_serializer.rb @@ -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 diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index af2f74ce56..9d62b2e505 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -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) diff --git a/db/migrate/20260425144553_add_for_bots_to_notification_policies.rb b/db/migrate/20260425144553_add_for_bots_to_notification_policies.rb new file mode 100644 index 0000000000..e31f4e4bf9 --- /dev/null +++ b/db/migrate/20260425144553_add_for_bots_to_notification_policies.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 51e2cef103..3341b513d1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/spec/models/notification_policy_spec.rb b/spec/models/notification_policy_spec.rb index 7d1b494dd5..a6ea32758d 100644 --- a/spec/models/notification_policy_spec.rb +++ b/spec/models/notification_policy_spec.rb @@ -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 diff --git a/spec/requests/api/v1/notifications/policies_spec.rb b/spec/requests/api/v1/notifications/policies_spec.rb index ac24501526..6d6b8d5a67 100644 --- a/spec/requests/api/v1/notifications/policies_spec.rb +++ b/spec/requests/api/v1/notifications/policies_spec.rb @@ -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 diff --git a/spec/requests/api/v2/notifications/policies_spec.rb b/spec/requests/api/v2/notifications/policies_spec.rb index f080bc730f..73383163c3 100644 --- a/spec/requests/api/v2/notifications/policies_spec.rb +++ b/spec/requests/api/v2/notifications/policies_spec.rb @@ -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 diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb index 9927fa9f04..df51bce8bf 100644 --- a/spec/services/notify_service_spec.rb +++ b/spec/services/notify_service_spec.rb @@ -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