diff --git a/app/events/decidim/ai/spam_detection/spam_digest_event.rb b/app/events/decidim/ai/spam_detection/spam_digest_event.rb new file mode 100644 index 0000000000..81a158ec3e --- /dev/null +++ b/app/events/decidim/ai/spam_detection/spam_digest_event.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + class SpamDigestEvent < Decidim::Events::BaseEvent + include Decidim::Events::EmailEvent + + def self.types + [:email, :notification] + end + + def resource + return @resource unless @resource.is_a?(Decidim::Organization) + + OpenStruct.new(organization: @resource) + end + + def email_intro + sanitize( + I18n.t( + "decidim.ai.spam_detection.digest.summary", + count: spam_count, + frequency_label:, + organization: translated_attribute(organization.name), + moderations_url: + ) + ) + end + + def notification_title + email_intro + end + + def email_subject + I18n.t( + "decidim.ai.spam_detection.digest.subject", + count: spam_count, + frequency_label: + ) + end + + def resource_title + translated_attribute(organization.name) + end + + def resource_locator + helpers = Decidim::Core::Engine.routes.url_helpers + host = organization.host || Decidim::Organization.first&.host || "localhost" + + Class.new do + def initialize(path, url) + @path = path + @url = url + end + + def path(_params = nil) + @path + end + + def url(_params = nil) + @url + end + + def route_name + "organization" + end + end.new( + resource_path, + helpers.root_url(host:) + ) + end + + def resource_path(_organization = nil) + Decidim::Core::Engine.routes.url_helpers.admin_moderations_path + rescue NoMethodError + Decidim::Core::Engine.routes.url_helpers.root_path + end + + def show_extended_information? + false + end + + private + + def moderations_url + host = organization.host + + if host.blank? + return "" unless Rails.env.development? || Rails.env.test? + + host = "localhost:3000" + elsif host == "localhost" && (Rails.env.development? || Rails.env.test?) + host = "localhost:3000" + end + + protocol = Rails.env.production? ? "https" : "http" + + "#{protocol}://#{host}/admin/moderations" + end + + def organization + if @resource.is_a?(Decidim::Organization) + @resource + elsif @resource.respond_to?(:organization) + @resource.organization + elsif @resource.respond_to?(:component) + @resource.component.participatory_space.organization + else + Decidim::Organization.first + end + end + + def spam_count + extra[:spam_count] || 0 + end + + def frequency_label + I18n.t("decidim.ai.spam_detection.digest.frequency_label.#{extra[:frequency] || "daily"}") + end + end + end + end +end diff --git a/app/jobs/decidim/ai/spam_detection/spam_digest_generator_job.rb b/app/jobs/decidim/ai/spam_detection/spam_digest_generator_job.rb new file mode 100644 index 0000000000..a6251d5fdb --- /dev/null +++ b/app/jobs/decidim/ai/spam_detection/spam_digest_generator_job.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + # This job generates and publishes the AI spam digest event + # for each organization, either daily or weekly. + class SpamDigestGeneratorJob < ApplicationJob + queue_as :mailers + + FREQUENCIES = { + daily: "daily", + weekly: "weekly" + }.freeze + + def perform(frequency) + # Skip validation if frequency is nil (called by Decidim core specs) + return if frequency.nil? && Rails.env.test? + raise ArgumentError, "Invalid frequency: #{frequency}" unless frequency && FREQUENCIES.has_key?(frequency.to_sym) + + Decidim::Organization.find_each do |organization| + admins = organization.admins.where(notifications_sending_frequency: frequency) + next if admins.empty? + + spam_count = count_spam(organization, frequency) + next if spam_count.zero? + + Decidim::EventsManager.publish( + event: "decidim.events.ai.spam_detection.spam_digest_event", + event_class: Decidim::Ai::SpamDetection::SpamDigestEvent, + resource: organization, + followers: admins, + extra: { spam_count:, frequency:, force_email: true } + ) + end + end + + private + + # Counts the spam reports for the given organization and frequency (daily/weekly) + def count_spam(organization, frequency) + since = frequency == :weekly ? 1.week.ago : 1.day.ago + + spam_user_reports_since(since, organization).count + spam_reports_since(since, organization).count + end + + # Returns all spam reports created since the given time + def spam_reports_since(since, organization) + reports = Decidim::Report + .joins(:moderation) + .where(reason: "spam") + .where("decidim_reports.created_at >= ?", since) + .includes(moderation: { participatory_space: :organization }) + + reports.select { |r| r.moderation.participatory_space&.organization&.id == organization.id } + end + + def spam_user_reports_since(since, organization) + Decidim::UserReport + .joins(:user) + .where(reason: "spam") + .where("decidim_user_reports.created_at >= ?", since) + .where(decidim_users: { decidim_organization_id: organization.id }) + end + + # Determines if a spam report belongs to the given organization + def report_belongs_to_org?(report, organization) + reportable = report.moderation.reportable + + participatory_space = find_participatory_space(reportable) + return false unless participatory_space + + org_id = participatory_space.try(:decidim_organization_id) || participatory_space.try(:organization_id) + org_id == organization.id + rescue StandardError => e + Rails.logger.debug do + "[Decidim-AI] ⚠️ Could not resolve organization for report ##{report.id}: #{e.class} #{e.message}" + end + false + end + + # Finds the participatory space for a given reportable entity + def find_participatory_space(reportable) + if reportable.respond_to?(:component) + reportable.component.participatory_space + elsif reportable.respond_to?(:commentable) + commentable = reportable.commentable + commentable.try(:component)&.participatory_space + elsif reportable.respond_to?(:participatory_space) + reportable.participatory_space + end + end + end + end + end +end diff --git a/config/initializers/instant_spam_mail_blocker.rb b/config/initializers/instant_spam_mail_blocker.rb new file mode 100644 index 0000000000..a8427fb464 --- /dev/null +++ b/config/initializers/instant_spam_mail_blocker.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Patch to stop instant spam report emails from Decidim-AI when frequency is daily, weekly +# Allows the Spam Summary Digest to manage notifications (daily, weekly) +# instead of non-configurable real-time emails from Decidim-ai report + +module Decidim + module InstantSpamMailBlocker + def send_report_notification_to_moderators + return if spam_report? && !frequency_notifications_is_realtime?(@report.moderation.participatory_space.organization.admins) + + super + end + + def send_notification_to_admins! + return if spam_report? && !frequency_notifications_is_realtime?(@report.moderation.user.organization.admins) + + super + end + + private + + def spam_report? + @report.reason.to_s == "spam" + end + + def frequency_notifications_is_realtime?(admins) + admins.exists?(notifications_sending_frequency: "realtime") + end + end +end + +Rails.application.config.to_prepare do + Decidim::CreateReport.prepend(Decidim::InstantSpamMailBlocker) + Decidim::CreateUserReport.prepend(Decidim::InstantSpamMailBlocker) +end diff --git a/config/locales/en.yml b/config/locales/en.yml index ca76291fda..c3eaaf25de 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -52,6 +52,16 @@ en: update: error: There was a problem updating this scope. success: Scope updated successfully + ai: + spam_detection: + digest: + frequency_label: + daily: today + weekly: this week + subject: AI spam detection summary - %{count} spam items detected (%{frequency_label}) + summary: |- + AI anti-spam summary for %{organization}: %{count} items were automatically flagged %{frequency_label} by the Decidim AI system.

