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.

![CleanShot 2025-03-10 at 21 32
28@2x](https://github.com/user-attachments/assets/1b019001-8d16-4e59-aca2-ced81e98f538)


---

## 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:
Muhsin Keloth
2025-04-08 10:47:41 +05:30
committed by GitHub
parent ae0b68147e
commit d827e66453
40 changed files with 1868 additions and 831 deletions

View File

@@ -63,9 +63,33 @@ class ContactInboxWithContactBuilder
contact = find_contact_by_identifier(contact_attributes[:identifier]) contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email]) contact ||= find_contact_by_email(contact_attributes[:email])
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number]) contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
contact contact
end end
def instagram_channel?
inbox.channel_type == 'Channel::Instagram'
end
# There might be existing contact_inboxes created through Channel::FacebookPage
# with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes
# while still reusing contacts if found in Facebook channels so that we can create
# new conversations with the same contact.
def find_contact_by_instagram_source_id(instagram_id)
return if instagram_id.blank?
existing_contact_inbox = ContactInbox.joins(:inbox)
.where(source_id: instagram_id)
.where(
'inboxes.channel_type = ? AND inboxes.account_id = ?',
'Channel::FacebookPage',
account.id
).first
existing_contact_inbox&.contact
end
def find_contact_by_identifier(identifier) def find_contact_by_identifier(identifier)
return if identifier.blank? return if identifier.blank?

View File

@@ -0,0 +1,179 @@
class Messages::Instagram::BaseMessageBuilder < 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 StandardError => e
handle_error(e)
end
private
def attachments
@messaging[:message][:attachments] || {}
end
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def message_identifier
message[:mid]
end
def message_source_id
@outgoing_echo ? recipient_id : sender_id
end
def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
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 ||= set_conversation_based_on_inbox_config
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
find_conversation_scope.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
end
def find_conversation_scope
Conversation.where(conversation_params)
end
def find_or_build_for_multiple_conversations
last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
# Duplicate webhook events may be sent for the same message
# when a user is connected to the Instagram account through both Messenger and Instagram login.
# Therefore, we need to check if the message already exists before creating it.
return if message_already_exists?
return if @outgoing_echo
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
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,
additional_attributes: additional_conversation_attributes
))
end
def additional_conversation_attributes
{}
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def message_already_exists?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
def handle_error(error)
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
true
end
# Abstract methods to be implemented by subclasses
def get_story_object_from_source_id(source_id)
raise NotImplementedError
end
end

View File

