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