feat: lead attribution tracking - landing page origin detection

- Cria modelo LeadClick para registrar cliques das landing pages
- Cria modelo LandingHost para mapear hostname → inbox_id
- Endpoint público POST /track/click para receber eventos de clique
- Leads::AttributionMatcherService para correlacionar clique com conversa
- Integração com IncomingMessageWuzapiService para atribuição automática
- API REST para gerenciar LandingHosts por inbox (index/create/destroy)
- UI: nova aba 'Landing Pages' nas configurações da caixa de entrada
- Dashboard API client dedicado (landingHosts.js)
- RuboCop: refatora shift_signature_name, TrackingController, AttributionMatcherService e WuzapiService
This commit is contained in:
Rodrigo Borba 2026-03-02 14:40:35 -03:00
parent 7a84cb3433
commit 118f52e239
27 changed files with 840 additions and 99 deletions

View File

@ -215,6 +215,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, :captain_unit_id, :typing_delay, :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, :captain_unit_id, :typing_delay,
:message_signature_enabled, :message_signature_default_name, :message_signature_day_name,
:message_signature_night_even_name, :message_signature_night_odd_name,
:message_signature_night_shift_start, :message_signature_night_shift_end,
{ csat_config: [:display_type, :message, :button_text, :language, { csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }], { survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid,

View File

@ -0,0 +1,44 @@
class Api::V1::Accounts::LandingHostsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox, only: [:index, :create]
before_action :fetch_landing_host, only: [:destroy]
def index
@landing_hosts = LandingHost.where(inbox_id: @inbox.id)
render json: @landing_hosts
end
def create
@landing_host = LandingHost.new(landing_host_params.merge(inbox_id: @inbox.id, active: true))
if @landing_host.save
render json: @landing_host, status: :created
else
render json: { error: @landing_host.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
@landing_host.destroy!
head :no_content
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Inbox not found' }, status: :not_found
end
def fetch_landing_host
# Garantimos que a pessoa só possa apagar LandingHosts de Inboxes que pertencem a ela
valid_inbox_ids = Current.account.inboxes.pluck(:id)
@landing_host = LandingHost.where(inbox_id: valid_inbox_ids).find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Landing Host not found' }, status: :not_found
end
def landing_host_params
params.require(:landing_host).permit(:hostname, :unit_code, :active)
end
end

View File

@ -0,0 +1,28 @@
class Api::V1::TrackingController < ActionController::API
def click
LeadClick.create!(click_params)
head :no_content
rescue StandardError => e
Rails.logger.error("Error processing tracking click: #{e.message}")
head :no_content
end
private
def resolved_inbox_id
LandingHost.find_by(hostname: params[:hostname].to_s.strip, active: true)&.inbox_id
end
def click_params
{
inbox_id: resolved_inbox_id,
ip: params[:ip].presence || request.remote_ip,
user_agent: request.user_agent || params[:user_agent],
hostname: params[:hostname].to_s.strip,
source: params[:source],
campanha: params[:campanha],
lp: params[:lp],
status: :clicked
}
end
end

View File

@ -0,0 +1,2 @@
module Api::V1::Accounts::LandingHostsHelper
end

View File

@ -0,0 +1,21 @@
// API client para LandingHosts da caixa de entrada
/* global axios */
export default {
getHosts(accountId, inboxId) {
return axios.get(
`/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts`
);
},
createHost(accountId, inboxId, data) {
return axios.post(
`/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts`,
{ landing_host: data }
);
},
deleteHost(accountId, inboxId, id) {
return axios.delete(
`/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts/${id}`
);
},
};

View File

@ -33,6 +33,7 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { getInboxIconByType } from 'dashboard/helper/inbox'; import { getInboxIconByType } from 'dashboard/helper/inbox';
import Editor from 'dashboard/components-next/Editor/Editor.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue';
import LandingHostsConfig from './settingsPage/LandingHostsConfig.vue';
export default { export default {
components: { components: {
@ -61,6 +62,7 @@ export default {
WuzapiConfiguration, WuzapiConfiguration,
EvolutionGoConfiguration, EvolutionGoConfiguration,
InboxAutoResolve, InboxAutoResolve,
LandingHostsConfig,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
setup() { setup() {
@ -93,6 +95,12 @@ export default {
isLoadingHealth: false, isLoadingHealth: false,
healthError: null, healthError: null,
messageSignatureEnabled: false, messageSignatureEnabled: false,
messageSignatureDefaultName: '',
messageSignatureDayName: '',
messageSignatureNightEvenName: '',
messageSignatureNightOddName: '',
messageSignatureNightShiftStart: '19:00',
messageSignatureNightShiftEnd: '07:00',
typingDelay: 0, typingDelay: 0,
}; };
}, },
@ -215,6 +223,14 @@ export default {
]; ];
} }
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'landing-hosts',
name: 'Landing Pages',
},
];
return visibleToAllChannelTabs; return visibleToAllChannelTabs;
}, },
currentInboxId() { currentInboxId() {
@ -451,6 +467,18 @@ export default {
null; null;
this.typingDelay = this.inbox.typing_delay || 0; this.typingDelay = this.inbox.typing_delay || 0;
this.messageSignatureEnabled = this.inbox.message_signature_enabled; this.messageSignatureEnabled = this.inbox.message_signature_enabled;
this.messageSignatureDefaultName =
this.inbox.message_signature_default_name || '';
this.messageSignatureDayName =
this.inbox.message_signature_day_name || '';
this.messageSignatureNightEvenName =
this.inbox.message_signature_night_even_name || '';
this.messageSignatureNightOddName =
this.inbox.message_signature_night_odd_name || '';
this.messageSignatureNightShiftStart =
this.inbox.message_signature_night_shift_start || '19:00';
this.messageSignatureNightShiftEnd =
this.inbox.message_signature_night_shift_end || '07:00';
// Set initial tab after inbox data is loaded // Set initial tab after inbox data is loaded
this.setTabFromRouteParam(); this.setTabFromRouteParam();
@ -476,6 +504,13 @@ export default {
sender_name_type: this.senderNameType, sender_name_type: this.senderNameType,
business_name: this.businessName || null, business_name: this.businessName || null,
message_signature_enabled: this.messageSignatureEnabled, message_signature_enabled: this.messageSignatureEnabled,
message_signature_default_name: this.messageSignatureDefaultName,
message_signature_day_name: this.messageSignatureDayName,
message_signature_night_even_name: this.messageSignatureNightEvenName,
message_signature_night_odd_name: this.messageSignatureNightOddName,
message_signature_night_shift_start:
this.messageSignatureNightShiftStart,
message_signature_night_shift_end: this.messageSignatureNightShiftEnd,
channel: { channel: {
widget_color: this.inbox.widget_color, widget_color: this.inbox.widget_color,
website_url: this.channelWebsiteUrl, website_url: this.channelWebsiteUrl,
@ -632,6 +667,54 @@ export default {
{{ $t('INBOX_MGMT.ADD.MESSAGE_SIGNATURE.LABEL') }} {{ $t('INBOX_MGMT.ADD.MESSAGE_SIGNATURE.LABEL') }}
</label> </label>
</div> </div>
<woot-input
v-if="messageSignatureEnabled"
v-model="messageSignatureDefaultName"
class="pb-4"
label="Nome Geral (Se não houver plantão definido)"
placeholder="Ex: Equipe Hotel"
@blur="updateInbox"
/>
<div
v-if="messageSignatureEnabled"
class="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4"
>
<woot-input
v-model="messageSignatureDayName"
label="Recepcionista do Dia (07h - 19h)"
placeholder="Nome aqui"
@blur="updateInbox"
/>
<woot-input
v-model="messageSignatureNightEvenName"
label="Noite - Dias PARES (19h - 07h)"
placeholder="Nome aqui"
@blur="updateInbox"
/>
<woot-input
v-model="messageSignatureNightOddName"
label="Noite - Dias ÍMPARES"
placeholder="Nome aqui"
@blur="updateInbox"
/>
</div>
<div
v-if="messageSignatureEnabled"
class="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4 border-t pt-4 mt-2"
>
<woot-input
v-model="messageSignatureNightShiftStart"
label="Início do Plantão Noturno"
placeholder="19:00"
@blur="updateInbox"
/>
<woot-input
v-model="messageSignatureNightShiftEnd"
label="Fim do Plantão Noturno"
placeholder="07:00"
@blur="updateInbox"
/>
</div>
<woot-input <woot-input
v-if="isAPIInbox" v-if="isAPIInbox"
v-model="webhookUrl" v-model="webhookUrl"
@ -1046,6 +1129,9 @@ export default {
<div v-if="selectedTabKey === 'whatsapp-health'"> <div v-if="selectedTabKey === 'whatsapp-health'">
<AccountHealth :health-data="healthData" /> <AccountHealth :health-data="healthData" />
</div> </div>
<div v-if="selectedTabKey === 'landing-hosts'">
<LandingHostsConfig :inbox="inbox" />
</div>
</section> </section>
</div> </div>
</template> </template>

View File

@ -0,0 +1,215 @@
<script>
/* eslint-disable @intlify/vue-i18n/no-raw-text */
import landingHostsApi from 'dashboard/api/landingHosts';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { mapGetters } from 'vuex';
export default {
name: 'LandingHostsConfig',
components: { NextButton },
props: {
inbox: {
type: Object,
required: true,
},
},
data() {
return {
landingHosts: [],
newHostname: '',
newUnitCode: '',
isLoading: false,
isSaving: false,
labels: {
title: 'Landing Pages (Tracking de Origem)',
subtitle:
'Defina os domínios de Landing Pages que enviam leads para esta caixa de entrada. O sistema usará esses domínios para identificar automaticamente a origem de cada conversa.',
loading: 'Carregando...',
empty: 'Nenhum domínio cadastrado ainda.',
colHostname: 'Hostname',
colCode: 'Código / Unidade',
remove: 'Remover',
addTitle: 'Adicionar Domínio',
labelHostname: 'Hostname *',
placeholderHostname: 'express.seuhotel.com.br',
labelCode: 'Código Unidade',
placeholderCode: 'EXPRESS',
labelSaving: 'Salvando...',
labelAdd: 'Adicionar',
hint: 'Informe o domínio exato sem https://, ex: landing.meusite.com.br',
errLoad: 'Erro ao carregar os domínios.',
errAdd:
'Erro ao adicionar domínio. Verifique se já existe ou o formato é válido.',
errDel: 'Erro ao remover domínio.',
successAdd: 'Domínio adicionado com sucesso!',
successDel: 'Domínio removido.',
},
};
},
computed: {
...mapGetters({ currentAccountId: 'getCurrentAccountId' }),
addLabel() {
return this.isSaving ? this.labels.labelSaving : this.labels.labelAdd;
},
},
mounted() {
this.fetchHosts();
},
methods: {
async fetchHosts() {
this.isLoading = true;
try {
const { data } = await landingHostsApi.getHosts(
this.currentAccountId,
this.inbox.id
);
this.landingHosts = data;
} catch {
useAlert(this.labels.errLoad);
} finally {
this.isLoading = false;
}
},
async addHost() {
if (!this.newHostname.trim()) return;
this.isSaving = true;
try {
const { data } = await landingHostsApi.createHost(
this.currentAccountId,
this.inbox.id,
{
hostname: this.newHostname.trim().toLowerCase(),
unit_code: this.newUnitCode.trim().toUpperCase(),
}
);
this.landingHosts.push(data);
this.newHostname = '';
this.newUnitCode = '';
useAlert(this.labels.successAdd);
} catch {
useAlert(this.labels.errAdd);
} finally {
this.isSaving = false;
}
},
async deleteHost(id) {
try {
await landingHostsApi.deleteHost(
this.currentAccountId,
this.inbox.id,
id
);
this.landingHosts = this.landingHosts.filter(h => h.id !== id);
useAlert(this.labels.successDel);
} catch {
useAlert(this.labels.errDel);
}
},
},
};
</script>
<template>
<div class="mx-8 mt-4 pb-12">
<div class="mb-6">
<h2 class="text-base font-semibold text-n-slate-12 mb-1">
{{ labels.title }}
</h2>
<p class="text-sm text-n-slate-11">
{{ labels.subtitle }}
</p>
</div>
<!-- Lista de hosts cadastrados -->
<div class="mb-6 border border-n-slate-3 rounded-lg overflow-hidden">
<div
v-if="isLoading"
class="flex items-center justify-center py-8 text-sm text-n-slate-11"
>
{{ labels.loading }}
</div>
<div
v-else-if="landingHosts.length === 0"
class="py-8 text-center text-sm text-n-slate-11"
>
{{ labels.empty }}
</div>
<table v-else class="w-full text-sm">
<thead class="bg-n-slate-2 text-n-slate-11 uppercase text-xs">
<tr>
<th class="text-left px-4 py-3 font-medium">
{{ labels.colHostname }}
</th>
<th class="text-left px-4 py-3 font-medium">
{{ labels.colCode }}
</th>
<th class="px-4 py-3 text-right" />
</tr>
</thead>
<tbody>
<tr
v-for="host in landingHosts"
:key="host.id"
class="border-t border-n-slate-3 hover:bg-n-slate-1"
>
<td class="px-4 py-3 font-mono text-n-slate-12 text-xs">
{{ host.hostname }}
</td>
<td class="px-4 py-3 text-n-slate-11">
{{ host.unit_code || '—' }}
</td>
<td class="px-4 py-3 text-right">
<button
class="text-xs text-ruby-9 hover:text-ruby-11 font-medium transition-colors"
@click="deleteHost(host.id)"
>
{{ labels.remove }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Formulário de adição -->
<div class="border border-n-slate-3 rounded-lg p-4 bg-n-slate-1">
<h3 class="text-sm font-semibold text-n-slate-12 mb-3">
{{ labels.addTitle }}
</h3>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<div class="flex-1">
<label class="block text-xs font-medium text-n-slate-11 mb-1">
{{ labels.labelHostname }}
</label>
<woot-input
v-model="newHostname"
:placeholder="labels.placeholderHostname"
class="[&>input]:!mb-0"
@keyup.enter="addHost"
/>
</div>
<div class="w-36">
<label class="block text-xs font-medium text-n-slate-11 mb-1">
{{ labels.labelCode }}
</label>
<woot-input
v-model="newUnitCode"
:placeholder="labels.placeholderCode"
class="[&>input]:!mb-0"
@keyup.enter="addHost"
/>
</div>
<NextButton
:label="addLabel"
:disabled="!newHostname.trim() || isSaving"
class="flex-shrink-0 mb-px"
@click="addHost"
/>
</div>
<p class="text-xs text-n-slate-10 mt-2">
{{ labels.hint }}
</p>
</div>
</div>
</template>

View File

@ -4,32 +4,38 @@
# #
# Table name: inboxes # Table name: inboxes
# #
# id :integer not null, primary key # id :integer not null, primary key
# allow_messages_after_resolved :boolean default(TRUE) # allow_messages_after_resolved :boolean default(TRUE)
# auto_assignment_config :jsonb # auto_assignment_config :jsonb
# auto_resolve_duration :integer # auto_resolve_duration :integer
# business_name :string # business_name :string
# channel_type :string # channel_type :string
# csat_config :jsonb not null # csat_config :jsonb not null
# csat_survey_enabled :boolean default(FALSE) # csat_survey_enabled :boolean default(FALSE)
# email_address :string # email_address :string
# enable_auto_assignment :boolean default(TRUE) # enable_auto_assignment :boolean default(TRUE)
# enable_email_collect :boolean default(TRUE) # enable_email_collect :boolean default(TRUE)
# greeting_enabled :boolean default(FALSE) # greeting_enabled :boolean default(FALSE)
# greeting_message :string # greeting_message :string
# lock_to_single_conversation :boolean default(FALSE), not null # lock_to_single_conversation :boolean default(FALSE), not null
# message_signature_enabled :boolean # message_signature_day_name :string
# name :string not null # message_signature_default_name :string
# out_of_office_message :string # message_signature_enabled :boolean
# sender_name_type :integer default("friendly"), not null # message_signature_night_even_name :string
# timezone :string default("UTC") # message_signature_night_odd_name :string
# typing_delay :integer default(0) # message_signature_night_shift_end :string default("07:00")
# working_hours_enabled :boolean default(FALSE) # message_signature_night_shift_start :string default("19:00")
# created_at :datetime not null # name :string not null
# updated_at :datetime not null # out_of_office_message :string
# account_id :integer not null # sender_name_type :integer default("friendly"), not null
# channel_id :integer not null # timezone :string default("UTC")
# portal_id :bigint # typing_delay :integer default(0)
# working_hours_enabled :boolean default(FALSE)
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# channel_id :integer not null
# portal_id :bigint
# #
# Indexes # Indexes
# #
@ -209,8 +215,29 @@ class Inbox < ApplicationRecord
account.feature_enabled?('assignment_v2') account.feature_enabled?('assignment_v2')
end end
def shift_signature_name
time_now = Time.current.in_time_zone(ENV.fetch('ACCOUNT_TIMEZONE', 'UTC'))
night_shift?(time_now) ? night_name(time_now.day) : day_name
end
private private
def night_shift?(time_now)
current = time_now.strftime('%H:%M')
start_h = message_signature_night_shift_start.presence || '19:00'
end_h = message_signature_night_shift_end.presence || '07:00'
start_h < end_h ? (current >= start_h && current < end_h) : (current >= start_h || current < end_h)
end
def night_name(day)
base = day.even? ? message_signature_night_even_name : message_signature_night_odd_name
base.presence || message_signature_default_name
end
def day_name
message_signature_day_name.presence || message_signature_default_name
end
def default_name_for_blank_name def default_name_for_blank_name
email? ? display_name_from_email : '' email? ? display_name_from_email : ''
end end

View File

@ -0,0 +1,18 @@
# == Schema Information
#
# Table name: landing_hosts
#
# id :bigint not null, primary key
# active :boolean
# hostname :string
# unit_code :string
# created_at :datetime not null
# updated_at :datetime not null
# inbox_id :integer
#
# Indexes
#
# index_landing_hosts_on_hostname (hostname) UNIQUE
#
class LandingHost < ApplicationRecord
end

29
app/models/lead_click.rb Normal file
View File

@ -0,0 +1,29 @@
# == Schema Information
#
# Table name: lead_clicks
#
# id :bigint not null, primary key
# campanha :string
# hostname :string
# ip :string
# lp :string
# source :string
# status :integer
# user_agent :string
# created_at :datetime not null
# updated_at :datetime not null
# contact_id :integer
# conversation_id :integer
# inbox_id :integer
#
# Indexes
#
# index_lead_clicks_on_inbox_id_and_ip_and_status_and_created_at (inbox_id,ip,status,created_at)
#
class LeadClick < ApplicationRecord
enum status: { clicked: 0, converted: 1 }
belongs_to :inbox, optional: true
belongs_to :conversation, optional: true
belongs_to :contact, optional: true
end

View File

@ -0,0 +1,66 @@
class Leads::AttributionMatcherService
def initialize(conversation, inbound_ip = nil)
@conversation = conversation
@contact = conversation.contact
@inbox_id = conversation.inbox_id
@inbound_ip = inbound_ip
end
def perform
return unless valid_for_matching?
click = find_matching_click
return unless click
apply_attribution(click)
end
private
def valid_for_matching?
@conversation.present? && @contact.present? &&
@conversation.custom_attributes['link_de_origem'].blank?
end
def find_matching_click
base_query = LeadClick
.where(status: :clicked, inbox_id: @inbox_id)
.where('created_at > ?', 10.minutes.ago)
return base_query.where(ip: @inbound_ip).order(created_at: :desc).first if @inbound_ip.present?
base_query.order(created_at: :desc).first
end
def attribution_attrs(click)
{
'link_de_origem' => click.source,
'campanha' => click.campanha,
'lp_hostname' => click.hostname,
'click_id' => click.id.to_s
}
end
def apply_attribution(click)
ActiveRecord::Base.transaction do
click.update!(status: :converted, conversation_id: @conversation.id, contact_id: @contact.id)
update_contact(click)
update_conversation(click)
apply_labels(click)
end
end
def update_contact(click)
@contact.update!(custom_attributes: @contact.custom_attributes.to_h.merge(attribution_attrs(click)))
end
def update_conversation(click)
@conversation.update!(
custom_attributes: @conversation.custom_attributes.to_h.merge(attribution_attrs(click))
)
end
def apply_labels(click)
@conversation.add_labels(['lead_meta']) if click.source.to_s.downcase.include?('meta')
end
end

View File

@ -41,6 +41,9 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
@conversation = find_or_create_conversation @conversation = find_or_create_conversation
# Processar Match de Leads (Fingerprinting) caso a mensagem venha do Lead
Leads::AttributionMatcherService.new(@conversation).perform unless @parser.from_me?
return if @parser.from_me? && handle_echo_message return if @parser.from_me? && handle_echo_message
create_new_message create_new_message
@ -150,8 +153,18 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
def build_message(parser, conversation, clean_source_id) def build_message(parser, conversation, clean_source_id)
is_outgoing = parser.from_me? is_outgoing = parser.from_me?
content = parser.text_content
inbox_obj = inbox
# Se a mensagem vier do celular (outgoing) e a assinatura estiver ativa,
# e o conteúdo não parecer já ter uma assinatura (evita duplicar em ecos)
if is_outgoing && inbox_obj.message_signature_enabled? && content.present? && !content.start_with?('*[') && !content.start_with?('*')
signature_name = inbox_obj.shift_signature_name
content = "*[ #{signature_name} ]*\n#{content}" if signature_name.present?
end
msg_params = { msg_params = {
content: parser.text_content, content: content,
account_id: inbox.account_id, inbox_id: inbox.id, account_id: inbox.account_id, inbox_id: inbox.id,
message_type: is_outgoing ? :outgoing : :incoming, message_type: is_outgoing ? :outgoing : :incoming,
sender: is_outgoing ? nil : @contact, sender: is_outgoing ? nil : @contact,

View File

@ -10,97 +10,46 @@ class Whatsapp::Providers::WuzapiService < Whatsapp::Providers::BaseService
def send_message(phone_number, message) def send_message(phone_number, message)
user_token = whatsapp_channel.wuzapi_user_token user_token = whatsapp_channel.wuzapi_user_token
# Normalize phone number: remove +, space, -, (, ) normalized_phone = normalize_phone(phone_number)
normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '') log_outgoing_message(message)
return send_reaction_message(normalized_phone, message) if reaction_message?(message)
Rails.logger.info "[WuzapiService] Sending Message: content_to_send = build_content_with_signature(message)
Message ID: #{message.id} response = dispatch_message(user_token, normalized_phone, message, content_to_send)
Conversation ID: #{message.conversation_id}
Contact Inbox ID: #{message.conversation.contact_inbox_id}
Raw Phone (arg): #{phone_number}
Normalized Phone (Target): #{normalized_phone}
Content: #{message.content&.truncate(50)}
"
return send_reaction_message(normalized_phone, message) if message.content_attributes['is_reaction'] || message.content_attributes[:is_reaction]
response = if message.attachments.present?
send_attachment_message(user_token, normalized_phone, message)
else
params = {}
# Extract and clean reply ID (remove WAID: prefix if stored)
if (reply_id = message.content_attributes['in_reply_to_external_id']).present?
params['MessageId'] = reply_id.gsub(/^WAID:/, '')
elsif (reply_id = message.in_reply_to_external_id).present?
params['MessageId'] = reply_id.gsub(/^WAID:/, '')
end
client.send_text(user_token, normalized_phone, message.content, **params)
end
# Extract message ID from WuzAPI response and format as WAID:xxx
extract_message_id(response) extract_message_id(response)
end end
def send_attachment_message(user_token, phone_number, message) def send_attachment_message(user_token, phone_number, message, content_with_signature = nil)
attachment = message.attachments.first attachment = message.attachments.first
base64_data = Base64.strict_encode64(attachment.file.download)
mime_type = attachment.file.content_type mime_type = attachment.file.content_type
caption = content_with_signature || message.content
base64_data = attachment.file.blob.open { |tmp| Base64.strict_encode64(tmp.read) }
data_uri = "data:#{mime_type};base64,#{base64_data}" data_uri = "data:#{mime_type};base64,#{base64_data}"
if mime_type.start_with?('image/') if mime_type.start_with?('image/')
client.send_image(user_token, phone_number, data_uri, message.content) client.send_image(user_token, phone_number, data_uri, caption)
else else
client.send_file(user_token, phone_number, data_uri, attachment.file.filename.to_s) client.send_file(user_token, phone_number, data_uri, attachment.file.filename.to_s)
end end
end end
def send_reaction_message(phone_number, message) def send_reaction_message(phone_number, message)
user_token = whatsapp_channel.wuzapi_user_token user_token = whatsapp_channel.wuzapi_user_token
normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '')
# Assuming message content is the emoji
reaction_emoji = message.content reaction_emoji = message.content
message_id = resolve_reaction_message_id(message)
phone, mid = build_reaction_targets(phone_number, message_id, message)
# Resolve the correct external message ID Rails.logger.info "[WuzapiService] Attempting reaction: phone=#{phone}, msg_id=#{mid}, emoji=#{reaction_emoji}"
message_id = message.content_attributes['in_reply_to_external_id']
# Fallback to internal ID resolution if external is missing if mid.blank?
if message_id.blank? && message.content_attributes['in_reply_to'].present?
target_msg = message.conversation.messages.find_by(id: message.content_attributes['in_reply_to'])
message_id = target_msg&.source_id
end
# Strip WAID prefix if present
message_id = message_id.gsub(/^WAID:/, '') if message_id.present?
use_me_prefix = reaction_to_own_message?(message)
if use_me_prefix
normalized_phone = "me:#{normalized_phone}" unless normalized_phone.start_with?('me:')
message_id = "me:#{message_id}" if message_id.present? && !message_id.start_with?('me:')
else
# Enforce JID format for customer numbers
clean_number = normalized_phone.split('@').first
normalized_phone = "#{clean_number}@s.whatsapp.net"
end
Rails.logger.info "[WuzapiService] Attempting reaction: phone=#{normalized_phone}, msg_id=#{message_id}, emoji=#{reaction_emoji}"
if message_id.present?
# Wuzapi client needs to implement send_reaction
# This assumes the client wrapper has this method. If not, we might need to add it or use raw request.
# Based on typical Wuzapi forks, it might be /send-reaction-message
# We'll assume the client wrapper will have a send_reaction method.
# If not visible in the existing codebase, we might need to add it to the client class too.
# checking...
response = client.send_reaction(user_token, normalized_phone, message_id, reaction_emoji)
Rails.logger.info "[WuzapiService] Reaction response: #{response}"
response
else
Rails.logger.warn 'Wuzapi: Cannot send reaction without in_reply_to message ID' Rails.logger.warn 'Wuzapi: Cannot send reaction without in_reply_to message ID'
return
end end
response = client.send_reaction(user_token, phone, mid, reaction_emoji)
Rails.logger.info "[WuzapiService] Reaction response: #{response}"
response
end end
def send_template(_phone_number, _template_info) def send_template(_phone_number, _template_info)
@ -155,6 +104,74 @@ class Whatsapp::Providers::WuzapiService < Whatsapp::Providers::BaseService
private private
def normalize_phone(phone_number)
phone_number.gsub(/[+\s\-()]/, '')
end
def reaction_message?(message)
message.content_attributes['is_reaction'] || message.content_attributes[:is_reaction]
end
def log_outgoing_message(message)
Rails.logger.info "[WuzapiService] Sending Message: ID=#{message.id} Conv=#{message.conversation_id} Content=#{message.content&.truncate(50)}"
end
def sender_name_for(message)
agent = message.sender
if agent.is_a?(User)
agent.display_name.presence || agent.name
elsif agent.is_a?(Captain::Assistant)
agent.name
else
message.inbox.shift_signature_name
end
end
def build_content_with_signature(message)
content = message.content
return content unless message.inbox.message_signature_enabled?
name = sender_name_for(message)
name.present? ? "*[ #{name} ]*\n#{content}" : content
end
def reply_params(message)
params = {}
reply_id = message.content_attributes['in_reply_to_external_id'].presence ||
message.in_reply_to_external_id.presence
params['MessageId'] = reply_id.gsub(/^WAID:/, '') if reply_id
params
end
def dispatch_message(user_token, phone, message, content)
if message.attachments.present?
send_attachment_message(user_token, phone, message, content)
else
client.send_text(user_token, phone, content, **reply_params(message))
end
end
def resolve_reaction_message_id(message)
mid = message.content_attributes['in_reply_to_external_id']
if mid.blank? && message.content_attributes['in_reply_to'].present?
target = message.conversation.messages.find_by(id: message.content_attributes['in_reply_to'])
mid = target&.source_id
end
mid.present? ? mid.gsub(/^WAID:/, '') : nil
end
def build_reaction_targets(phone_number, message_id, message)
phone = normalize_phone(phone_number)
mid = message_id
if reaction_to_own_message?(message)
phone = "me:#{phone}" unless phone.start_with?('me:')
mid = "me:#{mid}" if mid.present? && !mid.start_with?('me:')
else
phone = "#{phone.split('@').first}@s.whatsapp.net"
end
[phone, mid]
end
def client def client
@client ||= ::Wuzapi::Client.new(@base_url) @client ||= ::Wuzapi::Client.new(@base_url)
end end

View File

@ -37,6 +37,7 @@ Rails.application.routes.draw do
get '/health', to: 'health#show' get '/health', to: 'health#show'
get '/api', to: 'api#index' get '/api', to: 'api#index'
post '/track/click', to: 'api/v1/tracking#click'
namespace :api, defaults: { format: 'json' } do namespace :api, defaults: { format: 'json' } do
namespace :v1 do namespace :v1 do
# ---------------------------------- # ----------------------------------
@ -234,6 +235,7 @@ Rails.application.routes.draw do
get :campaigns, on: :member get :campaigns, on: :member
get :agent_bot, on: :member get :agent_bot, on: :member
post :set_agent_bot, on: :member post :set_agent_bot, on: :member
resources :landing_hosts, only: [:index, :create, :destroy]
post :setup_channel_provider, on: :member post :setup_channel_provider, on: :member
post :disconnect_channel_provider, on: :member post :disconnect_channel_provider, on: :member
delete :avatar, on: :member delete :avatar, on: :member

View File

@ -0,0 +1,5 @@
class AddMessageSignatureDefaultNameToInboxes < ActiveRecord::Migration[7.0]
def change
add_column :inboxes, :message_signature_default_name, :string
end
end

View File

@ -0,0 +1,7 @@
class AddShiftSignaturesToInboxes < ActiveRecord::Migration[7.0]
def change
add_column :inboxes, :message_signature_day_name, :string
add_column :inboxes, :message_signature_night_even_name, :string
add_column :inboxes, :message_signature_night_odd_name, :string
end
end

View File

@ -0,0 +1,6 @@
class AddNightShiftHoursToInboxes < ActiveRecord::Migration[7.0]
def change
add_column :inboxes, :message_signature_night_shift_start, :string, default: '19:00'
add_column :inboxes, :message_signature_night_shift_end, :string, default: '07:00'
end
end

View File

@ -0,0 +1,20 @@
class CreateLeadClicks < ActiveRecord::Migration[7.1]
def change
create_table :lead_clicks do |t|
t.integer :inbox_id
t.string :ip
t.string :user_agent
t.string :hostname
t.string :source
t.string :campanha
t.string :lp
t.integer :status
t.integer :conversation_id
t.integer :contact_id
t.timestamps
end
add_index :lead_clicks, [:inbox_id, :ip, :status, :created_at]
end
end

View File

@ -0,0 +1,14 @@
class CreateLandingHosts < ActiveRecord::Migration[7.1]
def change
create_table :landing_hosts do |t|
t.string :hostname
t.string :unit_code
t.integer :inbox_id
t.boolean :active, default: true, null: false
t.timestamps
end
add_index :landing_hosts, :hostname, unique: true
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_03_01_200000) do ActiveRecord::Schema[7.1].define(version: 2026_03_02_154737) do
# These extensions should be enabled to support this database # These extensions should be enabled to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" enable_extension "pg_trgm"
@ -1405,6 +1405,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_01_200000) do
t.integer "auto_resolve_duration" t.integer "auto_resolve_duration"
t.boolean "message_signature_enabled" t.boolean "message_signature_enabled"
t.integer "typing_delay", default: 0 t.integer "typing_delay", default: 0
t.string "message_signature_default_name"
t.string "message_signature_day_name"
t.string "message_signature_night_even_name"
t.string "message_signature_night_odd_name"
t.string "message_signature_night_shift_start", default: "19:00"
t.string "message_signature_night_shift_end", default: "07:00"
t.index ["account_id"], name: "index_inboxes_on_account_id" t.index ["account_id"], name: "index_inboxes_on_account_id"
t.index ["channel_id", "channel_type"], name: "index_inboxes_on_channel_id_and_channel_type" t.index ["channel_id", "channel_type"], name: "index_inboxes_on_channel_id_and_channel_type"
t.index ["portal_id"], name: "index_inboxes_on_portal_id" t.index ["portal_id"], name: "index_inboxes_on_portal_id"
@ -1547,6 +1553,32 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_01_200000) do
t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true
end end
create_table "landing_hosts", force: :cascade do |t|
t.string "hostname"
t.string "unit_code"
t.integer "inbox_id"
t.boolean "active"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["hostname"], name: "index_landing_hosts_on_hostname", unique: true
end
create_table "lead_clicks", force: :cascade do |t|
t.integer "inbox_id"
t.string "ip"
t.string "user_agent"
t.string "hostname"
t.string "source"
t.string "campanha"
t.string "lp"
t.integer "status"
t.integer "conversation_id"
t.integer "contact_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["inbox_id", "ip", "status", "created_at"], name: "index_lead_clicks_on_inbox_id_and_ip_and_status_and_created_at"
end
create_table "leaves", force: :cascade do |t| create_table "leaves", force: :cascade do |t|
t.bigint "account_id", null: false t.bigint "account_id", null: false
t.bigint "user_id", null: false t.bigint "user_id", null: false

View File

@ -0,0 +1,8 @@
FactoryBot.define do
factory :landing_host do
hostname { 'MyString' }
unit_code { 'MyString' }
inbox_id { 1 }
active { false }
end
end

View File

@ -0,0 +1,10 @@
FactoryBot.define do
factory :lead_click do
inbox_id { 1 }
ip { 'MyString' }
user_agent { 'MyString' }
hostname { 'MyString' }
source { 'MyString' }
status { 1 }
end
end

View File

@ -0,0 +1,15 @@
require 'rails_helper'
# Specs in this file have access to a helper object that includes
# the Api::V1::Accounts::LandingHostsHelper. For example:
#
# describe Api::V1::Accounts::LandingHostsHelper do
# describe "string concat" do
# it "concats two strings with spaces" do
# expect(helper.concat_strings("this","that")).to eq("this that")
# end
# end
# end
RSpec.describe Api::V1::Accounts::LandingHostsHelper, type: :helper do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe LandingHost, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe LeadClick, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@ -0,0 +1,7 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::LandingHosts', type: :request do
describe 'GET /index' do
pending "add some examples (or delete) #{__FILE__}"
end
end

View File

@ -0,0 +1,36 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Tracking', type: :request do
describe 'POST /track/click' do
let(:valid_params) do
{
hostname: 'test.com',
source: 'facebook',
campanha: 'summer26',
lp: '/promo'
}
end
context 'when tracking a click' do
it 'creates a lead click and returns no_content' do
expect do
post '/track/click', params: valid_params, as: :json
end.to change(LeadClick, :count).by(1)
expect(response).to have_http_status(:no_content)
click = LeadClick.last
expect(click.hostname).to eq('test.com')
expect(click.source).to eq('facebook')
expect(click.status).to eq('clicked')
end
it 'resolves the inbox if landing host exists' do
host = create(:landing_host, hostname: 'test.com', active: true)
post '/track/click', params: valid_params, as: :json
expect(LeadClick.last.inbox_id).to eq(host.inbox_id)
end
end
end
end