feat: Add Instagram Channel (#2955)

This commit is contained in:
Tejaswini Chile
2021-10-05 14:35:32 +05:30
committed by GitHub
parent 30244f79a6
commit 40d0b2faf3
30 changed files with 825 additions and 50 deletions

View File

@@ -4,10 +4,11 @@
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Facebook::MessageBuilder
class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :response
def initialize(response, inbox, outgoing_echo: false)
super()
@response = response
@inbox = inbox
@outgoing_echo = outgoing_echo
@@ -47,30 +48,12 @@ class Messages::Facebook::MessageBuilder
def build_message
@message = conversation.messages.create!(message_params)
@attachments.each do |attachment|
process_attachment(attachment)
end
end
def process_attachment(attachment)
return if attachment['type'].to_sym == :template
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
end
def attach_file(attachment, file_url)
attachment_file = Down.download(
file_url
)
attachment.file.attach(
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
end
def ensure_contact_avatar
return if contact_params[:remote_avatar_url].blank?
return if @contact.avatar.attached?
@@ -89,28 +72,6 @@ class Messages::Facebook::MessageBuilder
))
end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
elsif file_type == :fallback
params.merge!(fallback_params(attachment))
end
params
end
def file_type_params(attachment)
{
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
}
end
def location_params(attachment)
lat = attachment['payload']['coordinates']['lat']
long = attachment['payload']['coordinates']['long']

View File

@@ -0,0 +1,150 @@
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue StandardError => e
Sentry.capture_exception(e)
true
end
private
def attachments
@messaging[:message][:attachments] || {}
end
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def message_source_id
@outgoing_echo ? recipient_id : sender_id
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def message_content
@messaging[:message][:text]
end
def content_attributes
{ message_id: @messaging[:message][:mid] }
end
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
@message = conversation.messages.create!(message_params)
attachments.each do |attachment|
process_attachment(attachment)
end
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id
))
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id,
additional_attributes: {
type: 'instagram_direct_message'
}
}
end
def message_params
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_source_id,
content: message_content,
content_attributes: content_attributes,
sender: @outgoing_echo ? nil : contact
}
end
def already_sent_from_chatwoot?
cw_message = conversation.messages.where(
source_id: nil,
message_type: 'outgoing',
content: message_content,
private: false,
status: :sent
).first
cw_message.update(content_attributes: content_attributes) if cw_message.present?
cw_message.present?
end
### Sample response
# {
# "object": "instagram",
# "entry": [
# {
# "id": "<IGID>",// ig id of the business
# "time": 1569262486134,
# "messaging": [
# {
# "sender": {
# "id": "<IGSID>"
# },
# "recipient": {
# "id": "<IGID>"
# },
# "timestamp": 1569262485349,
# "message": {
# "mid": "<MESSAGE_ID>",
# "text": "<MESSAGE_CONTENT>"
# }
# }
# ]
# }
# ],
# }
end

View File

@@ -0,0 +1,42 @@
class Messages::Messenger::MessageBuilder
def process_attachment(attachment)
return if attachment['type'].to_sym == :template
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
end
def attach_file(attachment, file_url)
attachment_file = Down.download(
file_url
)
attachment.file.attach(
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
elsif file_type == :fallback
params.merge!(fallback_params(attachment))
end
params
end
def file_type_params(attachment)
{
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
}
end
end

View File

@@ -12,6 +12,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token: page_access_token
)
@facebook_inbox = Current.account.inboxes.create!(name: inbox_name, channel: facebook_channel)
set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id)
rescue StandardError => e
Rails.logger.info e
@@ -22,6 +23,15 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
@page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts'))
end
def set_instagram_id(page_access_token, facebook_channel)
fb_object = Koala::Facebook::API.new(page_access_token)
response = fb_object.get_connections('me', '', { fields: 'instagram_business_account' })
return if response['instagram_business_account'].blank?
instagram_id = response['instagram_business_account']['id']
facebook_channel.update(instagram_id: instagram_id)
end
# get params[:inbox_id], current_account. params[:omniauth_token]
def reauthorize_page
if @inbox&.facebook?

View File

@@ -0,0 +1,30 @@
class Api::V1::InstagramCallbacksController < ApplicationController
skip_before_action :authenticate_user!, raise: false
skip_before_action :set_current_user
def verify
if valid_instagram_token?(params['hub.verify_token'])
Rails.logger.info('Instagram webhook verified')
render json: params['hub.challenge']
else
render json: { error: 'Error; wrong verify token', status: 403 }
end
end
def events
Rails.logger.info('Instagram webhook received events')
if params['object'].casecmp('instagram').zero?
::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry])
render json: :ok
else
Rails.logger.info("Message is not received from the instagram webhook event: #{params['object']}")
head :unprocessable_entity
end
end
private
def valid_instagram_token?(token)
token == ENV['IG_VERIFY_TOKEN']
end
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -6,7 +6,7 @@
>
<img
v-if="channel.key === 'facebook'"
src="~dashboard/assets/images/channels/facebook.png"
src="~dashboard/assets/images/channels/messenger.png"
/>
<img
v-if="channel.key === 'twitter'"

