diff --git a/scripts/evocrm-patches/index.html b/scripts/evocrm-patches/index.html
new file mode 100644
index 00000000..225b354c
--- /dev/null
+++ b/scripts/evocrm-patches/index.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+ Evo CRM
+
+
+
+
+
+
+
+
diff --git a/scripts/evocrm-patches/rxp_patches.rb b/scripts/evocrm-patches/rxp_patches.rb
new file mode 100644
index 00000000..308c8f27
--- /dev/null
+++ b/scripts/evocrm-patches/rxp_patches.rb
@@ -0,0 +1,1034 @@
+# frozen_string_literal: true
+# RXP patches EVO CRM — upstream bug fixes applied at runtime
+# Issues: https://github.com/EvolutionAPI/evo-ai-crm-community/issues/6, /7, /8
+# https://github.com/EvolutionAPI/evo-ai-frontend-community/issues/14
+
+Rails.application.config.to_prepare do
+ # ------------------------------------------------------------------
+ # Bug 1: ContactCompany validator references undefined method
+ # ------------------------------------------------------------------
+ if defined?(ContactCompany)
+ ContactCompany.class_eval do
+ unless private_method_defined?(:must_belong_to_same_account) || method_defined?(:must_belong_to_same_account)
+ private
+ def must_belong_to_same_account; end
+ end
+ end
+ Rails.logger.info "[RXP_PATCH] ContactCompany#must_belong_to_same_account no-op installed"
+ end
+
+ # ------------------------------------------------------------------
+ # Bug 2: PipelineItemsController#update_custom_fields missing before_action
+ # ------------------------------------------------------------------
+ if defined?(Api::V1::PipelineItemsController)
+ Api::V1::PipelineItemsController.class_eval do
+ before_action :set_pipeline_item, only: [:update, :destroy, :move_to_stage, :update_conversation, :update_custom_fields]
+ # Allow pipeline_stage_id and merge notes into custom_fields
+ private
+ def pipeline_item_params
+ permitted = params.require(:pipeline_item).permit(:pipeline_stage_id, custom_fields: {})
+ if params[:notes].present?
+ permitted[:custom_fields] ||= {}
+ permitted[:custom_fields][:notes] = params[:notes]
+ end
+ permitted
+ end
+ end
+ Rails.logger.info "[RXP_PATCH] PipelineItemsController — set_pipeline_item + permit pipeline_stage_id + notes"
+ end
+
+ # ------------------------------------------------------------------
+ # Bug 3: ContactCompaniesController error_response kwargs → positional
+ # ------------------------------------------------------------------
+ if defined?(Api::V1::ContactCompaniesController)
+ Api::V1::ContactCompaniesController.class_eval do
+ def create
+ r = Contacts::LinkCompanyService.new(contact: @contact, company: @company).perform
+ if r[:success]
+ success_response(data: { contact: @contact.slice(:id,:name,:type), company: @company.slice(:id,:name,:type) }, message: "linked")
+ else
+ error_response(ApiErrorCodes::BUSINESS_RULE_VIOLATION, r[:error])
+ end
+ end
+ def destroy
+ r = Contacts::UnlinkCompanyService.new(contact: @contact, company: @company).perform
+ if r[:success]
+ success_response(data: { contact: @contact.slice(:id,:name,:type), company: @company.slice(:id,:name,:type) }, message: "unlinked")
+ else
+ error_response(ApiErrorCodes::BUSINESS_RULE_VIOLATION, r[:error])
+ end
+ end
+ end
+ Rails.logger.info "[RXP_PATCH] ContactCompaniesController create/destroy fixed"
+ end
+
+ # ------------------------------------------------------------------
+ # Bug 4: PipelinesController N+1 — kanban 8s → 200ms
+ # ------------------------------------------------------------------
+ if defined?(Api::V1::PipelinesController)
+ Api::V1::PipelinesController.class_eval do
+ def index
+ @pipelines = Pipeline.all.accessible_by(Current.user).active.includes(
+ pipeline_stages: [],
+ pipeline_items: [:pipeline_stage, conversation: [:contact,:assignee,:inbox]]
+ ).order(:name)
+ success_response(
+ data: PipelineSerializer.serialize_collection(@pipelines,
+ include_stages: true, include_items: false,
+ include_tasks_info: false, include_services_info: false),
+ message: "Pipelines retrieved")
+ end
+ def show
+ success_response(
+ data: PipelineSerializer.serialize(@pipeline,
+ include_stages: true, include_items: true,
+ include_tasks_info: false, include_services_info: false),
+ message: "Pipeline retrieved")
+ end
+ def fetch_pipeline
+ @pipeline = Pipeline.all.includes(
+ :created_by, pipeline_stages: [],
+ pipeline_items: [:pipeline_stage, :contact, conversation: [:contact,:assignee,:team,:inbox]]
+ ).find(params[:id])
+ end
+ end
+ Rails.logger.info "[RXP_PATCH] PipelinesController — N+1 removed"
+ end
+
+ # ------------------------------------------------------------------
+ # Bug 5: PipelineStage.stage_type missing enum
+ # ------------------------------------------------------------------
+ if defined?(PipelineStage)
+ PipelineStage.class_eval do
+ unless defined_enums.key?("stage_type")
+ enum stage_type: { active: 0, completed: 1, cancelled: 2 }
+ end
+ end
+ Rails.logger.info "[RXP_PATCH] PipelineStage.stage_type enum"
+ end
+
+
+
+ # ------------------------------------------------------------------
+ # Bug 9: AgentsController passed current_user as 1st arg to
+ # EvoAiCoreService — service signatures expect params/data/id.
+ # EvoAiCoreService.build_headers already resolves Current.user.
+ # Symptom: all /api/v1/agents endpoints returned 500
+ # (NoMethodError: undefined method 'empty?' for an instance of User).
+ # ------------------------------------------------------------------
+ if defined?(Api::V1::AgentsController)
+ Api::V1::AgentsController.class_eval do
+ def index
+ result = EvoAiCoreService.list_agents(index_params)
+ render json: result
+ end
+ def create
+ result = EvoAiCoreService.create_agent(agent_params)
+ render json: result, status: :created
+ end
+ def update
+ result = EvoAiCoreService.update_agent(params[:id], agent_params)
+ render json: result
+ end
+ def destroy
+ EvoAiCoreService.delete_agent(params[:id])
+ head :no_content
+ end
+ end
+ Rails.logger.info "[RXP_PATCH] AgentsController — current_user removed from EvoAiCoreService calls"
+ end
+
+ # ------------------------------------------------------------------
+ # Bug 10 (LGPD): Sidekiq / ActionController logavam payloads WhatsApp
+ # completos com telefones, nomes e conteúdo de mensagens em clear.
+ # Adiciona filtros para mascarar esses campos em todos os logs de
+ # parâmetros Rails (Controllers + ActiveJob).
+ # ------------------------------------------------------------------
+ Rails.application.config.filter_parameters += [
+ # WhatsApp (Evolution API webhook payload)
+ :remoteJid, :participantAlt, :participant, :pushName, :phone, :phone_number,
+ :sender, :senderKeyDistributionMessage, :mediaKey, :fileEncSha256, :fileSha256,
+ :jpegThumbnail, :directPath, :url,
+ # Message content (qualquer nested)
+ :conversation, :extendedTextMessage, :imageMessage, :videoMessage,
+ :audioMessage, :documentMessage, :stickerMessage, :locationMessage,
+ :contactMessage, :liveLocationMessage, :caption, :text, :body,
+ # Contato / Identificação
+ :email, :phone_number, :phones, :mobile, :cpf, :cnpj, :document,
+ :identifier, :source_id
+ ]
+ Rails.logger.info "[RXP_PATCH] filter_parameters expanded — LGPD: WhatsApp payload masked"
+
+
+
+ # ------------------------------------------------------------------
+ # Bug 11 (v3 — remoteJidAlt + phone_number_from_jid, PR #112 approach)
+ # v2 patched jid_type only via fetchProfile (sync HTTP, can timeout).
+ # v3 improvements:
+ # 1. effective_remote_jid uses remoteJidAlt (already in webhook payload,
+ # zero HTTP round-trip) before falling back to fetchProfile.
+ # 2. phone_number_from_jid also patched — was still reading @raw_message
+ # directly, causing contacts created with LID digits as phone number.
+ # 3. In-place mutation centralized in effective_remote_jid so ALL
+ # downstream methods benefit automatically.
+ # ref: evolution-foundation/evo-ai-crm-community PR #112
+ # ------------------------------------------------------------------
+ if defined?(Whatsapp::EvolutionHandlers::Helpers)
+ Whatsapp::EvolutionHandlers::Helpers.module_eval do
+ unless private_method_defined?(:resolve_lid_to_phone_jid)
+ def resolve_lid_to_phone_jid(lid_number)
+ cache_key = "rxp:evolution:lid_resolve:#{lid_number}"
+ cached = ::Redis::Alfred.get(cache_key)
+ return cached if cached.present?
+
+ api_url = @inbox&.channel&.provider_config&.dig('api_url')
+ admin = @inbox&.channel&.provider_config&.dig('admin_token')
+ instance = @inbox&.channel&.provider_config&.dig('instance_name')
+ return nil if api_url.blank? || admin.blank? || instance.blank?
+
+ uri = URI.parse("#{api_url.chomp('/')}/chat/fetchProfile/#{instance}")
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = (uri.scheme == 'https')
+ http.open_timeout = 5
+ http.read_timeout = 5
+
+ req = Net::HTTP::Post.new(uri)
+ req['apikey'] = admin
+ req['Content-Type'] = 'application/json'
+ req.body = { number: "#{lid_number}@lid" }.to_json
+
+ res = http.request(req)
+ return nil unless res.code.to_i.between?(200, 299)
+
+ body = JSON.parse(res.body) rescue {}
+ wuid = body['wuid']
+ return nil unless wuid.is_a?(String) && wuid.include?('@s.whatsapp.net')
+
+ ::Redis::Alfred.setex(cache_key, wuid, 7.days)
+ wuid
+ rescue StandardError => e
+ Rails.logger.warn "[RXP_PATCH] LID resolve failed for #{lid_number}: #{e.class} #{e.message}"
+ nil
+ end
+ end
+
+ unless private_method_defined?(:effective_remote_jid)
+ def effective_remote_jid
+ jid = @raw_message[:remoteJid] || @raw_message.dig(:key, :remoteJid)
+ return jid unless jid.to_s.end_with?('@lid')
+
+ # PR #112: prefer remoteJidAlt (already in payload, no HTTP round-trip)
+ alt = @raw_message[:remoteJidAlt] || @raw_message.dig(:key, :remoteJidAlt)
+ resolved = if alt.present? && !alt.to_s.end_with?('@lid')
+ lid_digits = jid.split('@').first.split(':').first
+ begin
+ ::Redis::Alfred.setex("rxp:evolution:lid_resolve:#{lid_digits}", alt, 7.days)
+ rescue StandardError
+ nil
+ end
+ Rails.logger.info "[RXP_PATCH] LID resolved via remoteJidAlt: #{jid} → #{alt}"
+ alt
+ else
+ lid_digits = jid.split('@').first.split(':').first
+ resolve_lid_to_phone_jid(lid_digits) || jid
+ end
+
+ # Mutate in-place so ALL downstream methods see the real JID
+ if @raw_message.is_a?(Hash)
+ @raw_message[:remoteJid] = resolved if @raw_message[:remoteJid].to_s.end_with?('@lid')
+ if @raw_message.dig(:key, :remoteJid).to_s.end_with?('@lid')
+ @raw_message[:key][:remoteJid] = resolved
+ end
+ end
+
+ resolved
+ end
+ end
+
+ unless private_method_defined?(:jid_type_without_lid_patch)
+ alias_method :jid_type_without_lid_patch, :jid_type
+ def jid_type
+ effective_remote_jid # triggers in-place mutation; non-LID messages are no-op
+ jid_type_without_lid_patch
+ end
+ end
+
+ unless private_method_defined?(:phone_number_from_jid_without_lid_patch)
+ alias_method :phone_number_from_jid_without_lid_patch, :phone_number_from_jid
+ def phone_number_from_jid
+ effective_remote_jid # triggers in-place mutation; non-LID messages are no-op
+ phone_number_from_jid_without_lid_patch
+ end
+ end
+ end
+ Rails.logger.info "[RXP_PATCH] EvolutionHandlers#jid_type+phone_number_from_jid — LID→remoteJidAlt+fetchProfile v3 (bug 11, PR #112)"
+ end
+
+ # ------------------------------------------------------------------
+ # Bug 12: OAuth Applications index returns plain array, frontend expects
+ # wrapped {success, data, meta: {pagination}} → "Cannot read properties of
+ # undefined (reading 'pagination')" in console, page shows empty list.
+ # ------------------------------------------------------------------
+ if defined?(Api::V1::Oauth::ApplicationsController)
+ Api::V1::Oauth::ApplicationsController.class_eval do
+ def index
+ apps = OauthApplication.all.order(:created_at).map { |app| application_serializer(app) }
+ render json: {
+ success: true,
+ data: apps,
+ meta: {
+ pagination: {
+ current_page: 1,
+ total_pages: 1,
+ total_count: apps.length,
+ per_page: apps.length
+ }
+ },
+ message: ''
+ }
+ end
+ end
+ Rails.logger.info "[RXP_PATCH] OauthApplicationsController#index wrapped with {success,data,meta.pagination}"
+ end
+
+ # ------------------------------------------------------------------
+ # Bug 13 (RXP Aurora Copilota): block public auto-reply from bots that
+ # have bot_config['private_only'] == true. Aurora is meant to ONLY post
+ # private notes (rascunhos de resposta) — it must NEVER send a message
+ # to the customer. The upstream MessageCreator hardcodes message_type
+ # to 'outgoing' regardless. We short-circuit create_bot_reply for any
+ # bot whose bot_config flags private_only=true.
+ # Incident 2026-04-28: Aurora vazou rascunho técnico no WhatsApp
+ # da Débora Rocco e resposta solta pro Tom (5551995035947).
+ # ------------------------------------------------------------------
+ if defined?(AgentBots::MessageCreator)
+ AgentBots::MessageCreator.class_eval do
+ alias_method :create_bot_reply_without_private_only, :create_bot_reply
+
+ def create_bot_reply(content, conversation, force: false)
+ if @agent_bot.bot_config&.dig('private_only') == true
+ Rails.logger.warn(
+ "[RXP_PATCH] Aurora private_only: dropping public reply attempt from bot " \
+ "#{@agent_bot.id} (#{@agent_bot.name}) for conversation #{conversation.id}. " \
+ "Content preview: #{content.to_s[0, 80]}"
+ )
+ return nil
+ end
+ create_bot_reply_without_private_only(content, conversation, force: force)
+ end
+ end
+ Rails.logger.info '[RXP_PATCH] AgentBots::MessageCreator#create_bot_reply gated by bot_config["private_only"]'
+ end
+
+ # ------------------------------------------------------------------
+ # Bug 14 (RXP Aurora Copilota — inactivity_only mode): skip on-incoming
+ # dispatch when bot_config['inactivity_only'] == true. The bot will only
+ # be triggered by the InactivityCheckSchedulerJob after the configured
+ # idle period. This lets Aurora wait for conversation lull before
+ # generating a single contextualized private note (saves tokens).
+ # Setting allowed_conversation_statuses to a non-matching value would
+ # ALSO disable the inactivity flow (shared filter), so we patch here
+ # instead. Adopted 2026-04-28.
+ # ------------------------------------------------------------------
+ if defined?(BotRuntime::DelegationService)
+ BotRuntime::DelegationService.class_eval do
+ alias_method :delegate_without_inactivity_only, :delegate
+
+ def delegate
+ if @agent_bot.bot_config&.dig('inactivity_only') == true
+ Rails.logger.info(
+ "[RXP_PATCH] Aurora inactivity_only: skipping on-incoming dispatch for bot " \
+ "#{@agent_bot.id} (#{@agent_bot.name}) conversation=#{@conversation.display_id}. " \
+ "Will wait for InactivityCheckSchedulerJob to trigger after idle period."
+ )
+ return nil
+ end
+ delegate_without_inactivity_only
+ end
+ end
+ Rails.logger.info '[RXP_PATCH] BotRuntime::DelegationService#delegate gated by bot_config["inactivity_only"]'
+ end
+
+ # ------------------------------------------------------------------
+ # Bug 15 (RXP Aurora Copilota — auto-status by categoria): after Aurora
+ # creates a private note, parse the [Aurora] **categoria** prefix and
+ # transition the conversation status accordingly:
+ # - spam-ruido / informativo → resolved (no action needed)
+ # - comercial-* / operacional → pending (Daniel needs to act)
+ # Only fires for messages from bots with bot_config['private_only']=true,
+ # so it's scoped to Aurora-class agents. Adopted 2026-04-28.
+ # ------------------------------------------------------------------
+ if defined?(Message)
+ AURORA_STATUS_BY_CATEGORIA = {
+ 'spam-ruido' => 'resolved',
+ 'informativo' => 'resolved',
+ 'comercial-novo' => 'pending',
+ 'comercial-followup' => 'pending',
+ 'comercial-pago' => 'pending',
+ 'operacional' => 'pending'
+ }.freeze
+
+ Message.class_eval do
+ after_create_commit :rxp_aurora_auto_status_transition
+
+ def rxp_aurora_auto_status_transition
+ return unless private == true
+ return unless sender_type == 'AgentBot'
+
+ agent_bot = sender
+ return unless agent_bot.respond_to?(:bot_config)
+ return unless agent_bot.bot_config&.dig('private_only') == true
+
+ match = content.to_s.match(/\[Aurora\]\s*\*\*([\w-]+)\*\*/i)
+ return unless match
+
+ categoria = match[1].to_s.downcase.strip
+ new_status = AURORA_STATUS_BY_CATEGORIA[categoria]
+ unless new_status
+ Rails.logger.info "[RXP_PATCH] Aurora auto-status: unknown categoria '#{categoria}' (msg #{id}), no transition"
+ return
+ end
+
+ return if conversation.status == new_status
+
+ Rails.logger.info(
+ "[RXP_PATCH] Aurora auto-status: conversation #{conversation.id} " \
+ "categoria='#{categoria}' #{conversation.status} → #{new_status}"
+ )
+ begin
+ conversation.update!(status: new_status)
+ rescue StandardError => e
+ Rails.logger.warn "[RXP_PATCH] Aurora auto-status: failed to transition conv #{conversation.id}: #{e.class}: #{e.message}"
+ end
+ end
+ end
+ Rails.logger.info '[RXP_PATCH] Message#rxp_aurora_auto_status_transition installed (bug 15)'
+ end
+
+ # ------------------------------------------------------------------
+ # Bug 16 (RXP Evolution media download via getBase64FromMediaMessage):
+ # Evolution v2.3.7 webhook payload does NOT include base64 nor mediaUrl
+ # for audio/image/video/document/sticker messages. The original
+ # AttachmentProcessor#download_attachment_file logs "No media found"
+ # and drops the attachment silently. Result: 100% of WhatsApp media
+ # since 2026-04-22 was discarded.
+ #
+ # Fix: prepend a fallback that calls the Evolution endpoint
+ # POST {api_url}/chat/getBase64FromMediaMessage/{instance}
+ # which returns the decrypted media as base64. We then reuse the
+ # existing create_tempfile_from_base64 helper.
+ #
+ # Adopted 2026-04-29.
+ # ------------------------------------------------------------------
+ module RxpEvolutionMediaDownload
+ MEDIA_MESSAGE_KEYS = %w[audioMessage imageMessage videoMessage documentMessage stickerMessage documentWithCaptionMessage].freeze
+
+ def download_attachment_file
+ result = super
+ return result if result
+
+ message = @raw_message[:message] || {}
+ has_media = MEDIA_MESSAGE_KEYS.any? { |k| message[k.to_sym].present? || message[k].present? }
+ return nil unless has_media
+
+ pc = inbox.channel.provider_config || {}
+ api_url = pc['api_url'].to_s.chomp('/')
+ api_token = pc['admin_token'].to_s
+ instance_name = pc['instance_name'].to_s
+ message_id = raw_message_id.to_s
+
+ if api_url.empty? || api_token.empty? || instance_name.empty? || message_id.empty?
+ Rails.logger.warn "[RXP_PATCH] Evolution getBase64 fallback: missing config for msg #{message_id}"
+ return nil
+ end
+
+ Rails.logger.info "[RXP_PATCH] Evolution getBase64 fallback: fetching media for msg #{message_id}"
+ response = HTTParty.post(
+ "#{api_url}/chat/getBase64FromMediaMessage/#{instance_name}",
+ headers: { 'apikey' => api_token, 'Content-Type' => 'application/json' },
+ body: { message: { key: { id: message_id } } }.to_json,
+ timeout: 30
+ )
+
+ unless [200, 201].include?(response.code)
+ Rails.logger.warn "[RXP_PATCH] Evolution getBase64: HTTP #{response.code} for msg #{message_id}"
+ return nil
+ end
+
+ data = JSON.parse(response.body)
+ base64_data = data['base64']
+ if base64_data.blank?
+ Rails.logger.warn "[RXP_PATCH] Evolution getBase64: empty base64 for msg #{message_id}"
+ return nil
+ end
+
+ Rails.logger.info "[RXP_PATCH] Evolution getBase64: got #{base64_data.length}b for msg #{message_id} (#{data['mimetype']})"
+ create_tempfile_from_base64(base64_data)
+ rescue StandardError => e
+ Rails.logger.error "[RXP_PATCH] Evolution getBase64 error for msg #{raw_message_id}: #{e.class}: #{e.message}"
+ nil
+ end
+ end
+
+ if defined?(Whatsapp::EvolutionHandlers::AttachmentProcessor)
+ Whatsapp::EvolutionHandlers::AttachmentProcessor.prepend RxpEvolutionMediaDownload
+ Rails.logger.info '[RXP_PATCH] EvolutionHandlers::AttachmentProcessor — getBase64FromMediaMessage fallback (bug 16)'
+ end
+
+ # ------------------------------------------------------------------
+ # Bug 16b (RXP EvoGo media download — getBase64FromMediaMessage):
+ # EvoGo webhook payloads may include a mediaUrl that is a signed/internal
+ # URL unavailable outside the EvoGo host. When Down.download fails or
+ # no URL is present, this fallback calls the EvoGo API endpoint
+ # POST {api_url}/chat/getBase64FromMediaMessage/{instance}
+ # to retrieve the decrypted media as base64.
+ #
+ # EvoGo provider_config fields used (same as Evolution API):
+ # api_url → https://whatsapp2.resultadosexponenciais.com.br
+ # admin_token → API key header 'apikey'
+ # instance_name → instance slug (e.g. "dvl")
+ #
+ # Adopted 2026-05-25.
+ # ------------------------------------------------------------------
+ module RxpEvolutionGoMediaDownload
+ EVOGO_MEDIA_TYPES = %w[image video audio ptt document sticker].freeze
+
+ def download_attachment_file
+ result = super
+ return result if result
+
+ # Only fire for media messages
+ media_type = @evolution_go_info&.dig(:MediaType).to_s.downcase
+ return nil unless EVOGO_MEDIA_TYPES.include?(media_type)
+
+ pc = inbox.channel.provider_config || {}
+ api_url = pc['api_url'].to_s.chomp('/')
+ api_token = pc['admin_token'].to_s
+ instance_name = pc['instance_name'].to_s
+ message_id = raw_message_id.to_s
+
+ if api_url.empty? || api_token.empty? || instance_name.empty? || message_id.empty?
+ Rails.logger.warn "[RXP_PATCH] EvoGo getBase64 fallback: missing config for msg #{message_id}"
+ return nil
+ end
+
+ Rails.logger.info "[RXP_PATCH] EvoGo getBase64 fallback: fetching media for msg #{message_id} (type: #{media_type})"
+ response = HTTParty.post(
+ "#{api_url}/chat/getBase64FromMediaMessage/#{instance_name}",
+ headers: { 'apikey' => api_token, 'Content-Type' => 'application/json' },
+ body: { message: { key: { id: message_id } } }.to_json,
+ timeout: 30
+ )
+
+ unless [200, 201].include?(response.code)
+ Rails.logger.warn "[RXP_PATCH] EvoGo getBase64: HTTP #{response.code} for msg #{message_id}"
+ return nil
+ end
+
+ data = JSON.parse(response.body)
+ base64_data = data['base64']
+ if base64_data.blank?
+ Rails.logger.warn "[RXP_PATCH] EvoGo getBase64: empty base64 for msg #{message_id}"
+ return nil
+ end
+
+ Rails.logger.info "[RXP_PATCH] EvoGo getBase64: got #{base64_data.length}b for msg #{message_id} (#{data['mimetype']})"
+
+ # Decode base64 and write to tempfile (EvoGo MessagesUpsert does not have
+ # create_tempfile_from_base64 helper, so we inline it here)
+ extension = ".#{media_extension}"
+ tmpfile = Tempfile.new([message_id, extension])
+ tmpfile.binmode
+ tmpfile.write(Base64.decode64(base64_data.gsub(/^data:.*?;base64,/, '')))
+ tmpfile.rewind
+
+ content_type_str = data['mimetype'] || determine_content_type
+ filename_str = "#{message_id}#{extension}"
+
+ tmpfile.define_singleton_method(:original_filename) { filename_str }
+ tmpfile.define_singleton_method(:content_type) { content_type_str }
+ tmpfile.define_singleton_method(:size) { File.size(path) }
+
+ tmpfile
+ rescue StandardError => e
+ Rails.logger.error "[RXP_PATCH] EvoGo getBase64 error for msg #{raw_message_id}: #{e.class}: #{e.message}"
+ nil
+ end
+ end
+
+ if defined?(Whatsapp::EvolutionGoHandlers::MessagesUpsert)
+ Whatsapp::EvolutionGoHandlers::MessagesUpsert.prepend RxpEvolutionGoMediaDownload
+ Rails.logger.info '[RXP_PATCH] EvolutionGoHandlers::MessagesUpsert — getBase64FromMediaMessage fallback (bug 16b)'
+ end
+
+ # ------------------------------------------------------------------
+ # Bug 17 (RXP companies_list returns 2/242): the upstream
+ # ContactsController#companies_list applies Contact.resolved_contacts
+ # which requires email/phone_number/identifier OR contact_inboxes
+ # presence. Companies are entities (not chat endpoints), so they
+ # rarely have any of these — only 2 of 242 companies were appearing
+ # in the autocomplete when editing a contact. Same scope blocks the
+ # "Pesquisar empresa" dropdown in the contact edit modal.
+ #
+ # Fix: return ALL companies with a name, ordered alphabetically. No
+ # filtering by resolved attributes. 242 records is well within the
+ # acceptable response size for this autocomplete endpoint.
+ #
+ # Adopted 2026-04-29.
+ # ------------------------------------------------------------------
+ if defined?(Api::V1::ContactsController)
+ Api::V1::ContactsController.class_eval do
+ def companies_list
+ companies = Contact.where(type: 'company')
+ .where("BTRIM(COALESCE(name,'')) <> ''")
+ .select(:id, :name, :type, :location, :country_code)
+ .order(:name)
+
+ success_response(
+ data: companies.map { |c| { id: c.id, name: c.name } },
+ message: 'Companies list retrieved successfully'
+ )
+ end
+ end
+ Rails.logger.info '[RXP_PATCH] ContactsController#companies_list returns all named companies (bug 17)'
+ end
+
+ # ------------------------------------------------------------------
+ # Bug 18 (RXP Aurora WHATSAPP-BUSINESS): Evolution API does NOT emit
+ # MESSAGES_UPSERT for outgoing messages on WHATSAPP-BUSINESS (Meta Cloud
+ # API) instances — instead it sends SEND_MESSAGE events. Upstream
+ # IncomingMessageEvolutionService#perform had no handler for
+ # 'send.message', so all aurora bot replies were silently discarded
+ # (logged as "Unsupported event type").
+ # The payload structure for send.message mirrors messages.upsert, with
+ # key.fromMe=true. The existing process_messages_upsert + helpers#incoming?
+ # already handles fromMe=true → :outgoing message type.
+ # Fix: route 'send.message' to process_messages_upsert.
+ # Adopted 2026-05-30.
+ # ------------------------------------------------------------------
+ if defined?(Whatsapp::IncomingMessageEvolutionService)
+ Whatsapp::IncomingMessageEvolutionService.class_eval do
+ def perform
+ event_type = processed_params[:event]
+ Rails.logger.info "Evolution API: Processing event #{event_type} for instance #{processed_params[:instance]}"
+ Rails.logger.debug { "Evolution API: Full payload: #{processed_params.inspect}" }
+
+ case event_type
+ when 'messages.upsert', 'send.message'
+ process_messages_upsert
+ when 'messages.update'
+ process_messages_update
+ when 'contacts.update'
+ process_contacts_update
+ when 'chats.update', 'chats.upsert'
+ Rails.logger.info "Evolution API: Chat event #{event_type} - not processing (chat-level events)"
+ when 'qrcode.updated'
+ Rails.logger.info 'Evolution API: QR Code updated'
+ when 'connection.update'
+ process_connection_update
+ when 'logout.instance'
+ process_logout_instance
+ else
+ Rails.logger.warn "Evolution API: Unsupported event type: #{event_type}"
+ end
+ end
+ end
+ Rails.logger.info '[RXP_PATCH] IncomingMessageEvolutionService#perform — send.message routed to process_messages_upsert (bug 18)'
+ end
+
+ # ------------------------------------------------------------------
+ # Bug 19 (RXP Aurora templates via Evolution API): upstream
+ # EvolutionService#sync_templates was a no-op and #create_template
+ # silently stored templates as APPROVED locally without calling Meta.
+ # For WHATSAPP-BUSINESS instances, Evolution API exposes:
+ # GET {api_url}/template/find/{instance} → list with real Meta status
+ # POST {api_url}/template/create/{instance} → submit template to Meta
+ # Fixes:
+ # sync_templates → calls GET and upserts all templates via TemplateSync
+ # create_template → calls POST, syncs response, falls back to local PENDING
+ # Adopted 2026-05-30.
+ # ------------------------------------------------------------------
+ if defined?(Whatsapp::Providers::EvolutionService)
+ Whatsapp::Providers::EvolutionService.class_eval do
+ include Whatsapp::Providers::Concerns::TemplateSync unless
+ include?(Whatsapp::Providers::Concerns::TemplateSync) rescue false
+
+ def sync_templates
+ return if send(:api_base_path).blank? || send(:instance_name).blank?
+
+ inst = send(:instance_name)
+ base = send(:api_base_path)
+ hdrs = send(:api_headers)
+
+ Rails.logger.info "Evolution: Syncing templates from API for #{inst}"
+ response = HTTParty.get("#{base}/template/find/#{inst}", headers: hdrs, timeout: 30)
+
+ unless response.success?
+ Rails.logger.error "Evolution: Template sync failed - HTTP #{response.code}: #{response.body}"
+ return
+ end
+
+ templates = response.parsed_response
+ templates = templates['templates'] if templates.is_a?(Hash) && templates.key?('templates')
+ templates = [templates] unless templates.is_a?(Array)
+ templates = templates.select { |t| t.is_a?(Hash) && t['name'].present? }
+
+ synced = 0
+ templates.each { |t| sync_template_to_database(t); synced += 1 }
+ Rails.logger.info "Evolution: Synced #{synced} templates for #{inst}"
+ rescue StandardError => e
+ Rails.logger.error "Evolution: sync_templates error - #{e.message}"
+ end
+
+ def create_template(template_data)
+ base = send(:api_base_path)
+ inst = send(:instance_name)
+ hdrs = send(:api_headers)
+
+ if base.blank? || inst.blank?
+ template_data['status'] ||= 'PENDING'
+ return rxp_store_template_locally(template_data)
+ end
+
+ Rails.logger.info "Evolution: Submitting template to Meta via API - #{template_data['name']}"
+ response = HTTParty.post("#{base}/template/create/#{inst}",
+ headers: hdrs, body: template_data.to_json, timeout: 30)
+
+ if response.success?
+ result = response.parsed_response
+ merged = result.is_a?(Hash) ? template_data.merge(result) : template_data
+ merged['status'] ||= 'PENDING'
+ sync_template_to_database(merged)
+ merged
+ else
+ Rails.logger.error "Evolution: Template creation failed - HTTP #{response.code}: #{response.body}"
+ rxp_store_template_locally(template_data.merge('status' => 'PENDING'))
+ end
+ rescue StandardError => e
+ Rails.logger.error "Evolution: create_template error - #{e.message}"
+ rxp_store_template_locally(template_data.merge('status' => 'PENDING'))
+ end
+
+ def rxp_store_template_locally(template_data)
+ sync_template_to_database(template_data)
+ template_data
+ end
+ end
+ Rails.logger.info '[RXP_PATCH] EvolutionService#sync_templates + #create_template — Evolution API integration (bug 19)'
+ end
+
+
+# ===========================================================================
+# Bug 20 (v1) -- ConditionsFilterService: query_operator leading fix
+# ---------------------------------------------------------------------------
+# EvoAI CRM stores query_operator on the RECEIVING condition (leading format):
+# [{assignee_id, q_op: null}, {labels, q_op: "AND"}]
+# But upstream code appends q_op TRAILING (Chatwoot format), producing:
+# "assignee_id IN (:v0) NOT EXISTS(...)" - PG::SyntaxError missing AND
+# Fix: override build_query_string to strip q_op from hash (so underlying
+# methods do not append it) then prepend it to the fragment.
+# ===========================================================================
+ if defined?(AutomationRules::ConditionsFilterService)
+ AutomationRules::ConditionsFilterService.module_eval do
+ unless method_defined?(:build_query_string_without_qop_fix)
+ alias_method :build_query_string_without_qop_fix, :build_query_string
+ def build_query_string(filters, query_hash, current_index)
+ qop = (query_hash['query_operator'] || query_hash[:query_operator]).to_s.strip
+ stripped = query_hash.with_indifferent_access.merge('query_operator' => nil)
+ result = build_query_string_without_qop_fix(filters, stripped, current_index)
+ qop.present? && !@query_string.strip.empty? ? " #{qop} #{result.strip} " : result
+ end
+ end
+ end
+ Rails.logger.info '[RXP_PATCH] ConditionsFilterService#build_query_string -- query_operator leading->prepend v2 (bug 20)'
+ end
+
+# ===========================================================================
+# Bug 21 (v1) -- PermissionFilterService nil user guard (PR #143)
+# ---------------------------------------------------------------------------
+# Service-token callers (e.g. evo-ai-processor) never set Current.user, so
+# user.nil? -> user_role calls nil.role -> NoMethodError -> 500 on
+# GET /api/v1/contacts/:id/conversations, breaking transfer_to_human.
+# Fix: treat nil user as fully trusted (internal) and skip inbox filtering.
+# ===========================================================================
+ if defined?(Conversations::PermissionFilterService)
+ Conversations::PermissionFilterService.prepend(Module.new do
+ def perform
+ return conversations if user.nil?
+ super
+ end
+ end)
+ Rails.logger.info '[RXP_PATCH] PermissionFilterService#perform -- nil user guard (PR #143, bug 21)'
+ end
+
+# ===========================================================================
+# Bug 22 (v1) -- LabelConcern index+create render JSON 204->200 (PR #145)
+# ---------------------------------------------------------------------------
+# LabelConcern#index and #create set @labels but have no view and no render
+# call, so Rails responds 204 No Content. Callers (manage_conversation_labels)
+# see empty body -> conclude 0 labels -> POST does update_labels (replace) ->
+# deletes ALL labels including atendimento_ia -> AI agent loses eligibility.
+# Fix: render json: { payload: @labels } in both actions.
+# ===========================================================================
+ if defined?(LabelConcern)
+ LabelConcern.module_eval do
+ def index
+ @labels = model.label_list
+ render json: { payload: @labels }
+ end
+
+ def create
+ model.update_labels(resolve_label_titles(permitted_params[:labels]))
+ @labels = model.label_list
+ render json: { payload: @labels }
+ end
+ end
+ Rails.logger.info '[RXP_PATCH] LabelConcern#index+#create -- render JSON 204->200 (PR #145, bug 22)'
+ end
+
+# ===========================================================================
+# Bug 23 (v1) -- Conversations::FilterService assignee_type handler
+# ---------------------------------------------------------------------------
+# The frontend filter modal sends assignee_type as attribute_key with values
+# [me/assigned/unassigned/all]. filter_keys.yml only defines assignee_id ->
+# model_filters['assignee_type'] = nil -> custom_attribute_query returns '' ->
+# InvalidAttribute raised -> controller returns 400 -> frontend shows all convs
+# instead of filtered results.
+# Fix: override build_condition_query to handle assignee_type before delegating
+# to the original method. Uses @filter_values for parameterized 'me' query.
+#
+# NOTE: The real root cause is in the frontend bundle (ChatPage-BV93Mg_x.js).
+# Function _o() in the filter serializer had no case for "assignee_type" --
+# the filter was silently dropped. jo() routes single assignee_type filters to
+# GET /conversations (not POST), so _o() must emit ?assignee_type=value.
+# Frontend fix: added case"assignee_type" to _o() in the bind-mounted bundle.
+# This backend patch still applies when jo() routes to POST /conversations/filter
+# (e.g. multi-filter combinations including assignee_type).
+# ===========================================================================
+ if defined?(Conversations::FilterService)
+ Conversations::FilterService.module_eval do
+ unless method_defined?(:build_condition_query_without_assignee_type_fix)
+ alias_method :build_condition_query_without_assignee_type_fix, :build_condition_query
+
+ def build_condition_query(model_filters, query_hash, current_index)
+ attr_key = (query_hash['attribute_key'] || query_hash[:attribute_key]).to_s
+
+ if attr_key == 'assignee_type'
+ value = ((query_hash['values'] || query_hash[:values]) || []).first.to_s
+ case value
+ when 'me'
+ key = "assignee_type_me_#{current_index}"
+ @filter_values[key] = @user.id
+ "conversations.assignee_id = :#{key}"
+ when 'assigned'
+ 'conversations.assignee_id IS NOT NULL'
+ when 'unassigned'
+ 'conversations.assignee_id IS NULL'
+ else
+ ''
+ end
+ else
+ build_condition_query_without_assignee_type_fix(model_filters, query_hash, current_index)
+ end
+ end
+ end
+ end
+ Rails.logger.info '[RXP_PATCH] Conversations::FilterService#build_condition_query -- assignee_type handler v1 (bug 23)'
+ end
+
+# ===========================================================================
+# Bug 24 (v1) -- EvolutionGoHandlers::MessagesUpsert: LID stored as source_id
+# ---------------------------------------------------------------------------
+# EvoGo implemented a LID->JID swap: when sender arrives as @lid, EvoGo swaps
+# so Sender = JID (@s.whatsapp.net) and SenderAlt = LID (@lid).
+# Before the swap, SenderAlt held the real JID -> using it as source_id was
+# correct. After the swap, SenderAlt holds the LID -> source_id stored as LID
+# (e.g. "222221842833426@lid") -> frontend can't parse as phone number ->
+# conversation shows "sem canal" (no channel display).
+# Same bug: build_contact_attributes and update_contact_information set
+# contact.identifier = LID -> set_contact_for_outgoing can't match outgoing
+# echo messages by identifier.
+# Fix: treat @lid values in SenderAlt as opaque device IDs, fall back to the
+# JID-derived phone_number for source_id and identifier.
+# ===========================================================================
+ if defined?(Whatsapp::EvolutionGoHandlers::MessagesUpsert)
+ Whatsapp::EvolutionGoHandlers::MessagesUpsert.module_eval do
+ private
+
+ def determine_source_id(sender_alt_value, phone_number)
+ if sender_alt_value.present? && !sender_alt_value.to_s.include?('@lid')
+ Rails.logger.info "Evolution Go API [RXP] Using SenderAlt '#{sender_alt_value}' as source_id (JID)"
+ sender_alt_value
+ else
+ Rails.logger.info "Evolution Go API [RXP] SenderAlt is LID or blank -- using phone_number '#{phone_number}' as source_id"
+ phone_number
+ end
+ end
+
+ def build_contact_attributes(push_name, phone_number, sender_alt_value, is_whatsapp_number)
+ attributes = { name: push_name }
+ # Only use SenderAlt as identifier when it is a JID, not a LID
+ if sender_alt_value.present? && !sender_alt_value.to_s.include?('@lid')
+ attributes[:identifier] = sender_alt_value
+ end
+ attributes[:phone_number] = "+#{phone_number}" if is_whatsapp_number
+ attributes
+ end
+
+ def update_contact_information(push_name, phone_number, sender_alt_value, is_whatsapp_number)
+ updates = {}
+ updates[:name] = push_name if @contact.name == phone_number && push_name.present?
+
+ jid_alt = sender_alt_value.present? && !sender_alt_value.to_s.include?('@lid')
+ if @contact.identifier.blank? && jid_alt
+ updates[:identifier] = sender_alt_value
+ Rails.logger.info "Evolution Go API [RXP]: Adding identifier #{sender_alt_value} to contact #{@contact.id}"
+ end
+
+ if @contact.phone_number.blank? && is_whatsapp_number
+ updates[:phone_number] = "+#{phone_number}"
+ Rails.logger.info "Evolution Go API [RXP]: Adding phone_number +#{phone_number} to contact #{@contact.id}"
+ end
+
+ @contact.update!(updates) if updates.any?
+ end
+ end
+ Rails.logger.info '[RXP_PATCH] EvolutionGoHandlers::MessagesUpsert -- LID source_id fix (bug 24)'
+ end
+
+# ===========================================================================
+# Bug 25 (v2) -- PipelineItemsController#index N+1: stage_movements + contact
+# ---------------------------------------------------------------------------
+# PipelineItem has TWO entity paths:
+# - conversation-based: pipeline_item.conversation.contact (most items)
+# - lead-based: pipeline_item.contact (direct contact_id, no conversation)
+# v1 patch only preloaded contact inside conversation includes -- lead-based
+# items still fired 1 SQL per item.
+#
+# days_in_current_stage calls stage_movements.order(:created_at).last which
+# adds ORDER BY and creates a NEW query scope even when stage_movements is
+# preloaded -- bypassing the batch include entirely.
+#
+# Fix A: add { contact: { avatar_attachment: :blob } } at top level so
+# lead-based items get batch-loaded too.
+# Fix B: patch PipelineItem#days_in_current_stage to sort in-memory when
+# the association is already loaded.
+# ===========================================================================
+ if defined?(Api::V1::PipelineItemsController)
+ Api::V1::PipelineItemsController.prepend(Module.new do
+ def index
+ contact_includes = [:labels, { avatar_attachment: :blob }]
+ @pipeline_items = @pipeline.pipeline_items.includes(
+ :pipeline_stage,
+ :stage_movements,
+ { contact: contact_includes },
+ conversation: [
+ :assignee,
+ :team,
+ { contact: contact_includes },
+ { messages: [:attachments, { sender: { avatar_attachment: :blob } }] }
+ ]
+ )
+ apply_filters
+ apply_sorting
+ labels_by_title = Label.all.index_by(&:title)
+ Thread.current[:rxp_labels_by_title] = labels_by_title
+ begin
+ success_response(
+ data: PipelineItemSerializer.serialize_collection(
+ @pipeline_items,
+ include_entity: true,
+ include_labels: true,
+ labels_by_title: labels_by_title
+ ),
+ message: 'Pipeline items retrieved successfully'
+ )
+ ensure
+ Thread.current[:rxp_labels_by_title] = nil
+ end
+ end
+ end)
+ Rails.logger.info '[RXP_PATCH] PipelineItemsController#index -- N+1 fix v3 labels+lead-contact (bug 25)'
+ end
+
+ if defined?(ContactSerializer)
+ ContactSerializer.singleton_class.prepend(Module.new do
+ def serialize(contact, include_labels: true, **options)
+ result = super(contact, include_labels: false, **options)
+ if include_labels
+ labels_by_title = Thread.current[:rxp_labels_by_title]
+ result['labels'] = contact.labels.map do |tag|
+ lbl = labels_by_title ? labels_by_title[tag.name] : Label.find_by(title: tag.name)
+ { name: tag.name, color: lbl&.color || '#1f93ff' }
+ end
+ end
+ result
+ end
+ end)
+ Rails.logger.info '[RXP_PATCH] ContactSerializer -- labels_by_title thread-local (no Label.find_by per tag) (bug 25)'
+ end
+
+ if defined?(PipelineItem)
+ PipelineItem.prepend(Module.new do
+ def days_in_current_stage
+ last_movement = if association(:stage_movements).loaded?
+ stage_movements.max_by(&:created_at)
+ else
+ stage_movements.order(:created_at).last
+ end
+ start_time = last_movement&.created_at || entered_at
+ ((Time.current - start_time) / 1.day).round
+ end
+ end)
+ Rails.logger.info '[RXP_PATCH] PipelineItem#days_in_current_stage -- in-memory sort when preloaded (bug 25)'
+ end
+
+ # ===========================================================================
+ # Bug 26 (v2 -- fix definitivo) -- Aurora MESSAGES_UPDATE: raw_message_id
+ # usava :messageId (ID interno do Prisma do evo.xmacna.ai) em vez de :keyId
+ # (wamid real = source_id no CRM).
+ #
+ # Fluxo do evento messages.update recebido do Evolution API v2 (Cloud API):
+ # data.messageId = "cmqs7ozt705cepl01mlhe3z42" <- Prisma ID (inutil aqui)
+ # data.keyId = "wamid.HBgM..." <- wamid = source_id do CRM
+ #
+ # Com a prioridade errada (:messageId primeiro), find_message_by_source_id
+ # nunca encontrava a mensagem e logava "Message not found for update: cmqs7..."
+ # O evento chegava ao CRM mas era descartado silenciosamente.
+ #
+ # Fix: inverter prioridade -- :keyId primeiro, :messageId como fallback.
+ # Seguro: para messages.upsert, nenhum dos dois existe e o fallback
+ # @raw_message.dig(:key, :id) continua funcionando normalmente.
+ #
+ # Verificacao: payload de teste em 2026-06-30 confirmou que apos o fix
+ # o evento chega, o lookup por keyId encontra a mensagem e o status atualiza.
+ # ===========================================================================
+ if defined?(Whatsapp::EvolutionHandlers::Helpers)
+ Whatsapp::EvolutionHandlers::Helpers.module_eval do
+ unless private_method_defined?(:raw_message_id_without_keyid_fix)
+ alias_method :raw_message_id_without_keyid_fix, :raw_message_id
+ def raw_message_id
+ @raw_message[:keyId] || @raw_message[:messageId] || @raw_message.dig(:key, :id)
+ end
+ end
+ end
+ Rails.logger.info '[RXP_PATCH] Bug 26 v2 -- EvolutionHandlers#raw_message_id keyId>messageId fix (MESSAGES_UPDATE aurora)'
+ end
+
+end
+
+# ===========================================================================
+# Bug 27 -- Filtro de conversas perdido ao paginar (scroll infinito)
+# Root cause: loadMoreConversations sempre chama GET /conversations?page=N
+# sem os filtros ativos, mesmo quando a carga inicial foi feita via
+# POST /conversations/filter. Resultado: página 2+ traz conversas sem filtro.
+#
+# Fix (frontend JS — /opt/evocrm-patches/assets/ChatPage-BV93Mg_x.js):
+# 1. filterConversations() armazena o corpo do filtro em window._rxpFilterBody
+# 2. getConversations() quando page>1 e _rxpFilterBody presente redireciona
+# para POST /conversations/filter com page, garantindo filtro preservado.
+# 3. _rxpFilterBody é limpo quando page===1 (nova busca sem filtro).
+#
+# Patch aplicado em: /opt/evocrm-patches/assets/ChatPage-BV93Mg_x.js
+# Backup em: ChatPage-BV93Mg_x.js.bak-bug27
+# ===========================================================================