@@ -1,200 +1,42 @@
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo` class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder
# 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) def initialize(messaging, inbox, outgoing_echo: false)
super() super(messaging, inbox, outgoing_echo: outgoing_echo)
@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 => e
Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error!
raise
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true
end end
private private
def attachments def get_story_object_from_source_id(source_id)
@messaging[:message][:attachments] || {} url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}"
response = HTTParty.get(url)
return JSON.parse(response.body).with_indifferent_access if response.success?
# Create message first if it doesn't exist
@message ||= conversation.messages.create!(message_params)
handle_error_response(response)
nil
end end
def message_type def handle_error_response(response)
@outgoing_echo ? :outgoing : :incoming parsed_response = JSON.parse(response.body)
end error_code = parsed_response.dig('error', 'code')
def message_identifier # https://developers.facebook.com/docs/messenger-platform/error-codes
message[:mid] # Access token has expired or become invalid.
end channel.authorization_error! if error_code == 190
def message_source_id # There was a problem scraping data from the provided link.
@outgoing_echo ? recipient_id : sender_id # https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005
end if error_code == 1_609_005
@message.attachments.destroy_all
def message_is_unsupported? @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
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 ||= set_conversation_based_on_inbox_config
end
def instagram_direct_message_conversation
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end end
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
end end
def find_or_build_for_multiple_conversations def base_uri
last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first "https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
return build_conversation if last_conversation.nil?
last_conversation
end end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
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,
additional_attributes: { type: 'instagram_direct_message' }
))
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def already_sent_from_chatwoot?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
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 end

View File

@@ -0,0 +1,33 @@
class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder
def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo)
end
private
def get_story_object_from_source_id(source_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
def find_conversation_scope
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def additional_conversation_attributes
{ type: 'instagram_direct_message' }
end
end

View File

@@ -68,20 +68,8 @@ class Messages::Messenger::MessageBuilder
message.save! message.save!
end end
def get_story_object_from_source_id(source_id) # This is a placeholder method to be overridden by child classes
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? def get_story_object_from_source_id(_source_id)
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{} {}
end end

View File

@@ -1,6 +1,5 @@
module InstagramConcern module InstagramConcern
extend ActiveSupport::Concern extend ActiveSupport::Concern
include HTTParty
def instagram_client def instagram_client
::OAuth2::Client.new( ::OAuth2::Client.new(

View File

@@ -12,6 +12,7 @@ export function useChannelIcon(inbox) {
'Channel::TwitterProfile': 'i-ri-twitter-x-fill', 'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::WebWidget': 'i-ri-global-fill', 'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill', 'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
}; };
const providerIconMap = { const providerIconMap = {

View File

@@ -19,6 +19,7 @@ const {
isAWebWidgetInbox, isAWebWidgetInbox,
isAWhatsAppChannel, isAWhatsAppChannel,
isAnEmailChannel, isAnEmailChannel,
isAInstagramChannel,
} = useInbox(); } = useInbox();
const { status, isPrivate, createdAt, sourceId, messageType } = const { status, isPrivate, createdAt, sourceId, messageType } =
@@ -47,7 +48,8 @@ const isSent = computed(() => {
isATwilioChannel.value || isATwilioChannel.value ||
isAFacebookInbox.value || isAFacebookInbox.value ||
isASmsInbox.value || isASmsInbox.value ||
isATelegramChannel.value isATelegramChannel.value ||
isAInstagramChannel.value
) { ) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT; return sourceId.value && status.value === MESSAGE_STATUS.SENT;
} }
@@ -86,7 +88,8 @@ const isRead = computed(() => {
if ( if (
isAWhatsAppChannel.value || isAWhatsAppChannel.value ||
isATwilioChannel.value || isATwilioChannel.value ||
isAFacebookInbox.value isAFacebookInbox.value ||
isAInstagramChannel.value
) { ) {
return sourceId.value && status.value === MESSAGE_STATUS.READ; return sourceId.value && status.value === MESSAGE_STATUS.READ;
} }
@@ -102,7 +105,6 @@ const statusToShow = computed(() => {
if (isRead.value) return MESSAGE_STATUS.READ; if (isRead.value) return MESSAGE_STATUS.READ;
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED; if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
if (isSent.value) return MESSAGE_STATUS.SENT; if (isSent.value) return MESSAGE_STATUS.SENT;
if (status.value === MESSAGE_STATUS.FAILED) return MESSAGE_STATUS.FAILED;
return MESSAGE_STATUS.PROGRESS; return MESSAGE_STATUS.PROGRESS;
}); });

View File

@@ -32,6 +32,10 @@ export default {
return this.enabledFeatures.channel_email; return this.enabledFeatures.channel_email;
} }
if (key === 'instagram') {
return this.enabledFeatures.channel_instagram;
}
return [ return [
'website', 'website',
'twilio', 'twilio',
@@ -40,6 +44,7 @@ export default {
'sms', 'sms',
'telegram', 'telegram',
'line', 'line',
'instagram',
].includes(key); ].includes(key);
}, },
}, },

View File

@@ -261,7 +261,8 @@ export default {
this.isAnEmailChannel || this.isAnEmailChannel ||
this.isASmsInbox || this.isASmsInbox ||
this.isATelegramChannel || this.isATelegramChannel ||
this.isALineChannel this.isALineChannel ||
this.isAInstagramChannel
); );
}, },
replyButtonLabel() { replyButtonLabel() {
@@ -1076,7 +1077,7 @@ export default {
v-if="showSelfAssignBanner" v-if="showSelfAssignBanner"
action-button-variant="ghost" action-button-variant="ghost"
color-scheme="secondary" color-scheme="secondary"
class="banner--self-assign mx-2 mb-2 rounded-lg" class="mx-2 mb-2 rounded-lg banner--self-assign"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')" :banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')" :action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"

View File

@@ -121,6 +121,10 @@ export const useInbox = () => {
); );
}); });
const isAInstagramChannel = computed(() => {
return channelType.value === INBOX_TYPES.INSTAGRAM;
});
return { return {
inbox, inbox,
isAFacebookInbox, isAFacebookInbox,
@@ -137,5 +141,6 @@ export const useInbox = () => {
isAWhatsAppCloudChannel, isAWhatsAppCloudChannel,
is360DialogWhatsAppChannel, is360DialogWhatsAppChannel,
isAnEmailChannel, isAnEmailChannel,
isAInstagramChannel,
}; };
}; };

View File

@@ -34,6 +34,7 @@ export const FEATURE_FLAGS = {
CUSTOM_ROLES: 'custom_roles', CUSTOM_ROLES: 'custom_roles',
CHATWOOT_V4: 'chatwoot_v4', CHATWOOT_V4: 'chatwoot_v4',
REPORT_V4: 'report_v4', REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team', CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
}; };

View File

@@ -48,6 +48,7 @@
}, },
"INSTAGRAM": { "INSTAGRAM": {
"CONTINUE_WITH_INSTAGRAM": "Continue with Instagram", "CONTINUE_WITH_INSTAGRAM": "Continue with Instagram",
"CONNECT_YOUR_INSTAGRAM_PROFILE": "Connect your Instagram Profile",
"HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ", "HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ",
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again", "ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again" "ERROR_AUTH": "There was an error connecting to Instagram, please try again"

View File

@@ -35,6 +35,8 @@ export default {
}, },
{ key: 'telegram', name: 'Telegram' }, { key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' }, { key: 'line', name: 'Line' },
// TODO: Add Instagram to the channel list after the feature is ready to use.
// { key: 'instagram', name: 'Instagram' },
]; ];
}, },
...mapGetters({ ...mapGetters({
@@ -62,7 +64,7 @@ export default {
<template> <template>
<div <div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto" class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
> >
<PageHeader <PageHeader
class="max-w-4xl" class="max-w-4xl"

View File

@@ -67,7 +67,7 @@ export default {
<div <div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0" class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
> >
<div class="flex flex-col items-center justify-center h-full text-center"> <div class="flex flex-col items-center justify-start h-full text-center">
<div v-if="hasError" class="max-w-lg mx-auto text-center"> <div v-if="hasError" class="max-w-lg mx-auto text-center">
<h5>{{ errorStateMessage }}</h5> <h5>{{ errorStateMessage }}</h5>
<p <p
@@ -77,23 +77,20 @@ export default {
</div> </div>
<div <div
v-else v-else
class="flex flex-col items-center justify-center h-full text-center" class="flex flex-col items-center justify-center px-8 py-10 text-center shadow rounded-3xl outline outline-1 outline-n-weak"
> >
<h6 class="text-2xl font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}
</h6>
<p class="py-6 text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
</p>
<button <button
class="flex items-center justify-center px-8 py-3.5 text-white rounded-full bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45] hover:shadow-lg transition-all duration-300 min-w-[240px] overflow-hidden" class="flex items-center justify-center px-8 py-3.5 gap-2 text-white rounded-full bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45] hover:shadow-lg transition-all duration-300 min-w-[240px] overflow-hidden"
:disabled="isRequestingAuthorization" :disabled="isRequestingAuthorization"
@click="requestAuthorization()" @click="requestAuthorization()"
> >
<svg <span class="i-ri-instagram-line size-5" />
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"
/>
</svg>
<span class="text-base font-medium"> <span class="text-base font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM') }} {{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM') }}
</span> </span>
@@ -120,9 +117,6 @@ export default {
</svg> </svg>
</span> </span>
</button> </button>
<p class="py-6">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -12,7 +12,8 @@ class SendReplyJob < ApplicationJob
'Channel::Line' => ::Line::SendOnLineService, 'Channel::Line' => ::Line::SendOnLineService,
'Channel::Telegram' => ::Telegram::SendOnTelegramService, 'Channel::Telegram' => ::Telegram::SendOnTelegramService,
'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService, 'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
'Channel::Sms' => ::Sms::SendOnSmsService 'Channel::Sms' => ::Sms::SendOnSmsService,
'Channel::Instagram' => ::Instagram::SendOnInstagramService
} }
case channel_name case channel_name
@@ -27,7 +28,7 @@ class SendReplyJob < ApplicationJob
def send_on_facebook_page(message) def send_on_facebook_page(message)
if message.conversation.additional_attributes['type'] == 'instagram_direct_message' if message.conversation.additional_attributes['type'] == 'instagram_direct_message'
::Instagram::SendOnInstagramService.new(message: message).perform ::Instagram::Messenger::SendOnInstagramService.new(message: message).perform
else else
::Facebook::SendOnFacebookService.new(message: message).perform ::Facebook::SendOnFacebookService.new(message: message).perform
end end

View File

@@ -2,10 +2,6 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
queue_as :default queue_as :default
retry_on LockAcquisitionError, wait: 1.second, attempts: 8 retry_on LockAcquisitionError, wait: 1.second, attempts: 8
include HTTParty
base_uri 'https://graph.facebook.com/v11.0/me'
# @return [Array] We will support further events like reaction or seen in future # @return [Array] We will support further events like reaction or seen in future
SUPPORTED_EVENTS = [:message, :read].freeze SUPPORTED_EVENTS = [:message, :read].freeze
@@ -18,18 +14,46 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
end end
end end
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook # https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook
def process_entries(entries) def process_entries(entries)
entries.each do |entry| entries.each do |entry|
entry = entry.with_indifferent_access process_single_entry(entry.with_indifferent_access)
messages(entry).each do |messaging|
send(@event_name, messaging) if event_name(messaging)
end
end end
end end
private private
def process_single_entry(entry)
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 instagram_id(messaging)
if agent_message_via_echo?(messaging)
messaging[:sender][:id]
else
messaging[:recipient][:id]
end
end
def ig_account_id def ig_account_id
@entries&.first&.dig(:id) @entries&.first&.dig(:id)
end end
@@ -38,19 +62,116 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
@entries&.dig(0, :messaging, 0, :sender, :id) @entries&.dig(0, :messaging, 0, :sender, :id)
end 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) def event_name(messaging)
@event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) } @event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
end end
def message(messaging) def message(messaging, channel)
::Instagram::MessageText.new(messaging).perform if channel.is_a?(Channel::Instagram)
::Instagram::MessageText.new(messaging, channel).perform
else
::Instagram::Messenger::MessageText.new(messaging, channel).perform
end
end end
def read(messaging) def read(messaging, channel)
::Instagram::ReadStatusService.new(params: messaging).perform # 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 end
def messages(entry) def messages(entry)
(entry[:messaging].presence || entry[:standby] || []) (entry[:messaging].presence || entry[:standby] || [])
end end
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"
# }
# }
# ]
# }
# ]

View File

@@ -31,6 +31,14 @@ class Channel::Instagram < ApplicationRecord
'Instagram' 'Instagram'
end end
def create_contact_inbox(instagram_id, name)
@contact_inbox = ::ContactInboxWithContactBuilder.new({
source_id: instagram_id,
inbox: inbox,
contact_attributes: { name: name }
}).perform
end
def subscribe def subscribe
# ref https://developers.facebook.com/docs/instagram-platform/webhooks#enable-subscriptions # ref https://developers.facebook.com/docs/instagram-platform/webhooks#enable-subscriptions
HTTParty.post( HTTParty.post(

View 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

View 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

View File

@@ -1,90 +1,54 @@
class Instagram::MessageText < Instagram::WebhooksBaseService class Instagram::MessageText < Instagram::BaseMessageText
include HTTParty
attr_reader :messaging 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) def ensure_contact(ig_scope_id)
begin result = fetch_instagram_user(ig_scope_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? find_or_create_contact(result) if result.present?
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?
end end
def message_is_deleted? def fetch_instagram_user(ig_scope_id)
@messaging[:message][:is_deleted].present? 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 end
# if contact was present before find out contact_inbox to create message def process_successful_response(response)
def contacts_first_message?(ig_scope_id) result = JSON.parse(response.body).with_indifferent_access
@contact_inbox = @inbox.contact_inboxes.where(source_id: ig_scope_id).last {
@contact_inbox.blank? && @inbox.channel.instagram_id.present? '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 end
def sent_via_test_webhook? def handle_error_response(response)
@messaging[:sender][:id] == '12334' && @messaging[:recipient][:id] == '23245' 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 end
def unsend_message def base_uri
message_to_delete = @inbox.messages.find_by( "https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
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 end
def create_message 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 Messages::Instagram::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
end 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 end

View 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

View File

@@ -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

View File

@@ -1,8 +1,8 @@
class Instagram::ReadStatusService class Instagram::ReadStatusService
pattr_initialize [:params!] pattr_initialize [:params!, :channel!]
def perform def perform
return if instagram_channel.blank? return if channel.blank?
::Conversations::UpdateMessageStatusJob.perform_later(message.conversation.id, message.created_at) if message.present? ::Conversations::UpdateMessageStatusJob.perform_later(message.conversation.id, message.created_at) if message.present?
end end
@@ -11,13 +11,9 @@ class Instagram::ReadStatusService
params[:recipient][:id] params[:recipient][:id]
end end
def instagram_channel
@instagram_channel ||= Channel::FacebookPage.find_by(instagram_id: instagram_id)
end
def message def message
return unless params[:read][:mid] 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
end end

View File

@@ -37,7 +37,7 @@ class Instagram::RefreshOauthTokenService
token_is_valid = Time.current < channel.expires_at token_is_valid = Time.current < channel.expires_at
# 2. Token is at least 24 hours old (based on updated_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) # 3. Token is approaching expiry (within 10 days)
approaching_expiry = channel.expires_at < 10.days.from_now approaching_expiry = channel.expires_at < 10.days.from_now

View File

@@ -1,130 +1,30 @@
class Instagram::SendOnInstagramService < Base::SendOnChannelService class Instagram::SendOnInstagramService < Instagram::BaseSendService
include HTTParty
pattr_initialize [:message!]
base_uri 'https://graph.facebook.com/v11.0/me'
private private
delegate :additional_attributes, to: :contact
def channel_class def channel_class
Channel::FacebookPage Channel::Instagram
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)
end end
# Deliver a message with the given payload. # Deliver a message with the given payload.
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api
def send_to_facebook_page(message_content) def send_message(message_content)
access_token = channel.page_access_token access_token = channel.access_token
app_secret_proof = calculate_app_secret_proof(GlobalConfigService.load('FB_APP_SECRET', ''), access_token)
query = { access_token: access_token } query = { access_token: access_token }
query[:appsecret_proof] = app_secret_proof if app_secret_proof instagram_id = channel.instagram_id.presence || 'me'
# url = "https://graph.facebook.com/v11.0/me/messages?access_token=#{access_token}"
response = HTTParty.post( response = HTTParty.post(
'https://graph.facebook.com/v11.0/me/messages', "https://graph.instagram.com/v22.0/#{instagram_id}/messages",
body: message_content, body: message_content,
query: query query: query
) )
handle_response(response, message_content) process_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
end end
def merge_human_agent_tag(params) 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[:messaging_type] = 'MESSAGE_TAG'
params[:tag] = 'HUMAN_AGENT' params[:tag] = 'HUMAN_AGENT'

View File

@@ -1,9 +1,14 @@
class Instagram::WebhooksBaseService class Instagram::WebhooksBaseService
attr_reader :channel
def initialize(channel)
@channel = channel
end
private private
def inbox_channel(instagram_id) def inbox_channel(_instagram_id)
messenger_channel = Channel::FacebookPage.where(instagram_id: instagram_id) @inbox = ::Inbox.find_by(channel: @channel)
@inbox = ::Inbox.find_by(channel: messenger_channel)
end end
def find_or_create_contact(user) def find_or_create_contact(user)
@@ -24,9 +29,31 @@ class Instagram::WebhooksBaseService
def update_instagram_profile_link(user) def update_instagram_profile_link(user)
return unless user['username'] return unless user['username']
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username instagram_attributes = build_instagram_attributes(user)
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_profiles': { 'instagram': user['username'] } }) @contact.update!(additional_attributes: @contact.additional_attributes.merge(instagram_attributes))
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_instagram_user_name': user['username'] }) end
@contact.save
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
end end

View File

@@ -85,15 +85,15 @@ linear:
enabled: true enabled: true
icon: 'icon-linear' icon: 'icon-linear'
config_key: 'linear' config_key: 'linear'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify'
enabled: true
icon: 'icon-shopify'
config_key: 'shopify'
instagram: instagram:
name: 'Instagram' name: 'Instagram'
description: 'Configuration for setting up Instagram' description: 'Configuration for setting up Instagram'
enabled: true enabled: true
icon: 'icon-instagram' icon: 'icon-instagram'
config_key: 'instagram' config_key: 'instagram'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify'
enabled: true
icon: 'icon-shopify'
config_key: 'shopify'

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -95,5 +95,33 @@ describe ContactInboxWithContactBuilder do
expect(contact_inbox.contact.id).to be(contact.id) expect(contact_inbox.contact.id).to be(contact.id)
end end
it 'reuses contact if it exists with the same source_id in a Facebook inbox when creating for Instagram inbox' do
instagram_source_id = '123456789'
# Create a Facebook page inbox with a contact using the same source_id
facebook_inbox = create(:inbox, channel_type: 'Channel::FacebookPage', account: account)
facebook_contact = create(:contact, account: account)
facebook_contact_inbox = create(:contact_inbox, contact: facebook_contact, inbox: facebook_inbox, source_id: instagram_source_id)
# Create an Instagram inbox
instagram_inbox = create(:inbox, channel_type: 'Channel::Instagram', account: account)
# Try to create a contact inbox with same source_id for Instagram
contact_inbox = described_class.new(
source_id: instagram_source_id,
inbox: instagram_inbox,
contact_attributes: {
name: 'Instagram User',
email: 'instagram_user@example.com'
}
).perform
# Should reuse the existing contact from Facebook
expect(contact_inbox.contact.id).to eq(facebook_contact.id)
# Make sure the contact inbox is not the same as the Facebook contact inbox
expect(contact_inbox.id).not_to eq(facebook_contact_inbox.id)
expect(contact_inbox.inbox_id).to eq(instagram_inbox.id)
end
end end
end end

View File

@@ -1,27 +1,26 @@
require 'rails_helper' require 'rails_helper'
describe Messages::Instagram::MessageBuilder do describe Messages::Instagram::MessageBuilder do
subject(:instagram_message_builder) { described_class } subject(:instagram_direct_message_builder) { described_class }
before do before do
stub_request(:post, /graph.facebook.com/) stub_request(:post, /graph\.instagram\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg') stub_request(:get, 'https://www.example.com/test.jpeg')
.to_return(status: 200, body: '', headers: {})
end end
let!(:account) { create(:account) } let!(:account) { create(:account) }
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') } let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) } let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access } let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access } let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
let!(:shared_reel_params) { build(:instagram_shared_reel_event).with_indifferent_access } let!(:shared_reel_params) { build(:instagram_shared_reel_event).with_indifferent_access }
let!(:instagram_story_reply_event) { build(:instagram_story_reply_event).with_indifferent_access } let!(:instagram_story_reply_event) { build(:instagram_story_reply_event).with_indifferent_access }
let!(:instagram_message_reply_event) { build(:instagram_message_reply_event).with_indifferent_access } let!(:instagram_message_reply_event) { build(:instagram_message_reply_event).with_indifferent_access }
let(:fb_object) { double } let!(:contact) { create(:contact, id: 'Sender-id-1', name: 'Jane Dae') }
let(:contact) { create(:contact, id: 'Sender-id-1', name: 'Jane Dae') } let!(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: instagram_inbox.id, source_id: 'Sender-id-1') }
let(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: instagram_inbox.id, source_id: 'Sender-id-1') }
let(:conversation) do let(:conversation) do
create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id)
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
end end
let(:message) do let(:message) do
create(:message, account_id: account.id, inbox_id: instagram_inbox.id, conversation_id: conversation.id, message_type: 'outgoing', create(:message, account_id: account.id, inbox_id: instagram_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
@@ -29,16 +28,27 @@ describe Messages::Instagram::MessageBuilder do
end end
describe '#perform' do describe '#perform' do
it 'creates contact and message for the facebook inbox' do before do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) instagram_channel.update(access_token: 'valid_instagram_token')
allow(fb_object).to receive(:get_object).and_return(
{ stub_request(:get, %r{https://graph\.instagram\.com/.*?/Sender-id-1\?.*})
name: 'Jane', .to_return(
id: 'Sender-id-1', status: 200,
account_id: instagram_inbox.account_id, body: {
profile_pic: 'https://chatwoot-assets.local/sample.png' name: 'Jane',
}.with_indifferent_access username: 'some_user_name',
) profile_pic: 'https://chatwoot-assets.local/sample.png',
id: 'Sender-id-1',
follower_count: 100,
is_user_follow_business: true,
is_business_follow_user: true,
is_verified_user: false
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'creates contact and message for the instagram direct inbox' do
messaging = dm_params[:entry][0]['messaging'][0] messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox contact_inbox
described_class.new(messaging, instagram_inbox).perform described_class.new(messaging, instagram_inbox).perform
@@ -48,30 +58,19 @@ describe Messages::Instagram::MessageBuilder do
expect(instagram_inbox.conversations.count).to be 1 expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1 expect(instagram_inbox.messages.count).to be 1
contact = instagram_channel.inbox.contacts.first message = instagram_inbox.messages.first
message = instagram_channel.inbox.messages.first
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('This is the first message from the customer') expect(message.content).to eq('This is the first message from the customer')
end end
it 'discard echo message already sent by chatwoot' do it 'discard echo message already sent by chatwoot' do
conversation
message message
expect(instagram_inbox.conversations.count).to be 1 expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1 expect(instagram_inbox.messages.count).to be 1
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = dm_params[:entry][0]['messaging'][0] messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox messaging[:message][:mid] = 'message-id-1' # Set same source_id as the existing message
described_class.new(messaging, instagram_inbox, outgoing_echo: true).perform described_class.new(messaging, instagram_inbox, outgoing_echo: true).perform
instagram_inbox.reload instagram_inbox.reload
@@ -81,220 +80,244 @@ describe Messages::Instagram::MessageBuilder do
end end
it 'creates message for shared reel' do it 'creates message for shared reel' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = shared_reel_params[:entry][0]['messaging'][0] messaging = shared_reel_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_inbox).perform described_class.new(messaging, instagram_inbox).perform
message = instagram_channel.inbox.messages.first message = instagram_inbox.messages.first
expect(message.attachments.first.file_type).to eq('ig_reel') expect(message.attachments.first.file_type).to eq('ig_reel')
expect(message.attachments.first.external_url).to eq( expect(message.attachments.first.external_url).to eq(
shared_reel_params[:entry][0]['messaging'][0]['message']['attachments'][0]['payload']['url'] shared_reel_params[:entry][0]['messaging'][0]['message']['attachments'][0]['payload']['url']
) )
end end
it 'creates message with for reply with story id' do it 'creates message with story id' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) story_source_id = instagram_story_reply_event[:entry][0]['messaging'][0]['message']['mid']
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
contact_inbox
stub_request(:get, %r{https://graph\.instagram\.com/.*?/#{story_source_id}\?.*})
.to_return(
status: 200,
body: {
story: {
mention: {
id: 'chatwoot-app-user-id-1'
}
},
from: {
username: instagram_inbox.channel.instagram_id
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
described_class.new(messaging, instagram_inbox).perform described_class.new(messaging, instagram_inbox).perform
message = instagram_channel.inbox.messages.first message = instagram_inbox.messages.first
expect(message.content).to eq('This is the story reply') expect(message.content).to eq('This is the story reply')
expect(message.content_attributes[:story_sender]).to eq(instagram_inbox.channel.instagram_id) expect(message.content_attributes[:story_sender]).to eq(instagram_inbox.channel.instagram_id)
expect(message.content_attributes[:story_id]).to eq('chatwoot-app-user-id-1') expect(message.content_attributes[:story_id]).to eq('chatwoot-app-user-id-1')
expect(message.content_attributes[:story_url]).to eq('https://chatwoot-assets.local/sample.png')
end end
it 'creates message with for reply with mid' do it 'creates message with reply to mid' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) # Create first message to ensure reply to is valid
allow(fb_object).to receive(:get_object).and_return( first_messaging = dm_params[:entry][0]['messaging'][0]
{ described_class.new(first_messaging, instagram_inbox).perform
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
# create first message to ensure reply to is valid
first_message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(first_message, instagram_inbox).perform
# create the second message with the reply to mid set # Create second message with reply to mid
messaging = instagram_message_reply_event[:entry][0]['messaging'][0] messaging = instagram_message_reply_event[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_inbox).perform described_class.new(messaging, instagram_inbox).perform
first_message = instagram_channel.inbox.messages.first
message = instagram_channel.inbox.messages.last
expect(message.content).to eq('This is message with replyto mid') first_message = instagram_inbox.messages.first
expect(message.content_attributes[:in_reply_to_external_id]).to eq(first_message.source_id) reply_message = instagram_inbox.messages.last
expect(message.content_attributes[:in_reply_to]).to eq(first_message.id)
expect(reply_message.content).to eq('This is message with replyto mid')
expect(reply_message.content_attributes[:in_reply_to_external_id]).to eq(first_message.source_id)
end end
it 'raises exception on deleted story' do it 'handles deleted story' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) story_source_id = story_mention_params[:entry][0][:messaging][0]['message']['mid']
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError.new(
190, stub_request(:get, %r{https://graph\.instagram\.com/.*?/#{story_source_id}\?.*})
'This Message has been deleted by the user or the business.' .to_return(status: 404, body: { error: { message: 'Story not found', code: 1_609_005 } }.to_json)
))
messaging = story_mention_params[:entry][0][:messaging][0] messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox described_class.new(messaging, instagram_inbox).perform
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
instagram_inbox.reload message = instagram_inbox.messages.first
# we would have contact created, message created but the empty message because the story mention has been deleted later
# As they show it in instagram that this story is no longer available
# and external attachments link would be reachable
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
contact = instagram_channel.inbox.contacts.first
message = instagram_channel.inbox.messages.first
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('This story is no longer available.') expect(message.content).to eq('This story is no longer available.')
expect(message.attachments.count).to eq(0) expect(message.attachments.count).to eq(0)
end end
it 'does not create message for unsupported file type' do it 'does not create message for unsupported file type' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type' story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
messaging = story_mention_params[:entry][0][:messaging][0] messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
instagram_inbox.reload expect(instagram_inbox.conversations.count).to be 1
# we would have contact created but message and attachments won't be created
expect(instagram_inbox.conversations.count).to be 0
expect(instagram_inbox.messages.count).to be 0 expect(instagram_inbox.messages.count).to be 0
end
contact = instagram_channel.inbox.contacts.first it 'does not create message if the message is already exists' do
message
expect(contact.name).to eq('Jane Dae') expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
messaging = dm_params[:entry][0]['messaging'][0]
messaging[:message][:mid] = 'message-id-1' # Set same source_id as the existing message
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
end
it 'handles authorization errors' do
instagram_channel.update(access_token: 'invalid_token')
# Stub the request to return authorization error status
stub_request(:get, %r{https://graph\.instagram\.com/.*?/Sender-id-1\?.*})
.to_return(
status: 401,
body: { error: { message: 'unauthorized access token', code: 190 } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
messaging = dm_params[:entry][0]['messaging'][0]
# The method should complete without raising an error
expect do
described_class.new(messaging, instagram_inbox).perform
end.not_to raise_error
end end
end end
context 'when lock to single conversation is disabled' do context 'when lock to single conversation is disabled' do
before do before do
instagram_inbox.update!(lock_to_single_conversation: false) instagram_inbox.update!(lock_to_single_conversation: false)
stub_request(:get, /graph.facebook.com/)
end end
it 'creates a new conversation if existing conversation is not present' do it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count initial_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0] messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_inbox).perform described_class.new(messaging, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
expect(instagram_inbox.conversations.count).to eq(1) expect(instagram_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1) expect(Conversation.count).to eq(initial_count + 1)
end end
it 'will not create a new conversation if last conversation is not resolved' do it 'will not create a new conversation if last conversation is not resolved' do
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :open, existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }) contact_id: contact.id, status: :open)
message = dm_params[:entry][0]['messaging'][0] messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox described_class.new(messaging, instagram_inbox).perform
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id) expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id)
end end
it 'creates a new conversation if last conversation is resolved' do it 'creates a new conversation if last conversation is resolved' do
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :resolved, existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }) contact_id: contact.id, status: :resolved)
inital_count = Conversation.count initial_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0] messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_inbox).perform described_class.new(messaging, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
expect(instagram_inbox.conversations.last.id).not_to eq(existing_conversation.id) expect(instagram_inbox.conversations.last.id).not_to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count + 1) expect(Conversation.count).to eq(initial_count + 1)
end end
end end
context 'when lock to single conversation is enabled' do context 'when lock to single conversation is enabled' do
before do before do
instagram_inbox.update!(lock_to_single_conversation: true) instagram_inbox.update!(lock_to_single_conversation: true)
stub_request(:get, /graph.facebook.com/)
end end
it 'creates a new conversation if existing conversation is not present' do it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count initial_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0] messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_inbox).perform described_class.new(messaging, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
expect(instagram_inbox.conversations.count).to eq(1) expect(instagram_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1) expect(Conversation.count).to eq(initial_count + 1)
end end
it 'reopens last conversation if last conversation is resolved' do it 'reopens last conversation if last conversation is resolved' do
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :resolved, existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }) contact_id: contact.id, status: :resolved)
inital_count = Conversation.count initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
message = dm_params[:entry][0]['messaging'][0] described_class.new(messaging, instagram_inbox).perform
contact_inbox
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id) expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count) expect(Conversation.count).to eq(initial_count)
end
end
describe '#fetch_story_link' do
let(:story_data) do
{
'story' => {
'mention' => {
'link' => 'https://example.com/story-link',
'id' => '18094414321535710'
}
},
'from' => {
'username' => 'instagram_user',
'id' => '2450757355263608'
},
'id' => 'story-source-id-123'
}.with_indifferent_access
end
before do
# Stub the HTTP request to Instagram API
stub_request(:get, %r{https://graph\.instagram\.com/.*?fields=story,from})
.to_return(
status: 200,
body: story_data.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'saves story information when story mention is processed' do
messaging = story_mention_params[:entry][0][:messaging][0]
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
expect(message.content).to include('instagram_user')
expect(message.attachments.count).to eq(1)
expect(message.content_attributes[:story_sender]).to eq('instagram_user')
expect(message.content_attributes[:story_id]).to eq('18094414321535710')
expect(message.content_attributes[:image_type]).to eq('story_mention')
end
it 'handles deleted stories' do
# Override the stub for this test to return a 404 error
stub_request(:get, %r{https://graph\.instagram\.com/.*?fields=story,from})
.to_return(
status: 404,
body: { error: { message: 'Story not found', code: 1_609_005 } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
messaging = story_mention_params[:entry][0][:messaging][0]
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
expect(message.content).to eq('This story is no longer available.')
expect(message.attachments.count).to eq(0)
end end
end end
end end

View File

@@ -0,0 +1,378 @@
require 'rails_helper'
describe Messages::Instagram::Messenger::MessageBuilder do
subject(:instagram_message_builder) { described_class }
before do
stub_request(:post, /graph\.facebook\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg')
end
let!(:account) { create(:account) }
let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
let!(:shared_reel_params) { build(:instagram_shared_reel_event).with_indifferent_access }
let!(:instagram_story_reply_event) { build(:instagram_story_reply_event).with_indifferent_access }
let!(:instagram_message_reply_event) { build(:instagram_message_reply_event).with_indifferent_access }
let(:fb_object) { double }
let(:contact) { create(:contact, id: 'Sender-id-1', name: 'Jane Dae') }
let(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: instagram_messenger_inbox.id, source_id: 'Sender-id-1') }
let(:conversation) do
create(:conversation, account_id: account.id, inbox_id: instagram_messenger_inbox.id, contact_id: contact.id,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
end
let(:message) do
create(:message, account_id: account.id, inbox_id: instagram_messenger_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
source_id: 'message-id-1')
end
describe '#perform' do
it 'creates contact and message for the facebook inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
contact = instagram_messenger_channel.inbox.contacts.first
message = instagram_messenger_channel.inbox.messages.first
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('This is the first message from the customer')
end
it 'discard echo message already sent by chatwoot' do
message
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: true).perform
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
end
it 'creates message for shared reel' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = shared_reel_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox).perform
message = instagram_messenger_channel.inbox.messages.first
expect(message.attachments.first.file_type).to eq('ig_reel')
expect(message.attachments.first.external_url).to eq(
shared_reel_params[:entry][0]['messaging'][0]['message']['attachments'][0]['payload']['url']
)
end
it 'creates message with for reply with story id' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox).perform
message = instagram_messenger_channel.inbox.messages.first
expect(message.content).to eq('This is the story reply')
expect(message.content_attributes[:story_sender]).to eq(instagram_messenger_inbox.channel.instagram_id)
expect(message.content_attributes[:story_id]).to eq('chatwoot-app-user-id-1')
expect(message.content_attributes[:story_url]).to eq('https://chatwoot-assets.local/sample.png')
end
it 'creates message with for reply with mid' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
# create first message to ensure reply to is valid
first_message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(first_message, instagram_messenger_inbox).perform
# create the second message with the reply to mid set
messaging = instagram_message_reply_event[:entry][0]['messaging'][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox).perform
first_message = instagram_messenger_channel.inbox.messages.first
message = instagram_messenger_channel.inbox.messages.last
expect(message.content).to eq('This is message with replyto mid')
expect(message.content_attributes[:in_reply_to_external_id]).to eq(first_message.source_id)
expect(message.content_attributes[:in_reply_to]).to eq(first_message.id)
end
it 'raises exception on deleted story' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError.new(
190,
'This Message has been deleted by the user or the business.'
))
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: false).perform
instagram_messenger_inbox.reload
# we would have contact created, message created but the empty message because the story mention has been deleted later
# As they show it in instagram that this story is no longer available
# and external attachments link would be reachable
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
contact = instagram_messenger_channel.inbox.contacts.first
message = instagram_messenger_channel.inbox.messages.first
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('This story is no longer available.')
expect(message.attachments.count).to eq(0)
end
it 'does not create message for unsupported file type' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: 'Sender-id-1',
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: false).perform
instagram_messenger_inbox.reload
# we would have contact created but message and attachments won't be created
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 0
contact = instagram_messenger_channel.inbox.contacts.first
expect(contact.name).to eq('Jane Dae')
end
end
context 'when lock to single conversation is disabled' do
before do
instagram_messenger_inbox.update!(lock_to_single_conversation: false)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
contact_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'will not create a new conversation if last conversation is not resolved' do
existing_conversation = create(
:conversation,
account_id: account.id,
inbox_id: instagram_messenger_inbox.id,
contact_id: contact.id,
status: :open,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
)
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
contact_inbox.reload
expect(instagram_messenger_inbox.conversations.last.id).to eq(existing_conversation.id)
end
it 'creates a new conversation if last conversation is resolved' do
existing_conversation = create(
:conversation,
account_id: account.id,
inbox_id: instagram_messenger_inbox.id,
contact_id: contact.id,
status: :resolved,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
)
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
contact_inbox.reload
expect(instagram_messenger_inbox.conversations.last.id).not_to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count + 1)
end
end
context 'when lock to single conversation is enabled' do
before do
instagram_messenger_inbox.update!(lock_to_single_conversation: true)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
contact_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'reopens last conversation if last conversation is resolved' do
existing_conversation = create(
:conversation,
account_id: account.id,
inbox_id: instagram_messenger_inbox.id,
contact_id: contact.id,
status: :resolved,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
)
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
contact_inbox.reload
expect(instagram_messenger_inbox.conversations.last.id).to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count)
end
end
describe '#fetch_story_link' do
before do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
end
let(:story_data) do
{
'story' => {
'mention' => {
'link' => 'https://example.com/story-link',
'id' => '18094414321535710'
}
},
'from' => {
'username' => 'instagram_user',
'id' => '2450757355263608'
},
'id' => 'story-source-id-123'
}.with_indifferent_access
end
it 'saves story information when story mention is processed' do
allow(fb_object).to receive(:get_object).and_return(story_data)
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
builder = described_class.new(messaging, instagram_messenger_inbox)
builder.perform
message = instagram_messenger_inbox.messages.first
expect(message.content).to include('instagram_user')
expect(message.attachments.count).to eq(1)
expect(message.content_attributes[:story_sender]).to eq('instagram_user')
expect(message.content_attributes[:story_id]).to eq('18094414321535710')
expect(message.content_attributes[:image_type]).to eq('story_mention')
end
it 'handles story mentions specifically in the Instagram builder' do
# First allow contact info fetch
allow(fb_object).to receive(:get_object).and_return({
name: 'Jane',
id: 'Sender-id-1'
}.with_indifferent_access)
# Then allow story data fetch
allow(fb_object).to receive(:get_object).with(anything, fields: %w[story from])
.and_return(story_data)
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox).perform
message = instagram_messenger_inbox.messages.first
expect(message.content_attributes[:story_sender]).to eq('instagram_user')
expect(message.content_attributes[:image_type]).to eq('story_mention')
end
end
end

View File

@@ -34,25 +34,21 @@ RSpec.describe V2::Reports::InboxSummaryBuilder do
let(:business_hours) { false } let(:business_hours) { false }
it 'includes correct stats for each inbox' do it 'includes correct stats for each inbox' do
expect(report).to eq( expect(report).to contain_exactly({
[ id: i1.id,
{ conversations_count: 1,
id: i1.id, resolved_conversations_count: 0,
conversations_count: 1, avg_resolution_time: nil,
resolved_conversations_count: 0, avg_first_response_time: 50.0,
avg_resolution_time: nil, avg_reply_time: 35.0
avg_first_response_time: 50.0, }, {
avg_reply_time: 35.0 id: i2.id,
}, { conversations_count: 1,
id: i2.id, resolved_conversations_count: 1,
conversations_count: 1, avg_resolution_time: 100.0,
resolved_conversations_count: 1, avg_first_response_time: nil,
avg_resolution_time: 100.0, avg_reply_time: nil
avg_first_response_time: nil, })
avg_reply_time: nil
}
]
)
end end
end end
@@ -60,25 +56,21 @@ RSpec.describe V2::Reports::InboxSummaryBuilder do
let(:business_hours) { true } let(:business_hours) { true }
it 'uses business hours values for calculations' do it 'uses business hours values for calculations' do
expect(report).to eq( expect(report).to contain_exactly({
[ id: i1.id,
{ conversations_count: 1,
id: i1.id, resolved_conversations_count: 0,
conversations_count: 1, avg_resolution_time: nil,
resolved_conversations_count: 0, avg_first_response_time: 30.0,
avg_resolution_time: nil, avg_reply_time: 15.0
avg_first_response_time: 30.0, }, {
avg_reply_time: 15.0 id: i2.id,
}, { conversations_count: 1,
id: i2.id, resolved_conversations_count: 1,
conversations_count: 1, avg_resolution_time: 60.0,
resolved_conversations_count: 1, avg_first_response_time: nil,
avg_resolution_time: 60.0, avg_reply_time: nil
avg_first_response_time: nil, })
avg_reply_time: nil
}
]
)
end end
end end

View File

@@ -49,8 +49,24 @@ RSpec.describe Instagram::CallbacksController do
expect(Channel::Instagram.last.instagram_id).to eq('12345') expect(Channel::Instagram.last.instagram_id).to eq('12345')
expect(Inbox.last.name).to eq('test_user') expect(Inbox.last.name).to eq('test_user')
expect(Inbox.last.channel.reauthorization_required?).to be false
expect(response).to redirect_to(app_instagram_inbox_agents_url(account_id: account.id, inbox_id: Inbox.last.id)) expect(response).to redirect_to(app_instagram_inbox_agents_url(account_id: account.id, inbox_id: Inbox.last.id))
end end
it 'updates existing channel with new token' do
# Create an existing channel
existing_channel = create(:channel_instagram, account: account, instagram_id: '12345', access_token: 'old_token')
create(:inbox, channel: existing_channel, account: account, name: 'old_username')
expect do
get :show, params: valid_params
end.to not_change(Channel::Instagram, :count).and not_change(Inbox, :count)
existing_channel.reload
expect(existing_channel.access_token).to eq('long_lived_test_token')
expect(existing_channel.instagram_id).to eq('12345')
expect(existing_channel.reauthorization_required?).to be false
end
end end
context 'when user denies authorization' do context 'when user denies authorization' do

View File

@@ -256,7 +256,7 @@ FactoryBot.define do
}, },
'timestamp': '2021-09-08T06:34:04+0000', 'timestamp': '2021-09-08T06:34:04+0000',
'message': { 'message': {
'mid': 'message-id-1', 'mid': 'mention-message-id-1',
'attachments': [ 'attachments': [
{ {
'type': 'story_mention', 'type': 'story_mention',

View File

@@ -84,5 +84,29 @@ RSpec.describe SendReplyJob do
expect(process_service).to receive(:perform) expect(process_service).to receive(:perform)
described_class.perform_now(message.id) described_class.perform_now(message.id)
end end
it 'calls ::Instagram::Direct::SendOnInstagramService when its instagram message' do
instagram_channel = create(:channel_instagram)
message = create(:message, conversation: create(:conversation, inbox: instagram_channel.inbox))
allow(Instagram::SendOnInstagramService).to receive(:new).with(message: message).and_return(process_service)
expect(Instagram::SendOnInstagramService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Instagram::Messenger::SendOnInstagramService when its an instagram_direct_message from facebook channel' do
stub_request(:post, /graph.facebook.com/)
facebook_channel = create(:channel_facebook_page)
facebook_inbox = create(:inbox, channel: facebook_channel)
conversation = create(:conversation,
inbox: facebook_inbox,
additional_attributes: { 'type' => 'instagram_direct_message' })
message = create(:message, conversation: conversation)
allow(Instagram::Messenger::SendOnInstagramService).to receive(:new).with(message: message).and_return(process_service)
expect(Instagram::Messenger::SendOnInstagramService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
end end
end end

View File

@@ -1,11 +1,10 @@
require 'rails_helper' require 'rails_helper'
require 'webhooks/twitter'
describe Webhooks::InstagramEventsJob do describe Webhooks::InstagramEventsJob do
subject(:instagram_webhook) { described_class } subject(:instagram_webhook) { described_class }
before do before do
stub_request(:post, /graph.facebook.com/) stub_request(:post, /graph\.facebook\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg') stub_request(:get, 'https://www.example.com/test.jpeg')
.to_return(status: 200, body: '', headers: {}) .to_return(status: 200, body: '', headers: {})
end end
@@ -14,39 +13,52 @@ describe Webhooks::InstagramEventsJob do
let(:return_object) do let(:return_object) do
{ name: 'Jane', { name: 'Jane',
id: 'Sender-id-1', id: 'Sender-id-1',
account_id: instagram_inbox.account_id, account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png', profile_pic: 'https://chatwoot-assets.local/sample.png',
username: 'some_user_name' } username: 'some_user_name' }
end end
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') } let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) } let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
let!(:standby_params) { build(:instagram_message_standby_event).with_indifferent_access } # Combined message events into one helper
let!(:test_params) { build(:instagram_test_text_event).with_indifferent_access } let(:message_events) do
let!(:unsend_event) { build(:instagram_message_unsend_event).with_indifferent_access } {
let!(:attachment_params) { build(:instagram_message_attachment_event).with_indifferent_access } dm: build(:instagram_message_create_event).with_indifferent_access,
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access } standby: build(:instagram_message_standby_event).with_indifferent_access,
let!(:story_mention_echo_params) { build(:instagram_story_mention_event_with_echo).with_indifferent_access } unsend: build(:instagram_message_unsend_event).with_indifferent_access,
let!(:messaging_seen_event) { build(:messaging_seen_event).with_indifferent_access } attachment: build(:instagram_message_attachment_event).with_indifferent_access,
let!(:unsupported_message_event) { build(:instagram_message_unsupported_event).with_indifferent_access } story_mention: build(:instagram_story_mention_event).with_indifferent_access,
let(:fb_object) { double } story_mention_echo: build(:instagram_story_mention_event_with_echo).with_indifferent_access,
messaging_seen: build(:messaging_seen_event).with_indifferent_access,
unsupported: build(:instagram_message_unsupported_event).with_indifferent_access
}
end
describe '#perform' do describe '#perform' do
context 'with direct_message params' do context 'when handling messaging events for Instagram via Facebook page' do
let(:fb_object) { double }
before do
instagram_inbox.destroy
end
it 'creates incoming message in the instagram inbox' do it 'creates incoming message in the instagram inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return( allow(fb_object).to receive(:get_object).and_return(
return_object.with_indifferent_access return_object.with_indifferent_access
) )
instagram_webhook.perform_now(dm_params[:entry]) instagram_webhook.perform_now(message_events[:dm][:entry])
instagram_inbox.reload instagram_messenger_inbox.reload
expect(instagram_inbox.contacts.count).to be 1 expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name' expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1 expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1 expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be_nil expect(instagram_messenger_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
end end
it 'creates standby message in the instagram inbox' do it 'creates standby message in the instagram inbox' do
@@ -54,53 +66,40 @@ describe Webhooks::InstagramEventsJob do
allow(fb_object).to receive(:get_object).and_return( allow(fb_object).to receive(:get_object).and_return(
return_object.with_indifferent_access return_object.with_indifferent_access
) )
instagram_webhook.perform_now(standby_params[:entry]) instagram_webhook.perform_now(message_events[:standby][:entry])
instagram_inbox.reload instagram_messenger_inbox.reload
expect(instagram_inbox.contacts.count).to be 1 expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name' expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1 expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1 expect(instagram_messenger_inbox.messages.count).to be 1
message = instagram_inbox.messages.last message = instagram_messenger_inbox.messages.last
expect(message.content).to eq('This is the first standby message from the customer, after 24 hours.') expect(message.content).to eq('This is the first standby message from the customer, after 24 hours.')
end end
it 'creates test text message in the instagram inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object.with_indifferent_access
)
instagram_webhook.perform_now(test_params[:entry])
instagram_inbox.reload
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.content).to eq('random_text')
expect(instagram_inbox.messages.last.source_id).to eq('random_mid')
end
it 'handle instagram unsend message event' do it 'handle instagram unsend message event' do
message = create(:message, inbox_id: instagram_inbox.id, source_id: 'message-id-to-delete') message = create(:message, inbox_id: instagram_messenger_inbox.id, source_id: 'message-id-to-delete')
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return( allow(fb_object).to receive(:get_object).and_return(
{ {
name: 'Jane', name: 'Jane',
id: 'Sender-id-1', id: 'Sender-id-1',
account_id: instagram_inbox.account_id, account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png' profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access }.with_indifferent_access
) )
message.attachments.new(file_type: :image, external_url: 'https://www.example.com/test.jpeg') message.attachments.new(file_type: :image, external_url: 'https://www.example.com/test.jpeg')
expect(instagram_inbox.messages.count).to be 1 expect(instagram_messenger_inbox.messages.count).to be 1
instagram_webhook.perform_now(unsend_event[:entry]) instagram_webhook.perform_now(message_events[:unsend][:entry])
expect(instagram_inbox.messages.last.content).to eq 'This message was deleted' expect(instagram_messenger_inbox.messages.last.content).to eq 'This message was deleted'
expect(instagram_inbox.messages.last.deleted).to be true expect(instagram_messenger_inbox.messages.last.deleted).to be true
expect(instagram_inbox.messages.last.attachments.count).to be 0 expect(instagram_messenger_inbox.messages.last.attachments.count).to be 0
expect(instagram_inbox.messages.last.reload.deleted).to be true expect(instagram_messenger_inbox.messages.last.reload.deleted).to be true
end end
it 'creates incoming message with attachments in the instagram inbox' do it 'creates incoming message with attachments in the instagram inbox' do
@@ -108,13 +107,13 @@ describe Webhooks::InstagramEventsJob do
allow(fb_object).to receive(:get_object).and_return( allow(fb_object).to receive(:get_object).and_return(
return_object.with_indifferent_access return_object.with_indifferent_access
) )
instagram_webhook.perform_now(attachment_params[:entry]) instagram_webhook.perform_now(message_events[:attachment][:entry])
instagram_inbox.reload instagram_messenger_inbox.reload
expect(instagram_inbox.contacts.count).to be 1 expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_inbox.messages.count).to be 1 expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.attachments.count).to be 1 expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
end end
it 'creates incoming message with attachments in the instagram inbox for story mention' do it 'creates incoming message with attachments in the instagram inbox for story mention' do
@@ -134,22 +133,145 @@ describe Webhooks::InstagramEventsJob do
id: 'instagram-message-id-1234' }.with_indifferent_access id: 'instagram-message-id-1234' }.with_indifferent_access
) )
instagram_webhook.perform_now(story_mention_params[:entry]) instagram_webhook.perform_now(message_events[:story_mention][:entry])
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
attachment = instagram_messenger_inbox.messages.last.attachments.last
expect(attachment.push_event_data[:data_url]).to eq(attachment.external_url)
end
it 'does not create contact or messages when Facebook API call fails' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError)
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.contacts.count).to be 0
expect(instagram_messenger_inbox.contact_inboxes.count).to be 0
expect(instagram_messenger_inbox.messages.count).to be 0
end
it 'handle messaging_seen callback' do
expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
channel: instagram_messenger_inbox.channel).and_call_original
instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
end
it 'handles unsupported message' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object.with_indifferent_access
)
instagram_webhook.perform_now(message_events[:unsupported][:entry])
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.content_attributes['is_unsupported']).to be true
end
end
context 'when handling messaging events for Instagram via Instagram login' do
before do
instagram_channel.update(access_token: 'valid_instagram_token')
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/Sender-id-1\?.*})
.to_return(
status: 200,
body: {
name: 'Jane',
username: 'some_user_name',
profile_pic: 'https://chatwoot-assets.local/sample.png',
id: 'Sender-id-1',
follower_count: 100,
is_user_follow_business: true,
is_business_follow_user: true,
is_verified_user: false
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'creates incoming message with correct contact info in the instagram direct inbox' do
instagram_webhook.perform_now(message_events[:dm][:entry])
instagram_inbox.reload
expect(instagram_inbox.contacts.count).to eq 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to eq 1
expect(instagram_inbox.messages.count).to eq 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
end
it 'sets correct instagram attributes on contact' do
instagram_webhook.perform_now(message_events[:dm][:entry])
instagram_inbox.reload
contact = instagram_inbox.contacts.last
expect(contact.additional_attributes['social_instagram_follower_count']).to eq 100
expect(contact.additional_attributes['social_instagram_is_user_follow_business']).to be true
expect(contact.additional_attributes['social_instagram_is_business_follow_user']).to be true
expect(contact.additional_attributes['social_instagram_is_verified_user']).to be false
end
it 'handle instagram unsend message event' do
message = create(:message, inbox_id: instagram_inbox.id, source_id: 'message-id-to-delete', content: 'random_text')
# Create attachment correctly with account association
message.attachments.create!(
file_type: :image,
external_url: 'https://www.example.com/test.jpeg',
account_id: instagram_inbox.account_id
)
instagram_inbox.reload instagram_inbox.reload
expect(instagram_inbox.messages.count).to be 1 expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.attachments.count).to be 1
attachment = instagram_inbox.messages.last.attachments.last instagram_webhook.perform_now(message_events[:unsend][:entry])
expect(attachment.push_event_data[:data_url]).to eq(attachment.external_url)
expect(instagram_inbox.messages.last.content).to eq 'This message was deleted'
expect(instagram_inbox.messages.last.deleted).to be true
expect(instagram_inbox.messages.last.attachments.count).to be 0
expect(instagram_inbox.messages.last.reload.deleted).to be true
end end
it 'creates does not create contact or messages' do it 'creates incoming message with attachments in the instagram direct inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) instagram_webhook.perform_now(message_events[:attachment][:entry])
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError)
instagram_webhook.perform_now(story_mention_echo_params[:entry]) instagram_inbox.reload
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.attachments.count).to be 1
end
it 'handles unsupported message' do
instagram_webhook.perform_now(message_events[:unsupported][:entry])
instagram_inbox.reload
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be true
end
it 'does not create contact or messages when Instagram API call fails' do
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
.to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json)
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
instagram_inbox.reload instagram_inbox.reload
@@ -159,24 +281,9 @@ describe Webhooks::InstagramEventsJob do
end end
it 'handle messaging_seen callback' do it 'handle messaging_seen callback' do
expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0]).and_call_original expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
instagram_webhook.perform_now(messaging_seen_event[:entry]) channel: instagram_inbox.channel).and_call_original
end instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
it 'handles unsupported message' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object.with_indifferent_access
)
instagram_webhook.perform_now(unsupported_message_event[:entry])
instagram_inbox.reload
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be true
end end
end end
end end

View File

@@ -0,0 +1,184 @@
require 'rails_helper'
describe Instagram::Messenger::SendOnInstagramService do
subject(:send_reply_service) { described_class.new(message: message) }
before do
stub_request(:post, /graph\.facebook\.com/)
create(:message, message_type: :incoming, inbox: instagram_messenger_inbox, account: account, conversation: conversation)
end
let!(:account) { create(:account) }
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
let!(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: instagram_messenger_inbox) }
let(:conversation) { create(:conversation, contact: contact, inbox: instagram_messenger_inbox, contact_inbox: contact_inbox) }
let(:response) { double }
let(:mock_response) do
instance_double(
HTTParty::Response,
:success? => true,
:body => { message_id: 'anyrandommessageid1234567890' }.to_json,
:parsed_response => { 'message_id' => 'anyrandommessageid1234567890' }
)
end
let(:error_body) do
{
'error' => {
'message' => 'The Instagram account is restricted.',
'type' => 'OAuthException',
'code' => 400,
'fbtrace_id' => 'anyrandomfbtraceid1234567890'
}
}
end
let(:error_response) do
instance_double(
HTTParty::Response,
:success? => false,
:body => error_body.to_json,
:parsed_response => error_body
)
end
let(:response_with_error) do
instance_double(
HTTParty::Response,
:success? => true,
:body => error_body.to_json,
:parsed_response => error_body
)
end
describe '#perform' do
context 'with reply' do
before do
allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token')
allow(HTTParty).to receive(:post).and_return(mock_response)
end
context 'without message_tag HUMAN_AGENT' do
before do
InstallationConfig.where(name: 'ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT').first_or_create(value: false)
end
it 'if message is sent from chatwoot and is outgoing' do
message = create(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
response = described_class.new(message: message).perform
expect(response['message_id']).to eq('anyrandommessageid1234567890')
end
it 'if message is sent from chatwoot and is outgoing with multiple attachments' do
message = build(
:message,
content: nil,
message_type: 'outgoing',
inbox: instagram_messenger_inbox,
account: account,
conversation: conversation
)
avatar = message.attachments.new(account_id: message.account_id, file_type: :image)
avatar.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
sample = message.attachments.new(account_id: message.account_id, file_type: :image)
sample.file.attach(io: Rails.root.join('spec/assets/sample.png').open, filename: 'sample.png', content_type: 'image/png')
message.save!
service = described_class.new(message: message)
# Stub the send_message method on the service instance
allow(service).to receive(:send_message)
service.perform
# Now you can set expectations on the stubbed method for each attachment
expect(service).to have_received(:send_message).exactly(:twice)
end
it 'if message with attachment is sent from chatwoot and is outgoing' do
message = build(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
message.save!
response = described_class.new(message: message).perform
expect(response['message_id']).to eq('anyrandommessageid1234567890')
end
it 'if message sent from chatwoot is failed' do
message = create(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
allow(HTTParty).to receive(:post).and_return(response_with_error)
described_class.new(message: message).perform
expect(HTTParty).to have_received(:post)
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('400 - The Instagram account is restricted.')
end
end
context 'with message_tag HUMAN_AGENT' do
before do
InstallationConfig.where(name: 'ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT').first_or_create(value: true)
end
it 'if message is sent from chatwoot and is outgoing' do
message = create(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
allow(HTTParty).to receive(:post).with(
{
recipient: { id: contact.get_source_id(instagram_messenger_inbox.id) },
message: {
text: message.content
},
messaging_type: 'MESSAGE_TAG',
tag: 'HUMAN_AGENT'
}
).and_return(
{
'message_id': 'anyrandommessageid1234567890'
}
)
described_class.new(message: message).perform
expect(HTTParty).to have_received(:post)
end
end
end
context 'when handling errors' do
before do
allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token')
end
it 'handles HTTP errors' do
message = create(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
allow(HTTParty).to receive(:post).and_return(error_response)
described_class.new(message: message).perform
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('400 - The Instagram account is restricted.')
end
it 'handles response errors' do
message = create(:message, message_type: 'outgoing', inbox: instagram_messenger_inbox, account: account, conversation: conversation)
error_response = instance_double(
HTTParty::Response,
success?: true,
body: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }.to_json,
parsed_response: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }
)
allow(HTTParty).to receive(:post).and_return(error_response)
described_class.new(message: message).perform
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('100 - Invalid message format')
end
end
end
end

View File

@@ -30,7 +30,7 @@ describe Instagram::ReadStatusService do
mid: message.source_id mid: message.source_id
} }
} }
described_class.new(params: params).perform described_class.new(params: params, channel: instagram_channel).perform
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(conversation.id, message.created_at) expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(conversation.id, message.created_at)
end end
@@ -43,7 +43,7 @@ describe Instagram::ReadStatusService do
mid: 'random-message-id' mid: 'random-message-id'
} }
} }
described_class.new(params: params).perform described_class.new(params: params, channel: instagram_channel).perform
expect(Conversations::UpdateMessageStatusJob).not_to have_received(:perform_later) expect(Conversations::UpdateMessageStatusJob).not_to have_received(:perform_later)
end end
end end

View File

@@ -3,14 +3,10 @@ require 'rails_helper'
describe Instagram::SendOnInstagramService do describe Instagram::SendOnInstagramService do
subject(:send_reply_service) { described_class.new(message: message) } subject(:send_reply_service) { described_class.new(message: message) }
before do
stub_request(:post, /graph\.facebook\.com/)
create(:message, message_type: :incoming, inbox: instagram_inbox, account: account, conversation: conversation)
end
let!(:account) { create(:account) } let!(:account) { create(:account) }
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') } let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'instagram-message-id-123') }
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) } let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
let!(:contact) { create(:contact, account: account) } let!(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: instagram_inbox) } let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: instagram_inbox) }
let(:conversation) { create(:conversation, contact: contact, inbox: instagram_inbox, contact_inbox: contact_inbox) } let(:conversation) { create(:conversation, contact: contact, inbox: instagram_inbox, contact_inbox: contact_inbox) }
@@ -19,8 +15,8 @@ describe Instagram::SendOnInstagramService do
instance_double( instance_double(
HTTParty::Response, HTTParty::Response,
:success? => true, :success? => true,
:body => { message_id: 'anyrandommessageid1234567890' }.to_json, :body => { message_id: 'random_message_id' }.to_json,
:parsed_response => { 'message_id' => 'anyrandommessageid1234567890' } :parsed_response => { 'message_id' => 'random_message_id' }
) )
end end
@@ -56,24 +52,24 @@ describe Instagram::SendOnInstagramService do
describe '#perform' do describe '#perform' do
context 'with reply' do context 'with reply' do
before do before do
allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token')
allow(HTTParty).to receive(:post).and_return(mock_response) allow(HTTParty).to receive(:post).and_return(mock_response)
end end
context 'without message_tag HUMAN_AGENT' do context 'without message_tag HUMAN_AGENT' do
before do before do
InstallationConfig.where(name: 'ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT').first_or_create(value: false) InstallationConfig.where(name: 'ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT').first_or_create(value: false)
end end
it 'if message is sent from chatwoot and is outgoing' do it 'if message is sent from chatwoot and is outgoing' do
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation) message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
response = described_class.new(message: message).perform response = described_class.new(message: message).perform
expect(response['message_id']).to eq('anyrandommessageid1234567890') expect(response['message_id']).to eq('random_message_id')
end end
it 'if message is sent from chatwoot and is outgoing with multiple attachments' do it 'if message is sent from chatwoot and is outgoing with multiple attachments' do
message = build(:message, content: nil, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation) message = build(:message, content: nil, message_type: 'outgoing', inbox: instagram_inbox, account: account,
conversation: conversation)
avatar = message.attachments.new(account_id: message.account_id, file_type: :image) avatar = message.attachments.new(account_id: message.account_id, file_type: :image)
avatar.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') avatar.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
sample = message.attachments.new(account_id: message.account_id, file_type: :image) sample = message.attachments.new(account_id: message.account_id, file_type: :image)
@@ -82,12 +78,12 @@ describe Instagram::SendOnInstagramService do
service = described_class.new(message: message) service = described_class.new(message: message)
# Stub the send_to_facebook_page method on the service instance # Stub the send_message method on the service instance
allow(service).to receive(:send_to_facebook_page) allow(service).to receive(:send_message)
service.perform service.perform
# Now you can set expectations on the stubbed method for each attachment # Now you can set expectations on the stubbed method for each attachment
expect(service).to have_received(:send_to_facebook_page).exactly(:twice) expect(service).to have_received(:send_message).exactly(:twice)
end end
it 'if message with attachment is sent from chatwoot and is outgoing' do it 'if message with attachment is sent from chatwoot and is outgoing' do
@@ -97,7 +93,7 @@ describe Instagram::SendOnInstagramService do
message.save! message.save!
response = described_class.new(message: message).perform response = described_class.new(message: message).perform
expect(response['message_id']).to eq('anyrandommessageid1234567890') expect(response['message_id']).to eq('random_message_id')
end end
it 'if message sent from chatwoot is failed' do it 'if message sent from chatwoot is failed' do
@@ -130,7 +126,7 @@ describe Instagram::SendOnInstagramService do
} }
).and_return( ).and_return(
{ {
'message_id': 'anyrandommessageid1234567890' 'message_id': 'random_message_id'
} }
) )
@@ -138,39 +134,54 @@ describe Instagram::SendOnInstagramService do
expect(HTTParty).to have_received(:post) expect(HTTParty).to have_received(:post)
end end
end end
end
context 'when handling errors' do context 'when handling errors' do
before do it 'handles HTTP errors' do
allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token') message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
end allow(HTTParty).to receive(:post).and_return(error_response)
it 'handles HTTP errors' do described_class.new(message: message).perform
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
allow(HTTParty).to receive(:post).and_return(error_response)
described_class.new(message: message).perform expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('400 - The Instagram account is restricted.')
end
expect(message.reload.status).to eq('failed') it 'handles response errors' do
expect(message.reload.external_error).to eq('400 - The Instagram account is restricted.') message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
end
it 'handles response errors' do error_response = instance_double(
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation) HTTParty::Response,
success?: true,
body: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }.to_json,
parsed_response: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }
)
error_response = instance_double( allow(HTTParty).to receive(:post).and_return(error_response)
HTTParty::Response,
success?: true,
body: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }.to_json,
parsed_response: { 'error' => { 'message' => 'Invalid message format', 'code' => 100 } }
)
allow(HTTParty).to receive(:post).and_return(error_response) described_class.new(message: message).perform
described_class.new(message: message).perform expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('100 - Invalid message format')
end
expect(message.reload.status).to eq('failed') it 'handles reauthorization errors if access token is expired' do
expect(message.reload.external_error).to eq('100 - Invalid message format') message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
error_response = instance_double(
HTTParty::Response,
success?: false,
body: { 'error' => { 'message' => 'Access token has expired', 'code' => 190 } }.to_json,
parsed_response: { 'error' => { 'message' => 'Access token has expired', 'code' => 190 } }
)
allow(HTTParty).to receive(:post).and_return(error_response)
described_class.new(message: message).perform
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('190 - Access token has expired')
expect(instagram_channel.reload).to be_reauthorization_required
end
end end
end end
end end