View File

@@ -14,12 +14,19 @@
color="white"
:size="avatarSize"
/>
<img
v-if="badge === 'instagram_direct_message'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="~dashboard/assets/images/instagram_direct.png"
/>
<img
v-if="badge === 'Channel::FacebookPage'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="~dashboard/assets/images/fb-badge.png"
src="~dashboard/assets/images/messenger_direct.png"
/>
<img
v-if="badge === 'twitter-tweet'"

View File

@@ -11,7 +11,7 @@
<thumbnail
v-if="!hideThumbnail"
:src="currentContact.thumbnail"
:badge="inboxBadge"
:badge="chatBadge"
class="columns"
:username="currentContact.name"
:status="currentContact.availability_status"
@@ -119,6 +119,10 @@ export default {
accountId: 'getCurrentAccountId',
}),
chatExtraAttributes() {
return this.chat.additional_attributes;
},
chatMetadata() {
return this.chat.meta || {};
},
@@ -127,6 +131,14 @@ export default {
return this.chatMetadata.assignee || {};
},
chatBadge() {
if(this.chatExtraAttributes['type']){
return this.chatExtraAttributes['type']
} else {
return this.chatMetadata.channel
}
},
currentContact() {
return this.$store.getters['contacts/getContact'](
this.chatMetadata.sender.id

View File

@@ -4,7 +4,7 @@
<Thumbnail
:src="currentContact.thumbnail"
size="40px"
:badge="inboxBadge"
:badge="chatBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
/>
@@ -73,9 +73,23 @@ export default {
uiFlags: 'inboxAssignableAgents/getUIFlags',
currentChat: 'getSelectedChat',
}),
chatExtraAttributes() {
return this.chat.additional_attributes;
},
chatMetadata() {
return this.chat.meta;
},
chatBadge() {
if(this.chatExtraAttributes['type']){
return this.chatExtraAttributes['type']
} else {
return this.chatMetadata.channel
}
},
currentContact() {
return this.$store.getters['contacts/getContact'](
this.chat.meta.sender.id

View File

@@ -40,7 +40,7 @@ export default {
const { apiChannelName, apiChannelThumbnail } = this.globalConfig;
return [
{ key: 'website', name: 'Website' },
{ key: 'facebook', name: 'Facebook' },
{ key: 'facebook', name: 'Messenger' },
{ key: 'twitter', name: 'Twitter' },
{ key: 'whatsapp', name: 'WhatsApp via Twilio' },
{ key: 'sms', name: 'SMS via Twilio' },

View File

@@ -206,7 +206,7 @@ export default {
}
},
{
scope: 'pages_manage_metadata,pages_messaging',
scope: 'pages_manage_metadata,pages_messaging,instagram_basic,pages_show_list,instagram_manage_messages',
}
);
},

View File

@@ -3,10 +3,16 @@ class SendReplyJob < ApplicationJob
def perform(message_id)
message = Message.find(message_id)
channel_name = message.conversation.inbox.channel.class.to_s
conversation = message.conversation
channel_name = conversation.inbox.channel.class.to_s
case channel_name
when 'Channel::FacebookPage'
::Facebook::SendOnFacebookService.new(message: message).perform
if conversation.additional_attributes['type'] == 'instagram_direct_message'
::Instagram::SendOnInstagramService.new(message: message).perform
else
::Facebook::SendOnFacebookService.new(message: message).perform
end
when 'Channel::TwitterProfile'
::Twitter::SendOnTwitterService.new(message: message).perform
when 'Channel::TwilioSms'

View File

@@ -0,0 +1,84 @@
class Webhooks::InstagramEventsJob < ApplicationJob
queue_as :default
include HTTParty
base_uri 'https://graph.facebook.com/v11.0/me'
# @return [Array] We will support further events like reaction or seen in future
SUPPORTED_EVENTS = [:message].freeze
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook
def perform(entries)
@entries = entries
if @entries[0].key?(:changes)
Rails.logger.info('Probably Test data.')
# grab the test entry for the review app
create_test_text
return
end
@entries.each do |entry|
entry[:messaging].each do |messaging|
send(@event_name, messaging) if event_name(messaging)
end
end
end
private
def event_name(messaging)
@event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
end
def message(messaging)
::Instagram::MessageText.new(messaging).perform
end
def create_test_text
messenger_channel = Channel::FacebookPage.last
@inbox = ::Inbox.find_by!(channel: messenger_channel)
@contact_inbox = @inbox.contact_inboxes.where(source_id: 'sender_username').first
unless @contact_inbox
@contact_inbox ||= @inbox.channel.create_contact_inbox(
'sender_username', 'sender_username'
)
end
@contact = @contact_inbox.contact
@conversation ||= Conversation.find_by(conversation_params) || build_conversation(conversation_params)
@message = @conversation.messages.create!(message_params)
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: @contact.id,
additional_attributes: {
type: 'instagram_direct_message'
}
}
end
def message_params
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: 'incoming',
source_id: 'facebook_test_webhooks',
content: 'This is a test message from facebook.',
sender: @contact
}
end
def build_conversation(conversation_params)
Conversation.create!(
conversation_params.merge(
contact_inbox_id: @contact_inbox.id
)
)
end
end

