Initial commit: Add logistics and order_detail message types
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled
- Add Logistics component with progress tracking - Add OrderDetail component for order information - Support data-driven steps and actions - Add blue color scale to widget SCSS - Fix node overflow and progress bar rendering issues - Add English translations for dashboard components Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
73
app/services/whatsapp/channel_creation_service.rb
Normal file
73
app/services/whatsapp/channel_creation_service.rb
Normal file
@@ -0,0 +1,73 @@
|
||||
class Whatsapp::ChannelCreationService
|
||||
def initialize(account, waba_info, phone_info, access_token)
|
||||
@account = account
|
||||
@waba_info = waba_info
|
||||
@phone_info = phone_info
|
||||
@access_token = access_token
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
|
||||
existing_channel = find_existing_channel
|
||||
raise I18n.t('errors.whatsapp.phone_number_already_exists', phone_number: existing_channel.phone_number) if existing_channel
|
||||
|
||||
create_channel_with_inbox
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Account is required' if @account.blank?
|
||||
raise ArgumentError, 'WABA info is required' if @waba_info.blank?
|
||||
raise ArgumentError, 'Phone info is required' if @phone_info.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def find_existing_channel
|
||||
Channel::Whatsapp.find_by(
|
||||
phone_number: @phone_info[:phone_number]
|
||||
)
|
||||
end
|
||||
|
||||
def create_channel_with_inbox
|
||||
ActiveRecord::Base.transaction do
|
||||
channel = build_channel
|
||||
create_inbox(channel)
|
||||
channel
|
||||
end
|
||||
end
|
||||
|
||||
def build_channel
|
||||
Channel::Whatsapp.build(
|
||||
account: @account,
|
||||
phone_number: @phone_info[:phone_number],
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: build_provider_config
|
||||
)
|
||||
end
|
||||
|
||||
def build_provider_config
|
||||
{
|
||||
api_key: @access_token,
|
||||
phone_number_id: @phone_info[:phone_number_id],
|
||||
business_account_id: @waba_info[:waba_id],
|
||||
source: 'embedded_signup'
|
||||
}
|
||||
end
|
||||
|
||||
def create_inbox(channel)
|
||||
inbox_name = build_inbox_name
|
||||
|
||||
Inbox.create!(
|
||||
account: @account,
|
||||
name: inbox_name,
|
||||
channel: channel
|
||||
)
|
||||
end
|
||||
|
||||
def build_inbox_name
|
||||
business_name = @phone_info[:business_name] || @waba_info[:business_name]
|
||||
"#{business_name} WhatsApp"
|
||||
end
|
||||
end
|
||||
139
app/services/whatsapp/csat_template_service.rb
Normal file
139
app/services/whatsapp/csat_template_service.rb
Normal file
@@ -0,0 +1,139 @@
|
||||
class Whatsapp::CsatTemplateService
|
||||
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||
DEFAULT_LANGUAGE = 'en'.freeze
|
||||
WHATSAPP_API_VERSION = 'v14.0'.freeze
|
||||
TEMPLATE_CATEGORY = 'MARKETING'.freeze
|
||||
TEMPLATE_STATUS_PENDING = 'PENDING'.freeze
|
||||
|
||||
def initialize(whatsapp_channel)
|
||||
@whatsapp_channel = whatsapp_channel
|
||||
end
|
||||
|
||||
def create_template(template_config)
|
||||
base_name = template_config[:template_name]
|
||||
template_name = generate_template_name(base_name)
|
||||
template_config_with_name = template_config.merge(template_name: template_name)
|
||||
request_body = build_template_request_body(template_config_with_name)
|
||||
response = send_template_creation_request(request_body)
|
||||
process_template_creation_response(response, template_config_with_name)
|
||||
end
|
||||
|
||||
def delete_template(template_name = nil)
|
||||
template_name ||= CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id)
|
||||
response = HTTParty.delete(
|
||||
"#{business_account_path}/message_templates?name=#{template_name}",
|
||||
headers: api_headers
|
||||
)
|
||||
{ success: response.success?, response_body: response.body }
|
||||
end
|
||||
|
||||
def get_template_status(template_name)
|
||||
response = HTTParty.get("#{business_account_path}/message_templates?name=#{template_name}", headers: api_headers)
|
||||
|
||||
if response.success? && response['data']&.any?
|
||||
template_data = response['data'].first
|
||||
{
|
||||
success: true,
|
||||
template: {
|
||||
id: template_data['id'], name: template_data['name'],
|
||||
status: template_data['status'], language: template_data['language']
|
||||
}
|
||||
}
|
||||
else
|
||||
{ success: false, error: 'Template not found' }
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error fetching template status: #{e.message}"
|
||||
{ success: false, error: e.message }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_template_name(base_name)
|
||||
current_template_name = current_template_name_from_config
|
||||
CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name)
|
||||
end
|
||||
|
||||
def current_template_name_from_config
|
||||
@whatsapp_channel.inbox.csat_config&.dig('template', 'name')
|
||||
end
|
||||
|
||||
def build_template_request_body(template_config)
|
||||
{
|
||||
name: template_config[:template_name],
|
||||
language: template_config[:language] || DEFAULT_LANGUAGE,
|
||||
category: TEMPLATE_CATEGORY,
|
||||
components: build_template_components(template_config)
|
||||
}
|
||||
end
|
||||
|
||||
def build_template_components(template_config)
|
||||
[
|
||||
build_body_component(template_config[:message]),
|
||||
build_buttons_component(template_config)
|
||||
]
|
||||
end
|
||||
|
||||
def build_body_component(message)
|
||||
{
|
||||
type: 'BODY',
|
||||
text: message
|
||||
}
|
||||
end
|
||||
|
||||
def build_buttons_component(template_config)
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
type: 'URL',
|
||||
text: template_config[:button_text] || DEFAULT_BUTTON_TEXT,
|
||||
url: "#{template_config[:base_url]}/survey/responses/{{1}}",
|
||||
example: ['12345']
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def send_template_creation_request(request_body)
|
||||
HTTParty.post(
|
||||
"#{business_account_path}/message_templates",
|
||||
headers: api_headers,
|
||||
body: request_body.to_json
|
||||
)
|
||||
end
|
||||
|
||||
def process_template_creation_response(response, template_config = {})
|
||||
if response.success?
|
||||
{
|
||||
success: true,
|
||||
template_id: response['id'],
|
||||
template_name: response['name'] || template_config[:template_name],
|
||||
language: template_config[:language] || DEFAULT_LANGUAGE,
|
||||
status: TEMPLATE_STATUS_PENDING
|
||||
}
|
||||
else
|
||||
Rails.logger.error "WhatsApp template creation failed: #{response.code} - #{response.body}"
|
||||
{
|
||||
success: false,
|
||||
error: 'Template creation failed',
|
||||
response_body: response.body
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def business_account_path
|
||||
"#{api_base_path}/#{WHATSAPP_API_VERSION}/#{@whatsapp_channel.provider_config['business_account_id']}"
|
||||
end
|
||||
|
||||
def api_headers
|
||||
{
|
||||
'Authorization' => "Bearer #{@whatsapp_channel.provider_config['api_key']}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
end
|
||||
|
||||
def api_base_path
|
||||
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
|
||||
end
|
||||
end
|
||||
88
app/services/whatsapp/embedded_signup_service.rb
Normal file
88
app/services/whatsapp/embedded_signup_service.rb
Normal file
@@ -0,0 +1,88 @@
|
||||
class Whatsapp::EmbeddedSignupService
|
||||
def initialize(account:, params:, inbox_id: nil)
|
||||
@account = account
|
||||
@code = params[:code]
|
||||
@business_id = params[:business_id]
|
||||
@waba_id = params[:waba_id]
|
||||
@phone_number_id = params[:phone_number_id]
|
||||
@inbox_id = inbox_id
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
|
||||
access_token = exchange_code_for_token
|
||||
phone_info = fetch_phone_info(access_token)
|
||||
validate_token_access(access_token)
|
||||
|
||||
channel = create_or_reauthorize_channel(access_token, phone_info)
|
||||
# NOTE: We call setup_webhooks explicitly here instead of relying on after_commit callback because:
|
||||
# 1. Reauthorization flow updates an existing channel (not a create), so after_commit on: :create won't trigger
|
||||
# 2. We need to run check_channel_health_and_prompt_reauth after webhook setup completes
|
||||
# 3. The channel is marked with source: 'embedded_signup' to skip the after_commit callback
|
||||
channel.setup_webhooks
|
||||
check_channel_health_and_prompt_reauth(channel)
|
||||
channel
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def exchange_code_for_token
|
||||
Whatsapp::TokenExchangeService.new(@code).perform
|
||||
end
|
||||
|
||||
def fetch_phone_info(access_token)
|
||||
Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
|
||||
end
|
||||
|
||||
def validate_token_access(access_token)
|
||||
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
|
||||
end
|
||||
|
||||
def create_or_reauthorize_channel(access_token, phone_info)
|
||||
if @inbox_id.present?
|
||||
Whatsapp::ReauthorizationService.new(
|
||||
account: @account,
|
||||
inbox_id: @inbox_id,
|
||||
phone_number_id: @phone_number_id,
|
||||
business_id: @business_id
|
||||
).perform(access_token, phone_info)
|
||||
else
|
||||
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
|
||||
Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
|
||||
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?
|
||||
missing_params << 'business_id' if @business_id.blank?
|
||||
missing_params << 'waba_id' if @waba_id.blank?
|
||||
|
||||
return if missing_params.empty?
|
||||
|
||||
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
|
||||
end
|
||||
end
|
||||
105
app/services/whatsapp/facebook_api_client.rb
Normal file
105
app/services/whatsapp/facebook_api_client.rb
Normal file
@@ -0,0 +1,105 @@
|
||||
class Whatsapp::FacebookApiClient
|
||||
BASE_URI = 'https://graph.facebook.com'.freeze
|
||||
|
||||
def initialize(access_token = nil)
|
||||
@access_token = access_token
|
||||
@api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
|
||||
end
|
||||
|
||||
def exchange_code_for_token(code)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/oauth/access_token",
|
||||
query: {
|
||||
client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''),
|
||||
code: code
|
||||
}
|
||||
)
|
||||
|
||||
handle_response(response, 'Token exchange failed')
|
||||
end
|
||||
|
||||
def fetch_phone_numbers(waba_id)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/phone_numbers",
|
||||
query: { access_token: @access_token }
|
||||
)
|
||||
|
||||
handle_response(response, 'WABA phone numbers fetch failed')
|
||||
end
|
||||
|
||||
def debug_token(input_token)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/debug_token",
|
||||
query: {
|
||||
input_token: input_token,
|
||||
access_token: build_app_access_token
|
||||
}
|
||||
)
|
||||
|
||||
handle_response(response, 'Token validation failed')
|
||||
end
|
||||
|
||||
def register_phone_number(phone_number_id, pin)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{phone_number_id}/register",
|
||||
headers: request_headers,
|
||||
body: { messaging_product: 'whatsapp', pin: pin.to_s }.to_json
|
||||
)
|
||||
|
||||
handle_response(response, 'Phone registration failed')
|
||||
end
|
||||
|
||||
def phone_number_verified?(phone_number_id)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/#{phone_number_id}",
|
||||
headers: request_headers
|
||||
)
|
||||
|
||||
data = handle_response(response, 'Phone status check failed')
|
||||
data['code_verification_status'] == 'VERIFIED'
|
||||
end
|
||||
|
||||
def subscribe_waba_webhook(waba_id, callback_url, verify_token)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
|
||||
headers: request_headers,
|
||||
body: {
|
||||
override_callback_uri: callback_url,
|
||||
verify_token: verify_token
|
||||
}.to_json
|
||||
)
|
||||
|
||||
handle_response(response, 'Webhook subscription failed')
|
||||
end
|
||||
|
||||
def unsubscribe_waba_webhook(waba_id)
|
||||
response = HTTParty.delete(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
|
||||
headers: request_headers
|
||||
)
|
||||
|
||||
handle_response(response, 'Webhook unsubscription failed')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request_headers
|
||||
{
|
||||
'Authorization' => "Bearer #{@access_token}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
end
|
||||
|
||||
def build_app_access_token
|
||||
app_id = GlobalConfigService.load('WHATSAPP_APP_ID', '')
|
||||
app_secret = GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
|
||||
"#{app_id}|#{app_secret}"
|
||||
end
|
||||
|
||||
def handle_response(response, error_message)
|
||||
raise "#{error_message}: #{response.body}" unless response.success?
|
||||
|
||||
response.parsed_response
|
||||
end
|
||||
end
|
||||
84
app/services/whatsapp/health_service.rb
Normal file
84
app/services/whatsapp/health_service.rb
Normal 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
|
||||
196
app/services/whatsapp/incoming_message_base_service.rb
Normal file
196
app/services/whatsapp/incoming_message_base_service.rb
Normal file
@@ -0,0 +1,196 @@
|
||||
# Mostly modeled after the intial implementation of the service based on 360 Dialog
|
||||
# https://docs.360dialog.com/whatsapp-api/whatsapp-api/media
|
||||
# https://developers.facebook.com/docs/whatsapp/api/media/
|
||||
class Whatsapp::IncomingMessageBaseService
|
||||
include ::Whatsapp::IncomingMessageServiceHelpers
|
||||
|
||||
pattr_initialize [:inbox!, :params!]
|
||||
|
||||
def perform
|
||||
processed_params
|
||||
|
||||
if processed_params.try(:[], :statuses).present?
|
||||
process_statuses
|
||||
elsif processed_params.try(:[], :messages).present?
|
||||
process_messages
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_messages
|
||||
# We don't support reactions & ephemeral message now, we need to skip processing the message
|
||||
# if the webhook event is a reaction or an ephermal message or an unsupported message.
|
||||
return if unprocessable_message_type?(message_type)
|
||||
|
||||
# Multiple webhook event can be received against the same message due to misconfigurations in the Meta
|
||||
# business manager account. While we have not found the core reason yet, the following line ensure that
|
||||
# there are no duplicate messages created.
|
||||
return if find_message_by_source_id(@processed_params[:messages].first[:id]) || message_under_process?
|
||||
|
||||
cache_message_source_id_in_redis
|
||||
set_contact
|
||||
return unless @contact
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
set_conversation
|
||||
create_messages
|
||||
clear_message_source_id_from_redis
|
||||
end
|
||||
end
|
||||
|
||||
def process_statuses
|
||||
return unless find_message_by_source_id(@processed_params[:statuses].first[:id])
|
||||
|
||||
update_message_with_status(@message, @processed_params[:statuses].first)
|
||||
rescue ArgumentError => e
|
||||
Rails.logger.error "Error while processing whatsapp status update #{e.message}"
|
||||
end
|
||||
|
||||
def update_message_with_status(message, status)
|
||||
message.status = status[:status]
|
||||
if status[:status] == 'failed' && status[:errors].present?
|
||||
error = status[:errors]&.first
|
||||
message.external_error = "#{error[:code]}: #{error[:title]}"
|
||||
end
|
||||
message.save!
|
||||
end
|
||||
|
||||
def create_messages
|
||||
message = @processed_params[:messages].first
|
||||
log_error(message) && return if error_webhook_event?(message)
|
||||
|
||||
process_in_reply_to(message)
|
||||
|
||||
message_type == 'contacts' ? create_contact_messages(message) : create_regular_message(message)
|
||||
end
|
||||
|
||||
def create_contact_messages(message)
|
||||
message['contacts'].each do |contact|
|
||||
create_message(contact)
|
||||
attach_contact(contact)
|
||||
@message.save!
|
||||
end
|
||||
end
|
||||
|
||||
def create_regular_message(message)
|
||||
create_message(message)
|
||||
attach_files
|
||||
attach_location if message_type == 'location'
|
||||
@message.save!
|
||||
end
|
||||
|
||||
def set_contact
|
||||
contact_params = @processed_params[:contacts]&.first
|
||||
return if contact_params.blank?
|
||||
|
||||
waid = processed_waid(contact_params[:wa_id])
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: waid,
|
||||
inbox: inbox,
|
||||
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{@processed_params[:messages].first[:from]}" }
|
||||
).perform
|
||||
|
||||
@contact_inbox = contact_inbox
|
||||
@contact = contact_inbox.contact
|
||||
|
||||
# Update existing contact name if ProfileName is available and current name is just phone number
|
||||
update_contact_with_profile_name(contact_params)
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
# 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)
|
||||
end
|
||||
|
||||
def attach_files
|
||||
return if %w[text button interactive location contacts].include?(message_type)
|
||||
|
||||
attachment_payload = @processed_params[:messages].first[message_type.to_sym]
|
||||
@message.content ||= attachment_payload[:caption]
|
||||
|
||||
attachment_file = download_attachment_file(attachment_payload)
|
||||
return if attachment_file.blank?
|
||||
|
||||
@message.attachments.new(
|
||||
account_id: @message.account_id,
|
||||
file_type: file_content_type(message_type),
|
||||
file: {
|
||||
io: attachment_file,
|
||||
filename: attachment_file.original_filename,
|
||||
content_type: attachment_file.content_type
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def attach_location
|
||||
location = @processed_params[:messages].first['location']
|
||||
location_name = location['name'] ? "#{location['name']}, #{location['address']}" : ''
|
||||
@message.attachments.new(
|
||||
account_id: @message.account_id,
|
||||
file_type: file_content_type(message_type),
|
||||
coordinates_lat: location['latitude'],
|
||||
coordinates_long: location['longitude'],
|
||||
fallback_title: location_name,
|
||||
external_url: location['url']
|
||||
)
|
||||
end
|
||||
|
||||
def create_message(message)
|
||||
@message = @conversation.messages.build(
|
||||
content: message_content(message),
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
message_type: :incoming,
|
||||
sender: @contact,
|
||||
source_id: message[:id].to_s,
|
||||
in_reply_to_external_id: @in_reply_to_external_id
|
||||
)
|
||||
end
|
||||
|
||||
def attach_contact(contact)
|
||||
phones = contact[:phones]
|
||||
phones = [{ phone: 'Phone number is not available' }] if phones.blank?
|
||||
|
||||
name_info = contact['name'] || {}
|
||||
contact_meta = {
|
||||
firstName: name_info['first_name'],
|
||||
lastName: name_info['last_name']
|
||||
}.compact
|
||||
|
||||
phones.each do |phone|
|
||||
@message.attachments.new(
|
||||
account_id: @message.account_id,
|
||||
file_type: file_content_type(message_type),
|
||||
fallback_title: phone[:phone].to_s,
|
||||
meta: contact_meta
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def update_contact_with_profile_name(contact_params)
|
||||
profile_name = contact_params.dig(:profile, :name)
|
||||
return if profile_name.blank?
|
||||
return if @contact.name == profile_name
|
||||
|
||||
# Only update if current name exactly matches the phone number or formatted phone number
|
||||
return unless contact_name_matches_phone_number?
|
||||
|
||||
@contact.update!(name: profile_name)
|
||||
end
|
||||
|
||||
def contact_name_matches_phone_number?
|
||||
phone_number = "+#{@processed_params[:messages].first[:from]}"
|
||||
formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
|
||||
@contact.name == phone_number || @contact.name == formatted_phone_number
|
||||
end
|
||||
end
|
||||
5
app/services/whatsapp/incoming_message_service.rb
Normal file
5
app/services/whatsapp/incoming_message_service.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
# https://docs.360dialog.com/whatsapp-api/whatsapp-api/media
|
||||
# https://developers.facebook.com/docs/whatsapp/api/media/
|
||||
|
||||
class Whatsapp::IncomingMessageService < Whatsapp::IncomingMessageBaseService
|
||||
end
|
||||
88
app/services/whatsapp/incoming_message_service_helpers.rb
Normal file
88
app/services/whatsapp/incoming_message_service_helpers.rb
Normal file
@@ -0,0 +1,88 @@
|
||||
module Whatsapp::IncomingMessageServiceHelpers
|
||||
def download_attachment_file(attachment_payload)
|
||||
Down.download(inbox.channel.media_url(attachment_payload[:id]), headers: inbox.channel.api_headers)
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: @contact.id,
|
||||
contact_inbox_id: @contact_inbox.id
|
||||
}
|
||||
end
|
||||
|
||||
def processed_params
|
||||
@processed_params ||= params
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= inbox.account
|
||||
end
|
||||
|
||||
def message_type
|
||||
@processed_params[:messages].first[:type]
|
||||
end
|
||||
|
||||
def message_content(message)
|
||||
# TODO: map interactive messages back to button messages in chatwoot
|
||||
message.dig(:text, :body) ||
|
||||
message.dig(:button, :text) ||
|
||||
message.dig(:interactive, :button_reply, :title) ||
|
||||
message.dig(:interactive, :list_reply, :title) ||
|
||||
message.dig(:name, :formatted_name)
|
||||
end
|
||||
|
||||
def file_content_type(file_type)
|
||||
return :image if %w[image sticker].include?(file_type)
|
||||
return :audio if %w[audio voice].include?(file_type)
|
||||
return :video if ['video'].include?(file_type)
|
||||
return :location if ['location'].include?(file_type)
|
||||
return :contact if ['contacts'].include?(file_type)
|
||||
|
||||
:file
|
||||
end
|
||||
|
||||
def unprocessable_message_type?(message_type)
|
||||
%w[reaction ephemeral unsupported request_welcome].include?(message_type)
|
||||
end
|
||||
|
||||
def processed_waid(waid)
|
||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(waid, :cloud)
|
||||
end
|
||||
|
||||
def error_webhook_event?(message)
|
||||
message.key?('errors')
|
||||
end
|
||||
|
||||
def log_error(message)
|
||||
Rails.logger.warn "Whatsapp Error: #{message['errors'][0]['title']} - contact: #{message['from']}"
|
||||
end
|
||||
|
||||
def process_in_reply_to(message)
|
||||
@in_reply_to_external_id = message['context']&.[]('id')
|
||||
end
|
||||
|
||||
def find_message_by_source_id(source_id)
|
||||
return unless source_id
|
||||
|
||||
@message = Message.find_by(source_id: source_id)
|
||||
end
|
||||
|
||||
def message_under_process?
|
||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
|
||||
Redis::Alfred.get(key)
|
||||
end
|
||||
|
||||
def cache_message_source_id_in_redis
|
||||
return if @processed_params.try(:[], :messages).blank?
|
||||
|
||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
|
||||
::Redis::Alfred.setex(key, true)
|
||||
end
|
||||
|
||||
def clear_message_source_id_from_redis
|
||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id])
|
||||
::Redis::Alfred.delete(key)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
# https://docs.360dialog.com/whatsapp-api/whatsapp-api/media
|
||||
# https://developers.facebook.com/docs/whatsapp/api/media/
|
||||
|
||||
class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageBaseService
|
||||
private
|
||||
|
||||
def processed_params
|
||||
@processed_params ||= params[:entry].try(:first).try(:[], 'changes').try(:first).try(:[], 'value')
|
||||
end
|
||||
|
||||
def download_attachment_file(attachment_payload)
|
||||
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?
|
||||
end
|
||||
end
|
||||
96
app/services/whatsapp/oneoff_campaign_service.rb
Normal file
96
app/services/whatsapp/oneoff_campaign_service.rb
Normal file
@@ -0,0 +1,96 @@
|
||||
class Whatsapp::OneoffCampaignService
|
||||
pattr_initialize [:campaign!]
|
||||
|
||||
def perform
|
||||
validate_campaign!
|
||||
# marks campaign completed so that other jobs won't pick it up
|
||||
campaign.completed!
|
||||
process_audience(extract_audience_labels)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
delegate :inbox, to: :campaign
|
||||
delegate :channel, to: :inbox
|
||||
|
||||
def validate_campaign_type!
|
||||
raise "Invalid campaign #{campaign.id}" unless whatsapp_campaign? && campaign.one_off?
|
||||
end
|
||||
|
||||
def whatsapp_campaign?
|
||||
campaign.inbox.inbox_type == 'Whatsapp'
|
||||
end
|
||||
|
||||
def validate_campaign_status!
|
||||
raise 'Completed Campaign' if campaign.completed?
|
||||
end
|
||||
|
||||
def validate_provider!
|
||||
raise 'WhatsApp Cloud provider required' if channel.provider != 'whatsapp_cloud'
|
||||
end
|
||||
|
||||
def validate_feature_flag!
|
||||
raise 'WhatsApp campaigns feature not enabled' unless campaign.account.feature_enabled?(:whatsapp_campaign)
|
||||
end
|
||||
|
||||
def validate_campaign!
|
||||
validate_campaign_type!
|
||||
validate_campaign_status!
|
||||
validate_provider!
|
||||
validate_feature_flag!
|
||||
end
|
||||
|
||||
def extract_audience_labels
|
||||
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
|
||||
campaign.account.labels.where(id: audience_label_ids).pluck(:title)
|
||||
end
|
||||
|
||||
def process_contact(contact)
|
||||
Rails.logger.info "Processing contact: #{contact.name} (#{contact.phone_number})"
|
||||
|
||||
if contact.phone_number.blank?
|
||||
Rails.logger.info "Skipping contact #{contact.name} - no phone number"
|
||||
return
|
||||
end
|
||||
|
||||
if campaign.template_params.blank?
|
||||
Rails.logger.error "Skipping contact #{contact.name} - no template_params found for WhatsApp campaign"
|
||||
return
|
||||
end
|
||||
|
||||
send_whatsapp_template_message(to: contact.phone_number)
|
||||
end
|
||||
|
||||
def process_audience(audience_labels)
|
||||
contacts = campaign.account.contacts.tagged_with(audience_labels, any: true)
|
||||
Rails.logger.info "Processing #{contacts.count} contacts for campaign #{campaign.id}"
|
||||
|
||||
contacts.each { |contact| process_contact(contact) }
|
||||
|
||||
Rails.logger.info "Campaign #{campaign.id} processing completed"
|
||||
end
|
||||
|
||||
def send_whatsapp_template_message(to:)
|
||||
processor = Whatsapp::TemplateProcessorService.new(
|
||||
channel: channel,
|
||||
template_params: campaign.template_params
|
||||
)
|
||||
|
||||
name, namespace, lang_code, processed_parameters = processor.call
|
||||
|
||||
return if name.blank?
|
||||
|
||||
channel.send_template(to, {
|
||||
name: name,
|
||||
namespace: namespace,
|
||||
lang_code: lang_code,
|
||||
parameters: processed_parameters
|
||||
}, nil)
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to send WhatsApp template message to #{to}: #{e.message}"
|
||||
Rails.logger.error "Backtrace: #{e.backtrace.first(5).join('\n')}"
|
||||
# continue processing remaining contacts
|
||||
nil
|
||||
end
|
||||
end
|
||||
57
app/services/whatsapp/phone_info_service.rb
Normal file
57
app/services/whatsapp/phone_info_service.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
class Whatsapp::PhoneInfoService
|
||||
def initialize(waba_id, phone_number_id, access_token)
|
||||
@waba_id = waba_id
|
||||
@phone_number_id = phone_number_id
|
||||
@access_token = access_token
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
fetch_and_process_phone_info
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def fetch_and_process_phone_info
|
||||
response = @api_client.fetch_phone_numbers(@waba_id)
|
||||
phone_numbers = response['data']
|
||||
|
||||
phone_data = find_phone_data(phone_numbers)
|
||||
raise "No phone numbers found for WABA #{@waba_id}" if phone_data.nil?
|
||||
|
||||
build_phone_info(phone_data)
|
||||
end
|
||||
|
||||
def find_phone_data(phone_numbers)
|
||||
return nil if phone_numbers.blank?
|
||||
|
||||
if @phone_number_id.present?
|
||||
phone_numbers.find { |phone| phone['id'] == @phone_number_id } || phone_numbers.first
|
||||
else
|
||||
phone_numbers.first
|
||||
end
|
||||
end
|
||||
|
||||
def build_phone_info(phone_data)
|
||||
display_phone_number = sanitize_phone_number(phone_data['display_phone_number'])
|
||||
|
||||
{
|
||||
phone_number_id: phone_data['id'],
|
||||
phone_number: "+#{display_phone_number}",
|
||||
verified: phone_data['code_verification_status'] == 'VERIFIED',
|
||||
business_name: phone_data['verified_name'] || phone_data['display_phone_number']
|
||||
}
|
||||
end
|
||||
|
||||
def sanitize_phone_number(phone_number)
|
||||
return phone_number if phone_number.blank?
|
||||
|
||||
phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
69
app/services/whatsapp/phone_number_normalization_service.rb
Normal file
69
app/services/whatsapp/phone_number_normalization_service.rb
Normal file
@@ -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
|
||||
163
app/services/whatsapp/populate_template_parameters_service.rb
Normal file
163
app/services/whatsapp/populate_template_parameters_service.rb
Normal file
@@ -0,0 +1,163 @@
|
||||
class Whatsapp::PopulateTemplateParametersService
|
||||
def build_parameter(value)
|
||||
case value
|
||||
when String
|
||||
build_string_parameter(value)
|
||||
when Hash
|
||||
build_hash_parameter(value)
|
||||
else
|
||||
{ type: 'text', text: value.to_s }
|
||||
end
|
||||
end
|
||||
|
||||
def build_button_parameter(button)
|
||||
return { type: 'text', text: '' } if button.blank?
|
||||
|
||||
case button['type']
|
||||
when 'copy_code'
|
||||
coupon_code = button['parameter'].to_s.strip
|
||||
raise ArgumentError, 'Coupon code cannot be empty' if coupon_code.blank?
|
||||
raise ArgumentError, 'Coupon code cannot exceed 15 characters' if coupon_code.length > 15
|
||||
|
||||
{
|
||||
type: 'coupon_code',
|
||||
coupon_code: coupon_code
|
||||
}
|
||||
else
|
||||
# For URL buttons and other button types, treat parameter as text
|
||||
# If parameter is blank, use empty string (required for URL buttons)
|
||||
{ type: 'text', text: button['parameter'].to_s.strip }
|
||||
end
|
||||
end
|
||||
|
||||
def build_media_parameter(url, media_type, media_name = nil)
|
||||
return nil if url.blank?
|
||||
|
||||
sanitized_url = sanitize_parameter(url)
|
||||
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)
|
||||
sanitized_value = sanitize_parameter(value.to_s)
|
||||
{ type: 'text', parameter_name: parameter_name, text: sanitized_value }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_string_parameter(value)
|
||||
sanitized_value = sanitize_parameter(value)
|
||||
if rich_formatting?(sanitized_value)
|
||||
build_rich_text_parameter(sanitized_value)
|
||||
else
|
||||
{ type: 'text', text: sanitized_value }
|
||||
end
|
||||
end
|
||||
|
||||
def build_hash_parameter(value)
|
||||
case value['type']
|
||||
when 'currency'
|
||||
build_currency_parameter(value)
|
||||
when 'date_time'
|
||||
build_date_time_parameter(value)
|
||||
else
|
||||
{ type: 'text', text: value.to_s }
|
||||
end
|
||||
end
|
||||
|
||||
def build_currency_parameter(value)
|
||||
{
|
||||
type: 'currency',
|
||||
currency: {
|
||||
fallback_value: value['fallback_value'],
|
||||
code: value['code'],
|
||||
amount_1000: value['amount_1000']
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def build_date_time_parameter(value)
|
||||
{
|
||||
type: 'date_time',
|
||||
date_time: {
|
||||
fallback_value: value['fallback_value'],
|
||||
day_of_week: value['day_of_week'],
|
||||
day_of_month: value['day_of_month'],
|
||||
month: value['month'],
|
||||
year: value['year']
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
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, media_name)
|
||||
else
|
||||
raise ArgumentError, "Unsupported media type: #{media_type}"
|
||||
end
|
||||
end
|
||||
|
||||
def build_image_parameter(url)
|
||||
{ type: 'image', image: { link: url } }
|
||||
end
|
||||
|
||||
def build_video_parameter(url)
|
||||
{ type: 'video', video: { link: url } }
|
||||
end
|
||||
|
||||
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)
|
||||
# Check if text contains WhatsApp rich formatting markers
|
||||
text.match?(/\*[^*]+\*/) || # Bold: *text*
|
||||
text.match?(/_[^_]+_/) || # Italic: _text_
|
||||
text.match?(/~[^~]+~/) || # Strikethrough: ~text~
|
||||
text.match?(/```[^`]+```/) # Monospace: ```text```
|
||||
end
|
||||
|
||||
def build_rich_text_parameter(text)
|
||||
# WhatsApp supports rich text formatting in templates
|
||||
# This preserves the formatting markers for the API
|
||||
{ type: 'text', text: text }
|
||||
end
|
||||
|
||||
def sanitize_parameter(value)
|
||||
# Basic sanitization - remove dangerous characters and limit length
|
||||
sanitized = value.to_s.strip
|
||||
sanitized = sanitized.gsub(/[<>\"']/, '') # Remove potential HTML/JS chars
|
||||
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
|
||||
|
||||
rescue URI::InvalidURIError => e
|
||||
raise ArgumentError, "Invalid URL format: #{e.message}. Please enter a valid URL like https://example.com/document.pdf"
|
||||
end
|
||||
end
|
||||
106
app/services/whatsapp/providers/base_service.rb
Normal file
106
app/services/whatsapp/providers/base_service.rb
Normal file
@@ -0,0 +1,106 @@
|
||||
#######################################
|
||||
# To create a whatsapp provider
|
||||
# - Inherit this as the base class.
|
||||
# - Implement `send_message` method in your child class.
|
||||
# - Implement `send_template_message` method in your child class.
|
||||
# - Implement `sync_templates` method in your child class.
|
||||
# - Implement `validate_provider_config` method in your child class.
|
||||
# - Use Childclass.new(whatsapp_channel: channel).perform.
|
||||
######################################
|
||||
|
||||
class Whatsapp::Providers::BaseService
|
||||
pattr_initialize [:whatsapp_channel!]
|
||||
|
||||
def send_message(_phone_number, _message)
|
||||
raise 'Overwrite this method in child class'
|
||||
end
|
||||
|
||||
def send_template(_phone_number, _template_info, _message)
|
||||
raise 'Overwrite this method in child class'
|
||||
end
|
||||
|
||||
def sync_template
|
||||
raise 'Overwrite this method in child class'
|
||||
end
|
||||
|
||||
def validate_provider_config
|
||||
raise 'Overwrite this method in child class'
|
||||
end
|
||||
|
||||
def error_message
|
||||
raise 'Overwrite this method in child class'
|
||||
end
|
||||
|
||||
def process_response(response, message)
|
||||
parsed_response = response.parsed_response
|
||||
if response.success? && parsed_response['error'].blank?
|
||||
parsed_response['messages'].first['id']
|
||||
else
|
||||
handle_error(response, message)
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def handle_error(response, message)
|
||||
Rails.logger.error response.body
|
||||
return if message.blank?
|
||||
|
||||
# https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response
|
||||
error_message = error_message(response)
|
||||
return if error_message.blank?
|
||||
|
||||
message.external_error = error_message
|
||||
message.status = :failed
|
||||
message.save!
|
||||
end
|
||||
|
||||
def create_buttons(items)
|
||||
buttons = []
|
||||
items.each do |item|
|
||||
button = { :type => 'reply', 'reply' => { 'id' => item['value'], 'title' => item['title'] } }
|
||||
buttons << button
|
||||
end
|
||||
buttons
|
||||
end
|
||||
|
||||
def create_rows(items)
|
||||
rows = []
|
||||
items.each do |item|
|
||||
row = { 'id' => item['value'], 'title' => item['title'] }
|
||||
rows << row
|
||||
end
|
||||
rows
|
||||
end
|
||||
|
||||
def create_payload(type, message_content, action)
|
||||
{
|
||||
'type': type,
|
||||
'body': {
|
||||
'text': message_content
|
||||
},
|
||||
'action': action
|
||||
}
|
||||
end
|
||||
|
||||
def create_payload_based_on_items(message)
|
||||
if message.content_attributes['items'].length <= 3
|
||||
create_button_payload(message)
|
||||
else
|
||||
create_list_payload(message)
|
||||
end
|
||||
end
|
||||
|
||||
def create_button_payload(message)
|
||||
buttons = create_buttons(message.content_attributes['items'])
|
||||
json_hash = { 'buttons' => buttons }
|
||||
create_payload('button', message.outgoing_content, JSON.generate(json_hash))
|
||||
end
|
||||
|
||||
def create_list_payload(message)
|
||||
rows = create_rows(message.content_attributes['items'])
|
||||
section1 = { 'rows' => rows }
|
||||
sections = [section1]
|
||||
json_hash = { :button => I18n.t('conversations.messages.whatsapp.list_button_label'), 'sections' => sections }
|
||||
create_payload('list', message.outgoing_content, JSON.generate(json_hash))
|
||||
end
|
||||
end
|
||||
128
app/services/whatsapp/providers/whatsapp_360_dialog_service.rb
Normal file
128
app/services/whatsapp/providers/whatsapp_360_dialog_service.rb
Normal file
@@ -0,0 +1,128 @@
|
||||
class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseService
|
||||
def send_message(phone_number, message)
|
||||
@message = message
|
||||
if message.attachments.present?
|
||||
send_attachment_message(phone_number, message)
|
||||
elsif message.content_type == 'input_select'
|
||||
send_interactive_text_message(phone_number, message)
|
||||
else
|
||||
send_text_message(phone_number, message)
|
||||
end
|
||||
end
|
||||
|
||||
def send_template(phone_number, template_info, message)
|
||||
response = HTTParty.post(
|
||||
"#{api_base_path}/messages",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
to: phone_number,
|
||||
template: template_body_parameters(template_info),
|
||||
type: 'template'
|
||||
}.to_json
|
||||
)
|
||||
|
||||
process_response(response, message)
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
# ensuring that channels with wrong provider config wouldn't keep trying to sync templates
|
||||
whatsapp_channel.mark_message_templates_updated
|
||||
response = HTTParty.get("#{api_base_path}/configs/templates", headers: api_headers)
|
||||
whatsapp_channel.update(message_templates: response['waba_templates'], message_templates_last_updated: Time.now.utc) if response.success?
|
||||
end
|
||||
|
||||
def validate_provider_config?
|
||||
response = HTTParty.post(
|
||||
"#{api_base_path}/configs/webhook",
|
||||
headers: { 'D360-API-KEY': whatsapp_channel.provider_config['api_key'], 'Content-Type': 'application/json' },
|
||||
body: {
|
||||
url: "#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{whatsapp_channel.phone_number}"
|
||||
}.to_json
|
||||
)
|
||||
response.success?
|
||||
end
|
||||
|
||||
def api_headers
|
||||
{ 'D360-API-KEY' => whatsapp_channel.provider_config['api_key'], 'Content-Type' => 'application/json' }
|
||||
end
|
||||
|
||||
def media_url(media_id)
|
||||
"#{api_base_path}/media/#{media_id}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def api_base_path
|
||||
# provide the environment variable when testing against sandbox : 'https://waba-sandbox.360dialog.io/v1'
|
||||
ENV.fetch('360DIALOG_BASE_URL', 'https://waba.360dialog.io/v1')
|
||||
end
|
||||
|
||||
def send_text_message(phone_number, message)
|
||||
response = HTTParty.post(
|
||||
"#{api_base_path}/messages",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
to: phone_number,
|
||||
text: { body: message.outgoing_content },
|
||||
type: 'text'
|
||||
}.to_json
|
||||
)
|
||||
|
||||
process_response(response, message)
|
||||
end
|
||||
|
||||
def send_attachment_message(phone_number, message)
|
||||
attachment = message.attachments.first
|
||||
type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document'
|
||||
type_content = {
|
||||
'link': attachment.download_url
|
||||
}
|
||||
type_content['caption'] = message.outgoing_content unless %w[audio sticker].include?(type)
|
||||
type_content['filename'] = attachment.file.filename if type == 'document'
|
||||
|
||||
response = HTTParty.post(
|
||||
"#{api_base_path}/messages",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
'to' => phone_number,
|
||||
'type' => type,
|
||||
type.to_s => type_content
|
||||
}.to_json
|
||||
)
|
||||
|
||||
process_response(response, message)
|
||||
end
|
||||
|
||||
def error_message(response)
|
||||
# {"meta": {"success": false, "http_code": 400, "developer_message": "errro-message", "360dialog_trace_id": "someid"}}
|
||||
response.parsed_response.dig('meta', 'developer_message')
|
||||
end
|
||||
|
||||
def template_body_parameters(template_info)
|
||||
{
|
||||
name: template_info[:name],
|
||||
namespace: template_info[:namespace],
|
||||
language: {
|
||||
policy: 'deterministic',
|
||||
code: template_info[:lang_code]
|
||||
},
|
||||
components: template_info[:parameters]
|
||||
}
|
||||
end
|
||||
|
||||
def send_interactive_text_message(phone_number, message)
|
||||
payload = create_payload_based_on_items(message)
|
||||
|
||||
response = HTTParty.post(
|
||||
"#{api_base_path}/messages",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
to: phone_number,
|
||||
interactive: payload,
|
||||
type: 'interactive'
|
||||
}.to_json
|
||||
)
|
||||
|
||||
process_response(response, message)
|
||||
end
|
||||
end
|
||||
209
app/services/whatsapp/providers/whatsapp_cloud_service.rb
Normal file
209
app/services/whatsapp/providers/whatsapp_cloud_service.rb
Normal file
@@ -0,0 +1,209 @@
|
||||
class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseService
|
||||
def send_message(phone_number, message)
|
||||
@message = message
|
||||
|
||||
if message.attachments.present?
|
||||
send_attachment_message(phone_number, message)
|
||||
elsif message.content_type == 'input_select'
|
||||
send_interactive_text_message(phone_number, message)
|
||||
else
|
||||
send_text_message(phone_number, message)
|
||||
end
|
||||
end
|
||||
|
||||
def send_template(phone_number, template_info, message)
|
||||
template_body = template_body_parameters(template_info)
|
||||
|
||||
request_body = {
|
||||
messaging_product: 'whatsapp',
|
||||
recipient_type: 'individual', # Only individual messages supported (not group messages)
|
||||
to: phone_number,
|
||||
type: 'template',
|
||||
template: template_body
|
||||
}
|
||||
|
||||
response = HTTParty.post(
|
||||
"#{phone_id_path}/messages",
|
||||
headers: api_headers,
|
||||
body: request_body.to_json
|
||||
)
|
||||
|
||||
process_response(response, message)
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
# ensuring that channels with wrong provider config wouldn't keep trying to sync templates
|
||||
whatsapp_channel.mark_message_templates_updated
|
||||
templates = fetch_whatsapp_templates("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
|
||||
whatsapp_channel.update(message_templates: templates, message_templates_last_updated: Time.now.utc) if templates.present?
|
||||
end
|
||||
|
||||
def fetch_whatsapp_templates(url)
|
||||
response = HTTParty.get(url)
|
||||
return [] unless response.success?
|
||||
|
||||
next_url = next_url(response)
|
||||
|
||||
return response['data'] + fetch_whatsapp_templates(next_url) if next_url.present?
|
||||
|
||||
response['data']
|
||||
end
|
||||
|
||||
def next_url(response)
|
||||
response['paging'] ? response['paging']['next'] : ''
|
||||
end
|
||||
|
||||
def validate_provider_config?
|
||||
response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
|
||||
response.success?
|
||||
end
|
||||
|
||||
def api_headers
|
||||
{ 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", 'Content-Type' => 'application/json' }
|
||||
end
|
||||
|
||||
def create_csat_template(template_config)
|
||||
csat_template_service.create_template(template_config)
|
||||
end
|
||||
|
||||
def delete_csat_template(template_name = nil)
|
||||
template_name ||= CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id)
|
||||
csat_template_service.delete_template(template_name)
|
||||
end
|
||||
|
||||
def get_template_status(template_name)
|
||||
csat_template_service.get_template_status(template_name)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
def csat_template_service
|
||||
@csat_template_service ||= Whatsapp::CsatTemplateService.new(whatsapp_channel)
|
||||
end
|
||||
|
||||
def api_base_path
|
||||
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
|
||||
end
|
||||
|
||||
# TODO: See if we can unify the API versions and for both paths and make it consistent with out facebook app API versions
|
||||
def phone_id_path
|
||||
"#{api_base_path}/v13.0/#{whatsapp_channel.provider_config['phone_number_id']}"
|
||||
end
|
||||
|
||||
def business_account_path
|
||||
"#{api_base_path}/v14.0/#{whatsapp_channel.provider_config['business_account_id']}"
|
||||
end
|
||||
|
||||
def send_text_message(phone_number, message)
|
||||
response = HTTParty.post(
|
||||
"#{phone_id_path}/messages",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
messaging_product: 'whatsapp',
|
||||
context: whatsapp_reply_context(message),
|
||||
to: phone_number,
|
||||
text: { body: message.outgoing_content },
|
||||
type: 'text'
|
||||
}.to_json
|
||||
)
|
||||
|
||||
process_response(response, message)
|
||||
end
|
||||
|
||||
def send_attachment_message(phone_number, message)
|
||||
attachment = message.attachments.first
|
||||
type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document'
|
||||
type_content = {
|
||||
'link': attachment.download_url
|
||||
}
|
||||
type_content['caption'] = message.outgoing_content unless %w[audio sticker].include?(type)
|
||||
type_content['filename'] = attachment.file.filename if type == 'document'
|
||||
response = HTTParty.post(
|
||||
"#{phone_id_path}/messages",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
:messaging_product => 'whatsapp',
|
||||
:context => whatsapp_reply_context(message),
|
||||
'to' => phone_number,
|
||||
'type' => type,
|
||||
type.to_s => type_content
|
||||
}.to_json
|
||||
)
|
||||
|
||||
process_response(response, message)
|
||||
end
|
||||
|
||||
def error_message(response)
|
||||
# https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response
|
||||
response.parsed_response&.dig('error', 'message')
|
||||
end
|
||||
|
||||
def template_body_parameters(template_info)
|
||||
template_body = {
|
||||
name: template_info[:name],
|
||||
language: {
|
||||
policy: 'deterministic',
|
||||
code: template_info[:lang_code]
|
||||
}
|
||||
}
|
||||
|
||||
# Enhanced template parameters structure
|
||||
# Note: Legacy format support (simple parameter arrays) has been removed
|
||||
# in favor of the enhanced component-based structure that supports
|
||||
# headers, buttons, and authentication templates.
|
||||
#
|
||||
# Expected payload format from frontend:
|
||||
# {
|
||||
# processed_params: {
|
||||
# body: { '1': 'John', '2': '123 Main St' },
|
||||
# header: {
|
||||
# media_url: 'https://...',
|
||||
# media_type: 'image',
|
||||
# media_name: 'filename.pdf' # Optional, for document templates only
|
||||
# },
|
||||
# buttons: [{ type: 'url', parameter: 'otp123456' }]
|
||||
# }
|
||||
# }
|
||||
# This gets transformed into WhatsApp API component format:
|
||||
# [
|
||||
# { type: 'body', parameters: [...] },
|
||||
# { type: 'header', parameters: [...] },
|
||||
# { type: 'button', sub_type: 'url', parameters: [...] }
|
||||
# ]
|
||||
template_body[:components] = template_info[:parameters] || []
|
||||
|
||||
template_body
|
||||
end
|
||||
|
||||
def whatsapp_reply_context(message)
|
||||
reply_to = message.content_attributes[:in_reply_to_external_id]
|
||||
return nil if reply_to.blank?
|
||||
|
||||
{
|
||||
message_id: reply_to
|
||||
}
|
||||
end
|
||||
|
||||
def send_interactive_text_message(phone_number, message)
|
||||
payload = create_payload_based_on_items(message)
|
||||
|
||||
response = HTTParty.post(
|
||||
"#{phone_id_path}/messages",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
messaging_product: 'whatsapp',
|
||||
to: phone_number,
|
||||
interactive: payload,
|
||||
type: 'interactive'
|
||||
}.to_json
|
||||
)
|
||||
|
||||
process_response(response, message)
|
||||
end
|
||||
end
|
||||
42
app/services/whatsapp/reauthorization_service.rb
Normal file
42
app/services/whatsapp/reauthorization_service.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class Whatsapp::ReauthorizationService
|
||||
def initialize(account:, inbox_id:, phone_number_id:, business_id:)
|
||||
@account = account
|
||||
@inbox_id = inbox_id
|
||||
@phone_number_id = phone_number_id
|
||||
@business_id = business_id
|
||||
end
|
||||
|
||||
def perform(access_token, phone_info)
|
||||
inbox = @account.inboxes.find(@inbox_id)
|
||||
channel = inbox.channel
|
||||
|
||||
# Validate phone number matches for reauthorization
|
||||
if phone_info[:phone_number] != channel.phone_number
|
||||
raise StandardError, "Phone number mismatch. Expected #{channel.phone_number}, got #{phone_info[:phone_number]}"
|
||||
end
|
||||
|
||||
# Update channel configuration
|
||||
update_channel_config(channel, access_token, phone_info)
|
||||
# Mark as reauthorized
|
||||
channel.reauthorized! if channel.respond_to?(:reauthorized!)
|
||||
|
||||
channel
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_channel_config(channel, access_token, phone_info)
|
||||
current_config = channel.provider_config || {}
|
||||
channel.provider_config = current_config.merge(
|
||||
'api_key' => access_token,
|
||||
'phone_number_id' => @phone_number_id,
|
||||
'business_account_id' => @business_id,
|
||||
'source' => 'embedded_signup'
|
||||
)
|
||||
channel.save!
|
||||
|
||||
# Update inbox name if business name changed
|
||||
business_name = phone_info[:business_name] || phone_info[:verified_name]
|
||||
channel.inbox.update!(name: business_name) if business_name.present?
|
||||
end
|
||||
end
|
||||
48
app/services/whatsapp/send_on_whatsapp_service.rb
Normal file
48
app/services/whatsapp/send_on_whatsapp_service.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
|
||||
private
|
||||
|
||||
def channel_class
|
||||
Channel::Whatsapp
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
should_send_template_message = template_params.present? || !message.conversation.can_reply?
|
||||
if should_send_template_message
|
||||
send_template_message
|
||||
else
|
||||
send_session_message
|
||||
end
|
||||
end
|
||||
|
||||
def send_template_message
|
||||
processor = Whatsapp::TemplateProcessorService.new(
|
||||
channel: channel,
|
||||
template_params: template_params,
|
||||
message: message
|
||||
)
|
||||
|
||||
name, namespace, lang_code, processed_parameters = processor.call
|
||||
|
||||
if name.blank?
|
||||
message.update!(status: :failed, external_error: 'Template not found or invalid template name')
|
||||
return
|
||||
end
|
||||
|
||||
message_id = channel.send_template(message.conversation.contact_inbox.source_id, {
|
||||
name: name,
|
||||
namespace: namespace,
|
||||
lang_code: lang_code,
|
||||
parameters: processed_parameters
|
||||
}, message)
|
||||
message.update!(source_id: message_id) if message_id.present?
|
||||
end
|
||||
|
||||
def send_session_message
|
||||
message_id = channel.send_message(message.conversation.contact_inbox.source_id, message)
|
||||
message.update!(source_id: message_id) if message_id.present?
|
||||
end
|
||||
|
||||
def template_params
|
||||
message.additional_attributes && message.additional_attributes['template_params']
|
||||
end
|
||||
end
|
||||
120
app/services/whatsapp/template_parameter_converter_service.rb
Normal file
120
app/services/whatsapp/template_parameter_converter_service.rb
Normal file
@@ -0,0 +1,120 @@
|
||||
# Service to convert legacy WhatsApp template parameter formats to enhanced format
|
||||
#
|
||||
# Legacy formats (deprecated):
|
||||
# - Array: ["John", "Order123"] - positional parameters
|
||||
# - Flat Hash: {"1": "John", "2": "Order123"} - direct key-value mapping
|
||||
#
|
||||
# Enhanced format:
|
||||
# - Component-based: {"body": {"1": "John", "2": "Order123"}} - structured by template components
|
||||
# - Supports header, body, footer, and button parameters separately
|
||||
#
|
||||
class Whatsapp::TemplateParameterConverterService
|
||||
def initialize(template_params, template)
|
||||
@template_params = template_params
|
||||
@template = template
|
||||
end
|
||||
|
||||
def normalize_to_enhanced
|
||||
processed_params = @template_params['processed_params']
|
||||
|
||||
# Early return if already enhanced format
|
||||
return @template_params if enhanced_format?(processed_params)
|
||||
|
||||
# Mark as legacy format before conversion for tracking
|
||||
@template_params['format_version'] = 'legacy'
|
||||
|
||||
# Convert legacy formats to enhanced structure
|
||||
# TODO: Legacy format support will be deprecated and removed after 2-3 releases
|
||||
enhanced_params = convert_legacy_to_enhanced(processed_params, @template)
|
||||
|
||||
# Replace original params with enhanced structure
|
||||
@template_params['processed_params'] = enhanced_params
|
||||
|
||||
@template_params
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enhanced_format?(processed_params)
|
||||
return false unless processed_params.is_a?(Hash)
|
||||
|
||||
# Enhanced format has component-based structure
|
||||
component_keys = %w[body header footer buttons]
|
||||
has_component_structure = processed_params.keys.any? { |k| component_keys.include?(k) }
|
||||
|
||||
# Additional validation for enhanced format
|
||||
if has_component_structure
|
||||
validate_enhanced_structure(processed_params)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def validate_enhanced_structure(params)
|
||||
valid_body?(params['body']) &&
|
||||
valid_header?(params['header']) &&
|
||||
valid_buttons?(params['buttons'])
|
||||
end
|
||||
|
||||
def valid_body?(body)
|
||||
body.nil? || body.is_a?(Hash)
|
||||
end
|
||||
|
||||
def valid_header?(header)
|
||||
header.nil? || header.is_a?(Hash)
|
||||
end
|
||||
|
||||
def valid_buttons?(buttons)
|
||||
return true if buttons.nil?
|
||||
return false unless buttons.is_a?(Array)
|
||||
|
||||
buttons.all? { |b| b.is_a?(Hash) && b['type'] }
|
||||
end
|
||||
|
||||
def convert_legacy_to_enhanced(legacy_params, _template)
|
||||
# Legacy system only supported text-based templates with body parameters
|
||||
# We only convert the parameter format, not add new features
|
||||
|
||||
enhanced = {}
|
||||
|
||||
case legacy_params
|
||||
when Array
|
||||
# Array format: ["John", "Order123"] → {body: {"1": "John", "2": "Order123"}}
|
||||
body_params = convert_array_to_body_params(legacy_params)
|
||||
enhanced['body'] = body_params unless body_params.empty?
|
||||
when Hash
|
||||
# Hash format: {"1": "John", "name": "Jane"} → {body: {"1": "John", "name": "Jane"}}
|
||||
body_params = convert_hash_to_body_params(legacy_params)
|
||||
enhanced['body'] = body_params unless body_params.empty?
|
||||
when NilClass
|
||||
# Templates without parameters (nil processed_params)
|
||||
# Return empty enhanced structure
|
||||
else
|
||||
raise ArgumentError, "Unknown legacy format: #{legacy_params.class}"
|
||||
end
|
||||
|
||||
enhanced
|
||||
end
|
||||
|
||||
def convert_array_to_body_params(params_array)
|
||||
return {} if params_array.empty?
|
||||
|
||||
body_params = {}
|
||||
params_array.each_with_index do |value, index|
|
||||
body_params[(index + 1).to_s] = value.to_s
|
||||
end
|
||||
|
||||
body_params
|
||||
end
|
||||
|
||||
def convert_hash_to_body_params(params_hash)
|
||||
return {} if params_hash.empty?
|
||||
|
||||
body_params = {}
|
||||
params_hash.each do |key, value|
|
||||
body_params[key.to_s] = value.to_s
|
||||
end
|
||||
|
||||
body_params
|
||||
end
|
||||
end
|
||||
130
app/services/whatsapp/template_processor_service.rb
Normal file
130
app/services/whatsapp/template_processor_service.rb
Normal file
@@ -0,0 +1,130 @@
|
||||
class Whatsapp::TemplateProcessorService
|
||||
pattr_initialize [:channel!, :template_params, :message]
|
||||
|
||||
def call
|
||||
return [nil, nil, nil, nil] if template_params.blank?
|
||||
|
||||
process_template_with_params
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_template_with_params
|
||||
[
|
||||
template_params['name'],
|
||||
template_params['namespace'],
|
||||
template_params['language'],
|
||||
processed_templates_params
|
||||
]
|
||||
end
|
||||
|
||||
def find_template
|
||||
channel.message_templates.find do |t|
|
||||
t['name'] == template_params['name'] &&
|
||||
t['language']&.downcase == template_params['language']&.downcase &&
|
||||
t['status']&.downcase == 'approved'
|
||||
end
|
||||
end
|
||||
|
||||
def processed_templates_params
|
||||
template = find_template
|
||||
return if template.blank?
|
||||
|
||||
# Convert legacy format to enhanced format before processing
|
||||
converter = Whatsapp::TemplateParameterConverterService.new(template_params, template)
|
||||
normalized_params = converter.normalize_to_enhanced
|
||||
|
||||
process_enhanced_template_params(template, normalized_params['processed_params'])
|
||||
end
|
||||
|
||||
def process_enhanced_template_params(template, processed_params = nil)
|
||||
processed_params ||= template_params['processed_params']
|
||||
components = []
|
||||
|
||||
components.concat(process_header_components(processed_params))
|
||||
components.concat(process_body_components(processed_params, template))
|
||||
components.concat(process_footer_components(processed_params))
|
||||
components.concat(process_button_components(processed_params))
|
||||
|
||||
@template_params = components
|
||||
end
|
||||
|
||||
def process_header_components(processed_params)
|
||||
return [] if processed_params['header'].blank?
|
||||
|
||||
header_params = build_header_params(processed_params['header'])
|
||||
header_params.present? ? [{ type: 'header', parameters: header_params }] : []
|
||||
end
|
||||
|
||||
def build_header_params(header_data)
|
||||
header_params = []
|
||||
header_data.each do |key, value|
|
||||
next if value.blank?
|
||||
|
||||
if media_url_with_type?(key, header_data)
|
||||
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' && key != 'media_name'
|
||||
header_params << parameter_builder.build_parameter(value)
|
||||
end
|
||||
end
|
||||
header_params
|
||||
end
|
||||
|
||||
def media_url_with_type?(key, header_data)
|
||||
key == 'media_url' && header_data['media_type'].present?
|
||||
end
|
||||
|
||||
def process_body_components(processed_params, template)
|
||||
return [] if processed_params['body'].blank?
|
||||
|
||||
body_params = processed_params['body'].filter_map do |key, value|
|
||||
next if value.blank?
|
||||
|
||||
parameter_format = template['parameter_format']
|
||||
if parameter_format == 'NAMED'
|
||||
parameter_builder.build_named_parameter(key, value)
|
||||
else
|
||||
parameter_builder.build_parameter(value)
|
||||
end
|
||||
end
|
||||
|
||||
body_params.present? ? [{ type: 'body', parameters: body_params }] : []
|
||||
end
|
||||
|
||||
def process_footer_components(processed_params)
|
||||
return [] if processed_params['footer'].blank?
|
||||
|
||||
footer_params = processed_params['footer'].filter_map do |_, value|
|
||||
next if value.blank?
|
||||
|
||||
parameter_builder.build_parameter(value)
|
||||
end
|
||||
|
||||
footer_params.present? ? [{ type: 'footer', parameters: footer_params }] : []
|
||||
end
|
||||
|
||||
def process_button_components(processed_params)
|
||||
return [] if processed_params['buttons'].blank?
|
||||
|
||||
button_params = processed_params['buttons'].filter_map.with_index do |button, index|
|
||||
next if button.blank?
|
||||
|
||||
if button['type'] == 'url' || button['parameter'].present?
|
||||
{
|
||||
type: 'button',
|
||||
sub_type: button['type'] || 'url',
|
||||
index: index,
|
||||
parameters: [parameter_builder.build_button_parameter(button)]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
button_params.compact
|
||||
end
|
||||
|
||||
def parameter_builder
|
||||
@parameter_builder ||= Whatsapp::PopulateTemplateParametersService.new
|
||||
end
|
||||
end
|
||||
26
app/services/whatsapp/token_exchange_service.rb
Normal file
26
app/services/whatsapp/token_exchange_service.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
class Whatsapp::TokenExchangeService
|
||||
def initialize(code)
|
||||
@code = code
|
||||
@api_client = Whatsapp::FacebookApiClient.new
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_code!
|
||||
exchange_token
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_code!
|
||||
raise ArgumentError, 'Authorization code is required' if @code.blank?
|
||||
end
|
||||
|
||||
def exchange_token
|
||||
response = @api_client.exchange_code_for_token(@code)
|
||||
access_token = response['access_token']
|
||||
|
||||
raise "No access token in response: #{response}" if access_token.blank?
|
||||
|
||||
access_token
|
||||
end
|
||||
end
|
||||
42
app/services/whatsapp/token_validation_service.rb
Normal file
42
app/services/whatsapp/token_validation_service.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class Whatsapp::TokenValidationService
|
||||
def initialize(access_token, waba_id)
|
||||
@access_token = access_token
|
||||
@waba_id = waba_id
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
validate_token_waba_access
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
end
|
||||
|
||||
def validate_token_waba_access
|
||||
token_debug_data = @api_client.debug_token(@access_token)
|
||||
waba_scope = extract_waba_scope(token_debug_data)
|
||||
verify_waba_authorization(waba_scope)
|
||||
end
|
||||
|
||||
def extract_waba_scope(token_data)
|
||||
granular_scopes = token_data.dig('data', 'granular_scopes')
|
||||
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
|
||||
|
||||
raise 'No WABA scope found in token' unless waba_scope
|
||||
|
||||
waba_scope
|
||||
end
|
||||
|
||||
def verify_waba_authorization(waba_scope)
|
||||
authorized_waba_ids = waba_scope['target_ids'] || []
|
||||
|
||||
return if authorized_waba_ids.include?(@waba_id)
|
||||
|
||||
raise "Token does not have access to WABA #{@waba_id}. Authorized WABAs: #{authorized_waba_ids}"
|
||||
end
|
||||
end
|
||||
116
app/services/whatsapp/webhook_setup_service.rb
Normal file
116
app/services/whatsapp/webhook_setup_service.rb
Normal file
@@ -0,0 +1,116 @@
|
||||
class Whatsapp::WebhookSetupService
|
||||
def initialize(channel, waba_id, access_token)
|
||||
@channel = channel
|
||||
@waba_id = waba_id
|
||||
@access_token = access_token
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
|
||||
# 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
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Channel is required' if @channel.blank?
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def register_phone_number
|
||||
phone_number_id = @channel.provider_config['phone_number_id']
|
||||
pin = fetch_or_create_pin
|
||||
|
||||
@api_client.register_phone_number(phone_number_id, pin)
|
||||
store_pin(pin)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[WHATSAPP] Phone registration failed but continuing: #{e.message}")
|
||||
# Continue with webhook setup even if registration fails
|
||||
# This is just a warning, not a blocking error
|
||||
end
|
||||
|
||||
def fetch_or_create_pin
|
||||
# Check if we have a stored PIN for this phone number
|
||||
existing_pin = @channel.provider_config['verification_pin']
|
||||
return existing_pin.to_i if existing_pin.present?
|
||||
|
||||
# Generate a new 6-digit PIN if none exists
|
||||
SecureRandom.random_number(900_000) + 100_000
|
||||
end
|
||||
|
||||
def store_pin(pin)
|
||||
# Store the PIN in provider_config for future use
|
||||
@channel.provider_config['verification_pin'] = pin
|
||||
@channel.save!
|
||||
end
|
||||
|
||||
def setup_webhook
|
||||
callback_url = build_callback_url
|
||||
verify_token = @channel.provider_config['webhook_verify_token']
|
||||
|
||||
@api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token)
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
|
||||
raise "Webhook setup failed: #{e.message}"
|
||||
end
|
||||
|
||||
def build_callback_url
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
phone_number = @channel.phone_number
|
||||
|
||||
"#{frontend_url}/webhooks/whatsapp/#{phone_number}"
|
||||
end
|
||||
|
||||
def phone_number_verified?
|
||||
phone_number_id = @channel.provider_config['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
|
||||
# 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
|
||||
47
app/services/whatsapp/webhook_teardown_service.rb
Normal file
47
app/services/whatsapp/webhook_teardown_service.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
class Whatsapp::WebhookTeardownService
|
||||
def initialize(channel)
|
||||
@channel = channel
|
||||
end
|
||||
|
||||
def perform
|
||||
return unless should_teardown_webhook?
|
||||
|
||||
teardown_webhook
|
||||
rescue StandardError => e
|
||||
handle_webhook_teardown_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_teardown_webhook?
|
||||
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present?
|
||||
end
|
||||
|
||||
def whatsapp_cloud_provider?
|
||||
@channel.provider == 'whatsapp_cloud'
|
||||
end
|
||||
|
||||
def embedded_signup_source?
|
||||
@channel.provider_config['source'] == 'embedded_signup'
|
||||
end
|
||||
|
||||
def webhook_config_present?
|
||||
@channel.provider_config['business_account_id'].present? &&
|
||||
@channel.provider_config['api_key'].present?
|
||||
end
|
||||
|
||||
def teardown_webhook
|
||||
waba_id = @channel.provider_config['business_account_id']
|
||||
access_token = @channel.provider_config['api_key']
|
||||
api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
|
||||
api_client.unsubscribe_waba_webhook(waba_id)
|
||||
Rails.logger.info "[WHATSAPP] Webhook unsubscribed successfully for channel #{@channel.id}"
|
||||
end
|
||||
|
||||
def handle_webhook_teardown_error(error)
|
||||
Rails.logger.error "[WHATSAPP] Webhook teardown failed: #{error.message}"
|
||||
# Don't raise the error to prevent channel deletion from failing
|
||||
# Failed webhook teardown shouldn't block deletion
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user