diff --git a/app/listeners/reporting_event_listener.rb b/app/listeners/reporting_event_listener.rb index 9f683a97f..bd27a8aba 100644 --- a/app/listeners/reporting_event_listener.rb +++ b/app/listeners/reporting_event_listener.rb @@ -133,11 +133,22 @@ class ReportingEventListener < BaseListener def create_bot_resolved_event(conversation, reporting_event) return unless conversation.inbox.active_bot? - # We don't want to create a bot_resolved event if there is user interaction on the conversation - return if conversation.messages.exists?(message_type: :outgoing, sender_type: 'User') + # We don't want to create a bot_resolved event if there is human interaction on the conversation. + # Human interaction = outgoing message either from a User (replied via Chatwoot UI) OR from a + # nil sender (replied directly via the connected WhatsApp app — webhook echo with IsFromMe=true, + # see app/services/whatsapp/incoming_message_wuzapi_service.rb#build_message). + # The bot itself uses sender_type 'Captain::Assistant' (or 'AgentBot'), so it stays excluded from this filter. + return if human_outgoing_messages?(conversation) bot_resolved_event = reporting_event.dup bot_resolved_event.name = 'conversation_bot_resolved' bot_resolved_event.save! end + + def human_outgoing_messages?(conversation) + conversation.messages + .where(message_type: :outgoing) + .where('sender_type = ? OR sender_type IS NULL', 'User') + .any? + end end diff --git a/lib/tasks/rebuild_bot_resolved.rake b/lib/tasks/rebuild_bot_resolved.rake new file mode 100644 index 000000000..1ccf745e8 --- /dev/null +++ b/lib/tasks/rebuild_bot_resolved.rake @@ -0,0 +1,79 @@ +# Rebuild conversation_bot_resolved retroactively +# +# Removes 'conversation_bot_resolved' reporting events that were created with the +# old (buggy) classification rule, where outgoing messages with sender_type=NULL +# (= human replied directly via the connected WhatsApp app, not via Chatwoot UI) +# were not considered as human interaction. The bot was incorrectly credited. +# +# What this task does: +# 1. Finds all conversation_bot_resolved events whose conversations contain at +# least one outgoing message with sender_id IS NULL (= replied externally). +# 2. (Optional) Saves the count + ids to a snapshot file before deleting. +# 3. Deletes those events. The classification will be re-evaluated correctly +# for any *future* resolution because the listener was fixed. +# +# Idempotent: re-running on a clean dataset is a no-op. +# +# Usage: +# # Dry-run (default) — counts only, no delete +# bundle exec rake reports:rebuild_bot_resolved +# +# # Actually delete +# APPLY=true bundle exec rake reports:rebuild_bot_resolved +# +# # Restrict to a specific account (recommended on multi-tenant prod) +# APPLY=true ACCOUNT_ID=1 bundle exec rake reports:rebuild_bot_resolved +# +# # Snapshot ids to a file before deleting (audit trail) +# APPLY=true SNAPSHOT_PATH=/tmp/bot_resolved_purge.csv bundle exec rake reports:rebuild_bot_resolved + +namespace :reports do + desc 'Remove buggy conversation_bot_resolved events created before the sender-NIL fix' + task rebuild_bot_resolved: :environment do + apply = ENV['APPLY'].to_s.casecmp('true').zero? + account_id = ENV['ACCOUNT_ID'].presence&.to_i + snapshot_path = ENV['SNAPSHOT_PATH'].presence + + base_scope = ReportingEvent.where(name: 'conversation_bot_resolved') + base_scope = base_scope.where(account_id: account_id) if account_id + + affected_conversation_ids = Message + .where(message_type: :outgoing, sender_id: nil) + .distinct + .pluck(:conversation_id) + + bad_events = base_scope.where(conversation_id: affected_conversation_ids) + + total_events = base_scope.count + purge_count = bad_events.count + + puts '=== Rebuild conversation_bot_resolved ===' + puts "Account filter: #{account_id || 'ALL'}" + puts "Total bot_resolved events in scope: #{total_events}" + puts "Events to purge (had outgoing with sender_id NULL): #{purge_count}" + puts "Mode: #{apply ? 'APPLY (will delete)' : 'DRY-RUN (no changes)'}" + + if purge_count.zero? + puts 'Nothing to do — already clean.' + next + end + + if snapshot_path + File.open(snapshot_path, 'w') do |f| + f.puts 'reporting_event_id,conversation_id,account_id,inbox_id,created_at' + bad_events.find_each(batch_size: 500) do |re| + f.puts [re.id, re.conversation_id, re.account_id, re.inbox_id, re.created_at.iso8601].join(',') + end + end + puts "Snapshot written to #{snapshot_path}" + end + + unless apply + puts 'DRY-RUN finished. Re-run with APPLY=true to delete.' + next + end + + deleted = bad_events.delete_all + puts "Deleted #{deleted} reporting_events. Bot resolution metrics will reflect the new count immediately." + end +end diff --git a/spec/listeners/reporting_event_listener_spec.rb b/spec/listeners/reporting_event_listener_spec.rb index 3f9276947..821cb2c1e 100644 --- a/spec/listeners/reporting_event_listener_spec.rb +++ b/spec/listeners/reporting_event_listener_spec.rb @@ -62,6 +62,14 @@ describe ReportingEventListener do listener.conversation_resolved(event) expect(account.reporting_events.where(name: 'conversation_bot_resolved').count).to be 0 end + + it 'does not create a conversation_bot_resolved event if a human replied directly via the connected WhatsApp app' do + # outgoing message with sender_type=NULL = WhatsApp echo (IsFromMe=true) — human replied outside Chatwoot UI + create(:message, :bot_message, account: account, inbox: agent_bot_inbox, conversation: bot_resolved_conversation) + event = Events::Base.new('conversation.resolved', Time.zone.now, conversation: bot_resolved_conversation) + listener.conversation_resolved(event) + expect(account.reporting_events.where(name: 'conversation_bot_resolved').count).to be 0 + end end end