Merge remote-tracking branch 'origin/assignment_v2/assignment_service' into assignment_v2/assignment_service

This commit is contained in:
Tanmay Deep Sharma
2025-11-12 18:00:34 +05:30
1665 changed files with 104331 additions and 7317 deletions
@@ -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
+27
View File
@@ -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
+2 -3
View File
@@ -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
+30 -15
View File
@@ -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
+88
View File
@@ -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
+28
View File
@@ -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?
+84
View File
@@ -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
+43 -4
View File
@@ -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
+19 -13
View File
@@ -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