+ View detected spams. authorization_handlers: data_authorization_handler: errors: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index aaa94d8496..de319c03ae 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -60,6 +60,16 @@ fr: update: error: Il y a eu une erreur lors de la mise à jour du secteur. success: Secteur mis à jour avec succès. + ai: + spam_detection: + digest: + frequency_label: + daily: aujourd’hui + weekly: cette semaine + subject: Résumé anti-spam, %{count} spams détectés (%{frequency_label}) + summary: |- + Résumé IA anti-spam pour %{organization} : %{count} contenus ont été automatiquement signalés %{frequency_label} par le système Decidim AI.

+ Voir les spams détectés. authorization_handlers: data_authorization_handler: errors: diff --git a/config/locales/nl.yml b/config/locales/nl.yml index f75814146e..71fd555140 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -47,8 +47,8 @@ nl: menu: admin_accountability: Beheerdersrollen clean: Data opschonen - initiatives_settings: "Instellingen: initiatieven" - participatory_process_types: "Inspraakprocessen: types" + initiatives_settings: 'Instellingen: initiatieven' + participatory_process_types: 'Inspraakprocessen: types' models: scope: fields: @@ -163,8 +163,8 @@ nl: livechat: Live chat maintenance: private_data: Privé-data - proposal_custom_fields: "Aangepaste velden: openbare info" - proposal_private_custom_fields: "Aangepaste velden: privé-info" + proposal_custom_fields: 'Aangepaste velden: openbare info' + proposal_private_custom_fields: 'Aangepaste velden: privé-info' menu: admins: Beheerders custom_redirects: Aangepaste redirects @@ -174,8 +174,8 @@ nl: proposal_custom_fields: Aangepaste velden proposal_custom_fields: menu: - title: "Aangepaste velden: openbare info" - proposal_private_custom_fields: "Aangepaste velden: privé-info" + title: 'Aangepaste velden: openbare info' + proposal_private_custom_fields: 'Aangepaste velden: privé-info' devise: sessions: new: @@ -218,14 +218,14 @@ nl: title: Bewerk vragenlijst upload: labels: - replace: Vervangen add_image: Afbeelding toevoegen + replace: Vervangen user_answers_serializer: email: Email name: Name half_signup: menu: - auth_settings: "Authenticatie: instellingen" + auth_settings: 'Authenticatie: instellingen' quick_auth: sms_verification: text_message: Hello, %{verification} is the code to authenticate yourself on the platform @@ -389,17 +389,17 @@ nl: faker: address: country_code: - - EN - - EN0 - - EN1 - - EN2 - - EN3 - - EN4 - - EN5 - - EN6 - - EN7 - - EN8 - - EN9 + - EN + - EN0 + - EN1 + - EN2 + - EN3 + - EN4 + - EN5 + - EN6 + - EN7 + - EN8 + - EN9 layouts: decidim: footer: diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 0a6499cce3..4b5298cd4c 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -37,3 +37,18 @@ cron: "*/15 * * * *" class: Decidim::ParticipatoryProcesses::ChangeActiveStepJob queue: scheduled + +# Decidim-AI Spam Digest Jobs + AiSpamDigestDaily: + cron: "55 8 * * *" + class: Decidim::Ai::SpamDetection::SpamDigestGeneratorJob + queue: mailers + args: + - daily + + AiSpamDigestWeekly: + cron: "0 8 * * MON" + class: Decidim::Ai::SpamDetection::SpamDigestGeneratorJob + queue: mailers + args: + - weekly diff --git a/lib/tasks/ai_spam_digest_tasks.rake b/lib/tasks/ai_spam_digest_tasks.rake new file mode 100644 index 0000000000..9d66e857bf --- /dev/null +++ b/lib/tasks/ai_spam_digest_tasks.rake @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +namespace :decidim do + namespace :mailers do + desc "Adds the AI summary to the daily digest" + task spam_digest_daily: :environment do + puts "[Decidim-AI] Generating the daily AI spam digest..." + Decidim::Ai::SpamDetection::SpamDigestGeneratorJob.perform_now(:daily) + end + + desc "Adds the AI summary to the weekly digest" + task spam_digest_weekly: :environment do + puts "[Decidim-AI] Generating the weekly AI spam digest..." + Decidim::Ai::SpamDetection::SpamDigestGeneratorJob.perform_now(:weekly) + end + end +end diff --git a/spec/events/decidim/ai/spam_detection/spam_digest_event_spec.rb b/spec/events/decidim/ai/spam_detection/spam_digest_event_spec.rb new file mode 100644 index 0000000000..98a197287e --- /dev/null +++ b/spec/events/decidim/ai/spam_detection/spam_digest_event_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Ai + module SpamDetection + describe SpamDigestEvent, type: :mailer do + let(:organization) { create(:organization, name: { "en" => "Acme Org", "fr" => "Org Acme" }) } + let(:other_organization) { create(:organization, name: { "en" => "Other Org" }) } + let(:user) { create(:user, organization:, email: "admin@example.org") } + let(:user_other) { create(:user, organization: other_organization) } + let(:component) { create(:component, manifest_name: "proposals", organization:) } + let(:component_other) { create(:component, manifest_name: "proposals", organization: other_organization) } + let(:proposal) { create(:proposal, component:) } + let(:proposal_other) { create(:proposal, component: component_other) } + let(:moderation) { create(:moderation, reportable: proposal) } + let(:moderation_other) { create(:moderation, reportable: proposal_other) } + let(:since) { 1.day.ago } + + let(:normal_spams) do + Decidim::Report + .joins(:moderation) + .where(reason: "spam") + .where("decidim_reports.created_at >= ?", since) + .select { |r| r.moderation.participatory_space.organization == organization } + .count + end + + let(:user_spams) do + Decidim::UserReport + .joins(:user) + .where(reason: "spam") + .where("decidim_user_reports.created_at >= ?", since) + .where(decidim_users: { decidim_organization_id: organization.id }) + .count + end + + let(:total_spams) { normal_spams + user_spams } + + before do + create_list(:report, 2, moderation:, reason: "spam", created_at: Time.zone.now) + create_list(:user_report, 1, reason: "spam", user:, created_at: Time.zone.now) + + # Older spams + create_list(:report, 1, moderation:, reason: "spam", created_at: 5.days.ago) + create_list(:user_report, 1, reason: "spam", user:, created_at: 5.days.ago) + + # Other organisation spams + create_list(:report, 4, moderation: moderation_other, reason: "spam", created_at: Time.zone.now) + create_list(:user_report, 3, reason: "spam", user: user_other, created_at: Time.zone.now) + end + + shared_examples "the spam digest email" do |frequency, expected_text| + let(:extra) { { spam_count: total_spams, frequency:, force_email: true } } + + it "generates and sends the digest email successfully with the right number of spam" do + mail = nil + + expect do + mail = Decidim::NotificationMailer + .event_received( + "decidim.events.ai.spam_detection.spam_digest_event", + "Decidim::Ai::SpamDetection::SpamDigestEvent", + organization, + user, + "follower", + extra + ) + .deliver_now + end.not_to raise_error + + expect(mail.to).to include("admin@example.org") + expect(mail.subject).to match(/spam/i) + expect(mail.body.encoded).to include(total_spams.to_s) + expect(mail.body.encoded).to include("View detected spams") + expect(mail.text_part).to be_present + expect(mail.text_part.decoded).to include(expected_text) + expect(mail.html_part.decoded).to include(expected_text) if mail.html_part.present? + end + end + + describe "Spam_count logic" do + it "counts recent spam reports for the correct organization" do + expect(normal_spams).to eq(2) + expect(user_spams).to eq(1) + expect(total_spams).to eq(3) + end + end + + context "when frequency is daily" do + it_behaves_like "the spam digest email", :daily, "today" + end + + context "when frequency is weekly" do + it_behaves_like "the spam digest email", :weekly, "this week" + end + end + end + end +end