Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
class Webhooks::FacebookDeliveryJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(message)
|
||||
response = ::Integrations::Facebook::MessageParser.new(message)
|
||||
Integrations::Facebook::DeliveryStatus.new(params: response).perform
|
||||
end
|
||||
end
|
||||
17
research/chatwoot/app/jobs/webhooks/facebook_events_job.rb
Normal file
17
research/chatwoot/app/jobs/webhooks/facebook_events_job.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
class Webhooks::FacebookEventsJob < MutexApplicationJob
|
||||
queue_as :default
|
||||
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
|
||||
|
||||
def perform(message)
|
||||
response = ::Integrations::Facebook::MessageParser.new(message)
|
||||
|
||||
key = format(::Redis::Alfred::FACEBOOK_MESSAGE_MUTEX, sender_id: response.sender_id, recipient_id: response.recipient_id)
|
||||
with_lock(key) do
|
||||
process_message(response)
|
||||
end
|
||||
end
|
||||
|
||||
def process_message(response)
|
||||
::Integrations::Facebook::MessageCreator.new(response).perform
|
||||
end
|
||||
end
|
||||
213
research/chatwoot/app/jobs/webhooks/instagram_events_job.rb
Normal file
213
research/chatwoot/app/jobs/webhooks/instagram_events_job.rb
Normal file
@@ -0,0 +1,213 @@
|
||||
class Webhooks::InstagramEventsJob < MutexApplicationJob
|
||||
queue_as :default
|
||||
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
|
||||
|
||||
# @return [Array] We will support further events like reaction or seen in future
|
||||
SUPPORTED_EVENTS = [:message, :read].freeze
|
||||
|
||||
def perform(entries)
|
||||
@entries = entries
|
||||
|
||||
key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: contact_instagram_id, ig_account_id: ig_account_id)
|
||||
with_lock(key) do
|
||||
process_entries(entries)
|
||||
end
|
||||
end
|
||||
|
||||
# https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook
|
||||
def process_entries(entries)
|
||||
entries.each do |entry|
|
||||
process_single_entry(entry.with_indifferent_access)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_single_entry(entry)
|
||||
if test_event?(entry)
|
||||
process_test_event(entry)
|
||||
return
|
||||
end
|
||||
|
||||
process_messages(entry)
|
||||
end
|
||||
|
||||
def process_messages(entry)
|
||||
messages(entry).each do |messaging|
|
||||
Rails.logger.info("Instagram Events Job Messaging: #{messaging}")
|
||||
|
||||
instagram_id = instagram_id(messaging)
|
||||
channel = find_channel(instagram_id)
|
||||
|
||||
next if channel.blank?
|
||||
|
||||
if (event_name = event_name(messaging))
|
||||
send(event_name, messaging, channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def agent_message_via_echo?(messaging)
|
||||
messaging[:message].present? && messaging[:message][:is_echo].present?
|
||||
end
|
||||
|
||||
def test_event?(entry)
|
||||
entry[:changes].present?
|
||||
end
|
||||
|
||||
def process_test_event(entry)
|
||||
messaging = extract_messaging_from_test_event(entry)
|
||||
|
||||
Instagram::TestEventService.new(messaging).perform if messaging.present?
|
||||
end
|
||||
|
||||
def extract_messaging_from_test_event(entry)
|
||||
entry[:changes].first&.dig(:value) if entry[:changes].present?
|
||||
end
|
||||
|
||||
def instagram_id(messaging)
|
||||
if agent_message_via_echo?(messaging)
|
||||
messaging[:sender][:id]
|
||||
else
|
||||
messaging[:recipient][:id]
|
||||
end
|
||||
end
|
||||
|
||||
def ig_account_id
|
||||
@entries&.first&.dig(:id)
|
||||
end
|
||||
|
||||
def contact_instagram_id
|
||||
entry = @entries&.first
|
||||
return nil unless entry
|
||||
|
||||
# Handle both messaging and standby arrays
|
||||
messaging = (entry[:messaging].presence || entry[:standby] || []).first
|
||||
return nil unless messaging
|
||||
|
||||
# For echo messages (outgoing from our account), use recipient's ID (the contact)
|
||||
# For incoming messages (from contact), use sender's ID (the contact)
|
||||
if messaging.dig(:message, :is_echo)
|
||||
messaging.dig(:recipient, :id)
|
||||
else
|
||||
messaging.dig(:sender, :id)
|
||||
end
|
||||
end
|
||||
|
||||
def sender_id
|
||||
@entries&.dig(0, :messaging, 0, :sender, :id)
|
||||
end
|
||||
|
||||
def find_channel(instagram_id)
|
||||
# There will be chances for the instagram account to be connected to a facebook page,
|
||||
# so we need to check for both instagram and facebook page channels
|
||||
# priority is for instagram channel which created via instagram login
|
||||
channel = Channel::Instagram.find_by(instagram_id: instagram_id)
|
||||
# If not found, fallback to the facebook page channel
|
||||
channel ||= Channel::FacebookPage.find_by(instagram_id: instagram_id)
|
||||
|
||||
channel
|
||||
end
|
||||
|
||||
def event_name(messaging)
|
||||
@event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
|
||||
end
|
||||
|
||||
def message(messaging, channel)
|
||||
if channel.is_a?(Channel::Instagram)
|
||||
::Instagram::MessageText.new(messaging, channel).perform
|
||||
else
|
||||
::Instagram::Messenger::MessageText.new(messaging, channel).perform
|
||||
end
|
||||
end
|
||||
|
||||
def read(messaging, channel)
|
||||
# Use a single service to handle read status for both channel types since the params are same
|
||||
::Instagram::ReadStatusService.new(params: messaging, channel: channel).perform
|
||||
end
|
||||
|
||||
def messages(entry)
|
||||
(entry[:messaging].presence || entry[:standby] || [])
|
||||
end
|
||||
end
|
||||
|
||||
# Actual response from Instagram webhook (both via Facebook page and Instagram direct)
|
||||
# [
|
||||
# {
|
||||
# "time": <timestamp>,
|
||||
# "id": <INSTAGRAM_USER_ID>,
|
||||
# "messaging": [
|
||||
# {
|
||||
# "sender": {
|
||||
# "id": <INSTAGRAM_USER_ID>
|
||||
# },
|
||||
# "recipient": {
|
||||
# "id": <INSTAGRAM_USER_ID>
|
||||
# },
|
||||
# "timestamp": <timestamp>,
|
||||
# "message": {
|
||||
# "mid": <MESSAGE_ID>,
|
||||
# "text": <MESSAGE_TEXT>
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# ]
|
||||
|
||||
# Instagram's webhook via Instagram direct testing quirk: Test payloads vs Actual payloads
|
||||
# When testing in Facebook's developer dashboard, you'll get a Page-style
|
||||
# payload with a "changes" object. But don't be fooled! Real Instagram DMs
|
||||
# arrive in the familiar Messenger format with a "messaging" array.
|
||||
# This apparent inconsistency is actually by design - Instagram's webhooks
|
||||
# use different formats for testing vs production to maintain compatibility
|
||||
# with both Instagram Direct and Facebook Page integrations.
|
||||
# See: https://developers.facebook.com/docs/instagram-platform/webhooks#event-notifications
|
||||
|
||||
# Test response from via Instagram direct
|
||||
# [
|
||||
# {
|
||||
# "id": "0",
|
||||
# "time": <timestamp>,
|
||||
# "changes": [
|
||||
# {
|
||||
# "field": "messages",
|
||||
# "value": {
|
||||
# "sender": {
|
||||
# "id": "12334"
|
||||
# },
|
||||
# "recipient": {
|
||||
# "id": "23245"
|
||||
# },
|
||||
# "timestamp": "1527459824",
|
||||
# "message": {
|
||||
# "mid": "random_mid",
|
||||
# "text": "random_text"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# ]
|
||||
|
||||
# Test response via Facebook page
|
||||
# [
|
||||
# {
|
||||
# "time": <timestamp>,,
|
||||
# "id": "0",
|
||||
# "messaging": [
|
||||
# {
|
||||
# "sender": {
|
||||
# "id": "12334"
|
||||
# },
|
||||
# "recipient": {
|
||||
# "id": "23245"
|
||||
# },
|
||||
# "timestamp": <timestamp>,
|
||||
# "message": {
|
||||
# "mid": "random_mid",
|
||||
# "text": "random_text"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# ]
|
||||
24
research/chatwoot/app/jobs/webhooks/line_events_job.rb
Normal file
24
research/chatwoot/app/jobs/webhooks/line_events_job.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
class Webhooks::LineEventsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(params: {}, signature: '', post_body: '')
|
||||
@params = params
|
||||
return unless valid_event_payload?
|
||||
return unless valid_post_body?(post_body, signature)
|
||||
|
||||
Line::IncomingMessageService.new(inbox: @channel.inbox, params: @params['line'].with_indifferent_access).perform
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_event_payload?
|
||||
@channel = Channel::Line.find_by(line_channel_id: @params[:line_channel_id]) if @params[:line_channel_id]
|
||||
end
|
||||
|
||||
# https://developers.line.biz/en/reference/messaging-api/#signature-validation
|
||||
# validate the line payload
|
||||
def valid_post_body?(post_body, signature)
|
||||
hash = OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA256'), @channel.line_channel_secret, post_body)
|
||||
Base64.strict_encode64(hash) == signature
|
||||
end
|
||||
end
|
||||
28
research/chatwoot/app/jobs/webhooks/sms_events_job.rb
Normal file
28
research/chatwoot/app/jobs/webhooks/sms_events_job.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
class Webhooks::SmsEventsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
SUPPORTED_EVENTS = %w[message-received message-delivered message-failed].freeze
|
||||
|
||||
def perform(params = {})
|
||||
return unless SUPPORTED_EVENTS.include?(params[:type])
|
||||
|
||||
channel = Channel::Sms.find_by(phone_number: params[:to])
|
||||
return unless channel
|
||||
|
||||
process_event_params(channel, params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_event_params(channel, params)
|
||||
if delivery_event?(params)
|
||||
Sms::DeliveryStatusService.new(channel: channel, params: params[:message].with_indifferent_access).perform
|
||||
else
|
||||
Sms::IncomingMessageService.new(inbox: channel.inbox, params: params[:message].with_indifferent_access).perform
|
||||
end
|
||||
end
|
||||
|
||||
def delivery_event?(params)
|
||||
params[:type] == 'message-delivered' || params[:type] == 'message-failed'
|
||||
end
|
||||
end
|
||||
44
research/chatwoot/app/jobs/webhooks/telegram_events_job.rb
Normal file
44
research/chatwoot/app/jobs/webhooks/telegram_events_job.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
class Webhooks::TelegramEventsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(params = {})
|
||||
return unless params[:bot_token]
|
||||
|
||||
channel = Channel::Telegram.find_by(bot_token: params[:bot_token])
|
||||
|
||||
if channel_is_inactive?(channel)
|
||||
log_inactive_channel(channel, params)
|
||||
return
|
||||
end
|
||||
|
||||
process_event_params(channel, params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def channel_is_inactive?(channel)
|
||||
return true if channel.blank?
|
||||
return true unless channel.account.active?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def log_inactive_channel(channel, params)
|
||||
message = if channel&.id
|
||||
"Account #{channel.account.id} is not active for channel #{channel.id}"
|
||||
else
|
||||
"Channel not found for bot_token: #{params[:bot_token]}"
|
||||
end
|
||||
Rails.logger.warn("Telegram event discarded: #{message}")
|
||||
end
|
||||
|
||||
def process_event_params(channel, params)
|
||||
return unless params[:telegram]
|
||||
|
||||
if params.dig(:telegram, :edited_message).present? || params.dig(:telegram, :edited_business_message).present?
|
||||
Telegram::UpdateMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform
|
||||
else
|
||||
Telegram::IncomingMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
69
research/chatwoot/app/jobs/webhooks/tiktok_events_job.rb
Normal file
69
research/chatwoot/app/jobs/webhooks/tiktok_events_job.rb
Normal file
@@ -0,0 +1,69 @@
|
||||
# https://business-api.tiktok.com/portal/docs?id=1832190670631937
|
||||
class Webhooks::TiktokEventsJob < MutexApplicationJob
|
||||
queue_as :default
|
||||
retry_on LockAcquisitionError, wait: 2.seconds, attempts: 8
|
||||
|
||||
SUPPORTED_EVENTS = [:im_send_msg, :im_receive_msg, :im_mark_read_msg].freeze
|
||||
|
||||
def perform(event)
|
||||
@event = event.with_indifferent_access
|
||||
|
||||
return if channel_is_inactive?
|
||||
|
||||
key = format(::Redis::Alfred::TIKTOK_MESSAGE_MUTEX, business_id: business_id, conversation_id: conversation_id)
|
||||
with_lock(key, 10.seconds) do
|
||||
process_event
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def channel_is_inactive?
|
||||
return true if channel.blank?
|
||||
return true unless channel.account.active?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def process_event
|
||||
return if event_name.blank? || channel.blank?
|
||||
|
||||
send(event_name)
|
||||
end
|
||||
|
||||
def event_name
|
||||
@event_name ||= SUPPORTED_EVENTS.include?(@event[:event].to_sym) ? @event[:event] : nil
|
||||
end
|
||||
|
||||
def business_id
|
||||
@business_id ||= @event[:user_openid]
|
||||
end
|
||||
|
||||
def content
|
||||
@content ||= JSON.parse(@event[:content]).deep_symbolize_keys
|
||||
end
|
||||
|
||||
def conversation_id
|
||||
@conversation_id ||= content[:conversation_id]
|
||||
end
|
||||
|
||||
def channel
|
||||
@channel ||= Channel::Tiktok.find_by(business_id: business_id)
|
||||
end
|
||||
|
||||
# Receive real-time notifications if you send a message to a user.
|
||||
def im_send_msg
|
||||
# This can be either an echo message or a message sent directly via tiktok application
|
||||
::Tiktok::MessageService.new(channel: channel, content: content, outgoing_echo: true).perform
|
||||
end
|
||||
|
||||
# Receive real-time notifications if a user outside the European Economic Area (EEA), Switzerland, or the UK sends a message to you.
|
||||
def im_receive_msg
|
||||
::Tiktok::MessageService.new(channel: channel, content: content).perform
|
||||
end
|
||||
|
||||
# Receive real-time notifications when a Personal Account user marks all messages in a session as read.
|
||||
def im_mark_read_msg
|
||||
::Tiktok::ReadStatusService.new(channel: channel, content: content).perform
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class Webhooks::TwilioDeliveryStatusJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(params = {})
|
||||
::Twilio::DeliveryStatusService.new(params: params).perform
|
||||
end
|
||||
end
|
||||
17
research/chatwoot/app/jobs/webhooks/twilio_events_job.rb
Normal file
17
research/chatwoot/app/jobs/webhooks/twilio_events_job.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
class Webhooks::TwilioEventsJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(params = {})
|
||||
# Skip processing if Body parameter, MediaUrl0, or location data is not present
|
||||
# This is to skip processing delivery events being delivered to this endpoint
|
||||
return if params[:Body].blank? && params[:MediaUrl0].blank? && !valid_location_message?(params)
|
||||
|
||||
::Twilio::IncomingMessageService.new(params: params).perform
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_location_message?(params)
|
||||
params[:MessageType] == 'location' && params[:Latitude].present? && params[:Longitude].present?
|
||||
end
|
||||
end
|
||||
102
research/chatwoot/app/jobs/webhooks/whatsapp_events_job.rb
Normal file
102
research/chatwoot/app/jobs/webhooks/whatsapp_events_job.rb
Normal file
@@ -0,0 +1,102 @@
|
||||
class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(params = {})
|
||||
channel = find_channel_from_whatsapp_business_payload(params)
|
||||
|
||||
if channel_is_inactive?(channel)
|
||||
Rails.logger.warn("Inactive WhatsApp channel: #{channel&.phone_number || "unknown - #{params[:phone_number]}"}")
|
||||
return
|
||||
end
|
||||
|
||||
if message_echo_event?(params)
|
||||
handle_message_echo(channel, params)
|
||||
else
|
||||
handle_message_events(channel, params)
|
||||
end
|
||||
end
|
||||
|
||||
# Detects if the webhook is an SMB message echo event (message sent from WhatsApp Business app)
|
||||
# This is part of WhatsApp coexistence feature where businesses can respond from both
|
||||
# Chatwoot and the WhatsApp Business app, with messages synced to Chatwoot.
|
||||
#
|
||||
# Regular message payload (field: "messages"):
|
||||
# {
|
||||
# "entry": [{
|
||||
# "changes": [{
|
||||
# "field": "messages",
|
||||
# "value": {
|
||||
# "contacts": [{ "wa_id": "919745786257", "profile": { "name": "Customer" } }],
|
||||
# "messages": [{ "from": "919745786257", "id": "wamid...", "text": { "body": "Hello" } }]
|
||||
# }
|
||||
# }]
|
||||
# }]
|
||||
# }
|
||||
#
|
||||
# Echo message payload (field: "smb_message_echoes"):
|
||||
# {
|
||||
# "entry": [{
|
||||
# "changes": [{
|
||||
# "field": "smb_message_echoes",
|
||||
# "value": {
|
||||
# "message_echoes": [{ "from": "971545296927", "to": "919745786257", "id": "wamid...", "text": { "body": "Hi" } }]
|
||||
# }
|
||||
# }]
|
||||
# }]
|
||||
# }
|
||||
#
|
||||
# Key differences:
|
||||
# - field: "smb_message_echoes" instead of "messages"
|
||||
# - message_echoes[] instead of messages[]
|
||||
# - "from" is the business number, "to" is the contact (reversed from regular messages)
|
||||
# - No "contacts" array in echo payload
|
||||
def message_echo_event?(params)
|
||||
params.dig(:entry, 0, :changes, 0, :field) == 'smb_message_echoes'
|
||||
end
|
||||
|
||||
def handle_message_echo(channel, params)
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params, outgoing_echo: true).perform
|
||||
end
|
||||
|
||||
def handle_message_events(channel, params)
|
||||
case channel.provider
|
||||
when 'whatsapp_cloud'
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform
|
||||
else
|
||||
Whatsapp::IncomingMessageService.new(inbox: channel.inbox, params: params).perform
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def channel_is_inactive?(channel)
|
||||
return true if channel.blank?
|
||||
return true if channel.reauthorization_required?
|
||||
return true unless channel.account.active?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def find_channel_by_url_param(params)
|
||||
return unless params[:phone_number]
|
||||
|
||||
Channel::Whatsapp.find_by(phone_number: params[:phone_number])
|
||||
end
|
||||
|
||||
def find_channel_from_whatsapp_business_payload(params)
|
||||
# for the case where facebook cloud api support multiple numbers for a single app
|
||||
# https://github.com/chatwoot/chatwoot/issues/4712#issuecomment-1173838350
|
||||
# we will give priority to the phone_number in the payload
|
||||
return get_channel_from_wb_payload(params) if params[:object] == 'whatsapp_business_account'
|
||||
|
||||
find_channel_by_url_param(params)
|
||||
end
|
||||
|
||||
def get_channel_from_wb_payload(wb_params)
|
||||
phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
|
||||
phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
|
||||
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
|
||||
# validate to ensure the phone number id matches the whatsapp channel
|
||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user