Merge remote-tracking branch 'origin/assignment_v2/assignment_service' into assignment_v2/assignment_service
This commit is contained in:
@@ -151,13 +151,36 @@ class AutomationRules::ConditionsFilterService < FilterService
|
||||
" #{table_name}.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} "
|
||||
when 'standard'
|
||||
if attribute_key == 'labels'
|
||||
" tags.id #{filter_operator_value} #{query_operator} "
|
||||
build_label_query_string(query_hash, current_index, query_operator)
|
||||
else
|
||||
" #{table_name}.#{attribute_key} #{filter_operator_value} #{query_operator} "
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def build_label_query_string(query_hash, current_index, query_operator)
|
||||
case query_hash['filter_operator']
|
||||
when 'equal_to'
|
||||
return " 1=0 #{query_operator} " if query_hash['values'].blank?
|
||||
|
||||
value_placeholder = "value_#{current_index}"
|
||||
@filter_values[value_placeholder] = query_hash['values'].first
|
||||
" tags.name = :#{value_placeholder} #{query_operator} "
|
||||
when 'not_equal_to'
|
||||
return " 1=0 #{query_operator} " if query_hash['values'].blank?
|
||||
|
||||
value_placeholder = "value_#{current_index}"
|
||||
@filter_values[value_placeholder] = query_hash['values'].first
|
||||
" tags.name != :#{value_placeholder} #{query_operator} "
|
||||
when 'is_present'
|
||||
" tags.id IS NOT NULL #{query_operator} "
|
||||
when 'is_not_present'
|
||||
" tags.id IS NULL #{query_operator} "
|
||||
else
|
||||
" tags.id #{filter_operation(query_hash, current_index)} #{query_operator} "
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def base_relation
|
||||
@@ -166,7 +189,21 @@ class AutomationRules::ConditionsFilterService < FilterService
|
||||
).joins(
|
||||
'LEFT OUTER JOIN messages on messages.conversation_id = conversations.id'
|
||||
)
|
||||
|
||||
# Only add label joins when label conditions exist
|
||||
if label_conditions?
|
||||
records = records.joins(
|
||||
'LEFT OUTER JOIN taggings ON taggings.taggable_id = conversations.id AND taggings.taggable_type = \'Conversation\''
|
||||
).joins(
|
||||
'LEFT OUTER JOIN tags ON taggings.tag_id = tags.id'
|
||||
)
|
||||
end
|
||||
|
||||
records = records.where(messages: { id: @options[:message].id }) if @options[:message].present?
|
||||
records
|
||||
end
|
||||
|
||||
def label_conditions?
|
||||
@rule.conditions.any? { |condition| condition['attribute_key'] == 'labels' }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
class BaseTokenService
|
||||
pattr_initialize [:payload, :token]
|
||||
|
||||
def generate_token
|
||||
JWT.encode(token_payload, secret_key, algorithm)
|
||||
end
|
||||
|
||||
def decode_token
|
||||
JWT.decode(token, secret_key, true, algorithm: algorithm).first.symbolize_keys
|
||||
rescue JWT::ExpiredSignature, JWT::DecodeError
|
||||
{}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def token_payload
|
||||
payload || {}
|
||||
end
|
||||
|
||||
def secret_key
|
||||
Rails.application.secret_key_base
|
||||
end
|
||||
|
||||
def algorithm
|
||||
'HS256'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
class Contacts::BulkActionService
|
||||
def initialize(account:, user:, params:)
|
||||
@account = account
|
||||
@user = user
|
||||
@params = params.deep_symbolize_keys
|
||||
end
|
||||
|
||||
def perform
|
||||
return delete_contacts if delete_requested?
|
||||
return assign_labels if labels_to_add.any?
|
||||
|
||||
Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}")
|
||||
{ success: false, error: 'unknown_operation' }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_labels
|
||||
Contacts::BulkAssignLabelsService.new(
|
||||
account: @account,
|
||||
contact_ids: ids,
|
||||
labels: labels_to_add
|
||||
).perform
|
||||
end
|
||||
|
||||
def delete_contacts
|
||||
Contacts::BulkDeleteService.new(
|
||||
account: @account,
|
||||
contact_ids: ids
|
||||
).perform
|
||||
end
|
||||
|
||||
def ids
|
||||
Array(@params[:ids]).compact
|
||||
end
|
||||
|
||||
def labels_to_add
|
||||
@labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?)
|
||||
end
|
||||
|
||||
def delete_requested?
|
||||
@params[:action_name] == 'delete'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
class Contacts::BulkAssignLabelsService
|
||||
def initialize(account:, contact_ids:, labels:)
|
||||
@account = account
|
||||
@contact_ids = Array(contact_ids)
|
||||
@labels = Array(labels).compact_blank
|
||||
end
|
||||
|
||||
def perform
|
||||
return { success: true, updated_contact_ids: [] } if @contact_ids.blank? || @labels.blank?
|
||||
|
||||
contacts = @account.contacts.where(id: @contact_ids)
|
||||
|
||||
contacts.find_each do |contact|
|
||||
contact.add_labels(@labels)
|
||||
end
|
||||
|
||||
{ success: true, updated_contact_ids: contacts.pluck(:id) }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
class Contacts::BulkDeleteService
|
||||
def initialize(account:, contact_ids: [])
|
||||
@account = account
|
||||
@contact_ids = Array(contact_ids).compact
|
||||
end
|
||||
|
||||
def perform
|
||||
return if @contact_ids.blank?
|
||||
|
||||
contacts.find_each(&:destroy!)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def contacts
|
||||
@account.contacts.where(id: @contact_ids)
|
||||
end
|
||||
end
|
||||
@@ -21,7 +21,7 @@ class Contacts::FilterService < FilterService
|
||||
def filter_values(query_hash)
|
||||
current_val = query_hash['values'][0]
|
||||
if query_hash['attribute_key'] == 'phone_number'
|
||||
"+#{current_val}"
|
||||
"+#{current_val&.delete('+')}"
|
||||
elsif query_hash['attribute_key'] == 'country_code'
|
||||
current_val.downcase
|
||||
else
|
||||
@@ -29,9 +29,8 @@ class Contacts::FilterService < FilterService
|
||||
end
|
||||
end
|
||||
|
||||
# TODO: @account.contacts.resolved_contacts ? to stay consistant with the behavior in ui
|
||||
def base_relation
|
||||
@account.contacts
|
||||
@account.contacts.resolved_contacts(use_crm_v2: @account.feature_enabled?('crm_v2'))
|
||||
end
|
||||
|
||||
def filter_config
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
class Email::SendOnEmailService < Base::SendOnChannelService
|
||||
private
|
||||
|
||||
def channel_class
|
||||
Channel::Email
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
return unless message.email_notifiable_message?
|
||||
|
||||
reply_mail = ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now
|
||||
Rails.logger.info("Email message #{message.id} sent with source_id: #{reply_mail.message_id}")
|
||||
message.update(source_id: reply_mail.message_id)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
|
||||
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
|
||||
end
|
||||
end
|
||||
@@ -10,11 +10,6 @@ class Line::IncomingMessageService
|
||||
# probably test events
|
||||
return if params[:events].blank?
|
||||
|
||||
line_contact_info
|
||||
return if line_contact_info['userId'].blank?
|
||||
|
||||
set_contact
|
||||
set_conversation
|
||||
parse_events
|
||||
end
|
||||
|
||||
@@ -22,6 +17,14 @@ class Line::IncomingMessageService
|
||||
|
||||
def parse_events
|
||||
params[:events].each do |event|
|
||||
next unless event_type_message?(event)
|
||||
|
||||
get_line_contact_info(event)
|
||||
next if @line_contact_info['userId'].blank?
|
||||
|
||||
set_contact
|
||||
set_conversation
|
||||
|
||||
next unless message_created? event
|
||||
|
||||
attach_files event['message']
|
||||
@@ -30,8 +33,6 @@ class Line::IncomingMessageService
|
||||
end
|
||||
|
||||
def message_created?(event)
|
||||
return unless event_type_message?(event)
|
||||
|
||||
@message = @conversation.messages.build(
|
||||
content: message_content(event),
|
||||
account_id: @inbox.account_id,
|
||||
@@ -76,7 +77,8 @@ class Line::IncomingMessageService
|
||||
|
||||
response = inbox.channel.client.get_message_content(message['id'])
|
||||
|
||||
file_name = "media-#{message['id']}.#{response.content_type.split('/')[1]}"
|
||||
extension = get_file_extension(response)
|
||||
file_name = message['fileName'] || "media-#{message['id']}.#{extension}"
|
||||
temp_file = Tempfile.new(file_name)
|
||||
temp_file.binmode
|
||||
temp_file << response.body
|
||||
@@ -93,25 +95,38 @@ class Line::IncomingMessageService
|
||||
)
|
||||
end
|
||||
|
||||
def get_file_extension(response)
|
||||
if response.content_type&.include?('/')
|
||||
response.content_type.split('/')[1]
|
||||
else
|
||||
'bin'
|
||||
end
|
||||
end
|
||||
|
||||
def event_type_message?(event)
|
||||
event['type'] == 'message' || event['type'] == 'sticker'
|
||||
end
|
||||
|
||||
def message_type_non_text?(type)
|
||||
[Line::Bot::Event::MessageType::Video, Line::Bot::Event::MessageType::Audio, Line::Bot::Event::MessageType::Image].include?(type)
|
||||
[
|
||||
Line::Bot::Event::MessageType::Video,
|
||||
Line::Bot::Event::MessageType::Audio,
|
||||
Line::Bot::Event::MessageType::Image,
|
||||
Line::Bot::Event::MessageType::File
|
||||
].include?(type)
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= inbox.account
|
||||
end
|
||||
|
||||
def line_contact_info
|
||||
@line_contact_info ||= JSON.parse(inbox.channel.client.get_profile(params[:events].first['source']['userId']).body)
|
||||
def get_line_contact_info(event)
|
||||
@line_contact_info = JSON.parse(inbox.channel.client.get_profile(event['source']['userId']).body)
|
||||
end
|
||||
|
||||
def set_contact
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: line_contact_info['userId'],
|
||||
source_id: @line_contact_info['userId'],
|
||||
inbox: inbox,
|
||||
contact_attributes: contact_attributes
|
||||
).perform
|
||||
@@ -138,15 +153,15 @@ class Line::IncomingMessageService
|
||||
|
||||
def contact_attributes
|
||||
{
|
||||
name: line_contact_info['displayName'],
|
||||
avatar_url: line_contact_info['pictureUrl'],
|
||||
name: @line_contact_info['displayName'],
|
||||
avatar_url: @line_contact_info['pictureUrl'],
|
||||
additional_attributes: additional_attributes
|
||||
}
|
||||
end
|
||||
|
||||
def additional_attributes
|
||||
{
|
||||
social_line_user_id: line_contact_info['userId']
|
||||
social_line_user_id: @line_contact_info['userId']
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -39,7 +39,14 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
||||
end
|
||||
|
||||
def format_message(message)
|
||||
sender = message.message_type == 'incoming' ? 'User' : 'Support agent'
|
||||
sender = case message.sender_type
|
||||
when 'User'
|
||||
'Support Agent'
|
||||
when 'Contact'
|
||||
'User'
|
||||
else
|
||||
'Bot'
|
||||
end
|
||||
sender = "[Private Note] #{sender}" if message.private?
|
||||
"#{sender}: #{message.content}\n"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
class Messages::SendEmailNotificationService
|
||||
pattr_initialize [:message!]
|
||||
|
||||
def perform
|
||||
return unless should_send_email_notification?
|
||||
|
||||
conversation = message.conversation
|
||||
conversation_mail_key = format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
|
||||
|
||||
# Atomically set redis key to prevent duplicate email workers. Keep the key alive longer than
|
||||
# the worker delay (1 hour) so slow queues don't enqueue duplicate jobs, but let it expire if
|
||||
# the worker never manages to clean up.
|
||||
return unless Redis::Alfred.set(conversation_mail_key, message.id, nx: true, ex: 1.hour.to_i)
|
||||
|
||||
ConversationReplyEmailJob.set(wait: 2.minutes).perform_later(conversation.id, message.id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_send_email_notification?
|
||||
return false unless message.email_notifiable_message?
|
||||
return false if message.conversation.contact.email.blank?
|
||||
|
||||
email_reply_enabled?
|
||||
end
|
||||
|
||||
def email_reply_enabled?
|
||||
inbox = message.inbox
|
||||
case inbox.channel.class.to_s
|
||||
when 'Channel::WebWidget'
|
||||
inbox.channel.continuity_via_email
|
||||
when 'Channel::Api'
|
||||
inbox.account.feature_enabled?('email_continuity_on_api_channel')
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
class Mfa::AuthenticationService
|
||||
pattr_initialize [:user!, :otp_code, :backup_code]
|
||||
|
||||
def authenticate
|
||||
return false unless user
|
||||
|
||||
return authenticate_with_otp if otp_code.present?
|
||||
return authenticate_with_backup_code if backup_code.present?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authenticate_with_otp
|
||||
user.validate_and_consume_otp!(otp_code)
|
||||
end
|
||||
|
||||
def authenticate_with_backup_code
|
||||
mfa_service = Mfa::ManagementService.new(user: user)
|
||||
mfa_service.validate_backup_code!(backup_code)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
class Mfa::ManagementService
|
||||
pattr_initialize [:user!]
|
||||
|
||||
def enable_two_factor!
|
||||
user.otp_secret = User.generate_otp_secret
|
||||
user.save!
|
||||
end
|
||||
|
||||
def disable_two_factor!
|
||||
user.otp_secret = nil
|
||||
user.otp_required_for_login = false
|
||||
user.otp_backup_codes = nil
|
||||
user.save!
|
||||
end
|
||||
|
||||
def verify_and_activate!
|
||||
ActiveRecord::Base.transaction do
|
||||
user.update!(otp_required_for_login: true)
|
||||
backup_codes_generated? ? nil : generate_backup_codes!
|
||||
end
|
||||
end
|
||||
|
||||
def two_factor_provisioning_uri
|
||||
return nil if user.otp_secret.blank?
|
||||
|
||||
issuer = 'Chatwoot'
|
||||
label = user.email
|
||||
user.otp_provisioning_uri(label, issuer: issuer)
|
||||
end
|
||||
|
||||
def generate_backup_codes!
|
||||
codes = Array.new(10) { SecureRandom.hex(4).upcase }
|
||||
user.otp_backup_codes = codes
|
||||
user.save!
|
||||
codes
|
||||
end
|
||||
|
||||
def validate_backup_code!(code)
|
||||
return false unless valid_backup_code_input?(code)
|
||||
|
||||
codes = user.otp_backup_codes
|
||||
found_index = find_matching_code_index(codes, code)
|
||||
|
||||
return false if found_index.nil?
|
||||
|
||||
mark_code_as_used(codes, found_index)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_backup_code_input?(code)
|
||||
user.otp_backup_codes.present? && code.present?
|
||||
end
|
||||
|
||||
def find_matching_code_index(codes, code)
|
||||
found_index = nil
|
||||
|
||||
# Constant-time comparison to prevent timing attacks
|
||||
codes.each_with_index do |stored_code, idx|
|
||||
is_match = ActiveSupport::SecurityUtils.secure_compare(stored_code, code)
|
||||
is_unused = stored_code != 'XXXXXXXX'
|
||||
found_index = idx if is_match && is_unused
|
||||
end
|
||||
|
||||
found_index
|
||||
end
|
||||
|
||||
def mark_code_as_used(codes, index)
|
||||
codes[index] = 'XXXXXXXX'
|
||||
user.otp_backup_codes = codes
|
||||
user.save!
|
||||
true
|
||||
end
|
||||
|
||||
public
|
||||
|
||||
def backup_codes_generated?
|
||||
user.otp_backup_codes.present?
|
||||
end
|
||||
|
||||
def mfa_enabled?
|
||||
user.otp_required_for_login?
|
||||
end
|
||||
|
||||
def two_factor_setup_pending?
|
||||
user.otp_secret.present? && !user.otp_required_for_login?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
class Mfa::TokenService < BaseTokenService
|
||||
pattr_initialize [:user, :token]
|
||||
|
||||
MFA_TOKEN_EXPIRY = 5.minutes
|
||||
|
||||
def generate_token
|
||||
@payload = build_payload
|
||||
super
|
||||
end
|
||||
|
||||
def verify_token
|
||||
decoded = decode_token
|
||||
return nil if decoded.blank?
|
||||
|
||||
User.find(decoded[:user_id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_payload
|
||||
{
|
||||
user_id: user.id,
|
||||
exp: MFA_TOKEN_EXPIRY.from_now.to_i
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -77,7 +77,13 @@ class Telegram::IncomingMessageService
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
@conversation = @contact_inbox.conversations.first
|
||||
# if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
|
||||
@conversation = if @inbox.lock_to_single_conversation
|
||||
@contact_inbox.conversations.last
|
||||
else
|
||||
@contact_inbox.conversations
|
||||
.where.not(status: :resolved).last
|
||||
end
|
||||
return if @conversation
|
||||
|
||||
@conversation = ::Conversation.create!(conversation_params)
|
||||
|
||||
@@ -44,6 +44,12 @@ class Twilio::IncomingMessageService
|
||||
twilio_channel.sms? ? params[:From] : params[:From].gsub('whatsapp:', '')
|
||||
end
|
||||
|
||||
def normalized_phone_number
|
||||
return phone_number unless twilio_channel.whatsapp?
|
||||
|
||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider("whatsapp:#{phone_number}", :twilio)
|
||||
end
|
||||
|
||||
def formatted_phone_number
|
||||
TelephoneNumber.parse(phone_number).international_number
|
||||
end
|
||||
@@ -53,8 +59,10 @@ class Twilio::IncomingMessageService
|
||||
end
|
||||
|
||||
def set_contact
|
||||
source_id = twilio_channel.whatsapp? ? normalized_phone_number : params[:From]
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: params[:From],
|
||||
source_id: source_id,
|
||||
inbox: inbox,
|
||||
contact_attributes: contact_attributes
|
||||
).perform
|
||||
|
||||
@@ -17,6 +17,7 @@ class Whatsapp::EmbeddedSignupService
|
||||
|
||||
channel = create_or_reauthorize_channel(access_token, phone_info)
|
||||
channel.setup_webhooks
|
||||
check_channel_health_and_prompt_reauth(channel)
|
||||
channel
|
||||
|
||||
rescue StandardError => e
|
||||
@@ -52,6 +53,24 @@ class Whatsapp::EmbeddedSignupService
|
||||
end
|
||||
end
|
||||
|
||||
def check_channel_health_and_prompt_reauth(channel)
|
||||
health_data = Whatsapp::HealthService.new(channel).fetch_health_status
|
||||
return unless health_data
|
||||
|
||||
if channel_in_pending_state?(health_data)
|
||||
channel.prompt_reauthorization!
|
||||
else
|
||||
Rails.logger.info "[WHATSAPP] Channel #{channel.phone_number} health check passed"
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WHATSAPP] Health check failed for channel #{channel.phone_number}: #{e.message}"
|
||||
end
|
||||
|
||||
def channel_in_pending_state?(health_data)
|
||||
health_data[:platform_type] == 'NOT_APPLICABLE' ||
|
||||
health_data.dig(:throughput, 'level') == 'NOT_APPLICABLE'
|
||||
end
|
||||
|
||||
def validate_parameters!
|
||||
missing_params = []
|
||||
missing_params << 'code' if @code.blank?
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
class Whatsapp::HealthService
|
||||
BASE_URI = 'https://graph.facebook.com'.freeze
|
||||
|
||||
def initialize(channel)
|
||||
@channel = channel
|
||||
@access_token = channel.provider_config['api_key']
|
||||
@api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
|
||||
end
|
||||
|
||||
def fetch_health_status
|
||||
validate_channel!
|
||||
fetch_phone_health_data
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_channel!
|
||||
raise ArgumentError, 'Channel is required' if @channel.blank?
|
||||
raise ArgumentError, 'API key is missing' if @access_token.blank?
|
||||
raise ArgumentError, 'Phone number ID is missing' if @channel.provider_config['phone_number_id'].blank?
|
||||
end
|
||||
|
||||
def fetch_phone_health_data
|
||||
phone_number_id = @channel.provider_config['phone_number_id']
|
||||
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/#{phone_number_id}",
|
||||
query: {
|
||||
fields: health_fields,
|
||||
access_token: @access_token
|
||||
}
|
||||
)
|
||||
|
||||
handle_response(response)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WHATSAPP HEALTH] Error fetching health data: #{e.message}"
|
||||
raise e
|
||||
end
|
||||
|
||||
def health_fields
|
||||
%w[
|
||||
quality_rating
|
||||
messaging_limit_tier
|
||||
code_verification_status
|
||||
account_mode
|
||||
id
|
||||
display_phone_number
|
||||
name_status
|
||||
verified_name
|
||||
webhook_configuration
|
||||
throughput
|
||||
last_onboarded_time
|
||||
platform_type
|
||||
certificate
|
||||
].join(',')
|
||||
end
|
||||
|
||||
def handle_response(response)
|
||||
unless response.success?
|
||||
error_message = "WhatsApp API request failed: #{response.code} - #{response.body}"
|
||||
Rails.logger.error "[WHATSAPP HEALTH] #{error_message}"
|
||||
raise error_message
|
||||
end
|
||||
|
||||
data = response.parsed_response
|
||||
format_health_response(data)
|
||||
end
|
||||
|
||||
def format_health_response(response)
|
||||
{
|
||||
display_phone_number: response['display_phone_number'],
|
||||
verified_name: response['verified_name'],
|
||||
name_status: response['name_status'],
|
||||
quality_rating: response['quality_rating'],
|
||||
messaging_limit_tier: response['messaging_limit_tier'],
|
||||
account_mode: response['account_mode'],
|
||||
code_verification_status: response['code_verification_status'],
|
||||
throughput: response['throughput'],
|
||||
last_onboarded_time: response['last_onboarded_time'],
|
||||
platform_type: response['platform_type'],
|
||||
business_id: @channel.provider_config['business_account_id']
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -32,9 +32,11 @@ class Whatsapp::IncomingMessageBaseService
|
||||
set_contact
|
||||
return unless @contact
|
||||
|
||||
set_conversation
|
||||
create_messages
|
||||
clear_message_source_id_from_redis
|
||||
ActiveRecord::Base.transaction do
|
||||
set_conversation
|
||||
create_messages
|
||||
clear_message_source_id_from_redis
|
||||
end
|
||||
end
|
||||
|
||||
def process_statuses
|
||||
|
||||
@@ -47,36 +47,8 @@ module Whatsapp::IncomingMessageServiceHelpers
|
||||
%w[reaction ephemeral unsupported request_welcome].include?(message_type)
|
||||
end
|
||||
|
||||
def brazil_phone_number?(phone_number)
|
||||
phone_number.match(/^55/)
|
||||
end
|
||||
|
||||
# ref: https://github.com/chatwoot/chatwoot/issues/5840
|
||||
def normalised_brazil_mobile_number(phone_number)
|
||||
# DDD : Area codes in Brazil are popularly known as "DDD codes" (códigos DDD) or simply "DDD", from the initials of "direct distance dialing"
|
||||
# https://en.wikipedia.org/wiki/Telephone_numbers_in_Brazil
|
||||
ddd = phone_number[2, 2]
|
||||
# Remove country code and DDD to obtain the number
|
||||
number = phone_number[4, phone_number.length - 4]
|
||||
normalised_number = "55#{ddd}#{number}"
|
||||
# insert 9 to convert the number to the new mobile number format
|
||||
normalised_number = "55#{ddd}9#{number}" if normalised_number.length != 13
|
||||
normalised_number
|
||||
end
|
||||
|
||||
def processed_waid(waid)
|
||||
# in case of Brazil, we need to do additional processing
|
||||
# https://github.com/chatwoot/chatwoot/issues/5840
|
||||
if brazil_phone_number?(waid)
|
||||
# check if there is an existing contact inbox with the normalised waid
|
||||
# We will create conversation against it
|
||||
contact_inbox = inbox.contact_inboxes.find_by(source_id: normalised_brazil_mobile_number(waid))
|
||||
|
||||
# if there is no contact inbox with the waid without 9,
|
||||
# We will create contact inboxes and contacts with the number 9 added
|
||||
waid = contact_inbox.source_id if contact_inbox.present?
|
||||
end
|
||||
waid
|
||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(waid, :cloud)
|
||||
end
|
||||
|
||||
def error_webhook_event?(message)
|
||||
|
||||
@@ -9,7 +9,13 @@ class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageB
|
||||
end
|
||||
|
||||
def download_attachment_file(attachment_payload)
|
||||
url_response = HTTParty.get(inbox.channel.media_url(attachment_payload[:id]), headers: inbox.channel.api_headers)
|
||||
url_response = HTTParty.get(
|
||||
inbox.channel.media_url(
|
||||
attachment_payload[:id],
|
||||
inbox.channel.provider_config['phone_number_id']
|
||||
),
|
||||
headers: inbox.channel.api_headers
|
||||
)
|
||||
# This url response will be failure if the access token has expired.
|
||||
inbox.channel.authorization_error! if url_response.unauthorized?
|
||||
Down.download(url_response.parsed_response['url'], headers: inbox.channel.api_headers) if url_response.success?
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Handles Argentina phone number normalization
|
||||
#
|
||||
# Argentina phone numbers can appear with or without "9" after country code
|
||||
# This normalizer removes the "9" when present to create consistent format: 54 + area + number
|
||||
class Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer < Whatsapp::PhoneNormalizers::BasePhoneNormalizer
|
||||
def normalize(waid)
|
||||
return waid unless handles_country?(waid)
|
||||
|
||||
# Remove "9" after country code if present (549 → 54)
|
||||
waid.sub(/^549/, '54')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def country_code_pattern
|
||||
/^54/
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
# Base class for country-specific phone number normalizers
|
||||
# Each country normalizer should inherit from this class and implement:
|
||||
# - country_code_pattern: regex to identify the country code
|
||||
# - normalize: logic to convert phone number to normalized format for contact lookup
|
||||
class Whatsapp::PhoneNormalizers::BasePhoneNormalizer
|
||||
def handles_country?(waid)
|
||||
waid.match(country_code_pattern)
|
||||
end
|
||||
|
||||
def normalize(waid)
|
||||
raise NotImplementedError, 'Subclasses must implement #normalize'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def country_code_pattern
|
||||
raise NotImplementedError, 'Subclasses must implement #country_code_pattern'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
# Handles Brazil phone number normalization
|
||||
# ref: https://github.com/chatwoot/chatwoot/issues/5840
|
||||
#
|
||||
# Brazil changed its mobile number system by adding a "9" prefix to existing numbers.
|
||||
# This normalizer adds the "9" digit if the number is 12 digits (making it 13 digits total)
|
||||
# to match the new format: 55 + DDD + 9 + number
|
||||
class Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer < Whatsapp::PhoneNormalizers::BasePhoneNormalizer
|
||||
COUNTRY_CODE_LENGTH = 2
|
||||
DDD_LENGTH = 2
|
||||
|
||||
def normalize(waid)
|
||||
return waid unless handles_country?(waid)
|
||||
|
||||
ddd = waid[COUNTRY_CODE_LENGTH, DDD_LENGTH]
|
||||
number = waid[COUNTRY_CODE_LENGTH + DDD_LENGTH, waid.length - (COUNTRY_CODE_LENGTH + DDD_LENGTH)]
|
||||
normalized_number = "55#{ddd}#{number}"
|
||||
normalized_number = "55#{ddd}9#{number}" if normalized_number.length != 13
|
||||
normalized_number
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def country_code_pattern
|
||||
/^55/
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,69 @@
|
||||
# Service to handle phone number normalization for WhatsApp messages
|
||||
# Currently supports Brazil and Argentina phone number format variations
|
||||
# Supports both WhatsApp Cloud API and Twilio WhatsApp providers
|
||||
class Whatsapp::PhoneNumberNormalizationService
|
||||
def initialize(inbox)
|
||||
@inbox = inbox
|
||||
end
|
||||
|
||||
# @param raw_number [String] The phone number in provider-specific format
|
||||
# - Cloud: "5541988887777" (clean number)
|
||||
# - Twilio: "whatsapp:+5541988887777" (prefixed format)
|
||||
# @param provider [Symbol] :cloud or :twilio
|
||||
# @return [String] Normalized source_id in provider format or original if not found
|
||||
def normalize_and_find_contact_by_provider(raw_number, provider)
|
||||
# Extract clean number based on provider format
|
||||
clean_number = extract_clean_number(raw_number, provider)
|
||||
|
||||
# Find appropriate normalizer for the country
|
||||
normalizer = find_normalizer_for_country(clean_number)
|
||||
return raw_number unless normalizer
|
||||
|
||||
# Normalize the clean number
|
||||
normalized_clean_number = normalizer.normalize(clean_number)
|
||||
|
||||
# Format for provider and check for existing contact
|
||||
provider_format = format_for_provider(normalized_clean_number, provider)
|
||||
existing_contact_inbox = find_existing_contact_inbox(provider_format)
|
||||
|
||||
existing_contact_inbox&.source_id || raw_number
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :inbox
|
||||
|
||||
def find_normalizer_for_country(waid)
|
||||
NORMALIZERS.map(&:new)
|
||||
.find { |normalizer| normalizer.handles_country?(waid) }
|
||||
end
|
||||
|
||||
def find_existing_contact_inbox(normalized_waid)
|
||||
inbox.contact_inboxes.find_by(source_id: normalized_waid)
|
||||
end
|
||||
|
||||
# Extract clean number from provider-specific format
|
||||
def extract_clean_number(raw_number, provider)
|
||||
case provider
|
||||
when :twilio
|
||||
raw_number.gsub(/^whatsapp:\+/, '') # Remove prefix: "whatsapp:+5541988887777" → "5541988887777"
|
||||
else
|
||||
raw_number # Default fallback for unknown providers
|
||||
end
|
||||
end
|
||||
|
||||
# Format normalized number for provider-specific storage
|
||||
def format_for_provider(clean_number, provider)
|
||||
case provider
|
||||
when :twilio
|
||||
"whatsapp:+#{clean_number}" # Add prefix: "5541988887777" → "whatsapp:+5541988887777"
|
||||
else
|
||||
clean_number # Default for :cloud and unknown providers: "5541988887777"
|
||||
end
|
||||
end
|
||||
|
||||
NORMALIZERS = [
|
||||
Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer,
|
||||
Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer
|
||||
].freeze
|
||||
end
|
||||
@@ -30,12 +30,13 @@ class Whatsapp::PopulateTemplateParametersService
|
||||
end
|
||||
end
|
||||
|
||||
def build_media_parameter(url, media_type)
|
||||
def build_media_parameter(url, media_type, media_name = nil)
|
||||
return nil if url.blank?
|
||||
|
||||
sanitized_url = sanitize_parameter(url)
|
||||
validate_url(sanitized_url)
|
||||
build_media_type_parameter(sanitized_url, media_type.downcase)
|
||||
normalized_url = normalize_url(sanitized_url)
|
||||
validate_url(normalized_url)
|
||||
build_media_type_parameter(normalized_url, media_type.downcase, media_name)
|
||||
end
|
||||
|
||||
def build_named_parameter(parameter_name, value)
|
||||
@@ -89,14 +90,14 @@ class Whatsapp::PopulateTemplateParametersService
|
||||
}
|
||||
end
|
||||
|
||||
def build_media_type_parameter(sanitized_url, media_type)
|
||||
def build_media_type_parameter(sanitized_url, media_type, media_name = nil)
|
||||
case media_type
|
||||
when 'image'
|
||||
build_image_parameter(sanitized_url)
|
||||
when 'video'
|
||||
build_video_parameter(sanitized_url)
|
||||
when 'document'
|
||||
build_document_parameter(sanitized_url)
|
||||
build_document_parameter(sanitized_url, media_name)
|
||||
else
|
||||
raise ArgumentError, "Unsupported media type: #{media_type}"
|
||||
end
|
||||
@@ -110,8 +111,11 @@ class Whatsapp::PopulateTemplateParametersService
|
||||
{ type: 'video', video: { link: url } }
|
||||
end
|
||||
|
||||
def build_document_parameter(url)
|
||||
{ type: 'document', document: { link: url } }
|
||||
def build_document_parameter(url, media_name = nil)
|
||||
document_params = { link: url }
|
||||
document_params[:filename] = media_name if media_name.present?
|
||||
|
||||
{ type: 'document', document: document_params }
|
||||
end
|
||||
|
||||
def rich_formatting?(text)
|
||||
@@ -135,9 +139,20 @@ class Whatsapp::PopulateTemplateParametersService
|
||||
sanitized[0...1000] # Limit length to prevent DoS
|
||||
end
|
||||
|
||||
def normalize_url(url)
|
||||
# Use Addressable::URI for better URL normalization
|
||||
# It handles spaces, special characters, and encoding automatically
|
||||
Addressable::URI.parse(url).normalize.to_s
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
# Fallback: simple space encoding if Addressable fails
|
||||
url.gsub(' ', '%20')
|
||||
end
|
||||
|
||||
def validate_url(url)
|
||||
return if url.blank?
|
||||
|
||||
# url is already normalized by the caller
|
||||
|
||||
uri = URI.parse(url)
|
||||
raise ArgumentError, "Invalid URL scheme: #{uri.scheme}. Only http and https are allowed" unless %w[http https].include?(uri.scheme)
|
||||
raise ArgumentError, 'URL too long (max 2000 characters)' if url.length > 2000
|
||||
|
||||
@@ -62,8 +62,10 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
{ 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", 'Content-Type' => 'application/json' }
|
||||
end
|
||||
|
||||
def media_url(media_id)
|
||||
"#{api_base_path}/v13.0/#{media_id}"
|
||||
def media_url(media_id, phone_number_id = nil)
|
||||
url = "#{api_base_path}/v13.0/#{media_id}"
|
||||
url += "?phone_number_id=#{phone_number_id}" if phone_number_id
|
||||
url
|
||||
end
|
||||
|
||||
def api_base_path
|
||||
@@ -141,7 +143,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
# {
|
||||
# processed_params: {
|
||||
# body: { '1': 'John', '2': '123 Main St' },
|
||||
# header: { media_url: 'https://...', media_type: 'image' },
|
||||
# header: {
|
||||
# media_url: 'https://...',
|
||||
# media_type: 'image',
|
||||
# media_name: 'filename.pdf' # Optional, for document templates only
|
||||
# },
|
||||
# buttons: [{ type: 'url', parameter: 'otp123456' }]
|
||||
# }
|
||||
# }
|
||||
|
||||
@@ -60,9 +60,10 @@ class Whatsapp::TemplateProcessorService
|
||||
next if value.blank?
|
||||
|
||||
if media_url_with_type?(key, header_data)
|
||||
media_param = parameter_builder.build_media_parameter(value, header_data['media_type'])
|
||||
media_name = header_data['media_name']
|
||||
media_param = parameter_builder.build_media_parameter(value, header_data['media_type'], media_name)
|
||||
header_params << media_param if media_param
|
||||
elsif key != 'media_type'
|
||||
elsif key != 'media_type' && key != 'media_name'
|
||||
header_params << parameter_builder.build_parameter(value)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,8 +8,12 @@ class Whatsapp::WebhookSetupService
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
# Since coexistence method does not need to register, we check it
|
||||
register_phone_number unless phone_number_verified?
|
||||
|
||||
# Register phone number if either condition is met:
|
||||
# 1. Phone number is not verified (code_verification_status != 'VERIFIED')
|
||||
# 2. Phone number needs registration (pending provisioning state)
|
||||
register_phone_number if !phone_number_verified? || phone_number_needs_registration?
|
||||
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
@@ -69,9 +73,44 @@ class Whatsapp::WebhookSetupService
|
||||
def phone_number_verified?
|
||||
phone_number_id = @channel.provider_config['phone_number_id']
|
||||
|
||||
@api_client.phone_number_verified?(phone_number_id)
|
||||
# Check with WhatsApp API if the phone number code verification is complete
|
||||
# This checks code_verification_status == 'VERIFIED'
|
||||
verified = @api_client.phone_number_verified?(phone_number_id)
|
||||
Rails.logger.info("[WHATSAPP] Phone number #{phone_number_id} code verification status: #{verified}")
|
||||
|
||||
verified
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Phone registration status check failed, but continuing: #{e.message}")
|
||||
# If verification check fails, assume not verified to be safe
|
||||
Rails.logger.error("[WHATSAPP] Phone verification status check failed: #{e.message}")
|
||||
false
|
||||
end
|
||||
|
||||
def phone_number_needs_registration?
|
||||
# Check if phone is in pending provisioning state based on health data
|
||||
# This is a separate check from phone_number_verified? which only checks code verification
|
||||
|
||||
phone_number_in_pending_state?
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Phone registration check failed: #{e.message}")
|
||||
# Conservative approach: don't register if we can't determine the state
|
||||
false
|
||||
end
|
||||
|
||||
def phone_number_in_pending_state?
|
||||
health_service = Whatsapp::HealthService.new(@channel)
|
||||
health_data = health_service.fetch_health_status
|
||||
|
||||
# Check if phone number is in "not provisioned" state based on health indicators
|
||||
# These conditions indicate the number is pending and needs registration:
|
||||
# - platform_type: "NOT_APPLICABLE" means not fully set up
|
||||
# - throughput.level: "NOT_APPLICABLE" means no messaging capacity assigned
|
||||
health_data[:platform_type] == 'NOT_APPLICABLE' ||
|
||||
health_data.dig(:throughput, :level) == 'NOT_APPLICABLE'
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Health status check failed: #{e.message}")
|
||||
# If health check fails, assume registration is not needed to avoid errors
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
class Widget::TokenService
|
||||
pattr_initialize [:payload, :token]
|
||||
class Widget::TokenService < BaseTokenService
|
||||
DEFAULT_EXPIRY_DAYS = 180
|
||||
|
||||
def generate_token
|
||||
JWT.encode payload, secret_key, 'HS256'
|
||||
end
|
||||
|
||||
def decode_token
|
||||
JWT.decode(
|
||||
token, secret_key, true, algorithm: 'HS256'
|
||||
).first.symbolize_keys
|
||||
rescue StandardError
|
||||
{}
|
||||
JWT.encode(token_payload, secret_key, algorithm)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def secret_key
|
||||
Rails.application.secret_key_base
|
||||
def token_payload
|
||||
(payload || {}).merge(exp: exp, iat: iat)
|
||||
end
|
||||
|
||||
def iat
|
||||
Time.zone.now.to_i
|
||||
end
|
||||
|
||||
def exp
|
||||
iat + expire_in.days.to_i
|
||||
end
|
||||
|
||||
def expire_in
|
||||
# Value is stored in days, defaulting to 6 months (180 days)
|
||||
token_expiry_value = InstallationConfig.find_by(name: 'WIDGET_TOKEN_EXPIRY')&.value
|
||||
(token_expiry_value.presence || DEFAULT_EXPIRY_DAYS).to_i
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user