feat: Instagram Inbox using Instagram Business Login (#11054)
This PR introduces basic minimum version of **Instagram Business Login**, making Instagram inbox setup more straightforward by removing the Facebook Page dependency. This update enhances user experience and aligns with Meta’s recommended best practices. Fixes https://linear.app/chatwoot/issue/CW-3728/instagram-login-how-to-implement-the-changes ## Why Introduce Instagram as a Separate Inbox? Currently, our Instagram integration requires linking an Instagram account to a Facebook Page, making setup complex. To simplify this process, Instagram now offers **Instagram Business Login**, which allows users to authenticate directly with their Instagram credentials. The **Instagram API with Instagram Login** enables businesses and creators to send and receive messages without needing a Facebook Page connection. While an Instagram Business or Creator account is still required, this approach provides a more straightforward integration process. | **Existing Approach (Facebook Login for Business)** | **New Approach (Instagram Business Login)** | | --- | --- | | Requires linking Instagram to a Facebook Page | No Facebook Page required | | Users log in via Facebook credentials | Users log in via Instagram credentials | | Configuration is more complex | Simpler setup | Meta recommends using **Instagram Business Login** as the preferred authentication method due to its easier configuration and improved developer experience. --- ## Implementation Plan The core messaging functionality is already in place, but the transition to **Instagram Business Login** requires adjustments. ### Changes & Considerations - **API Adjustments**: The Instagram API uses `graph.instagram`, whereas Koala (our existing library) interacts with `graph.facebook`. We may need to modify API calls accordingly. - **Three Main Modules**: 1. **Instagram Business Login** – Handle authentication flow. 2. **Permissions & Features** – Ensure necessary API scopes are granted. 3. **Webhooks** – Enable real-time message retrieval.  --- ## Instagram Login Flow 1. User clicks **"Create Inbox"** for Instagram. 2. App redirects to the [Instagram Authorization URL](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#embed-the-business-login-url). 3. After authentication, Instagram returns an authorization code. 5. The app exchanges the code for a **long-lived token** (valid for 60 days). 6. Tokens are refreshed periodically to maintain access. 7. Once completed, the app creates an inbox and redirects to the Chatwoot dashboard. --- ## How to Test the Instagram Inbox 1. Create a new app on [Meta's Developer Portal](https://developers.facebook.com/apps/). 2. Select **Business** as the app type and configure it. 3. Add the Instagram product and connect a business account. 4. Copy Instagram app ID and Instagram app secret 5. Add the Instagram app ID and Instagram app secret to your app config via `{Chatwoot installation url}/super_admin/app_config?config=instagram` 6. Configure Webhooks: - Callback URL: `{your_chatwoot_url}/webhooks/instagram` - Verify Token: `INSTAGRAM_VERIFY_TOKEN` - Subscribe to `messages`, `messaging_seen`, and `message_reactions` events. 7. Set up **Instagram Business Login**: - Redirect URL: `{your_chatwoot_url}/instagram/callback` 8. Test inbox creation via the Chatwoot dashboard. ## Troubleshooting & Common Errors ### Insufficient Developer Role Error - Ensure the Instagram user is added as a developer: - **Meta Dashboard → App Roles → Roles → Add People → Enter Instagram ID** ### API Access Deactivated - Ensure the **Privacy Policy URL** is valid and correctly set. ### Invalid request: Request parameters are invalid: Invalid redirect_uri - Please configure the Frontend URL. The Frontend URL does not match the authorization URL. --- ## To-Do List - [x] Basic integration setup completed. - [x] Enable sending messages via [Messaging API](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api). - [x] Implement automatic webhook subscriptions on inbox creation. - [x] Handle **canceled authorization errors**. - [x] Handle all the errors https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes - [x] Dynamically fetch **account IDs** instead of hardcoding them. - [x] Prevent duplicate Instagram channel creation for the same account. - [x] Use **Global Config** instead of environment variables. - [x] Explore **Human Agent feature** for message handling. - [x] Write and refine **test cases** for all scenarios. - [x] Implement **token refresh mechanism** (tokens expire after 60 days). Fixes https://github.com/chatwoot/chatwoot/issues/10440 --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
69
app/services/instagram/base_message_text.rb
Normal file
69
app/services/instagram/base_message_text.rb
Normal file
@@ -0,0 +1,69 @@
|
||||
class Instagram::BaseMessageText < Instagram::WebhooksBaseService
|
||||
attr_reader :messaging
|
||||
|
||||
def initialize(messaging, channel)
|
||||
@messaging = messaging
|
||||
super(channel)
|
||||
end
|
||||
|
||||
def perform
|
||||
connected_instagram_id, contact_id = instagram_and_contact_ids
|
||||
inbox_channel(connected_instagram_id)
|
||||
|
||||
return if @inbox.blank?
|
||||
|
||||
if @inbox.channel.reauthorization_required?
|
||||
Rails.logger.info("Skipping message processing as reauthorization is required for inbox #{@inbox.id}")
|
||||
return
|
||||
end
|
||||
|
||||
return unsend_message if message_is_deleted?
|
||||
|
||||
ensure_contact(contact_id) if contacts_first_message?(contact_id)
|
||||
|
||||
create_message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def instagram_and_contact_ids
|
||||
if agent_message_via_echo?
|
||||
[@messaging[:sender][:id], @messaging[:recipient][:id]]
|
||||
else
|
||||
[@messaging[:recipient][:id], @messaging[:sender][:id]]
|
||||
end
|
||||
end
|
||||
|
||||
def agent_message_via_echo?
|
||||
@messaging[:message][:is_echo].present?
|
||||
end
|
||||
|
||||
def message_is_deleted?
|
||||
@messaging[:message][:is_deleted].present?
|
||||
end
|
||||
|
||||
# if contact was present before find out contact_inbox to create message
|
||||
def contacts_first_message?(ig_scope_id)
|
||||
@contact_inbox = @inbox.contact_inboxes.where(source_id: ig_scope_id).last
|
||||
@contact_inbox.blank? && @inbox.channel.instagram_id.present?
|
||||
end
|
||||
|
||||
def unsend_message
|
||||
message_to_delete = @inbox.messages.find_by(
|
||||
source_id: @messaging[:message][:mid]
|
||||
)
|
||||
return if message_to_delete.blank?
|
||||
|
||||
message_to_delete.attachments.destroy_all
|
||||
message_to_delete.update!(content: I18n.t('conversations.messages.deleted'), deleted: true)
|
||||
end
|
||||
|
||||
# Methods to be implemented by subclasses
|
||||
def ensure_contact(contact_id)
|
||||
raise NotImplementedError, "#{self.class} must implement #ensure_contact"
|
||||
end
|
||||
|
||||
def create_message
|
||||
raise NotImplementedError, "#{self.class} must implement #create_message"
|
||||
end
|
||||
end
|
||||
95
app/services/instagram/base_send_service.rb
Normal file
95
app/services/instagram/base_send_service.rb
Normal file
@@ -0,0 +1,95 @@
|
||||
class Instagram::BaseSendService < Base::SendOnChannelService
|
||||
pattr_initialize [:message!]
|
||||
|
||||
private
|
||||
|
||||
delegate :additional_attributes, to: :contact
|
||||
|
||||
def perform_reply
|
||||
send_attachments if message.attachments.present?
|
||||
send_content if message.content.present?
|
||||
rescue StandardError => e
|
||||
handle_error(e)
|
||||
end
|
||||
|
||||
def send_attachments
|
||||
message.attachments.each do |attachment|
|
||||
send_message(attachment_message_params(attachment))
|
||||
end
|
||||
end
|
||||
|
||||
def send_content
|
||||
send_message(message_params)
|
||||
end
|
||||
|
||||
def handle_error(error)
|
||||
ChatwootExceptionTracker.new(error, account: message.account, user: message.sender).capture_exception
|
||||
end
|
||||
|
||||
def message_params
|
||||
params = {
|
||||
recipient: { id: contact.get_source_id(inbox.id) },
|
||||
message: {
|
||||
text: message.content
|
||||
}
|
||||
}
|
||||
|
||||
merge_human_agent_tag(params)
|
||||
end
|
||||
|
||||
def attachment_message_params(attachment)
|
||||
params = {
|
||||
recipient: { id: contact.get_source_id(inbox.id) },
|
||||
message: {
|
||||
attachment: {
|
||||
type: attachment_type(attachment),
|
||||
payload: {
|
||||
url: attachment.download_url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merge_human_agent_tag(params)
|
||||
end
|
||||
|
||||
def process_response(response, message_content)
|
||||
parsed_response = response.parsed_response
|
||||
if response.success? && parsed_response['error'].blank?
|
||||
message.update!(source_id: parsed_response['message_id'])
|
||||
parsed_response
|
||||
else
|
||||
external_error = external_error(parsed_response)
|
||||
Rails.logger.error("Instagram response: #{external_error} : #{message_content}")
|
||||
message.update!(status: :failed, external_error: external_error)
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def external_error(response)
|
||||
error_message = response.dig('error', 'message')
|
||||
error_code = response.dig('error', 'code')
|
||||
|
||||
# https://developers.facebook.com/docs/messenger-platform/error-codes
|
||||
# Access token has expired or become invalid. This may be due to a password change,
|
||||
# removal of the connected app from Instagram account settings, or other reasons.
|
||||
channel.authorization_error! if error_code == 190
|
||||
|
||||
"#{error_code} - #{error_message}"
|
||||
end
|
||||
|
||||
def attachment_type(attachment)
|
||||
return attachment.file_type if %w[image audio video file].include? attachment.file_type
|
||||
|
||||
'file'
|
||||
end
|
||||
|
||||
# Methods to be implemented by child classes
|
||||
def send_message(message_content)
|
||||
raise NotImplementedError, 'Subclasses must implement send_message'
|
||||
end
|
||||
|
||||
def merge_human_agent_tag(params)
|
||||
raise NotImplementedError, 'Subclasses must implement merge_human_agent_tag'
|
||||
end
|
||||
end
|
||||
@@ -1,90 +1,54 @@
|
||||
class Instagram::MessageText < Instagram::WebhooksBaseService
|
||||
include HTTParty
|
||||
|
||||
class Instagram::MessageText < Instagram::BaseMessageText
|
||||
attr_reader :messaging
|
||||
|
||||
base_uri 'https://graph.facebook.com/v11.0/'
|
||||
|
||||
def initialize(messaging)
|
||||
super()
|
||||
@messaging = messaging
|
||||
end
|
||||
|
||||
def perform
|
||||
create_test_text
|
||||
instagram_id, contact_id = instagram_and_contact_ids
|
||||
inbox_channel(instagram_id)
|
||||
# person can connect the channel and then delete the inbox
|
||||
return if @inbox.blank?
|
||||
|
||||
# This channel might require reauthorization, may be owner might have changed the fb password
|
||||
if @inbox.channel.reauthorization_required?
|
||||
Rails.logger.info("Skipping message processing as reauthorization is required for inbox #{@inbox.id}")
|
||||
return
|
||||
end
|
||||
|
||||
return unsend_message if message_is_deleted?
|
||||
|
||||
ensure_contact(contact_id) if contacts_first_message?(contact_id)
|
||||
|
||||
create_message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def instagram_and_contact_ids
|
||||
if agent_message_via_echo?
|
||||
[@messaging[:sender][:id], @messaging[:recipient][:id]]
|
||||
else
|
||||
[@messaging[:recipient][:id], @messaging[:sender][:id]]
|
||||
end
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize
|
||||
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 => e
|
||||
@inbox.channel.authorization_error!
|
||||
Rails.logger.warn("Authorization error for account #{@inbox.account_id} for inbox #{@inbox.id}")
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
rescue StandardError, Koala::Facebook::ClientError => e
|
||||
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
|
||||
Rails.logger.warn("[FacebookUserFetchClientError]: #{e.message}")
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
end
|
||||
|
||||
find_or_create_contact(result) if defined?(result) && result.present?
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize
|
||||
|
||||
def agent_message_via_echo?
|
||||
@messaging[:message][:is_echo].present?
|
||||
result = fetch_instagram_user(ig_scope_id)
|
||||
find_or_create_contact(result) if result.present?
|
||||
end
|
||||
|
||||
def message_is_deleted?
|
||||
@messaging[:message][:is_deleted].present?
|
||||
def fetch_instagram_user(ig_scope_id)
|
||||
fields = 'name,username,profile_pic,follower_count,is_user_follow_business,is_business_follow_user,is_verified_user'
|
||||
url = "#{base_uri}/#{ig_scope_id}?fields=#{fields}&access_token=#{@inbox.channel.access_token}"
|
||||
|
||||
response = HTTParty.get(url)
|
||||
|
||||
return process_successful_response(response) if response.success?
|
||||
|
||||
handle_error_response(response)
|
||||
{}
|
||||
end
|
||||
|
||||
# if contact was present before find out contact_inbox to create message
|
||||
def contacts_first_message?(ig_scope_id)
|
||||
@contact_inbox = @inbox.contact_inboxes.where(source_id: ig_scope_id).last
|
||||
@contact_inbox.blank? && @inbox.channel.instagram_id.present?
|
||||
def process_successful_response(response)
|
||||
result = JSON.parse(response.body).with_indifferent_access
|
||||
{
|
||||
'name' => result['name'],
|
||||
'username' => result['username'],
|
||||
'profile_pic' => result['profile_pic'],
|
||||
'id' => result['id'],
|
||||
'follower_count' => result['follower_count'],
|
||||
'is_user_follow_business' => result['is_user_follow_business'],
|
||||
'is_business_follow_user' => result['is_business_follow_user'],
|
||||
'is_verified_user' => result['is_verified_user']
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
def sent_via_test_webhook?
|
||||
@messaging[:sender][:id] == '12334' && @messaging[:recipient][:id] == '23245'
|
||||
def handle_error_response(response)
|
||||
parsed_response = response.parsed_response
|
||||
error_message = parsed_response.dig('error', 'message')
|
||||
error_code = parsed_response.dig('error', 'code')
|
||||
|
||||
# https://developers.facebook.com/docs/messenger-platform/error-codes
|
||||
# Access token has expired or become invalid.
|
||||
channel.authorization_error! if error_code == 190
|
||||
|
||||
Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
|
||||
Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}")
|
||||
|
||||
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
|
||||
end
|
||||
|
||||
def unsend_message
|
||||
message_to_delete = @inbox.messages.find_by(
|
||||
source_id: @messaging[:message][:mid]
|
||||
)
|
||||
return if message_to_delete.blank?
|
||||
|
||||
message_to_delete.attachments.destroy_all
|
||||
message_to_delete.update!(content: I18n.t('conversations.messages.deleted'), deleted: true)
|
||||
def base_uri
|
||||
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
|
||||
end
|
||||
|
||||
def create_message
|
||||
@@ -92,65 +56,4 @@ class Instagram::MessageText < Instagram::WebhooksBaseService
|
||||
|
||||
Messages::Instagram::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
|
||||
end
|
||||
|
||||
def create_test_text
|
||||
return unless sent_via_test_webhook?
|
||||
|
||||
Rails.logger.info('Probably Test data.')
|
||||
|
||||
messenger_channel = Channel::FacebookPage.last
|
||||
@inbox = ::Inbox.find_by(channel: messenger_channel)
|
||||
return unless @inbox
|
||||
|
||||
@contact = create_test_contact
|
||||
|
||||
@conversation ||= create_test_conversation(conversation_params)
|
||||
|
||||
@message = @conversation.messages.create!(test_message_params)
|
||||
end
|
||||
|
||||
def create_test_contact
|
||||
@contact_inbox = @inbox.contact_inboxes.where(source_id: @messaging[:sender][:id]).first
|
||||
unless @contact_inbox
|
||||
@contact_inbox ||= @inbox.channel.create_contact_inbox(
|
||||
'sender_username', 'sender_username'
|
||||
)
|
||||
end
|
||||
|
||||
@contact_inbox.contact
|
||||
end
|
||||
|
||||
def create_test_conversation(conversation_params)
|
||||
Conversation.find_by(conversation_params) || build_conversation(conversation_params)
|
||||
end
|
||||
|
||||
def test_message_params
|
||||
{
|
||||
account_id: @conversation.account_id,
|
||||
inbox_id: @conversation.inbox_id,
|
||||
message_type: 'incoming',
|
||||
source_id: @messaging[:message][:mid],
|
||||
content: @messaging[:message][:text],
|
||||
sender: @contact
|
||||
}
|
||||
end
|
||||
|
||||
def build_conversation(conversation_params)
|
||||
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
|
||||
end
|
||||
|
||||
37
app/services/instagram/messenger/message_text.rb
Normal file
37
app/services/instagram/messenger/message_text.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
class Instagram::Messenger::MessageText < Instagram::BaseMessageText
|
||||
private
|
||||
|
||||
def ensure_contact(ig_scope_id)
|
||||
result = fetch_instagram_user(ig_scope_id)
|
||||
find_or_create_contact(result) if result.present?
|
||||
end
|
||||
|
||||
def fetch_instagram_user(ig_scope_id)
|
||||
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
|
||||
k.get_object(ig_scope_id) || {}
|
||||
rescue Koala::Facebook::AuthenticationError => e
|
||||
handle_authentication_error(e)
|
||||
{}
|
||||
rescue StandardError, Koala::Facebook::ClientError => e
|
||||
handle_client_error(e)
|
||||
{}
|
||||
end
|
||||
|
||||
def handle_authentication_error(error)
|
||||
@inbox.channel.authorization_error!
|
||||
Rails.logger.warn("Authorization error for account #{@inbox.account_id} for inbox #{@inbox.id}")
|
||||
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
|
||||
end
|
||||
|
||||
def handle_client_error(error)
|
||||
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
|
||||
Rails.logger.warn("[FacebookUserFetchClientError]: #{error.message}")
|
||||
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
|
||||
end
|
||||
|
||||
def create_message
|
||||
return unless @contact_inbox
|
||||
|
||||
Messages::Instagram::Messenger::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
class Instagram::Messenger::SendOnInstagramService < Instagram::BaseSendService
|
||||
private
|
||||
|
||||
def channel_class
|
||||
Channel::FacebookPage
|
||||
end
|
||||
|
||||
# Deliver a message with the given payload.
|
||||
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message
|
||||
def send_message(message_content)
|
||||
access_token = channel.page_access_token
|
||||
app_secret_proof = calculate_app_secret_proof(GlobalConfigService.load('FB_APP_SECRET', ''), access_token)
|
||||
query = { access_token: access_token }
|
||||
query[:appsecret_proof] = app_secret_proof if app_secret_proof
|
||||
|
||||
response = HTTParty.post(
|
||||
'https://graph.facebook.com/v11.0/me/messages',
|
||||
body: message_content,
|
||||
query: query
|
||||
)
|
||||
|
||||
process_response(response, message_content)
|
||||
end
|
||||
|
||||
def calculate_app_secret_proof(app_secret, access_token)
|
||||
Facebook::Messenger::Configuration::AppSecretProofCalculator.call(
|
||||
app_secret, access_token
|
||||
)
|
||||
end
|
||||
|
||||
def merge_human_agent_tag(params)
|
||||
global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
|
||||
|
||||
return params unless global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT']
|
||||
|
||||
params[:messaging_type] = 'MESSAGE_TAG'
|
||||
params[:tag] = 'HUMAN_AGENT'
|
||||
params
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,8 @@
|
||||
class Instagram::ReadStatusService
|
||||
pattr_initialize [:params!]
|
||||
pattr_initialize [:params!, :channel!]
|
||||
|
||||
def perform
|
||||
return if instagram_channel.blank?
|
||||
return if channel.blank?
|
||||
|
||||
::Conversations::UpdateMessageStatusJob.perform_later(message.conversation.id, message.created_at) if message.present?
|
||||
end
|
||||
@@ -11,13 +11,9 @@ class Instagram::ReadStatusService
|
||||
params[:recipient][:id]
|
||||
end
|
||||
|
||||
def instagram_channel
|
||||
@instagram_channel ||= Channel::FacebookPage.find_by(instagram_id: instagram_id)
|
||||
end
|
||||
|
||||
def message
|
||||
return unless params[:read][:mid]
|
||||
|
||||
@message ||= @instagram_channel.inbox.messages.find_by(source_id: params[:read][:mid])
|
||||
@message ||= @channel.inbox.messages.find_by(source_id: params[:read][:mid])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,7 +37,7 @@ class Instagram::RefreshOauthTokenService
|
||||
token_is_valid = Time.current < channel.expires_at
|
||||
|
||||
# 2. Token is at least 24 hours old (based on updated_at)
|
||||
token_is_old_enough = channel.updated_at.present? && channel.updated_at < 24.hours.ago
|
||||
token_is_old_enough = channel.updated_at.present? && Time.current - channel.updated_at >= 24.hours
|
||||
|
||||
# 3. Token is approaching expiry (within 10 days)
|
||||
approaching_expiry = channel.expires_at < 10.days.from_now
|
||||
|
||||
@@ -1,130 +1,30 @@
|
||||
class Instagram::SendOnInstagramService < Base::SendOnChannelService
|
||||
include HTTParty
|
||||
|
||||
pattr_initialize [:message!]
|
||||
|
||||
base_uri 'https://graph.facebook.com/v11.0/me'
|
||||
|
||||
class Instagram::SendOnInstagramService < Instagram::BaseSendService
|
||||
private
|
||||
|
||||
delegate :additional_attributes, to: :contact
|
||||
|
||||
def channel_class
|
||||
Channel::FacebookPage
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
if message.attachments.present?
|
||||
message.attachments.each do |attachment|
|
||||
send_to_facebook_page attachment_message_params(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
send_to_facebook_page message_params if message.content.present?
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: message.account, user: message.sender).capture_exception
|
||||
# TODO : handle specific errors or else page will get disconnected
|
||||
# channel.authorization_error!
|
||||
end
|
||||
|
||||
def message_params
|
||||
params = {
|
||||
recipient: { id: contact.get_source_id(inbox.id) },
|
||||
message: {
|
||||
text: message.content
|
||||
}
|
||||
}
|
||||
|
||||
merge_human_agent_tag(params)
|
||||
end
|
||||
|
||||
def attachment_message_params(attachment)
|
||||
params = {
|
||||
recipient: { id: contact.get_source_id(inbox.id) },
|
||||
message: {
|
||||
attachment: {
|
||||
type: attachment_type(attachment),
|
||||
payload: {
|
||||
url: attachment.download_url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merge_human_agent_tag(params)
|
||||
Channel::Instagram
|
||||
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(GlobalConfigService.load('FB_APP_SECRET', ''), access_token)
|
||||
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api
|
||||
def send_message(message_content)
|
||||
access_token = channel.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}"
|
||||
instagram_id = channel.instagram_id.presence || 'me'
|
||||
|
||||
response = HTTParty.post(
|
||||
'https://graph.facebook.com/v11.0/me/messages',
|
||||
"https://graph.instagram.com/v22.0/#{instagram_id}/messages",
|
||||
body: message_content,
|
||||
query: query
|
||||
)
|
||||
|
||||
handle_response(response, message_content)
|
||||
end
|
||||
|
||||
def handle_response(response, message_content)
|
||||
parsed_response = response.parsed_response
|
||||
if response.success? && parsed_response['error'].blank?
|
||||
message.update!(source_id: parsed_response['message_id'])
|
||||
|
||||
parsed_response
|
||||
else
|
||||
external_error = external_error(parsed_response)
|
||||
Rails.logger.error("Instagram response: #{external_error} : #{message_content}")
|
||||
message.update!(status: :failed, external_error: external_error)
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def external_error(response)
|
||||
# https://developers.facebook.com/docs/instagram-api/reference/error-codes/
|
||||
error_message = response.dig('error', 'message')
|
||||
error_code = response.dig('error', 'code')
|
||||
|
||||
"#{error_code} - #{error_message}"
|
||||
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 > ?', conversation.last_incoming_message.id).count == 1
|
||||
end
|
||||
|
||||
def config
|
||||
Facebook::Messenger.config
|
||||
process_response(response, message_content)
|
||||
end
|
||||
|
||||
def merge_human_agent_tag(params)
|
||||
global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
|
||||
global_config = GlobalConfig.get('ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT')
|
||||
|
||||
return params unless global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT']
|
||||
return params unless global_config['ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT']
|
||||
|
||||
params[:messaging_type] = 'MESSAGE_TAG'
|
||||
params[:tag] = 'HUMAN_AGENT'
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
class Instagram::WebhooksBaseService
|
||||
attr_reader :channel
|
||||
|
||||
def initialize(channel)
|
||||
@channel = channel
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbox_channel(instagram_id)
|
||||
messenger_channel = Channel::FacebookPage.where(instagram_id: instagram_id)
|
||||
@inbox = ::Inbox.find_by(channel: messenger_channel)
|
||||
def inbox_channel(_instagram_id)
|
||||
@inbox = ::Inbox.find_by(channel: @channel)
|
||||
end
|
||||
|
||||
def find_or_create_contact(user)
|
||||
@@ -24,9 +29,31 @@ class Instagram::WebhooksBaseService
|
||||
def update_instagram_profile_link(user)
|
||||
return unless user['username']
|
||||
|
||||
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username
|
||||
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_profiles': { 'instagram': user['username'] } })
|
||||
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_instagram_user_name': user['username'] })
|
||||
@contact.save
|
||||
instagram_attributes = build_instagram_attributes(user)
|
||||
@contact.update!(additional_attributes: @contact.additional_attributes.merge(instagram_attributes))
|
||||
end
|
||||
|
||||
def build_instagram_attributes(user)
|
||||
attributes = {
|
||||
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username
|
||||
'social_profiles': { 'instagram': user['username'] },
|
||||
'social_instagram_user_name': user['username']
|
||||
}
|
||||
|
||||
# Add optional attributes if present
|
||||
optional_fields = %w[
|
||||
follower_count
|
||||
is_user_follow_business
|
||||
is_business_follow_user
|
||||
is_verified_user
|
||||
]
|
||||
|
||||
optional_fields.each do |field|
|
||||
next if user[field].nil?
|
||||
|
||||
attributes["social_instagram_#{field}"] = user[field]
|
||||
end
|
||||
|
||||
attributes
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user