From 572b9ccd10a2e91eefceb6bcfaf4b0c3d95e77e3 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Sun, 24 May 2026 16:51:28 -0300 Subject: [PATCH] fix(captain): send WhatsApp reply context to Hermes --- .../app/services/captain/hermes/client.rb | 5 +- .../captain/hermes/reply_context_builder.rb | 95 +++++++++++++++++ .../services/captain/hermes/client_spec.rb | 100 ++++++++++++++++++ 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 enterprise/app/services/captain/hermes/reply_context_builder.rb create mode 100644 spec/enterprise/services/captain/hermes/client_spec.rb diff --git a/enterprise/app/services/captain/hermes/client.rb b/enterprise/app/services/captain/hermes/client.rb index 68f41864f..77bb8d808 100644 --- a/enterprise/app/services/captain/hermes/client.rb +++ b/enterprise/app/services/captain/hermes/client.rb @@ -71,10 +71,13 @@ class Captain::Hermes::Client contact_attrs = contact&.custom_attributes.to_h.with_indifferent_access cpf_digits = contact_attrs[:cpf].to_s.gsub(/\D/, '') history = contact_history_snapshot(contact, conversation) + reply_context_builder = Captain::Hermes::ReplyContextBuilder.new(message: message, conversation: conversation) + reply_context = reply_context_builder.perform { - message: content_override.presence || text_for_hermes(message), + message: reply_context_builder.wrap_message(content_override.presence || text_for_hermes(message)), image_urls: image_urls_for_hermes(message), + reply_context: reply_context, contact_name: contact&.name, contact_first_name: contact&.name.to_s.split.first, contact_id: conversation.contact_id, diff --git a/enterprise/app/services/captain/hermes/reply_context_builder.rb b/enterprise/app/services/captain/hermes/reply_context_builder.rb new file mode 100644 index 000000000..3603385f3 --- /dev/null +++ b/enterprise/app/services/captain/hermes/reply_context_builder.rb @@ -0,0 +1,95 @@ +class Captain::Hermes::ReplyContextBuilder + def initialize(message:, conversation:) + @message = message + @conversation = conversation + end + + def perform + return nil if reply_to_external_id.blank? + + { + external_id: reply_to_external_id, + found: quoted_message.present?, + quoted_message: quoted_message_snapshot + }.compact + end + + def wrap_message(current_text) + return current_text if reply_context.blank? + + "#{formatted_reply_context}\n\n[RESPOSTA ATUAL DO CLIENTE]\n#{current_text}" + end + + private + + attr_reader :message, :conversation + + def reply_context + @reply_context ||= perform + end + + def formatted_reply_context + return missing_reply_context unless reply_context[:found] + + quoted = reply_context[:quoted_message] + quoted_content = quoted[:content].presence || quoted[:attachment_summary].presence || '[mensagem sem texto]' + + <<~TEXT.strip + [CONTEXTO DE RESPOSTA DO WHATSAPP] + O cliente respondeu citando uma mensagem anterior. + Interprete a resposta atual como referência direta a essa mensagem citada. + Mensagem citada (#{quoted[:sender_label]}, #{quoted[:created_at]}): #{quoted_content} + TEXT + end + + def missing_reply_context + <<~TEXT.strip + [CONTEXTO DE RESPOSTA DO WHATSAPP] + O cliente respondeu citando uma mensagem anterior, mas o Chatwoot não encontrou o conteúdo da mensagem citada. + ID externo citado: #{reply_context[:external_id]} + TEXT + end + + def reply_to_external_id + @reply_to_external_id ||= message.in_reply_to_external_id.presence || + message.content_attributes.to_h['in_reply_to_external_id'].presence || + message.content_attributes.to_h[:in_reply_to_external_id].presence + end + + def quoted_message + @quoted_message ||= conversation.messages.find_by(source_id: reply_to_external_id) + end + + def quoted_message_snapshot + return nil if quoted_message.blank? + + { + id: quoted_message.id, + external_id: quoted_message.source_id, + message_type: quoted_message.message_type, + sender_label: sender_label, + sender_name: quoted_message.sender&.available_name, + content: quoted_message_content, + attachment_summary: attachment_summary, + created_at: quoted_message.created_at&.iso8601 + }.compact + end + + def sender_label + return 'cliente' if quoted_message.incoming? + return 'atendente/Hermes' if quoted_message.outgoing? + + 'sistema' + end + + def quoted_message_content + quoted_message.content.to_s.truncate(1200) + end + + def attachment_summary + return nil if quoted_message.attachments.blank? + + types = quoted_message.attachments.filter_map(&:file_type) + "anexos: #{types.join(', ')}" + end +end diff --git a/spec/enterprise/services/captain/hermes/client_spec.rb b/spec/enterprise/services/captain/hermes/client_spec.rb new file mode 100644 index 000000000..be5c8f11e --- /dev/null +++ b/spec/enterprise/services/captain/hermes/client_spec.rb @@ -0,0 +1,100 @@ +require 'rails_helper' + +RSpec.describe Captain::Hermes::Client do + describe '#build_payload' do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account, name: 'Cliente Teste') } + let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) } + let(:conversation) do + create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox) + end + let(:client) { described_class.new(inbox) } + + it 'includes quoted WhatsApp message context in the Hermes-visible message' do + quoted_message = create( + :message, + account: account, + inbox: inbox, + conversation: conversation, + message_type: :outgoing, + content: 'A suite Alexa esta disponivel por R$ 199.', + source_id: 'wamid.quoted-message' + ) + reply = create( + :message, + account: account, + inbox: inbox, + conversation: conversation, + message_type: :incoming, + content: 'Pode reservar essa', + source_id: 'wamid.reply-message', + content_attributes: { in_reply_to_external_id: quoted_message.source_id } + ) + + payload = client.send(:build_payload, message: reply, conversation: conversation) + + expect(payload[:reply_context]).to include( + external_id: quoted_message.source_id, + found: true + ) + expect(payload[:reply_context][:quoted_message]).to include( + id: quoted_message.id, + message_type: 'outgoing', + sender_label: 'atendente/Hermes', + content: 'A suite Alexa esta disponivel por R$ 199.' + ) + expect(payload[:message]).to include('[CONTEXTO DE RESPOSTA DO WHATSAPP]') + expect(payload[:message]).to include('A suite Alexa esta disponivel por R$ 199.') + expect(payload[:message]).to include('[RESPOSTA ATUAL DO CLIENTE]') + expect(payload[:message]).to include('Pode reservar essa') + end + + it 'keeps the combined incoming text while adding quote context' do + quoted_message = create( + :message, + account: account, + inbox: inbox, + conversation: conversation, + message_type: :outgoing, + content: 'Temos opcoes com hidro e garagem privativa.', + source_id: 'wamid.quoted-options' + ) + reply = create( + :message, + account: account, + inbox: inbox, + conversation: conversation, + message_type: :incoming, + content: 'Essa', + content_attributes: { in_reply_to_external_id: quoted_message.source_id } + ) + + payload = client.send( + :build_payload, + message: reply, + conversation: conversation, + content_override: "Quero ver as suites\nEssa" + ) + + expect(payload[:message]).to include('Temos opcoes com hidro e garagem privativa.') + expect(payload[:message]).to include("Quero ver as suites\nEssa") + end + + it 'does not add reply context when the message is not a WhatsApp reply' do + message = create( + :message, + account: account, + inbox: inbox, + conversation: conversation, + message_type: :incoming, + content: 'Oi, tem suite disponivel?' + ) + + payload = client.send(:build_payload, message: message, conversation: conversation) + + expect(payload[:reply_context]).to be_nil + expect(payload[:message]).to eq('Oi, tem suite disponivel?') + end + end +end