View File

@@ -8,6 +8,7 @@
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# instagram_id :string
# page_id :string not null
#
# Indexes
@@ -35,6 +36,19 @@ class Channel::FacebookPage < ApplicationRecord
true
end
def create_contact_inbox(instagram_id, name)
ActiveRecord::Base.transaction do
contact = inbox.account.contacts.create!(name: name)
::ContactInbox.create(
contact_id: contact.id,
inbox_id: inbox.id,
source_id: instagram_id
)
rescue StandardError => e
Rails.logger.info e
end
end
def subscribe
# ref https://developers.facebook.com/docs/messenger-platform/reference/webhook-events
response = Facebook::Messenger::Subscriptions.subscribe(

View File

@@ -0,0 +1,49 @@
class Instagram::MessageText < Instagram::WebhooksBaseService
include HTTParty
attr_reader :messaging
base_uri 'https://graph.facebook.com/v11.0/'
def initialize(messaging)
super()
@messaging = messaging
end
def perform
instagram_id, contact_id = if agent_message_via_echo?
[@messaging[:sender][:id], @messaging[:recipient][:id]]
else
[@messaging[:recipient][:id], @messaging[:sender][:id]]
end
inbox_channel(instagram_id)
ensure_contact(contact_id)
create_message
end
private
def ensure_contact(ig_scope_id)
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(ig_scope_id) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue StandardError => e
result = {}
Sentry.capture_exception(e)
end
find_or_create_contact(result)
end
def agent_message_via_echo?
@messaging[:message][:is_echo].present?
end
def create_message
Messages::Instagram::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
end
end

View File

@@ -0,0 +1,99 @@
class Instagram::SendOnInstagramService < Base::SendOnChannelService
include HTTParty
pattr_initialize [:message!]
base_uri 'https://graph.facebook.com/v11.0/me'
private
delegate :additional_attributes, to: :contact
def channel_class
Channel::FacebookPage
end
def perform_reply
send_to_facebook_page attachament_message_params if message.attachments.present?
send_to_facebook_page message_params
rescue StandardError => e
Sentry.capture_exception(e)
channel.authorization_error!
end
def message_params
{
recipient: { id: contact.get_source_id(inbox.id) },
message: {
text: message.content
}
}
end
def attachament_message_params
attachment = message.attachments.first
{
recipient: { id: contact.get_source_id(inbox.id) },
message: {
attachment: {
type: attachment_type(attachment),
payload: {
url: attachment.file_url
}
}
}
}
end
# Deliver a message with the given payload.
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message
def send_to_facebook_page(message_content)
access_token = channel.page_access_token
app_secret_proof = calculate_app_secret_proof(ENV['FB_APP_SECRET'], access_token)
query = { access_token: access_token }
query[:appsecret_proof] = app_secret_proof if app_secret_proof
# url = "https://graph.facebook.com/v11.0/me/messages?access_token=#{access_token}"
response = HTTParty.post(
'https://graph.facebook.com/v11.0/me/messages',
body: message_content,
query: query
)
# response = HTTParty.post(url, options)
Rails.logger.info("Instagram response: #{response} : #{message_content}") if response[:body][:error]
response[:body]
end
def calculate_app_secret_proof(app_secret, access_token)
Facebook::Messenger::Configuration::AppSecretProofCalculator.call(
app_secret, access_token
)
end
def attachment_type(attachment)
return attachment.file_type if %w[image audio video file].include? attachment.file_type
'file'
end
def conversation_type
conversation.additional_attributes['type']
end
def sent_first_outgoing_message_after_24_hours?
# we can send max 1 message after 24 hour window
conversation.messages.outgoing.where('id > ?', last_incoming_message.id).count == 1
end
def last_incoming_message
conversation.messages.incoming.last
end
def config
Facebook::Messenger.config
end
end

View File

@@ -0,0 +1,21 @@
class Instagram::WebhooksBaseService
private
def inbox_channel(instagram_id)
messenger_channel = Channel::FacebookPage.where(instagram_id: instagram_id)
@inbox = ::Inbox.find_by!(channel: messenger_channel)
end
def find_or_create_contact(user)
@contact_inbox = @inbox.contact_inboxes.where(source_id: user['id']).first
@contact = @contact_inbox.contact if @contact_inbox
return if @contact
@contact_inbox = @inbox.channel.create_contact_inbox(
user['id'], user['name']
)
@contact = @contact_inbox.contact
ContactAvatarJob.perform_later(@contact, user['profile_pic']) if user['profile_pic']
end
end