Merge branch 'develop' into feat/github-integration

This commit is contained in:
Muhsin Keloth
2025-10-28 19:27:09 +05:30
committed by GitHub
623 changed files with 34053 additions and 3965 deletions
@@ -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
@@ -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
@@ -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
@@ -48,7 +48,7 @@ module Whatsapp::IncomingMessageServiceHelpers
end
def processed_waid(waid)
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(waid, :cloud)
end
def error_webhook_event?(message)
@@ -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
@@ -1,23 +1,32 @@
# Service to handle phone number normalization for WhatsApp messages
# Currently supports Brazil phone number format variations
# Designed to be extensible for additional countries in future PRs
#
# Usage: Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
# 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
# Main entry point for phone number normalization
# Returns the source_id of an existing contact if found, otherwise returns original waid
def normalize_and_find_contact(waid)
normalizer = find_normalizer_for_country(waid)
return waid unless normalizer
# @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)
normalized_waid = normalizer.normalize(waid)
existing_contact_inbox = find_existing_contact_inbox(normalized_waid)
# Find appropriate normalizer for the country
normalizer = find_normalizer_for_country(clean_number)
return raw_number unless normalizer
existing_contact_inbox&.source_id || waid
# 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
@@ -33,7 +42,28 @@ class Whatsapp::PhoneNumberNormalizationService
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::BrazilPhoneNormalizer,
Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer
].freeze
end
@@ -34,8 +34,9 @@ class Whatsapp::PopulateTemplateParametersService
return nil if url.blank?
sanitized_url = sanitize_parameter(url)
validate_url(sanitized_url)
build_media_type_parameter(sanitized_url, media_type.downcase, media_name)
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)
@@ -138,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