diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index f647667c6..96ab324c9 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -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,
: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,
+ :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,
{ survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid,
diff --git a/app/controllers/api/v1/accounts/landing_hosts_controller.rb b/app/controllers/api/v1/accounts/landing_hosts_controller.rb
new file mode 100644
index 000000000..c1c23b6e7
--- /dev/null
+++ b/app/controllers/api/v1/accounts/landing_hosts_controller.rb
@@ -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
diff --git a/app/controllers/api/v1/tracking_controller.rb b/app/controllers/api/v1/tracking_controller.rb
new file mode 100644
index 000000000..228ef75f6
--- /dev/null
+++ b/app/controllers/api/v1/tracking_controller.rb
@@ -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
diff --git a/app/helpers/api/v1/accounts/landing_hosts_helper.rb b/app/helpers/api/v1/accounts/landing_hosts_helper.rb
new file mode 100644
index 000000000..9a8b8a323
--- /dev/null
+++ b/app/helpers/api/v1/accounts/landing_hosts_helper.rb
@@ -0,0 +1,2 @@
+module Api::V1::Accounts::LandingHostsHelper
+end
diff --git a/app/javascript/dashboard/api/landingHosts.js b/app/javascript/dashboard/api/landingHosts.js
new file mode 100644
index 000000000..3895b78e1
--- /dev/null
+++ b/app/javascript/dashboard/api/landingHosts.js
@@ -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}`
+ );
+ },
+};
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
index d36ed7f1f..f8d09f095 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
@@ -33,6 +33,7 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
+import LandingHostsConfig from './settingsPage/LandingHostsConfig.vue';
export default {
components: {
@@ -61,6 +62,7 @@ export default {
WuzapiConfiguration,
EvolutionGoConfiguration,
InboxAutoResolve,
+ LandingHostsConfig,
},
mixins: [inboxMixin],
setup() {
@@ -93,6 +95,12 @@ export default {
isLoadingHealth: false,
healthError: null,
messageSignatureEnabled: false,
+ messageSignatureDefaultName: '',
+ messageSignatureDayName: '',
+ messageSignatureNightEvenName: '',
+ messageSignatureNightOddName: '',
+ messageSignatureNightShiftStart: '19:00',
+ messageSignatureNightShiftEnd: '07:00',
typingDelay: 0,
};
},
@@ -215,6 +223,14 @@ export default {
];
}
+ visibleToAllChannelTabs = [
+ ...visibleToAllChannelTabs,
+ {
+ key: 'landing-hosts',
+ name: 'Landing Pages',
+ },
+ ];
+
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -451,6 +467,18 @@ export default {
null;
this.typingDelay = this.inbox.typing_delay || 0;
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
this.setTabFromRouteParam();
@@ -476,6 +504,13 @@ export default {
sender_name_type: this.senderNameType,
business_name: this.businessName || null,
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: {
widget_color: this.inbox.widget_color,
website_url: this.channelWebsiteUrl,
@@ -632,6 +667,54 @@ export default {
{{ $t('INBOX_MGMT.ADD.MESSAGE_SIGNATURE.LABEL') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/LandingHostsConfig.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/LandingHostsConfig.vue
new file mode 100644
index 000000000..45668be4a
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/LandingHostsConfig.vue
@@ -0,0 +1,215 @@
+
+
+
+
+
+
+ {{ labels.title }}
+
+
+ {{ labels.subtitle }}
+
+
+
+
+
+
+ {{ labels.loading }}
+
+
+ {{ labels.empty }}
+
+
+
+
+ |
+ {{ labels.colHostname }}
+ |
+
+ {{ labels.colCode }}
+ |
+ |
+
+
+
+
+ |
+ {{ host.hostname }}
+ |
+
+ {{ host.unit_code || '—' }}
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+ {{ labels.addTitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ labels.hint }}
+
+
+
+
diff --git a/app/models/inbox.rb b/app/models/inbox.rb
index 8b6d35382..ba5474ecd 100644
--- a/app/models/inbox.rb
+++ b/app/models/inbox.rb
@@ -4,32 +4,38 @@
#
# Table name: inboxes
#
-# id :integer not null, primary key
-# allow_messages_after_resolved :boolean default(TRUE)
-# auto_assignment_config :jsonb
-# auto_resolve_duration :integer
-# business_name :string
-# channel_type :string
-# csat_config :jsonb not null
-# csat_survey_enabled :boolean default(FALSE)
-# email_address :string
-# enable_auto_assignment :boolean default(TRUE)
-# enable_email_collect :boolean default(TRUE)
-# greeting_enabled :boolean default(FALSE)
-# greeting_message :string
-# lock_to_single_conversation :boolean default(FALSE), not null
-# message_signature_enabled :boolean
-# name :string not null
-# out_of_office_message :string
-# sender_name_type :integer default("friendly"), not null
-# timezone :string default("UTC")
-# 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
+# id :integer not null, primary key
+# allow_messages_after_resolved :boolean default(TRUE)
+# auto_assignment_config :jsonb
+# auto_resolve_duration :integer
+# business_name :string
+# channel_type :string
+# csat_config :jsonb not null
+# csat_survey_enabled :boolean default(FALSE)
+# email_address :string
+# enable_auto_assignment :boolean default(TRUE)
+# enable_email_collect :boolean default(TRUE)
+# greeting_enabled :boolean default(FALSE)
+# greeting_message :string
+# lock_to_single_conversation :boolean default(FALSE), not null
+# message_signature_day_name :string
+# message_signature_default_name :string
+# message_signature_enabled :boolean
+# message_signature_night_even_name :string
+# message_signature_night_odd_name :string
+# message_signature_night_shift_end :string default("07:00")
+# message_signature_night_shift_start :string default("19:00")
+# name :string not null
+# out_of_office_message :string
+# sender_name_type :integer default("friendly"), not null
+# timezone :string default("UTC")
+# 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
#
@@ -209,8 +215,29 @@ class Inbox < ApplicationRecord
account.feature_enabled?('assignment_v2')
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
+ 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
email? ? display_name_from_email : ''
end
diff --git a/app/models/landing_host.rb b/app/models/landing_host.rb
new file mode 100644
index 000000000..be345bd78
--- /dev/null
+++ b/app/models/landing_host.rb
@@ -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
diff --git a/app/models/lead_click.rb b/app/models/lead_click.rb
new file mode 100644
index 000000000..d0fd0cf61
--- /dev/null
+++ b/app/models/lead_click.rb
@@ -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
diff --git a/app/services/leads/attribution_matcher_service.rb b/app/services/leads/attribution_matcher_service.rb
new file mode 100644
index 000000000..720b2ec4d
--- /dev/null
+++ b/app/services/leads/attribution_matcher_service.rb
@@ -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
diff --git a/app/services/whatsapp/incoming_message_wuzapi_service.rb b/app/services/whatsapp/incoming_message_wuzapi_service.rb
index 568b0b88f..ba877575e 100644
--- a/app/services/whatsapp/incoming_message_wuzapi_service.rb
+++ b/app/services/whatsapp/incoming_message_wuzapi_service.rb
@@ -41,6 +41,9 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
@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
create_new_message
@@ -150,8 +153,18 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
def build_message(parser, conversation, clean_source_id)
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 = {
- content: parser.text_content,
+ content: content,
account_id: inbox.account_id, inbox_id: inbox.id,
message_type: is_outgoing ? :outgoing : :incoming,
sender: is_outgoing ? nil : @contact,
diff --git a/app/services/whatsapp/providers/wuzapi_service.rb b/app/services/whatsapp/providers/wuzapi_service.rb
index cf8005990..ccd67461d 100644
--- a/app/services/whatsapp/providers/wuzapi_service.rb
+++ b/app/services/whatsapp/providers/wuzapi_service.rb
@@ -10,97 +10,46 @@ class Whatsapp::Providers::WuzapiService < Whatsapp::Providers::BaseService
def send_message(phone_number, message)
user_token = whatsapp_channel.wuzapi_user_token
- # Normalize phone number: remove +, space, -, (, )
- normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '')
+ normalized_phone = normalize_phone(phone_number)
+ log_outgoing_message(message)
+ return send_reaction_message(normalized_phone, message) if reaction_message?(message)
- Rails.logger.info "[WuzapiService] Sending Message:
- Message ID: #{message.id}
- 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
+ content_to_send = build_content_with_signature(message)
+ response = dispatch_message(user_token, normalized_phone, message, content_to_send)
extract_message_id(response)
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
- base64_data = Base64.strict_encode64(attachment.file.download)
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}"
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
client.send_file(user_token, phone_number, data_uri, attachment.file.filename.to_s)
end
end
def send_reaction_message(phone_number, message)
- user_token = whatsapp_channel.wuzapi_user_token
- normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '')
-
- # Assuming message content is the emoji
+ user_token = whatsapp_channel.wuzapi_user_token
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
- message_id = message.content_attributes['in_reply_to_external_id']
+ Rails.logger.info "[WuzapiService] Attempting reaction: phone=#{phone}, msg_id=#{mid}, emoji=#{reaction_emoji}"
- # Fallback to internal ID resolution if external is missing
- 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
+ if mid.blank?
Rails.logger.warn 'Wuzapi: Cannot send reaction without in_reply_to message ID'
+ return
end
+
+ response = client.send_reaction(user_token, phone, mid, reaction_emoji)
+ Rails.logger.info "[WuzapiService] Reaction response: #{response}"
+ response
end
def send_template(_phone_number, _template_info)
@@ -155,6 +104,74 @@ class Whatsapp::Providers::WuzapiService < Whatsapp::Providers::BaseService
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
@client ||= ::Wuzapi::Client.new(@base_url)
end
diff --git a/config/routes.rb b/config/routes.rb
index 971e0122d..29b730656 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -37,6 +37,7 @@ Rails.application.routes.draw do
get '/health', to: 'health#show'
get '/api', to: 'api#index'
+ post '/track/click', to: 'api/v1/tracking#click'
namespace :api, defaults: { format: 'json' } do
namespace :v1 do
# ----------------------------------
@@ -234,6 +235,7 @@ Rails.application.routes.draw do
get :campaigns, on: :member
get :agent_bot, on: :member
post :set_agent_bot, on: :member
+ resources :landing_hosts, only: [:index, :create, :destroy]
post :setup_channel_provider, on: :member
post :disconnect_channel_provider, on: :member
delete :avatar, on: :member
diff --git a/db/migrate/20260302091000_add_message_signature_default_name_to_inboxes.rb b/db/migrate/20260302091000_add_message_signature_default_name_to_inboxes.rb
new file mode 100644
index 000000000..5404d4d85
--- /dev/null
+++ b/db/migrate/20260302091000_add_message_signature_default_name_to_inboxes.rb
@@ -0,0 +1,5 @@
+class AddMessageSignatureDefaultNameToInboxes < ActiveRecord::Migration[7.0]
+ def change
+ add_column :inboxes, :message_signature_default_name, :string
+ end
+end
diff --git a/db/migrate/20260302092000_add_shift_signatures_to_inboxes.rb b/db/migrate/20260302092000_add_shift_signatures_to_inboxes.rb
new file mode 100644
index 000000000..00cc4f259
--- /dev/null
+++ b/db/migrate/20260302092000_add_shift_signatures_to_inboxes.rb
@@ -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
diff --git a/db/migrate/20260302093000_add_night_shift_hours_to_inboxes.rb b/db/migrate/20260302093000_add_night_shift_hours_to_inboxes.rb
new file mode 100644
index 000000000..791e5f189
--- /dev/null
+++ b/db/migrate/20260302093000_add_night_shift_hours_to_inboxes.rb
@@ -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
diff --git a/db/migrate/20260302154630_create_lead_clicks.rb b/db/migrate/20260302154630_create_lead_clicks.rb
new file mode 100644
index 000000000..01bd8b5c1
--- /dev/null
+++ b/db/migrate/20260302154630_create_lead_clicks.rb
@@ -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
diff --git a/db/migrate/20260302154737_create_landing_hosts.rb b/db/migrate/20260302154737_create_landing_hosts.rb
new file mode 100644
index 000000000..efaa6ac1b
--- /dev/null
+++ b/db/migrate/20260302154737_create_landing_hosts.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 450553dee..4e3105fb4 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# 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
enable_extension "pg_stat_statements"
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.boolean "message_signature_enabled"
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 ["channel_id", "channel_type"], name: "index_inboxes_on_channel_id_and_channel_type"
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
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|
t.bigint "account_id", null: false
t.bigint "user_id", null: false
diff --git a/spec/factories/landing_hosts.rb b/spec/factories/landing_hosts.rb
new file mode 100644
index 000000000..46038705d
--- /dev/null
+++ b/spec/factories/landing_hosts.rb
@@ -0,0 +1,8 @@
+FactoryBot.define do
+ factory :landing_host do
+ hostname { 'MyString' }
+ unit_code { 'MyString' }
+ inbox_id { 1 }
+ active { false }
+ end
+end
diff --git a/spec/factories/lead_clicks.rb b/spec/factories/lead_clicks.rb
new file mode 100644
index 000000000..80b0816a8
--- /dev/null
+++ b/spec/factories/lead_clicks.rb
@@ -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
diff --git a/spec/helpers/api/v1/accounts/landing_hosts_helper_spec.rb b/spec/helpers/api/v1/accounts/landing_hosts_helper_spec.rb
new file mode 100644
index 000000000..e8648287a
--- /dev/null
+++ b/spec/helpers/api/v1/accounts/landing_hosts_helper_spec.rb
@@ -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
diff --git a/spec/models/landing_host_spec.rb b/spec/models/landing_host_spec.rb
new file mode 100644
index 000000000..f8c5ec3ff
--- /dev/null
+++ b/spec/models/landing_host_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe LandingHost, type: :model do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/models/lead_click_spec.rb b/spec/models/lead_click_spec.rb
new file mode 100644
index 000000000..1c67d4256
--- /dev/null
+++ b/spec/models/lead_click_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe LeadClick, type: :model do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/requests/api/v1/accounts/landing_hosts_spec.rb b/spec/requests/api/v1/accounts/landing_hosts_spec.rb
new file mode 100644
index 000000000..dd7e60dd7
--- /dev/null
+++ b/spec/requests/api/v1/accounts/landing_hosts_spec.rb
@@ -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
diff --git a/spec/requests/api/v1/tracking_spec.rb b/spec/requests/api/v1/tracking_spec.rb
new file mode 100644
index 000000000..b2155085c
--- /dev/null
+++ b/spec/requests/api/v1/tracking_spec.rb
@@ -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