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_email(contact_attributes[:email])
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
contact
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)
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`
# 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
class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder
def initialize(messaging, inbox, outgoing_echo: false)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue Koala::Facebook::AuthenticationError => 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
super(messaging, inbox, outgoing_echo: outgoing_echo)
end
private
def attachments
@messaging[:message][:attachments] || {}
def get_story_object_from_source_id(source_id)
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
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def handle_error_response(response)
parsed_response = JSON.parse(response.body)
error_code = parsed_response.dig('error', 'code')
def message_identifier
message[:mid]
end
# https://developers.facebook.com/docs/messenger-platform/error-codes
# Access token has expired or become invalid.
channel.authorization_error! if error_code == 190
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 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
# There was a problem scraping data from the provided link.
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005
if error_code == 1_609_005
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
end
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
end
def find_or_build_for_multiple_conversations
last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
def base_uri
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
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

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!
end
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
# This is a placeholder method to be overridden by child classes
def get_story_object_from_source_id(_source_id)
{}
end

View File

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

View File

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

View File

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

View File

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

View File

@@ -261,7 +261,8 @@ export default {
this.isAnEmailChannel ||
this.isASmsInbox ||
this.isATelegramChannel ||
this.isALineChannel
this.isALineChannel ||
this.isAInstagramChannel
);
},
replyButtonLabel() {
@@ -1076,7 +1077,7 @@ export default {
v-if="showSelfAssignBanner"
action-button-variant="ghost"
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')"
has-action-button
: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 {
inbox,
isAFacebookInbox,
@@ -137,5 +141,6 @@ export const useInbox = () => {
isAWhatsAppCloudChannel,
is360DialogWhatsAppChannel,
isAnEmailChannel,
isAInstagramChannel,
};
};

View File

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

View File

@@ -48,6 +48,7 @@
},
"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' ",
"ERROR_MESSAGE": "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: 'line', name: 'Line' },
// TODO: Add Instagram to the channel list after the feature is ready to use.
// { key: 'instagram', name: 'Instagram' },
];
},
...mapGetters({
@@ -62,7 +64,7 @@ export default {
<template>
<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
class="max-w-4xl"

View File

@@ -67,7 +67,7 @@ export default {
<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"
>
<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">
<h5>{{ errorStateMessage }}</h5>
<p
@@ -77,23 +77,20 @@ export default {
</div>
<div
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
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"
@click="requestAuthorization()"
>
<svg
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="i-ri-instagram-line size-5" />
<span class="text-base font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM') }}
</span>
@@ -120,9 +117,6 @@ export default {
</svg>
</span>
</button>
<p class="py-6">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
</p>
</div>
</div>
</div>

View File

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

View File

@@ -2,10 +2,6 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
queue_as :default
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
SUPPORTED_EVENTS = [:message, :read].freeze
@@ -18,18 +14,46 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
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)
entries.each do |entry|
entry = entry.with_indifferent_access
messages(entry).each do |messaging|
send(@event_name, messaging) if event_name(messaging)
end
process_single_entry(entry.with_indifferent_access)
end
end
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
@entries&.first&.dig(:id)
end
@@ -38,19 +62,116 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
@entries&.dig(0, :messaging, 0, :sender, :id)
end
def find_channel(instagram_id)
# There will be chances for the instagram account to be connected to a facebook page,
# so we need to check for both instagram and facebook page channels
# priority is for instagram channel which created via instagram login
channel = Channel::Instagram.find_by(instagram_id: instagram_id)
# If not found, fallback to the facebook page channel
channel ||= Channel::FacebookPage.find_by(instagram_id: instagram_id)
channel
end
def event_name(messaging)
@event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) }
end
def message(messaging)
::Instagram::MessageText.new(messaging).perform
def message(messaging, channel)
if channel.is_a?(Channel::Instagram)
::Instagram::MessageText.new(messaging, channel).perform
else
::Instagram::Messenger::MessageText.new(messaging, channel).perform
end
end
def read(messaging)
::Instagram::ReadStatusService.new(params: messaging).perform
def read(messaging, channel)
# Use a single service to handle read status for both channel types since the params are same
::Instagram::ReadStatusService.new(params: messaging, channel: channel).perform
end
def messages(entry)
(entry[:messaging].presence || entry[:standby] || [])
end
end
# Actual response from Instagram webhook (both via Facebook page and Instagram direct)
# [
# {
# "time": <timestamp>,
# "id": <INSTAGRAM_USER_ID>,
# "messaging": [
# {
# "sender": {
# "id": <INSTAGRAM_USER_ID>
# },
# "recipient": {
# "id": <INSTAGRAM_USER_ID>
# },
# "timestamp": <timestamp>,
# "message": {
# "mid": <MESSAGE_ID>,
# "text": <MESSAGE_TEXT>
# }
# }
# ]
# }
# ]
# Instagram's webhook via Instagram direct testing quirk: Test payloads vs Actual payloads
# When testing in Facebook's developer dashboard, you'll get a Page-style
# payload with a "changes" object. But don't be fooled! Real Instagram DMs
# arrive in the familiar Messenger format with a "messaging" array.
# This apparent inconsistency is actually by design - Instagram's webhooks
# use different formats for testing vs production to maintain compatibility
# with both Instagram Direct and Facebook Page integrations.
# See: https://developers.facebook.com/docs/instagram-platform/webhooks#event-notifications
# Test response from via Instagram direct
# [
# {
# "id": "0",
# "time": <timestamp>,
# "changes": [
# {
# "field": "messages",
# "value": {
# "sender": {
# "id": "12334"
# },
# "recipient": {
# "id": "23245"
# },
# "timestamp": "1527459824",
# "message": {
# "mid": "random_mid",
# "text": "random_text"
# }
# }
# }
# ]
# }
# ]
# Test response via Facebook page
# [
# {
# "time": <timestamp>,,
# "id": "0",
# "messaging": [
# {
# "sender": {
# "id": "12334"
# },
# "recipient": {
# "id": "23245"
# },
# "timestamp": <timestamp>,
# "message": {
# "mid": "random_mid",
# "text": "random_text"
# }
# }
# ]
# }
# ]

View File

@@ -31,6 +31,14 @@ class Channel::Instagram < ApplicationRecord
'Instagram'
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
# ref https://developers.facebook.com/docs/instagram-platform/webhooks#enable-subscriptions
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
include HTTParty
class Instagram::MessageText < Instagram::BaseMessageText
attr_reader :messaging
base_uri 'https://graph.facebook.com/v11.0/'
def initialize(messaging)
super()
@messaging = messaging
end
def perform
create_test_text
instagram_id, contact_id = instagram_and_contact_ids
inbox_channel(instagram_id)
# person can connect the channel and then delete the inbox
return if @inbox.blank?
# This channel might require reauthorization, may be owner might have changed the fb password
if @inbox.channel.reauthorization_required?
Rails.logger.info("Skipping message processing as reauthorization is required for inbox #{@inbox.id}")
return
end
return unsend_message if message_is_deleted?
ensure_contact(contact_id) if contacts_first_message?(contact_id)
create_message
end
private
def instagram_and_contact_ids
if agent_message_via_echo?
[@messaging[:sender][:id], @messaging[:recipient][:id]]
else
[@messaging[:recipient][:id], @messaging[:sender][:id]]
end
end
# rubocop:disable Metrics/AbcSize
def ensure_contact(ig_scope_id)
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(ig_scope_id) || {}
rescue Koala::Facebook::AuthenticationError => e
@inbox.channel.authorization_error!
Rails.logger.warn("Authorization error for account #{@inbox.account_id} for inbox #{@inbox.id}")
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
rescue StandardError, Koala::Facebook::ClientError => e
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
Rails.logger.warn("[FacebookUserFetchClientError]: #{e.message}")
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
end
find_or_create_contact(result) if defined?(result) && result.present?
end
# rubocop:enable Metrics/AbcSize
def agent_message_via_echo?
@messaging[:message][:is_echo].present?
result = fetch_instagram_user(ig_scope_id)
find_or_create_contact(result) if result.present?
end
def message_is_deleted?
@messaging[:message][:is_deleted].present?
def fetch_instagram_user(ig_scope_id)
fields = 'name,username,profile_pic,follower_count,is_user_follow_business,is_business_follow_user,is_verified_user'
url = "#{base_uri}/#{ig_scope_id}?fields=#{fields}&access_token=#{@inbox.channel.access_token}"
response = HTTParty.get(url)
return process_successful_response(response) if response.success?
handle_error_response(response)
{}
end
# if contact was present before find out contact_inbox to create message
def contacts_first_message?(ig_scope_id)
@contact_inbox = @inbox.contact_inboxes.where(source_id: ig_scope_id).last
@contact_inbox.blank? && @inbox.channel.instagram_id.present?
def process_successful_response(response)
result = JSON.parse(response.body).with_indifferent_access
{
'name' => result['name'],
'username' => result['username'],
'profile_pic' => result['profile_pic'],
'id' => result['id'],
'follower_count' => result['follower_count'],
'is_user_follow_business' => result['is_user_follow_business'],
'is_business_follow_user' => result['is_business_follow_user'],
'is_verified_user' => result['is_verified_user']
}.with_indifferent_access
end
def sent_via_test_webhook?
@messaging[:sender][:id] == '12334' && @messaging[:recipient][:id] == '23245'
def handle_error_response(response)
parsed_response = response.parsed_response
error_message = parsed_response.dig('error', 'message')
error_code = parsed_response.dig('error', 'code')
# https://developers.facebook.com/docs/messenger-platform/error-codes
# Access token has expired or become invalid.
channel.authorization_error! if error_code == 190
Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}")
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
end
def unsend_message
message_to_delete = @inbox.messages.find_by(
source_id: @messaging[:message][:mid]
)
return if message_to_delete.blank?
message_to_delete.attachments.destroy_all
message_to_delete.update!(content: I18n.t('conversations.messages.deleted'), deleted: true)
def base_uri
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
end
def create_message
@@ -92,65 +56,4 @@ class Instagram::MessageText < Instagram::WebhooksBaseService
Messages::Instagram::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
end
def create_test_text
return unless sent_via_test_webhook?
Rails.logger.info('Probably Test data.')
messenger_channel = Channel::FacebookPage.last
@inbox = ::Inbox.find_by(channel: messenger_channel)
return unless @inbox
@contact = create_test_contact
@conversation ||= create_test_conversation(conversation_params)
@message = @conversation.messages.create!(test_message_params)
end
def create_test_contact
@contact_inbox = @inbox.contact_inboxes.where(source_id: @messaging[:sender][:id]).first
unless @contact_inbox
@contact_inbox ||= @inbox.channel.create_contact_inbox(
'sender_username', 'sender_username'
)
end
@contact_inbox.contact
end
def create_test_conversation(conversation_params)
Conversation.find_by(conversation_params) || build_conversation(conversation_params)
end
def test_message_params
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: 'incoming',
source_id: @messaging[:message][:mid],
content: @messaging[:message][:text],
sender: @contact
}
end
def build_conversation(conversation_params)
Conversation.create!(
conversation_params.merge(
contact_inbox_id: @contact_inbox.id
)
)
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: @contact.id,
additional_attributes: {
type: 'instagram_direct_message'
}
}
end
end

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

View File

@@ -37,7 +37,7 @@ class Instagram::RefreshOauthTokenService
token_is_valid = Time.current < channel.expires_at
# 2. Token is at least 24 hours old (based on updated_at)
token_is_old_enough = channel.updated_at.present? && channel.updated_at < 24.hours.ago
token_is_old_enough = channel.updated_at.present? && Time.current - channel.updated_at >= 24.hours
# 3. Token is approaching expiry (within 10 days)
approaching_expiry = channel.expires_at < 10.days.from_now

View File

@@ -1,130 +1,30 @@
class Instagram::SendOnInstagramService < Base::SendOnChannelService
include HTTParty
pattr_initialize [:message!]
base_uri 'https://graph.facebook.com/v11.0/me'
class Instagram::SendOnInstagramService < Instagram::BaseSendService
private
delegate :additional_attributes, to: :contact
def channel_class
Channel::FacebookPage
end
def perform_reply
if message.attachments.present?
message.attachments.each do |attachment|
send_to_facebook_page attachment_message_params(attachment)
end
end
send_to_facebook_page message_params if message.content.present?
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account, user: message.sender).capture_exception
# TODO : handle specific errors or else page will get disconnected
# channel.authorization_error!
end
def message_params
params = {
recipient: { id: contact.get_source_id(inbox.id) },
message: {
text: message.content
}
}
merge_human_agent_tag(params)
end
def attachment_message_params(attachment)
params = {
recipient: { id: contact.get_source_id(inbox.id) },
message: {
attachment: {
type: attachment_type(attachment),
payload: {
url: attachment.download_url
}
}
}
}
merge_human_agent_tag(params)
Channel::Instagram
end
# Deliver a message with the given payload.
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message
def send_to_facebook_page(message_content)
access_token = channel.page_access_token
app_secret_proof = calculate_app_secret_proof(GlobalConfigService.load('FB_APP_SECRET', ''), access_token)
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api
def send_message(message_content)
access_token = channel.access_token
query = { access_token: access_token }
query[:appsecret_proof] = app_secret_proof if app_secret_proof
# url = "https://graph.facebook.com/v11.0/me/messages?access_token=#{access_token}"
instagram_id = channel.instagram_id.presence || 'me'
response = HTTParty.post(
'https://graph.facebook.com/v11.0/me/messages',
"https://graph.instagram.com/v22.0/#{instagram_id}/messages",
body: message_content,
query: query
)
handle_response(response, message_content)
end
def handle_response(response, message_content)
parsed_response = response.parsed_response
if response.success? && parsed_response['error'].blank?
message.update!(source_id: parsed_response['message_id'])
parsed_response
else
external_error = external_error(parsed_response)
Rails.logger.error("Instagram response: #{external_error} : #{message_content}")
message.update!(status: :failed, external_error: external_error)
nil
end
end
def external_error(response)
# https://developers.facebook.com/docs/instagram-api/reference/error-codes/
error_message = response.dig('error', 'message')
error_code = response.dig('error', 'code')
"#{error_code} - #{error_message}"
end
def calculate_app_secret_proof(app_secret, access_token)
Facebook::Messenger::Configuration::AppSecretProofCalculator.call(
app_secret, access_token
)
end
def attachment_type(attachment)
return attachment.file_type if %w[image audio video file].include? attachment.file_type
'file'
end
def conversation_type
conversation.additional_attributes['type']
end
def sent_first_outgoing_message_after_24_hours?
# we can send max 1 message after 24 hour window
conversation.messages.outgoing.where('id > ?', conversation.last_incoming_message.id).count == 1
end
def config
Facebook::Messenger.config
process_response(response, message_content)
end
def merge_human_agent_tag(params)
global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
global_config = GlobalConfig.get('ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT')
return params unless global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT']
return params unless global_config['ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT']
params[:messaging_type] = 'MESSAGE_TAG'
params[:tag] = 'HUMAN_AGENT'

View File

@@ -1,9 +1,14 @@
class Instagram::WebhooksBaseService
attr_reader :channel
def initialize(channel)
@channel = channel
end
private
def inbox_channel(instagram_id)
messenger_channel = Channel::FacebookPage.where(instagram_id: instagram_id)
@inbox = ::Inbox.find_by(channel: messenger_channel)
def inbox_channel(_instagram_id)
@inbox = ::Inbox.find_by(channel: @channel)
end
def find_or_create_contact(user)
@@ -24,9 +29,31 @@ class Instagram::WebhooksBaseService
def update_instagram_profile_link(user)
return unless user['username']
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_profiles': { 'instagram': user['username'] } })
@contact.additional_attributes = @contact.additional_attributes.merge({ 'social_instagram_user_name': user['username'] })
@contact.save
instagram_attributes = build_instagram_attributes(user)
@contact.update!(additional_attributes: @contact.additional_attributes.merge(instagram_attributes))
end
def build_instagram_attributes(user)
attributes = {
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username
'social_profiles': { 'instagram': user['username'] },
'social_instagram_user_name': user['username']
}
# Add optional attributes if present
optional_fields = %w[
follower_count
is_user_follow_business
is_business_follow_user
is_verified_user
]
optional_fields.each do |field|
next if user[field].nil?
attributes["social_instagram_#{field}"] = user[field]
end
attributes
end
end

View File

@@ -85,15 +85,15 @@ linear:
enabled: true
icon: 'icon-linear'
config_key: 'linear'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify'
enabled: true
icon: 'icon-shopify'
config_key: 'shopify'
instagram:
name: 'Instagram'
description: 'Configuration for setting up Instagram'
enabled: true
icon: 'icon-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)
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

View File

@@ -1,27 +1,26 @@
require 'rails_helper'
describe Messages::Instagram::MessageBuilder do
subject(:instagram_message_builder) { described_class }
subject(:instagram_direct_message_builder) { described_class }
before do
stub_request(:post, /graph.facebook.com/)
stub_request(:post, /graph\.instagram\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg')
.to_return(status: 200, body: '', headers: {})
end
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!(: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_inbox.id, source_id: 'Sender-id-1') }
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(:conversation) do
create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id)
end
let(:message) do
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
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_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
before do
instagram_channel.update(access_token: 'valid_instagram_token')
stub_request(:get, %r{https://graph\.instagram\.com/.*?/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 contact and message for the instagram direct inbox' do
messaging = dm_params[:entry][0]['messaging'][0]
contact_inbox
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.messages.count).to be 1
contact = instagram_channel.inbox.contacts.first
message = instagram_channel.inbox.messages.first
expect(contact.name).to eq('Jane Dae')
message = instagram_inbox.messages.first
expect(message.content).to eq('This is the first message from the customer')
end
it 'discard echo message already sent by chatwoot' do
conversation
message
expect(instagram_inbox.conversations.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]
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
instagram_inbox.reload
@@ -81,220 +80,244 @@ describe Messages::Instagram::MessageBuilder do
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_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_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.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_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
it 'creates message with story id' do
story_source_id = instagram_story_reply_event[:entry][0]['messaging'][0]['message']['mid']
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
message = instagram_channel.inbox.messages.first
message = instagram_inbox.messages.first
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_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_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
it 'creates message with reply to mid' do
# Create first message to ensure reply to is valid
first_messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(first_messaging, 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]
contact_inbox
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')
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)
first_message = instagram_inbox.messages.first
reply_message = instagram_inbox.messages.last
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
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.'
))
it 'handles deleted story' do
story_source_id = story_mention_params[:entry][0][:messaging][0]['message']['mid']
stub_request(:get, %r{https://graph\.instagram\.com/.*?/#{story_source_id}\?.*})
.to_return(status: 404, body: { error: { message: 'Story not found', code: 1_609_005 } }.to_json)
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).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.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_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_inbox, outgoing_echo: false).perform
instagram_inbox.reload
# we would have contact created but message and attachments won't be created
expect(instagram_inbox.conversations.count).to be 0
expect(instagram_inbox.conversations.count).to be 1
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
context 'when lock to single conversation is disabled' do
before do
instagram_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
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
described_class.new(messaging, instagram_inbox).perform
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
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,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
contact_id: contact.id, status: :open)
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(messaging, instagram_inbox).perform
expect(instagram_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_inbox.id, contact_id: contact.id, status: :resolved,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
contact_id: contact.id, status: :resolved)
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
described_class.new(messaging, instagram_inbox).perform
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
context 'when lock to single conversation is enabled' do
before do
instagram_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
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
described_class.new(messaging, instagram_inbox).perform
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
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,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
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]
contact_inbox
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
described_class.new(messaging, instagram_inbox).perform
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

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

View File

@@ -49,8 +49,24 @@ RSpec.describe Instagram::CallbacksController do
expect(Channel::Instagram.last.instagram_id).to eq('12345')
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))
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
context 'when user denies authorization' do

View File

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

View File

@@ -84,5 +84,29 @@ RSpec.describe SendReplyJob do
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
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

View File

@@ -1,11 +1,10 @@
require 'rails_helper'
require 'webhooks/twitter'
describe Webhooks::InstagramEventsJob do
subject(:instagram_webhook) { described_class }
before do
stub_request(:post, /graph.facebook.com/)
stub_request(:post, /graph\.facebook\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg')
.to_return(status: 200, body: '', headers: {})
end
@@ -14,39 +13,52 @@ describe Webhooks::InstagramEventsJob do
let(:return_object) do
{ name: 'Jane',
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',
username: 'some_user_name' }
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!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
let!(:standby_params) { build(:instagram_message_standby_event).with_indifferent_access }
let!(:test_params) { build(:instagram_test_text_event).with_indifferent_access }
let!(:unsend_event) { build(:instagram_message_unsend_event).with_indifferent_access }
let!(:attachment_params) { build(:instagram_message_attachment_event).with_indifferent_access }
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
let!(:story_mention_echo_params) { build(:instagram_story_mention_event_with_echo).with_indifferent_access }
let!(:messaging_seen_event) { build(:messaging_seen_event).with_indifferent_access }
let!(:unsupported_message_event) { build(:instagram_message_unsupported_event).with_indifferent_access }
let(:fb_object) { double }
# Combined message events into one helper
let(:message_events) do
{
dm: build(:instagram_message_create_event).with_indifferent_access,
standby: build(:instagram_message_standby_event).with_indifferent_access,
unsend: build(:instagram_message_unsend_event).with_indifferent_access,
attachment: build(:instagram_message_attachment_event).with_indifferent_access,
story_mention: build(:instagram_story_mention_event).with_indifferent_access,
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
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
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(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_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_nil
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_nil
end
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(
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_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_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
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.')
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
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(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
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'
}.with_indifferent_access
)
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_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
expect(instagram_messenger_inbox.messages.last.content).to eq 'This message was deleted'
expect(instagram_messenger_inbox.messages.last.deleted).to be true
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 0
expect(instagram_messenger_inbox.messages.last.reload.deleted).to be true
end
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(
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_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.attachments.count).to be 1
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
end
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
)
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
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
expect(attachment.push_event_data[:data_url]).to eq(attachment.external_url)
instagram_webhook.perform_now(message_events[:unsend][:entry])
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
it 'creates does not create contact or messages' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError)
it 'creates incoming message with attachments in the instagram direct inbox' do
instagram_webhook.perform_now(message_events[:attachment][:entry])
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
@@ -159,24 +281,9 @@ describe Webhooks::InstagramEventsJob do
end
it 'handle messaging_seen callback' do
expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0]).and_call_original
instagram_webhook.perform_now(messaging_seen_event[: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(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
expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
channel: instagram_inbox.channel).and_call_original
instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
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
}
}
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)
end
@@ -43,7 +43,7 @@ describe Instagram::ReadStatusService do
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)
end
end

View File

@@ -3,14 +3,10 @@ require 'rails_helper'
describe Instagram::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_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_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!(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: instagram_inbox) }
let(:conversation) { create(:conversation, contact: contact, inbox: instagram_inbox, contact_inbox: contact_inbox) }
@@ -19,8 +15,8 @@ describe Instagram::SendOnInstagramService do
instance_double(
HTTParty::Response,
:success? => true,
:body => { message_id: 'anyrandommessageid1234567890' }.to_json,
:parsed_response => { 'message_id' => 'anyrandommessageid1234567890' }
:body => { message_id: 'random_message_id' }.to_json,
:parsed_response => { 'message_id' => 'random_message_id' }
)
end
@@ -56,24 +52,24 @@ describe Instagram::SendOnInstagramService do
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)
InstallationConfig.where(name: 'ENABLE_INSTAGRAM_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_inbox, account: account, conversation: conversation)
response = described_class.new(message: message).perform
expect(response['message_id']).to eq('anyrandommessageid1234567890')
expect(response['message_id']).to eq('random_message_id')
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_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.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)
@@ -82,12 +78,12 @@ describe Instagram::SendOnInstagramService do
service = described_class.new(message: message)
# Stub the send_to_facebook_page method on the service instance
allow(service).to receive(:send_to_facebook_page)
# 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_to_facebook_page).exactly(:twice)
expect(service).to have_received(:send_message).exactly(:twice)
end
it 'if message with attachment is sent from chatwoot and is outgoing' do
@@ -97,7 +93,7 @@ describe Instagram::SendOnInstagramService do
message.save!
response = described_class.new(message: message).perform
expect(response['message_id']).to eq('anyrandommessageid1234567890')
expect(response['message_id']).to eq('random_message_id')
end
it 'if message sent from chatwoot is failed' do
@@ -130,7 +126,7 @@ describe Instagram::SendOnInstagramService do
}
).and_return(
{
'message_id': 'anyrandommessageid1234567890'
'message_id': 'random_message_id'
}
)
@@ -138,39 +134,54 @@ describe Instagram::SendOnInstagramService do
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
context 'when handling errors' do
it 'handles HTTP errors' do
message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation)
allow(HTTParty).to receive(:post).and_return(error_response)
it 'handles HTTP errors' do
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
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')
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_inbox, account: account, conversation: conversation)
it 'handles response errors' do
message = create(:message, message_type: 'outgoing', inbox: instagram_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 } }
)
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)
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')
expect(message.reload.external_error).to eq('100 - Invalid message format')
it 'handles reauthorization errors if access token is expired' do
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